Compare commits

..

62 commits

Author SHA1 Message Date
1f20d5f00c Merge pull request 'refactor(libra): redesign transactions list status + type encoding' (#93) from feat/libra-tx-status-encoding into dev
Reviewed-on: #93
2026-06-06 21:16:45 +00:00
75dfd8a541 refactor(libra): redesign transactions list status + type encoding
Rework how the standalone transactions list communicates entry status
and type so each visual channel does one job and the filter UI matches
the underlying axes.

Encoding:
- Type lives in the signed/colored amount (+green income, -red expense)
  and a matching Income/Expense badge in the badge row.
- Status lives in badges only: red Voided (leftmost) and yellow Pending
  (after the type badge). Cleared entries carry no status badge — the
  quiet default.
- Voided rows additionally strike-through and mute the amount.
- Drop the title-row status icons and the colored left border that
  previously fought with the amount color for the same meaning.

Filter UI:
- Replace the type radio + voided switch with three category chips —
  Income, Expenses, Voided — that independently toggle inclusion of one
  bucket of rows. Each row belongs to exactly one bucket (voided wins
  over type). Defaults: Income + Expenses on, Voided off.
- Empty-selection state nudges the user to enable a category.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 23:12:57 +02:00
4af220adda Merge pull request 'feat(libra): show voided transactions in standalone' (#92) from feat/libra-show-voided into dev
Reviewed-on: #92
2026-06-06 20:31:58 +00:00
1fbf7b3d26 fix(libra): exclude voided txs from balance Pending section
BalancePage filtered tx.flag === '!' to compute pending count/sum/list.
After the libra backend stops hiding voided transactions, those will
arrive with flag='!' plus a 'voided' tag and would otherwise leak into
the Pending section. Add the tag-aware exclusion to keep Pending
showing only genuinely pending entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 20:45:55 +02:00
e9195978c1 feat(libra): surface voided transactions in standalone history
Voided entries keep their '!' flag and gain a 'voided' tag per the libra
reject convention, so detecting them needs a tag check rather than a new
flag char. Render them inline with the existing 'x'-flag voided styling
(grey XCircle icon, strike-through title/amount, red-tinted Voided badge)
so users like Nancy can see their rejected entries instead of having them
silently disappear from the list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 20:45:25 +02:00
4c704e5a41 chore(expenses): delete orphaned admin permission UI
PermissionManager.vue and GrantPermissionDialog.vue were never
imported or routed anywhere; the three ExpensesAPI methods backing
them (listPermissions, grantPermission, revokePermission) pointed
at /libra/api/v1/permissions* which doesn't exist on the backend
(real path is /api/v1/admin/permissions*). The whole feature has
been unreachable since whenever the path drifted.

Removes the two components, the three API methods, and the four
types only they used (AccountPermission, GrantPermissionRequest,
AccountWithPermissions, PermissionType).

If cross-account permission management becomes a real need, the
backend at aiolabs/libra already provides the endpoints (now
correctly gated by require_super_user); rebuild the UI fresh
against the right paths rather than reviving this dead surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 23:17:52 +02:00
ce2488941f Merge pull request 'feat(webapp): add color scheme switcher with 7 palettes' (#90) from feat/color-scheme-switcher into dev
Reviewed-on: #90
2026-06-04 09:51:43 +00:00
53af36ad01 feat(webapp): add color scheme switcher with 7 palettes
Replace the bespoke index.css with a shadcn-vue-idiomatic theme.css
(Catppuccin Latte/Mocha as the default), and add a palette picker to the
profile sheet that swaps between 6 alternative palettes scoped under
:root[data-theme="<name>"]: Countryside Castle, Dark Matter, Emerald
Forest, Light Green, Neo Brutalist, Starry Night.

useTheme() now also persists a 'ui-palette' localStorage key alongside
the existing 'ui-theme' (dark/light/system) and applies the choice via a
data-theme attribute on <html>. Standalone apps inherit the palette
automatically since AppShell already invokes useTheme() on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 11:50:19 +02:00
ca3ad434d3 Merge pull request 'fix(activities): surface statsError on the door-scanner page' (#89) from fix/scanner-stats-error-banner into dev
Reviewed-on: #89
2026-06-04 09:49:39 +00:00
b8910868cd fix(activities): surface statsError on the door-scanner page
useTicketScanner already captures the stats fetch error into a ref
but ScanTicketsPage never read it, so a 404 / 403 / auth failure on
GET /tickets/event/{id}/stats was completely silent — the counts
strip kept showing the last good value while scans landed on the
backend, making it look like the scanner was broken when actually
the refresh path was just dead.

Adds a small destructive-toned banner under the counts strip,
visible across both Scanner and Scanned tabs. AlertCircle already
imported. No new composable surface — statsError is already exported
from useTicketScanner.

Surfaced by a missing /stats endpoint on aio-demo's events backend
(now shipped as events 1.6.1-aio.5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 19:53:48 +02:00
52c03328b4 Merge pull request 'feat(base): phase-2 bucket-B migration via signEventViaLnbits' (#88) from feat/phase-2-bucket-b-sign-event into dev
Reviewed-on: #88
2026-06-03 16:50:13 +00:00
ebd8cef8cd feat(base): phase-2 bucket-B migration via signEventViaLnbits
Closes the build-fail interval opened by PR #84 (User.prvkey field
removal). Adds the uniform signEventViaLnbits() helper per
design-questions Q3.3 and migrates the 5 compile-failing sites the
prvkey removal exposed.

New helper at src/lib/nostr/signing.ts:
- POST /api/v1/auth/sign-event with Bearer auth + credentials:include
- Lazy CSRF token fetch + cache; one-shot refresh on 403-with-CSRF
- Returns the fully-signed event for caller to publish

Site migrations:
- activities/composables/useBookmarks.ts (kind 10003) — drop finalizeEvent
- activities/composables/useRSVP.ts (kind 31925) — drop finalizeEvent
- nostr-feed/components/NostrFeed.vue (kind 5 deletion) — drop finalizeEvent
- base/services/NostrTransportService.ts (kind 21000 RPC bootstrap) —
  call() now throws "deferred to phase 3+ per Q4.2/Q4.3"; scaffolding
  retained for the eventual transport revival
- market/composables/useMarket.ts (NIP-59 gift-wrap unwrap) — disabled
  with a console.warn; no server-routed nip44_decrypt endpoint exists
  yet (Bucket C territory)

Known regression intentionally accepted: incoming order-DM gift-wrap
processing in the marketplace is non-functional until phase-3 adds
NIP-44 decrypt over HTTP/bunker. Per design-questions §"Open questions
deferred", marketplace order receipt routes through nostrmarket
server-side anyway; this client-side path was a redundant fast-path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 18:48:42 +02:00
9048248353 Merge pull request 'fix(activities): route ticket scanner through HTTP, not nostr-transport RPC' (#87) from fix/scanner-via-http into dev
Reviewed-on: #87
2026-06-03 16:34:01 +00:00
ce4ee80359 fix(activities): route ticket scanner through HTTP, not nostr-transport RPC
Post-aiolabs/lnbits#9 the webapp no longer holds a raw user prvkey,
so the kind-21000 nostr-transport RPC layer fails closed for every
caller at the "Sign-in with a Nostr key required to call RPC" guard
in NostrTransportService.call. The ticket scanner was the only
remaining user of that transport on the organizer side.

Route the door scanner through the events extension's existing
admin_key-gated HTTP endpoints instead, matching the Bucket A
pattern the team converged on for the rest of the prvkey-removal
migration (operator-class events route through extension HTTP,
not webapp-side signing).

Pairs with a new GET /tickets/event/{id}/stats endpoint on the
events extension (admin_key + owner check, mirroring the
events_list_event_tickets RPC shape). PUT /tickets/register/{id}
was already hardened in v1.6.1-aio.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 16:34:01 +00:00
386273baab Merge pull request 'chore(api): remove User.prvkey field + thread-through helpers (Q1.2 Option b)' (#84) from chore/remove-user-prvkey-field into dev
Reviewed-on: #84
2026-06-03 16:33:48 +00:00
c07de62af1 fix(nostr-feed): repoint dangling ScheduledEventService imports to TaskService
The dedup commit (e2a1f02) deleted nostr-feed/services/ScheduledEventService.ts
but missed two type imports in NostrFeed.vue and ScheduledEventCard.vue.
vue-tsc failed in the chat-app standalone build with TS2307.

The ScheduledEvent / EventCompletion / TaskStatus types now live in
@/modules/tasks/services/TaskService (already where useTasks comes from
post-dedup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 12:13:36 +02:00
9a300c1679 chore(api): remove User.prvkey field + thread-through helpers (Q1.2 Option b)
Atomic phase-1 final per design-questions Q1.2 Option (b) and the
2026-05-29T00:30Z architecture-decisions lock-in. Removing the
prvkey?: string field from the User interface flips the type system
into the pressure mechanism that forces phase-2 to start: every
remaining bucket-B sign-site (chat / forum / nostr-feed /
activities-bookmarks/RSVP / market / tasks) now fails vue-tsc until
it migrates to signEventViaLnbits() against POST /api/v1/auth/sign-event
(aiolabs/lnbits PR #29, deployed on aio-demo).

Changes:
- src/lib/api/lnbits.ts:
  - Drop `prvkey?: string` from User interface.
  - getCurrentUser(): /auth/nostr/me used to merge prvkey alongside
    pubkey; post-cascade the endpoint returns only the pubkey.
    Updated the comment + cleaned the merge object.
- src/modules/base/auth/auth-service.ts:
  - updateProfile() no longer threads `prvkey` through the merge.
    Server-side PATCH /auth publishes kind-0 via the signer per
    869f67c3; the webapp doesn't keep prvkey at all.
- src/modules/nostr-feed/components/NostrFeed.vue +
  src/modules/nostr-feed/components/ScheduledEventCard.vue:
  - Repoint the `ScheduledEvent` type import from the deleted
    `../services/ScheduledEventService` to
    `@/modules/tasks/services/TaskService`. Trivial post-#81-merge
    cleanup that fell through the dedup PR; same file exports the
    same interface.

vue-tsc --noEmit fails with 8 errors after this commit, all
TS2339 "Property 'prvkey' does not exist". The failing sites are
exactly the bucket-B targets the design doc enumerates as
phase-2 migration work:

| Failing site                                  | Bucket B kind |
|-----------------------------------------------|---------------|
| activities/composables/useBookmarks.ts:92,113 | kind 10003 (NIP-51 bookmarks) |
| activities/composables/useRSVP.ts:153,187     | kind 31925 (NIP-52 RSVP) |
| base/services/NostrTransportService.ts:100,112| kind 21000 (NIP-44 v2 RPC envelope) |
| market/composables/useMarket.ts:455           | NIP-44 gift-wrap (kind 1059) unwrap |
| nostr-feed/components/NostrFeed.vue:408       | kind 5 (deletion of own post) |

NOT caught by vue-tsc but still bucket-B (BaseService injection
pattern types `this.authService` as `any`, so optional chaining
bypasses the type check):

- chat/services/chat-service.ts:341,511,714
- forum/services/SubmissionService.ts:755,1167
- nostr-feed/services/SubmissionService.ts:769,1226
- nostr-feed/components/NoteComposer.vue:306
- nostr-feed/components/RideshareComposer.vue:423
- tasks/services/TaskService.ts:507,562,616

Those sites will runtime-fail (prvkey is undefined from the API
post-cascade) but won't surface at compile time. Phase 2's
per-module migration (Q5.2) catches them as each module flips.

The webapp WILL NOT BUILD CLEANLY after this PR merges to dev
until phase 2 lands. That's the intended trade-off per Q5.1 +
Q1.2; the broken-build interval is the design-intended pressure
mechanism to start phase 2. server-deploy's webapp-demo flake.lock
bump will fail until phase 2 lands; demo will stay on the
pre-PR-#84 webapp during that interval.

Refs:
- log:2026-05-29T00:30Z (consolidated decisions; Q1.2 Option (b)
  + Q5.1 risk: demo gap acceptable)
- log:2026-05-29T17:30Z (lnbits confirming this PR stays
  atomic-after-the-two-bucket-A PRs)
- ~/dev/coordination/webapp-design-questions.md Q1.2 + Q5.1
- Parent initiative: aiolabs/lnbits#9 (signer abstraction / bunker)
- Sibling PRs (stacked base→head): #82#83 → this

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 17:29:45 +02:00
05bbe68682 Merge pull request 'chore(activities): reroute CreateActivityDialog through TicketApiService.createEvent' (#83) from chore/delete-activities-nostr-service-publish into dev
Reviewed-on: #83
2026-05-30 15:26:06 +00:00
9bef2d58ac chore(activities): reroute CreateActivityDialog through TicketApiService.createEvent
The aiolabs/events extension on its signer-abstraction branch (commit
66076d6) constructs and publishes kind-31922 NIP-52 calendar events
server-side via NostrSigner — POST /events/api/v1/events accepts a
CreateEventRequest payload, signs through the operator's signer, and
broadcasts to configured relays. The webapp no longer needs to sign
calendar events client-side.

Changes:
- ActivitiesNostrService.ts: delete publishCalendarEvent() and its
  helper imports (finalizeEvent, EventTemplate, buildCalendarTimeEventTags,
  the local hexToUint8Array). The subscribe / query paths stay — the
  service still reads NIP-52 events off relays for the activity feed.
  Docstring updated to reflect the read-only role and point at the
  events extension for the publish path.
- CreateActivityDialog.vue: swap the publish flow.
  - Drop ActivitiesNostrService injection + currentUser.value.prvkey read.
  - Inject TicketApiService instead; pull invoiceKey from
    currentUser.value.wallets[0].inkey (same pattern as EventsPage.vue
    handleCreateEvent).
  - Build CreateEventRequest with amount_tickets: 0, price_per_ticket: 0
    (events extension treats 0 as unlimited/not-ticketed per
    models.py:45-46 per lnbits 22:30Z audit).
  - Fold summary + description into the events extension's `info`
    field since CreateEventRequest has no separate summary slot.
  - Update toast on success to "Activity created!" (server publishes
    to relays via the signer, not the webapp).

Approval-workflow caveat documented inline in the submit handler:
non-admin users on instances with auto_approve=false (the default)
land in the proposal queue and don't publish to relays until an admin
approves. Admins / auto_approve=true instances publish immediately.
This is the intended new behavior — operators can flip auto_approve
on the events extension config per-instance if they want the legacy
direct-publish moderation posture.

This is webapp's second bucket-A leg per aiolabs/lnbits#9 phase-1.
The remaining `currentUser.value.prvkey` reads stay until the
atomic User.prvkey field-removal PR (Q1.2 Option (b)).

Refs:
- log:2026-05-28T22:30Z (lnbits Q2.1 audit verifying ticket-less
  acceptance + approval-workflow caveat)
- ~/dev/coordination/webapp-design-questions.md Q2.1
- aiolabs/events signer-abstraction commit 66076d6 (the server-side
  publish path)
- aiolabs/lnbits cascade tip 861f427c deployed to aio-demo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:26:06 +00:00
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
73 changed files with 3923 additions and 3439 deletions

View file

@ -11,6 +11,13 @@ VITE_API_KEY=your-api-key-here
VITE_LNBITS_DEBUG=false VITE_LNBITS_DEBUG=false
VITE_WEBSOCKET_ENABLED=true VITE_WEBSOCKET_ENABLED=true
# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint).
# Logged by the LNbits server at startup:
# `Nostr transport: starting with pubkey <hex>... on N relay(s)`
# Required for the activities ticket scanner; legacy HTTP path still
# works without it.
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY=
# Lightning Address Domain (optional) # Lightning Address Domain (optional)
# Override the domain used for Lightning Addresses # Override the domain used for Lightning Addresses
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL # If not set, domain will be extracted from VITE_LNBITS_BASE_URL

View file

@ -714,6 +714,90 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
VITE_WEBSOCKET_ENABLED=true VITE_WEBSOCKET_ENABLED=true
``` ```
## Payment Rails Pattern
Shared primitives for modules that mix Lightning + fiat (and, future,
cash / internal-wallet) payment rails. Activities is the first
consumer; restaurant + marketplace will adopt the same primitives as
their backends gain fiat support.
### Vocabulary (canonical — used in code AND UI labels)
| Term | Meaning | Field |
|---|---|---|
| **Price currency** | unit the price is quoted in | `currency` |
| **Payment method** | the rail used to pay (Lightning / Fiat / Cash / Internal) | `payment_method` |
| **Fiat currency** | currency the fiat provider settles in | `fiat_currency` |
| **Also accept fiat** | the user-facing label for the `allow_fiat` toggle | `allow_fiat` |
The bare word `Currency` is **banned** in payment-context UI labels —
it always carries a `Price` or `Fiat` qualifier. The literal string
`Pay in fiat` is also banned on buyer-side buttons — each fiat rail
shows as a button labeled with its provider name (`Stripe`, `PayPal`,
`Square`, `SEPA`). Only the degenerate "providers unknown" fallback
shows a generic `Card`.
### Fiat-provider architecture (LNbits today)
Fiat providers are configured **globally** by the LNbits admin
(`lnbits/settings.py`). Each provider has an optional `allowed_users`
whitelist; the per-session filtered list is exposed as
`User.fiat_providers: string[]` on `GET /api/v1/auth` (which the
webapp already reads as `currentUser.fiat_providers`). Both organizer
and buyer on the same instance see the same list.
Per-user provider configuration is a deferred backend feature. Until
then, `useFiatProviders` reads the same `currentUser.fiat_providers`
for both sides.
### Shared primitives (live in base module)
```
src/modules/base/
├── composables/
│ ├── useFiatProviders.ts // providers + hasAnyProvider + refresh + providerMeta()
│ └── usePriceConversion.ts // convert() + useLivePreview(); 60s cache; null on failure
└── components/payments/
├── PaymentMethodSelector.vue // buyer-side rail picker
├── FiatToggleField.vue // organizer-side toggle + conditional fiat-currency dropdown
└── PriceConversionPreview.vue // muted "≈ X.XX USD" line
```
All three components consume services via DI — never import them
directly across module boundaries.
### `PaymentMethodSelector` data shape
```ts
type PaymentRail = 'lightning' | 'fiat' | 'cash' | 'internal' | (string & {})
type PaymentMethod = {
id: string // unique v-for key, e.g. 'fiat:stripe'
rail: PaymentRail // sent as payment_method
provider?: string // sent as fiat_provider when present
label: string // 'Lightning' | 'Stripe' | 'SEPA' | 'Cash' | …
icon: Component // lucide icon
available: boolean // false ⇒ rendered disabled with tooltip
unavailableReason?: string // tooltip when disabled
badge?: string // optional secondary hint, e.g. '≈ 1000 sats'
}
```
Module usage:
- **Activities** passes `[lightning, ...one entry per organizer provider]`.
- **Restaurant** (future) passes the subset of
`[lightning, cash, internal, ...fiat providers]` enabled by the
restaurant's `accepts_*` flags.
### Adding a new fiat provider
1. Backend exposes the provider id in `User.fiat_providers`.
2. Add the id to `KNOWN_PROVIDERS` in `useFiatProviders.ts` with its
display label and icon hint (`'card' | 'bank' | 'wallet'`).
3. Unknown ids fall back to a `Capitalized` label with a `'card'`
icon hint — no code change required just for the buttons to
render, only for nice branding.
## Mobile Browser File Input & Form Refresh Issues ## Mobile Browser File Input & Form Refresh Issues
### **Problem Overview** ### **Problem Overview**

View file

@ -59,7 +59,7 @@
"light-bolt11-decoder": "^3.2.0", "light-bolt11-decoder": "^3.2.0",
"lucide-vue-next": "^0.474.0", "lucide-vue-next": "^0.474.0",
"ngeohash": "^0.6.3", "ngeohash": "^0.6.3",
"nostr-tools": "2.15.0", "nostr-tools": "^2.23.3",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",

88
pnpm-lock.yaml generated
View file

@ -57,8 +57,8 @@ importers:
specifier: ^0.6.3 specifier: ^0.6.3
version: 0.6.3 version: 0.6.3
nostr-tools: nostr-tools:
specifier: 2.15.0 specifier: ^2.23.3
version: 2.15.0(typescript@5.6.3) version: 2.23.5(typescript@5.6.3)
pinia: pinia:
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.1(typescript@5.6.3)(vue@3.5.34(typescript@5.6.3)) version: 2.3.1(typescript@5.6.3)(vue@3.5.34(typescript@5.6.3))
@ -1247,22 +1247,17 @@ packages:
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
'@noble/ciphers@0.5.3': '@noble/ciphers@2.1.1':
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
'@noble/curves@1.1.0': '@noble/curves@2.0.1':
resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
engines: {node: '>= 20.19.0'}
'@noble/curves@1.2.0': '@noble/hashes@2.0.1':
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@1.3.1':
resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==}
engines: {node: '>= 16'}
'@noble/hashes@1.3.2':
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
engines: {node: '>= 16'}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
@ -1478,11 +1473,14 @@ packages:
'@scure/base@1.1.1': '@scure/base@1.1.1':
resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==}
'@scure/bip32@1.3.1': '@scure/base@2.0.0':
resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
'@scure/bip39@1.2.1': '@scure/bip32@2.0.1':
resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==}
'@scure/bip39@2.0.1':
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
'@sindresorhus/is@4.6.0': '@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
@ -3535,8 +3533,8 @@ packages:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'} engines: {node: '>=10'}
nostr-tools@2.15.0: nostr-tools@2.23.5:
resolution: {integrity: sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==} resolution: {integrity: sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==}
peerDependencies: peerDependencies:
typescript: '>=5.0.0' typescript: '>=5.0.0'
peerDependenciesMeta: peerDependenciesMeta:
@ -6204,19 +6202,13 @@ snapshots:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
'@noble/ciphers@0.5.3': {} '@noble/ciphers@2.1.1': {}
'@noble/curves@1.1.0': '@noble/curves@2.0.1':
dependencies: dependencies:
'@noble/hashes': 1.3.1 '@noble/hashes': 2.0.1
'@noble/curves@1.2.0': '@noble/hashes@2.0.1': {}
dependencies:
'@noble/hashes': 1.3.2
'@noble/hashes@1.3.1': {}
'@noble/hashes@1.3.2': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
@ -6362,16 +6354,18 @@ snapshots:
'@scure/base@1.1.1': {} '@scure/base@1.1.1': {}
'@scure/bip32@1.3.1': '@scure/base@2.0.0': {}
dependencies:
'@noble/curves': 1.1.0
'@noble/hashes': 1.3.1
'@scure/base': 1.1.1
'@scure/bip39@1.2.1': '@scure/bip32@2.0.1':
dependencies: dependencies:
'@noble/hashes': 1.3.1 '@noble/curves': 2.0.1
'@scure/base': 1.1.1 '@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@scure/bip39@2.0.1':
dependencies:
'@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@sindresorhus/is@4.6.0': {} '@sindresorhus/is@4.6.0': {}
@ -8539,14 +8533,14 @@ snapshots:
normalize-url@6.1.0: {} normalize-url@6.1.0: {}
nostr-tools@2.15.0(typescript@5.6.3): nostr-tools@2.23.5(typescript@5.6.3):
dependencies: dependencies:
'@noble/ciphers': 0.5.3 '@noble/ciphers': 2.1.1
'@noble/curves': 1.2.0 '@noble/curves': 2.0.1
'@noble/hashes': 1.3.1 '@noble/hashes': 2.0.1
'@scure/base': 1.1.1 '@scure/base': 2.0.0
'@scure/bip32': 1.3.1 '@scure/bip32': 2.0.1
'@scure/bip39': 1.2.1 '@scure/bip39': 2.0.1
nostr-wasm: 0.1.0 nostr-wasm: 0.1.0
optionalDependencies: optionalDependencies:
typescript: 5.6.3 typescript: 5.6.3

View file

@ -98,8 +98,11 @@ async function loadData() {
totalIncomeSats.value = balanceData.total_income_sats || 0 totalIncomeSats.value = balanceData.total_income_sats || 0
totalIncomeFiat.value = balanceData.total_income_fiat || {} totalIncomeFiat.value = balanceData.total_income_fiat || {}
// Filter for pending transactions (flag = '!') // Filter for pending transactions (flag = '!'), excluding voided ones
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!') // (libra convention: voided keeps '!' flag and carries a 'voided' tag).
pendingTransactions.value = txData.entries.filter(
tx => tx.flag === '!' && !tx.tags?.includes('voided')
)
} catch (error) { } catch (error) {
console.error('[BalancePage] Error loading data:', error) console.error('[BalancePage] Error loading data:', error)
toast.error('Failed to load balance data') toast.error('Failed to load balance data')

View file

@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next' import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue' import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue' import type { BottomTab } from '@/components/layout/BottomNav.vue'
@ -15,6 +16,7 @@ import type { CreateEventRequest } from '@/modules/activities/types/ticket'
import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue' import CreateEventDialog from '@/modules/activities/components/CreateEventDialog.vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const activitiesStore = useActivitiesStore() const activitiesStore = useActivitiesStore()
@ -25,9 +27,9 @@ const { isAdmin, autoApprove } = useApprovalState()
const { loadOwnEvents } = useActivities() const { loadOwnEvents } = useActivities()
// Settings dropped theme/lang/currency now live in the shared profile sheet. // Settings dropped theme/lang/currency now live in the shared profile sheet.
// Create lives in the bottom nav (auth-gated): activity creation is a deliberate // Create lives in the bottom nav: when logged out, tapping it shows an
// act, surfacing it as a tab keeps it one tap away when authed and out of the // auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
// way when not. Per-app placement deliberation tracked at #53. // opening the dialog. Per-app placement deliberation tracked at #53.
const tabs = computed<BottomTab[]>(() => [ const tabs = computed<BottomTab[]>(() => [
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' }, { name: t('activities.nav.feed'), icon: Search, path: '/activities' },
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' }, { name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
@ -35,6 +37,15 @@ const tabs = computed<BottomTab[]>(() => [
name: t('activities.createNew'), name: t('activities.createNew'),
icon: Plus, icon: Plus,
onClick: () => { onClick: () => {
if (!isAuthenticated.value) {
toast.info('Log in to create an activity', {
action: {
label: 'Log in',
onClick: () => router.push('/login'),
},
})
return
}
// Defensively clear any lingering edit selection so the Create // Defensively clear any lingering edit selection so the Create
// tap always opens in Create mode regardless of a prior Edit. // tap always opens in Create mode regardless of a prior Edit.
activitiesStore.editingEvent = null activitiesStore.editingEvent = null
@ -43,7 +54,27 @@ const tabs = computed<BottomTab[]>(() => [
disabled: !isAuthenticated.value, disabled: !isAuthenticated.value,
}, },
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' }, { name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
{ name: t('activities.nav.favorites'), icon: Heart, path: '/activities/favorites' }, {
name: t('activities.nav.favorites'),
icon: Heart,
// path kept so the tab stays active-highlighted while the user is
// on /activities/favorites; onClick wins for the actual tap so we
// can gate on auth (mirrors the Create tab pattern above).
path: '/activities/favorites',
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('activities.favorites.loginPrompt'), {
action: {
label: t('activities.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
router.push('/activities/favorites')
},
disabled: !isAuthenticated.value,
},
]) ])
// Feed tab is active for the bare /activities route AND all sub-paths that // Feed tab is active for the bare /activities route AND all sub-paths that

View file

@ -1,179 +1,195 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import './themes/countrysidecastle.css';
@import './themes/darkmatter.css';
@import './themes/emeraldforest.css';
@import './themes/lightgreen.css';
@import './themes/neobrut.css';
@import './themes/starrynight.css';
@plugin 'tailwindcss-animate'; @plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme inline {
--radius-lg: var(--radius); --color-background: var(--background);
--radius-md: calc(var(--radius) - 2px); --color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-serif: var(--font-serif);
--font-mono: var(--font-mono);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: oklch(var(--background)); --shadow-2xs: var(--shadow-2xs);
--color-foreground: oklch(var(--foreground)); --shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--color-card: oklch(var(--card)); --duration-fast: 150ms;
--color-card-foreground: oklch(var(--card-foreground)); --duration-normal: 200ms;
--duration-slow: 300ms;
--color-popover: oklch(var(--popover)); --ease-in: cubic-bezier(0.4, 0, 1, 1);
--color-popover-foreground: oklch(var(--popover-foreground)); --ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--color-primary: oklch(var(--primary));
--color-primary-foreground: oklch(var(--primary-foreground));
--color-secondary: oklch(var(--secondary));
--color-secondary-foreground: oklch(var(--secondary-foreground));
--color-muted: oklch(var(--muted));
--color-muted-foreground: oklch(var(--muted-foreground));
--color-accent: oklch(var(--accent));
--color-accent-foreground: oklch(var(--accent-foreground));
--color-destructive: oklch(var(--destructive));
--color-destructive-foreground: oklch(var(--destructive-foreground));
--color-border: oklch(var(--border));
--color-input: oklch(var(--input));
--color-ring: oklch(var(--ring));
--color-chart-1: oklch(var(--chart-1));
--color-chart-2: oklch(var(--chart-2));
--color-chart-3: oklch(var(--chart-3));
--color-chart-4: oklch(var(--chart-4));
--color-chart-5: oklch(var(--chart-5));
--animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down { @keyframes accordion-down {
from { from { height: 0; }
height: 0; to { height: var(--reka-accordion-content-height); }
}
to {
height: var(--reka-accordion-content-height);
}
} }
@keyframes accordion-up { @keyframes accordion-up {
from { from { height: var(--reka-accordion-content-height); }
height: var(--reka-accordion-content-height); to { height: 0; }
}
to {
height: 0;
} }
} }
/* Add standard shadcn animation durations */ /* Default palette: Catppuccin (Latte for light, Mocha for dark).
--duration-fast: 150ms; Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
--duration-normal: 200ms; :root {
--duration-slow: 300ms; --background: oklch(0.9578 0.0058 264.5321);
--foreground: oklch(0.4355 0.0430 279.3250);
/* Add standard shadcn easings */ --card: oklch(1.0000 0 0);
--ease-in: cubic-bezier(0.4, 0, 1, 1); --card-foreground: oklch(0.4355 0.0430 279.3250);
--ease-out: cubic-bezier(0, 0, 0.2, 1); --popover: oklch(0.8575 0.0145 268.4756);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --popover-foreground: oklch(0.4355 0.0430 279.3250);
--primary: oklch(0.5547 0.2503 297.0156);
/* Add standard shadcn animations */ --primary-foreground: oklch(1.0000 0 0);
--animate-in: animate-in var(--duration-normal) var(--ease-out); --secondary: oklch(0.8575 0.0145 268.4756);
--animate-out: animate-out var(--duration-normal) var(--ease-in); --secondary-foreground: oklch(0.4355 0.0430 279.3250);
--muted: oklch(0.9060 0.0117 264.5071);
--animate-fade-in: fade-in var(--duration-normal) var(--ease-out); --muted-foreground: oklch(0.5471 0.0343 279.0837);
--animate-fade-out: fade-out var(--duration-normal) var(--ease-in); --accent: oklch(0.6820 0.1448 235.3822);
--accent-foreground: oklch(1.0000 0 0);
--animate-slide-in-from-top: slide-in-from-top var(--duration-normal) var(--ease-out); --destructive: oklch(0.5505 0.2155 19.8095);
--animate-slide-out-to-top: slide-out-to-top var(--duration-normal) var(--ease-in); --destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8083 0.0174 271.1982);
--animate-slide-in-from-bottom: slide-in-from-bottom var(--duration-normal) var(--ease-out); --input: oklch(0.8575 0.0145 268.4756);
--animate-slide-out-to-bottom: slide-out-to-bottom var(--duration-normal) var(--ease-in); --ring: oklch(0.5547 0.2503 297.0156);
--chart-1: oklch(0.5547 0.2503 297.0156);
--animate-slide-in-from-left: slide-in-from-left var(--duration-normal) var(--ease-out); --chart-2: oklch(0.6820 0.1448 235.3822);
--animate-slide-out-to-left: slide-out-to-left var(--duration-normal) var(--ease-in); --chart-3: oklch(0.6250 0.1772 140.4448);
--chart-4: oklch(0.6920 0.2041 42.4293);
--animate-slide-in-from-right: slide-in-from-right var(--duration-normal) var(--ease-out); --chart-5: oklch(0.7141 0.1045 33.0967);
--animate-slide-out-to-right: slide-out-to-right var(--duration-normal) var(--ease-in); --sidebar: oklch(0.9335 0.0087 264.5206);
--sidebar-foreground: oklch(0.4355 0.0430 279.3250);
--sidebar-primary: oklch(0.5547 0.2503 297.0156);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.6820 0.1448 235.3822);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0.8083 0.0174 271.1982);
--sidebar-ring: oklch(0.5547 0.2503 297.0156);
--font-sans: Montserrat, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Fira Code, monospace;
--radius: 0.35rem;
--shadow-2xs: 0px 4px 6px 0px hsl(240 30% 25% / 0.06);
--shadow-xs: 0px 4px 6px 0px hsl(240 30% 25% / 0.06);
--shadow-sm: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 1px 2px -1px hsl(240 30% 25% / 0.12);
--shadow: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 1px 2px -1px hsl(240 30% 25% / 0.12);
--shadow-md: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 2px 4px -1px hsl(240 30% 25% / 0.12);
--shadow-lg: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 4px 6px -1px hsl(240 30% 25% / 0.12);
--shadow-xl: 0px 4px 6px 0px hsl(240 30% 25% / 0.12), 0px 8px 10px -1px hsl(240 30% 25% / 0.12);
--shadow-2xl: 0px 4px 6px 0px hsl(240 30% 25% / 0.30);
} }
/* .dark {
The default border color has changed to `currentColor` in Tailwind CSS v4, --background: oklch(0.2155 0.0254 284.0647);
so we've added these compatibility styles to make sure everything still --foreground: oklch(0.8787 0.0426 272.2767);
looks the same as it did with Tailwind CSS v3. --card: oklch(0.2429 0.0304 283.9110);
--card-foreground: oklch(0.8787 0.0426 272.2767);
--popover: oklch(0.4037 0.0320 280.1520);
--popover-foreground: oklch(0.8787 0.0426 272.2767);
--primary: oklch(0.7871 0.1187 304.7693);
--primary-foreground: oklch(0.2429 0.0304 283.9110);
--secondary: oklch(0.4765 0.0340 278.6430);
--secondary-foreground: oklch(0.8787 0.0426 272.2767);
--muted: oklch(0.2973 0.0294 276.2144);
--muted-foreground: oklch(0.7510 0.0396 273.9320);
--accent: oklch(0.8467 0.0833 210.2545);
--accent-foreground: oklch(0.2429 0.0304 283.9110);
--destructive: oklch(0.7556 0.1297 2.7642);
--destructive-foreground: oklch(0.2429 0.0304 283.9110);
--border: oklch(0.3240 0.0319 281.9784);
--input: oklch(0.3240 0.0319 281.9784);
--ring: oklch(0.7871 0.1187 304.7693);
--chart-1: oklch(0.7871 0.1187 304.7693);
--chart-2: oklch(0.8467 0.0833 210.2545);
--chart-3: oklch(0.8577 0.1092 142.7153);
--chart-4: oklch(0.8237 0.1015 52.6294);
--chart-5: oklch(0.9226 0.0238 30.4919);
--sidebar: oklch(0.1828 0.0204 284.2039);
--sidebar-foreground: oklch(0.8787 0.0426 272.2767);
--sidebar-primary: oklch(0.7871 0.1187 304.7693);
--sidebar-primary-foreground: oklch(0.2429 0.0304 283.9110);
--sidebar-accent: oklch(0.8467 0.0833 210.2545);
--sidebar-accent-foreground: oklch(0.2429 0.0304 283.9110);
--sidebar-border: oklch(0.4037 0.0320 280.1520);
--sidebar-ring: oklch(0.7871 0.1187 304.7693);
}
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base { @layer base {
*, *,
::after, ::after,
::before, ::before,
::backdrop, ::backdrop,
::file-selector-button { ::file-selector-button {
border-color: var(--color-gray-200, currentColor); border-color: var(--color-border, currentColor);
}
} }
@layer base {
:root {
/* Catppuccin Latte - Enhanced */
--background: 0.98 0.005 235; /* base - slightly brighter */
--foreground: 0.25 0.02 235; /* text - darker for contrast */
--card: 1.0 0.005 235; /* pure white for cards */
--card-foreground: 0.25 0.02 235;
--popover: 1.0 0.005 235;
--popover-foreground: 0.25 0.02 235;
--primary: 0.60 0.18 250; /* lavender - more saturated */
--primary-foreground: 1.0 0.005 235;
--secondary: 0.92 0.04 235; /* surface1 - more distinct */
--secondary-foreground: 0.25 0.02 235;
--muted: 0.95 0.03 235; /* surface0 - slightly more color */
--muted-foreground: 0.45 0.03 235; /* subtext0 - better contrast */
--accent: 0.70 0.18 290; /* mauve - more saturated */
--accent-foreground: 0.25 0.02 235;
--destructive: 0.70 0.28 0; /* red - more vibrant */
--destructive-foreground: 1.0 0.005 235;
--border: 0.90 0.04 235; /* surface1 - more visible */
--input: 0.90 0.04 235;
--ring: 0.60 0.18 250; /* matching primary */
--chart-1: 0.70 0.28 0; /* red - more vibrant */
--chart-2: 0.80 0.25 30; /* peach - more saturated */
--chart-3: 0.85 0.20 90; /* yellow - more saturated */
--chart-4: 0.75 0.20 150; /* green - more saturated */
--chart-5: 0.65 0.20 180; /* blue - more saturated */
--radius: 0.5rem;
}
.dark {
/* Catppuccin Mocha - Enhanced */
--background: 0.25 0.03 256; /* base #1e1e2e */
--foreground: 0.98 0.02 256; /* text #cdd6f4 - slightly brighter */
--card: 0.27 0.03 256; /* slightly lighter than background */
--card-foreground: 0.98 0.02 256;
--popover: 0.27 0.03 256;
--popover-foreground: 0.98 0.02 256;
--primary: 0.80 0.15 270; /* lavender #b4befe - more saturated */
--primary-foreground: 0.20 0.02 256;
--secondary: 0.32 0.04 256; /* surface1 #313244 - more distinct */
--secondary-foreground: 0.98 0.02 256;
--muted: 0.29 0.03 256; /* surface0 #2a2b3c */
--muted-foreground: 0.85 0.03 256; /* subtext0 - brighter */
--accent: 0.75 0.18 300; /* mauve #cba6f7 - more saturated */
--accent-foreground: 0.20 0.02 256;
--destructive: 0.75 0.28 10; /* red #f38ba8 - more vibrant */
--destructive-foreground: 0.98 0.02 256;
--border: 0.32 0.04 256; /* slightly more visible borders */
--input: 0.32 0.04 256;
--ring: 0.80 0.15 270; /* matching primary */
--chart-1: 0.75 0.28 10; /* red #f38ba8 */
--chart-2: 0.85 0.22 40; /* peach #fab387 */
--chart-3: 0.88 0.18 95; /* yellow #f9e2af */
--chart-4: 0.80 0.18 155; /* green #a6e3a1 */
--chart-5: 0.70 0.18 190; /* blue #89b4fa */
}
}
@layer base {
* { * {
@apply border-border; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }

View file

@ -1,64 +0,0 @@
/* ... add other utility classes as needed ... */
/* :root { */
/* /* gruvbox light theme */ */
/* --color-background: hsl(32 92% 87%); /* bg0 */ */
/* --color-foreground: hsl(40 13% 23%); /* fg */ */
/**/
/* --color-card: hsl(39 59% 88%); /* bg1 */ */
/* --color-card-foreground: hsl(40 13% 23%); /* fg */ */
/**/
/* --color-popover: hsl(39 59% 88%); /* bg1 */ */
/* --color-popover-foreground: hsl(40 13% 23%); /* fg */ */
/**/
/* --color-primary: hsl(0 100% 31%); /* red */ */
/* --color-primary-foreground: hsl(40 92% 88%); /* bg */ */
/**/
/* --color-secondary: hsl(39 46% 81%); /* bg2 */ */
/* --color-secondary-foreground: hsl(40 13% 23%); /* fg */ */
/**/
/* --color-muted: hsl(37 29% 73%); /* bg3 */ */
/* --color-muted-foreground: hsl(40 4% 36%); /* gray */ */
/**/
/* --color-accent: hsl(40 71% 49%); /* yellow */ */
/* --color-accent-foreground: hsl(0 0% 16%); */
/**/
/* --color-destructive: hsl(0 76% 46%); /* bright_red */ */
/* --color-destructive-foreground: hsl(40 92% 88%); /* bg */ */
/**/
/* --color-border: hsl(33 14% 59%); /* fg4 */ */
/* --color-input: hsl(33 14% 59%); /* fg4 */ */
/* --color-ring: hsl(0 100% 31%); /* red */ */
/**/
/* --radius: 0.5rem; */
/* } */
/* .dark { */
/* /* gruvbox dark theme */ */
/* --color-background: hsl(0 0% 16%); /* bg0 */ */
/* --color-foreground: hsl(40 92% 88%); /* fg */ */
/**/
/* --color-card: hsl(0 7% 23%); /* bg1 */ */
/* --color-card-foreground: hsl(40 92% 88%); /* fg */ */
/**/
/* --color-popover: hsl(0 7% 23%); /* bg1 */ */
/* --color-popover-foreground: hsl(40 92% 88%); /* fg */ */
/**/
/* --color-primary: hsl(6 93% 59%); /* red */ */
/* --color-primary-foreground: hsl(0 0% 16%); /* bg */ */
/**/
/* --color-secondary: hsl(0 5% 29%); /* bg2 */ */
/* --color-secondary-foreground: hsl(40 92% 88%); /* fg */ */
/**/
/* --color-muted: hsl(20 6% 36%); /* bg3 */ */
/* --color-muted-foreground: hsl(33 14% 59%); /* gray */ */
/**/
/* --color-accent: hsl(42 95% 58%); /* yellow */ */
/* --color-accent-foreground: hsl(0 0% 16%); */
/**/
/* --color-destructive: hsl(6 93% 59%); /* bright_red */ */
/* --color-destructive-foreground: hsl(40 92% 88%); /* bg */ */
/**/
/* --color-border: hsl(24 10% 51%); /* fg4 */ */
/* --color-input: hsl(24 10% 51%); /* fg4 */ */
/* --color-ring: hsl(6 93% 59%); /* red */ */
/* } */

View file

@ -0,0 +1,89 @@
:root[data-theme='countrysidecastle'] {
--background: oklch(0.9730 0.0058 84.5664);
--foreground: oklch(0.3247 0.0226 57.3596);
--card: oklch(0.9459 0.0117 84.5794);
--card-foreground: oklch(0.2724 0.0177 57.4319);
--popover: oklch(0.9595 0.0088 84.5733);
--popover-foreground: oklch(0.2175 0.0125 57.5482);
--primary: oklch(0.5698 0.0614 230.9798);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.8197 0.0295 76.4350);
--secondary-foreground: oklch(0.3750 0.0273 57.3103);
--muted: oklch(0.8903 0.0080 91.4872);
--muted-foreground: oklch(0.5129 0.0202 57.8140);
--accent: oklch(0.5425 0.0625 154.6280);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0.5107 0.1226 22.3963);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8540 0.0188 76.5358);
--input: oklch(0.9130 0.0111 76.5933);
--ring: oklch(0.5698 0.0614 230.9798);
--chart-1: oklch(0.5698 0.0614 230.9798);
--chart-2: oklch(0.5425 0.0625 154.6280);
--chart-3: oklch(0.6550 0.0929 74.2249);
--chart-4: oklch(0.5570 0.0685 47.4365);
--chart-5: oklch(0.7343 0.0644 91.8078);
--sidebar: oklch(0.9266 0.0069 76.6174);
--sidebar-foreground: oklch(0.4237 0.0317 57.2745);
--sidebar-primary: oklch(0.5698 0.0614 230.9798);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.8912 0.0161 156.8939);
--sidebar-accent-foreground: oklch(0.3497 0.0513 153.7441);
--sidebar-border: oklch(0.8649 0.0085 76.6058);
--sidebar-ring: oklch(0.5698 0.0614 230.9798);
--font-sans: Garamond, 'Eb Garamond', serif;
--font-serif: Palatino, 'Palatino Linotype', serif;
--font-mono: monospace;
--radius: 0.3rem;
--shadow-2xs: 0px 4px 12px 2px hsl(25 20% 10% / 0.05);
--shadow-xs: 0px 4px 12px 2px hsl(25 20% 10% / 0.05);
--shadow-sm: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 1px 2px 1px hsl(25 20% 10% / 0.10);
--shadow: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 1px 2px 1px hsl(25 20% 10% / 0.10);
--shadow-md: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 2px 4px 1px hsl(25 20% 10% / 0.10);
--shadow-lg: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 4px 6px 1px hsl(25 20% 10% / 0.10);
--shadow-xl: 0px 4px 12px 2px hsl(25 20% 10% / 0.10), 0px 8px 10px 1px hsl(25 20% 10% / 0.10);
--shadow-2xl: 0px 4px 12px 2px hsl(25 20% 10% / 0.25);
}
:root[data-theme='countrysidecastle'].dark {
--background: oklch(0.2299 0.0198 256.8306);
--foreground: oklch(0.8894 0.0105 76.5953);
--card: oklch(0.2605 0.0240 256.8358);
--card-foreground: oklch(0.9266 0.0069 76.6174);
--popover: oklch(0.2090 0.0169 256.8258);
--popover-foreground: oklch(0.9635 0.0034 76.6361);
--primary: oklch(0.7041 0.0385 211.1736);
--primary-foreground: oklch(0.2079 0.0203 256.8404);
--secondary: oklch(0.4265 0.0167 76.4097);
--secondary-foreground: oklch(0.8894 0.0105 76.5953);
--muted: oklch(0.3137 0.0183 256.8010);
--muted-foreground: oklch(0.7169 0.0173 256.7349);
--accent: oklch(0.5342 0.0451 158.8384);
--accent-foreground: oklch(0.9635 0.0034 76.6361);
--destructive: oklch(0.4708 0.1367 24.0033);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.3591 0.0295 256.8271);
--input: oklch(0.2920 0.0224 256.8218);
--ring: oklch(0.7041 0.0385 211.1736);
--chart-1: oklch(0.7041 0.0385 211.1736);
--chart-2: oklch(0.5810 0.0497 158.8087);
--chart-3: oklch(0.6763 0.0649 75.6254);
--chart-4: oklch(0.6009 0.0747 47.4204);
--chart-5: oklch(0.5781 0.0524 256.8345);
--sidebar: oklch(0.1974 0.0185 256.8372);
--sidebar-foreground: oklch(0.8518 0.0141 76.5687);
--sidebar-primary: oklch(0.7041 0.0385 211.1736);
--sidebar-primary-foreground: oklch(0.2079 0.0203 256.8404);
--sidebar-accent: oklch(0.2920 0.0224 256.8218);
--sidebar-accent-foreground: oklch(0.8544 0.0190 211.0454);
--sidebar-border: oklch(0.3095 0.0306 256.8416);
--sidebar-ring: oklch(0.7041 0.0385 211.1736);
--shadow-2xs: 0px 10px 20px 5px hsl(215 40% 5% / 0.25);
--shadow-xs: 0px 10px 20px 5px hsl(215 40% 5% / 0.25);
--shadow-sm: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 1px 2px 4px hsl(215 40% 5% / 0.50);
--shadow: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 1px 2px 4px hsl(215 40% 5% / 0.50);
--shadow-md: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 2px 4px 4px hsl(215 40% 5% / 0.50);
--shadow-lg: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 4px 6px 4px hsl(215 40% 5% / 0.50);
--shadow-xl: 0px 10px 20px 5px hsl(215 40% 5% / 0.50), 0px 8px 10px 4px hsl(215 40% 5% / 0.50);
--shadow-2xl: 0px 10px 20px 5px hsl(215 40% 5% / 1.25);
}

View file

@ -0,0 +1,81 @@
:root[data-theme='darkmatter'] {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.2101 0.0318 264.6645);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.2101 0.0318 264.6645);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2101 0.0318 264.6645);
--primary: oklch(0.6716 0.1368 48.5130);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.5360 0.0398 196.0280);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9670 0.0029 264.5419);
--muted-foreground: oklch(0.5510 0.0234 264.3637);
--accent: oklch(0.9491 0 0);
--accent-foreground: oklch(0.2101 0.0318 264.6645);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(0.9851 0 0);
--border: oklch(0.9276 0.0058 264.5313);
--input: oklch(0.9276 0.0058 264.5313);
--ring: oklch(0.6716 0.1368 48.5130);
--chart-1: oklch(0.5940 0.0443 196.0233);
--chart-2: oklch(0.7214 0.1337 49.9802);
--chart-3: oklch(0.8721 0.0864 68.5474);
--chart-4: oklch(0.6268 0 0);
--chart-5: oklch(0.6830 0 0);
--sidebar: oklch(0.9670 0.0029 264.5419);
--sidebar-foreground: oklch(0.2101 0.0318 264.6645);
--sidebar-primary: oklch(0.6716 0.1368 48.5130);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(1.0000 0 0);
--sidebar-accent-foreground: oklch(0.2101 0.0318 264.6645);
--sidebar-border: oklch(0.9276 0.0058 264.5313);
--sidebar-ring: oklch(0.6716 0.1368 48.5130);
--font-sans: Geist Mono, ui-monospace, monospace;
--font-serif: serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.75rem;
--shadow-2xs: 0px 1px 4px 0px hsl(0 0% 0% / 0.03);
--shadow-xs: 0px 1px 4px 0px hsl(0 0% 0% / 0.03);
--shadow-sm: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow-md: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 2px 4px -1px hsl(0 0% 0% / 0.05);
--shadow-lg: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 4px 6px -1px hsl(0 0% 0% / 0.05);
--shadow-xl: 0px 1px 4px 0px hsl(0 0% 0% / 0.05), 0px 8px 10px -1px hsl(0 0% 0% / 0.05);
--shadow-2xl: 0px 1px 4px 0px hsl(0 0% 0% / 0.13);
}
:root[data-theme='darkmatter'].dark {
--background: oklch(0.1797 0.0043 308.1928);
--foreground: oklch(0.8109 0 0);
--card: oklch(0.1822 0 0);
--card-foreground: oklch(0.8109 0 0);
--popover: oklch(0.1797 0.0043 308.1928);
--popover-foreground: oklch(0.8109 0 0);
--primary: oklch(0.7214 0.1337 49.9802);
--primary-foreground: oklch(0.1797 0.0043 308.1928);
--secondary: oklch(0.5940 0.0443 196.0233);
--secondary-foreground: oklch(0.1797 0.0043 308.1928);
--muted: oklch(0.2520 0 0);
--muted-foreground: oklch(0.6268 0 0);
--accent: oklch(0.3211 0 0);
--accent-foreground: oklch(0.8109 0 0);
--destructive: oklch(0.5940 0.0443 196.0233);
--destructive-foreground: oklch(0.1797 0.0043 308.1928);
--border: oklch(0.2520 0 0);
--input: oklch(0.2520 0 0);
--ring: oklch(0.7214 0.1337 49.9802);
--chart-1: oklch(0.5940 0.0443 196.0233);
--chart-2: oklch(0.7214 0.1337 49.9802);
--chart-3: oklch(0.8721 0.0864 68.5474);
--chart-4: oklch(0.6268 0 0);
--chart-5: oklch(0.6830 0 0);
--sidebar: oklch(0.1822 0 0);
--sidebar-foreground: oklch(0.8109 0 0);
--sidebar-primary: oklch(0.7214 0.1337 49.9802);
--sidebar-primary-foreground: oklch(0.1797 0.0043 308.1928);
--sidebar-accent: oklch(0.3211 0 0);
--sidebar-accent-foreground: oklch(0.8109 0 0);
--sidebar-border: oklch(0.2520 0 0);
--sidebar-ring: oklch(0.7214 0.1337 49.9802);
}

