Commit graph

1,023 commits

Author SHA1 Message Date
bc565ebf4b Merge pull request 'chore(base): delete nostr-metadata-service + retire webapp-side kind-0 broadcast paths' (#82) from chore/delete-nostr-metadata-service into dev
Reviewed-on: #82
2026-05-30 15:25:47 +00:00
cb6e1351fb fix(activities): scope detail-page query by NIP-52 d-tag
`useActivityDetail.load()` previously asked every relay for every
kind-31922/31923 event and raced a 5s timeout to find the one
matching the route param. On a cold refresh of the detail page, the
race was often lost — the store starts empty (no feed subscription
to populate it), the relay sprays the whole calendar, and the
matching event may arrive after the timeout, leaving the user with
"Activity not found" on a valid URL.

Add a `dTags` field to `CalendarEventFilters` and emit it as the
nostr `#d` tag filter. Detail-page subscribe + query both scope to
the single activity, so the relay resolves a parameterized-replaceable
lookup in milliseconds instead of streaming the whole calendar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 08:03:55 +02:00
261eded316 fix(api): align webapp client with post-cascade lnbits + surface error detail
Two fixes that together make the kind-0 server-side publish path (this
PR's whole reason for existing) actually work end-to-end against the
deployed cascade:

1. **updateProfile() uses PATCH /api/v1/auth, not PUT /auth/update.**
   aiolabs/lnbits PR #26 gap-fill (869f67c3) wired
   _publish_nostr_metadata_event into the PATCH handler at
   auth_api.py:546. The legacy `/auth/update` route doesn't exist on
   the post-cascade server — a `PUT /auth/update` request gets routed
   into the `/auth/{provider}` SSO wildcard which only allows GET and
   returns 405. Caught while smoke-testing this PR against a local
   regtest pointed at the issue-18-phase-2.3 branch.

2. **request<T>() parses FastAPI's `{"detail": "..."}` error shape.**
   The old error path threw `API request failed: 405 Method Not Allowed`
   for the regtest's 405 above — useful only if you also opened the
   network panel and read the response body manually. Now we parse the
   detail (string or pydantic-validation array), include the endpoint
   path, and throw `LNbits /auth 405: Method Not Allowed`. Falls back
   to raw text for non-JSON bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 07:45:35 +02:00
414b79565c chore(base): delete nostr-metadata-service + retire webapp-side kind-0 broadcast paths
Lnbits's cascade now publishes kind-0 user metadata server-side on
account creation AND on every PATCH /api/v1/auth (aiolabs/lnbits commit
869f67c3 folded into PR #26, deployed to aio-demo via server-deploy
e2eed9c). The webapp no longer needs its own kind-0 publish surface.

Changes:
- Delete src/modules/base/nostr/nostr-metadata-service.ts (162 lines).
  Server now owns kind-0 lifecycle via NostrSigner.sign_event.
- Delete src/modules/base/composables/useNostrMetadata.ts (had zero
  callers; was just a thin wrapper around the deleted service).
- Remove NOSTR_METADATA_SERVICE token from di-container.ts.
- Remove all NostrMetadataService imports / instantiations /
  registrations / dispose calls from src/modules/base/index.ts.
- src/modules/base/auth/auth-service.ts:
  - Drop the broadcastNostrMetadata() helper entirely.
  - Drop its callers in login() (was line 118 pre-edit) and register()
    (was line 142 pre-edit) — both flagged for removal by lnbits in the
    01:45Z coordination handoff. Login-time republish was always
    redundant for kind-0 (replaceable event); register-time is covered
    by lnbits's create_user_account -> _publish_nostr_metadata_event
    path.
  - Drop the auto-broadcast in updateProfile() too — covered by the
    PATCH /api/v1/auth handler's _publish_nostr_metadata_event call
    per the gap-fill commit.
  - Leave the prvkey/pubkey preservation in updateProfile() in place
    for now; the prvkey field removal is the atomic phase-1 final PR
    per design doc Q1.2 Option (b).
- src/modules/base/components/ProfileSettings.vue:
  - Remove the "Broadcast to Nostr" button + isBroadcasting state +
    Radio icon + broadcastMetadata() handler. Manual re-broadcast was
    a local-testing safety net for relay resets that's no longer
    needed once the server publishes automatically on profile save.
  - Simplify the post-save toast to a generic "Profile updated!".
  - Update the helper text accordingly.

This is webapp's bucket-A leg per aiolabs/lnbits#9 phase-1 plan.

Refs:
- log:2026-05-29T01:45Z (lnbits handoff identifying the auth-service
  line numbers to drop)
- ~/dev/coordination/webapp-design-questions.md Q2.3 (decision context)
- aiolabs/lnbits PR #26 commit 869f67c3 (server-side kind-0 publish)
- aiolabs/lnbits dev tip 861f427c, deployed to aio-demo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 21:38:01 +02:00
114d2837c9 Merge pull request 'chore(nostr-feed): delete legacy ScheduledEventService duplicate' (#81) from chore/dedup-scheduled-event-service into dev
Reviewed-on: #81
2026-05-29 19:33:40 +00:00
221c927c74 Merge pull request 'chore(nostr-feed): delete dead-code ReactionService + useReactions duplicates' (#80) from chore/dedup-reaction-service into dev
Reviewed-on: #80
2026-05-29 19:33:27 +00:00
e2a1f024e4 chore(nostr-feed): delete legacy ScheduledEventService duplicate
ScheduledEventService and useScheduledEvents were a legacy duplicate
of TaskService and useTasks. The DI token was already marked
@deprecated, and FeedService routed runtime events to TASK_SERVICE
already — only the publish-side and the NostrFeed view hadn't been
repointed yet.

Changes:
- Delete nostr-feed/services/ScheduledEventService.ts (1067 lines)
- Delete nostr-feed/composables/useScheduledEvents.ts
- Remove SCHEDULED_EVENT_SERVICE token from di-container.ts
- Repoint NostrFeed.vue to useTasks from the tasks module, with
  autoSubscribe: false (FeedService still owns the relay subscription
  for kinds 31922/31925/5)
- Rename FeedService.scheduledEventService field to taskService for
  honesty (the alias was already pointing at TASK_SERVICE)
- Drop tryInjectService legacy-fallback shim — strict-from-the-start
  per workspace pre-launch policy. The tasks module is required;
  inject hard-fails on absence.
- Remove now-dead defensive null guards around taskService and
  reactionService calls in route methods

Closes #79.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:12:21 +02:00
99ca0bf64a chore(nostr-feed): delete dead-code ReactionService + useReactions duplicates
The nostr-feed module had its own copies of ReactionService and
useReactions that were never wired in — the live implementations
live in src/modules/base/. The nostr-feed copy of ReactionService
was a strict subset of the base copy (missing toggleLikeEvent /
toggleDislikeEvent) and was never registered in DI. The
nostr-feed copy of useReactions was identical to the base copy
modulo the type import path; the one consumer (NostrFeed.vue)
already imports from the base path.

Closes #78.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:51:47 +02:00
464ee642de ui(qr-scanner): swap flash-toggle icon from lightning-bolt to flashlight
The control toggles the camera torch, but the inline-SVG bolt read
as a Lightning Network glyph — confusing on a Lightning-payments
app, especially next to the scanner used to validate paid tickets.
Use lucide's Flashlight icon for an unambiguous match to the
control's actual function, and add an aria-label while we're here.
2026-05-25 12:07:20 +02:00
aee29f1ad5 fix(activities): favorites tab shows login toast instead of navigating when logged out
Tapping Favorites used to drop the user onto an empty page that
*then* fired the auth-prompt toast on mount — the navigation was
wasted motion. Mirror the Create tab pattern: onClick checks auth
first, fires the same toast (with Log in action) when unauthed,
and only router-pushes when authed.

`path` is kept on the tab so the active-highlight still works when
the user is actually on /activities/favorites after logging in
(BottomNav highlights via tab.path && isActive(tab.path)). The
mount-time toast on ActivitiesFavoritesPage stays as a defensive
fallback for direct URL access.
2026-05-25 11:56:27 +02:00
f92d4090dd fix(activities): buy-tickets login toast gets a Log in action button
Matches the pattern already used by BookmarkButton, RSVPButton, and
ActivitiesFavoritesPage — the auth-prompt toast carries an inline
"Log in" action that pushes /login. The buy-tickets toast was the
odd one out, leaving the buyer to find the login route themselves.

Also lifts the message + button label into i18n
(activities.detail.loginToBuyTickets, .logIn) so es/fr aren't
English-on-top.
2026-05-25 11:56:11 +02:00
aa2e573f0e fix(activities): "Past events" chip narrows to past-only, not include-past
The chip should mirror the "My tickets" / "Hosting" mental model
(narrow to *only* X), not "additionally include X". Toggling ON with
"This Week" / "This Month" / "Tomorrow" was leaking upcoming events
through — confusing because the other role chips don't behave that
way.

Flip the past-events filter from a hide-when-off guard to a
side-of-now split: showPast=false → upcoming-only (default),
showPast=true → past-only. The DatePickerStrip override stays outside
this branch so date-pick still bypasses the split.
2026-05-25 11:46:19 +02:00
f6c15beb81 feat(activities): hide past events by default + "Past events" filter chip
Closes aiolabs/webapp#72.

useActivityFilters gains `showPast` (default false) and `togglePast`.
applyFilters drops activities whose end-or-start date is before now
unless the chip is toggled on. Sits next to the existing temporal
filter inside the no-selectedDate branch, so picking a specific past
date in the DatePickerStrip still surfaces that day's activities —
mirroring how date-pick already bypasses the temporal pills. Counts
as an active filter and resets cleanly.

ActivitiesPage adds the chip in the role-filter row, outside the
auth gate so logged-out users can still browse past events. Uses the
lucide `History` icon.

ActivityCard gains an `isPast` computed and renders a small Past
badge bottom-left when applicable, suppressed when a Pending /
Rejected status badge is already taking that slot (creator's own
past draft — vanishingly rare, status hint is more actionable).

ActivityDetailPage replaces the Buy ticket CTA with a muted "This
event has already happened" notice when the event is past, so the
buy flow stays unambiguous even when the user lands on a past
event by direct link. The owned-tickets pill above still renders so
past attendees can still see their tickets.

i18n: pastEvents (chip) + past (badge) + pastEvent (detail notice)
added to en/es/fr.
2026-05-25 11:37:46 +02:00
b4baad0d82 feat(activities): backend-truth counts + scanned list, tabs + popup result
The Scan Tickets page now sources its counts strip and scanned-ticket
roster from the new `events_list_event_tickets` RPC instead of a
per-device localStorage cache. The previous design diverged the
moment a second organizer scanned, or the operator switched from
mobile to laptop, or refreshed in incognito — backend truth keeps
all sessions consistent.

Webapp-side changes:

- useTicketScanner now exposes `eventStats` (sold / registered /
  remaining + per-ticket roster), `statsLoading`, `statsError`, and
  `refreshStats()`. Initial load on mount, refresh after every
  decode (success or failure) so the UI reflects state seconds
  after a scan lands.
- localStorage cache demoted to silent decode dedup only. The
  Clear-list button + its confirm dialog are gone — the cache
  isn't authoritative state to clear anymore.
- ScanTicketsPage gets two tabs: Scanner (camera + result) and
  Scanned ({count} from backend). Counts strip up top reads from
  `eventStats` (with the nostr-event `tickets_sold` tag as a
  fallback before the RPC roundtrip completes). A manual Refresh
  button in the top bar covers the rare case where a second device
  scans during your session.
- Result of each scan now lands as a full-viewport tap-to-dismiss
  overlay (success green / warning amber / destructive red) so
  the door operator can't skim past it on a busy entry.

Depends on aiolabs/events v1.6.1-aio.3 (already in the catalog).
2026-05-24 23:33:12 +02:00
815bc2d15f Merge pull request 'feat(activities): organizer ticket scanner over Nostr transport' (#73) from ticket-scanner-nostr-webapp into dev
Reviewed-on: #73
2026-05-24 16:51:12 +00:00
2498fbe518 fix(activities): pause scanner after each decode, require tap to scan next
Without a pause gate the qr-scanner's 5-fps decode loop instantly
fires another scan on whatever QR is still in frame — most
visibly, the ticket that just registered immediately re-fires as
"already scanned this session". The operator at the door never
gets a beat to confirm the result or act on it (let the attendee
in, deny entry, redirect to manual lookup, etc.).

useTicketScanner gains an `isPaused` ref that flips to true the
moment a decode resolves (success, error, or duplicate-session
de-dup) and gates further `onDecode` calls. The camera keeps
streaming so resumption is instant — only the decode handler is
muted.

The page replaces the small "Dismiss" button with a full-width
"Scan next" CTA below the result banner. Same place every time so
the operator's hand can stay in muscle memory; disabled while the
in-flight RPC is still sending. Result banner upgrades to a
slightly larger icon + label so the success/failure is readable
at arm's length over the venue.

`clearScanned` also resets `isPaused` so the operator can recover
from a stuck state via the "Clear list" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:00:21 +02:00
5ebf0582e0 feat(activities): "Hosting" filter chip on the activities feed
Companion to the "My tickets" chip from #71. Where "My tickets"
narrows the feed to events you're attending, "Hosting" narrows it
to events you're organizing — reading `activity.isMine` which
useActivities.tagOwnership() already populates from organizer
pubkey match + own LNbits drafts.

Naming rationale: "My events" would have been ambiguous with
favorited / bookmarked. "Hosting" is short, role-oriented, and
pairs as the natural counterpart to "My tickets" (attending vs.
organizing). Spanish/French translations lean on the verb form
("Organizo" / "J'organise") since those languages don't have a
clean noun equivalent.

- useActivityFilters: onlyHosting flag, toggleHosting action,
  resetFilters clears it, hasActiveFilters lights up.
- applyFilters filters by `a.isMine === true` when the flag is
  on. Composes with category / temporal / "My tickets" via the
  same intersection chain.
- ActivitiesPage: chip rendered alongside "My tickets" with the
  Megaphone icon (lucide). Hidden when logged out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:58:12 +02:00
0f8f98d4c5 feat(activities): organizer ticket scanner over nostr-transport
Closes the activities loop: organizers scan attendees' QRs from the
standalone PWA at the door instead of dropping into the LNbits admin
register page. Every scan invokes the events_ticket_register RPC
(see aiolabs/events#19) over the nostr transport — the organizer's
signed kind-21000 event IS the authorization, no admin_key in the
browser.

- useTicketScanner: stateful driver. Parses `ticket://<id>` URIs,
  dedups in-session via localStorage (`activities_scanned_<id>`,
  mirroring the LNbits admin page's `events_scanned_<eventId>`
  pattern), surfaces lastScan with three states (ok / duplicate-
  session / error). Backend errors arrive as NostrRpcError messages
  ("Ticket not paid for", "Ticket already registered", etc.) and
  render directly.
- ScanTicketsPage: camera viewport + last-scan banner +
  scrollable session list with timestamps and (when available)
  ticket-holder names. Three banner variants (success/warning/
  destructive) so the organizer can read at a glance.
- Route /scan/:activityId, gated by requiresAuth. The "Scan" entry
  button on ActivityDetailPage's top bar is rendered only when
  `ownedLnbitsEvent !== null`, matching the existing "Edit" gating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:46:48 +02:00
02c1be0ba7 feat(base): NostrTransportService — nip44 v2 kind-21000 RPC client for LNbits
Generic client for LNbits's nostr-transport (landed upstream Sun May
24, commit f235966c). Encrypts a request envelope to the server's
transport pubkey with NIP-44 v2, signs a kind-21000 event with the
current user's Nostr key, publishes via RelayHub, and listens for a
signed response addressed back to us. Shards (Lightning.Pub's
`{part, index, totalShards, shardsId}` wrapper) are reassembled
before parsing.

Activities ticket scanner is the first consumer; wallet ops + event
CRUD are obvious next adopters (file as follow-up). Server pubkey
discovery is currently env-var (VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY)
— see also the follow-up to add a `.well-known` discovery endpoint
on LNbits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:46:31 +02:00
f3c8b1cf95 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-24 00:38:25 +02:00
7e3ecf81db 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-24 00:38:25 +02:00
218ff30983 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-24 00:38:25 +02:00
da8de0a219 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-24 00:38:25 +02:00
493a12a86b 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-24 00:38:25 +02:00
c6d3e5cb26 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-24 00:38:25 +02:00
40edba8a8d 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-24 00:38:25 +02:00
75306eaae8 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-24 00:38:25 +02:00
794b63e699 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-24 00:38:25 +02:00
722bc21f4d 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-24 00:38:25 +02:00
5ed0d6da9e 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-24 00:38:25 +02:00
a59712327f 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-24 00:38:25 +02:00
6a35e8e0cb 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-24 00:38:25 +02:00
9f38611f4f feat(activities): notification config on event create + edit
CreateEventDialog gains a collapsible "Buyer notifications" section
exposing the EventExtra fields added upstream in v1.4.0 / v1.6.0:

- email_notifications + nostr_notifications switches — opt buyers
  into email and NIP-04 Nostr DM ticket confirmations.
- notification_subject + notification_body inputs — let organizers
  customize the message. Empty falls back to extension defaults.

Submit handler builds `extra` by overlaying onto the existing
event.extra so unrelated fields the LNbits admin UI sets
(promo_codes, conditional, min_tickets) survive the round-trip
through the webapp. Populate-from-event mirrors the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
a4200749ae fix(activities): normalize 'sat' vs 'sats' across fiat conditionals
TicketApiService.getCurrencies() returns 'sats' (plural) while the
schema, initialValues, and existing comparisons used 'sat' (singular)
— a pre-existing inconsistency in the events extension surface. The
new payment-rails conditionals tripped on it: as soon as the user
picked the populated 'sats' option from the price-currency dropdown,
form.values.currency became 'sats', the `=== 'sat'` check failed, and
the Fiat currency dropdown stayed hidden even with the toggle on.

Normalize all the new comparisons to accept both spellings:

- FiatToggleField: isSatDenomination(d) helper drives both the
  v-show and the auto-mirror watch.
- CreateEventDialog Zod superRefine: same accept-both rule on the
  require-fiat_currency branch.
- PurchaseTicketDialog: isPriceInSats computed drives the
  Lightning-sats badge AND the PriceConversionPreview render
  condition AND the inverse conversion watcher's bail-out.

Also flip FiatToggleField to drive dropdown visibility from the
outer FormField's slot value rather than useFormContext — slot
bindings are guaranteed reactive, sidesteps the public-form-context
indirection that earlier left allowFiat stale in the child's
template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
d6efbd2c65 fix(base): FiatToggleField reads form state via useFormContext
The previous version called useField directly with a getter for the
field name. That created a child-local field rather than connecting
to the parent form's allow_fiat / fiat_currency state — so the
Switch's on/off visually toggled but the form never knew, and the
conditional Fiat currency dropdown never appeared.

Rewrite around the proven pattern used elsewhere in the dialog: bind
the inputs through FormField (the shadcn-vue / vee-validate Field
component) and reach for cross-field state via useFormContext.
showCurrencyDropdown now reads form.values[allowFiatField] directly,
which mirrors the parent's actual state, and the denomination-mirror
watch goes through form.setFieldValue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
574c178d89 feat(activities): provider-aware checkout labels and conversion preview
The buyer-side payment-method block now surfaces one button per
configured fiat provider — Stripe, PayPal, Square, SEPA — rather than
a single bare "Fiat" catch-all. Buttons read in provider names so the
buyer never has to guess what rail backs each choice; the dispatch on
click forwards both `rail` and `provider` to the existing
`ticketApi.requestTicket` signature.

PaymentMethodSelector + useFiatProviders from the base module drive
the list. The Lightning button picks up a "≈ N sats" badge whenever
the event price is denominated in fiat, so the buyer sees the live
sat charge alongside the headline price. A new conversion-preview
line under the headline shows the sat→fiat estimate in the inverse
case (sat-denominated event with fiat enabled), giving the rail-vs-
unit asymmetry an explicit place in the UI.

Explanatory copy makes the equivalence explicit: both methods charge
the same amount, rates are estimates, exact amount locks in at
checkout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
985c10939d refactor(activities): adopt shared payment-rails pattern in CreateEventDialog
Split the bottom of the create/edit form into two semantic sections
that read in the canonical vocabulary:

  Pricing
    Tickets · Price · Price currency   ← renamed from bare "Currency"
  Payment methods
    Lightning — always on (informational chip)
    <FiatToggleField/>                  ← replaces the inline switch
                                          + raw fiat_currency dropdown

The toggle field handles the conditional dropdown (hide + auto-mirror
when the price denomination IS the fiat currency) and the disabled-
with-tooltip state when the user has no configured provider, so the
parent form just supplies field names + the denomination value.

Zod superRefine grows a check that requires `fiat_currency` only in
the surface where the toggle exposes the dropdown — `allow_fiat &&
currency === 'sat'`. Submit-time payload drops `fiat_currency` when
`allow_fiat` is off so we don't persist a rail-currency the backend
won't use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
caec8eddcc feat(base): payment-rails composables + components shared across modules
Activities is the first module to mix Lightning + fiat rails; restaurant
and marketplace will follow. Extract the cross-cutting bits to the base
module so the next adoption is a wiring exercise:

- useFiatProviders: reactive `User.fiat_providers` (today the same list
  for organizer + buyer because LNbits configures providers globally),
  plus `providerMeta()` for label/icon hints.
- usePriceConversion: `convert()` + reactive `useLivePreview()` over
  the existing `/api/v1/conversion` endpoint, 60s cache, null on
  transient failure.
- PaymentMethodSelector: buyer-side rail picker. `PaymentMethod.id`
  enumerates rails (`lightning | fiat | cash | internal | …`) with
  `provider` for the fiat case so a multi-provider instance shows one
  button per provider instead of a bare "Fiat" catch-all.
- FiatToggleField: organizer-side switch + conditional fiat-currency
  dropdown. Auto-disables with a setup-instructions tooltip when the
  user has no providers; silently mirrors fiat_currency to a non-sat
  price denomination to keep the backend payload consistent.
- PriceConversionPreview: muted "≈ X.XX USD" line for surfaces where
  the price denomination differs from the chosen rail's currency.

LnbitsAPI.getConversion wraps the conversion endpoint so the composable
goes through the existing API service rather than raw fetch. CLAUDE.md
gains a "Payment rails pattern" section documenting the canonical
vocabulary ("Price currency" / "Fiat currency" / "Payment method" /
"Also accept fiat" — bare "Currency" and "Pay in fiat" are banned in
payment-context UI labels) and the fiat-providers-are-global note.

The pre-existing `prvkey` comment on User picks up an inline allowlist
marker so the secret scanner stops flagging this file on every commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
ec0dbf727b feat(activities): expose fiat checkout on event create + purchase
Both sides of the fiat-payment surface introduced by events v1.4.0:

CreateEventDialog — organizer-side opt-in:
- New "Accept fiat payments" switch (allow_fiat) + fiat currency
  picker (USD/EUR/GBP/CHF). Toggle is always shipped on
  create/edit so a true→false flip propagates correctly.
- Hint copy notes that the host's LNbits admin needs a configured
  fiat provider (Stripe etc.) for the toggle to actually work at
  purchase time.

PurchaseTicketDialog — buyer-side method selector:
- Two-button selector (Lightning / Fiat) shown only when the event
  has `allow_fiat=true`. Hidden entirely for Lightning-only events.
- Lightning path: unchanged (uses useTicketPurchase composable).
- Fiat path: posts to the API with `payment_method=fiat`, then
  surfaces a "Open <provider> checkout" button that opens the
  returned fiat_payment_request URL in a new tab. Payment
  confirmation happens via webhook on the backend; ticket appears
  in My Tickets on next reload.

EventsPage threads the new fiat fields through `selectedEvent` so
the dialog sees them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
73aee75b5b feat(activities): align types + API service with events v1.6.1
The backend rebase brought in PR #50 (fiat checkout + email/Nostr
ticket notifications), v1.6.0 (custom notification subject/body), and
PR #51 (resend-email endpoint). The webapp types lagged.

Aligns the type surface in src/modules/activities/types/ticket.ts:

- EventExtra (with notification toggles + custom subject/body), promo
  codes, conditional event config.
- ActivityTicketExtra mirroring backend's TicketExtra (nostr_identifier,
  email/nostr notification_sent flags, refund state).
- TicketedEvent + CreateEventRequest gain allow_fiat, fiat_currency, extra.
- TicketPurchaseInvoice extended for fiat: paymentRequest now optional,
  fiatPaymentRequest + fiatProvider + isFiat added. **Closes a latent
  blocker**: a backend response with is_fiat=true would have lost the
  fiat URL during deserialization (silent crash on QR generation).
- New CreateTicketRequest type for the POST /tickets/{id} body, with
  the v1.6.1 payment_method + fiat_provider + nostr_identifier fields.

TicketApiService:
- requestTicket() accepts the new optional fields (paymentMethod,
  fiatProvider, promoCode, refundAddress, nostrIdentifier) and
  deserializes the full TicketPurchaseInvoice shape including fiat.
- fetchUserTickets() / validateTicket() / new resendTicketEmail()
  thread the extra metadata through.

useTicketPurchase composable rejects fiat responses with a clear
error (the QR-and-bolt11 path doesn't know fiat); the eventual UI
selector will live in PurchaseTicketDialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
6cd420d9cb fix(activities): stamp local tz offset on event datetimes before submit
The form sent naive "YYYY-MM-DDTHH:MM" to the LNbits events backend,
where _to_unix (nostr_publisher.py) assumes UTC when tzinfo is None.
So 08:00 entered in CEST got stored as 08:00 UTC, and the NIP-52 start
tag landed on the relays at the wrong instant — the detail page then
re-localized it to 10:00 (offset doubly applied).

Stamp the wall-clock value with the user's UTC offset before sending so
the backend builds the correct unix and the detail page renders the
intended wall-clock. Seconds (`:00`) included for pre-3.11 Python
fromisoformat compatibility. Round-trips through edit mode unchanged:
splitDateTime trims to "HH:MM" so the suffix drops cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
8c09fbdc18 fix(activities): toast on logged-out Create tap instead of opening dialog
BottomNav fires onClick regardless of tab.disabled — the opacity gate
was visual only. Mirror BookmarkButton/RSVPButton: show a toast.info
with a Log in action and bail before opening the create dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
cf1740d025 chore(deps): bump nostr-tools to ^2.23.3 to match lnbits
The only breaking surface in webapp code is SimplePool.subscribeMany —
2.23 dropped the Filter[] form: a single subscription now takes one
Filter, and multi-filter REQs go through subscribeMap. RelayHub gets
an internal poolSubscribe() adapter that routes single-filter to
pool.subscribe() and multi-filter to pool.subscribeMap(), preserving
the external RelayHub.subscribe() API so no downstream modules change.

Peer-dep bump (@noble/* and @scure/* → 2.x) is contained: nostr-tools
is the only consumer in the lockfile, so the major version shift
doesn't conflict with anything else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:25 +02:00
2bc0b9d57b chore(mcp): add shadcn-vue MCP server alongside playwright 2026-05-24 00:37:16 +02:00
80a213e984 build: add workbox-window as explicit devDependency
Required for pnpm strict-hoisting builds (used by aiolabs/server-deploy
NixOS builds). Without it as a direct dep, Rollup can't resolve
`workbox-window` from vite-plugin-pwa's virtual:pwa-register module —
npm's flat hoisting masked this previously.
2026-05-23 11:23:53 +02:00
d6e0019fca build: switch from npm to pnpm
- Replace package-lock.json with pnpm-lock.yaml
- Add packageManager: pnpm@10.33.0
- Allowlist postinstall scripts for esbuild, sharp, vue-demi, electron,
  electron-winstaller via pnpm.onlyBuiltDependencies
- Pin nostr-tools to 2.15.0 (was ^2.10.4 resolving to 2.15.0 via npm).
  A fresh pnpm resolve drifted to 2.23.5, which the regtest nostrrelay
  extension can't parse; upgrade deferred to a follow-up issue covering
  the matching server-side fix.
2026-05-23 11:23:53 +02:00
b3db5e81ef fix(events): inline-arrow multi-statement @update:open handler
Vue's template expression parser doesn't accept multi-line statement
bodies separated by newlines alone — it errors with 'Unexpected token,
expected ","' as it tries to parse the second statement as a
continuation of the first. Convert to the inline-arrow form already
used elsewhere in the codebase (WalletPage.vue, TaskCard.vue, etc.).
2026-05-21 17:00:06 +02:00
63fc7b3ab8 feat(activities): pending-aware toast + unified pending gate
Rename willGoToPending → willLandInPending, drop the (now-redundant)
isEditMode predicate from the gate so create can reuse it. Toast
copy now confirms the destination explicitly:

  create + pending : "Submitted! Awaiting admin approval — your
                     draft is visible on your feed with a Pending
                     badge."
  edit + pending   : "Updated — awaiting re-approval. Hidden from
                     the public feed until reviewed."

Closes the surprise where a non-admin user under auto_approve=off
got a generic "Event submitted!" and then couldn't tell whether
their post had been accepted or was just waiting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:53:55 +02:00
556b9e5cfe feat(activities): ownership + status badges on cards & detail
ActivityCard now renders a "Yours" badge (top-right corner of the
image) when activity.isMine, and a "Pending review" / "Rejected"
badge (bottom-left) when activity.lnbitsStatus is non-approved. The
creator can spot their own posts at a glance on the main feed —
particularly important for pending drafts that don't exist on
Nostr yet.

ActivityDetailPage echoes both badges next to the category row so
users landing on the detail link of their own draft have the same
signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:53:42 +02:00
dbc8b7abf4 feat(activities): merge own LNbits drafts into the feed
The /activities feed is Nostr-driven, so an event that hasn't been
published yet (typically `proposed` under auto_approve=off) silently
vanishes from the creator's view. Add ticketedEventToActivity (the
LNbits → NIP-52-shape adapter) and call it from useActivities so own
drafts surface alongside Nostr-published activities. Once approved
and published, the relay-sourced Activity has a newer createdAt and
wins on upsert (and lacks lnbitsStatus, so any badge disappears).

Also fire loadOwnEvents from the shell after event-created /
event-updated so a fresh draft shows up immediately, not on the next
subscribe cycle.

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