feat(activities): ticket purchase + Nostr-driven inventory sync #71

Merged
padreug merged 13 commits from tickets-purchase-sync into dev 2026-05-23 21:19:23 +00:00

13 commits

Author SHA1 Message Date
ac96e073c8 ui(activities): center the tickets-remaining line on detail page
Was left-aligned alone on its row above the owned + buy blocks,
which read as visually orphaned. Adding `justify-center` aligns
it with how the line reads as a status pill — same alignment the
buy CTA below uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:09:52 +02:00
391acb92f2 ui(activities): surface tickets-remaining on the event detail page
The card had it; the detail page didn't. Reuses the same three-
state language as the card ("Unlimited" / "{count} tickets
available" / "Sold out") so the buyer sees the same signal on
both surfaces.

Placed at the top of the tickets section, above the owned-tickets
chip + buy CTA, so it reads top-down: how many are left → how
many you have → buy more.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:08:33 +02:00
d3c479868a ui(activities): drop the ticket-id list from the owned-tickets section
The detail page's owned-tickets card was rendering one font-mono
row per ticket id — useful for verifying state during development
but pure noise for the buyer. The "View in My Tickets" button
already links to the place where the buyer interacts with the
individual rows. Collapse to a single line: "You have N tickets"
+ the link button, on one row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:07:36 +02:00
70c798072e fix(activities): simplify purchase success modal + dialog overflow
Three small fixes the buyer flagged on the multi-ticket purchase
flow:

1. Drop the inline QR grid from the success modal. The buyer's
   real ticket interaction lives in My Tickets — the modal's job
   is just to confirm the purchase landed and point them there.
   N stacked QRs made the dialog overflow on small viewports
   (point 2) and duplicated UI that already exists on the
   destination page.
2. DialogContent gets `max-h-[90dvh] overflow-y-auto` so even
   long content (long invoice expiry text, multiple methods, etc.)
   scrolls inside the dialog instead of bleeding off the viewport.
3. Companion to events ext c8602e0 which switched every row to a
   fresh short-hash id (was: first row reused the 64-hex
   payment_hash, rest got short hashes — inconsistent). No webapp
   code change for that — we just consume what the backend
   returns — but worth noting the ids you'll see now are all
   uniform short hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:46:17 +02:00
c29f7e4d6b feat(activities): one row per attendee + render N QRs on multi-buy
Companion to aiolabs/events PR #15's d087bf3 (N rows sharing one
payment_hash). Now that the backend persists each attendee as a
distinct scannable row, the webapp surfaces them properly:

- TicketPaymentStatus carries `ticketIds: string[]` (every row),
  with `ticketId` kept for back-compat. checkPaymentStatus reads
  both fields off the polling response.
- useTicketPurchase tracks `purchasedTicketIds` + `ticketQRCodes`
  (parallel map id → data url). After payment lands the composable
  generates one QR per row so each attendee has their own.
- PurchaseTicketDialog success screen renders every QR + ticket id
  in a stack with "Ticket N of M" labels. Each can be shared with
  a different attendee for an independent door scan.

Reverts the "seats via extra.quantity" workarounds that landed in
the previous two commits — now that rows == tickets the counters
go back to row-count semantics across MyTickets, ActivityCard
badges, ActivityDetailPage owned-tickets, useUserTickets group
tallies, and the dialog's success header.

Door-scan compatibility: the existing LNbits register-page
scanner (events ext static/js/register.js) already reads
`ticket://<id>` QRs and PUTs /tickets/register/<id>. With N rows
each having a unique uuid id, each attendee's QR maps to a
distinct PUT — independent registration, all 3 friends can enter
separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:36:21 +02:00
ab171b4903 fix(activities): MyTickets tab pills + group header count seats not rows
Last commit fixed the dialog + ActivityDetailPage to read extra.quantity,
but missed three more row-count → seat-count surfaces in
MyTicketsPage:

- Tab pills (All / Paid / Pending / Registered) used
  `paidTickets.length` etc. on the filtered row arrays — so a user
  who bought 1+5+5+6+3+1+1+1 = 23 seats across 8 rows saw "All
  (8)". Now reads from useUserTickets.{total,paid,pending,
  registered}Seats which sum extra.quantity.
- Group header badge "{{ group.tickets.length }} tickets" → uses
  group.paidCount + pendingCount (already seat-summed by the
  previous fix to groupedTickets).