View file

@ -0,0 +1,89 @@
:root[data-theme='emeraldforest'] {
--background: oklch(0.9793 0.0077 145.5161);
--foreground: oklch(0.2464 0.0358 168.9829);
--card: oklch(0.9649 0.0108 145.4913);
--card-foreground: oklch(0.2464 0.0358 168.9829);
--popover: oklch(0.9793 0.0077 145.5161);
--popover-foreground: oklch(0.2464 0.0358 168.9829);
--primary: oklch(0.5767 0.1258 156.2896);
--primary-foreground: oklch(0.9860 0.0025 165.0756);
--secondary: oklch(0.8935 0.0214 156.7700);
--secondary-foreground: oklch(0.3633 0.0586 159.6692);
--muted: oklch(0.9274 0.0118 150.6461);
--muted-foreground: oklch(0.4883 0.0348 172.3051);
--accent: oklch(0.9398 0.0317 164.2277);
--accent-foreground: oklch(0.4557 0.0902 161.0214);
--destructive: oklch(0.5714 0.2121 27.2502);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8935 0.0214 156.7700);
--input: oklch(0.9293 0.0141 156.9525);
--ring: oklch(0.5767 0.1258 156.2896);
--chart-1: oklch(0.6610 0.1560 154.8128);
--chart-2: oklch(0.6335 0.1421 147.0021);
--chart-3: oklch(0.5044 0.0769 179.9212);
--chart-4: oklch(0.7869 0.1601 151.8584);
--chart-5: oklch(0.6954 0.1084 168.3427);
--sidebar: oklch(0.9588 0.0148 147.9012);
--sidebar-foreground: oklch(0.3142 0.0494 168.2500);
--sidebar-primary: oklch(0.5767 0.1258 156.2896);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9168 0.0213 156.7859);
--sidebar-accent-foreground: oklch(0.4542 0.0965 156.7126);
--sidebar-border: oklch(0.9150 0.0170 156.8819);
--sidebar-ring: oklch(0.5767 0.1258 156.2896);
--font-sans: Inter, sans-serif;
--font-serif: Georgia, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.5rem;
--shadow-2xs: 0px 2px 10px 0px hsl(160 50% 10% / 0.03);
--shadow-xs: 0px 2px 10px 0px hsl(160 50% 10% / 0.03);
--shadow-sm: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 1px 2px -1px hsl(160 50% 10% / 0.05);
--shadow: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 1px 2px -1px hsl(160 50% 10% / 0.05);
--shadow-md: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 2px 4px -1px hsl(160 50% 10% / 0.05);
--shadow-lg: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 4px 6px -1px hsl(160 50% 10% / 0.05);
--shadow-xl: 0px 2px 10px 0px hsl(160 50% 10% / 0.05), 0px 8px 10px -1px hsl(160 50% 10% / 0.05);
--shadow-2xl: 0px 2px 10px 0px hsl(160 50% 10% / 0.13);
}
:root[data-theme='emeraldforest'].dark {
--background: oklch(0.1554 0.0140 178.0345);
--foreground: oklch(0.9650 0.0064 164.9697);
--card: oklch(0.1965 0.0190 176.6910);
--card-foreground: oklch(0.9650 0.0064 164.9697);
--popover: oklch(0.1703 0.0163 177.0235);
--popover-foreground: oklch(0.9650 0.0064 164.9697);
--primary: oklch(0.7355 0.1793 154.0472);
--primary-foreground: oklch(0.1703 0.0163 177.0235);
--secondary: oklch(0.2896 0.0276 171.3798);
--secondary-foreground: oklch(0.9297 0.0128 164.7776);
--muted: oklch(0.2503 0.0187 172.1743);
--muted-foreground: oklch(0.7753 0.0200 164.4487);
--accent: oklch(0.3031 0.0438 164.4442);
--accent-foreground: oklch(0.9059 0.0980 161.8651);
--destructive: oklch(0.5181 0.1747 25.7610);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2852 0.0226 172.0143);
--input: oklch(0.3190 0.0263 171.8953);
--ring: oklch(0.7355 0.1793 154.0472);
--chart-1: oklch(0.7355 0.1793 154.0472);
--chart-2: oklch(0.7403 0.1521 151.7821);
--chart-3: oklch(0.6461 0.1081 178.6914);
--chart-4: oklch(0.5457 0.0949 162.7657);
--chart-5: oklch(0.7786 0.0938 157.7379);
--sidebar: oklch(0.1848 0.0187 176.5131);
--sidebar-foreground: oklch(0.9297 0.0128 164.7776);
--sidebar-primary: oklch(0.7355 0.1793 154.0472);
--sidebar-primary-foreground: oklch(0.1703 0.0163 177.0235);
--sidebar-accent: oklch(0.2503 0.0187 172.1743);
--sidebar-accent-foreground: oklch(0.8800 0.1137 161.0658);
--sidebar-border: oklch(0.2737 0.0213 172.0621);
--sidebar-ring: oklch(0.7355 0.1793 154.0472);
--shadow-2xs: 0px 4px 15px 0px hsl(0 0% 0% / 0.20);
--shadow-xs: 0px 4px 15px 0px hsl(0 0% 0% / 0.20);
--shadow-sm: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40);
--shadow: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40);
--shadow-md: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 2px 4px -1px hsl(0 0% 0% / 0.40);
--shadow-lg: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 4px 6px -1px hsl(0 0% 0% / 0.40);
--shadow-xl: 0px 4px 15px 0px hsl(0 0% 0% / 0.40), 0px 8px 10px -1px hsl(0 0% 0% / 0.40);
--shadow-2xl: 0px 4px 15px 0px hsl(0 0% 0% / 1.00);
}

View file

@ -0,0 +1,89 @@
:root[data-theme='lightgreen'] {
--background: oklch(0.9892 0.0054 117.9205);
--foreground: oklch(0.2077 0.0398 265.7549);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.2077 0.0398 265.7549);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.2077 0.0398 265.7549);
--primary: oklch(0.8871 0.2122 128.5041);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.3717 0.0392 257.2870);
--secondary-foreground: oklch(0.9842 0.0034 247.8575);
--muted: oklch(0.9683 0.0069 247.8956);
--muted-foreground: oklch(0.5544 0.0407 257.4166);
--accent: oklch(0.9819 0.0181 155.8263);
--accent-foreground: oklch(0.4479 0.1083 151.3277);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.9288 0.0126 255.5078);
--input: oklch(0.9288 0.0126 255.5078);
--ring: oklch(0.8871 0.2122 128.5041);
--chart-1: oklch(0.8871 0.2122 128.5041);
--chart-2: oklch(0.3717 0.0392 257.2870);
--chart-3: oklch(0.7227 0.1920 149.5793);
--chart-4: oklch(0.5544 0.0407 257.4166);
--chart-5: oklch(0.7107 0.0351 256.7878);
--sidebar: oklch(1.0000 0 0);
--sidebar-foreground: oklch(0.2077 0.0398 265.7549);
--sidebar-primary: oklch(0.8871 0.2122 128.5041);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.9842 0.0034 247.8575);
--sidebar-accent-foreground: oklch(0.2077 0.0398 265.7549);
--sidebar-border: oklch(0.9683 0.0069 247.8956);
--sidebar-ring: oklch(0.8871 0.2122 128.5041);
--font-sans: Inter, system-ui, sans-serif;
--font-serif: Georgia, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 1rem;
--shadow-2xs: 0px 8px 20px 0px hsl(0 0% 0% / 0.03);
--shadow-xs: 0px 8px 20px 0px hsl(0 0% 0% / 0.03);
--shadow-sm: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 1px 2px -1px hsl(0 0% 0% / 0.05);
--shadow-md: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 2px 4px -1px hsl(0 0% 0% / 0.05);
--shadow-lg: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 4px 6px -1px hsl(0 0% 0% / 0.05);
--shadow-xl: 0px 8px 20px 0px hsl(0 0% 0% / 0.05), 0px 8px 10px -1px hsl(0 0% 0% / 0.05);
--shadow-2xl: 0px 8px 20px 0px hsl(0 0% 0% / 0.13);
}
:root[data-theme='lightgreen'].dark {
--background: oklch(0.1288 0.0406 264.6952);
--foreground: oklch(0.9842 0.0034 247.8575);
--card: oklch(0.2077 0.0398 265.7549);
--card-foreground: oklch(0.9842 0.0034 247.8575);
--popover: oklch(0.2077 0.0398 265.7549);
--popover-foreground: oklch(0.9842 0.0034 247.8575);
--primary: oklch(0.8871 0.2122 128.5041);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2795 0.0368 260.0310);
--secondary-foreground: oklch(0.9842 0.0034 247.8575);
--muted: oklch(0.2795 0.0368 260.0310);
--muted-foreground: oklch(0.7107 0.0351 256.7878);
--accent: oklch(0.3925 0.0896 152.5353);
--accent-foreground: oklch(0.8871 0.2122 128.5041);
--destructive: oklch(0.4437 0.1613 26.8994);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2795 0.0368 260.0310);
--input: oklch(0.2795 0.0368 260.0310);
--ring: oklch(0.8871 0.2122 128.5041);
--chart-1: oklch(0.8871 0.2122 128.5041);
--chart-2: oklch(0.6231 0.1880 259.8145);
--chart-3: oklch(0.7227 0.1920 149.5793);
--chart-4: oklch(0.6268 0.2325 303.9004);
--chart-5: oklch(0.7686 0.1647 70.0804);
--sidebar: oklch(0.1288 0.0406 264.6952);
--sidebar-foreground: oklch(0.9842 0.0034 247.8575);
--sidebar-primary: oklch(0.8871 0.2122 128.5041);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.2795 0.0368 260.0310);
--sidebar-accent-foreground: oklch(0.9842 0.0034 247.8575);
--sidebar-border: oklch(0.2795 0.0368 260.0310);
--sidebar-ring: oklch(0.8871 0.2122 128.5041);
--shadow-2xs: 0px 10px 25px 0px hsl(0 0% 0% / 0.20);
--shadow-xs: 0px 10px 25px 0px hsl(0 0% 0% / 0.20);
--shadow-sm: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40);
--shadow: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 1px 2px -1px hsl(0 0% 0% / 0.40);
--shadow-md: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 2px 4px -1px hsl(0 0% 0% / 0.40);
--shadow-lg: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 4px 6px -1px hsl(0 0% 0% / 0.40);
--shadow-xl: 0px 10px 25px 0px hsl(0 0% 0% / 0.40), 0px 8px 10px -1px hsl(0 0% 0% / 0.40);
--shadow-2xl: 0px 10px 25px 0px hsl(0 0% 0% / 1.00);
}

View file

@ -0,0 +1,81 @@
:root[data-theme='neobrut'] {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
}
:root[data-theme='neobrut'].dark {
--background: oklch(0 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.3211 0 0);
--card-foreground: oklch(1.0000 0 0);
--popover: oklch(0.3211 0 0);
--popover-foreground: oklch(1.0000 0 0);
--primary: oklch(0.7044 0.1872 23.1858);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.9691 0.2005 109.6228);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.2178 0 0);
--muted-foreground: oklch(0.8452 0 0);
--accent: oklch(0.6755 0.1765 252.2592);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(1.0000 0 0);
--destructive-foreground: oklch(0 0 0);
--border: oklch(1.0000 0 0);
--input: oklch(1.0000 0 0);
--ring: oklch(0.7044 0.1872 23.1858);
--chart-1: oklch(0.7044 0.1872 23.1858);
--chart-2: oklch(0.9691 0.2005 109.6228);
--chart-3: oklch(0.6755 0.1765 252.2592);
--chart-4: oklch(0.7395 0.2268 142.8504);
--chart-5: oklch(0.6131 0.2458 328.0714);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1.0000 0 0);
--sidebar-primary: oklch(0.7044 0.1872 23.1858);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.6755 0.1765 252.2592);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(1.0000 0 0);
--sidebar-ring: oklch(0.7044 0.1872 23.1858);
}

View file

