Swap the single hero image for the shared ImageViewer (thumbnails,
lightbox and cycle controls when a room has more than one photo, as the
marketplace product page does) and add a 'Things to know' card: check-in
from / check-out by, guest cap, minimum stay and the cancellation policy.
The rules come from the room owner's operator settings (chatelet ≥ v0.5.0)
so the card only renders when the extension sends them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the two date pickers and the manual 'Check availability' button
with one range calendar fed by the extension's keyless unavailable-ranges
endpoint (chatelet ≥ v0.5.0): occupied nights are struck through, a stay
can never straddle one, and check-out may land on the morning another
guest arrives (half-open nights). Two months on sm+, one on phones.
reka-ui only guards clicks with isDateDisabled and hides selected
styling on isDateUnavailable days, so the matchers are selection-aware:
once a check-in is chosen the first occupied night after it stops being
unavailable (it is the last legal check-out) and every later day is
disabled; min_nights is enforced the same way. Both matchers close over
refs because reka reads them once at setup.
Picking a complete stay quotes it automatically; the quote card shows
rate × nights and the total. A quote that contradicts the local calendar
refreshes it (someone else's hold appeared), as does a confirmed booking.
The /login?redirect round-trip of dates is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Port of ui/calendar onto reka-ui's RangeCalendar* parts, same slot shape.
The cell trigger gains range states — accent tint for the nights inside
the range, primary pills on the two ends — and a real unavailable look
(struck through, muted, not a pointer target).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Switch the invoice screen's poll from LNbits core's payment status to
chatelet's new keyless GET /api/v1/public/bookings/{id} (chatelet#18).
The dialog now waits for the booking to reach confirmed — meaning the
extension's paid listener has actually run — and shows a 'Dates
released' screen when the hold expires or is declined before payment
instead of spinning forever.
Needs chatelet >= the build that ships the endpoint on the target
instance (staging must be upgraded before this reaches dev).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Slice 2 of the guest UI. The Book button on the room page opens a
dialog that collects guest count, optional contact and a note, asks
the extension to hold the dates, then shows the bolt11 (QR, copy,
open-in-wallet, pay from the LNbits wallet) and polls settlement
until it is paid. useBookingFlow mirrors events' useTicketPurchase.
Booking needs a Nostr identity (the extension DMs check-in details to
the guest pubkey), so anonymous guests bounce through
/login?redirect= and land back on the room with their dates in the
query. Guest checkout without an account is a later slice.
Settlement is polled on LNbits core's anonymous payment status until
the extension grows a public booking-status endpoint; the paid-invoice
listener on the extension is what confirms the booking server-side.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mirror the extension's BookingRequestData / Booking / BookingQuote
models and add the two calls slice 2 needs: POST /bookings (hold +
invoice) and an anonymous settlement poll on LNbits core's
GET /api/v1/payments/{hash}. Chatelet's own booking read needs a
wallet key, so the invoice is what the guest can watch until a public
booking-status endpoint lands on the extension.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both login pages always landed on '/'. A page that needs an identity
(chatelet's Book button) can now bounce an anonymous user through
/login?redirect=<path> and get them back. Only same-origin absolute
paths are honoured; anything else falls back to home.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Side by side at phone width the DatePicker's long-form date truncates
to "September 20t…"; one column below the sm breakpoint keeps the
full date readable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replace the two native <input type="date"> fields on the room detail
page with the base DatePicker (Popover + shadcn Calendar), the same
component the events editor uses. Check-in is floored at today and
check-out at the morning after check-in; moving check-in past the
current check-out clears it, and any change resets the last quote.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Slice-1 part B of the chatelet guest booking UI (aiolabs/webapp #141).
- ChateletApiService: plain HTTP wrapper (events TicketApiService pattern) →
chatelet's public guest endpoints (/public/rooms, /availability). Unauth by
default; swaps HTTP→kind-21000 RPC once aiolabs/lnbits#64 lands, no view change.
- useChatelet composable + pinia store (DI via tryInjectService, never direct import).
- Views: ChateletPage (room grid) + ChateletDetailPage (details + date-range
availability check with sats/fiat quote; Book button stubbed for slice 2).
- SERVICE_TOKENS.CHATELET_API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
Maps applied_promo_code from the stats endpoint onto the roster rows as a
small outline badge and includes it in the fuzzy search keys.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
usePromoCode drives an Apply flow against the validate endpoint (states
idle / checking / applied / invalid / unverified; quantity changes
re-validate with a debounce; stale responses are ignored; 404 from an
older extension keeps the code and sends it unverified). The dialog shows
the struck-through original and the discounted total in the header, price
block, conversion preview and invoice amount; the code rides on both the
Lightning and card requests. A 100 %-off code on the card rail lands on the
success screen via finalizePurchasedTickets instead of the "no checkout URL"
error, and a guest whose total becomes zero gets an inline log-in prompt
(free claims stay login-gated, events#29) rather than a swapped form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
Collapsible "Promo codes" section: rows of code (upper-cased as typed),
discount %, max uses (blank = unlimited), active switch, remove; per-row
errors from validatePromoRows gate submit alongside the vee-validate form.
extra.promo_codes is always sent explicitly (empty editor = clear); edit
mode prefills from the authenticated record.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
The authenticated fetch returns the full Event (incl. extra.promo_codes,
which public responses omit as of events ext v1.6.1-aio.12) and is what
the edit dialog must prefill from. Public records still win for events the
user does not own.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
New events.create.promo.* (first key under a create namespace; the rest
of the create dialog remains hardcoded English) and events.purchase.promo.*
in en/es/fr + the types interface.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
Types follow upstream v2 names (PromoCode.max_uses / used_count,
PromoValidateRequest, BasketDiscount, BasketTotals) as served by the
events extension >= v1.6.1-aio.12; EventExtra.promo_codes becomes
optional because public event responses no longer carry it.
TicketApiService.validatePromoCode posts the upper-cased code + quantity
to POST /events/api/v1/promo/validate/{event_id}; request() now attaches
the HTTP status to thrown errors so a 404 from an older extension can be
told apart from a 400. lib/promoCodes.ts holds the pure helpers shared by
the organizer editor and the buyer dialog (normalise, editor validation
returning i18n keys, wire <-> row mapping, display-only percent rounding).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
On phone widths the event list is a one-column grid, and a grid item
defaults to min-width:auto (= min-content). The card's nowrap truncate
rows (location, organizer) push that min-content past the viewport for
any event with a long address, so the card spilled off screen and the
text was clipped by the window instead of ellipsised. min-w-0 on the
Card root lets the item shrink; overflow-hidden would have clipped the
pending/rejected badge that deliberately hangs past the bottom edge.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVxfxB2sDFrdwnXrjhEp1b
ticketQr.ts imported @brand/logo.png by literal filename, so any brand
kit that ships its logo as SVG only (cfaun's does) failed the events
standalone build with ENOENT. Every other logo consumer goes through
the @brand-app-logo alias, which resolves the per-app or brand logo in
SVG-then-PNG order; use the same alias here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nx87UjQtd1HS5u4DwCMve8
Two causes of the visible gap under the bar on phone and desktop: the
bar lived inside the page's `space-y-6` column, whose child margin also
offsets a fixed box (24px), and its bottom offset was hand-derived. It
now teleports to <body> and uses `--bottom-nav-height`. Measured in a
headless run: bar bottom == nav top on 390px and 1280px viewports.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
BottomNav measures itself (ResizeObserver) and writes
`--bottom-nav-height` on <html>; index.css declares the 0px default. Any
element that must sit flush above the nav pins with
`bottom: var(--bottom-nav-height)` instead of re-deriving
`3.5rem + env(safe-area-inset-bottom)` and drifting when the device's
inset, border or font scaling differs from that guess. The restaurant
item page's add-to-cart bar is migrated to it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
The ticket block (owned banner, Buy ticket + availability, past/sold-out
notices, and the host's Scan CTA) moved from below the description to
directly under the date and location. A long description was pushing
the primary action below the fold — on the Château du Faune event the
button sat under ~1.7k characters of text.
A sticky bar (title, price, tickets left, the same CTA) pins above the
bottom nav whenever the inline block is scrolled out of view
(useElementVisibility), and hides again when it is back, or while the
purchase / return dialogs are open. Container gets extra bottom padding
while the bar is up so the organizer card is never covered.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
A tester's ticket landed in spam. A mail-tester run of the demo mailer
scored 8.3/10 with authentication fully green, so the sender setup is
not at fault and the mailer is being hardened separately (aiolabs/events
fix/ticket-email-headers). Until sender reputation builds, tell buyers
where to look: a red hint under the email field.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
`selectedMethodId` started as 'lightning', so the "keep the current
selection" guard in the default-rail watcher always found a match and the
guest preference for fiat never applied — verified on demo, where an
anonymous buyer saw "Proceed" (Lightning) preselected with Card on offer.
Start empty and reset to empty on close so each open re-evaluates:
guests land on Card when it exists, everyone else on the first enabled
rail, exactly as the comment already promised.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Replace the "Also accept fiat" switch with a Lightning / Card checkbox
group so an organizer can switch Lightning off for a card-only sale.
Sends extra.payment_methods (upstream v2 shape) and keeps allow_fiat in
sync (fiat in list ⇒ allow_fiat=true) for older readers. Card stays
disabled with a tooltip when the organizer has no fiat provider; the
fiat-currency picker appears when Card is on and the price is in sats.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Target of the emailed ticket link the extension now builds under the
webapp root. Anonymous, like the LNbits UI's own /events/ticket/{id}:
reads the PublicTicket, resolves the event name (Nostr store → scoped
relay query → public REST as a last resort), and renders the logo QR
with paid / pending / checked-in state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Stripe now brings the buyer back to the event page with
?checkout=success&tickets=<ids> or ?checkout=cancelled. The page strips
the query, then opens CheckoutReturnDialog, which polls the anonymous
ticket endpoint until every row is paid (60 s budget with a retry), and
renders one logo QR per ticket with a link to its public ticket page and
the "emailed to" note from the pending-checkout record. Logged-in buyers
also get their owned tickets refreshed and a My Tickets shortcut.
The login toast on "Buy ticket" now only fires for free events; paid
events open the dialog for guests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Buyers no longer need an account to buy a paid ticket. The dialog gains a
"Your details" block (name for guests, email + confirm-email for everyone,
prefilled from the account email) as a vee-validate/zod form, and builds
its rail buttons from the event's published payment methods so an
organizer can run card-only events.
- Fiat: same-tab redirect to the provider right after the POST, with the
pending checkout (ticket ids, email) remembered in localStorage for the
return page; the two-step "open in new tab" panel is gone.
- Lightning: guests get the bolt11 QR + "Open in wallet" and settle via
the existing poll; the LNbits-wallet button stays login-only.
- Free tickets remain login-gated (events#29).
- Guest success state renders the ticket QRs inline with the "emailed
to" note, since guests have no My Tickets.
i18n keys added under events.purchase / checkout / ticket (en, es, fr).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Prepare the webapp for account-less ticket purchases against events ext
v1.6.1-aio.8:
- requestTicket takes an options object: user_id (+ optional email) for
logged-in buyers, name + email for guests, Authorization only when a
token exists, and frontend_url so the backend builds the Stripe return
URLs and the emailed ticket link under this app's root.
- getTicket (anonymous PublicTicket) and getPublicEvent for the return
dialog and the public ticket page.
- resendTicketEmail maps the structured TicketResendResult (legacy bare
ticket tolerated).
- NIP-52 tickets_payment_methods tag parsed into ticketInfo.paymentMethods;
effectivePaymentMethods() mirrors the backend rule.
- renderTicketQr: level-H ticket:// QR with the brand logo centred, shared
by every ticket surface; useTicketPurchase accepts a guest contact.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
The fiat path pulled `currentUser` off the LNbits API client, which
never exposed it — only `getAccessToken()`. `userId` was therefore
always undefined and every Stripe/fiat "Continue" click failed with
"Missing user id" before the request was ever sent.
Use `useAuth()`'s `currentUser`, the same source the Lightning path
already goes through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLt6zWfXA1hM8zYQdpbeyn
Add a compact inline amber warning (icon + text) next to the
"Select the account…" instruction on both the expense and income
account-selection steps, so users know to fall back to the "Other"
account when unsure.
Splits the income i18n into selectAccount + a new otherAccountHint
key (en/fr/es + types).
Remove the "Amount in selected currency" and "Currency for this
income/expense" helper lines on both forms, drop the Reference helper
line, and mark the field as "Reference (optional)" in the label
instead. Also drops the now-unused FormDescription import from the
income form.
Fold the description guidance into the placeholder and drop the
separate FormDescription helper line, so the income Description field
mirrors the expense form's structure.
Append the same "Other" account guidance to the income form's Step 1
(libra.income.selectAccount) that the expense form already shows, for
consistency. Updated across en / fr / es locales.
Wrap the Amount and Currency fields in a two-column grid on both the
expense (AddExpense.vue) and income (AddIncome.vue) forms, and add
w-full to the currency SelectTrigger so the dropdown fills its column.
Tightens the vertical layout of the entry dialogs.
Step 1 (account selection) now tells the user to pick the "Other"
account if they're not sure. The Description field's placeholder is
rewritten to prompt for a detailed description of the purchase /
invoice / bill and what it was used for (event/project/etc), replacing
the redundant helper line.
Client feedback: with no fixed category taxonomy yet, steering unsure
users to the Other account plus a richer free-text description lets us
reconcile/recategorize later.
Refs aiolabs/webapp#137
Companion to aiolabs/events#31. Free events (price 0 / 100%-off promo)
now come back from POST /tickets/{event_id} as paid=true with the row
ids inline and no payment_request — the backend issued them
already-paid, no invoice to settle.
Previously the composable's `!paymentRequest` guard treated any
invoice-less response as fiat and threw "This event uses fiat
checkout", so free tickets were unbuyable.
- TicketPurchaseInvoice gains `paid` + `ticketIds`; TicketApiService
maps them.
- purchaseTicketForEvent short-circuits on `invoice.paid`: skip the QR /
payment-poll and go straight to the ticket-QR success state. The fiat
error now only fires for an actual fiat (not-paid) response.
- The ticket-QR rendering (refresh owned tickets, one QR per row, toast)
is extracted into a shared finalizePurchasedTickets() used by both the
Lightning-poll path and the free path.
- PurchaseTicketDialog: for free events drop the payment-method selector
and price line, show "Free", and label the CTA "Get ticket".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hub rendered all 8 standalones statically and greyed out any whose
VITE_HUB_<APP>_URL was unset — so an events-only deploy showed 7 dead
"coming soon" tiles. The greyed path was overloaded: hubLink() returned
null for both "not deployed" and "logged out", which the template then
couldn't tell apart.
Replace that with a registry → resolver → view-model pipeline:
- availabilityOf(m) resolves an explicit state from deploy config + auth:
available | auth-locked | inactive | unavailable.
- A `tiles` computed maps the catalog to view-models and drops
'unavailable' (not provisioned) entirely — no more ghost tiles.
- 'auth-locked' keeps today's greyed + login-prompt behavior for
deployed-but-logged-out apps (wallet, chat, tasks, libra).
- 'inactive' (active:false) is wired as a greyed, inert state — an unused
seam for a future install/disable model; no app sets it yet.
The view treats the catalog as an opaque list, so the source can later
move from this in-code array to a runtime feed (LNbits /hub/apps or a
NIP-78 event) without touching the render path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverses the events-only hide (c037d45) now that the link has a real
home. Three parts:
- Add a @brand-hub-logo alias (brandHubLogoAliasEntry) resolving to the
brand's primary/global logo — the HUB's logo, never the per-standalone
@brand-app-logo. Wired into all 9 app vite configs since the shared
ProfileSheetContent renders it.
- Restructure the profile sheet into a fixed-height flex column: a
flex-1 min-h-0 overflow-y-auto scroll region over a shrink-0 footer,
so "Back to hub" + the log-in/out bar stay pinned to the bottom while
the identity/preferences area scrolls.
- Move the edit-profile Dialog out of the flex root (it portals to body,
so it's not part of the sheet flow).
Logo bumped to w-8 h-8, centered, with tightened footer padding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Coordinate-keying the events store (kind:pubkey:d-tag) regressed the
own-events merge: a creator's own event showed up twice in the feed.
`loadOwnEvents` surfaces the caller's own LNbits events via REST
(`ticketedEventToEvent`) so drafts appear before their NIP-52 event is
on a relay. That adapter stamps an empty `organizer.pubkey`, `isMine`,
and no ticket info. The relay-published copy lands under the real
publisher pubkey (`resolve_for_wallet` — NOT the user's Nostr login
key) with full ticket counts. Pre-coordinate-keying both collapsed on
the bare d-tag so the relay copy replaced the draft; under
`kind:pubkey:d-tag` the empty-pubkey draft and the real-pubkey copy are
distinct keys, so both render — the empty "..."/no-tickets card next to
the real one. Only the logged-in owner sees it, since only own events
get the REST merge.
upsertEvent now reconciles by d-tag: the published copy supersedes the
provisional draft and inherits its `isMine`, so the creator keeps the
Yours badge + Hosting filter even though the publisher key differs from
their login key. Handles both arrival orderings; a draft with no
published copy yet (pending review) still shows alone; genuinely
distinct authors sharing a d-tag are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adding vitest (#124) updated pnpm-lock.yaml (pulled in @standard-schema/spec
and other vitest deps) but the fixed-output pnpmDeps.hash in mkWebapp was
not regenerated, so the build reused the stale offline store and failed
with ERR_PNPM_NO_OFFLINE_TARBALL during `pnpm install --offline`. Rehash
so the deps FOD is refetched from the current lockfile.
Verified: `nix build .#chat` succeeds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>