- Group description gains a "({N} purchases)" sub-line when seats
  ≠ rows so the buyer can see at a glance "you have 23 tickets
  across 8 purchases".
- Per-row carousel card grows a `×N` chip next to the truncated
  Ticket #ID when that row represents multi-seat — same chip
  language as the ActivityDetailPage owned-tickets section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:25:33 +02:00
be7bcd393e fix(activities): count seats by extra.quantity across all UI surfaces
Earlier commit landed the backend storing N seats on one row via
extra.quantity (one invoice, one payment, one ticket row), but the
UI kept counting rows instead of seats. A 5-ticket purchase
showed:

  Dialog header: "Purchase a ticket for X for 100 sats"  ← lied
  Success modal: "Ticket purchased!" / one ticket ID    ← lied
  My Tickets / badges: "1 paid ticket"                  ← lied

even though the buyer correctly paid 500 sats and 5 seats were
sold (DB verified: extra.quantity=5, sats_paid=500, event.sold
incremented by 5). The bolt11 invoice amount is cryptographic so
the wallet charge was always right — only the labels were wrong.

Fixes:

- ActivityTicketExtra grows `quantity?: number` (the field already
  on the wire from the backend; just adding it to the type).
- useOwnedTickets exposes `seatsOnRow(ticket)` and `paidCount`
  sums seats (extra.quantity) across rows instead of counting
  rows. ActivityCard's "You have N tickets" badge now reflects
  actual seat ownership.
- useUserTickets.groupedTickets sums seats into paidCount /
  pendingCount / registeredCount so MyTicketsPage groups read
  correctly.
- ActivityDetailPage owned-tickets section adds a `×N` chip on
  rows that represent multiple seats so the buyer can see which
  row covers how many.