@ -0,0 +1,81 @@
:root[data-theme='starrynight'] {
--background: oklch(0.9755 0.0045 258.3245);
--foreground: oklch(0.2558 0.0433 268.0662);
--card: oklch(0.9341 0.0132 251.5628);
--card-foreground: oklch(0.2558 0.0433 268.0662);
--popover: oklch(0.9856 0.0278 98.0540);
--popover-foreground: oklch(0.2558 0.0433 268.0662);
--primary: oklch(0.4815 0.1178 263.3758);
--primary-foreground: oklch(0.9856 0.0278 98.0540);
--secondary: oklch(0.8567 0.1164 81.0092);
--secondary-foreground: oklch(0.2558 0.0433 268.0662);
--muted: oklch(0.9202 0.0080 106.5563);
--muted-foreground: oklch(0.4815 0.1178 263.3758);
--accent: oklch(0.6896 0.0714 234.0387);
--accent-foreground: oklch(0.9856 0.0278 98.0540);
--destructive: oklch(0.2611 0.0376 322.5267);
--destructive-foreground: oklch(0.9856 0.0278 98.0540);
--border: oklch(0.7791 0.0156 251.1926);
--input: oklch(0.6896 0.0714 234.0387);
--ring: oklch(0.8567 0.1164 81.0092);
--chart-1: oklch(0.4815 0.1178 263.3758);
--chart-2: oklch(0.8567 0.1164 81.0092);
--chart-3: oklch(0.6896 0.0714 234.0387);
--chart-4: oklch(0.7791 0.0156 251.1926);
--chart-5: oklch(0.2611 0.0376 322.5267);
--sidebar: oklch(0.9341 0.0132 251.5628);
--sidebar-foreground: oklch(0.2558 0.0433 268.0662);
--sidebar-primary: oklch(0.4815 0.1178 263.3758);
--sidebar-primary-foreground: oklch(0.9856 0.0278 98.0540);
--sidebar-accent: oklch(0.8567 0.1164 81.0092);
--sidebar-accent-foreground: oklch(0.2558 0.0433 268.0662);
--sidebar-border: oklch(0.7791 0.0156 251.1926);
--sidebar-ring: oklch(0.8567 0.1164 81.0092);
--font-sans: Libre Baskerville, serif;
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
:root[data-theme='starrynight'].dark {
--background: oklch(0.2204 0.0198 275.8439);
--foreground: oklch(0.9366 0.0129 266.6974);
--card: oklch(0.2703 0.0407 281.3036);
--card-foreground: oklch(0.9366 0.0129 266.6974);
--popover: oklch(0.2703 0.0407 281.3036);
--popover-foreground: oklch(0.9097 0.1440 95.1120);
--primary: oklch(0.4815 0.1178 263.3758);
--primary-foreground: oklch(0.9097 0.1440 95.1120);
--secondary: oklch(0.9097 0.1440 95.1120);
--secondary-foreground: oklch(0.2703 0.0407 281.3036);
--muted: oklch(0.2424 0.0324 281.0890);
--muted-foreground: oklch(0.6243 0.0412 262.0375);
--accent: oklch(0.8469 0.0524 264.7751);
--accent-foreground: oklch(0.2204 0.0198 275.8439);
--destructive: oklch(0.5280 0.1200 357.1130);
--destructive-foreground: oklch(0.9097 0.1440 95.1120);
--border: oklch(0.3072 0.0287 281.7681);
--input: oklch(0.4815 0.1178 263.3758);
--ring: oklch(0.9097 0.1440 95.1120);
--chart-1: oklch(0.4815 0.1178 263.3758);
--chart-2: oklch(0.9097 0.1440 95.1120);
--chart-3: oklch(0.6896 0.0714 234.0387);
--chart-4: oklch(0.6243 0.0412 262.0375);
--chart-5: oklch(0.5280 0.1200 357.1130);
--sidebar: oklch(0.2703 0.0407 281.3036);
--sidebar-foreground: oklch(0.9366 0.0129 266.6974);
--sidebar-primary: oklch(0.4815 0.1178 263.3758);
--sidebar-primary-foreground: oklch(0.9097 0.1440 95.1120);
--sidebar-accent: oklch(0.9097 0.1440 95.1120);
--sidebar-accent-foreground: oklch(0.2703 0.0407 281.3036);
--sidebar-border: oklch(0.3072 0.0287 281.7681);
--sidebar-ring: oklch(0.9097 0.1440 95.1120);
}

View file

@ -2,9 +2,9 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { Sun, Moon, Monitor, Globe, Coins } from 'lucide-vue-next' import { Sun, Moon, Monitor, Globe, Coins, Palette } from 'lucide-vue-next'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { useTheme } from '@/components/theme-provider' import { useTheme, PALETTES, type Palette as PaletteName } from '@/components/theme-provider'
import { useLocale } from '@/composables/useLocale' import { useLocale } from '@/composables/useLocale'
import { import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
@ -14,14 +14,14 @@ import {
interface Props { interface Props {
/** 'row' = three icon-stacked buttons side-by-side (used by Hub bottom nav). /** 'row' = three icon-stacked buttons side-by-side (used by Hub bottom nav).
* 'list' = three full-width list rows (used inside the profile sheet). */ * 'list' = full-width list rows (used inside the profile sheet). */
layout?: 'row' | 'list' layout?: 'row' | 'list'
} }
const props = withDefaults(defineProps<Props>(), { layout: 'row' }) const props = withDefaults(defineProps<Props>(), { layout: 'row' })
const { t } = useI18n() const { t } = useI18n()
const { theme, setTheme, currentTheme } = useTheme() const { theme, setTheme, currentTheme, palette, setPalette } = useTheme()
const { currentLocale, locales, setLocale } = useLocale() const { currentLocale, locales, setLocale } = useLocale()
const ThemeIcon = computed(() => (currentTheme.value === 'dark' ? Moon : Sun)) const ThemeIcon = computed(() => (currentTheme.value === 'dark' ? Moon : Sun))
@ -29,6 +29,10 @@ const currentLocaleLabel = computed(
() => locales.value.find(l => l.code === currentLocale.value)?.name ?? currentLocale.value () => locales.value.find(l => l.code === currentLocale.value)?.name ?? currentLocale.value
) )
const paletteLabel = (p: PaletteName) =>
t(`common.nav.palette_${p}`)
const currentPaletteLabel = computed(() => paletteLabel(palette.value))
// Currency picker is intentionally still a placeholder until #45 lands // Currency picker is intentionally still a placeholder until #45 lands
// the row UX is what we're building here, not the underlying preference. // the row UX is what we're building here, not the underlying preference.
function notImplemented() { function notImplemented() {
@ -114,6 +118,31 @@ function notImplemented() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<!-- Color scheme (palette) -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="flex items-center justify-between gap-3 px-3 py-3 hover:bg-accent rounded-md transition-colors text-left">
<div class="flex items-center gap-3">
<Palette class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('common.nav.colorScheme') }}</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<span>{{ currentPaletteLabel }}</span>
<ChevronRight class="w-4 h-4" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuLabel>{{ t('common.nav.colorScheme') }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup :model-value="palette" @update:model-value="(v) => v != null && setPalette(v as PaletteName)">
<DropdownMenuRadioItem v-for="p in PALETTES" :key="p" :value="p">
{{ paletteLabel(p) }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<!-- Language --> <!-- Language -->
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>

View file

@ -2,9 +2,31 @@ import { computed, onMounted, ref, watch } from 'vue'
type Theme = 'dark' | 'light' | 'system' type Theme = 'dark' | 'light' | 'system'
export type Palette =
| 'catppuccin'
| 'countrysidecastle'
| 'darkmatter'
| 'emeraldforest'
| 'lightgreen'
| 'neobrut'
| 'starrynight'
export const PALETTES: Palette[] = [
'catppuccin',
'countrysidecastle',
'darkmatter',
'emeraldforest',
'lightgreen',
'neobrut',
'starrynight',
]
const DEFAULT_PALETTE: Palette = 'catppuccin'
const useTheme = () => { const useTheme = () => {
const theme = ref<Theme>('dark') const theme = ref<Theme>('dark')
const systemTheme = ref<'dark' | 'light'>('light') const systemTheme = ref<'dark' | 'light'>('light')
const palette = ref<Palette>(DEFAULT_PALETTE)
const updateSystemTheme = () => { const updateSystemTheme = () => {
systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
@ -22,31 +44,56 @@ const useTheme = () => {
} }
} }
const applyPalette = () => {
if (palette.value === DEFAULT_PALETTE) {
document.documentElement.removeAttribute('data-theme')
} else {
document.documentElement.setAttribute('data-theme', palette.value)
}
}
onMounted(() => { onMounted(() => {
const stored = localStorage.getItem('ui-theme') const stored = localStorage.getItem('ui-theme')
if (stored && (stored === 'dark' || stored === 'light' || stored === 'system')) { if (stored && (stored === 'dark' || stored === 'light' || stored === 'system')) {
theme.value = stored as Theme theme.value = stored as Theme
} }
const storedPalette = localStorage.getItem('ui-palette')
if (storedPalette && (PALETTES as string[]).includes(storedPalette)) {
palette.value = storedPalette as Palette
}
updateSystemTheme() updateSystemTheme()
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateSystemTheme) window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateSystemTheme)
applyTheme() applyTheme()
applyPalette()
}) })
watch(currentTheme, () => { watch(currentTheme, () => {
applyTheme() applyTheme()
}) })
watch(palette, () => {
applyPalette()
})
const setTheme = (newTheme: Theme) => { const setTheme = (newTheme: Theme) => {
theme.value = newTheme theme.value = newTheme
localStorage.setItem('ui-theme', newTheme) localStorage.setItem('ui-theme', newTheme)
} }
const setPalette = (newPalette: Palette) => {
palette.value = newPalette
localStorage.setItem('ui-palette', newPalette)
}
return { return {
theme, theme,
setTheme, setTheme,
systemTheme, systemTheme,
currentTheme, currentTheme,
palette,
setPalette,
} }
} }

View file

@ -43,16 +43,17 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<!-- Flash toggle (if available) --> <!-- Flash toggle (if available) flashlight, not lightning-bolt:
this controls the camera torch, not anything Lightning-related,
and the bolt icon was confusing in a Lightning-payments app. -->
<Button <Button
v-if="flashAvailable" v-if="flashAvailable"
@click="toggleFlash" @click="toggleFlash"
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="Toggle flashlight"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <Flashlight class="w-4 h-4" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</Button> </Button>
<!-- Close scanner --> <!-- Close scanner -->
@ -73,7 +74,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-vue-next' import { Loader2, Flashlight } from 'lucide-vue-next'
import { useQRScanner } from '@/composables/useQRScanner' import { useQRScanner } from '@/composables/useQRScanner'
interface Emits { interface Emits {

View file

@ -139,15 +139,13 @@ export const SERVICE_TOKENS = {
// Tasks services // Tasks services
TASK_SERVICE: Symbol('taskService'), TASK_SERVICE: Symbol('taskService'),
/** @deprecated Use TASK_SERVICE instead */
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
// Links services // Links services
SUBMISSION_SERVICE: Symbol('submissionService'), SUBMISSION_SERVICE: Symbol('submissionService'),
LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'), LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'),
// Nostr metadata services // Nostr transport (kind-21000 RPC over relays — LNbits backend)
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),
// Activities services (Nostr-native events + ticketing module) // Activities services (Nostr-native events + ticketing module)
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'), ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),

View file

@ -29,6 +29,14 @@ const messages: LocaleMessages = {
themeLight: 'Light', themeLight: 'Light',
themeDark: 'Dark', themeDark: 'Dark',
themeSystem: 'System', themeSystem: 'System',
colorScheme: 'Color scheme',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Countryside Castle',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Emerald Forest',
palette_lightgreen: 'Light Green',
palette_neobrut: 'Neo Brutalist',
palette_starrynight: 'Starry Night',
language: 'Language', language: 'Language',
currency: 'Currency', currency: 'Currency',
currencyComingSoon: 'Currency picker — coming soon', currencyComingSoon: 'Currency picker — coming soon',
@ -57,6 +65,10 @@ const messages: LocaleMessages = {
tomorrow: 'Tomorrow', tomorrow: 'Tomorrow',
thisWeek: 'This Week', thisWeek: 'This Week',
thisMonth: 'This Month', thisMonth: 'This Month',
myTickets: 'My tickets',
hosting: 'Hosting',
pastEvents: 'Past events',
past: 'Past',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -96,7 +108,15 @@ const messages: LocaleMessages = {
when: 'When', when: 'When',
tickets: 'Tickets', tickets: 'Tickets',
ticketsAvailable: '{count} tickets available', ticketsAvailable: '{count} tickets available',
ticketsOwned: 'You have {count} ticket | You have {count} tickets',
unlimitedTickets: 'Unlimited tickets',
buyTicket: 'Buy ticket',
buyAnotherTicket: 'Buy another ticket',
viewMyTickets: 'View in My Tickets',
soldOut: 'Sold Out', soldOut: 'Sold Out',
pastEvent: 'This event has already happened',
loginToBuyTickets: 'Log in to buy tickets',
logIn: 'Log in',
free: 'Free', free: 'Free',
}, },
tickets: { tickets: {

View file

@ -29,6 +29,14 @@ const messages: LocaleMessages = {
themeLight: 'Claro', themeLight: 'Claro',
themeDark: 'Oscuro', themeDark: 'Oscuro',
themeSystem: 'Sistema', themeSystem: 'Sistema',
colorScheme: 'Paleta',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Castillo Campestre',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Bosque Esmeralda',
palette_lightgreen: 'Verde Claro',
palette_neobrut: 'Neo Brutalista',
palette_starrynight: 'Noche Estrellada',
language: 'Idioma', language: 'Idioma',
currency: 'Moneda', currency: 'Moneda',
currencyComingSoon: 'Selector de moneda — próximamente', currencyComingSoon: 'Selector de moneda — próximamente',
@ -57,6 +65,10 @@ const messages: LocaleMessages = {
tomorrow: 'Mañana', tomorrow: 'Mañana',
thisWeek: 'Esta semana', thisWeek: 'Esta semana',
thisMonth: 'Este mes', thisMonth: 'Este mes',
myTickets: 'Mis boletos',
hosting: 'Organizo',
pastEvents: 'Eventos pasados',
past: 'Pasado',
}, },
categories: { categories: {
concert: 'Concierto', concert: 'Concierto',
@ -96,7 +108,15 @@ const messages: LocaleMessages = {
when: 'Cuándo', when: 'Cuándo',
tickets: 'Boletos', tickets: 'Boletos',
ticketsAvailable: '{count} boletos disponibles', ticketsAvailable: '{count} boletos disponibles',
ticketsOwned: 'Tienes {count} boleto | Tienes {count} boletos',
unlimitedTickets: 'Boletos ilimitados',
buyTicket: 'Comprar boleto',
buyAnotherTicket: 'Comprar otro boleto',
viewMyTickets: 'Ver en Mis boletos',
soldOut: 'Agotado', soldOut: 'Agotado',
pastEvent: 'Este evento ya pasó',
loginToBuyTickets: 'Inicia sesión para comprar boletos',
logIn: 'Iniciar sesión',
free: 'Gratis', free: 'Gratis',
}, },
tickets: { tickets: {

View file

@ -29,6 +29,14 @@ const messages: LocaleMessages = {
themeLight: 'Clair', themeLight: 'Clair',
themeDark: 'Sombre', themeDark: 'Sombre',
themeSystem: 'Système', themeSystem: 'Système',
colorScheme: 'Palette',
palette_catppuccin: 'Catppuccin',
palette_countrysidecastle: 'Château Champêtre',
palette_darkmatter: 'Dark Matter',
palette_emeraldforest: 'Forêt d\'Émeraude',
palette_lightgreen: 'Vert Clair',
palette_neobrut: 'Néo-Brutaliste',
palette_starrynight: 'Nuit Étoilée',
language: 'Langue', language: 'Langue',
currency: 'Devise', currency: 'Devise',
currencyComingSoon: 'Sélecteur de devise — bientôt disponible', currencyComingSoon: 'Sélecteur de devise — bientôt disponible',
@ -57,6 +65,10 @@ const messages: LocaleMessages = {
tomorrow: 'Demain', tomorrow: 'Demain',
thisWeek: 'Cette semaine', thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci', thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
hosting: 'J\'organise',
pastEvents: 'Événements passés',
past: 'Passé',
}, },
categories: { categories: {
concert: 'Concert', concert: 'Concert',
@ -96,7 +108,15 @@ const messages: LocaleMessages = {
when: 'Quand', when: 'Quand',
tickets: 'Billets', tickets: 'Billets',
ticketsAvailable: '{count} billets disponibles', ticketsAvailable: '{count} billets disponibles',
ticketsOwned: 'Vous avez {count} billet | Vous avez {count} billets',
unlimitedTickets: 'Billets illimités',
buyTicket: 'Acheter un billet',
buyAnotherTicket: 'Acheter un autre billet',
viewMyTickets: 'Voir dans Mes billets',
soldOut: 'Épuisé', soldOut: 'Épuisé',
pastEvent: 'Cet événement est déjà passé',
loginToBuyTickets: 'Connectez-vous pour acheter des billets',
logIn: 'Connexion',
free: 'Gratuit', free: 'Gratuit',
}, },
tickets: { tickets: {

View file

@ -28,6 +28,14 @@ export interface LocaleMessages {
themeLight: string themeLight: string
themeDark: string themeDark: string
themeSystem: string themeSystem: string
colorScheme: string
palette_catppuccin: string
palette_countrysidecastle: string
palette_darkmatter: string
palette_emeraldforest: string
palette_lightgreen: string
palette_neobrut: string
palette_starrynight: string
language: string language: string
currency: string currency: string
currencyComingSoon: string currencyComingSoon: string
@ -58,6 +66,10 @@ export interface LocaleMessages {
tomorrow: string tomorrow: string
thisWeek: string thisWeek: string
thisMonth: string thisMonth: string
myTickets: string
hosting: string
pastEvents: string
past: string
} }
categories: Record<string, string> categories: Record<string, string>
detail: { detail: {
@ -71,7 +83,15 @@ export interface LocaleMessages {
when: string when: string
tickets: string tickets: string
ticketsAvailable: string ticketsAvailable: string
ticketsOwned: string
unlimitedTickets: string
buyTicket: string
buyAnotherTicket: string
viewMyTickets: string
soldOut: string soldOut: string
pastEvent: string
loginToBuyTickets: string
logIn: string
free: string free: string
} }
tickets: { tickets: {

View file

@ -40,7 +40,12 @@ interface User {
username?: string username?: string
email?: string email?: string
pubkey?: string pubkey?: string
prvkey?: string // Nostr private key for user // The `prvkey` field was removed from this interface as the final step of
// phase-1 per aiolabs/lnbits#9 / design-questions Q1.2 Option (b). LNbits
// signs server-side via the NostrSigner abstraction (PR #26) and exposes
// `signer_type` instead of raw key material on /api/v1/auth. Bucket-B
// sign-sites (kind 1 / 4 / 5 / 7 / 31925 / 10003 / 1111 etc.) migrate to
// POST /api/v1/auth/sign-event (PR #29) in phase 2.
external_id?: string external_id?: string
extensions: string[] extensions: string[]
wallets: Wallet[] wallets: Wallet[]
@ -111,12 +116,29 @@ export class LnbitsAPI extends BaseService {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
// Try to surface FastAPI's `{"detail": "..."}` shape; fall back to raw
// body for non-JSON errors. Without this, every backend error renders
// as a generic "API request failed: <status>" and you can't distinguish
// "wrong endpoint" from "expired token" from "validation failure".
let detail: string = errorText
try {
const parsed = JSON.parse(errorText)
if (parsed && typeof parsed.detail === 'string') {
detail = parsed.detail
} else if (parsed && Array.isArray(parsed.detail)) {
// pydantic ValidationError: take the first msg
detail = parsed.detail[0]?.msg ?? errorText
}
} catch {
// body wasn't JSON; keep the raw text in `detail`
}
console.error('LNBits API Error:', { console.error('LNBits API Error:', {
endpoint,
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
errorText detail,
}) })
throw new Error(`API request failed: ${response.status} ${response.statusText}`) throw new Error(`LNbits ${endpoint} ${response.status}: ${detail || response.statusText}`)
} }
const data = await response.json() const data = await response.json()
@ -157,19 +179,21 @@ export class LnbitsAPI extends BaseService {
// First get basic user info from /auth // First get basic user info from /auth
const basicUser = await this.request<User>('/auth') const basicUser = await this.request<User>('/auth')
// Then get Nostr keys from /auth/nostr/me (this was working in main branch) // /auth/nostr/me used to return the user's prvkey for client-side signing;
// post-aiolabs/lnbits#9 phase-1 the server signs and the endpoint returns
// only the pubkey. We keep the call to merge the pubkey (which the basic
// /auth response also includes on the post-cascade server; this is the
// belt-and-suspenders fallback for older lnbits revisions until we ship a
// signer_type-aware client).
try { try {
const nostrUser = await this.request<User>('/auth/nostr/me') const nostrUser = await this.request<User>('/auth/nostr/me')
// Merge the data - basic user info + Nostr keys
return { return {
...basicUser, ...basicUser,
pubkey: nostrUser.pubkey, pubkey: nostrUser.pubkey,
prvkey: nostrUser.prvkey
} }
} catch (error) { } catch (error) {
console.warn('Failed to fetch Nostr keys, returning basic user info:', error) console.warn('Failed to fetch Nostr pubkey from /auth/nostr/me, returning basic user info:', error)
// Return basic user info without Nostr keys if the endpoint fails
return basicUser return basicUser
} }
} }
@ -185,12 +209,23 @@ export class LnbitsAPI extends BaseService {
} }
async updateProfile(data: Partial<User>): Promise<User> { async updateProfile(data: Partial<User>): Promise<User> {
return this.request<User>('/auth/update', { // aiolabs/lnbits PR #26 (gap-fill 869f67c3) wired
method: 'PUT', // _publish_nostr_metadata_event into PATCH /api/v1/auth
// (auth_api.py:546). The legacy PUT /auth/update route does not
// exist on the post-cascade server.
return this.request<User>('/auth', {
method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
} }
async getConversion(params: { from: string; to: string; amount: number }): Promise<Record<string, number>> {
return this.request<Record<string, number>>('/conversion', {
method: 'POST',
body: JSON.stringify({ from_: params.from, to: params.to, amount: params.amount }),
})
}
isAuthenticated(): boolean { isAuthenticated(): boolean {
return !!this.accessToken return !!this.accessToken
} }

View file

@ -4,6 +4,12 @@ export const LNBITS_CONFIG = {
// This should point to your LNBits instance // This should point to your LNBits instance
API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`, API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`,
// LNbits Nostr-transport server pubkey. The webapp encrypts its
// signed kind-21000 RPC events to this pubkey and listens for
// signed responses from it. Logged by the LNbits server at startup
// (`Nostr transport: starting with pubkey <hex>...`).
NOSTR_TRANSPORT_PUBKEY: import.meta.env.VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY || '',
// Whether to enable debug logging // Whether to enable debug logging
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true', DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',

108
src/lib/nostr/signing.ts Normal file
View file

@ -0,0 +1,108 @@
import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { getApiUrl, getAuthToken } from '@/lib/config/lnbits'
/**
* Uniform bucket-B signing helper. Bucket-B kinds (1, 3, 4, 5, 7, 1111,
* 1621, 1622, 10003, 30023, 31922, 31923, 31925) sign server-side via
* the `NostrSigner` ABC LocalSigner does it in-process, RemoteBunker
* routes via NIP-46 to nsecbunkerd. The webapp posts an unsigned event
* template and receives the signed event back.
*
* Wire shape: POST /api/v1/auth/sign-event
* - Cookie auth (cookie_access_token from login) OR Bearer auth
* (Authorization: Bearer <token>). The webapp uses Bearer.
* - CSRF: GET /auth/csrf-token issues an XSRF-TOKEN cookie + body
* token; we echo the token in X-CSRF-Token. We lazy-fetch once
* per page load and refresh on 403.
* - Body: {kind, created_at, tags, content}
* - Returns: fully-signed event {kind, created_at, tags, content,
* pubkey, id, sig}.
*
* Refs:
* - `~/dev/coordination/webapp-design-questions.md` Q3.3 (uniform
* helper); aiolabs/lnbits PR #29 (the endpoint); commit
* `9a300c1` (the prvkey-removal that this unblocks).
*/
let cachedCsrfToken: string | null = null
async function fetchCsrfToken(): Promise<string> {
const response = await fetch(getApiUrl('/auth/csrf-token'), {
method: 'GET',
credentials: 'include',
headers: getAuthHeaders(),
})
if (!response.ok) {
throw new Error(
`Failed to obtain CSRF token: ${response.status} ${response.statusText}`,
)
}
const data = await response.json()
if (typeof data?.csrf_token !== 'string') {
throw new Error('CSRF token response missing csrf_token field')
}
return data.csrf_token
}
async function getCsrfToken(forceRefresh = false): Promise<string> {
if (!forceRefresh && cachedCsrfToken) return cachedCsrfToken
cachedCsrfToken = await fetchCsrfToken()
return cachedCsrfToken
}
function getAuthHeaders(): Record<string, string> {
const token = getAuthToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function signOnce(
template: EventTemplate,
csrfToken: string,
): Promise<Response> {
return fetch(getApiUrl('/auth/sign-event'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
...getAuthHeaders(),
},
body: JSON.stringify({
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
}),
})
}
export async function signEventViaLnbits(
template: EventTemplate,
): Promise<NostrEvent> {
let csrfToken = await getCsrfToken()
let response = await signOnce(template, csrfToken)
// On CSRF rejection, refresh the token once and retry. The cached
// token outlives the cookie when the browser is restarted across
// an expired XSRF-TOKEN cookie; one retry covers that race.
if (response.status === 403) {
const body = await response.clone().text()
if (body.includes('CSRF')) {
csrfToken = await getCsrfToken(true)
response = await signOnce(template, csrfToken)
}
}
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`
try {
const parsed = await response.json()
if (typeof parsed?.detail === 'string') detail = parsed.detail
} catch {
// body wasn't JSON
}
throw new Error(`signEventViaLnbits: ${detail}`)
}
return (await response.json()) as NostrEvent
}

View file

@ -4,9 +4,10 @@ import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket, User } from 'lucide-vue-next' import { MapPin, Calendar, Ticket, User, CheckCircle2, History } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue' import BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale' import { useDateLocale } from '../composables/useDateLocale'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import type { Activity } from '../types/activity' import type { Activity } from '../types/activity'
const props = defineProps<{ const props = defineProps<{
@ -19,6 +20,9 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const { dateLocale } = useDateLocale() const { dateLocale } = useDateLocale()
const { paidCount } = useOwnedTickets()
const ownedCount = computed(() => paidCount(props.activity.id))
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
const a = props.activity const a = props.activity
@ -54,6 +58,13 @@ const placeholderBg = computed(() => {
const hue = hash % 360 const hue = hash % 360
return `hsl(${hue}, 40%, 85%)` return `hsl(${hue}, 40%, 85%)`
}) })
const isPast = computed(() => {
const a = props.activity
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
</script> </script>
<template> <template>
@ -117,6 +128,22 @@ const placeholderBg = computed(() => {
> >
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }} {{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge> </Badge>
<!-- Past badge shown when the activity has already ended.
Only relevant on the feed when the "Past events" filter
chip is toggled on (otherwise these cards aren't rendered);
on the detail page the card view isn't used. Suppressed
when a pending/rejected status badge is taking the same
slot that case is the creator's own past draft, which is
vanishingly rare and the status hint is more actionable. -->
<Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
<History class="w-3 h-3" />
{{ t('activities.filters.past', 'Past') }}
</Badge>
</div> </div>
<CardContent class="p-4 flex-1 flex flex-col gap-2"> <CardContent class="p-4 flex-1 flex flex-col gap-2">
@ -155,19 +182,38 @@ const placeholderBg = computed(() => {
<span class="truncate">{{ activity.location }}</span> <span class="truncate">{{ activity.location }}</span>
</div> </div>
<!-- Tickets available --> <!-- Tickets available. `available === undefined` means
unlimited capacity (no `tickets_available` tag was
published); show the price-only line in that case. -->
<div <div
v-if="activity.ticketInfo" v-if="activity.ticketInfo"
class="flex items-center gap-1.5 text-sm text-muted-foreground" class="flex items-center gap-1.5 text-sm text-muted-foreground"
> >
<Ticket class="w-3.5 h-3.5 shrink-0" /> <Ticket class="w-3.5 h-3.5 shrink-0" />
<span v-if="activity.ticketInfo.available > 0"> <span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }} {{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span> </span>
<span v-else class="text-destructive font-medium"> <span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }} {{ t('activities.detail.soldOut') }}
</span> </span>
</div> </div>
<!-- Owned tickets shown when the current user holds at
least one paid ticket for this activity. Sits next to
the availability line so the buyer can see at a glance
whether they've already bought in. -->
<div
v-if="ownedCount > 0"
class="flex items-center gap-1.5 text-sm text-primary"
>
<CheckCircle2 class="w-3.5 h-3.5 shrink-0" />
<span>
{{ t('activities.detail.ticketsOwned', { count: ownedCount }, ownedCount) }}
</span>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -16,8 +16,8 @@ import { Button } from '@/components/ui/button'
import { CalendarPlus } from 'lucide-vue-next' import { CalendarPlus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService' import type { TicketApiService } from '../services/TicketApiService'
import type { CalendarTimeEvent } from '../types/nip52' import type { CreateEventRequest } from '../types/ticket'
import type { ActivityCategory } from '../types/category' import type { ActivityCategory } from '../types/category'
import CategorySelector from './CategorySelector.vue' import CategorySelector from './CategorySelector.vue'
import LocationPicker from './LocationPicker.vue' import LocationPicker from './LocationPicker.vue'
@ -67,56 +67,64 @@ const form = useForm({
const isFormValid = computed(() => form.meta.value.valid) const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => { const onSubmit = form.handleSubmit(async (values) => {
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE) const ticketApi = tryInjectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
if (!nostrService) { if (!ticketApi) {
toast.error('Activities service not available') toast.error('Activities service not available')
return return
} }
const signingKey = currentUser.value?.prvkey const invoiceKey = currentUser.value?.wallets?.[0]?.inkey
if (!signingKey) { if (!invoiceKey) {
toast.error('Signing key not available. Please log in again.') toast.error('No wallet available. Please log in first.')
return return
} }
isPublishing.value = true isPublishing.value = true
try { try {
// Build unix timestamps // Compose ISO 8601 datetime strings the events extension parses.
const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000) const startIso = `${values.startDate}T${values.startTime}`
let endTimestamp: number | undefined const endIso =
if (values.endDate && values.endTime) { values.endDate && values.endTime
endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000) ? `${values.endDate}T${values.endTime}`
: undefined
// Fold summary + description into `info` since the events extension
// CreateEventRequest has no separate summary field.
const info =
values.summary && values.description
? `${values.summary}\n\n${values.description}`
: values.description || values.summary || ''
// Ticket-less activity amount_tickets and price_per_ticket both
// pinned at 0 (events extension treats 0 as "unlimited / not
// ticketed" per models.py:45-46). Server-side `signer.sign_event`
// produces the kind-31922 calendar event and publishes via the
// operator's configured relays no webapp signing path needed.
const eventData: CreateEventRequest = {
name: values.title,
info,
event_start_date: startIso,
event_end_date: endIso,
location: location.value || null,
banner: values.image || null,
categories: selectedCategories.value,
amount_tickets: 0,
price_per_ticket: 0,
} }
// Generate a unique d-tag await ticketApi.createEvent(eventData, invoiceKey)
const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const eventData: Partial<CalendarTimeEvent> = { // Approval workflow caveat: non-admin users on instances with
dTag, // `auto_approve=false` (the default) land in the proposal queue;
title: values.title, // their event isn't published to relays until an admin approves.
summary: values.summary || undefined, // Admins-and-auto-approve-on instances publish immediately.
content: values.description, toast.success('Activity created!')
image: values.image || undefined,
start: startTimestamp,
end: endTimestamp,
startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: location.value || undefined,
hashtags: selectedCategories.value,
}
const result = await nostrService.publishCalendarEvent(eventData, signingKey)
if (result.success > 0) {
toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`)
emit('created') emit('created')
handleClose() handleClose()
} else {
toast.error('Failed to publish to any relay')
}
} catch (err) { } catch (err) {
console.error('Failed to publish activity:', err) console.error('Failed to create activity:', err)
toast.error(err instanceof Error ? err.message : 'Failed to publish activity') toast.error(err instanceof Error ? err.message : 'Failed to create activity')
} finally { } finally {
isPublishing.value = false isPublishing.value = false
} }

View file

@ -24,6 +24,9 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Bell, ChevronDown } from 'lucide-vue-next'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
Select, Select,
@ -32,12 +35,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Calendar, Loader2, MapPin, AlertCircle } from 'lucide-vue-next' import { Calendar, Loader2, MapPin, AlertCircle, Zap } from 'lucide-vue-next'
import { toastService } from '@/core/services/ToastService' import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue' import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue' import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue'
import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
@ -87,6 +91,28 @@ function foldDateTime(date: string, time: string): string {
return time ? `${date}T${time}` : date return time ? `${date}T${time}` : date
} }
// Stamp the form's wall-clock datetime with the user's local UTC offset
// before sending it to the LNbits events backend. Without this, the
// backend's `_to_unix` (nostr_publisher.py) treats a naive ISO string
// as UTC, so e.g. "08:00" entered in CEST gets stored as 08:00 UTC and
// the NIP-52 `start` tag is off by the user's offset on the relay
// the detail page then renders it +offset (08:00 10:00 in CEST).
// Preserving the user's intended wall-clock means stamping it here.
// Date-only values (no "T") pass through unchanged.
function withLocalTzOffset(value: string): string {
if (!value || !value.includes('T')) return value
// The form's "YYYY-MM-DDTHH:MM" is parsed by JS Date as local time;
// getTimezoneOffset() returns minutes west of UTC, so negate it.
const offMin = -new Date(value).getTimezoneOffset()
const sign = offMin >= 0 ? '+' : '-'
const abs = Math.abs(offMin)
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
const mm = String(abs % 60).padStart(2, '0')
// Include `:00` seconds for compatibility with older Python
// `datetime.fromisoformat` (pre-3.11 won't accept "HH:MM+HH:MM").
return `${value}:00${sign}${hh}:${mm}`
}
const formSchema = toTypedSchema( const formSchema = toTypedSchema(
z z
.object({ .object({
@ -98,13 +124,19 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''), event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
currency: z.string().default("sat"), currency: z.string().default("sat"),
allow_fiat: z.boolean().default(false),
fiat_currency: z.string().default("USD"),
amount_tickets: z.number().min(0).max(100000).default(0), amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0), price_per_ticket: z.number().min(0).default(0),
email_notifications: z.boolean().default(false),
nostr_notifications: z.boolean().default(false),
notification_subject: z.string().max(200).default(''),
notification_body: z.string().max(2000).default(''),
}) })
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time // End must not precede start. Compare on the folded date+time
// string so equal-date / later-time is enforced too. // string so equal-date / later-time is enforced too.
if (!v.event_end_date) return if (v.event_end_date) {
const start = foldDateTime(v.event_start_date, v.event_start_time) const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time) const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) { if (start && end && end < start) {
@ -114,6 +146,19 @@ const formSchema = toTypedSchema(
message: 'End must be on or after start', message: 'End must be on or after start',
}) })
} }
}
// When the price is in sats and the organizer also accepts fiat,
// they MUST choose a settle currency. Other price denominations
// mirror themselves into fiat_currency automatically. The events
// extension uses 'sat' and 'sats' interchangeably accept both.
const isSat = v.currency === 'sat' || v.currency === 'sats'
if (v.allow_fiat && isSat && !v.fiat_currency) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['fiat_currency'],
message: 'Pick a fiat currency for buyers paying by card',
})
}
}) })
) )
@ -128,8 +173,14 @@ const form = useForm({
event_end_time: '', event_end_time: '',
location: '', location: '',
currency: 'sat', currency: 'sat',
allow_fiat: false,
fiat_currency: 'USD',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 0, price_per_ticket: 0,
email_notifications: false,
nostr_notifications: false,
notification_subject: '',
notification_body: '',
} }
}) })
@ -138,8 +189,11 @@ interface BannerImage extends UploadedImage {
} }
const bannerImages = ref<BannerImage[]>([]) const bannerImages = ref<BannerImage[]>([])
// Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM]" back // Inverse of foldDateTime: split a stored "YYYY-MM-DD[THH:MM[:SS][±HH:MM]]"
// into separate date + time pieces for the form inputs. // back into separate date + time pieces for the form inputs. The
// time slice trims to "HH:MM" so any seconds + offset suffix added by
// withLocalTzOffset on submit drops cleanly the user sees the same
// wall-clock they originally entered when re-editing.
function splitDateTime(value: string | null | undefined): { date: string; time: string } { function splitDateTime(value: string | null | undefined): { date: string; time: string } {
if (!value) return { date: '', time: '' } if (!value) return { date: '', time: '' }
const [date, time = ''] = value.split('T') const [date, time = ''] = value.split('T')
@ -149,6 +203,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
// When `true`, suppress the auto-mirror watcher so we don't clobber an // When `true`, suppress the auto-mirror watcher so we don't clobber an
// edit-mode population with start-date side effects mid-setValues. // edit-mode population with start-date side effects mid-setValues.
const isPopulating = ref(false) const isPopulating = ref(false)
const notificationsOpen = ref(false)
// Auto-mirror end date to start: when the user picks a start date, // Auto-mirror end date to start: when the user picks a start date,
// surface that same date in the end-date picker so a one-day event // surface that same date in the end-date picker so a one-day event
@ -188,8 +243,14 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time, event_end_time: end.time,
location: event.location ?? '', location: event.location ?? '',
currency: event.currency ?? 'sat', currency: event.currency ?? 'sat',
allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0, amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0, price_per_ticket: event.price_per_ticket ?? 0,
email_notifications: event.extra?.email_notifications ?? false,
nostr_notifications: event.extra?.nostr_notifications ?? false,
notification_subject: event.extra?.notification_subject ?? '',
notification_body: event.extra?.notification_body ?? '',
}) })
selectedCategories.value = [...(event.categories ?? [])] selectedCategories.value = [...(event.categories ?? [])]
if (event.banner) { if (event.banner) {
@ -267,9 +328,8 @@ const onSubmit = form.handleSubmit(async (formValues) => {
try { try {
const eventData: CreateEventRequest = { const eventData: CreateEventRequest = {
name: formValues.name, name: formValues.name,
event_start_date: foldDateTime( event_start_date: withLocalTzOffset(
formValues.event_start_date, foldDateTime(formValues.event_start_date, formValues.event_start_time)
formValues.event_start_time
), ),
} }
if (!isEditMode.value) { if (!isEditMode.value) {
@ -281,9 +341,8 @@ const onSubmit = form.handleSubmit(async (formValues) => {
// Optional fields only include if provided // Optional fields only include if provided
if (formValues.info) eventData.info = formValues.info if (formValues.info) eventData.info = formValues.info
if (formValues.event_end_date) { if (formValues.event_end_date) {
eventData.event_end_date = foldDateTime( eventData.event_end_date = withLocalTzOffset(
formValues.event_end_date, foldDateTime(formValues.event_end_date, formValues.event_end_time)
formValues.event_end_time
) )
} }
if (formValues.location) eventData.location = formValues.location if (formValues.location) eventData.location = formValues.location
@ -295,10 +354,29 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null eventData.banner = null
} }
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends so a truefalse flip propagates on edit;
// fiat_currency only sends when fiat is on (no point persisting a
// rail-currency the backend won't use).
eventData.allow_fiat = formValues.allow_fiat
if (formValues.allow_fiat && formValues.fiat_currency) {
eventData.fiat_currency = formValues.fiat_currency
}
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
// Notification config goes inside the `extra` envelope. On edit
// overlay onto the existing event.extra so unrelated fields the
// LNbits admin UI sets (promo_codes, conditional, min_tickets)
// survive the round-trip.
eventData.extra = {
...(props.event?.extra ?? {}),
email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body,
}
if (isEditMode.value) { if (isEditMode.value) {
if (!props.onUpdateEvent || !props.event?.id) { if (!props.onUpdateEvent || !props.event?.id) {
toastService.error('Update handler missing') toastService.error('Update handler missing')
@ -524,7 +602,15 @@ const handleOpenChange = (open: boolean) => {
/> />
</div> </div>
<!-- Tickets (optional, visible) --> <!-- Pricing -->
<div class="space-y-3">
<div>
<p class="text-sm font-medium">Pricing</p>
<p class="text-xs text-muted-foreground">
Set what buyers see. Lightning charges happen in sats;
fiat amounts convert at checkout using current rates.
</p>
</div>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets"> <FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem> <FormItem>
@ -550,7 +636,7 @@ const handleOpenChange = (open: boolean) => {
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem> <FormItem>
<FormLabel>Currency</FormLabel> <FormLabel>Price currency</FormLabel>
<FormControl> <FormControl>
<Select v-bind="componentField"> <Select v-bind="componentField">
<SelectTrigger> <SelectTrigger>
@ -567,6 +653,91 @@ const handleOpenChange = (open: boolean) => {
</FormItem> </FormItem>
</FormField> </FormField>
</div> </div>
</div>
<!-- Payment methods -->
<div class="space-y-3">
<div>
<p class="text-sm font-medium">Payment methods</p>
<p class="text-xs text-muted-foreground">
Lightning is always available. Enable fiat to also accept
card and bank payments through your configured provider.
</p>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Zap class="w-4 h-4" />
<span>Lightning always on</span>
</div>
<FiatToggleField
allow-fiat-field="allow_fiat"
fiat-currency-field="fiat_currency"
:denomination="form.values.currency ?? 'sat'"
:available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
:disabled="isLoading"
/>
</div>
<!-- Ticket buyer notifications (collapsible). The backend
sends email + NIP-04 Nostr DM confirmations on
payment when these are on. notification_subject /
body let the organizer customize the message; empty
strings fall back to the extension's defaults. -->
<Collapsible v-model:open="notificationsOpen">
<CollapsibleTrigger as-child>
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
<span class="flex items-center gap-1.5">
<Bell class="w-4 h-4" />
Buyer notifications
</span>
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': notificationsOpen }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="space-y-3 pt-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField v-slot="{ value, handleChange }" name="email_notifications">
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3">
<FormLabel class="text-sm">Email confirmation</FormLabel>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="nostr_notifications">
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3">
<FormLabel class="text-sm">Nostr DM confirmation</FormLabel>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="notification_subject">
<FormItem>
<FormLabel class="text-sm">Subject</FormLabel>
<FormControl>
<Input placeholder="Your ticket for {event_name}" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">Leave blank to use the default.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="notification_body">
<FormItem>
<FormLabel class="text-sm">Body</FormLabel>
<FormControl>
<Textarea placeholder="See you there!" rows="3" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">
Leave blank to use the default. The ticket link is appended automatically.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</CollapsibleContent>
</Collapsible>
<!-- Actions --> <!-- Actions -->
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">

View file

@ -1,12 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { onUnmounted } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useTicketPurchase } from '../composables/useTicketPurchase' import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting' import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry,
} from '@/modules/base/components/payments/PaymentMethodSelector.vue'
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
interface Props { interface Props {
event: { event: {
@ -14,6 +22,9 @@ interface Props {
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
/** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean
fiat_currency?: string
} }
isOpen: boolean isOpen: boolean
} }
@ -30,6 +41,7 @@ const {
isLoading, isLoading,
error, error,
paymentHash, paymentHash,
paymentRequest,
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
@ -37,27 +49,198 @@ const {
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
purchaseTicketForEvent, purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet, handleOpenLightningWallet,
resetPaymentState, resetPaymentState,
cleanup, cleanup,
ticketQRCode, purchasedTicketIds,
purchasedTicketId,
showTicketQR showTicketQR
} = useTicketPurchase() } = useTicketPurchase()
const MAX_QUANTITY = 10
const quantity = ref(1)
const copiedInvoice = ref(false)
function decreaseQuantity() {
if (quantity.value > 1) quantity.value -= 1
}
function increaseQuantity() {
if (quantity.value < MAX_QUANTITY) quantity.value += 1
}
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
async function copyInvoice() {
if (!paymentRequest.value) return
try {
await navigator.clipboard.writeText(paymentRequest.value)
copiedInvoice.value = true
setTimeout(() => (copiedInvoice.value = false), 1500)
} catch {
// Older browsers / insecure contexts; the Open-in-wallet button
// still works as a fallback.
}
}
const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion()
const selectedMethodId = ref<string>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false)
const fiatError = ref<string | null>(null)
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats',
)
// Lightning-button badge: when the price is denominated in fiat, show
// the live sat equivalent so the buyer knows roughly what their wallet
// will be charged. Best-effort silent if the conversion fails.
const lightningSats = ref<number | null>(null)
watch(
() => [props.event.currency, props.event.price_per_ticket, props.isOpen] as const,
async ([cur, amt, open]) => {
if (!open || !amt || cur === 'sat' || cur === 'sats') {
lightningSats.value = null
return
}
lightningSats.value = await convert(amt as number, cur as string, 'sat')
},
{ immediate: true },
)
function iconFor(hint: 'card' | 'bank' | 'wallet') {
if (hint === 'bank') return Landmark
if (hint === 'wallet') return Wallet
return CreditCard
}
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const lightning: PaymentMethodEntry = {
id: 'lightning',
rail: 'lightning',
label: 'Lightning',
icon: Zap,
available: true,
badge:
!isPriceInSats.value && lightningSats.value
? `${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined,
}
if (!props.event.allow_fiat) return [lightning]
if (providers.value.length > 0) {
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
const meta = providerMeta(id)
return {
id: `fiat:${id}`,
rail: 'fiat',
provider: id,
label: meta.label,
icon: iconFor(meta.icon),
available: true,
}
})
return [lightning, ...fiatRails]
}
// Degenerate fallback allow_fiat is on but the buyer's session
// can't enumerate the organizer's providers. Show a generic Card
// button and let the backend pick a default at request time.
return [
lightning,
{
id: 'fiat',
rail: 'fiat',
label: 'Card',
icon: CreditCard,
available: true,
},
]
})
const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
)
async function handlePurchase() { async function handlePurchase() {
if (!canPurchase.value) return if (!canPurchase.value) return
fiatError.value = null
const method = selectedMethod.value
if (!method) return
// Lightning path: the composable just creates the invoice + starts
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in
// external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here.
if (method.rail === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id) await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
} }
return
}
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
// API directly with the chosen provider, then redirect the buyer to
// the provider's checkout URL. Payment confirmation happens via
// webhook on the backend and shows up next time the buyer reloads
// MyTickets.
try {
isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const currentUser = (lnbitsAPI?.currentUser?.value) || null
const userId = currentUser?.id
if (!userId) {
fiatError.value = 'Missing user id'
return
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken,
{
paymentMethod: 'fiat',
fiatProvider: method.provider,
quantity: quantity.value,
},
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
return
}
fiatRedirectUrl.value = invoice.fiatPaymentRequest
fiatProviderLabel.value = invoice.fiatProvider
? providerMeta(invoice.fiatProvider).label
: method.label
} catch (err) {
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
} finally {
isFiatPending.value = false
}
}
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
} }
function handleClose() { function handleClose() {
emit('update:isOpen', false) emit('update:isOpen', false)
resetPaymentState() resetPaymentState()
selectedMethodId.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null
quantity.value = 1
copiedInvoice.value = false
} }
onUnmounted(() => { onUnmounted(() => {
@ -67,14 +250,20 @@ onUnmounted(() => {
<template> <template>
<Dialog :open="isOpen" @update:open="handleClose"> <Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[425px]"> <DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle class="flex items-center gap-2"> <DialogTitle class="flex items-center gap-2">
<CreditCard class="w-5 h-5" /> <CreditCard class="w-5 h-5" />
Purchase Ticket {{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }} Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
</span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -149,93 +338,233 @@ onUnmounted(() => {
<CreditCard class="w-4 h-4 text-muted-foreground" /> <CreditCard class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Payment Details:</span> <span class="text-sm font-medium">Payment Details:</span>
</div> </div>
<!-- Quantity selector backend caps at 10. One invoice for
the whole purchase, one ticket row representing N seats. -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7"
:disabled="quantity <= 1"
@click="decreaseQuantity"
>
<Minus class="h-3.5 w-3.5" />
</Button>
<span class="w-6 text-center text-sm font-medium">{{ quantity }}</span>
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7"
:disabled="quantity >= MAX_QUANTITY"
@click="increaseQuantity"
>
<Plus class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="space-y-1"> <div class="space-y-1">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground">Event:</span> <span class="text-sm text-muted-foreground">Event:</span>
<span class="text-sm font-medium">{{ event.name }}</span> <span class="text-sm font-medium">{{ event.name }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-sm text-muted-foreground">Price:</span> <span class="text-sm text-muted-foreground">
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span> {{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span>
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
</div> </div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="totalPrice"
from="sat"
:to="event.fiat_currency"
prefix="Equivalent ~"
suffix=" if paid in fiat"
/>
</div> </div>
</div> </div>
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg"> <!-- Payment method selector (only shown when fiat is enabled
{{ error }} on the event). Buttons surface one per configured fiat
provider so "Stripe" / "PayPal" / "Square" stand alongside
Lightning rather than collapsing into a single "Fiat"
catch-all. Hidden entirely for Lightning-only events to
keep the dialog uncluttered. -->
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails.
Live rates shown are estimates; the exact sat amount locks
in when you start checkout.
</p>
<PaymentMethodSelector
:methods="paymentMethods"
:model-value="selectedMethodId"
@update:model-value="selectedMethodId = $event"
/>
</div>
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{{ error || fiatError }}
</div>
<!-- Fiat checkout panel shown after a successful fiat
POST when we have a provider URL to redirect to. -->
<div v-if="fiatRedirectUrl" class="space-y-3">
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
<p class="text-xs text-muted-foreground">
Opens the provider's checkout in a new tab. Your ticket
appears in My Tickets once the payment settles.
</p>
</div>
<Button @click="openFiatCheckout" class="w-full">
<ExternalLink class="w-4 h-4 mr-2" />
Open {{ fiatProviderLabel }} checkout
</Button>
</div> </div>
<Button <Button
v-else
@click="handlePurchase" @click="handlePurchase"
:disabled="isLoading || !canPurchase" :disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full" class="w-full"
> >
<span v-if="isLoading" class="animate-spin mr-2"></span> <Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2"> <template v-else-if="selectedMethod?.rail === 'fiat'">
<Zap class="w-4 h-4" /> <CreditCard class="w-4 h-4 mr-2" />
Pay with Wallet Continue to {{ selectedMethod.label }} checkout
</template>
<template v-else>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</template>
</Button>
</div>
<!-- Lightning invoice restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the
LNbits wallet, scan with an external wallet, or hand off
via lightning: URI on mobile. Polling fires whichever
path the buyer takes. -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 space-y-4">
<div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground">
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
</p>
</div>
<!-- QR + amount + copy/open buttons (restaurant
OrderInvoiceCard pattern). The QR keeps a white background
regardless of theme so phone cameras parse it reliably. -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<img
v-if="qrCode"
:src="qrCode"
alt="Lightning payment QR code"
class="block h-56 w-56 sm:h-64 sm:w-64"
/>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-sm font-semibold text-primary">
{{ formatEventPrice(totalPrice, event.currency) }}
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
({{ quantity }} tickets)
</span> </span>
<span v-else>Generate Payment Request</span> </span>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
class="flex-1 font-mono text-xs"
@click="copyInvoice"
>
<Check v-if="copiedInvoice" class="mr-2 h-3.5 w-3.5" />
<Copy v-else class="mr-2 h-3.5 w-3.5" />
{{ copiedInvoice ? 'Copied' : 'Copy' }}
</Button>
<Button
variant="default"
size="sm"
class="flex-1 text-xs"
@click="handleOpenLightningWallet"
>
<Zap class="mr-2 h-3.5 w-3.5" />
Open in wallet
</Button> </Button>
</div> </div>
<!-- Payment QR Code and Status -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold">Payment Required</h3>
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
Processing payment with your wallet...
</p>
<p v-else class="text-sm text-muted-foreground">
Scan the QR code with your Lightning wallet to complete the payment
</p>
</div> </div>
<div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4"> <!-- LNbits-wallet pay button only shown when the buyer is
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" /> logged in with a funded wallet. Same screen as the QR so
</div> the user can pick either path without having to back out
of the dialog. -->
<div class="space-y-3 w-full"> <Button
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full"> v-if="hasWalletWithBalance"
<Wallet class="w-4 h-4 mr-2" /> size="lg"
Open in Lightning Wallet class="w-full"
:disabled="isPayingWithWallet"
@click="payCurrentInvoiceWithWallet"
>
<Loader2 v-if="isPayingWithWallet" class="mr-2 h-4 w-4 animate-spin" />
<Wallet v-else class="mr-2 h-4 w-4" />
{{ isPayingWithWallet ? 'Paying…' : 'Pay from my LNbits wallet' }}
</Button> </Button>
<p
v-else-if="userWallets.length > 0"
class="text-center text-xs text-muted-foreground"
>
Your LNbits wallet is empty pay with an external wallet
using the QR or "Open in wallet" above.
</p>
<div v-if="isPaymentPending" class="text-center space-y-2"> <div v-if="isPaymentPending" class="text-center space-y-1">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div> <div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }} Waiting for payment
</span> </span>
</div> </div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Payment will be confirmed automatically once received Confirmation lands automatically no need to refresh.
</p> </p>
</div> </div>
</div> </div>
</div>
<!-- Ticket QR Code (After Successful Purchase) --> <!-- Success state. QRs live in My Tickets no need to
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4"> pre-render them here; this view's job is to confirm the
<div class="text-center space-y-2"> purchase landed and route the buyer to where they actually
<h3 class="text-lg font-semibold text-green-600">Ticket Purchased Successfully!</h3> interact with their tickets. -->
<p class="text-sm text-muted-foreground"> <div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
Your ticket has been purchased and is now available in your tickets area.
</p>
</div>
<div class="bg-muted/50 rounded-lg p-4 w-full">
<div class="text-center space-y-3">
<div class="flex justify-center"> <div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" /> <Ticket class="w-12 h-12 text-green-600" />
</div> </div>
<div> <div class="text-center space-y-2">
<p class="text-sm font-medium">Ticket ID</p> <h3 class="text-lg font-semibold text-green-600">
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1"> {{ purchasedTicketIds.length > 1
<p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p> ? `${purchasedTicketIds.length} tickets purchased!`
</div> : 'Ticket purchased!' }}
</div> </h3>
</div> <p class="text-sm text-muted-foreground">
<span v-if="purchasedTicketIds.length > 1">
Each attendee gets their own scannable QR in My Tickets
hand them out independently for the door scan.
</span>
<span v-else>
Your ticket is now in My Tickets.
</span>
</p>
</div> </div>
<div class="space-y-3 w-full"> <div class="space-y-3 w-full">

View file

@ -8,6 +8,7 @@ import type { TicketedEvent } from '../types/ticket'
import { ticketedEventToActivity } from '../types/activity' import { ticketedEventToActivity } from '../types/activity'
import { useActivitiesStore } from '../stores/activities' import { useActivitiesStore } from '../stores/activities'
import { useActivityFilters } from './useActivityFilters' import { useActivityFilters } from './useActivityFilters'
import { useOwnedTickets } from './useOwnedTickets'
/** /**
* Main composable for activities discovery. * Main composable for activities discovery.
@ -17,6 +18,7 @@ export function useActivities() {
const store = useActivitiesStore() const store = useActivitiesStore()
const filters = useActivityFilters() const filters = useActivityFilters()
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const { ownedActivityIds } = useOwnedTickets()
const isSubscribed = ref(false) const isSubscribed = ref(false)
const subscriptionError = ref<string | null>(null) const subscriptionError = ref<string | null>(null)
@ -70,7 +72,10 @@ export function useActivities() {
const all = store.activities.sort( const all = store.activities.sort(
(a, b) => a.startDate.getTime() - b.startDate.getTime() (a, b) => a.startDate.getTime() - b.startDate.getTime()
) )
return filters.applyFilters(all) const filtered = filters.applyFilters(all)
if (!filters.onlyOwnedTickets.value) return filtered
const owned = ownedActivityIds.value
return filtered.filter(a => owned.has(a.id))
}) })
/** /**

View file

@ -32,18 +32,24 @@ export function useActivityDetail(activityId: string) {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
// Subscribe and wait for this specific event // Scope both the subscription and the one-shot query to this
// activity's d-tag. Without this scope, the query asks every
// relay for every kind-31922/31923 event and races a 5s timeout
// to find ours — on a cold page refresh that race is often lost
// even when the activity is reachable.
const detailFilters = { dTags: [activityId] }
unsubscribe = nostrService.subscribeToCalendarEvents( unsubscribe = nostrService.subscribeToCalendarEvents(
(incoming) => { (incoming) => {
store.upsertActivity(incoming) store.upsertActivity(incoming)
if (incoming.id === activityId) { if (incoming.id === activityId) {
isLoading.value = false isLoading.value = false
} }
} },
detailFilters
) )
// Also do a one-shot query const results = await nostrService.queryCalendarEvents(detailFilters)
const results = await nostrService.queryCalendarEvents()
store.upsertActivities(results) store.upsertActivities(results)
// If we still don't have it after query, stop loading // If we still don't have it after query, stop loading

View file

@ -15,6 +15,28 @@ export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal) const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([]) const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined) const selectedDate = ref<Date | undefined>(undefined)
/**
* When true, the feed is narrowed to activities the current user
* holds at least one paid ticket for. Crossed with the
* `ownedActivityIds` set from useOwnedTickets in useActivities
* (this composable stays free of ticket fetching).
*/
const onlyOwnedTickets = ref(false)
/**
* When true, the feed is narrowed to activities the current user
* is hosting (organizer pubkey matches the signed-in user, or the
* row is a local LNbits draft of theirs). Reads `activity.isMine`
* which `useActivities.tagOwnership()` populates.
*/
const onlyHosting = ref(false)
/**
* When false (default), activities that have already ended are
* hidden from the feed. Toggling on includes them so the user can
* browse past events. The date-picker overrides this picking a
* specific past date shows that day's activities regardless,
* mirroring how it overrides the temporal pills.
*/
const showPast = ref(false)
const filters = computed<ActivityFilters>(() => ({ const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value, temporal: temporal.value,
@ -27,7 +49,9 @@ export function useActivityFilters() {
function applyFilters(activities: Activity[]): Activity[] { function applyFilters(activities: Activity[]): Activity[] {
let result = activities let result = activities
// Specific date filter (from DatePickerStrip) takes priority over temporal // Specific date filter (from DatePickerStrip) takes priority over
// temporal. Picking a date also bypasses the past/upcoming split
// so the user can browse activities for any day they choose.
if (selectedDate.value) { if (selectedDate.value) {
const dayStart = startOfDay(selectedDate.value) const dayStart = startOfDay(selectedDate.value)
const dayEnd = endOfDay(selectedDate.value) const dayEnd = endOfDay(selectedDate.value)
@ -38,6 +62,16 @@ export function useActivityFilters() {
} else { } else {
// Temporal filter // Temporal filter
result = applyTemporalFilter(result, temporal.value) result = applyTemporalFilter(result, temporal.value)
// Past/upcoming split — the chip narrows to one side of "now",
// mirroring the "My tickets" / "Hosting" mental model. Default
// (showPast=false) is upcoming-only; toggling on flips to
// past-only. Composes with temporal pills: "This Week" +
// showPast=true shows only the days already passed this week.
const now = new Date()
result = result.filter(a => {
const activityEnd = a.endDate ?? a.startDate
return showPast.value ? activityEnd < now : activityEnd >= now
})
} }
// Category filter // Category filter
@ -47,6 +81,13 @@ export function useActivityFilters() {
) )
} }
// Hosting filter — activities the signed-in user organizes.
// Read off `activity.isMine` which `useActivities.tagOwnership()`
// populates from organizer-pubkey match + LNbits drafts.
if (onlyHosting.value) {
result = result.filter(a => a.isMine === true)
}
return result return result
} }
@ -81,12 +122,30 @@ export function useActivityFilters() {
temporal.value = DEFAULT_FILTERS.temporal temporal.value = DEFAULT_FILTERS.temporal
selectedCategories.value = [] selectedCategories.value = []
selectedDate.value = undefined selectedDate.value = undefined
onlyOwnedTickets.value = false
onlyHosting.value = false
showPast.value = false
}
function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value
}
function toggleHosting() {
onlyHosting.value = !onlyHosting.value
}
function togglePast() {
showPast.value = !showPast.value
} }
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
temporal.value !== 'all' || temporal.value !== 'all' ||
selectedCategories.value.length > 0 || selectedCategories.value.length > 0 ||
selectedDate.value !== undefined selectedDate.value !== undefined ||
onlyOwnedTickets.value ||
onlyHosting.value ||
showPast.value
) )
return { return {
@ -94,6 +153,9 @@ export function useActivityFilters() {
temporal, temporal,
selectedCategories, selectedCategories,
selectedDate, selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
filters, filters,
hasActiveFilters, hasActiveFilters,
@ -103,6 +165,9 @@ export function useActivityFilters() {
selectDate, selectDate,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters, resetFilters,
} }
} }

View file

@ -1,7 +1,8 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools' import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
/** /**
* NIP-51 Bookmarks (kind 10003) for saving favorite activities. * NIP-51 Bookmarks (kind 10003) for saving favorite activities.
@ -89,7 +90,7 @@ export function useBookmarks() {
* Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list. * Toggle bookmark for an activity. Publishes updated NIP-51 bookmark list.
*/ */
async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) { async function toggleBookmark(activityKind: number, pubkey: string, dTag: string) {
if (!isAuthenticated.value || !currentUser.value?.prvkey) return if (!isAuthenticated.value || !currentUser.value?.pubkey) return
const coord = `${activityKind}:${pubkey}:${dTag}` const coord = `${activityKind}:${pubkey}:${dTag}`
const newCoords = new Set(state.value.bookmarkedCoords) const newCoords = new Set(state.value.bookmarkedCoords)
@ -110,8 +111,13 @@ export function useBookmarks() {
tags, tags,
} }
const signingKey = hexToUint8Array(currentUser.value.prvkey) let signedEvent: NostrEvent
const signedEvent = finalizeEvent(template, signingKey) try {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useBookmarks] signEventViaLnbits failed:', err)
return
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB) const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return if (!relayHub) return
@ -147,10 +153,3 @@ export function useBookmarks() {
} }
} }
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}

View file

@ -0,0 +1,127 @@
import { computed, ref, watch } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { ActivityTicket } from '../types/ticket'
/**
* Module-level singleton: owned-ticket lookup keyed by activity id
* (== LNbits event id == NIP-52 d-tag, all the same string by
* extension contract). Lives at module scope so every <ActivityCard>
* + the detail page + the feed filter share ONE underlying fetch
* instead of each instance hitting the API.
*
* Auto-loads on first use after auth is ready, and re-loads when
* the current user changes (login/logout). Consumers that mutate the
* user's ticket set (e.g. a successful purchase) call `refresh()`
* directly so every surface reading this composable updates
* atomically.
*/
const tickets = ref<ActivityTicket[]>([])
const isLoading = ref(false)
const error = ref<Error | null>(null)
let hasAutoLoaded = false
let lastLoadedUserId: string | null = null
async function fetchTickets(): Promise<void> {
const { isAuthenticated, currentUser } = useAuth()
if (!isAuthenticated.value || !currentUser.value) {
tickets.value = []
lastLoadedUserId = null
return
}
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
isLoading.value = true
error.value = null
try {
tickets.value = await ticketApi.fetchUserTickets(currentUser.value.id, accessToken)
lastLoadedUserId = currentUser.value.id
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
tickets.value = []
} finally {
isLoading.value = false
}
}
const ticketsByActivity = computed<Map<string, ActivityTicket[]>>(() => {
const m = new Map<string, ActivityTicket[]>()
for (const ticket of tickets.value) {
const existing = m.get(ticket.activityId)
if (existing) {
existing.push(ticket)
} else {
m.set(ticket.activityId, [ticket])
}
}
return m
})
const ownedActivityIds = computed<Set<string>>(() => {
const s = new Set<string>()
for (const ticket of tickets.value) {
if (ticket.paid) s.add(ticket.activityId)
}
return s
})
function getTickets(activityId: string): ActivityTicket[] {
return ticketsByActivity.value.get(activityId) ?? []
}
/** Number of paid ticket rows for an activity. With the
* multi-ticket-as-N-rows backend (events ext >= v1.6.1-aio.2),
* this matches the number of attendees / scannable QRs. */
function paidCount(activityId: string): number {
return getTickets(activityId).filter(t => t.paid).length
}
export function useOwnedTickets() {
const { isAuthenticated, currentUser } = useAuth()
// First call kicks off the initial load + sets up the auth-change
// watcher. Subsequent calls attach to the shared state.
if (!hasAutoLoaded) {
hasAutoLoaded = true
fetchTickets()
// Re-fetch when the current user changes (login / logout /
// account switch). Compares against the last-fetched user id
// so we don't re-fetch when other auth fields update (e.g.
// metadata refresh) without the user id changing.
watch(
() => currentUser.value?.id ?? null,
(id) => {
if (id !== lastLoadedUserId) fetchTickets()
},
)
} else if (
!isLoading.value &&
isAuthenticated.value &&
currentUser.value &&
lastLoadedUserId !== currentUser.value.id
) {
// A previous load failed (lastLoadedUserId stayed null) or the
// user changed identity while the singleton was idle. Retry —
// the buyer landing on a fresh detail page after a transient
// backend hiccup shouldn't be stuck with empty tickets.
fetchTickets()
}
return {
tickets,
ticketsByActivity,
ownedActivityIds,
getTickets,
paidCount,
refresh: fetchTickets,
isLoading,
error,
isAuthenticated,
}
}

View file

@ -1,7 +1,8 @@
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { finalizeEvent, type EventTemplate, type Event as NostrEvent } from 'nostr-tools' import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing'
import { NIP52_KINDS, type RSVPStatus } from '../types/nip52' import { NIP52_KINDS, type RSVPStatus } from '../types/nip52'
/** /**
@ -150,7 +151,7 @@ export function useRSVP() {
activityDTag: string, activityDTag: string,
status: RSVPStatus status: RSVPStatus
): Promise<RSVPStatus | null> { ): Promise<RSVPStatus | null> {
if (!isAuthenticated.value || !currentUser.value?.prvkey) return null if (!isAuthenticated.value || !currentUser.value?.pubkey) return null
const coord = `${activityKind}:${activityPubkey}:${activityDTag}` const coord = `${activityKind}:${activityPubkey}:${activityDTag}`
@ -184,8 +185,13 @@ export function useRSVP() {
], ],
} }
const signingKey = hexToUint8Array(currentUser.value.prvkey) let signedEvent: NostrEvent
const signedEvent = finalizeEvent(template, signingKey) try {
signedEvent = await signEventViaLnbits(template)
} catch (err) {
console.error('[useRSVP] signEventViaLnbits failed:', err)
return null
}
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB) const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return null if (!relayHub) return null
@ -240,10 +246,3 @@ export function useRSVP() {
} }
} }
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}

View file

@ -20,9 +20,16 @@ export function useTicketPurchase() {
const qrCode = ref<string | null>(null) const qrCode = ref<string | null>(null)
const isPaymentPending = ref(false) const isPaymentPending = ref(false)
// Ticket QR code state // Ticket QR code state. After payment lands, `purchasedTicketIds`
// is populated with every row id created on the invoice (one for
// a single-ticket purchase, N for multi). `ticketQRCodes` is a
// parallel map id → QR data URL so the UI can render one QR per
// attendee. `purchasedTicketId` stays for back-compat with the
// single-id success path.
const ticketQRCode = ref<string | null>(null) const ticketQRCode = ref<string | null>(null)
const ticketQRCodes = ref<Record<string, string>>({})
const purchasedTicketId = ref<string | null>(null) const purchasedTicketId = ref<string | null>(null)
const purchasedTicketIds = ref<string[]>([])
const showTicketQR = ref(false) const showTicketQR = ref(false)
// Computed properties // Computed properties
@ -75,7 +82,15 @@ export function useTicketPurchase() {
} }
} }
async function purchaseTicketForEvent(eventId: string) { /** The event id this composable is currently driving kept so
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
* have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null)
async function purchaseTicketForEvent(
eventId: string,
options: { quantity?: number } = {},
) {
if (!canPurchase.value || !currentUser.value) { if (!canPurchase.value || !currentUser.value) {
throw new Error('User must be authenticated to purchase tickets') throw new Error('User must be authenticated to purchase tickets')
} }
@ -86,8 +101,11 @@ export function useTicketPurchase() {
paymentRequest.value = null paymentRequest.value = null
qrCode.value = null qrCode.value = null
ticketQRCode.value = null ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false showTicketQR.value = false
currentEventId.value = eventId
// Get the invoice via TicketApiService // Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
@ -96,26 +114,36 @@ export function useTicketPurchase() {
const invoice = await ticketApi.requestTicket( const invoice = await ticketApi.requestTicket(
eventId, eventId,
currentUser.value!.id, currentUser.value!.id,
accessToken accessToken,
{ quantity: options.quantity },
) )
// Backend now returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows
// how to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog
// (it has the user-visible payment-method selector). Reject the
// fiat response here so callers get a clear error instead of a
// silent broken QR.
if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' +
'to follow the provider link.',
)
}
const bolt11: string = invoice.paymentRequest
paymentHash.value = invoice.paymentHash paymentHash.value = invoice.paymentHash
paymentRequest.value = invoice.paymentRequest paymentRequest.value = bolt11
// Generate QR code for payment // Generate QR code for payment
await generateQRCode(invoice.paymentRequest) await generateQRCode(bolt11)
// Try to pay with wallet if available // Restaurant-style: don't auto-pay. Surface the QR + amount and
if (hasWalletWithBalance.value) { // let the buyer pick "Pay with my LNbits wallet" vs "Open in
try { // external wallet" on the same screen. The composable just
await payWithWallet(invoice.paymentRequest) // starts polling so when payment lands (from any path) the UI
// advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash) await startPaymentStatusCheck(eventId, invoice.paymentHash)
} catch (walletError) {
console.log('Wallet payment failed, falling back to manual payment:', walletError)
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
} else {
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
return invoice return invoice
}, { }, {
@ -123,6 +151,19 @@ export function useTicketPurchase() {
}) })
} }
/**
* Trigger LNbits-wallet payment of the invoice this composable is
* currently displaying. Called when the buyer clicks the "Pay from
* my LNbits wallet" button on the invoice screen.
*/
async function payCurrentInvoiceWithWallet(): Promise<void> {
if (!paymentRequest.value) return
await payWithWallet(paymentRequest.value)
// Polling is already running from purchaseTicketForEvent — when
// the payment lands, it advances to showTicketQR. No need to
// restart it here.
}
async function startPaymentStatusCheck(eventId: string, hash: string) { async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true isPaymentPending.value = true
let checkInterval: number | null = null let checkInterval: number | null = null
@ -137,13 +178,34 @@ export function useTicketPurchase() {
clearInterval(checkInterval) clearInterval(checkInterval)
} }
if (result.ticketId) { // Multi-ticket purchases come back with `ticketIds` (N rows
purchasedTicketId.value = result.ticketId // sharing one invoice). Single-ticket purchases include
await generateTicketQRCode(result.ticketId) // `ticketId` only. Render one QR per row so each attendee
// has their own scannable code at the door.
const ids = result.ticketIds && result.ticketIds.length > 0
? result.ticketIds
: result.ticketId
? [result.ticketId]
: []
if (ids.length > 0) {
purchasedTicketIds.value = ids
purchasedTicketId.value = ids[0]
const qrMap: Record<string, string> = {}
for (const id of ids) {
const dataUrl = await generateTicketQRCode(id)
if (dataUrl) qrMap[id] = dataUrl
}
ticketQRCodes.value = qrMap
ticketQRCode.value = qrMap[ids[0]] ?? null
showTicketQR.value = true showTicketQR.value = true
} }
toast.success('Ticket purchased successfully!') toast.success(
ids.length > 1
? `${ids.length} tickets purchased!`
: 'Ticket purchased successfully!',
)
} }
} catch (err) { } catch (err) {
console.error('Error checking payment status:', err) console.error('Error checking payment status:', err)
@ -165,7 +227,9 @@ export function useTicketPurchase() {
qrCode.value = null qrCode.value = null
isPaymentPending.value = false isPaymentPending.value = false
ticketQRCode.value = null ticketQRCode.value = null
ticketQRCodes.value = {}
purchasedTicketId.value = null purchasedTicketId.value = null
purchasedTicketIds.value = []
showTicketQR.value = false showTicketQR.value = false
} }
@ -193,7 +257,9 @@ export function useTicketPurchase() {
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
ticketQRCode, ticketQRCode,
ticketQRCodes,
purchasedTicketId, purchasedTicketId,
purchasedTicketIds,
showTicketQR, showTicketQR,
// Computed // Computed
@ -204,6 +270,7 @@ export function useTicketPurchase() {
// Actions // Actions
purchaseTicketForEvent, purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet, handleOpenLightningWallet,
resetPaymentState, resetPaymentState,
cleanup, cleanup,

View file

@ -0,0 +1,214 @@
import { ref, onMounted, type Ref } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { TicketApiService } from '../services/TicketApiService'
export type ScanStatus = 'ok' | 'duplicate-session' | 'error'
export interface ScanResult {
status: ScanStatus
ticketId: string
/** Backend response payload on OK. */
ticket?: Record<string, unknown>
/** Error string from the backend or local validation. */
message?: string
}
export interface ScanRecord {
ticketId: string
/** Holder display name from the backend, if any. */
name?: string | null
registeredAt: string
}
/** A paid ticket as returned by `events_list_event_tickets`. */
export interface EventTicket {
id: string
name?: string | null
registered: boolean
registeredAt: string | null
}
/** Counts + roster snapshot for the event, sourced from the backend. */
export interface EventStats {
sold: number
registered: number
remaining: number
tickets: EventTicket[]
}
/**
* Stateful scanner driver. Owns the camera lifecycle (delegated to
* useQRScanner upstream), the QR decode, the register-ticket call,
* an authoritative event roster fetch, and a session-local dedup
* cache.
*
* Counts + the displayed scanned list come from the backend so the
* UI agrees with reality even when a second organizer is scanning
* on another device. The localStorage cache is kept as a silent
* dedup so the 5-fps decode loop doesn't re-fire the request on a
* QR the camera held in frame for multiple ticks.
*
* Auth: the organizer's wallet admin_key. The events extension's
* `GET /tickets/event/{id}/stats` + `PUT /tickets/register/{id}`
* endpoints both check the event's wallet is in the caller's
* wallet set, so admin_key alone is sufficient. We deliberately
* route via HTTP rather than the kind-21000 nostr-transport RPC
* because post-#9 the webapp no longer holds a raw user prvkey.
*/
export function useTicketScanner(activityId: Ref<string>) {
const ticketApi = injectService<TicketApiService>(SERVICE_TOKENS.TICKET_API)
const { currentUser } = useAuth()
const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null)
/**
* Set to `true` immediately after a decode resolves (success or
* failure) and stays true until the operator dismisses or taps
* "Scan next". While paused, further `onDecode` calls are dropped
* the camera keeps streaming for instant resume but the result
* banner sticks so the door can confirm the outcome before
* moving on. Without this, the 5-fps decode loop instantly
* fires "already scanned this session" on the very ticket that
* just succeeded.
*/
const isPaused = ref(false)
/** Server-authoritative counts + per-ticket registered status. */
const eventStats = ref<EventStats | null>(null)
const statsLoading = ref(false)
const statsError = ref<string | null>(null)
/** Session-local dedup. Hidden from UI; only guards repeat decodes. */
const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`,
[],
)
function parseTicketId(qrText: string): string {
return qrText.startsWith('ticket://')
? qrText.slice('ticket://'.length)
: qrText
}
async function refreshStats(): Promise<void> {
if (!activityId.value) return
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) {
statsError.value = 'No wallet admin key available'
return
}
statsLoading.value = true
statsError.value = null
try {
const data = await ticketApi.getEventStats(activityId.value, adminKey)
eventStats.value = {
sold: data.sold,
registered: data.registered,
remaining: data.remaining,
tickets: data.tickets.map(t => ({
id: t.id,
name: t.name ?? null,
registered: t.registered,
registeredAt: t.registered_at,
})),
}
} catch (e) {
statsError.value = e instanceof Error ? e.message : String(e)
} finally {
statsLoading.value = false
}
}
async function onDecode(qrText: string): Promise<void> {
if (isProcessing.value || isPaused.value) return
const ticketId = parseTicketId(qrText).trim()
if (!ticketId) return
// Session-local de-dup. Distinct from the backend's "already
// registered" — this guards against the QR being held in front
// of the camera for multiple decode frames.
if (scanned.value.some(r => r.ticketId === ticketId)) {
lastScan.value = { status: 'duplicate-session', ticketId }
isPaused.value = true
return
}
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) {
lastScan.value = {
status: 'error',
ticketId,
message: 'No wallet admin key available',
}
isPaused.value = true
return
}
isProcessing.value = true
try {
const ticket = await ticketApi.registerTicket(ticketId, adminKey)
scanned.value = [
{
ticketId,
name: ticket.name ?? null,
registeredAt: new Date().toISOString(),
},
...scanned.value,
]
lastScan.value = {
status: 'ok',
ticketId,
ticket: ticket as unknown as Record<string, unknown>,
}
} catch (e) {
// Backend errors surface via TicketApiService.request as the
// HTTP `detail` string: "Ticket not paid for", "Ticket
// already registered", "Ticket does not exist.", "You do not
// own this event.", etc.
lastScan.value = {
status: 'error',
ticketId,
message: e instanceof Error ? e.message : String(e),
}
} finally {
isProcessing.value = false
// Pause the decode loop regardless of outcome. The operator
// taps "Scan next" to resume; this guarantees they see the
// banner and can correct (let the attendee in, deny entry,
// etc.) before the next QR comes into frame.
isPaused.value = true
// Refresh the roster so the counts strip + scanned tab reflect
// the new state. Fire-and-forget — UI doesn't block on it.
void refreshStats()
}
}
function resume() {
lastScan.value = null
isPaused.value = false
}
function clearScanned() {
scanned.value = []
lastScan.value = null
isPaused.value = false
}
onMounted(() => {
void refreshStats()
})
return {
isProcessing,
isPaused,
lastScan,
scanned,
eventStats,
statsLoading,
statsError,
refreshStats,
onDecode,
resume,
clearScanned,
}
}

View file

@ -66,6 +66,7 @@ export function useUserTickets() {
return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered) return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered)
}) })
const groupedTickets = computed(() => { const groupedTickets = computed(() => {
const groups = new Map<string, GroupedTickets>() const groups = new Map<string, GroupedTickets>()

View file

@ -78,6 +78,15 @@ export const activitiesModule = createModulePlugin({
requiresAuth: true, requiresAuth: true,
}, },
}, },
{
path: '/scan/:activityId',
name: 'scan-tickets',
component: () => import('./views/ScanTicketsPage.vue'),
meta: {
title: 'Scan Tickets',
requiresAuth: true,
},
},
{ {
path: '/events', path: '/events',
name: 'events', name: 'events',

View file

@ -1,12 +1,10 @@
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools' import type { Event as NostrEvent } from 'nostr-tools'
import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub' import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
import { import {
NIP52_KINDS, NIP52_KINDS,
parseCalendarTimeEvent, parseCalendarTimeEvent,
parseCalendarDateEvent, parseCalendarDateEvent,
buildCalendarTimeEventTags,
type CalendarTimeEvent,
} from '../types/nip52' } from '../types/nip52'
import { import {
calendarTimeEventToActivity, calendarTimeEventToActivity,
@ -25,10 +23,20 @@ export interface CalendarEventFilters {
hashtags?: string[] hashtags?: string[]
/** Filter by geohash prefix (NIP-52 'g' tag) */ /** Filter by geohash prefix (NIP-52 'g' tag) */
geohash?: string geohash?: string
/** Filter by NIP-52 'd' tag — scopes the query to specific parameterized-replaceable events */
dTags?: string[]
} }
/** /**
* Service for subscribing to and publishing NIP-52 Calendar Events via RelayHub. * Service for subscribing to NIP-52 Calendar Events via RelayHub.
*
* Publishing kind-31922 calendar events lives server-side in the
* `aiolabs/events` LNbits extension (signer-abstraction branch, commit
* 66076d6) `POST /events/api/v1/events` constructs and signs the
* event via NostrSigner and broadcasts it to the operator's configured
* relays. The webapp constructs only the request payload; see
* CreateActivityDialog for the flow.
*
* Extends BaseService for standardized dependency injection and lifecycle. * Extends BaseService for standardized dependency injection and lifecycle.
*/ */
export class ActivitiesNostrService extends BaseService { export class ActivitiesNostrService extends BaseService {
@ -105,32 +113,6 @@ export class ActivitiesNostrService extends BaseService {
return activities return activities
} }
/**
* Publish a NIP-52 time-based calendar event.
* Requires an authenticated user with a signing key.
*/
async publishCalendarEvent(
eventData: Partial<CalendarTimeEvent>,
signingKeyHex: string
): Promise<{ success: number; total: number }> {
if (!this.relayHub) {
throw new Error('RelayHub not available')
}
const tags = buildCalendarTimeEventTags(eventData)
const template: EventTemplate = {
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
created_at: Math.floor(Date.now() / 1000),
content: eventData.content ?? '',
tags,
}
const privkeyBytes = hexToUint8Array(signingKeyHex)
const signedEvent = finalizeEvent(template, privkeyBytes)
return await this.relayHub.publishEvent(signedEvent)
}
/** /**
* Parse a raw Nostr event into an Activity view model. * Parse a raw Nostr event into an Activity view model.
*/ */
@ -168,6 +150,7 @@ export class ActivitiesNostrService extends BaseService {
if (filters?.authors?.length) filter.authors = filters.authors if (filters?.authors?.length) filter.authors = filters.authors
if (filters?.hashtags?.length) filter['#t'] = filters.hashtags if (filters?.hashtags?.length) filter['#t'] = filters.hashtags
if (filters?.geohash) filter['#g'] = [filters.geohash] if (filters?.geohash) filter['#g'] = [filters.geohash]
if (filters?.dTags?.length) filter['#d'] = filters.dTags
return [filter] return [filter]
} }
@ -179,11 +162,3 @@ export class ActivitiesNostrService extends BaseService {
this.activeUnsubscribes = [] this.activeUnsubscribes = []
} }
} }
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}

View file

@ -1,5 +1,8 @@
import type { import type {
ActivityTicket, ActivityTicket,
ActivityTicketExtra,
CreateTicketRequest,
PaymentMethod,
TicketPurchaseInvoice, TicketPurchaseInvoice,
TicketPaymentStatus, TicketPaymentStatus,
TicketedEvent, TicketedEvent,
@ -49,14 +52,41 @@ export class TicketApiService {
} }
/** /**
* Request a ticket purchase (creates a Lightning invoice). * Request a ticket purchase. Returns either a Lightning invoice
* Uses POST /tickets/{event_id} with user_id in body (upstream API). * (`paymentRequest` = bolt11) or a fiat invoice (`fiatPaymentRequest`
* = follow-the-URL string from the configured fiat provider). The
* `isFiat` flag is the discriminator.
*
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
* the fiat path (requires the event to have `allow_fiat=true`).
* `fiatProvider` is optional backend picks the user's configured
* default when omitted.
*
* Additional ticket metadata (promo code, refund address, nostr
* identifier for DM delivery) can be supplied via `options`.
*/ */
async requestTicket( async requestTicket(
eventId: string, eventId: string,
userId: string, userId: string,
accessToken: string accessToken: string,
options: {
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
} = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId }
if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}`, `/events/api/v1/tickets/${eventId}`,
{ {
@ -65,13 +95,16 @@ export class TicketApiService {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, 'Authorization': `Bearer ${accessToken}`,
}, },
body: JSON.stringify({ user_id: userId }), body: JSON.stringify(body),
} }
) )
return { return {
paymentHash: data.payment_hash, paymentHash: data.payment_hash,
paymentRequest: data.payment_request, paymentRequest: data.payment_request ?? undefined,
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat),
} }
} }
@ -90,6 +123,7 @@ export class TicketApiService {
return { return {
paid: data.paid === true, paid: data.paid === true,
ticketId: data.ticket_id, ticketId: data.ticket_id,
ticketIds: Array.isArray(data.ticket_ids) ? data.ticket_ids : undefined,
} }
} }
@ -121,6 +155,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
})) }))
} }
@ -144,6 +179,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
})) }))
} }
@ -183,6 +219,93 @@ export class TicketApiService {
}) })
} }
/**
* Resend the ticket confirmation email for a paid ticket. Requires
* the event's wallet admin key (organizer-only). Returns the updated
* Ticket with the `email_notification_sent` flag refreshed.
*
* Endpoint added upstream in v1.6.1 (PR #51).
*/
async resendTicketEmail(
ticketId: string,
adminKey: string,
): Promise<ActivityTicket> {
const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`,
{
method: 'POST',
headers: { 'X-API-KEY': adminKey },
}
)
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}
}
/**
* Door-scanner roster + counts for one event. Organizer-only
* requires the event-owning wallet's admin_key. Returns the same
* shape the `events_list_event_tickets` nostr-transport RPC does;
* we route via HTTP because post-#9 the webapp no longer holds a
* raw user prvkey to sign kind-21000 envelopes with.
*/
async getEventStats(
eventId: string,
adminKey: string,
): Promise<{
event_id: string
sold: number
registered: number
remaining: number
tickets: Array<{
id: string
name?: string | null
registered: boolean
registered_at: string | null
}>
}> {
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
method: 'GET',
headers: { 'X-API-KEY': adminKey },
})
}
/**
* Mark a paid ticket as registered at the door. Organizer-only
* requires the event-owning wallet's admin_key. Backend rejects
* unpaid / already-registered / not-owned cases with HTTP errors
* whose `detail` becomes the thrown Error message.
*/
async registerTicket(ticketId: string, adminKey: string): Promise<ActivityTicket> {
const t = await this.request(`/events/api/v1/tickets/register/${ticketId}`, {
method: 'PUT',
headers: { 'X-API-KEY': adminKey },
})
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}
}
/** /**
* Probe whether the current user has LNbits admin privileges. The * Probe whether the current user has LNbits admin privileges. The
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin", * `/all` endpoint is `check_admin`-gated, so a 200 means "admin",

View file

@ -1,6 +1,6 @@
import ngeohash from 'ngeohash' import ngeohash from 'ngeohash'
import type { ActivityCategory } from './category' import type { ActivityCategory } from './category'
import type { CalendarTimeEvent, CalendarDateEvent } from './nip52' import type { CalendarTimeEvent, CalendarDateEvent, TicketTags } from './nip52'
import type { TicketedEvent } from './ticket' import type { TicketedEvent } from './ticket'
/** /**
@ -74,8 +74,26 @@ export interface OrganizerInfo {
export interface ActivityTicketInfo { export interface ActivityTicketInfo {
price: number price: number
currency: string currency: string
available: number /** Remaining capacity. Undefined means unlimited. */
total: number available?: number
/** Running paid count. */
sold: number
/** Whether the organizer enabled fiat checkout. */
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
}
function ticketTagsToInfo(ticket: TicketTags | undefined): ActivityTicketInfo | undefined {
if (!ticket) return undefined
return {
price: ticket.price,
currency: ticket.currency,
available: ticket.available,
sold: ticket.sold,
allowFiat: ticket.allowFiat,
fiatCurrency: ticket.fiatCurrency,
}
} }
/** /**
@ -104,6 +122,7 @@ export function calendarTimeEventToActivity(event: CalendarTimeEvent, organizer?
geohash: event.geohash, geohash: event.geohash,
category, category,
tags: event.hashtags, tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false, isPrivate: false,
createdAt: new Date(event.createdAt * 1000), createdAt: new Date(event.createdAt * 1000),
} }
@ -140,6 +159,7 @@ export function calendarDateEventToActivity(event: CalendarDateEvent, organizer?
geohash: event.geohash, geohash: event.geohash,
category, category,
tags: event.hashtags, tags: event.hashtags,
ticketInfo: ticketTagsToInfo(event.ticket),
isPrivate: false, isPrivate: false,
createdAt: new Date(event.createdAt * 1000), createdAt: new Date(event.createdAt * 1000),
} }

View file

@ -17,6 +17,27 @@ export const NIP52_KINDS = {
export type Nip52Kind = typeof NIP52_KINDS[keyof typeof NIP52_KINDS] export type Nip52Kind = typeof NIP52_KINDS[keyof typeof NIP52_KINDS]
/**
* AIO custom tags carried on ticketed calendar events. The aiolabs/events
* extension adds these so connected clients can render the buy CTA + the
* "X tickets remaining" badge without an extra REST hop. Absent when the
* event was published by a non-AIO client.
*/
export interface TicketTags {
/** Remaining capacity. Undefined means unlimited. */
available?: number
/** Running paid-count. */
sold: number
/** Price per ticket in the event's `currency`. */
price: number
/** Currency string (e.g. 'sat', 'sats', 'USD'). */
currency: string
/** Whether the organizer enabled fiat checkout. */
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
}
/** /**
* Parsed NIP-52 date-based calendar event (kind 31922) * Parsed NIP-52 date-based calendar event (kind 31922)
*/ */
@ -36,6 +57,7 @@ export interface CalendarDateEvent {
references: string[] references: string[]
id: string id: string
createdAt: number createdAt: number
ticket?: TicketTags
} }
/** /**
@ -59,6 +81,7 @@ export interface CalendarTimeEvent {
references: string[] references: string[]
id: string id: string
createdAt: number createdAt: number
ticket?: TicketTags
} }
export interface Participant { export interface Participant {
@ -96,6 +119,35 @@ function getTagValues(tags: string[][], tagName: string): string[] {
return tags.filter(t => t[0] === tagName).map(t => t[1]) return tags.filter(t => t[0] === tagName).map(t => t[1])
} }
/**
* Parse the AIO ticket_* tags off a NIP-52 calendar event. Returns
* undefined when the event carries no ticket info (e.g. an event
* published by a non-AIO client or a non-ticketed AIO event though
* the latter doesn't currently exist since every aiolabs/events row
* has a price + currency).
*
* `tickets_currency` is the discriminator: when absent, the event has
* no inventory metadata and the buy UI stays hidden.
*/
function parseTicketTags(tags: string[][]): TicketTags | undefined {
const currency = getTagValue(tags, 'tickets_currency')
if (!currency) return undefined
const availableStr = getTagValue(tags, 'tickets_available')
const soldStr = getTagValue(tags, 'tickets_sold')
const priceStr = getTagValue(tags, 'tickets_price')
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
return {
available: availableStr != null ? Number(availableStr) : undefined,
sold: soldStr != null ? Number(soldStr) : 0,
price: priceStr != null ? Number(priceStr) : 0,
currency,
allowFiat: allowFiatStr === 'true',
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
}
}
/** /**
* Parse a NIP-52 start/end tag value to a unix timestamp in seconds. * Parse a NIP-52 start/end tag value to a unix timestamp in seconds.
* Handles: unix seconds, unix milliseconds, and ISO date strings. * Handles: unix seconds, unix milliseconds, and ISO date strings.
@ -166,6 +218,7 @@ export function parseCalendarTimeEvent(event: NostrEvent): CalendarTimeEvent | n
references: getTagValues(event.tags, 'r'), references: getTagValues(event.tags, 'r'),
id: event.id, id: event.id,
createdAt: event.created_at, createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
} }
} }
@ -213,6 +266,7 @@ export function parseCalendarDateEvent(event: NostrEvent): CalendarDateEvent | n
references: getTagValues(event.tags, 'r'), references: getTagValues(event.tags, 'r'),
id: event.id, id: event.id,
createdAt: event.created_at, createdAt: event.created_at,
ticket: parseTicketTags(event.tags),
} }
} }

View file

@ -1,7 +1,44 @@
/** /**
* Database-backed ticket types (via LNbits events extension) * Database-backed ticket types (via LNbits events extension).
*
* Wire-format types names match the snake_case fields the events
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
* below) are the webapp-internal view models after adapter conversion.
*/ */
export interface PromoCode {
code: string
discount_percent: number
active: boolean
}
/**
* EventExtra mirrors the EventExtra Pydantic model in
* `events/models.py`. Carries promo codes, conditional-event config,
* and the per-event notification toggles + custom subject/body added
* in upstream v1.4.0 (PR #50) and v1.6.0.
*/
export interface EventExtra {
promo_codes: PromoCode[]
conditional: boolean
min_tickets: number
email_notifications: boolean
nostr_notifications: boolean
notification_subject: string
notification_body: string
}
export interface ActivityTicketExtra {
applied_promo_code?: string | null
sats_paid?: number | null
refund_address?: string | null
nostr_identifier?: string | null
ticket_base_url?: string | null
email_notification_sent: boolean
nostr_notification_sent: boolean
refunded: boolean
}
export interface ActivityTicket { export interface ActivityTicket {
id: string id: string
wallet: string wallet: string
@ -21,24 +58,51 @@ export interface ActivityTicket {
time: string time: string
/** Registration/scan timestamp */ /** Registration/scan timestamp */
regTimestamp: string regTimestamp: string
/** Optional metadata promo code applied, sats paid, notification
* delivery flags, refund state. May be absent on older tickets. */
extra?: ActivityTicketExtra
} }
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled' export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
export type PaymentMethod = 'lightning' | 'fiat'
export interface TicketPurchaseRequest { export interface TicketPurchaseRequest {
activityId: string activityId: string
userId: string userId: string
accessToken: string accessToken: string
/** Lightning (default) or fiat. Only meaningful if the event has
* `allow_fiat=true` on the backend; otherwise the backend coerces
* to lightning. */
paymentMethod?: PaymentMethod
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
* user's default if omitted. */
fiatProvider?: string
} }
/**
* Server response from `POST /tickets/{event_id}`. Either Lightning
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* the buyer follows to complete payment with `fiatProvider`).
* `isFiat` is the discriminator.
*/
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string
paymentRequest: string paymentRequest?: string
fiatPaymentRequest?: string
fiatProvider?: string
isFiat: boolean
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {
paid: boolean paid: boolean
/** First ticket id created on this invoice. Back-compat with
* single-ticket purchases equals the payment_hash. */
ticketId?: string ticketId?: string
/** Every row created on this invoice one for single-ticket
* purchases, N for multi-ticket. Each row is independently
* scannable at the door. */
ticketIds?: string[]
} }
/** /**
@ -58,6 +122,10 @@ export interface TicketedEvent {
event_start_date: string event_start_date: string
event_end_date: string | null event_end_date: string | null
currency: string currency: string
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */
allow_fiat: boolean
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
fiat_currency: string
amount_tickets: number amount_tickets: number
price_per_ticket: number price_per_ticket: number
time: string time: string
@ -65,6 +133,7 @@ export interface TicketedEvent {
banner: string | null banner: string | null
location: string | null location: string | null
categories: string[] categories: string[]
extra: EventExtra
status: string status: string
} }
@ -76,9 +145,36 @@ export interface CreateEventRequest {
event_start_date: string event_start_date: string
event_end_date?: string event_end_date?: string
currency?: string currency?: string
allow_fiat?: boolean
fiat_currency?: string
amount_tickets?: number amount_tickets?: number
price_per_ticket?: number price_per_ticket?: number
banner?: string | null banner?: string | null
location?: string | null location?: string | null
categories?: string[] categories?: string[]
/** Optional notification toggles + custom subject/body, promo
* codes, conditional-event config. Backend defaults to a fresh
* EventExtra if omitted. */
extra?: Partial<EventExtra>
}
/**
* Body for `POST /tickets/{event_id}`. Either `user_id` OR the
* `name`+`email` pair is required (backend root_validator enforces
* mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM
* delivery when the event has nostr_notifications enabled. The
* `payment_method` + `fiat_provider` pair selects between Lightning
* and fiat checkout.
*/
export interface CreateTicketRequest {
name?: string
email?: string
user_id?: string
promo_code?: string
refund_address?: string
nostr_identifier?: string
payment_method?: PaymentMethod
fiat_provider?: string
/** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number
} }

View file

@ -8,8 +8,9 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown } from 'lucide-vue-next' import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities' import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue' import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue' import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue'
@ -28,14 +29,22 @@ const {
selectedCategories, selectedCategories,
hasActiveFilters, hasActiveFilters,
selectedDate, selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
selectDate, selectDate,
setTemporal, setTemporal,
toggleCategory, toggleCategory,
clearCategories, clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters, resetFilters,
subscribe, subscribe,
} = useActivities() } = useActivities()
const { isAuthenticated } = useAuth()
const filtersOpen = ref(false) const filtersOpen = ref(false)
onMounted(() => { onMounted(() => {
@ -74,6 +83,43 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" /> <TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div> </div>
<!-- Role + past-events filter chips. The role chips ("My tickets",
"Hosting") narrow the feed to activities the signed-in user
has skin in and are hidden when logged out. The "Past events"
chip is always visible since past-browsing doesn't require an
account. -->
<div class="mb-4 flex flex-wrap gap-2">
<template v-if="isAuthenticated">
<Button
:variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleOwnedTickets"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }}
</Button>
</template>
<Button
:variant="showPast ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="togglePast"
>
<History class="w-3.5 h-3.5" />
{{ t('activities.filters.pastEvents', 'Past events') }}
</Button>
</div>
<!-- Category filters (collapsible) --> <!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6"> <Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child> <CollapsibleTrigger as-child>

View file

@ -8,15 +8,18 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
Calendar, MapPin, ArrowLeft, Pencil, Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine, History,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail' import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
import RSVPButton from '../components/RSVPButton.vue' import RSVPButton from '../components/RSVPButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue' import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { NIP52_KINDS } from '../types/nip52' import { NIP52_KINDS } from '../types/nip52'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '../stores/activities' import { useActivitiesStore } from '../stores/activities'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import type { TicketedEvent } from '../types/ticket' import type { TicketedEvent } from '../types/ticket'
@ -61,6 +64,10 @@ function openEditDialog() {
activitiesStore.showCreateDialog = true activitiesStore.showCreateDialog = true
} }
function openScannerPage() {
router.push({ name: 'scan-tickets', params: { activityId } })
}
const dateDisplay = computed(() => { const dateDisplay = computed(() => {
if (!activity.value) return '' if (!activity.value) return ''
const a = activity.value const a = activity.value
@ -94,6 +101,71 @@ const categoryLabel = computed(() => {
function goBack() { function goBack() {
router.push({ name: 'activities' }) router.push({ name: 'activities' })
} }
// --- Ticket purchase + owned-tickets surface ----------------------
const { paidCount, refresh: refreshOwnedTickets } = useOwnedTickets()
const ownedPaidCount = computed(() => paidCount(activityId))
const purchaseEvent = computed(() => {
const a = activity.value
if (!a || !a.ticketInfo) return null
return {
id: a.id,
name: a.title,
price_per_ticket: a.ticketInfo.price,
currency: a.ticketInfo.currency,
allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency,
}
})
// available === undefined unlimited capacity, button always shown
// available === 0 sold out, button hidden
// available > 0 button shown
const canBuyTicket = computed(() => {
const info = activity.value?.ticketInfo
if (!info) return false
return info.available === undefined || info.available > 0
})
// Past events can't be bought into. The notice below replaces the
// buy CTA so the flow is unambiguous date alone is easy to miss
// on a long detail page.
const isPast = computed(() => {
const a = activity.value
if (!a) return false
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
const showPurchaseDialog = ref(false)
function openPurchaseDialog() {
if (!isAuthenticated.value) {
toastService.info(t('activities.detail.loginToBuyTickets'), {
action: {
label: t('activities.detail.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
showPurchaseDialog.value = true
}
// Re-fetch the user's tickets when the purchase dialog closes (the
// buyer may have just paid). The inventory side updates automatically
// via the relay republish from the events extension.
watch(showPurchaseDialog, (open) => {
if (!open) refreshOwnedTickets()
})
function goToMyTickets() {
router.push('/my-tickets')
}
</script> </script>
<template> <template>
@ -105,6 +177,17 @@ function goBack() {
Back Back
</Button> </Button>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openScannerPage"
aria-label="Scan tickets"
>
<ScanLine class="w-4 h-4" />
Scan
</Button>
<Button <Button
v-if="ownedLnbitsEvent" v-if="ownedLnbitsEvent"
variant="ghost" variant="ghost"
@ -219,6 +302,79 @@ function goBack() {
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT" :kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/> />
<!-- Tickets gated on the activity carrying ticketInfo (set
by the calendarActivity converter from the AIO custom
tickets_* tags on the published event). Sections render
bottom-up: availability count, then existing owned
tickets (when count > 0) above a Purchase CTA (when
capacity remains). -->
<div v-if="activity.ticketInfo" class="space-y-3">
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Ticket class="w-4 h-4 shrink-0" />
<span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }}
</span>
</div>
<div
v-if="ownedPaidCount > 0"
class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-center justify-between gap-3"
>
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</div>
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('activities.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
</div>
<div
v-if="isPast"
class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground bg-muted/40 rounded-lg p-3"
>
<History class="w-4 h-4 shrink-0" />
{{ t('activities.detail.pastEvent', 'This event has already happened') }}
</div>
<div v-else-if="canBuyTicket">
<Button
class="w-full gap-1.5"
size="lg"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ ownedPaidCount > 0
? t('activities.detail.buyAnotherTicket', 'Buy another ticket')
: t('activities.detail.buyTicket', 'Buy ticket') }}
<span class="ml-2 opacity-80 font-normal">
{{ activity.ticketInfo.price === 0
? t('activities.detail.free')
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
</span>
</Button>
</div>
<p
v-else-if="ownedPaidCount === 0"
class="text-sm text-destructive text-center"
>
{{ t('activities.detail.soldOut') }}
</p>
</div>
<PurchaseTicketDialog
v-if="purchaseEvent"
:is-open="showPurchaseDialog"
:event="purchaseEvent"
@update:is-open="showPurchaseDialog = $event"
/>
<!-- Organizer --> <!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4"> <div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2"> <p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">

View file

@ -27,6 +27,8 @@ const selectedEvent = ref<{
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
allow_fiat?: boolean
fiat_currency?: string
} | null>(null) } | null>(null)
const showEventDialog = ref(false) const showEventDialog = ref(false)
@ -56,6 +58,8 @@ function handlePurchaseClick(event: {
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
allow_fiat?: boolean
fiat_currency?: string
}) { }) {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedEvent.value = event selectedEvent.value = event

View file

@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft,
CheckCircle2,
AlertCircle,
Clock,
Ticket,
ScanLine,
RefreshCw,
} from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner'
import { useActivityDetail } from '../composables/useActivityDetail'
const route = useRoute()
const router = useRouter()
const activityId = ref(route.params.activityId as string)
const { activity } = useActivityDetail(activityId.value)
const {
isProcessing,
isPaused,
lastScan,
eventStats,
statsLoading,
statsError,
refreshStats,
onDecode,
resume,
} = useTicketScanner(activityId)
const scannerOpen = ref(true)
const activeTab = ref<'scanner' | 'list'>('scanner')
const lastScanVariant = computed(() => {
switch (lastScan.value?.status) {
case 'ok':
return 'success'
case 'duplicate-session':
return 'warning'
case 'error':
return 'destructive'
default:
return null
}
})
// Backend-authoritative roster. Falls back to the activity nostr
// event's `tickets_sold` tag if the RPC hasn't completed yet.
const soldCount = computed(
() => eventStats.value?.sold ?? activity.value?.ticketInfo?.sold,
)
const registeredCount = computed(() => eventStats.value?.registered ?? 0)
const remainingCount = computed(() => {
if (soldCount.value == null) return undefined
return Math.max(0, soldCount.value - registeredCount.value)
})
// Registered tickets only what the "Scanned" tab shows.
const registeredTickets = computed(
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
)
function handleResult(qrText: string) {
// Don't pause the scanner useQRScanner's `maxScansPerSecond: 5`
// already throttles, and useTicketScanner.onDecode dedups the same
// ticket id at the session-list level.
void onDecode(qrText)
}
function goBack() {
if (window.history.length > 1) router.back()
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
}
function fmtTime(iso: string) {
try {
return format(new Date(iso), 'HH:mm:ss')
} catch {
return iso
}
}
</script>
<template>
<div class="container mx-auto py-6 px-4 max-w-2xl">
<!-- Top bar -->
<div class="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<Button
variant="ghost"
size="sm"
class="gap-1.5"
:disabled="statsLoading"
@click="refreshStats"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': statsLoading }" />
Refresh
</Button>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
<p v-if="activity" class="text-sm text-muted-foreground mb-4">
{{ activity.title }}
</p>
<!-- Counts strip backend-authoritative. Source: the
`events_list_event_tickets` RPC, refreshed after every scan.
Stays consistent across organizer devices unlike a
per-device localStorage count. -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
<p class="text-2xl font-bold text-foreground">{{ registeredCount }}</p>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Scanned</p>
</div>
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
<p class="text-2xl font-bold text-foreground">
{{ soldCount ?? '—' }}
</p>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Sold</p>
</div>
<div class="rounded-lg border border-border bg-muted/30 p-3 text-center">
<p class="text-2xl font-bold text-foreground">
{{ remainingCount ?? '—' }}
</p>
<p class="text-[10px] uppercase tracking-wide text-muted-foreground mt-1">Remaining</p>
</div>
</div>
<!-- Surface stats fetch failures (e.g. backend missing the /stats
endpoint, or wallet ownership rejected). Without this the
counts strip silently freezes on the last good value while
scans keep landing on the backend. -->
<div
v-if="statsError"
class="mb-4 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-xs text-destructive"
role="alert"
>
<AlertCircle class="w-4 h-4 mt-0.5 shrink-0" />
<div class="min-w-0">
<p class="font-medium">Counts may be out of date</p>
<p class="text-destructive/80 mt-0.5 break-words">{{ statsError }}</p>
</div>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="scanner" class="gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</TabsTrigger>
<TabsTrigger value="list" class="gap-1.5">
<Ticket class="w-4 h-4" />
Scanned ({{ registeredCount }})
</TabsTrigger>
</TabsList>
<TabsContent value="scanner" class="mt-0">
<!-- Scanner -->
<div v-if="scannerOpen" class="bg-card rounded-lg border border-border overflow-hidden">
<QRScanner @result="handleResult" @close="scannerOpen = false" />
</div>
<div v-else class="flex justify-center my-6">
<Button @click="scannerOpen = true" class="gap-1.5">
<Ticket class="w-4 h-4" />
Resume scanning
</Button>
</div>
<!-- Sending indicator (popup handles success/error post-fact). -->
<p
v-if="isProcessing && !isPaused"
class="text-xs text-center text-muted-foreground mt-3"
>
Sending registration over Nostr
</p>
</TabsContent>
<TabsContent value="list" class="mt-0">
<div class="space-y-3">
<h2 class="text-sm font-medium text-foreground">
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
</h2>
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
<ul class="space-y-1.5 pr-3">
<li
v-for="record in registeredTickets"
:key="record.id"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<Badge
v-if="record.registeredAt"
variant="secondary"
class="text-[10px] font-mono px-1.5"
>
{{ fmtTime(record.registeredAt) }}
</Badge>
<span v-if="record.name" class="font-medium text-foreground">
{{ record.name }}
</span>
</div>
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
{{ record.id }}
</p>
</div>
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
</li>
</ul>
</ScrollArea>
<p v-else class="text-sm text-muted-foreground text-center py-12">
No tickets scanned yet.
</p>
</div>
</TabsContent>
</Tabs>
<!-- Full-screen result overlay. Tap anywhere to dismiss and
resume the decode loop. Replaces the inline banner so the
door operator can't miss the outcome a busy entry meant
the small banner was easy to skim past. -->
<div
v-if="lastScan && isPaused"
class="fixed inset-0 z-50 flex items-center justify-center p-6 cursor-pointer"
:class="{
'bg-emerald-500/95': lastScanVariant === 'success',
'bg-amber-500/95': lastScanVariant === 'warning',
'bg-destructive/95': lastScanVariant === 'destructive',
}"
role="alertdialog"
aria-modal="true"
@click="resume"
>
<div class="text-center max-w-md">
<CheckCircle2
v-if="lastScanVariant === 'success'"
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<Clock
v-else-if="lastScanVariant === 'warning'"
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<AlertCircle
v-else
class="w-32 h-32 mx-auto mb-4 text-white"
/>
<p class="text-3xl sm:text-4xl font-bold text-white">
<template v-if="lastScan.status === 'ok'">
Registered
</template>
<template v-else-if="lastScan.status === 'duplicate-session'">
Already scanned
</template>
<template v-else>
Scan failed
</template>
</p>
<p
v-if="lastScan.status === 'ok' && lastScan.ticket?.name"
class="text-xl text-white/90 mt-2 font-medium"
>
{{ lastScan.ticket.name }}
</p>
<p
v-else-if="lastScan.status === 'error'"
class="text-base text-white/90 mt-2"
>
{{ lastScan.message }}
</p>
<p class="text-xs font-mono text-white/70 break-all mt-4 px-4">
{{ lastScan.ticketId }}
</p>
<p class="text-xs uppercase tracking-widest text-white/80 mt-8">
Tap anywhere to scan next
</p>
</div>
</div>
</div>
</template>

View file

@ -2,9 +2,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { eventBus } from '@/core/event-bus' import { eventBus } from '@/core/event-bus'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits' import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits' import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits'
export class AuthService extends BaseService { export class AuthService extends BaseService {
@ -114,9 +112,6 @@ export class AuthService extends BaseService {
eventBus.emit('auth:login', { user: userData }, 'auth-service') eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on login
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'login') const err = this.handleError(error, 'login')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service') eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -138,9 +133,6 @@ export class AuthService extends BaseService {
eventBus.emit('auth:login', { user: userData }, 'auth-service') eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on registration
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'register') const err = this.handleError(error, 'register')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service') eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -188,18 +180,14 @@ export class AuthService extends BaseService {
this.isLoading.value = true this.isLoading.value = true
const updatedUser = await this.lnbitsAPI.updateProfile(data) const updatedUser = await this.lnbitsAPI.updateProfile(data)
// Preserve prvkey and pubkey from existing user since /auth/update doesn't return them // Preserve pubkey from existing user since /auth/update doesn't return it.
// Kind-0 metadata is published server-side by lnbits's PATCH /auth handler
// (aiolabs/lnbits commit 869f67c3); no webapp-side broadcast path remains.
this.user.value = { this.user.value = {
...updatedUser, ...updatedUser,
pubkey: this.user.value?.pubkey || updatedUser.pubkey, pubkey: this.user.value?.pubkey || updatedUser.pubkey,
prvkey: this.user.value?.prvkey || updatedUser.prvkey
} }
// Auto-broadcast Nostr metadata when profile is updated
// Note: ProfileSettings component will also manually broadcast,
// but this ensures metadata stays in sync even if updated elsewhere
this.broadcastNostrMetadata()
} catch (error) { } catch (error) {
const err = this.handleError(error, 'updateProfile') const err = this.handleError(error, 'updateProfile')
throw err throw err
@ -208,26 +196,6 @@ export class AuthService extends BaseService {
} }
} }
/**
* Broadcast user metadata to Nostr relays (NIP-01 kind 0)
* Called automatically on login, registration, and profile updates
*/
private async broadcastNostrMetadata(): Promise<void> {
try {
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
if (metadataService && this.user.value?.pubkey) {
// Broadcast in background - don't block login/update
metadataService.publishMetadata().catch(error => {
console.warn('Failed to broadcast Nostr metadata:', error)
// Don't throw - this is a non-critical background operation
})
}
} catch (error) {
// If service isn't available yet, silently skip
console.debug('Nostr metadata service not yet available')
}
}
/** /**
* Cleanup when service is disposed * Cleanup when service is disposed
*/ */

View file

@ -122,32 +122,17 @@
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<Button <Button
type="submit" type="submit"
:disabled="isUpdating || !isFormValid" :disabled="isUpdating || !isFormValid"
class="flex-1" class="w-full"
> >
<span v-if="isUpdating">Updating...</span> <span v-if="isUpdating">Updating...</span>
<span v-else>Update Profile</span> <span v-else>Update Profile</span>
</Button> </Button>
<Button
type="button"
variant="outline"
:disabled="isBroadcasting"
@click="broadcastMetadata"
class="flex-1"
>
<Radio class="mr-2 h-4 w-4" :class="{ 'animate-pulse': isBroadcasting }" />
<span v-if="isBroadcasting">Broadcasting...</span>
<span v-else>Broadcast to Nostr</span>
</Button>
</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Your profile is automatically broadcast to Nostr when you update it or log in. Your profile is broadcast to Nostr automatically when you save changes.
Use the "Broadcast to Nostr" button to manually re-broadcast your profile.
</p> </p>
</form> </form>
@ -189,7 +174,7 @@ import * as z from 'zod'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { User, Zap, Hash, Radio } from 'lucide-vue-next' import { User, Zap, Hash } from 'lucide-vue-next'
import { import {
FormControl, FormControl,
FormDescription, FormDescription,
@ -215,19 +200,16 @@ import { useAuth } from '@/composables/useAuthService'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService' import type { ImageUploadService } from '../services/ImageUploadService'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
// Services // Services
const { user, updateProfile, logout } = useAuth() const { user, updateProfile, logout } = useAuth()
const router = useRouter() const router = useRouter()
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
const toast = useToast() const toast = useToast()
// Local state // Local state
const isUpdating = ref(false) const isUpdating = ref(false)
const isBroadcasting = ref(false)
const updateError = ref<string | null>(null) const updateError = ref<string | null>(null)
const updateSuccess = ref(false) const updateSuccess = ref(false)
const uploadedPicture = ref<any[]>([]) const uploadedPicture = ref<any[]>([])
@ -323,18 +305,12 @@ const updateUserProfile = async (formData: any) => {
} }
} }
// Update profile via AuthService (which updates LNbits) // Update profile via AuthService (which updates LNbits).
// Kind-0 metadata publishing happens server-side as part of the
// PATCH /api/v1/auth handler (aiolabs/lnbits 869f67c3).
await updateProfile(updateData) await updateProfile(updateData)
// Broadcast to Nostr automatically toast.success('Profile updated!')
try {
await metadataService.publishMetadata()
toast.success('Profile updated and broadcast to Nostr!')
} catch (nostrError) {
console.error('Failed to broadcast to Nostr:', nostrError)
toast.warning('Profile updated, but failed to broadcast to Nostr')
}
updateSuccess.value = true updateSuccess.value = true
// Clear success message after 3 seconds // Clear success message after 3 seconds
@ -352,22 +328,6 @@ const updateUserProfile = async (formData: any) => {
} }
} }
// Manually broadcast metadata to Nostr
const broadcastMetadata = async () => {
isBroadcasting.value = true
try {
const result = await metadataService.publishMetadata()
toast.success(`Profile broadcast to ${result.success}/${result.total} relays!`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to broadcast metadata'
console.error('Error broadcasting metadata:', error)
toast.error(`Failed to broadcast: ${errorMessage}`)
} finally {
isBroadcasting.value = false
}
}
// Log out + redirect to /login on this app's origin. // Log out + redirect to /login on this app's origin.
const onLogout = async () => { const onLogout = async () => {
try { try {

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useFormContext } from 'vee-validate'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
const props = defineProps<{
/** Field name on the parent vee-validate form for the boolean toggle. */
allowFiatField: string
/** Field name on the parent vee-validate form for the fiat-currency dropdown. */
fiatCurrencyField: string
/** Current value of the price-denomination field (e.g. 'sat', 'USD'). */
denomination: string
/** Allowed values for the fiat-currency dropdown. */
availableFiatCurrencies: string[]
/** Disable all controls (e.g. while the parent form is submitting). */
disabled?: boolean
}>()
const { hasAnyProvider, refresh } = useFiatProviders()
const form = useFormContext()
// Refresh once on mount so the disabled-state reflects providers the
// user may have just configured in another tab.
refresh()
// "sat" / "sats" appear interchangeably across the LNbits events
// extension and the webapp's currency lists treat both as the
// BTC-denominated case for the conditional + auto-mirror.
function isSatDenomination(d: string): boolean {
return d === 'sat' || d === 'sats'
}
// When the price is denominated in a fiat currency, the rail currency
// MUST match it silently mirror so backend payload stays consistent.
watch(
() => props.denomination,
(d) => {
if (!form) return
if (
d &&
!isSatDenomination(d) &&
form.values[props.fiatCurrencyField as keyof typeof form.values] !== d
) {
form.setFieldValue(props.fiatCurrencyField, d)
}
},
{ immediate: true },
)
</script>
<template>
<FormField v-slot="{ value: allowFiat, handleChange: setAllowFiat }" :name="allowFiatField">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
<FormItem class="sm:col-span-2 flex flex-row items-center justify-between rounded-md border p-3">
<div class="space-y-0.5">
<FormLabel>Also accept fiat</FormLabel>
<FormDescription class="text-xs">
Buyers can pay with card or bank through your configured provider.
</FormDescription>
</div>
<FormControl>
<TooltipProvider v-if="!hasAnyProvider" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Switch :model-value="false" disabled />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-xs">
Your LNbits user has no fiat provider configured. Open
LNbits Account Fiat providers and add Stripe, PayPal,
or Square to enable this.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
v-else
:model-value="allowFiat as boolean"
:disabled="disabled"
@update:model-value="setAllowFiat"
/>
</FormControl>
</FormItem>
<FormField v-slot="{ componentField }" :name="fiatCurrencyField">
<FormItem v-show="(allowFiat as boolean) && isSatDenomination(denomination)">
<FormLabel>Fiat currency</FormLabel>
<FormControl>
<Select v-bind="componentField" :disabled="disabled">
<SelectTrigger>
<SelectValue placeholder="USD" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="c in availableFiatCurrencies"
:key="c"
:value="c"
>
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</FormField>
</template>

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type PaymentRail =
| 'lightning'
| 'fiat'
| 'cash'
| 'internal'
| (string & {})
export interface PaymentMethod {
id: string
rail: PaymentRail
provider?: string
label: string
icon: Component
available: boolean
unavailableReason?: string
badge?: string
}
defineProps<{
methods: PaymentMethod[]
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [id: string]
}>()
function select(method: PaymentMethod) {
if (!method.available) return
emit('update:modelValue', method.id)
}
</script>
<template>
<div
class="grid gap-2"
:class="methods.length > 2 ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2'"
>
<template v-for="method in methods" :key="method.id">
<TooltipProvider
v-if="!method.available && method.unavailableReason"
:delay-duration="200"
>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="sm"
disabled
class="opacity-60 flex-col h-auto py-2 gap-1"
>
<span class="flex items-center">
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
{{ method.label }}
</span>
<span v-if="method.badge" class="text-[10px] opacity-70">
{{ method.badge }}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ method.unavailableReason }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
v-else
type="button"
:variant="modelValue === method.id ? 'default' : 'outline'"
size="sm"
:disabled="!method.available"
class="flex-col h-auto py-2 gap-1"
@click="select(method)"
>
<span class="flex items-center">
<component :is="method.icon" class="w-4 h-4 mr-1.5" />
{{ method.label }}
</span>
<span v-if="method.badge" class="text-[10px] opacity-70">
{{ method.badge }}
</span>
</Button>
</template>
</div>
</template>

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
const props = withDefaults(
defineProps<{
amount: number
from: string
to: string
/** Text prepended to the conversion line (e.g. "≈" or "Equivalent"). */
prefix?: string
/** Suffix appended after the number (e.g. " at current rate"). */
suffix?: string
}>(),
{
prefix: '≈',
suffix: ' at current rate',
},
)
const { useLivePreview } = usePriceConversion()
const { result, loading } = useLivePreview(
toRef(props, 'amount'),
toRef(props, 'from'),
toRef(props, 'to'),
)
const formatted = computed(() => {
const v = result.value
if (v == null) return null
if (props.to.toLowerCase() === 'sat') {
return `${Math.round(v).toLocaleString()} sats`
}
const fixed = v < 1 ? v.toFixed(4) : v.toFixed(2)
return `${fixed} ${props.to.toUpperCase()}`
})
</script>
<template>
<p v-if="amount > 0" class="text-xs text-muted-foreground">
<span v-if="loading && !formatted">Loading rate</span>
<span v-else-if="formatted">{{ prefix }} {{ formatted }}{{ suffix }}</span>
<span v-else class="opacity-60">(rate unavailable)</span>
</p>
</template>

View file

@ -0,0 +1,53 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
export type FiatProviderIcon = 'card' | 'bank' | 'wallet'
export interface FiatProviderMeta {
label: string
icon: FiatProviderIcon
}
const KNOWN_PROVIDERS: Record<string, FiatProviderMeta> = {
stripe: { label: 'Stripe', icon: 'card' },
paypal: { label: 'PayPal', icon: 'wallet' },
square: { label: 'Square', icon: 'card' },
sepa: { label: 'SEPA', icon: 'bank' },
}
export function providerMeta(id: string): FiatProviderMeta {
const known = KNOWN_PROVIDERS[id.toLowerCase()]
if (known) return known
return {
label: id.charAt(0).toUpperCase() + id.slice(1),
icon: 'card',
}
}
/**
* Shared accessor for the current user's available fiat providers.
*
* Fiat providers (Stripe, PayPal, Square, SEPA, ) are configured
* globally by the LNbits admin. Per-provider `allowed_users`
* whitelists narrow that to a session-specific list, exposed as
* `User.fiat_providers` on `GET /api/v1/auth`. Both organizers and
* buyers on the same instance see the same list today.
*
* Call `refresh()` from owner-side dialogs that may open right after
* the user configured a new provider in another tab.
*/
export function useFiatProviders() {
const auth = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const providers = computed<string[]>(
() => auth.currentUser.value?.fiat_providers ?? []
)
const hasAnyProvider = computed(() => providers.value.length > 0)
async function refresh(): Promise<void> {
await auth.refresh()
}
return { providers, hasAnyProvider, refresh, providerMeta }
}

View file

@ -1,39 +0,0 @@
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrMetadataService, NostrMetadata } from '../nostr/nostr-metadata-service'
/**
* Composable for accessing Nostr metadata service
*
* @example
* ```ts
* const { publishMetadata, getMetadata } = useNostrMetadata()
*
* // Get current metadata
* const metadata = getMetadata()
*
* // Publish metadata to Nostr relays
* await publishMetadata()
* ```
*/
export function useNostrMetadata() {
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
/**
* Publish user metadata to Nostr relays (NIP-01 kind 0)
*/
const publishMetadata = async (): Promise<{ success: number; total: number }> => {
return await metadataService.publishMetadata()
}
/**
* Get current user's Nostr metadata
*/
const getMetadata = (): NostrMetadata => {
return metadataService.getMetadata()
}
return {
publishMetadata,
getMetadata
}
}

View file

@ -0,0 +1,88 @@
import { ref, watch, type Ref } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { LnbitsAPI } from '@/lib/api/lnbits'
interface CacheEntry {
value: number
expiresAt: number
}
const cache = new Map<string, CacheEntry>()
const TTL_MS = 60_000
function cacheKey(amount: number, from: string, to: string): string {
return `${from.toLowerCase()}|${to.toLowerCase()}|${amount}`
}
/**
* Live + cached BTC fiat rate previews via LNbits `/api/v1/conversion`.
*
* Both helpers tolerate a transient failure (returning `null`) surface
* conversion preview as best-effort UX, never as a blocker. 60s in-memory
* cache de-duplicates dialog re-renders.
*/
export function usePriceConversion() {
const lnbitsAPI = injectService<LnbitsAPI>(SERVICE_TOKENS.LNBITS_API)
async function convert(
amount: number,
from: string,
to: string,
): Promise<number | null> {
if (!amount || !from || !to) return null
if (from.toLowerCase() === to.toLowerCase()) return amount
const key = cacheKey(amount, from, to)
const cached = cache.get(key)
if (cached && cached.expiresAt > Date.now()) return cached.value
try {
const data = await lnbitsAPI.getConversion({ from, to, amount })
const result =
data[to] ??
data[to.toUpperCase()] ??
data[to.toLowerCase()] ??
(data as Record<string, number>).amount ??
(data as Record<string, number>).result
if (typeof result !== 'number') return null
cache.set(key, { value: result, expiresAt: Date.now() + TTL_MS })
return result
} catch (err) {
console.warn('[usePriceConversion] convert failed:', err)
return null
}
}
function useLivePreview(
amount: Ref<number>,
from: Ref<string>,
to: Ref<string>,
debounceMs = 300,
): { result: Ref<number | null>; loading: Ref<boolean> } {
const result = ref<number | null>(null)
const loading = ref(false)
let activeToken = 0
let timer: ReturnType<typeof setTimeout> | null = null
watch(
[amount, from, to],
() => {
if (timer) clearTimeout(timer)
const myToken = ++activeToken
loading.value = true
timer = setTimeout(async () => {
const v = await convert(amount.value, from.value, to.value)
if (myToken === activeToken) {
result.value = v
loading.value = false
}
}, debounceMs)
},
{ immediate: true },
)
return { result, loading }
}
return { convert, useLivePreview }
}

View file

@ -2,9 +2,9 @@ import type { App } from 'vue'
import type { ModulePlugin } from '@/core/types' import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container' import { container, SERVICE_TOKENS } from '@/core/di-container'
import { relayHub } from './nostr/relay-hub' import { relayHub } from './nostr/relay-hub'
import { NostrMetadataService } from './nostr/nostr-metadata-service'
import { ProfileService } from './nostr/ProfileService' import { ProfileService } from './nostr/ProfileService'
import { ReactionService } from './nostr/ReactionService' import { ReactionService } from './nostr/ReactionService'
import { NostrTransportService } from './services/NostrTransportService'
// Import auth services // Import auth services
import { auth } from './auth/auth-service' import { auth } from './auth/auth-service'
@ -29,9 +29,9 @@ import ProfileSettings from './components/ProfileSettings.vue'
const invoiceService = new InvoiceService() const invoiceService = new InvoiceService()
const lnbitsAPI = new LnbitsAPI() const lnbitsAPI = new LnbitsAPI()
const imageUploadService = new ImageUploadService() const imageUploadService = new ImageUploadService()
const nostrMetadataService = new NostrMetadataService()
const profileService = new ProfileService() const profileService = new ProfileService()
const reactionService = new ReactionService() const reactionService = new ReactionService()
const nostrTransportService = new NostrTransportService()
/** /**
* Base Module Plugin * Base Module Plugin
@ -46,7 +46,6 @@ export const baseModule: ModulePlugin = {
// Register core Nostr services // Register core Nostr services
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub) container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
container.provide(SERVICE_TOKENS.NOSTR_METADATA_SERVICE, nostrMetadataService)
// Register auth service // Register auth service
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth) container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
@ -75,6 +74,7 @@ export const baseModule: ModulePlugin = {
// Register shared Nostr services (used by multiple modules) // Register shared Nostr services (used by multiple modules)
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService) container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService) container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
container.provide(SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, nostrTransportService)
// Register PWA service // Register PWA service
container.provide('pwaService', pwaService) container.provide('pwaService', pwaService)
@ -110,10 +110,6 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ImageUploadService depends on ToastService waitForDependencies: true, // ImageUploadService depends on ToastService
maxRetries: 3 maxRetries: 3
}) })
await nostrMetadataService.initialize({
waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub
maxRetries: 3
})
await profileService.initialize({ await profileService.initialize({
waitForDependencies: true, // ProfileService depends on RelayHub waitForDependencies: true, // ProfileService depends on RelayHub
maxRetries: 3 maxRetries: 3
@ -122,6 +118,10 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
maxRetries: 3 maxRetries: 3
}) })
await nostrTransportService.initialize({
waitForDependencies: true, // NostrTransportService depends on RelayHub and AuthService
maxRetries: 3
})
// InvoiceService doesn't need initialization as it's not a BaseService // InvoiceService doesn't need initialization as it's not a BaseService
console.log('✅ Base module installed successfully') console.log('✅ Base module installed successfully')
@ -138,9 +138,9 @@ export const baseModule: ModulePlugin = {
await storageService.dispose() await storageService.dispose()
await toastService.dispose() await toastService.dispose()
await imageUploadService.dispose() await imageUploadService.dispose()
await nostrMetadataService.dispose()
await profileService.dispose() await profileService.dispose()
await reactionService.dispose() await reactionService.dispose()
await nostrTransportService.dispose()
// InvoiceService doesn't need disposal as it's not a BaseService // InvoiceService doesn't need disposal as it's not a BaseService
await lnbitsAPI.dispose() await lnbitsAPI.dispose()
@ -148,7 +148,6 @@ export const baseModule: ModulePlugin = {
container.remove(SERVICE_TOKENS.LNBITS_API) container.remove(SERVICE_TOKENS.LNBITS_API)
container.remove(SERVICE_TOKENS.INVOICE_SERVICE) container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
container.remove(SERVICE_TOKENS.PROFILE_SERVICE) container.remove(SERVICE_TOKENS.PROFILE_SERVICE)
container.remove(SERVICE_TOKENS.REACTION_SERVICE) container.remove(SERVICE_TOKENS.REACTION_SERVICE)
@ -165,7 +164,6 @@ export const baseModule: ModulePlugin = {
invoiceService, invoiceService,
pwaService, pwaService,
imageUploadService, imageUploadService,
nostrMetadataService,
profileService, profileService,
reactionService reactionService
}, },

View file

@ -1,162 +0,0 @@
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
/**
* Nostr User Metadata (NIP-01 kind 0)
* https://github.com/nostr-protocol/nips/blob/master/01.md
*/
export interface NostrMetadata {
name?: string // Display name (from username)
display_name?: string // Alternative display name
about?: string // Bio/description
picture?: string // Profile picture URL
banner?: string // Profile banner URL
nip05?: string // NIP-05 identifier (username@domain)
lud16?: string // Lightning Address (same as nip05)
website?: string // Personal website
}
/**
* Service for publishing and managing Nostr user metadata (NIP-01 kind 0)
*
* This service handles:
* - Publishing user profile metadata to Nostr relays
* - Syncing LNbits user data with Nostr profile
* - Auto-broadcasting metadata on login and profile updates
*/
export class NostrMetadataService extends BaseService {
protected readonly metadata = {
name: 'NostrMetadataService',
version: '1.0.0',
dependencies: ['AuthService', 'RelayHub']
}
protected authService: AuthService | null = null
protected relayHub: RelayHub | null = null
protected async onInitialize(): Promise<void> {
console.log('NostrMetadataService: Starting initialization...')
this.authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
this.relayHub = injectService<RelayHub>(SERVICE_TOKENS.RELAY_HUB)
if (!this.authService) {
throw new Error('AuthService not available')
}
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('NostrMetadataService: Initialization complete')
}
/**
* Build Nostr metadata from LNbits user data
*/
private buildMetadata(): NostrMetadata {
const user = this.authService?.user.value
if (!user) {
throw new Error('No authenticated user')
}
const lightningDomain = import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
const username = user.username || user.id.slice(0, 8)
const metadata: NostrMetadata = {
name: username,
nip05: `${username}@${lightningDomain}`,
lud16: `${username}@${lightningDomain}`
}
// Add optional fields from user.extra if they exist
if (user.extra?.display_name) {
metadata.display_name = user.extra.display_name
}
if (user.extra?.picture) {
metadata.picture = user.extra.picture
}
return metadata
}
/**
* Publish user metadata to Nostr relays (NIP-01 kind 0)
*
* This creates a replaceable event that updates the user's profile.
* Only the latest kind 0 event for a given pubkey is kept by relays.
*/
async publishMetadata(): Promise<{ success: number; total: number }> {
if (!this.authService?.isAuthenticated.value) {
throw new Error('Must be authenticated to publish metadata')
}
if (!this.relayHub?.isConnected.value) {
throw new Error('Not connected to relays')
}
const user = this.authService.user.value
if (!user?.prvkey) {
throw new Error('User private key not available')
}
try {
const metadata = this.buildMetadata()
console.log('📤 Publishing Nostr metadata (kind 0):', metadata)
// Create kind 0 event (user metadata)
// Content is JSON-stringified metadata
const eventTemplate: EventTemplate = {
kind: 0,
content: JSON.stringify(metadata),
tags: [],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(user.prvkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
console.log('✅ Metadata event signed:', signedEvent.id)
console.log('📋 Full signed event:', JSON.stringify(signedEvent, null, 2))
// Publish to all connected relays
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`✅ Metadata published to ${result.success}/${result.total} relays`)
return result
} catch (error) {
console.error('Failed to publish metadata:', error)
throw error
}
}
/**
* Get current user's Nostr metadata
*/
getMetadata(): NostrMetadata {
return this.buildMetadata()
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
protected async onDestroy(): Promise<void> {
// Cleanup if needed
}
}

View file

@ -1,4 +1,5 @@
import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools' import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools'
import type { SubscribeManyParams, SubCloser } from 'nostr-tools/abstract-pool'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { ref } from 'vue' import { ref } from 'vue'
@ -438,7 +439,7 @@ export class RelayHub extends BaseService {
} }
// Recreate the subscription // Recreate the subscription
const subscription = this.pool.subscribeMany(availableRelays, config.filters, { const subscription = this.poolSubscribe(availableRelays, config.filters, {
onevent: (event: Event) => { onevent: (event: Event) => {
config.onEvent?.(event) config.onEvent?.(event)
this.emit('event', { subscriptionId: id, event, relay: 'unknown' }) this.emit('event', { subscriptionId: id, event, relay: 'unknown' })
@ -482,7 +483,7 @@ export class RelayHub extends BaseService {
// Create subscription using the pool // Create subscription using the pool
const subscription = this.pool.subscribeMany(availableRelays, config.filters, { const subscription = this.poolSubscribe(availableRelays, config.filters, {
onevent: (event: Event) => { onevent: (event: Event) => {
config.onEvent?.(event) config.onEvent?.(event)
this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' }) this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' })
@ -550,6 +551,24 @@ export class RelayHub extends BaseService {
return { success: successful, total } return { success: successful, total }
} }
// nostr-tools 2.23+ deprecated the Filter[] form on pool.subscribeMany; route
// single-filter through pool.subscribe and multi-filter through subscribeMap
// so a single REQ-per-relay still carries every filter.
private poolSubscribe(
relays: string[],
filters: Filter[],
params: SubscribeManyParams
): SubCloser {
if (filters.length === 0) {
throw new Error('Cannot subscribe with empty filters')
}
if (filters.length === 1) {
return this.pool.subscribe(relays, filters[0], params)
}
const requests = relays.flatMap(url => filters.map(filter => ({ url, filter })))
return this.pool.subscribeMap(requests, params)
}
/** /**
* Query events from relays (one-time fetch) * Query events from relays (one-time fetch)
*/ */

View file

@ -0,0 +1,68 @@
import { BaseService } from '@/core/base/BaseService'
import { LNBITS_CONFIG } from '@/lib/config/lnbits'
/**
* Client for LNbits's nostr-transport (kind-21000 RPC over relays,
* landed upstream Sun May 24 2026, commit f235966c).
*
* **Disabled in phase 1/2 per design-questions Q4.2.** The kind-21000
* NIP-44 RPC transport requires the caller to hold a raw user prvkey
* for the NIP-44 v2 conversation key derivation AND for signing the
* outer envelope. Post-aiolabs/lnbits#9 the webapp no longer has
* prvkey at all, and `/auth/sign-event` doesn't list 21000 in
* `ALLOWED_KINDS`. Reviving this transport is phase-3+ work (Q4.3:
* lnbits-side change so the transport accepts an inner-payload
* identity claim rather than the outer pubkey).
*
* Every prior caller has been migrated:
* - ticket scanner events extension HTTP (PR #87)
* - bookmarks / RSVP / NIP-09 deletion `signEventViaLnbits`
*
* The service + DI registration stay as scaffolding so phase-3+
* revival is a single-file diff.
*/
interface RpcCallOptions {
walletId?: string
timeoutMs?: number
}
export class NostrRpcError extends Error {
constructor(public readonly message: string) {
super(message)
this.name = 'NostrRpcError'
}
}
export class NostrTransportService extends BaseService {
protected readonly metadata = {
name: 'NostrTransportService',
version: '1.0.0',
dependencies: ['AuthService', 'RelayHub'],
}
protected async onInitialize(): Promise<void> {
if (!LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY) {
this.debug(
'No VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY configured — RPC calls will fail',
)
} else {
this.debug(
`Initialized with server pubkey ${LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY.slice(0, 16)}` +
'(phase 1/2: call() is disabled per Q4.2)',
)
}
}
async call<T = unknown>(
_rpcName: string,
_body: Record<string, unknown>,
_options: RpcCallOptions = {},
): Promise<T> {
throw new Error(
'NostrTransportService.call is disabled in phase 1/2; deferred ' +
'to phase 3+ per design-questions Q4.2/Q4.3. Route through ' +
'extension HTTP endpoints (Bucket A) or signEventViaLnbits (Bucket B).',
)
}
}

View file

@ -1,256 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../../services/ExpensesAPI'
import type { Account } from '../../types'
import { PermissionType } from '../../types'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Loader2 } from 'lucide-vue-next'
interface Props {
isOpen: boolean
accounts: Account[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
permissionGranted: []
}>()
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const isGranting = ref(false)
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
// Form schema
const formSchema = toTypedSchema(
z.object({
user_id: z.string().min(1, 'User ID is required'),
account_id: z.string().min(1, 'Account is required'),
permission_type: z.nativeEnum(PermissionType, {
errorMap: () => ({ message: 'Permission type is required' })
}),
notes: z.string().optional(),
expires_at: z.string().optional()
})
)
// Setup form
const form = useForm({
validationSchema: formSchema,
initialValues: {
user_id: '',
account_id: '',
permission_type: PermissionType.READ,
notes: '',
expires_at: ''
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
// Permission type options
const permissionTypes = [
{ value: PermissionType.READ, label: 'Read', description: 'View account and balance' },
{
value: PermissionType.SUBMIT_EXPENSE,
label: 'Submit Expense',
description: 'Submit expenses to this account'
},
{ value: PermissionType.MANAGE, label: 'Manage', description: 'Full account management' }
]
// Submit form
const onSubmit = form.handleSubmit(async (values) => {
if (!adminKey.value) {
toast.error('Admin access required')
return
}
isGranting.value = true
try {
await expensesAPI.grantPermission(adminKey.value, {
user_id: values.user_id,
account_id: values.account_id,
permission_type: values.permission_type,
notes: values.notes || undefined,
expires_at: values.expires_at || undefined
})
emit('permissionGranted')
resetForm()
} catch (error) {
console.error('Failed to grant permission:', error)
toast.error('Failed to grant permission', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isGranting.value = false
}
})
// Handle dialog close
function handleClose() {
if (!isGranting.value) {
resetForm()
emit('close')
}
}
</script>
<template>
<Dialog :open="props.isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Grant Account Permission</DialogTitle>
<DialogDescription>
Grant a user permission to access an expense account. Permissions on parent accounts
cascade to children.
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4">
<!-- User ID -->
<FormField v-slot="{ componentField }" name="user_id">
<FormItem>
<FormLabel>User ID *</FormLabel>
<FormControl>
<Input
placeholder="Enter user wallet ID"
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>The wallet ID of the user to grant permission to</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Account -->
<FormField v-slot="{ componentField }" name="account_id">
<FormItem>
<FormLabel>Account *</FormLabel>
<Select v-bind="componentField" :disabled="isGranting">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select account" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="account in props.accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Account to grant access to</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Permission Type -->
<FormField v-slot="{ componentField }" name="permission_type">
<FormItem>
<FormLabel>Permission Type *</FormLabel>
<Select v-bind="componentField" :disabled="isGranting">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select permission type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="type in permissionTypes"
:key="type.value"
:value="type.value"
>
<div>
<div class="font-medium">{{ type.label }}</div>
<div class="text-sm text-muted-foreground">{{ type.description }}</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Type of permission to grant</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Expiration Date (Optional) -->
<FormField v-slot="{ componentField }" name="expires_at">
<FormItem>
<FormLabel>Expiration Date (Optional)</FormLabel>
<FormControl>
<Input
type="datetime-local"
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>Leave empty for permanent access</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Notes (Optional) -->
<FormField v-slot="{ componentField }" name="notes">
<FormItem>
<FormLabel>Notes (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Add notes about this permission..."
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>Optional notes for admin reference</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="handleClose"
:disabled="isGranting"
>
Cancel
</Button>
<Button type="submit" :disabled="isGranting || !isFormValid">
<Loader2 v-if="isGranting" class="mr-2 h-4 w-4 animate-spin" />
{{ isGranting ? 'Granting...' : 'Grant Permission' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -1,399 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../../services/ExpensesAPI'
import type { AccountPermission, Account } from '../../types'
import { PermissionType } from '../../types'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Loader2, Plus, Trash2, Users, Shield } from 'lucide-vue-next'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import GrantPermissionDialog from './GrantPermissionDialog.vue'
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const permissions = ref<AccountPermission[]>([])
const accounts = ref<Account[]>([])
const isLoading = ref(false)
const showGrantDialog = ref(false)
const permissionToRevoke = ref<AccountPermission | null>(null)
const showRevokeDialog = ref(false)
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
// Get permission type badge variant
function getPermissionBadge(type: PermissionType) {
switch (type) {
case PermissionType.READ:
return 'default'
case PermissionType.SUBMIT_EXPENSE:
return 'secondary'
case PermissionType.MANAGE:
return 'destructive'
default:
return 'outline'
}
}
// Get permission type label
function getPermissionLabel(type: PermissionType): string {
switch (type) {
case PermissionType.READ:
return 'Read'
case PermissionType.SUBMIT_EXPENSE:
return 'Submit Expense'
case PermissionType.MANAGE:
return 'Manage'
default:
return type
}
}
// Get account name by ID
function getAccountName(accountId: string): string {
const account = accounts.value.find((a) => a.id === accountId)
return account?.name || accountId
}
// Format date
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
// Load accounts
async function loadAccounts() {
if (!adminKey.value) return
try {
accounts.value = await expensesAPI.getAccounts(adminKey.value)
} catch (error) {
console.error('Failed to load accounts:', error)
toast.error('Failed to load accounts', {
description: error instanceof Error ? error.message : 'Unknown error'
})
}
}
// Load all permissions
async function loadPermissions() {
if (!adminKey.value) {
toast.error('Admin access required', {
description: 'You need admin privileges to manage permissions'
})
return
}
isLoading.value = true
try {
permissions.value = await expensesAPI.listPermissions(adminKey.value)
} catch (error) {
console.error('Failed to load permissions:', error)
toast.error('Failed to load permissions', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isLoading.value = false
}
}
// Handle permission granted
function handlePermissionGranted() {
showGrantDialog.value = false
loadPermissions()
toast.success('Permission granted', {
description: 'User permission has been successfully granted'
})
}
// Confirm revoke permission
function confirmRevoke(permission: AccountPermission) {
permissionToRevoke.value = permission
showRevokeDialog.value = true
}
// Revoke permission
async function revokePermission() {
if (!adminKey.value || !permissionToRevoke.value) return
try {
await expensesAPI.revokePermission(adminKey.value, permissionToRevoke.value.id)
toast.success('Permission revoked', {
description: 'User permission has been successfully revoked'
})
loadPermissions()
} catch (error) {
console.error('Failed to revoke permission:', error)
toast.error('Failed to revoke permission', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
showRevokeDialog.value = false
permissionToRevoke.value = null
}
}
// Group permissions by user
const permissionsByUser = computed(() => {
const grouped = new Map<string, AccountPermission[]>()
for (const permission of permissions.value) {
const existing = grouped.get(permission.user_id) || []
existing.push(permission)
grouped.set(permission.user_id, existing)
}
return grouped
})
// Group permissions by account
const permissionsByAccount = computed(() => {
const grouped = new Map<string, AccountPermission[]>()
for (const permission of permissions.value) {
const existing = grouped.get(permission.account_id) || []
existing.push(permission)
grouped.set(permission.account_id, existing)
}
return grouped
})
onMounted(() => {
loadAccounts()
loadPermissions()
})
</script>
<template>
<div class="container mx-auto p-6 space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Permission Management</h1>
<p class="text-muted-foreground">
Manage user access to expense accounts
</p>
</div>
<Button @click="showGrantDialog = true" :disabled="isLoading">
<Plus class="mr-2 h-4 w-4" />
Grant Permission
</Button>
</div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Shield class="h-5 w-5" />
Account Permissions
</CardTitle>
<CardDescription>
View and manage all account permissions. Permissions on parent accounts cascade to
children.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs default-value="by-user" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="by-user">
<Users class="mr-2 h-4 w-4" />
By User
</TabsTrigger>
<TabsTrigger value="by-account">
<Shield class="mr-2 h-4 w-4" />
By Account
</TabsTrigger>
</TabsList>
<!-- By User View -->
<TabsContent value="by-user" class="space-y-4">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="permissionsByUser.size === 0" class="text-center py-8">
<p class="text-muted-foreground">No permissions granted yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="[userId, userPermissions] in permissionsByUser"
:key="userId"
class="border rounded-lg p-4"
>
<h3 class="font-semibold mb-2">User: {{ userId }}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Granted</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Notes</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="permission in userPermissions" :key="permission.id">
<TableCell class="font-medium">
{{ getAccountName(permission.account_id) }}
</TableCell>
<TableCell>
<Badge :variant="getPermissionBadge(permission.permission_type)">
{{ getPermissionLabel(permission.permission_type) }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
<TableCell>
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ permission.notes || '-' }}
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="confirmRevoke(permission)"
:disabled="isLoading"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</TabsContent>
<!-- By Account View -->
<TabsContent value="by-account" class="space-y-4">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="permissionsByAccount.size === 0" class="text-center py-8">
<p class="text-muted-foreground">No permissions granted yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="[accountId, accountPermissions] in permissionsByAccount"
:key="accountId"
class="border rounded-lg p-4"
>
<h3 class="font-semibold mb-2">Account: {{ getAccountName(accountId) }}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Granted</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Notes</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="permission in accountPermissions" :key="permission.id">
<TableCell class="font-medium">{{ permission.user_id }}</TableCell>
<TableCell>
<Badge :variant="getPermissionBadge(permission.permission_type)">
{{ getPermissionLabel(permission.permission_type) }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
<TableCell>
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ permission.notes || '-' }}
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="confirmRevoke(permission)"
:disabled="isLoading"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<!-- Grant Permission Dialog -->
<GrantPermissionDialog
:is-open="showGrantDialog"
:accounts="accounts"
@close="showGrantDialog = false"
@permission-granted="handlePermissionGranted"
/>
<!-- Revoke Confirmation Dialog -->
<AlertDialog :open="showRevokeDialog" @update:open="showRevokeDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke Permission?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to revoke this permission? The user will immediately lose access.
<div v-if="permissionToRevoke" class="mt-4 p-4 bg-muted rounded-md">
<p class="font-medium">Permission Details:</p>
<p class="text-sm mt-2">
<strong>User:</strong> {{ permissionToRevoke.user_id }}
</p>
<p class="text-sm">
<strong>Account:</strong> {{ getAccountName(permissionToRevoke.account_id) }}
</p>
<p class="text-sm">
<strong>Type:</strong> {{ getPermissionLabel(permissionToRevoke.permission_type) }}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="revokePermission" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>

View file

@ -11,8 +11,6 @@ import type {
IncomeEntry, IncomeEntry,
AccountNode, AccountNode,
UserInfo, UserInfo,
AccountPermission,
GrantPermissionRequest,
TransactionListResponse TransactionListResponse
} from '../types' } from '../types'
import { appConfig } from '@/app.config' import { appConfig } from '@/app.config'
@ -343,93 +341,6 @@ export class ExpensesAPI extends BaseService {
} }
} }
/**
* List all account permissions (admin only)
*
* @param adminKey - Admin key for authentication
*/
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
try {
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
method: 'GET',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to list permissions: ${response.statusText}`)
}
const permissions = await response.json()
return permissions as AccountPermission[]
} catch (error) {
console.error('[ExpensesAPI] Error listing permissions:', error)
throw error
}
}
/**
* Grant account permission to a user (admin only)
*
* @param adminKey - Admin key for authentication
* @param request - Permission grant request
*/
async grantPermission(
adminKey: string,
request: GrantPermissionRequest
): Promise<AccountPermission> {
try {
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
method: 'POST',
headers: this.getHeaders(adminKey),
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to grant permission: ${response.statusText}`
throw new Error(errorMessage)
}
const permission = await response.json()
return permission as AccountPermission
} catch (error) {
console.error('[ExpensesAPI] Error granting permission:', error)
throw error
}
}
/**
* Revoke account permission (admin only)
*
* @param adminKey - Admin key for authentication
* @param permissionId - ID of the permission to revoke
*/
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
try {
const response = await fetch(
`${this.baseUrl}/libra/api/v1/permissions/${permissionId}`,
{
method: 'DELETE',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to revoke permission: ${response.statusText}`
throw new Error(errorMessage)
}
} catch (error) {
console.error('[ExpensesAPI] Error revoking permission:', error)
throw error
}
}
/** /**
* Get user's transactions from journal * Get user's transactions from journal
* *

View file

@ -28,25 +28,6 @@ export interface Account {
has_children?: boolean has_children?: boolean
} }
/**
* Account with user-specific permission metadata
* (Will be available once libra API implements permissions)
*/
export interface AccountWithPermissions extends Account {
user_permissions?: PermissionType[]
inherited_from?: string
}
/**
* Permission types for account access control
*/
export enum PermissionType {
READ = 'read',
SUBMIT_EXPENSE = 'submit_expense',
SUBMIT_INCOME = 'submit_income',
MANAGE = 'manage'
}
/** /**
* Expense entry request payload * Expense entry request payload
*/ */
@ -125,31 +106,6 @@ export interface UserInfo {
equity_account_name?: string equity_account_name?: string
} }
/**
* Account permission for user access control
*/
export interface AccountPermission {
id: string
user_id: string
account_id: string
permission_type: PermissionType
granted_at: string
granted_by: string
expires_at?: string
notes?: string
}
/**
* Grant permission request payload
*/
export interface GrantPermissionRequest {
user_id: string
account_id: string
permission_type: PermissionType
expires_at?: string
notes?: string
}
/** /**
* Transaction entry from journal (user view) * Transaction entry from journal (user view)
*/ */

View file

@ -12,10 +12,6 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
CheckCircle2,
Clock,
Flag,
XCircle,
RefreshCw, RefreshCw,
Calendar, Calendar,
Filter Filter
@ -30,14 +26,26 @@ const isLoading = ref(false)
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom' const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
const customStartDate = ref<string>('') const customStartDate = ref<string>('')
const customEndDate = ref<string>('') const customEndDate = ref<string>('')
const typeFilter = ref<'all' | 'income' | 'expense'>('all') // Each chip is an inclusion toggle for one bucket of rows. Every row
// belongs to exactly one bucket (voided rows go to 'voided' regardless
// of their income/expense type). Default hides voided.
type Category = 'income' | 'expense' | 'voided'
const typeFilterOptions = [ const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
{ label: 'All', value: 'all' as const },
{ label: 'Income', value: 'income' as const }, const categoryChips: { label: string; value: Category }[] = [
{ label: 'Expenses', value: 'expense' as const } { label: 'Income', value: 'income' },
{ label: 'Expenses', value: 'expense' },
{ label: 'Voided', value: 'voided' }
] ]
function toggleCategory(cat: Category) {
const next = new Set(activeCategories.value)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
activeCategories.value = next
}
function isIncome(t: Transaction): boolean { function isIncome(t: Transaction): boolean {
return t.tags?.includes('income-entry') ?? false return t.tags?.includes('income-entry') ?? false
} }
@ -46,6 +54,22 @@ function isExpense(t: Transaction): boolean {
return t.tags?.includes('expense-entry') ?? false return t.tags?.includes('expense-entry') ?? false
} }
function isVoided(t: Transaction): boolean {
return t.tags?.includes('voided') ?? false
}
function isPending(t: Transaction): boolean {
return t.flag === '!' && !isVoided(t)
}
// Which chip bucket a row falls into. Voided always wins over type.
function getBucket(t: Transaction): Category | null {
if (isVoided(t)) return 'voided'
if (isIncome(t)) return 'income'
if (isExpense(t)) return 'expense'
return null
}
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey) const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
// Fuzzy search state and configuration // Fuzzy search state and configuration
@ -71,12 +95,13 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
matchAllWhenSearchEmpty: true matchAllWhenSearchEmpty: true
} }
// Transactions to display (search results or all transactions), filtered by type // Transactions to display: row passes if its bucket's chip is active.
const transactionsToDisplay = computed(() => { const transactionsToDisplay = computed(() => {
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
if (typeFilter.value === 'income') return base.filter(isIncome) return base.filter(t => {
if (typeFilter.value === 'expense') return base.filter(isExpense) const bucket = getBucket(t)
return base return bucket !== null && activeCategories.value.has(bucket)
})
}) })
// Handle search results // Handle search results
@ -108,20 +133,28 @@ function formatAmount(amount: number): string {
return new Intl.NumberFormat('en-US').format(amount) return new Intl.NumberFormat('en-US').format(amount)
} }
// Get status icon and color based on flag // Income gets a leading '+', expense a leading '-'.
function getStatusInfo(flag?: string) { function getAmountSign(t: Transaction): string {
switch (flag) { if (isIncome(t)) return '+'
case '*': if (isExpense(t)) return '-'
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' } return ''
case '!':
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
case '#':
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
case 'x':
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
default:
return null
} }
// Color tint for the amount text. Voided entries drop to muted regardless
// of type since the strike-through carries the "ignore this" signal.
function getAmountColorClass(t: Transaction): string {
if (isVoided(t)) return 'line-through text-muted-foreground'
if (isIncome(t)) return 'text-green-600 dark:text-green-400'
if (isExpense(t)) return 'text-red-600 dark:text-red-400'
return ''
}
// Tags that drive other visual channels (border / sign / strike-through)
// suppressed from the badge row so it only carries user-added tags.
const TYPE_TAGS = new Set(['income-entry', 'expense-entry', 'voided'])
function getDisplayTags(t: Transaction): string[] {
return (t.tags ?? []).filter(tag => !TYPE_TAGS.has(tag))
} }
// Load transactions // Load transactions
@ -224,19 +257,20 @@ onMounted(() => {
</Button> </Button>
</div> </div>
<!-- Type Filter (All / Income / Expenses) --> <!-- Category chips: each chip toggles inclusion of one bucket
of rows. Defaults: Income + Expenses on, Voided off. -->
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<Filter class="h-4 w-4 text-muted-foreground" /> <Filter class="h-4 w-4 text-muted-foreground" />
<Button <Button
v-for="option in typeFilterOptions" v-for="chip in categoryChips"
:key="option.value" :key="chip.value"
:variant="typeFilter === option.value ? 'default' : 'outline'" :variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
size="sm" size="sm"
class="h-7 md:h-8 px-3 text-xs" class="h-7 md:h-8 px-3 text-xs"
@click="typeFilter = option.value" @click="toggleCategory(chip.value)"
:disabled="isLoading" :disabled="isLoading"
> >
{{ option.label }} {{ chip.label }}
</Button> </Button>
</div> </div>
@ -291,7 +325,7 @@ onMounted(() => {
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }} Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span> </span>
<span v-else> <span v-else>
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }} {{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span> </span>
</div> </div>
@ -307,7 +341,9 @@ onMounted(() => {
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4"> <div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
<p class="text-muted-foreground">No transactions found</p> <p class="text-muted-foreground">No transactions found</p>
<p class="text-sm text-muted-foreground mt-2"> <p class="text-sm text-muted-foreground mt-2">
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }} <template v-if="searchResults.length > 0">Try a different search term</template>
<template v-else-if="activeCategories.size === 0">Select a category above to see transactions</template>
<template v-else>Try selecting a different time period or toggling more categories</template>
</p> </p>
</div> </div>
@ -316,29 +352,19 @@ onMounted(() => {
<div <div
v-for="transaction in transactionsToDisplay" v-for="transaction in transactionsToDisplay"
:key="transaction.id" :key="transaction.id"
:class="[ class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
isIncome(transaction) && 'border-l-4 border-l-green-600',
isExpense(transaction) && 'border-l-4 border-l-red-600'
]"
> >
<!-- Transaction Header --> <!-- Transaction Header -->
<div class="flex items-start justify-between gap-3 mb-2"> <div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1"> <h3
<!-- Status Icon -->
<component
v-if="getStatusInfo(transaction.flag)"
:is="getStatusInfo(transaction.flag)!.icon"
:class="[ :class="[
'h-4 w-4 flex-shrink-0', 'font-medium text-sm sm:text-base truncate mb-1',
getStatusInfo(transaction.flag)!.color isVoided(transaction) && 'line-through text-muted-foreground'
]" ]"
/> >
<h3 class="font-medium text-sm sm:text-base truncate">
{{ transaction.description }} {{ transaction.description }}
</h3> </h3>
</div>
<p class="text-xs sm:text-sm text-muted-foreground"> <p class="text-xs sm:text-sm text-muted-foreground">
{{ formatDate(transaction.date) }} {{ formatDate(transaction.date) }}
</p> </p>
@ -346,11 +372,17 @@ onMounted(() => {
<!-- Amount --> <!-- Amount -->
<div class="text-right flex-shrink-0"> <div class="text-right flex-shrink-0">
<p class="font-semibold text-sm sm:text-base"> <p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
{{ formatAmount(transaction.amount) }} sats {{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
</p> </p>
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground"> <p
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }} v-if="transaction.fiat_amount"
:class="[
'text-xs',
getAmountColorClass(transaction) || 'text-muted-foreground'
]"
>
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
</p> </p>
</div> </div>
</div> </div>
@ -367,17 +399,42 @@ onMounted(() => {
<span class="font-medium">Ref:</span> {{ transaction.reference }} <span class="font-medium">Ref:</span> {{ transaction.reference }}
</div> </div>
<!-- Tags --> <!-- Badges: type (Income / Expense) + status (Voided / Pending,
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2"> mutually exclusive) + any user-added tags. -->
<div class="flex flex-wrap gap-1 mt-2">
<Badge <Badge
v-for="tag in transaction.tags" v-if="isIncome(transaction)"
variant="secondary"
class="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300"
>
Income
</Badge>
<Badge
v-else-if="isExpense(transaction)"
variant="secondary"
class="text-xs bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
>
Expense
</Badge>
<Badge
v-if="isVoided(transaction)"
variant="outline"
class="text-xs text-muted-foreground"
>
Voided
</Badge>
<Badge
v-else-if="isPending(transaction)"
variant="secondary"
class="text-xs bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300"
>
Pending approval
</Badge>
<Badge
v-for="tag in getDisplayTags(transaction)"
:key="tag" :key="tag"
variant="secondary" variant="secondary"
:class="[ class="text-xs"
'text-xs',
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
]"
> >
{{ tag }} {{ tag }}
</Badge> </Badge>

View file

@ -2,8 +2,6 @@ import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
import { useMarketStore } from '../stores/market' import { useMarketStore } from '../stores/market'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { config } from '@/lib/config' import { config } from '@/lib/config'
import type { NostrmarketService } from '../services/nostrmarketService'
import { nip59 } from 'nostr-tools'
import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { auth } from '@/composables/useAuthService' import { auth } from '@/composables/useAuthService'
@ -44,7 +42,6 @@ export function useMarket() {
const marketStore = useMarketStore() const marketStore = useMarketStore()
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
const nostrmarketService = injectService(SERVICE_TOKENS.NOSTRMARKET_SERVICE) as NostrmarketService
if (!relayHub) { if (!relayHub) {
throw new Error('RelayHub not available. Make sure base module is installed.') throw new Error('RelayHub not available. Make sure base module is installed.')
@ -433,56 +430,24 @@ export function useMarket() {
return null return null
} }
// Convert hex string to Uint8Array (browser-compatible)
const hexToUint8Array = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}
// Handle incoming order gift wraps (kind 1059) — payment requests, status updates. // Handle incoming order gift wraps (kind 1059) — payment requests, status updates.
// //
// The outer event's pubkey is an ephemeral key (NIP-59); the real merchant // **Disabled in phase 1/2 per design-questions Q4.2 / Bucket C.** NIP-59
// pubkey is on the unwrapped rumor. Content is JSON with a `type` field // gift-wrap unwrap requires a raw user prvkey for NIP-44 v2 decryption;
// (1 = payment request, 2 = order status update). // post-aiolabs/lnbits#9 the webapp doesn't hold one and lnbits doesn't
// yet expose a server-routed nip44_decrypt endpoint (would route through
// the signer ABC's existing nip44_decrypt method — phase-3+ work).
//
// Until then incoming order DMs are not processed by the webapp.
// Buyers will see order status changes via the nostrmarket extension's
// own server-side handling rather than relying on this client-side
// unwrap. Flag locally so we notice if the path becomes hot.
const handleOrderDM = async (event: any) => { const handleOrderDM = async (event: any) => {
try { console.warn(
console.log('🎁 Received order gift wrap:', event.id, '(kind', event.kind + ')') '[useMarket] Skipping order gift wrap (kind 1059) unwrap — phase 1/2 ' +
'has no prvkey access for NIP-44 decryption. Event id:',
const userPrivkey = event?.id,
authService.user.value?.prvkey ?? auth.currentUser.value?.prvkey )
if (!userPrivkey) {
console.warn('Cannot unwrap gift wrap: no user private key available')
return
}
const prvkeyBytes = hexToUint8Array(userPrivkey)
const rumor = nip59.unwrapEvent(event, prvkeyBytes)
console.log('🔓 Unwrapped rumor from merchant:', rumor.pubkey.slice(0, 10) + '...')
const messageData = JSON.parse(rumor.content)
console.log('📨 Parsed message data:', messageData)
switch (messageData.type) {
case 1: // Payment request
console.log('💰 Processing payment request for order:', messageData.id)
await nostrmarketService.handlePaymentRequest(messageData)
console.log('✅ Payment request processed successfully')
break
case 2: // Order status update
console.log('📦 Processing order status update for order:', messageData.id)
await nostrmarketService.handleOrderStatusUpdate(messageData)
console.log('✅ Order status update processed successfully')
break
default:
console.log('❓ Unknown message type:', messageData.type)
}
} catch (error) {
console.error('Failed to handle order gift wrap:', error)
}
} }
// Handle incoming market events // Handle incoming market events

View file

@ -13,16 +13,16 @@ import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lu
import { useFeed } from '../composables/useFeed' import { useFeed } from '../composables/useFeed'
import { useProfiles } from '@/modules/base/composables/useProfiles' import { useProfiles } from '@/modules/base/composables/useProfiles'
import { useReactions } from '@/modules/base/composables/useReactions' import { useReactions } from '@/modules/base/composables/useReactions'
import { useScheduledEvents } from '../composables/useScheduledEvents' import { useTasks } from '@/modules/tasks/composables/useTasks'
import ThreadedPost from './ThreadedPost.vue' import ThreadedPost from './ThreadedPost.vue'
import ScheduledEventCard from './ScheduledEventCard.vue' import ScheduledEventCard from './ScheduledEventCard.vue'
import appConfig from '@/app.config' import appConfig from '@/app.config'
import type { ContentFilter, FeedPost } from '../services/FeedService' import type { ContentFilter, FeedPost } from '../services/FeedService'
import type { ScheduledEvent } from '../services/ScheduledEventService' import type { ScheduledEvent } from '@/modules/tasks/services/TaskService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service' import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub' import type { RelayHub } from '@/modules/base/nostr/relay-hub'
import { finalizeEvent } from 'nostr-tools' import { signEventViaLnbits } from '@/lib/nostr/signing'
import { useToast } from '@/core/composables/useToast' import { useToast } from '@/core/composables/useToast'
interface Emits { interface Emits {
@ -98,7 +98,9 @@ const { getDisplayName, fetchProfiles } = useProfiles()
// Use reactions service for likes/hearts // Use reactions service for likes/hearts
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions() const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
// Use scheduled events service // Task service is shared with the standalone tasks app; FeedService
// already routes kind 31922/31925/5 events to it, so opt out of the
// composable's own subscription lifecycle.
const { const {
getEventsForSpecificDate, getEventsForSpecificDate,
getCompletion, getCompletion,
@ -109,7 +111,7 @@ const {
unclaimTask, unclaimTask,
deleteTask, deleteTask,
allCompletions allCompletions
} = useScheduledEvents() } = useTasks({ autoSubscribe: false })
// Selected date for viewing scheduled tasks (defaults to today) // Selected date for viewing scheduled tasks (defaults to today)
const selectedDate = ref(new Date().toISOString().split('T')[0]) const selectedDate = ref(new Date().toISOString().split('T')[0])
@ -364,15 +366,6 @@ function onToggleLimited(postId: string) {
limitedReplyPosts.value = newLimited limitedReplyPosts.value = newLimited
} }
// Helper function to convert hex string to Uint8Array
const hexToUint8Array = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
// Handle delete post button click - show confirmation dialog // Handle delete post button click - show confirmation dialog
function onDeletePost(note: FeedPost) { function onDeletePost(note: FeedPost) {
if (!authService?.isAuthenticated.value || !authService?.user.value) { if (!authService?.isAuthenticated.value || !authService?.user.value) {
@ -403,9 +396,8 @@ async function confirmDeletePost() {
return return
} }
const userPrivkey = authService?.user.value?.prvkey if (!authService?.user.value?.pubkey) {
if (!userPrivkey) { toast.error("Not signed in")
toast.error("User private key not available")
showDeleteDialog.value = false showDeleteDialog.value = false
postToDelete.value = null postToDelete.value = null
return return
@ -423,9 +415,8 @@ async function confirmDeletePost() {
created_at: Math.floor(Date.now() / 1000) created_at: Math.floor(Date.now() / 1000)
} }
// Sign the deletion event // Sign the deletion event server-side via lnbits.
const privkeyBytes = hexToUint8Array(userPrivkey) const signedEvent = await signEventViaLnbits(deletionEvent)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request // Publish the deletion request
const result = await relayHub.publishEvent(signedEvent) const result = await relayHub.publishEvent(signedEvent)

View file

@ -17,7 +17,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next' import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next'
import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService' import type { ScheduledEvent, EventCompletion, TaskStatus } from '@/modules/tasks/services/TaskService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service' import type { AuthService } from '@/modules/base/auth/auth-service'

View file

@ -1,102 +0,0 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ReactionService, EventReactions } from '../services/ReactionService'
import { useToast } from '@/core/composables/useToast'
/**
* Composable for managing reactions in the feed
*/
export function useReactions() {
const reactionService = injectService<ReactionService>(SERVICE_TOKENS.REACTION_SERVICE)
const toast = useToast()
/**
* Get reactions for a specific event
*/
const getEventReactions = (eventId: string): EventReactions => {
if (!reactionService) {
return {
eventId,
likes: 0,
dislikes: 0,
totalReactions: 0,
userHasLiked: false,
userHasDisliked: false,
reactions: []
}
}
return reactionService.getEventReactions(eventId)
}
/**
* Subscribe to reactions for a list of event IDs
*/
const subscribeToReactions = async (eventIds: string[]): Promise<void> => {
if (!reactionService || eventIds.length === 0) return
try {
await reactionService.subscribeToReactions(eventIds)
} catch (error) {
console.error('Failed to subscribe to reactions:', error)
}
}
/**
* Toggle like on an event - like if not liked, unlike if already liked
*/
const toggleLike = async (eventId: string, eventPubkey: string, eventKind: number): Promise<void> => {
if (!reactionService) {
toast.error('Reaction service not available')
return
}
try {
await reactionService.toggleLikeEvent(eventId, eventPubkey, eventKind)
// Check if we liked or unliked
const eventReactions = reactionService.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
toast.success('Post liked!')
} else {
toast.success('Like removed')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle reaction'
if (message.includes('authenticated')) {
toast.error('Please sign in to react to posts')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
toast.error(message)
}
console.error('Failed to toggle like:', error)
}
}
/**
* Get loading state
*/
const isLoading = computed(() => {
return reactionService?.isLoading ?? false
})
/**
* Get all event reactions (for debugging)
*/
const allEventReactions = computed(() => {
return reactionService?.eventReactions ?? new Map()
})
return {
// Methods
getEventReactions,
subscribeToReactions,
toggleLike,
// State
isLoading,
allEventReactions
}
}

View file

@ -1,261 +0,0 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
import type { AuthService } from '@/modules/base/auth/auth-service'
import { useToast } from '@/core/composables/useToast'
/**
* Composable for managing scheduled events in the feed
*/
export function useScheduledEvents() {
const scheduledEventService = injectService<ScheduledEventService>(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const toast = useToast()
// Get current user's pubkey
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
/**
* Get all scheduled events
*/
const getScheduledEvents = (): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getScheduledEvents()
}
/**
* Get events for a specific date (YYYY-MM-DD)
*/
const getEventsForDate = (date: string): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getEventsForDate(date)
}
/**
* Get events for a specific date (filtered by current user participation)
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
*/
const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value)
}
/**
* Get today's scheduled events (filtered by current user participation)
*/
const getTodaysEvents = (): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getTodaysEvents(currentUserPubkey.value)
}
/**
* Get completion status for an event
*/
const getCompletion = (eventAddress: string): EventCompletion | undefined => {
if (!scheduledEventService) return undefined
return scheduledEventService.getCompletion(eventAddress)
}
/**
* Check if an event is completed
*/
const isCompleted = (eventAddress: string): boolean => {
if (!scheduledEventService) return false
return scheduledEventService.isCompleted(eventAddress)
}
/**
* Get task status for an event
*/
const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
if (!scheduledEventService) return null
return scheduledEventService.getTaskStatus(eventAddress, occurrence)
}
/**
* Claim a task
*/
const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.claimTask(event, notes, occurrence)
toast.success('Task claimed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to claim task'
if (message.includes('authenticated')) {
toast.error('Please sign in to claim tasks')
} else {
toast.error(message)
}
console.error('Failed to claim task:', error)
}
}
/**
* Start a task (mark as in-progress)
*/
const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.startTask(event, notes, occurrence)
toast.success('Task started!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start task'
toast.error(message)
console.error('Failed to start task:', error)
}
}
/**
* Unclaim a task (remove task status)
*/
const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to unclaim task'
toast.error(message)
console.error('Failed to unclaim task:', error)
}
}
/**
* Toggle completion status of an event (optionally for a specific occurrence)
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
*/
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
if (!scheduledEventService) {
console.error('❌ useScheduledEvents: Scheduled event service not available')
toast.error('Scheduled event service not available')
return
}
try {
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence)
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
if (currentlyCompleted) {
console.log('⬇️ useScheduledEvents: Unclaiming task...')
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} else {
console.log('⬆️ useScheduledEvents: Marking as complete...')
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle completion'
if (message.includes('authenticated')) {
toast.error('Please sign in to complete tasks')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
toast.error(message)
}
console.error('❌ useScheduledEvents: Failed to toggle completion:', error)
}
}
/**
* Complete an event with optional notes
*/
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to complete task'
toast.error(message)
console.error('Failed to complete task:', error)
}
}
/**
* Get loading state
*/
const isLoading = computed(() => {
return scheduledEventService?.isLoading ?? false
})
/**
* Get all scheduled events (reactive)
*/
const allScheduledEvents = computed(() => {
return scheduledEventService?.scheduledEvents ?? new Map()
})
/**
* Delete a task (only author can delete)
*/
const deleteTask = async (event: ScheduledEvent): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.deleteTask(event)
toast.success('Task deleted!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete task'
toast.error(message)
console.error('Failed to delete task:', error)
}
}
/**
* Get all completions (reactive) - returns array for better reactivity
*/
const allCompletions = computed(() => {
if (!scheduledEventService?.completions) return []
return Array.from(scheduledEventService.completions.values())
})
return {
// Methods - Getters
getScheduledEvents,
getEventsForDate,
getEventsForSpecificDate,
getTodaysEvents,
getCompletion,
isCompleted,
getTaskStatus,
// Methods - Actions
claimTask,
startTask,
completeEvent,
unclaimTask,
deleteTask,
toggleComplete, // DEPRECATED: Use specific actions instead
// State
isLoading,
allScheduledEvents,
allCompletions
}
}