- PurchaseTicketDialog header + DialogDescription reflect the
  selected quantity ("Purchase 5 tickets" / "5 tickets for X · 500
  sats"). The success modal switches to "5 tickets purchased!" and
  re-labels the ticket id "Purchase ID (covers all tickets)" so
  the buyer doesn't expect 5 separate ids.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:20:51 +02:00
a116357c57 feat(activities): multi-ticket purchase + restaurant-style invoice screen
Two related UX changes for the buy flow:

1. Quantity selector in PurchaseTicketDialog (1-10). The total
   line updates as the buyer steps the count up/down; the fiat
   conversion preview reflects the totalled amount. Backend caps
   the upper bound (HTTP 400 if anyone tries to bypass via curl).

2. Restaurant-style invoice screen: when the invoice is generated,
   we drop the "single Pay-with-Wallet button" auto-pay path and
   show the QR + amount + Copy + "Open in wallet" together,
   restaurant OrderInvoiceCard-style. Below that, a "Pay from my
   LNbits wallet" button appears when the buyer is signed in with
   a funded wallet — same screen, two paths, buyer picks at the
   moment they see the invoice. The poll already started fires on
   either path.

useTicketPurchase exposes `payCurrentInvoiceWithWallet()` so the
dialog can trigger the wallet-pay path explicitly without going
through purchaseTicketForEvent again. purchaseTicketForEvent no
longer auto-pays — it just creates the invoice + starts polling.

CreateTicketRequest grows `quantity?` (1..10) and requestTicket
forwards it. Quantity is only sent when > 1 so existing flows
stay byte-identical on the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:09:44 +02:00
4dcee143fd fix(activities): i18n keys + retry useOwnedTickets after transient failure
i18n: add the missing keys the ticket purchase + owned-tickets
surfaces use across en/es/fr — activities.detail.{buyTicket,
buyAnotherTicket, viewMyTickets, ticketsOwned, unlimitedTickets}
and activities.filters.myTickets. Without these the runtime fell
back to the literal key strings + spammed [intlify] warnings; the
filter chip rendered the bare key text on logged-in sessions.

ticketsOwned uses i18n pluralization so "You have 1 ticket" vs
"You have 5 tickets" both come out correct.

useOwnedTickets: the hasAutoLoaded guard prevented retries after
a transient backend failure (e.g. an LNbits restart mid-fetch).
The composable would stay stuck with tickets = [] forever, so the
buyer landing on a fresh detail page right after a transient error
saw no badges anywhere. Detect the "previous load didn't actually
hydrate" state (lastLoadedUserId still null while authenticated)
and retry on the next useOwnedTickets() call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:11:05 +02:00
ea4e1960f5 feat(activities): "My tickets" filter chip on ActivitiesPage
A new filter chip sits below the temporal pills, hidden when the
user is logged out. Clicking it narrows the feed to activities the
user holds at least one paid ticket for — intersecting the
existing filter pipeline (temporal / categories / date) with the
ownedActivityIds set from useOwnedTickets.

The coupling lives in useActivities (it already orchestrates the
data + filter pipeline). useActivityFilters stays free of ticket
fetching; it just carries the boolean state. resetFilters clears
the chip alongside the other filters, and hasActiveFilters lights
up when it's on so the "Clear all" affordance is visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:46:42 +02:00
5589bb3e67 feat(activities): purchase + owned-tickets section on ActivityDetailPage
Until now the Purchase button only existed on EventsPage (the
LNbits-sourced listing). Activities sourced from Nostr relays had
no buy path at all. Now that calendar events carry the AIO
tickets_* tags (aiolabs/events#15), the detail page can wire the
existing PurchaseTicketDialog from any activity that has ticketInfo.

Two new blocks appear above the Organizer card when the activity
is ticketed (ticketInfo set):

- Owned-tickets section (primary-tinted card): shown when the
  buyer holds at least one paid ticket. Lists ticket IDs + a
  "View in My Tickets" link.
- Buy ticket CTA: shown when remaining capacity allows. Label
  switches to "Buy another ticket" when the user already owns at
  least one. Price/currency rendered inline so the user knows the
  charge before opening the dialog. A Sold-out message replaces
  the button when available === 0 and the user has no owned
  tickets.

Activity → PurchaseTicketDialog event-shape mapping lives in a
computed so the dialog never receives a partial event. The dialog
itself was untouched (it's the same one EventsPage uses); the
detail page just refreshes useOwnedTickets when the dialog closes
so the badge / section updates immediately after a Lightning
purchase resolves. The inventory side (tickets_available /
tickets_sold counters) updates automatically via the relay
republish from the events extension — no manual refresh needed.

Unauth users get a toast pointing them at login instead of opening
the dialog into a "Login required" state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:44:15 +02:00
fd78a915a6 feat(activities): useOwnedTickets composable + ActivityCard ticket badge
Module-level singleton so the badge on every ActivityCard, the
owned-tickets section on ActivityDetailPage, and the (forthcoming)
"My tickets" filter chip on the activity feed all share one fetch
of the user's tickets rather than each instance hitting the
backend.

useOwnedTickets exposes:
- ticketsByActivity: Map<activityId, ActivityTicket[]> for O(1)
  lookup from the card/detail surfaces
- ownedActivityIds: Set used by the feed filter
- paidCount(id) / getTickets(id) for ergonomic per-activity reads
- refresh() for consumers that just mutated the user's ticket set
  (a successful purchase) to update every surface atomically

Auto-loads on first use after auth is ready, re-fetches when the
current user id changes (login/logout/switch).

ActivityCard grows a primary-colored "You have N tickets" row that
sits next to the existing "X tickets remaining" line — buyer can
see at a glance whether they've already bought in for any activity
in the feed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:42:23 +02:00
7cf009cff6 feat(activities): parse ticket inventory tags from NIP-52 events
The aiolabs/events extension publishes six AIO custom tags on every
kind 31922/31923 calendar event (tickets_available, _sold, _price,
_currency, _allow_fiat, _fiat_currency — see aiolabs/events#15) and
republishes the event on every ticket sale. Connected clients pick
up the new state via their existing relay subscription, no REST
polling.

- New TicketTags shape on CalendarTimeEvent + CalendarDateEvent.
  parseTicketTags reads the six tags off the raw event; tickets_
  currency is the discriminator so non-AIO calendar events (which
  don't have these tags) cleanly produce undefined.
- ActivityTicketInfo grows `sold` + `allowFiat` + `fiatCurrency`
  for the buyer surfaces, drops the never-populated `total` field,
  makes `available` optional (undefined = unlimited capacity).
- Both calendar→Activity converters now populate ticketInfo via
  ticketTagsToInfo so Nostr-sourced activities carry the inventory
  info that was previously only on LNbits drafts.
- ActivityCard handles the three-state available display
  (unlimited / count / sold-out) instead of just truthy/sold-out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:39:53 +02:00