View file

@ -1,6 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService' import { BaseService } from '@/core/base/BaseService'
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { eventBus } from '@/core/event-bus' import { eventBus } from '@/core/event-bus'
import type { Event as NostrEvent, Filter } from 'nostr-tools' import type { Event as NostrEvent, Filter } from 'nostr-tools'
@ -47,7 +47,7 @@ export class FeedService extends BaseService {
protected relayHub: any = null protected relayHub: any = null
protected visibilityService: any = null protected visibilityService: any = null
protected reactionService: any = null protected reactionService: any = null
protected scheduledEventService: any = null protected taskService: any = null
// Event ID tracking for deduplication // Event ID tracking for deduplication
private seenEventIds = new Set<string>() private seenEventIds = new Set<string>()
@ -73,13 +73,12 @@ export class FeedService extends BaseService {
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE) this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE) this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
// ScheduledEventService moved to tasks module - use tryInjectService for backward compat this.taskService = injectService(SERVICE_TOKENS.TASK_SERVICE)
this.scheduledEventService = tryInjectService(SERVICE_TOKENS.TASK_SERVICE)
console.log('FeedService: RelayHub injected:', !!this.relayHub) console.log('FeedService: RelayHub injected:', !!this.relayHub)
console.log('FeedService: VisibilityService injected:', !!this.visibilityService) console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
console.log('FeedService: ReactionService injected:', !!this.reactionService) console.log('FeedService: ReactionService injected:', !!this.reactionService)
console.log('FeedService: TaskService injected:', !!this.scheduledEventService) console.log('FeedService: TaskService injected:', !!this.taskService)
if (!this.relayHub) { if (!this.relayHub) {
throw new Error('RelayHub service not available') throw new Error('RelayHub service not available')
@ -261,28 +260,19 @@ export class FeedService extends BaseService {
// Route reaction events (kind 7) to ReactionService // Route reaction events (kind 7) to ReactionService
if (event.kind === 7) { if (event.kind === 7) {
if (this.reactionService) {
this.reactionService.handleReactionEvent(event) this.reactionService.handleReactionEvent(event)
}
return return
} }
// Route scheduled events (kind 31922) to ScheduledEventService // Route scheduled events (kind 31922) to TaskService
if (event.kind === 31922) { if (event.kind === 31922) {
if (this.scheduledEventService) { this.taskService.handleScheduledEvent(event)
this.scheduledEventService.handleScheduledEvent(event)
}
return return
} }
// Route RSVP/completion events (kind 31925) to ScheduledEventService // Route RSVP/completion events (kind 31925) to TaskService
if (event.kind === 31925) { if (event.kind === 31925) {
console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService') this.taskService.handleCompletionEvent(event)
if (this.scheduledEventService) {
this.scheduledEventService.handleCompletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
@ -378,31 +368,19 @@ export class FeedService extends BaseService {
// Route to ReactionService for reaction deletions (kind 7) // Route to ReactionService for reaction deletions (kind 7)
if (deletedKind === '7') { if (deletedKind === '7') {
if (this.reactionService) {
this.reactionService.handleDeletionEvent(event) this.reactionService.handleDeletionEvent(event)
}
return return
} }
// Route to ScheduledEventService for completion/RSVP deletions (kind 31925) // Route to TaskService for completion/RSVP deletions (kind 31925)
if (deletedKind === '31925') { if (deletedKind === '31925') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService') this.taskService.handleDeletionEvent(event)
if (this.scheduledEventService) {
this.scheduledEventService.handleDeletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
// Route to ScheduledEventService for scheduled event deletions (kind 31922) // Route to TaskService for scheduled event deletions (kind 31922)
if (deletedKind === '31922') { if (deletedKind === '31922') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService') this.taskService.handleTaskDeletion(event)
if (this.scheduledEventService) {
this.scheduledEventService.handleTaskDeletion(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return return
} }
@ -623,17 +601,9 @@ export class FeedService extends BaseService {
* Get like count for a post from ReactionService * Get like count for a post from ReactionService
*/ */
private getLikeCount(postId: string): number { private getLikeCount(postId: string): number {
try {
if (this.reactionService && typeof this.reactionService.getEventReactions === 'function') {
const reactions = this.reactionService.getEventReactions(postId) const reactions = this.reactionService.getEventReactions(postId)
return reactions?.likes || 0 return reactions?.likes || 0
} }
} catch (error) {
// Silently fail if reaction service is not available
console.debug('FeedService: Could not get like count for post', postId, error)
}
return 0
}
/** /**
* Get filtered posts for specific feed type * Get filtered posts for specific feed type

View file

@ -1,585 +0,0 @@
import { ref, reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
export interface Reaction {
id: string
eventId: string // The event being reacted to
pubkey: string // Who reacted
content: string // The reaction content ('+', '-', emoji)
created_at: number
}
export interface EventReactions {
eventId: string
likes: number
dislikes: number
totalReactions: number
userHasLiked: boolean
userHasDisliked: boolean
userReactionId?: string // Track the user's reaction ID for deletion
reactions: Reaction[]
}
export class ReactionService extends BaseService {
protected readonly metadata = {
name: 'ReactionService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected authService: any = null
// Reaction state - indexed by event ID
private _eventReactions = reactive(new Map<string, EventReactions>())
private _isLoading = ref(false)
// Track reaction subscription
private currentSubscription: string | null = null
private currentUnsubscribe: (() => void) | null = null
// Track which events we're monitoring
private monitoredEvents = new Set<string>()
// Track deleted reactions to hide them
private deletedReactions = new Set<string>()
protected async onInitialize(): Promise<void> {
console.log('ReactionService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
// Deletion monitoring is now handled by FeedService's consolidated subscription
console.log('ReactionService: Initialization complete (deletion monitoring handled by FeedService)')
}
/**
* Get reactions for a specific event
*/
getEventReactions(eventId: string): EventReactions {
if (!this._eventReactions.has(eventId)) {
this._eventReactions.set(eventId, {
eventId,
likes: 0,
dislikes: 0,
totalReactions: 0,
userHasLiked: false,
userHasDisliked: false,
reactions: []
})
}
return this._eventReactions.get(eventId)!
}
/**
* Subscribe to reactions for a list of event IDs
*/
async subscribeToReactions(eventIds: string[]): Promise<void> {
if (eventIds.length === 0) return
// Filter out events we're already monitoring
const newEventIds = eventIds.filter(id => !this.monitoredEvents.has(id))
if (newEventIds.length === 0) return
console.log(`ReactionService: Subscribing to reactions for ${newEventIds.length} events`)
try {
if (!this.relayHub?.isConnected) {
await this.relayHub?.connect()
}
// Add to monitored set
newEventIds.forEach(id => this.monitoredEvents.add(id))
const subscriptionId = `reactions-${Date.now()}`
// Subscribe to reactions (kind 7) for these events
// Deletions (kind 5) are now handled by FeedService's consolidated subscription
const filters = [
{
kinds: [7], // Reactions
'#e': newEventIds, // Events being reacted to
limit: 1000
}
]
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: filters,
onEvent: (event: NostrEvent) => {
this.handleReactionEvent(event)
},
onEose: () => {
console.log(`ReactionService: Subscription ${subscriptionId} ready`)
}
})
// Store subscription info (we can have multiple)
if (!this.currentSubscription) {
this.currentSubscription = subscriptionId
this.currentUnsubscribe = unsubscribe
}
} catch (error) {
console.error('Failed to subscribe to reactions:', error)
}
}
/**
* Handle incoming reaction event
* Made public so FeedService can route kind 7 events to this service
*/
public handleReactionEvent(event: NostrEvent): void {
try {
// Find the event being reacted to
const eTag = event.tags.find(tag => tag[0] === 'e')
if (!eTag || !eTag[1]) {
console.warn('Reaction event missing e tag:', event.id)
return
}
const eventId = eTag[1]
const content = event.content.trim()
// Create reaction object
const reaction: Reaction = {
id: event.id,
eventId,
pubkey: event.pubkey,
content,
created_at: event.created_at
}
// Update event reactions
const eventReactions = this.getEventReactions(eventId)
// Check if this reaction already exists (deduplication) or is deleted
const existingIndex = eventReactions.reactions.findIndex(r => r.id === reaction.id)
if (existingIndex >= 0) {
return // Already have this reaction
}
// Check if this reaction has been deleted
if (this.deletedReactions.has(reaction.id)) {
return // This reaction was deleted
}
// IMPORTANT: Remove any previous reaction from the same user
// This ensures one reaction per user per event, even if deletion events aren't processed
const previousReactionIndex = eventReactions.reactions.findIndex(r =>
r.pubkey === reaction.pubkey &&
r.content === reaction.content
)
if (previousReactionIndex >= 0) {
// Replace the old reaction with the new one
eventReactions.reactions[previousReactionIndex] = reaction
} else {
// Add as new reaction
eventReactions.reactions.push(reaction)
}
// Recalculate counts and user state
this.recalculateEventReactions(eventId)
} catch (error) {
console.error('Failed to handle reaction event:', error)
}
}
/**
* Handle deletion event (called by FeedService when a kind 5 event with k=7 is received)
* Made public so FeedService can route deletion events to this service
*/
public handleDeletionEvent(event: NostrEvent): void {
try {
// Process each deleted event
const eTags = event.tags.filter(tag => tag[0] === 'e')
const deletionAuthor = event.pubkey
for (const eTag of eTags) {
const deletedEventId = eTag[1]
if (deletedEventId) {
// Add to deleted set
this.deletedReactions.add(deletedEventId)
// Find and remove the reaction from all event reactions
for (const [eventId, eventReactions] of this._eventReactions) {
const reactionIndex = eventReactions.reactions.findIndex(r => r.id === deletedEventId)
if (reactionIndex >= 0) {
const reaction = eventReactions.reactions[reactionIndex]
// IMPORTANT: Only process deletion if it's from the same user who created the reaction
// This follows NIP-09 spec: "Relays SHOULD delete or stop publishing any referenced events
// that have an identical `pubkey` as the deletion request"
if (reaction.pubkey === deletionAuthor) {
eventReactions.reactions.splice(reactionIndex, 1)
// Recalculate counts for this event
this.recalculateEventReactions(eventId)
}
}
}
}
}
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Recalculate reaction counts and user state for an event
*/
private recalculateEventReactions(eventId: string): void {
const eventReactions = this.getEventReactions(eventId)
const userPubkey = this.authService?.user?.value?.pubkey
// Use Sets to track unique users who liked/disliked
const likedUsers = new Set<string>()
const dislikedUsers = new Set<string>()
let userHasLiked = false
let userHasDisliked = false
let userReactionId: string | undefined
// Group reactions by user, keeping only the most recent
const latestReactionsByUser = new Map<string, Reaction>()
for (const reaction of eventReactions.reactions) {
// Skip deleted reactions
if (this.deletedReactions.has(reaction.id)) {
continue
}
// Keep only the latest reaction from each user
const existing = latestReactionsByUser.get(reaction.pubkey)
if (!existing || reaction.created_at > existing.created_at) {
latestReactionsByUser.set(reaction.pubkey, reaction)
}
}
// Now count unique reactions
for (const reaction of latestReactionsByUser.values()) {
const isLike = reaction.content === '+' || reaction.content === '❤️' || reaction.content === ''
const isDislike = reaction.content === '-'
if (isLike) {
likedUsers.add(reaction.pubkey)
if (userPubkey && reaction.pubkey === userPubkey) {
userHasLiked = true
userReactionId = reaction.id
}
} else if (isDislike) {
dislikedUsers.add(reaction.pubkey)
if (userPubkey && reaction.pubkey === userPubkey) {
userHasDisliked = true
userReactionId = reaction.id
}
}
}
// Update the reactive state with unique user counts
eventReactions.likes = likedUsers.size
eventReactions.dislikes = dislikedUsers.size
eventReactions.totalReactions = latestReactionsByUser.size
eventReactions.userHasLiked = userHasLiked
eventReactions.userHasDisliked = userHasDisliked
eventReactions.userReactionId = userReactionId
}
/**
* Send a heart reaction (like) to an event
*/
async likeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Check if user already liked this event
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
throw new Error('Already liked this event')
}
try {
this._isLoading.value = true
// Create reaction event template according to NIP-25
const eventTemplate: EventTemplate = {
kind: 7, // Reaction
content: '+', // Like reaction
tags: [
['e', eventId, '', eventPubkey], // Event being reacted to
['p', eventPubkey], // Author of the event being reacted to
['k', eventKind.toString()] // Kind of the event being reacted to
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the reaction
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to like event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a like from an event (unlike) using NIP-09 deletion events
*/
async unlikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Get the user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasLiked || !eventReactions.userReactionId) {
throw new Error('No reaction to remove')
}
try {
this._isLoading.value = true
// Create deletion event according to NIP-09
const eventTemplate: EventTemplate = {
kind: 5, // Deletion request
content: '', // Empty content or reason
tags: [
['e', eventReactions.userReactionId], // The reaction event to delete
['k', '7'] // Kind of event being deleted (reaction)
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Deletion published to ${result.success}/${result.total} relays`)
// Optimistically update local state
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to unlike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Toggle like on an event - like if not liked, unlike if already liked
*/
async toggleLikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
// Unlike the event
await this.unlikeEvent(eventId)
} else {
// Like the event
await this.likeEvent(eventId, eventPubkey, eventKind)
}
}
/**
* Send a dislike reaction to an event
*/
async dislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Check if user already disliked this event
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasDisliked) {
throw new Error('Already disliked this event')
}
try {
this._isLoading.value = true
// Create reaction event template according to NIP-25
const eventTemplate: EventTemplate = {
kind: 7, // Reaction
content: '-', // Dislike reaction
tags: [
['e', eventId, '', eventPubkey], // Event being reacted to
['p', eventPubkey], // Author of the event being reacted to
['k', eventKind.toString()] // Kind of the event being reacted to
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the reaction
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to dislike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a dislike from an event using NIP-09 deletion events
*/
async undislikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Get the user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasDisliked || !eventReactions.userReactionId) {
throw new Error('No dislike reaction to remove')
}
try {
this._isLoading.value = true
// Create deletion event according to NIP-09
const eventTemplate: EventTemplate = {
kind: 5, // Deletion request
content: '', // Empty content or reason
tags: [
['e', eventReactions.userReactionId], // The reaction event to delete
['k', '7'] // Kind of event being deleted (reaction)
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Dislike deletion published to ${result.success}/${result.total} relays`)
// Optimistically update local state
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to remove dislike:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Get all event reactions
*/
get eventReactions(): Map<string, EventReactions> {
return this._eventReactions
}
/**
* Check if currently loading
*/
get isLoading(): boolean {
return this._isLoading.value
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
if (this.currentUnsubscribe) {
this.currentUnsubscribe()
}
// deletionUnsubscribe is no longer used - deletions handled by FeedService
this._eventReactions.clear()
this.monitoredEvents.clear()
this.deletedReactions.clear()
}
}

View file

@ -1,678 +0,0 @@
import { ref, reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
export interface RecurrencePattern {
frequency: 'daily' | 'weekly'
dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc.
endDate?: string // ISO date string - when to stop recurring (optional)
}
export interface ScheduledEvent {
id: string
pubkey: string
created_at: number
dTag: string // Unique identifier from 'd' tag
title: string
start: string // ISO date string (YYYY-MM-DD or ISO datetime)
end?: string
description?: string
location?: string
status: string
eventType?: string // 'task' for completable events, 'announcement' for informational
participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer'
content: string
tags: string[][]
recurrence?: RecurrencePattern // Optional: for recurring events
}
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
export interface EventCompletion {
id: string
eventAddress: string // "31922:pubkey:d-tag"
occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
pubkey: string // Who claimed/completed it
created_at: number
taskStatus: TaskStatus
completedAt?: number // Unix timestamp when completed
notes: string
}
export class ScheduledEventService extends BaseService {
protected readonly metadata = {
name: 'ScheduledEventService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected authService: any = null
// Scheduled events state - indexed by event address
private _scheduledEvents = reactive(new Map<string, ScheduledEvent>())
private _completions = reactive(new Map<string, EventCompletion>())
private _isLoading = ref(false)
protected async onInitialize(): Promise<void> {
console.log('ScheduledEventService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('ScheduledEventService: Initialization complete')
}
/**
* Handle incoming scheduled event (kind 31922)
* Made public so FeedService can route kind 31922 events to this service
*/
public handleScheduledEvent(event: NostrEvent): void {
try {
// Extract event data from tags
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
if (!dTag) {
console.warn('Scheduled event missing d tag:', event.id)
return
}
const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event'
const start = event.tags.find(tag => tag[0] === 'start')?.[1]
const end = event.tags.find(tag => tag[0] === 'end')?.[1]
const description = event.tags.find(tag => tag[0] === 'description')?.[1]
const location = event.tags.find(tag => tag[0] === 'location')?.[1]
const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending'
const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1]
// Parse participant tags: ["p", "<pubkey>", "<relay-hint>", "<participation-type>"]
const participantTags = event.tags.filter(tag => tag[0] === 'p')
const participants = participantTags.map(tag => ({
pubkey: tag[1],
type: tag[3] // 'required', 'optional', 'organizer'
}))
// Parse recurrence tags
const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined
const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1]
const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1]
let recurrence: RecurrencePattern | undefined
if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') {
recurrence = {
frequency: recurrenceFreq,
dayOfWeek: recurrenceDayOfWeek,
endDate: recurrenceEndDate
}
}
if (!start) {
console.warn('Scheduled event missing start date:', event.id)
return
}
// Create event address: "kind:pubkey:d-tag"
const eventAddress = `31922:${event.pubkey}:${dTag}`
const scheduledEvent: ScheduledEvent = {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
dTag,
title,
start,
end,
description,
location,
status,
eventType,
participants: participants.length > 0 ? participants : undefined,
content: event.content,
tags: event.tags,
recurrence
}
// Store or update the event (replaceable by d-tag)
this._scheduledEvents.set(eventAddress, scheduledEvent)
} catch (error) {
console.error('Failed to handle scheduled event:', error)
}
}
/**
* Handle RSVP/completion event (kind 31925)
* Made public so FeedService can route kind 31925 events to this service
*/
public handleCompletionEvent(event: NostrEvent): void {
console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id)
try {
// Find the event being responded to
const aTag = event.tags.find(tag => tag[0] === 'a')?.[1]
if (!aTag) {
console.warn('Completion event missing a tag:', event.id)
return
}
// Parse task status (new approach)
const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
// Backward compatibility: check old 'completed' tag if task-status not present
let taskStatus: TaskStatus
if (taskStatusTag) {
taskStatus = taskStatusTag
} else {
// Legacy support: convert old 'completed' tag to new taskStatus
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
taskStatus = completed ? 'completed' : 'claimed'
}
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
console.log('📋 Completion details:', {
aTag,
occurrence,
taskStatus,
pubkey: event.pubkey,
eventId: event.id
})
const completion: EventCompletion = {
id: event.id,
eventAddress: aTag,
occurrence,
pubkey: event.pubkey,
created_at: event.created_at,
taskStatus,
completedAt,
notes: event.content
}
// Store completion (most recent one wins)
// For recurring events, include occurrence in the key: "eventAddress:occurrence"
// For non-recurring, just use eventAddress
const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag
const existing = this._completions.get(completionKey)
if (!existing || event.created_at > existing.created_at) {
this._completions.set(completionKey, completion)
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
} else {
console.log('⏭️ Skipped older completion for:', completionKey)
}
} catch (error) {
console.error('Failed to handle completion event:', error)
}
}
/**
* Handle deletion event (kind 5) for completion events
* Made public so FeedService can route deletion events to this service
*/
public handleDeletionEvent(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id)
try {
// Extract event IDs to delete from 'e' tags
const eventIdsToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'e')
.map((tag: string[]) => tag[1]) || []
if (eventIdsToDelete.length === 0) {
console.warn('Deletion event missing e tags:', event.id)
return
}
console.log('🔍 Looking for completions to delete:', eventIdsToDelete)
// Find and remove completions that match the deleted event IDs
let deletedCount = 0
for (const [completionKey, completion] of this._completions.entries()) {
// Only delete if:
// 1. The completion event ID matches one being deleted
// 2. The deletion request comes from the same author (NIP-09 validation)
if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) {
this._completions.delete(completionKey)
console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id)
deletedCount++
}
}
console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`)
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Handle deletion event (kind 5) for scheduled events (kind 31922)
* Made public so FeedService can route deletion events to this service
*/
public handleTaskDeletion(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id)
try {
// Extract event addresses to delete from 'a' tags
const eventAddressesToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'a')
.map((tag: string[]) => tag[1]) || []
if (eventAddressesToDelete.length === 0) {
console.warn('Task deletion event missing a tags:', event.id)
return
}
console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete)
// Find and remove tasks that match the deleted event addresses
let deletedCount = 0
for (const eventAddress of eventAddressesToDelete) {
const task = this._scheduledEvents.get(eventAddress)
// Only delete if:
// 1. The task exists
// 2. The deletion request comes from the task author (NIP-09 validation)
if (task && task.pubkey === event.pubkey) {
this._scheduledEvents.delete(eventAddress)
console.log('✅ Deleted task:', eventAddress)
deletedCount++
} else if (task) {
console.warn('⚠️ Deletion request not from task author:', eventAddress)
}
}
console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`)
} catch (error) {
console.error('Failed to handle task deletion event:', error)
}
}
/**
* Get all scheduled events
*/
getScheduledEvents(): ScheduledEvent[] {
return Array.from(this._scheduledEvents.values())
}
/**
* Get events scheduled for a specific date (YYYY-MM-DD)
*/
getEventsForDate(date: string): ScheduledEvent[] {
return this.getScheduledEvents().filter(event => {
// Simple date matching (start date)
// For ISO datetime strings, extract just the date part
const eventDate = event.start.split('T')[0]
return eventDate === date
})
}
/**
* Check if a recurring event occurs on a specific date
*/
private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean {
if (!event.recurrence) return false
const target = new Date(targetDate)
const eventStart = new Date(event.start.split('T')[0]) // Get date part only
// Check if target date is before the event start date
if (target < eventStart) return false
// Check if target date is after the event end date (if specified)
if (event.recurrence.endDate) {
const endDate = new Date(event.recurrence.endDate)
if (target > endDate) return false
}
// Check frequency-specific rules
if (event.recurrence.frequency === 'daily') {
// Daily events occur every day within the range
return true
} else if (event.recurrence.frequency === 'weekly') {
// Weekly events occur on specific day of week
const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase()
return targetDayOfWeek === eventDayOfWeek
}
return false
}
/**
* Get events for a specific date, optionally filtered by user participation
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
* @param userPubkey - Optional user pubkey to filter by participation
*/
getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] {
const targetDate = date || new Date().toISOString().split('T')[0]
// Get one-time events for the date (exclude recurring events to avoid duplicates)
const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence)
// Get all events and check for recurring events that occur on this date
const allEvents = this.getScheduledEvents()
const recurringEventsOnDate = allEvents.filter(event =>
event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate)
)
// Combine one-time and recurring events
let events = [...oneTimeEvents, ...recurringEventsOnDate]
// Filter events based on participation (if user pubkey provided)
if (userPubkey) {
events = events.filter(event => {
// If event has no participants, it's community-wide (show to everyone)
if (!event.participants || event.participants.length === 0) return true
// Otherwise, only show if user is a participant
return event.participants.some(p => p.pubkey === userPubkey)
})
}
// Sort by start time (ascending order)
events.sort((a, b) => {
// ISO datetime strings can be compared lexicographically
return a.start.localeCompare(b.start)
})
return events
}
/**
* Get events for today, optionally filtered by user participation
*/
getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
return this.getEventsForSpecificDate(undefined, userPubkey)
}
/**
* Get completion status for an event (optionally for a specific occurrence)
*/
getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined {
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
return this._completions.get(completionKey)
}
/**
* Check if an event is completed (optionally for a specific occurrence)
*/
isCompleted(eventAddress: string, occurrence?: string): boolean {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus === 'completed'
}
/**
* Get task status for an event
*/
getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus || null
}
/**
* Claim a task (mark as claimed)
*/
async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'claimed', notes, occurrence)
}
/**
* Start a task (mark as in-progress)
*/
async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
}
/**
* Mark an event as complete (optionally for a specific occurrence)
*/
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'completed', notes, occurrence)
}
/**
* Internal method to update task status
*/
private async updateTaskStatus(
event: ScheduledEvent,
taskStatus: TaskStatus,
notes: string = '',
occurrence?: string
): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to update task status')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
if (!userPrivkey) {
throw new Error('User private key not available')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create RSVP event with task-status tag
const tags: string[][] = [
['a', eventAddress],
['task-status', taskStatus]
]
// Add completed_at timestamp if task is completed
if (taskStatus === 'completed') {
tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
}
// Add occurrence tag if provided (for recurring events)
if (occurrence) {
tags.push(['occurrence', occurrence])
}
const eventTemplate: EventTemplate = {
kind: 31925, // Calendar Event RSVP
content: notes,
tags,
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the status update
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
// Update local state (publishEvent throws if no relays accepted)
console.log('🔄 Updating local state (event published successfully)')
this.handleCompletionEvent(signedEvent)
} catch (error) {
console.error('Failed to update task status:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Unclaim/reset a task (removes task status - makes it unclaimed)
* Note: In Nostr, we can't truly "delete" an event, but we can publish
* a deletion request (kind 5) to ask relays to remove our RSVP
*/
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to unclaim tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
if (!userPrivkey) {
throw new Error('User private key not available')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
const completion = this._completions.get(completionKey)
if (!completion) {
console.log('No completion to unclaim')
return
}
// Create deletion event (kind 5) for the RSVP
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task unclaimed',
tags: [
['e', completion.id], // Reference to the RSVP event being deleted
['k', '31925'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task RSVP:', completion.id)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._completions.delete(completionKey)
console.log('🗑️ Removed completion from local state:', completionKey)
} catch (error) {
console.error('Failed to unclaim task:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Delete a scheduled event (kind 31922)
* Only the author can delete their own event
*/
async deleteTask(event: ScheduledEvent): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to delete tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
const userPubkey = this.authService.user.value?.pubkey
if (!userPrivkey || !userPubkey) {
throw new Error('User credentials not available')
}
// Only author can delete
if (userPubkey !== event.pubkey) {
throw new Error('Only the task author can delete this task')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create deletion event (kind 5) for the scheduled event
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task deleted',
tags: [
['a', eventAddress], // Reference to the parameterized replaceable event being deleted
['k', '31922'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task:', eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._scheduledEvents.delete(eventAddress)
console.log('🗑️ Removed task from local state:', eventAddress)
} catch (error) {
console.error('Failed to delete task:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Get all scheduled events
*/
get scheduledEvents(): Map<string, ScheduledEvent> {
return this._scheduledEvents
}
/**
* Get all completions
*/
get completions(): Map<string, EventCompletion> {
return this._completions
}
/**
* Check if currently loading
*/
get isLoading(): boolean {
return this._isLoading.value
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
this._scheduledEvents.clear()
this._completions.clear()
}
}