Compare commits

...

27 commits

Author SHA1 Message Date
65770ad9be feat(chatelet): pick check-in/check-out with the shared DatePicker
Replace the two native <input type="date"> fields on the room detail
page with the base DatePicker (Popover + shadcn Calendar), the same
component the events editor uses. Check-in is floored at today and
check-out at the morning after check-in; moving check-in past the
current check-out clears it, and any change resets the last quote.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 22:41:14 +02:00
3720dbc35a feat(chatelet): standalone app scaffold (second Vite entry)
chatelet.html + src/chatelet-app/{main,app,App,app.config,views/SettingsPage}
+ vite.chatelet.config.ts (port 5188, cacheDir .vite-chatelet, dist-chatelet,
manifest id aiolabs-chatelet), mirroring the events standalone. package.json
gains dev:/build:/preview:chatelet + entries in dev:all and build:demo
(VITE_BASE_PATH=/chatelet/). Verified: build:chatelet typechecks + bundles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-09-15 22:33:29 +02:00
654be4bf8a feat(chatelet): guest module — ChateletApiService + browse/availability views
Slice-1 part B of the chatelet guest booking UI (aiolabs/webapp #141).

- ChateletApiService: plain HTTP wrapper (events TicketApiService pattern) →
  chatelet's public guest endpoints (/public/rooms, /availability). Unauth by
  default; swaps HTTP→kind-21000 RPC once aiolabs/lnbits#64 lands, no view change.
- useChatelet composable + pinia store (DI via tryInjectService, never direct import).
- Views: ChateletPage (room grid) + ChateletDetailPage (details + date-range
  availability check with sats/fiat quote; Book button stubbed for slice 2).
- SERVICE_TOKENS.CHATELET_API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
2026-09-15 22:33:29 +02:00
8b5baeb06d Merge pull request 'feat(events): promo codes — organizer editor and server-validated checkout discount' (#153) from feat/events-promo-codes into dev
Reviewed-on: #153
2026-09-13 16:55:14 +00:00
7dbe04afa3 feat(events): show the redeemed promo code in the scanner roster
Maps applied_promo_code from the stats endpoint onto the roster rows as a
small outline badge and includes it in the fuzzy search keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:22:13 +02:00
a1b9aaf2a2 feat(events): promo code entry with server-validated total at checkout
usePromoCode drives an Apply flow against the validate endpoint (states
idle / checking / applied / invalid / unverified; quantity changes
re-validate with a debounce; stale responses are ignored; 404 from an
older extension keeps the code and sends it unverified). The dialog shows
the struck-through original and the discounted total in the header, price
block, conversion preview and invoice amount; the code rides on both the
Lightning and card requests. A 100 %-off code on the card rail lands on the
success screen via finalizePurchasedTickets instead of the "no checkout URL"
error, and a guest whose total becomes zero gets an inline log-in prompt
(free claims stay login-gated, events#29) rather than a swapped form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:22:13 +02:00
4162e0af99 feat(events): promo codes editor in the create/edit event dialog
Collapsible "Promo codes" section: rows of code (upper-cased as typed),
discount %, max uses (blank = unlimited), active switch, remove; per-row
errors from validatePromoRows gate submit alongside the vee-validate form.
extra.promo_codes is always sent explicitly (empty editor = clear); edit
mode prefills from the authenticated record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:22:13 +02:00
262e674f9f fix(events): let the organizer's own record win when merging My Events
The authenticated fetch returns the full Event (incl. extra.promo_codes,
which public responses omit as of events ext v1.6.1-aio.12) and is what
the edit dialog must prefill from. Public records still win for events the
user does not own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:22:13 +02:00
a68fed916d i18n(events): promo code strings for the editor and the purchase dialog
New events.create.promo.* (first key under a create namespace; the rest
of the create dialog remains hardcoded English) and events.purchase.promo.*
in en/es/fr + the types interface.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:22:13 +02:00
0a21c092ff feat(events): promo code wire types, validate endpoint client, pure helpers
Types follow upstream v2 names (PromoCode.max_uses / used_count,
PromoValidateRequest, BasketDiscount, BasketTotals) as served by the
events extension >= v1.6.1-aio.12; EventExtra.promo_codes becomes
optional because public event responses no longer carry it.

TicketApiService.validatePromoCode posts the upper-cased code + quantity
to POST /events/api/v1/promo/validate/{event_id}; request() now attaches
the HTTP status to thrown errors so a 404 from an older extension can be
told apart from a 400. lib/promoCodes.ts holds the pure helpers shared by
the organizer editor and the buyer dialog (normalise, editor validation
returning i18n keys, wire <-> row mapping, display-only percent rounding).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:22:12 +02:00
9c0abfae1f fix(events): let the event card shrink below its truncated rows (min-w-0)
On phone widths the event list is a one-column grid, and a grid item
defaults to min-width:auto (= min-content). The card's nowrap truncate
rows (location, organizer) push that min-content past the viewport for
any event with a long address, so the card spilled off screen and the
text was clipped by the window instead of ellipsised. min-w-0 on the
Card root lets the item shrink; overflow-hidden would have clipped the
pending/rejected badge that deliberately hangs past the bottom edge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVxfxB2sDFrdwnXrjhEp1b
2026-09-10 06:09:10 +02:00
4faa57f87a fix(events): resolve the ticket QR logo through @brand-app-logo
ticketQr.ts imported @brand/logo.png by literal filename, so any brand
kit that ships its logo as SVG only (cfaun's does) failed the events
standalone build with ENOENT. Every other logo consumer goes through
the @brand-app-logo alias, which resolves the per-app or brand logo in
SVG-then-PNG order; use the same alias here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nx87UjQtd1HS5u4DwCMve8
2026-09-09 14:48:31 +02:00
b080d802c5 Merge pull request 'fix(events): pin the sticky buy bar flush above the nav (--bottom-nav-height)' (#150) from fix/events-sticky-bar-gap into dev
Reviewed-on: #150
2026-09-09 11:19:46 +00:00
6e4e2f3d0c fix(events): pin the sticky buy bar flush above the nav
Two causes of the visible gap under the bar on phone and desktop: the
bar lived inside the page's `space-y-6` column, whose child margin also
offsets a fixed box (24px), and its bottom offset was hand-derived. It
now teleports to <body> and uses `--bottom-nav-height`. Measured in a
headless run: bar bottom == nav top on 390px and 1280px viewports.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-09 13:19:05 +02:00
82f7e5659a feat(layout): publish the bottom nav's rendered height as --bottom-nav-height
BottomNav measures itself (ResizeObserver) and writes
`--bottom-nav-height` on <html>; index.css declares the 0px default. Any
element that must sit flush above the nav pins with
`bottom: var(--bottom-nav-height)` instead of re-deriving
`3.5rem + env(safe-area-inset-bottom)` and drifting when the device's
inset, border or font scaling differs from that guess. The restaurant
item page's add-to-cart bar is migrated to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-09 13:19:05 +02:00
9eb75a2017 feat(events): surface the buy-ticket CTA above the description + sticky bar
The ticket block (owned banner, Buy ticket + availability, past/sold-out
notices, and the host's Scan CTA) moved from below the description to
directly under the date and location. A long description was pushing
the primary action below the fold — on the Château du Faune event the
button sat under ~1.7k characters of text.

A sticky bar (title, price, tickets left, the same CTA) pins above the
bottom nav whenever the inline block is scrolled out of view
(useElementVisibility), and hides again when it is back, or while the
purchase / return dialogs are open. Container gets extra bottom padding
while the bar is up so the organizer card is never covered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-09 12:47:38 +02:00
cd70e65425 feat(events): warn buyers to check their spam folder for the ticket email
A tester's ticket landed in spam. A mail-tester run of the demo mailer
scored 8.3/10 with authentication fully green, so the sender setup is
not at fault and the mailer is being hardened separately (aiolabs/events
fix/ticket-email-headers). Until sender reputation builds, tell buyers
where to look: a red hint under the email field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-08 16:24:19 +02:00
1e43e8ed0a fix(events): let guests default to the card rail in the purchase dialog
`selectedMethodId` started as 'lightning', so the "keep the current
selection" guard in the default-rail watcher always found a match and the
guest preference for fiat never applied — verified on demo, where an
anonymous buyer saw "Proceed" (Lightning) preselected with Card on offer.
Start empty and reset to empty on close so each open re-evaluates:
guests land on Card when it exists, everyone else on the first enabled
rail, exactly as the comment already promised.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-08 13:02:31 +02:00
411ac0c93c Merge pull request 'feat(events): guest checkout with email delivery, Stripe return flow, public ticket page' (#144) from feat/events-guest-checkout into dev
Reviewed-on: #144
2026-09-08 10:25:56 +00:00
cbd4159f72 docs(events): guest checkout, fiat return flow and ticket delivery contract
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:46:16 +02:00
89a1fcb030 feat(events): use the shared logo QR renderer in My Tickets
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:43:11 +02:00
5d3ebcc5e4 feat(events): per-event payment methods in the create/edit dialog
Replace the "Also accept fiat" switch with a Lightning / Card checkbox
group so an organizer can switch Lightning off for a card-only sale.
Sends extra.payment_methods (upstream v2 shape) and keeps allow_fiat in
sync (fiat in list ⇒ allow_fiat=true) for older readers. Card stays
disabled with a tooltip when the organizer has no fiat provider; the
fiat-currency picker appears when Card is on and the price is in sats.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:43:10 +02:00
b853fbce4b feat(events): public ticket page at /events/ticket/:ticketId
Target of the emailed ticket link the extension now builds under the
webapp root. Anonymous, like the LNbits UI's own /events/ticket/{id}:
reads the PublicTicket, resolves the event name (Nostr store → scoped
relay query → public REST as a last resort), and renders the logo QR
with paid / pending / checked-in state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:39:42 +02:00
bd78e3227d feat(events): handle the return from fiat checkout on the event page
Stripe now brings the buyer back to the event page with
?checkout=success&tickets=<ids> or ?checkout=cancelled. The page strips
the query, then opens CheckoutReturnDialog, which polls the anonymous
ticket endpoint until every row is paid (60 s budget with a retry), and
renders one logo QR per ticket with a link to its public ticket page and
the "emailed to" note from the pending-checkout record. Logged-in buyers
also get their owned tickets refreshed and a My Tickets shortcut.

The login toast on "Buy ticket" now only fires for free events; paid
events open the dialog for guests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:37:52 +02:00
891b5072f8 feat(events): guest checkout in the purchase dialog with delivery email
Buyers no longer need an account to buy a paid ticket. The dialog gains a
"Your details" block (name for guests, email + confirm-email for everyone,
prefilled from the account email) as a vee-validate/zod form, and builds
its rail buttons from the event's published payment methods so an
organizer can run card-only events.

- Fiat: same-tab redirect to the provider right after the POST, with the
  pending checkout (ticket ids, email) remembered in localStorage for the
  return page; the two-step "open in new tab" panel is gone.
- Lightning: guests get the bolt11 QR + "Open in wallet" and settle via
  the existing poll; the LNbits-wallet button stays login-only.
- Free tickets remain login-gated (events#29).
- Guest success state renders the ticket QRs inline with the "emailed
  to" note, since guests have no My Tickets.

i18n keys added under events.purchase / checkout / ticket (en, es, fr).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:35:45 +02:00
f85848a50b feat(events): guest identity, frontend_url and public ticket read in the ticket API
Prepare the webapp for account-less ticket purchases against events ext
v1.6.1-aio.8:

- requestTicket takes an options object: user_id (+ optional email) for
  logged-in buyers, name + email for guests, Authorization only when a
  token exists, and frontend_url so the backend builds the Stripe return
  URLs and the emailed ticket link under this app's root.
- getTicket (anonymous PublicTicket) and getPublicEvent for the return
  dialog and the public ticket page.
- resendTicketEmail maps the structured TicketResendResult (legacy bare
  ticket tolerated).
- NIP-52 tickets_payment_methods tag parsed into ticketInfo.paymentMethods;
  effectivePaymentMethods() mirrors the backend rule.
- renderTicketQr: level-H ticket:// QR with the brand logo centred, shared
  by every ticket surface; useTicketPurchase accepts a guest contact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
2026-09-06 19:30:47 +02:00
0c5b577499 fix(events): read buyer id from auth service in fiat checkout
The fiat path pulled `currentUser` off the LNbits API client, which
never exposed it — only `getAccessToken()`. `userId` was therefore
always undefined and every Stripe/fiat "Continue" click failed with
"Missing user id" before the request was ever sent.

Use `useAuth()`'s `currentUser`, the same source the Lightning path
already goes through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLt6zWfXA1hM8zYQdpbeyn
2026-09-05 19:51:04 +02:00
53 changed files with 3598 additions and 288 deletions

19
chatelet.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/icons/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
<title>%VITE_APP_NAME%</title>
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
<meta name="description" content="Book a room">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/chatelet-app/main.ts"></script>
</body>
</html>

View file

@ -0,0 +1,76 @@
# Events — guest checkout, fiat return flow and ticket delivery
Buyers can purchase **paid** tickets without an LNbits account. This note is
the wire contract between the webapp and the `aiolabs/events` extension
(≥ v1.6.1-aio.8) for that flow. Free tickets still require a login
(events#29: no anonymous free issuance).
## Identity on `POST /events/api/v1/tickets/{event_id}`
| Buyer | Body fields | Auth header |
|---|---|---|
| Logged in | `user_id` + `email` (delivery address, prefilled from the account, editable) | `Authorization: Bearer <token>` |
| Guest | `name` + `email` | none (the endpoint is anonymous; the instance `X-API-KEY` is always sent) |
Every purchase also sends `frontend_url` — this app's root
(`window.location.origin + import.meta.env.BASE_URL`, no trailing slash;
`src/modules/events/lib/frontendRoot.ts`). The extension only accepts it when
its origin is on the LNbits `LNBITS_CORS_ALLOWED_ORIGINS` allowlist (or the
LNbits base / custom-frontend origin); otherwise the request fails with
`frontend_url origin is not allowed.` and the dialog shows that message.
`payment_method` is `lightning` or `fiat`. The event's enabled rails come from
the NIP-52 tag `tickets_payment_methods` (`"lightning,fiat"`); when the tag is
absent the legacy rule applies (Lightning always, fiat iff
`tickets_allow_fiat`). See `lib/paymentMethods.ts`. A rail that is off is
rejected by the backend with `Payment method not enabled for this event.`
## Return from the fiat provider
The extension composes both Stripe URLs under `frontend_url`:
| Purpose | URL |
|---|---|
| success | `<root>/events/{event_id}?checkout=success&tickets=<id1,id2>` |
| cancel | `<root>/events/{event_id}?checkout=cancelled` |
Before redirecting (same tab, `window.location.assign`), the purchase dialog
writes `localStorage['events:pending-checkout']` =
`{ eventId, paymentHash, ticketIds, email, ts }` (`lib/pendingCheckout.ts`).
`EventDetailPage` strips the query on arrival, then:
- `success` → opens `CheckoutReturnDialog`, which polls the anonymous
`GET /events/api/v1/tickets/{ticket_id}` for every id every 2 s (60 s budget,
retry button) until all are `paid`, then renders one QR per ticket, the
"emailed to" note (email from the localStorage record) and a link to the
ticket page. Logged-in buyers also get `useOwnedTickets().refresh()`.
- `cancelled` → info toast, record cleared. Nothing was charged; the
extension purges unpaid rows after 24 h.
## Public ticket page
`/events/ticket/:ticketId` (`views/TicketPage.vue`, `requiresAuth: false`) is
where the emailed link lands. It reads the `PublicTicket` (no id / email /
user_id in the payload; the id in the URL is the door credential, exactly as
in the LNbits UI), resolves the event name (Nostr store → scoped relay query
→ public REST, which 410s on sold-out events and is treated as non-fatal) and
renders the QR.
## QR rendering
All `ticket://<id>` QRs (purchase success, return dialog, ticket page, My
Tickets) go through `lib/ticketQr.ts`: error-correction level H with the
brand logo (`@brand/logo.png`) centred at ≤ 20 % width on a white pad, so the
in-app image matches the PNG the extension embeds in the ticket email
(`GET /events/api/v1/qr/{ticket_id}`). Canvas failures fall back to the plain
QR.
## Organizer side
`CreateEventDialog` publishes `extra.payment_methods` (Lightning / Card
checkboxes; at least one) and keeps `allow_fiat` in sync for older readers.
Card is disabled with a tooltip when the organizer's LNbits user has no fiat
provider (`useFiatProviders().hasAnyProvider`).
`resendTicketEmail` returns the structured `TicketResendResult`
(`{ ticket, email: {attempted, sent, error}, nostr: {...} }`).

View file

@ -182,6 +182,13 @@ interface ModulePlugin {
**See:** [[market-module/index|📖 Market Module Documentation]]
### **Events Module** 🎟️
**Purpose:** Ticketed events (Nostr NIP-52 feed + LNbits events extension)
**Location:** `src/modules/events/`
**Dependencies:** `['base']`
**See:** [[events-module/guest-checkout|📖 Guest checkout, fiat return flow and ticket delivery]]
## Module Development
### **Creating a New Module**

View file

@ -36,8 +36,11 @@
"dev:restaurant": "vite --host --config vite.restaurant.config.ts",
"build:restaurant": "vue-tsc -b && vite build --config vite.restaurant.config.ts",
"preview:restaurant": "vite preview --host --config vite.restaurant.config.ts",
"dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant -c blue,magenta,cyan,yellow,green,blue,red,gray,green \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\"",
"build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant",
"dev:chatelet": "vite --host --config vite.chatelet.config.ts",
"build:chatelet": "vue-tsc -b && vite build --config vite.chatelet.config.ts",
"preview:chatelet": "vite preview --host --config vite.chatelet.config.ts",
"dev:all": "concurrently -n hub,libra,events,wallet,chat,forum,market,tasks,restaurant,chatelet -c blue,magenta,cyan,yellow,green,blue,red,gray,green,cyan \"npm:dev\" \"npm:dev:libra\" \"npm:dev:events\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\" \"npm:dev:restaurant\" \"npm:dev:chatelet\"",
"build:demo": "npm run build && VITE_BASE_PATH=/events/ npm run build:events && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks && VITE_BASE_PATH=/restaurant/ npm run build:restaurant && VITE_BASE_PATH=/chatelet/ npm run build:chatelet",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder",

View file

@ -96,6 +96,10 @@
/* Default palette: Catppuccin (Latte for light, Mocha for dark).
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
:root {
/* Rendered height of the fixed BottomNav (h-14 + safe-area + border), kept
current by BottomNav.vue. Pin anything that must sit flush above the nav
with `bottom: var(--bottom-nav-height)`; 0 when no nav is mounted. */
--bottom-nav-height: 0px;
--background: oklch(0.9578 0.0058 264.5321);
--foreground: oklch(0.4355 0.0430 279.3250);
--card: oklch(1.0000 0 0);

16
src/chatelet-app/App.vue Normal file
View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
// Chatelet's in-page navigation is route-driven (browse → detail); the shell
// only contributes the always-on Profile entry on the bottom row.
const tabs: BottomTab[] = []
function isActive(_path: string): boolean {
return false
}
</script>
<template>
<AppShell :tabs="tabs" :is-active="isActive" />
</template>

View file

@ -0,0 +1,54 @@
import type { AppConfig } from '@/core/types'
/**
* Standalone chatelet app configuration.
* Enables base + chatelet modules only.
*/
export const appConfig: AppConfig = {
modules: {
base: {
name: 'base',
enabled: true,
lazy: false,
config: {
nostr: {
relays: JSON.parse(
import.meta.env.VITE_NOSTR_RELAYS ||
'["wss://relay.damus.io", "wss://nos.lol"]',
),
},
auth: {
sessionTimeout: 24 * 60 * 60 * 1000,
},
pwa: {
autoPrompt: true,
},
imageUpload: {
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
maxSizeMB: 10,
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'],
},
},
},
chatelet: {
name: 'chatelet',
enabled: true,
lazy: false,
config: {
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
apiKey: import.meta.env.VITE_API_KEY || '',
},
},
},
},
features: {
pwa: true,
pushNotifications: true,
electronApp: false,
developmentMode: import.meta.env.DEV,
},
}
export default appConfig

128
src/chatelet-app/app.ts Normal file
View file

@ -0,0 +1,128 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
import { pluginManager } from '@/core/plugin-manager'
import { eventBus } from '@/core/event-bus'
import { container } from '@/core/di-container'
import appConfig from './app.config'
import baseModule from '@/modules/base'
import chateletModule from '@/modules/chatelet'
import App from './App.vue'
import '@/assets/index.css'
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
import { acceptTokenFromUrl } from '@/lib/url-token'
const APP_NAME = (import.meta.env.VITE_APP_NAME as string) || 'Chatelet'
const APP_LABEL =
APP_NAME.toLowerCase() === 'chatelet' ? 'Chatelet' : `Chatelet (${APP_NAME})`
/**
* Initialize the standalone chatelet app (guest room booking).
*/
export async function createAppInstance() {
console.log(`🚀 Starting ${APP_LABEL}...`)
// Accept token from URL before anything else (cross-subdomain auth relay)
acceptTokenFromUrl(APP_NAME)
const app = createApp(App)
const moduleRoutes = [
...(baseModule.routes || []),
...(chateletModule.routes || []),
].filter(Boolean)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', redirect: '/chatelet' },
{
path: '/login',
name: 'login',
component:
import.meta.env.VITE_DEMO_MODE === 'true'
? () => import('@/pages/LoginDemo.vue')
: () => import('@/pages/Login.vue'),
meta: { requiresAuth: false },
},
...moduleRoutes,
{
path: '/settings',
name: 'settings',
component: () => import('./views/SettingsPage.vue'),
meta: { requiresAuth: false },
},
catchAllRoute,
],
})
installLenientAuthGuard(router)
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.use(i18n)
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
if (defaultLocale && !localStorage.getItem('user-locale')) {
await changeLocale(defaultLocale)
}
pluginManager.init(app, router)
const moduleRegistrations = []
if (appConfig.modules.base.enabled) {
moduleRegistrations.push(
pluginManager.register(baseModule, appConfig.modules.base),
)
}
if (appConfig.modules.chatelet?.enabled) {
moduleRegistrations.push(
pluginManager.register(chateletModule, appConfig.modules.chatelet),
)
}
await Promise.all(moduleRegistrations)
await pluginManager.installAll()
const { auth } = await import('@/composables/useAuthService')
await auth.initialize()
markAuthReady(auth)
app.config.errorHandler = (err, _vm, info) => {
console.error('Global error:', err, info)
eventBus.emit('app:error', { error: err, info }, 'app')
}
if (appConfig.features.developmentMode) {
;(window as any).__pluginManager = pluginManager
;(window as any).__eventBus = eventBus
;(window as any).__container = container
}
console.log(`✅ ${APP_LABEL} initialized`)
return { app, router }
}
export async function startApp() {
try {
const { app } = await createAppInstance()
app.mount('#app')
console.log(`🎉 ${APP_LABEL} started!`)
eventBus.emit('app:started', {}, 'app')
} catch (error) {
console.error(`💥 Failed to start ${APP_LABEL}:`, error)
document.getElementById('app')!.innerHTML = `
<div style="padding: 20px; text-align: center; color: red;">
<h1>Failed to Start</h1>
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
<p>Please refresh the page.</p>
</div>
`
}
}

24
src/chatelet-app/main.ts Normal file
View file

@ -0,0 +1,24 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import 'vue-sonner/style.css'
cleanupStaleDevServiceWorkers()
// PWA service worker with periodic updates
const intervalMS = 60 * 60 * 1000 // 1 hour
registerSW({
onRegistered(r) {
r &&
setInterval(() => {
r.update()
}, intervalMS)
},
onOfflineReady() {
console.log(
`${(import.meta.env.VITE_APP_NAME as string) || 'Chatelet'} ready to work offline`,
)
},
})
startApp()

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { useTheme } from '@/components/theme-provider'
import { auth } from '@/composables/useAuthService'
const { theme, setTheme } = useTheme()
const router = useRouter()
const isAuthenticated = computed(() => auth.isAuthenticated.value)
const userPubkey = computed(() => auth.currentUser.value?.pubkey)
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
async function handleLogout() {
await auth.logout()
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<h1 class="text-2xl font-bold text-foreground mb-6">Settings</h1>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-foreground">Theme</span>
<Button variant="outline" size="sm" @click="toggleTheme">
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
<span class="ml-2">{{ theme === 'dark' ? 'Light' : 'Dark' }}</span>
</Button>
</div>
<Separator />
<div v-if="isAuthenticated" class="space-y-2">
<p class="text-sm text-muted-foreground">Signed in as</p>
<p class="text-sm text-foreground break-all">{{ userPubkey }}</p>
<Button variant="outline" size="sm" @click="handleLogout">
<LogOut class="h-4 w-4 mr-2" /> Log out
</Button>
</div>
<div v-else>
<Button variant="outline" size="sm" @click="router.push('/login')">
<LogIn class="h-4 w-4 mr-2" /> Log in
</Button>
</div>
</div>
</div>
</template>

View file

@ -1,6 +1,8 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { Component } from 'vue'
import { useResizeObserver } from '@vueuse/core'
export interface BottomTab {
/** Translated label shown under the icon. */
@ -33,6 +35,22 @@ interface Props {
const props = defineProps<Props>()
// Publish the nav's rendered height (h-14 + safe-area padding + border,
// whatever the device makes of it) as `--bottom-nav-height` on <html>, so
// anything that needs to sit flush above the nav — sticky action bars,
// toasts — can use `bottom: var(--bottom-nav-height)` instead of
// re-deriving the CSS and drifting when insets or font scaling differ.
const navRef = ref<HTMLElement | null>(null)
function publishHeight() {
const h = navRef.value?.getBoundingClientRect().height ?? 0
document.documentElement.style.setProperty('--bottom-nav-height', `${Math.round(h)}px`)
}
useResizeObserver(navRef, publishHeight)
onMounted(publishHeight)
onBeforeUnmount(() => {
document.documentElement.style.setProperty('--bottom-nav-height', '0px')
})
const router = useRouter()
function onTabClick(tab: BottomTab) {
@ -51,6 +69,7 @@ function isTabActive(tab: BottomTab): boolean {
<template>
<nav
ref="navRef"
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)"
>

View file

@ -175,6 +175,9 @@ export const SERVICE_TOKENS = {
// Restaurant services
RESTAURANT_API: Symbol('restaurantAPI'),
RESTAURANT_NOSTR_SYNC: Symbol('restaurantNostrSync'),
// Chatelet (room rentals) services
CHATELET_API: Symbol('chateletAPI'),
} as const
// Type-safe injection helpers

View file

@ -58,6 +58,27 @@ const messages: LocaleMessages = {
events: {
title: 'Events',
createNew: 'Create Event',
create: {
promo: {
title: 'Promo codes',
hint: 'Percentage discounts buyers can enter at checkout. Codes are never shown publicly.',
empty: 'No promo codes yet.',
code: 'Code',
codePlaceholder: 'EARLYBIRD',
discount: 'Discount %',
maxUses: 'Max uses',
active: 'Active',
used: 'Used {count}',
add: 'Add code',
removeLabel: 'Remove code {code}',
errors: {
required: 'Enter a code',
duplicate: 'Duplicate code',
percentRange: 'Between 1 and 100',
maxUsesMin: 'Must be 1 or more',
},
},
},
noEvents: 'No events found',
filters: {
all: 'All',
@ -129,6 +150,65 @@ const messages: LocaleMessages = {
pending: 'Pending',
registered: 'Registered',
},
purchase: {
card: 'Card',
continueTo: 'Continue to {provider} checkout',
fiatRedirectHint: "You'll be taken to the provider's secure checkout and brought back here once paid.",
noCheckoutUrl: 'Fiat provider did not return a checkout URL.',
fiatFailed: 'Card checkout failed',
loginRequiredTitle: 'Login required',
loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.',
promo: {
label: 'Promo code',
placeholder: 'Enter code',
apply: 'Apply',
remove: 'Remove',
checking: 'Checking…',
applied: '{code} −{percent}%',
discountLine: 'Discount ({code})',
invalid: 'This code is not valid for this purchase.',
unverified: 'Could not verify the code — it will be applied at checkout if valid.',
loginRequiredDiscount: 'This code makes your ticket free. Free tickets need an account — log in to claim it.',
},
contact: {
title: 'Your details',
name: 'Name',
namePlaceholder: 'Name on the ticket',
nameRequired: 'Name is required',
email: 'Email',
emailHint: 'Your ticket will be sent to this address.',
spamHint: 'If it has not arrived a few minutes after paying, check your spam folder.',
emailInvalid: 'Enter a valid email address',
confirmEmail: 'Confirm email',
emailMismatch: 'Email addresses do not match',
},
},
checkout: {
title: 'Confirming your payment…',
paidTitle: 'Payment received',
processing: 'Waiting for the payment to settle. This usually takes a few seconds.',
stillProcessing: 'Still processing. Your ticket will be emailed to {email} as soon as the payment settles.',
stillProcessingNoEmail: 'Still processing. Reload this page in a moment to check again.',
emailedTo: 'We emailed your ticket to {email}.',
cancelled: 'Payment cancelled — no seats were reserved.',
viewTicket: 'Open ticket page',
viewMyTickets: 'View My Tickets',
retry: 'Check again',
ticketN: 'Ticket {n} of {total}',
close: 'Close',
},
ticket: {
title: 'Your ticket',
loading: 'Loading ticket…',
notFound: 'Ticket not found',
event: 'Event',
holder: 'Name',
paid: 'Paid',
pending: 'Payment pending',
registered: 'Checked in',
showAtDoor: 'Show this QR at the door.',
backToEvent: 'Back to event',
},
nav: {
feed: 'Home',
calendar: 'Calendar',

View file

@ -58,6 +58,27 @@ const messages: LocaleMessages = {
events: {
title: 'Eventos',
createNew: 'Crear evento',
create: {
promo: {
title: 'Códigos promocionales',
hint: 'Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.',
empty: 'Aún no hay códigos promocionales.',
code: 'Código',
codePlaceholder: 'EARLYBIRD',
discount: 'Descuento %',
maxUses: 'Usos máx.',
active: 'Activo',
used: 'Usado {count}',
add: 'Añadir código',
removeLabel: 'Eliminar el código {code}',
errors: {
required: 'Introduce un código',
duplicate: 'Código duplicado',
percentRange: 'Entre 1 y 100',
maxUsesMin: 'Debe ser 1 o más',
},
},
},
noEvents: 'No se encontraron eventos',
filters: {
all: 'Todas',
@ -129,6 +150,65 @@ const messages: LocaleMessages = {
pending: 'Pendiente',
registered: 'Registrado',
},
purchase: {
card: 'Tarjeta',
continueTo: 'Continuar al pago con {provider}',
fiatRedirectHint: 'Te llevaremos al pago seguro del proveedor y volverás aquí una vez pagado.',
noCheckoutUrl: 'El proveedor no devolvió una URL de pago.',
fiatFailed: 'El pago con tarjeta falló',
loginRequiredTitle: 'Inicio de sesión requerido',
loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.',
promo: {
label: 'Código promocional',
placeholder: 'Introduce el código',
apply: 'Aplicar',
remove: 'Quitar',
checking: 'Comprobando…',
applied: '{code} −{percent}%',
discountLine: 'Descuento ({code})',
invalid: 'Este código no es válido para esta compra.',
unverified: 'No se pudo verificar el código; se aplicará al pagar si es válido.',
loginRequiredDiscount: 'Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.',
},
contact: {
title: 'Tus datos',
name: 'Nombre',
namePlaceholder: 'Nombre en el boleto',
nameRequired: 'El nombre es obligatorio',
email: 'Correo electrónico',
emailHint: 'Tu boleto se enviará a esta dirección.',
spamHint: 'Si no llega unos minutos después de pagar, revisa tu carpeta de spam.',
emailInvalid: 'Introduce un correo electrónico válido',
confirmEmail: 'Confirmar correo',
emailMismatch: 'Los correos no coinciden',
},
},
checkout: {
title: 'Confirmando tu pago…',
paidTitle: 'Pago recibido',
processing: 'Esperando a que se liquide el pago. Suele tardar unos segundos.',
stillProcessing: 'Aún en proceso. Tu boleto se enviará a {email} en cuanto se liquide el pago.',
stillProcessingNoEmail: 'Aún en proceso. Recarga esta página en un momento.',
emailedTo: 'Enviamos tu boleto a {email}.',
cancelled: 'Pago cancelado — no se reservó ningún asiento.',
viewTicket: 'Abrir página del boleto',
viewMyTickets: 'Ver mis boletos',
retry: 'Comprobar de nuevo',
ticketN: 'Boleto {n} de {total}',
close: 'Cerrar',
},
ticket: {
title: 'Tu boleto',
loading: 'Cargando boleto…',
notFound: 'Boleto no encontrado',
event: 'Evento',
holder: 'Nombre',
paid: 'Pagado',
pending: 'Pago pendiente',
registered: 'Registrado',
showAtDoor: 'Muestra este QR en la entrada.',
backToEvent: 'Volver al evento',
},
nav: {
feed: 'Inicio',
calendar: 'Calendario',

View file

@ -58,6 +58,27 @@ const messages: LocaleMessages = {
events: {
title: 'Événements',
createNew: 'Créer un événement',
create: {
promo: {
title: 'Codes promo',
hint: 'Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.',
empty: 'Aucun code promo pour le moment.',
code: 'Code',
codePlaceholder: 'EARLYBIRD',
discount: 'Remise %',
maxUses: 'Utilisations max.',
active: 'Actif',
used: 'Utilisé {count}',
add: 'Ajouter un code',
removeLabel: 'Supprimer le code {code}',
errors: {
required: 'Saisissez un code',
duplicate: 'Code en double',
percentRange: 'Entre 1 et 100',
maxUsesMin: 'Doit être 1 ou plus',
},
},
},
noEvents: 'Aucun événement trouvé',
filters: {
all: 'Tout',
@ -129,6 +150,65 @@ const messages: LocaleMessages = {
pending: 'En attente',
registered: 'Enregistré',
},
purchase: {
card: 'Carte',
continueTo: 'Continuer vers le paiement {provider}',
fiatRedirectHint: 'Vous serez redirigé vers le paiement sécurisé du prestataire, puis ramené ici une fois payé.',
noCheckoutUrl: "Le prestataire n'a pas renvoyé d'URL de paiement.",
fiatFailed: 'Le paiement par carte a échoué',
loginRequiredTitle: 'Connexion requise',
loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.',
promo: {
label: 'Code promo',
placeholder: 'Saisir le code',
apply: 'Appliquer',
remove: 'Retirer',
checking: 'Vérification…',
applied: '{code} −{percent}%',
discountLine: 'Remise ({code})',
invalid: "Ce code n'est pas valable pour cet achat.",
unverified: "Impossible de vérifier le code — il sera appliqué au paiement s'il est valable.",
loginRequiredDiscount: 'Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.',
},
contact: {
title: 'Vos coordonnées',
name: 'Nom',
namePlaceholder: 'Nom sur le billet',
nameRequired: 'Le nom est obligatoire',
email: 'E-mail',
emailHint: 'Votre billet sera envoyé à cette adresse.',
spamHint: 'S’il n’est pas arrivé quelques minutes après le paiement, vérifiez votre dossier spam.',
emailInvalid: 'Saisissez une adresse e-mail valide',
confirmEmail: "Confirmer l'e-mail",
emailMismatch: 'Les adresses e-mail ne correspondent pas',
},
},
checkout: {
title: 'Confirmation de votre paiement…',
paidTitle: 'Paiement reçu',
processing: 'En attente du règlement du paiement. Cela prend généralement quelques secondes.',
stillProcessing: 'Toujours en cours. Votre billet sera envoyé à {email} dès que le paiement sera réglé.',
stillProcessingNoEmail: 'Toujours en cours. Rechargez cette page dans un instant.',
emailedTo: 'Nous avons envoyé votre billet à {email}.',
cancelled: 'Paiement annulé — aucune place réservée.',
viewTicket: 'Ouvrir la page du billet',
viewMyTickets: 'Voir mes billets',
retry: 'Vérifier à nouveau',
ticketN: 'Billet {n} sur {total}',
close: 'Fermer',
},
ticket: {
title: 'Votre billet',
loading: 'Chargement du billet…',
notFound: 'Billet introuvable',
event: 'Événement',
holder: 'Nom',
paid: 'Payé',
pending: 'Paiement en attente',
registered: 'Enregistré',
showAtDoor: "Présentez ce QR à l'entrée.",
backToEvent: "Retour à l'événement",
},
nav: {
feed: 'Accueil',
calendar: 'Calendrier',

View file

@ -59,6 +59,27 @@ export interface LocaleMessages {
events?: {
title: string
createNew: string
create: {
promo: {
title: string
hint: string
empty: string
code: string
codePlaceholder: string
discount: string
maxUses: string
active: string
used: string
add: string
removeLabel: string
errors: {
required: string
duplicate: string
percentRange: string
maxUsesMin: string
}
}
}
noEvents: string
filters: {
all: string
@ -104,6 +125,65 @@ export interface LocaleMessages {
pending: string
registered: string
}
purchase: {
card: string
continueTo: string
fiatRedirectHint: string
noCheckoutUrl: string
fiatFailed: string
loginRequiredTitle: string
loginRequiredFree: string
promo: {
label: string
placeholder: string
apply: string
remove: string
checking: string
applied: string
discountLine: string
invalid: string
unverified: string
loginRequiredDiscount: string
}
contact: {
title: string
name: string
namePlaceholder: string
nameRequired: string
email: string
emailHint: string
spamHint: string
emailInvalid: string
confirmEmail: string
emailMismatch: string
}
}
checkout: {
title: string
paidTitle: string
processing: string
stillProcessing: string
stillProcessingNoEmail: string
emailedTo: string
cancelled: string
viewTicket: string
viewMyTickets: string
retry: string
ticketN: string
close: string
}
ticket: {
title: string
loading: string
notFound: string
event: string
holder: string
paid: string
pending: string
registered: string
showAtDoor: string
backToEvent: string
}
nav: {
feed: string
calendar: string

View file

@ -0,0 +1,38 @@
import { storeToRefs } from 'pinia'
import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
import type { ChateletApiService } from '../services/ChateletApiService'
import type { AvailabilityResult, Room } from '../types/room'
import { useChateletStore } from '../stores/chatelet'
export function useChatelet() {
const store = useChateletStore()
const { rooms, isLoading } = storeToRefs(store)
// DI, never a direct import (workspace rule).
const api = tryInjectService<ChateletApiService>(SERVICE_TOKENS.CHATELET_API)
async function loadRooms(): Promise<void> {
if (!api) return
isLoading.value = true
try {
store.rooms = await api.getPublicRooms()
} finally {
isLoading.value = false
}
}
async function getRoom(id: string): Promise<Room | null> {
if (!api) return null
return api.getPublicRoom(id)
}
async function checkAvailability(
roomId: string,
checkIn: string,
checkOut: string,
): Promise<AvailabilityResult | null> {
if (!api) return null
return api.checkAvailability(roomId, checkIn, checkOut)
}
return { rooms, isLoading, loadRooms, getRoom, checkAvailability }
}

View file

@ -0,0 +1,54 @@
import { createModulePlugin } from '@/core/base/BaseModulePlugin'
import { SERVICE_TOKENS } from '@/core/di-container'
import { ChateletApiService, type ChateletApiConfig } from './services/ChateletApiService'
export interface ChateletModuleConfig {
apiConfig: ChateletApiConfig
}
/**
* Chatelet module — guest booking UI for the LNbits `chatelet` room-rentals
* extension. Slice 1: read-only discovery + availability over HTTP.
*/
export const chateletModule = createModulePlugin({
name: 'chatelet',
version: '1.0.0',
dependencies: ['base'],
routes: [
{
path: '/chatelet',
name: 'chatelet',
component: () => import('./views/ChateletPage.vue'),
meta: {
title: (import.meta.env.VITE_APP_NAME as string) || 'Chatelet',
requiresAuth: false,
},
},
{
path: '/chatelet/:id',
name: 'chatelet-detail',
component: () => import('./views/ChateletDetailPage.vue'),
meta: { title: 'Room', requiresAuth: false },
},
],
onInstall: async (_app, options) => {
const config = options?.config as ChateletModuleConfig | undefined
if (!config) {
throw new Error('Chatelet module requires configuration')
}
const { container } = await import('@/core/di-container')
const api = new ChateletApiService(config.apiConfig)
container.provide(SERVICE_TOKENS.CHATELET_API, api)
},
onUninstall: async () => {
const { container } = await import('@/core/di-container')
container.remove(SERVICE_TOKENS.CHATELET_API)
},
})
export default chateletModule
export type { Room, AvailabilityResult } from './types/room'

View file

@ -0,0 +1,69 @@
import type { AvailabilityResult, Room } from '../types/room'
export interface ChateletApiConfig {
baseUrl: string
apiKey: string
}
/**
* HTTP client for the LNbits `chatelet` extension's guest surface.
*
* Plain config-wrapper (same pattern as events' TicketApiService) — not a
* BaseService. Slice 1 is read-only guest browsing over public endpoints;
* booking (POST /bookings) + status polling arrive in slice 2.
*
* Transport note: this is HTTP-for-now. The extension also exposes a
* kind-21000 Nostr-RPC surface (chatelet_room_list / _availability / …), but
* the webapp's RPC client is disabled post-lnbits#9 (see aiolabs/lnbits#64).
* When that's revived, only this class swaps HTTP→RPC — no view changes.
*/
export class ChateletApiService {
constructor(private config: ChateletApiConfig) {}
/** Active rooms for guest browsing (operator-private fields stripped server-side). */
async getPublicRooms(): Promise<Room[]> {
return this.request('/chatelet/api/v1/public/rooms', { method: 'GET' })
}
async getPublicRoom(id: string): Promise<Room> {
return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' })
}
async checkAvailability(
roomId: string,
checkIn: string,
checkOut: string,
): Promise<AvailabilityResult> {
return this.request('/chatelet/api/v1/availability', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ room_id: roomId, check_in: checkIn, check_out: checkOut }),
})
}
private async request(path: string, init: RequestInit = {}): Promise<any> {
const headers: Record<string, string> = {
accept: 'application/json',
// Guest endpoints are unauthenticated; only send a key if one is set.
...(this.config.apiKey ? { 'X-API-KEY': this.config.apiKey } : {}),
...((init.headers as Record<string, string>) ?? {}),
}
const response = await fetch(`${this.config.baseUrl}${path}`, { ...init, headers })
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: `Request failed: ${path}` }))
const message =
typeof error.detail === 'string'
? error.detail
: Array.isArray(error.detail)
? (error.detail[0]?.msg ?? 'Request failed')
: 'Request failed'
throw new Error(message)
}
return response.json()
}
}

View file

@ -0,0 +1,9 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Room } from '../types/room'
export const useChateletStore = defineStore('chatelet', () => {
const rooms = ref<Room[]>([])
const isLoading = ref(false)
return { rooms, isLoading }
})

View file

@ -0,0 +1,33 @@
// Public shapes returned by the chatelet extension's guest endpoints.
// Note: operator-private fields (wallet, checkin_instructions) are stripped
// server-side by public_room_dict and never reach the client.
export interface Room {
id: string
title: string
description: string
price_amount: number
price_currency: string
price_frequency: string
max_guests: number
min_nights: number
amenities: string[]
location: string
geohash: string
images: string[]
status: string
listing_event_id: string | null
created_at: string
updated_at: string
}
export interface AvailabilityResult {
room_id: string
check_in: string
check_out: string
available: boolean
nights: number
quote_sat: number | null
quote_fiat: number | null
currency: string | null
}

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import { addDays, format } from 'date-fns'
import { MapPin, Users } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import DatePicker from '@/modules/base/components/DatePicker.vue'
import { useChatelet } from '../composables/useChatelet'
import type { AvailabilityResult, Room } from '../types/room'
const route = useRoute()
const { getRoom, checkAvailability } = useChatelet()
const room = ref<Room | null>(null)
const loading = ref(true)
const checkIn = ref('')
const checkOut = ref('')
const checking = ref(false)
const result = ref<AvailabilityResult | null>(null)
// Same YYYY-MM-DD wire format the DatePicker speaks; the availability
// endpoint takes the same strings.
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
// Check-out must be at least the next morning. Bumping the floor also
// clears a check-out that the new check-in has overtaken.
const minCheckOut = computed(() =>
checkIn.value
? format(addDays(new Date(checkIn.value + 'T00:00:00'), 1), 'yyyy-MM-dd')
: today.value,
)
watch(checkIn, () => {
if (checkOut.value && checkOut.value < minCheckOut.value) checkOut.value = ''
result.value = null
})
watch(checkOut, () => {
result.value = null
})
onMounted(async () => {
try {
room.value = await getRoom(route.params.id as string)
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Could not load room')
} finally {
loading.value = false
}
})
async function onCheck() {
if (!room.value || !checkIn.value || !checkOut.value) return
checking.value = true
result.value = null
try {
result.value = await checkAvailability(room.value.id, checkIn.value, checkOut.value)
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Availability check failed')
} finally {
checking.value = false
}
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-2xl">
<div v-if="loading" class="text-muted-foreground py-12">Loading…</div>
<div v-else-if="!room" class="text-muted-foreground py-12">Room not found.</div>
<div v-else class="space-y-4">
<img
v-if="room.images?.length"
:src="room.images[0]"
:alt="room.title"
class="w-full h-56 object-cover rounded-lg"
/>
<div>
<h1 class="text-2xl font-bold text-foreground">{{ room.title }}</h1>
<p
v-if="room.location"
class="text-muted-foreground flex items-center gap-1 mt-1"
>
<MapPin class="h-4 w-4" /> {{ room.location }}
</p>
<p class="text-muted-foreground flex items-center gap-1">
<Users class="h-4 w-4" /> up to {{ room.max_guests }} guests · min
{{ room.min_nights }} night(s)
</p>
<p class="text-lg font-semibold text-foreground mt-2">
{{ room.price_amount }} {{ room.price_currency }} / {{ room.price_frequency }}
</p>
</div>
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1">
<Badge v-for="a in room.amenities" :key="a" variant="outline">{{ a }}</Badge>
</div>
<p v-if="room.description" class="text-foreground whitespace-pre-line">
{{ room.description }}
</p>
<Card>
<CardContent class="p-4 space-y-3">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1 min-w-0">
<Label>Check-in</Label>
<DatePicker v-model="checkIn" :min="today" placeholder="Check-in" />
</div>
<div class="space-y-1 min-w-0">
<Label>Check-out</Label>
<DatePicker
v-model="checkOut"
:min="minCheckOut"
:disabled="!checkIn"
placeholder="Check-out"
/>
</div>
</div>
<Button
class="w-full"
:disabled="!checkIn || !checkOut || checking"
@click="onCheck"
>
{{ checking ? 'Checking…' : 'Check availability' }}
</Button>
<div v-if="result" class="text-sm pt-1">
<template v-if="result.available">
<p class="text-foreground">
Available for {{ result.nights }} night(s) —
<span class="font-semibold">{{ result.quote_sat }} sats</span>
<span v-if="result.quote_fiat" class="text-muted-foreground">
(≈ {{ result.quote_fiat }} {{ result.currency }})
</span>
</p>
<Button class="w-full mt-3" disabled>Book — coming soon</Button>
</template>
<p v-else class="text-destructive">Not available for those dates.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</template>

View file

@ -0,0 +1,80 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { MapPin, Users } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useChatelet } from '../composables/useChatelet'
import type { Room } from '../types/room'
const router = useRouter()
const { rooms, isLoading, loadRooms } = useChatelet()
onMounted(loadRooms)
function open(id: string) {
router.push(`/chatelet/${id}`)
}
function priceLabel(r: Room): string {
return `${r.price_amount} ${r.price_currency} / ${r.price_frequency}`
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-4xl">
<h1 class="text-2xl font-bold text-foreground mb-6">Rooms</h1>
<div v-if="isLoading" class="grid gap-4 sm:grid-cols-2">
<Skeleton v-for="n in 4" :key="n" class="h-64 w-full rounded-lg" />
</div>
<div v-else-if="rooms.length === 0" class="text-muted-foreground text-center py-16">
No rooms available right now.
</div>
<div v-else class="grid gap-4 sm:grid-cols-2">
<Card
v-for="room in rooms"
:key="room.id"
class="cursor-pointer overflow-hidden hover:border-ring transition-colors"
@click="open(room.id)"
>
<img
v-if="room.images?.length"
:src="room.images[0]"
:alt="room.title"
class="h-40 w-full object-cover"
loading="lazy"
/>
<div
v-else
class="h-40 w-full bg-muted flex items-center justify-center text-muted-foreground text-sm"
>
No image
</div>
<CardContent class="p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="font-semibold text-foreground">{{ room.title }}</h3>
<Badge variant="secondary" class="shrink-0">{{ priceLabel(room) }}</Badge>
</div>
<p
v-if="room.location"
class="text-sm text-muted-foreground mt-1 flex items-center gap-1"
>
<MapPin class="h-3.5 w-3.5" /> {{ room.location }}
</p>
<p class="text-sm text-muted-foreground mt-1 flex items-center gap-1">
<Users class="h-3.5 w-3.5" /> up to {{ room.max_guests }} guests
</p>
<div v-if="room.amenities?.length" class="flex flex-wrap gap-1 mt-2">
<Badge v-for="a in room.amenities.slice(0, 4)" :key="a" variant="outline">
{{ a }}
</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
</template>

View file

@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Ticket, Loader2, Clock } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { renderTicketQr } from '../lib/ticketQr'
import { clearPendingCheckout } from '../lib/pendingCheckout'
/**
* Shown when the buyer lands back on the event page from the fiat
* provider with `?checkout=success&tickets=…`. Polls the anonymous
* ticket endpoint until every row is paid (Stripe waits for our
* webhook before redirecting, so the first poll usually already
* says paid), then renders one scannable QR per ticket.
*/
const props = defineProps<{
isOpen: boolean
eventId: string
ticketIds: string[]
email?: string
}>()
const emit = defineEmits<{ (e: 'update:isOpen', value: boolean): void }>()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { refresh: refreshOwnedTickets } = useOwnedTickets()
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const POLL_MS = 2000
const TIMEOUT_MS = 60_000
type Phase = 'checking' | 'paid' | 'timeout'
const phase = ref<Phase>('checking')
const qrCodes = ref<Record<string, string>>({})
let timer: number | null = null
let startedAt = 0
const paidCount = ref(0)
async function checkOnce(): Promise<boolean> {
const results = await Promise.all(
props.ticketIds.map((id) => ticketApi.getTicket(id).then((t) => t.paid).catch(() => false)),
)
paidCount.value = results.filter(Boolean).length
return results.length > 0 && results.every(Boolean)
}
async function onPaid() {
phase.value = 'paid'
stop()
clearPendingCheckout()
if (isAuthenticated.value) void refreshOwnedTickets()
const map: Record<string, string> = {}
for (const id of props.ticketIds) {
try {
map[id] = await renderTicketQr(id, { width: 200 })
} catch {
// leave the slot empty; the ticket page link still works
}
}
qrCodes.value = map
}
async function tick() {
if (phase.value !== 'checking') return
try {
if (await checkOnce()) {
await onPaid()
return
}
} catch {
// transient network error — keep polling until the timeout
}
if (Date.now() - startedAt > TIMEOUT_MS) {
phase.value = 'timeout'
stop()
}
}
function start() {
stop()
phase.value = 'checking'
qrCodes.value = {}
startedAt = Date.now()
void tick()
timer = window.setInterval(() => void tick(), POLL_MS)
}
function stop() {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
watch(
() => [props.isOpen, props.ticketIds.join(',')] as const,
([open]) => {
if (open && props.ticketIds.length > 0) start()
else stop()
},
{ immediate: true },
)
onUnmounted(stop)
function close() {
stop()
emit('update:isOpen', false)
}
const title = computed(() =>
phase.value === 'paid' ? t('events.checkout.paidTitle') : t('events.checkout.title'),
)
</script>
<template>
<Dialog :open="isOpen" @update:open="close">
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Ticket class="w-5 h-5" />
{{ title }}
</DialogTitle>
<DialogDescription v-if="phase === 'paid' && email">
{{ t('events.checkout.emailedTo', { email }) }}
</DialogDescription>
</DialogHeader>
<div v-if="phase === 'checking'" class="py-8 flex flex-col items-center gap-3 text-center">
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<p class="text-sm text-muted-foreground">{{ t('events.checkout.processing') }}</p>
<p v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
{{ paidCount }} / {{ ticketIds.length }}
</p>
</div>
<div v-else-if="phase === 'timeout'" class="py-6 flex flex-col items-center gap-3 text-center">
<Clock class="w-10 h-10 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
{{ email
? t('events.checkout.stillProcessing', { email })
: t('events.checkout.stillProcessingNoEmail') }}
</p>
<Button variant="outline" @click="start">{{ t('events.checkout.retry') }}</Button>
</div>
<div v-else class="py-4 space-y-3">
<div
v-for="(id, idx) in ticketIds"
:key="id"
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
>
<span v-if="ticketIds.length > 1" class="text-xs text-muted-foreground">
{{ t('events.checkout.ticketN', { n: idx + 1, total: ticketIds.length }) }}
</span>
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="qrCodes[id]" :src="qrCodes[id]" :alt="`Ticket ${id}`" class="block h-48 w-48" />
<div v-else class="h-48 w-48 flex items-center justify-center">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
{{ t('events.checkout.viewTicket') }}
</RouterLink>
</div>
<p class="text-xs text-muted-foreground text-center">{{ t('events.ticket.showAtDoor') }}</p>
<div class="space-y-2 pt-1">
<Button v-if="isAuthenticated" class="w-full" @click="() => { close(); $router.push('/my-tickets') }">
{{ t('events.checkout.viewMyTickets') }}
</Button>
<Button variant="outline" class="w-full" @click="close">{{ t('events.checkout.close') }}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -26,7 +26,7 @@ import { Button } from '@/components/ui/button'
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 { Bell, ChevronDown, Tag } from 'lucide-vue-next'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
@ -41,12 +41,28 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue'
import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { CreditCard } from 'lucide-vue-next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category'
import PromoCodesEditor from './PromoCodesEditor.vue'
import {
fromWirePromoCodes,
hasPromoErrors,
toWirePromoCodes,
validatePromoRows,
type PromoCodeRow,
} from '../lib/promoCodes'
interface Props {
open: boolean
@ -124,7 +140,11 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''),
currency: z.string().default("sat"),
allow_fiat: z.boolean().default(false),
// Rails buyers may use. `allow_fiat` on the wire is derived from
// this list (fiat ∈ list) so older extensions / clients keep working.
payment_methods: z
.array(z.enum(['lightning', 'fiat']))
.min(1, 'Enable at least one payment method'),
fiat_currency: z.string().default("USD"),
amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0),
@ -152,7 +172,7 @@ const formSchema = toTypedSchema(
// 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) {
if (v.payment_methods.includes('fiat') && isSat && !v.fiat_currency) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['fiat_currency'],
@ -173,7 +193,7 @@ const form = useForm({
event_end_time: '',
location: '',
currency: 'sat',
allow_fiat: false,
payment_methods: ['lightning'],
fiat_currency: 'USD',
amount_tickets: 0,
price_per_ticket: 0,
@ -184,6 +204,42 @@ const form = useForm({
}
})
/** Rails an existing event has on; legacy events (no list) derive
* from allow_fiat. */
function paymentMethodsOf(event: TicketedEvent): Array<'lightning' | 'fiat'> {
const explicit = (event.extra?.payment_methods ?? []).filter(
(m): m is 'lightning' | 'fiat' => m === 'lightning' || m === 'fiat',
)
if (explicit.length > 0) return explicit
return event.allow_fiat ? ['lightning', 'fiat'] : ['lightning']
}
const { hasAnyProvider, refresh: refreshFiatProviders } = useFiatProviders()
// Refresh once so the Card checkbox reflects providers the organizer may
// have just configured in LNbits.
refreshFiatProviders()
const PAYMENT_METHOD_OPTIONS = [
{ id: 'lightning' as const, label: 'Lightning', icon: Zap, hint: 'Pay with any Lightning wallet' },
{ id: 'fiat' as const, label: 'Card', icon: CreditCard, hint: 'Card or bank through your configured fiat provider' },
]
function isSatDenomination(d: string | undefined): boolean {
return d === 'sat' || d === 'sats'
}
// When the price is denominated in a fiat currency, the rail currency
// MUST match it — silently mirror so the backend payload stays consistent.
watch(
() => form.values.currency,
(d) => {
if (d && !isSatDenomination(d) && form.values.fiat_currency !== d) {
form.setFieldValue('fiat_currency', d)
}
},
{ immediate: true },
)
interface BannerImage extends UploadedImage {
isPrimary: boolean
}
@ -204,6 +260,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
// edit-mode population with start-date side effects mid-setValues.
const isPopulating = ref(false)
const notificationsOpen = ref(false)
const promoOpen = ref(false)
// 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
@ -229,6 +286,10 @@ const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLO
const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false)
const selectedCategories = ref<string[]>([])
// Promo codes live outside vee-validate like categories: a plain list
// with its own pure validator (`validatePromoRows`), gating submit below.
const promoRows = ref<PromoCodeRow[]>([])
const promoErrors = computed(() => validatePromoRows(promoRows.value))
async function populateFromEvent(event: TicketedEvent) {
isPopulating.value = true
@ -243,7 +304,7 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time,
location: event.location ?? '',
currency: event.currency ?? 'sat',
allow_fiat: event.allow_fiat ?? false,
payment_methods: paymentMethodsOf(event),
fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0,
@ -253,6 +314,10 @@ async function populateFromEvent(event: TicketedEvent) {
notification_body: event.extra?.notification_body ?? '',
})
selectedCategories.value = [...(event.categories ?? [])]
// Codes come only on the organizer-authenticated record (public
// responses omit them) — useMyEvents / EventDetailPage hand us that one.
promoRows.value = fromWirePromoCodes(event.extra?.promo_codes)
promoOpen.value = promoRows.value.length > 0
if (event.banner) {
// Re-render the stored banner via its pict-rs file ID. delete_token
// is intentionally empty: we don't own the original upload's token
@ -291,11 +356,13 @@ watch(() => props.open, async (isOpen) => {
}
} else {
selectedCategories.value = []
promoRows.value = []
promoOpen.value = false
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value))
const isLoading = ref(false)
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
@ -354,27 +421,31 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null
}
if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends so a true→false 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) {
// allow_fiat always sends (derived from the rail list) so a
// true→false flip propagates on edit; fiat_currency only sends when
// fiat is on (no point persisting a rail-currency the backend won't use).
const acceptsFiat = formValues.payment_methods.includes('fiat')
eventData.allow_fiat = acceptsFiat
if (acceptsFiat && formValues.fiat_currency) {
eventData.fiat_currency = formValues.fiat_currency
}
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
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.
// Notification config + promo codes go inside the `extra` envelope.
// On edit overlay onto the existing event.extra so fields only the
// LNbits admin UI sets (conditional, min_tickets) survive the
// round-trip. promo_codes is always sent explicitly: what the
// organizer sees in the editor is the truth, an empty list clears.
eventData.extra = {
...(props.event?.extra ?? {}),
payment_methods: formValues.payment_methods,
email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body,
promo_codes: toWirePromoCodes(promoRows.value),
}
if (isEditMode.value) {
@ -405,6 +476,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
resetForm()
selectedCategories.value = []
promoRows.value = []
bannerImages.value = []
emit('update:open', false)
} catch (error) {
@ -424,6 +496,7 @@ const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) {
resetForm()
selectedCategories.value = []
promoRows.value = []
bannerImages.value = []
}
emit('update:open', open)
@ -660,26 +733,87 @@ const handleOpenChange = (open: boolean) => {
</div>
<!-- ── Payment methods ──────────────────────────────────── -->
<div class="space-y-3">
<FormField name="payment_methods">
<FormItem 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>
<FormLabel class="text-sm font-medium">Payment methods</FormLabel>
<FormDescription class="text-xs">
Pick the rails buyers can pay with. Turn Lightning off for a
card-only sale.
</FormDescription>
</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')"
<div class="space-y-2">
<FormField
v-for="opt in PAYMENT_METHOD_OPTIONS"
:key="opt.id"
v-slot="{ value, handleChange }"
type="checkbox"
:value="opt.id"
:unchecked-value="false"
name="payment_methods"
>
<FormItem class="flex flex-row items-center gap-3 space-y-0 rounded-md border p-3">
<FormControl>
<TooltipProvider v-if="opt.id === 'fiat' && !hasAnyProvider" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<Checkbox :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 card payments.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Checkbox
v-else
:model-value="Array.isArray(value) && value.includes(opt.id)"
:disabled="isLoading"
@update:model-value="handleChange"
/>
</FormControl>
<div class="flex items-center gap-2 min-w-0">
<component :is="opt.icon" class="w-4 h-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<FormLabel class="font-normal">{{ opt.label }}</FormLabel>
<p class="text-xs text-muted-foreground">{{ opt.hint }}</p>
</div>
</div>
</FormItem>
</FormField>
</div>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="fiat_currency">
<FormItem
v-show="(form.values.payment_methods ?? []).includes('fiat') && isSatDenomination(form.values.currency)"
>
<FormLabel>Fiat currency</FormLabel>
<FormControl>
<Select v-bind="componentField" :disabled="isLoading">
<SelectTrigger>
<SelectValue placeholder="USD" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="c in availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
:key="c"
:value="c"
>
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription class="text-xs">Currency card buyers are charged in.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Ticket buyer notifications (collapsible). The backend
sends email + NIP-04 Nostr DM confirmations on
@ -743,6 +877,27 @@ const handleOpenChange = (open: boolean) => {
</CollapsibleContent>
</Collapsible>
<!-- Promo codes (collapsible). Percent discounts with an
optional redemption cap; the backend hides the list from
public responses and enforces active/max_uses at purchase. -->
<Collapsible v-model:open="promoOpen">
<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">
<Tag class="w-4 h-4" />
{{ t('events.create.promo.title') }}
<Badge v-if="promoRows.length > 0" variant="secondary" class="text-[10px] px-1.5">
{{ promoRows.length }}
</Badge>
</span>
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': promoOpen }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="pt-2">
<PromoCodesEditor v-model="promoRows" :disabled="isLoading" />
</CollapsibleContent>
</Collapsible>
<!-- Actions. Stack full-width on narrow viewports so a wide
localized "Submit Event" label can't push the Cancel
button out of the dialog. Submit reads top-down on

View file

@ -72,8 +72,14 @@ const isNonApproved = computed(
</script>
<template>
<!-- `min-w-0`: the card is a grid item, and a grid item's default
`min-width: auto` resolves to its min-content width. The nowrap
`truncate` rows below (location, organizer) contribute their
full text width to that, so a long address would widen the
whole column past the viewport on phones. Letting the item
shrink is what makes `truncate` actually truncate. -->
<Card
class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
class="relative min-w-0 cursor-pointer hover:shadow-lg transition-shadow duration-200"
@click="emit('click', event)"
>
<!-- Wash-out wrapper. The pending/rejected status badge below sits

View file

@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Plus, Trash2 } from 'lucide-vue-next'
import { normalizePromoCode, validatePromoRows, type PromoCodeRow } from '../lib/promoCodes'
/**
* Organizer-side promo code editor: one row per code with percentage,
* optional redemption cap and an active switch. Plain `v-model` over a
* `PromoCodeRow[]` (the parent keeps it as a ref next to its vee-validate
* form, the same way categories are handled); validation is the pure
* `validatePromoRows` so the parent can gate submit on the same rules.
*/
const props = defineProps<{
modelValue: PromoCodeRow[]
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [rows: PromoCodeRow[]] }>()
const { t } = useI18n()
const errors = computed(() => validatePromoRows(props.modelValue))
function update(index: number, patch: Partial<PromoCodeRow>) {
emit(
'update:modelValue',
props.modelValue.map((row, i) => (i === index ? { ...row, ...patch } : row)),
)
}
function add() {
emit('update:modelValue', [
...props.modelValue,
{ code: '', discount_percent: null, max_uses: null, active: true },
])
}
function remove(index: number) {
emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))
}
/** Empty input → null (so "unset" never becomes 0); otherwise a number. */
function toNumberOrNull(value: string | number): number | null {
const str = String(value).trim()
if (str === '') return null
const n = Number(str)
return Number.isFinite(n) ? n : null
}
</script>
<template>
<div class="space-y-3">
<p class="text-xs text-muted-foreground">{{ t('events.create.promo.hint') }}</p>
<p v-if="modelValue.length === 0" class="text-sm text-muted-foreground">
{{ t('events.create.promo.empty') }}
</p>
<div
v-for="(row, index) in modelValue"
:key="index"
class="rounded-md border border-border p-3 space-y-2"
>
<div class="grid grid-cols-[minmax(0,1fr)_5.5rem_5.5rem] gap-2">
<div class="space-y-1">
<Label :for="`promo-code-${index}`" class="text-xs">{{ t('events.create.promo.code') }}</Label>
<Input
:id="`promo-code-${index}`"
:model-value="row.code"
:placeholder="t('events.create.promo.codePlaceholder')"
class="font-mono uppercase"
autocapitalize="characters"
autocomplete="off"
spellcheck="false"
:disabled="disabled"
@update:model-value="update(index, { code: normalizePromoCode(String($event)) })"
/>
<p v-if="errors[index]?.code" class="text-xs text-destructive">
{{ t(errors[index].code!) }}
</p>
</div>
<div class="space-y-1">
<Label :for="`promo-pct-${index}`" class="text-xs">{{ t('events.create.promo.discount') }}</Label>
<Input
:id="`promo-pct-${index}`"
type="number"
inputmode="numeric"
min="1"
max="100"
step="1"
:model-value="row.discount_percent ?? ''"
:disabled="disabled"
@update:model-value="update(index, { discount_percent: toNumberOrNull($event) })"
/>
<p v-if="errors[index]?.discount_percent" class="text-xs text-destructive">
{{ t(errors[index].discount_percent!) }}
</p>
</div>
<div class="space-y-1">
<Label :for="`promo-max-${index}`" class="text-xs">{{ t('events.create.promo.maxUses') }}</Label>
<Input
:id="`promo-max-${index}`"
type="number"
inputmode="numeric"
min="1"
step="1"
placeholder="∞"
:model-value="row.max_uses ?? ''"
:disabled="disabled"
@update:model-value="update(index, { max_uses: toNumberOrNull($event) })"
/>
<p v-if="errors[index]?.max_uses" class="text-xs text-destructive">
{{ t(errors[index].max_uses!) }}
</p>
</div>
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Switch
:id="`promo-active-${index}`"
:model-value="row.active"
:disabled="disabled"
@update:model-value="update(index, { active: Boolean($event) })"
/>
<Label :for="`promo-active-${index}`" class="text-xs">{{ t('events.create.promo.active') }}</Label>
<span v-if="(row.used_count ?? 0) > 0" class="text-xs text-muted-foreground">
· {{ t('events.create.promo.used', { count: row.used_count }) }}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive"
:disabled="disabled"
:aria-label="t('events.create.promo.removeLabel', { code: row.code || '…' })"
@click="remove(index)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" class="gap-1.5" :disabled="disabled" @click="add">
<Plus class="w-4 h-4" />
{{ t('events.create.promo.add') }}
</Button>
</div>
</template>

View file

@ -1,13 +1,29 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { useTicketPurchase } from '../composables/useTicketPurchase'
import { usePromoCode } from '../composables/usePromoCode'
import { normalizePromoCode } from '../lib/promoCodes'
import { useAuth } from '@/composables/useAuthService'
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 { User, Wallet, CreditCard, Zap, Ticket, Landmark, Minus, Plus, Copy, Check, Loader2, Mail, Tag, X } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry,
@ -15,6 +31,9 @@ import PaymentMethodSelector, {
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion'
import { effectivePaymentMethods } from '../lib/paymentMethods'
import { frontendRoot } from '../lib/frontendRoot'
import { PENDING_CHECKOUT_KEY, type PendingCheckout } from '../lib/pendingCheckout'
interface Props {
event: {
@ -25,6 +44,9 @@ interface Props {
/** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean
fiat_currency?: string
/** Rails the organizer enabled (`tickets_payment_methods`). Absent
* on legacy events — falls back to the allow_fiat rule. */
payment_methods?: string[]
}
isOpen: boolean
}
@ -35,8 +57,9 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const { isAuthenticated, userDisplay } = useAuth()
const { isAuthenticated, userDisplay, currentUser } = useAuth()
const {
isLoading,
error,
@ -45,7 +68,6 @@ const {
qrCode,
isPaymentPending,
isPayingWithWallet,
canPurchase,
userWallets,
hasWalletWithBalance,
purchaseTicketForEvent,
@ -54,7 +76,9 @@ const {
resetPaymentState,
cleanup,
purchasedTicketIds,
showTicketQR
ticketQRCodes,
showTicketQR,
finalizePurchasedTickets
} = useTicketPurchase()
const MAX_QUANTITY = 10
@ -75,6 +99,52 @@ const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
// and label the CTA "Get ticket" instead of "Proceed".
const isFree = computed(() => props.event.price_per_ticket <= 0)
// Guests may buy paid tickets (name + email identity); free claims stay
// login-gated so an anonymous script can't drain a free event's
// capacity (events#29).
const guestNeedsLogin = computed(() => isFree.value && !isAuthenticated.value)
// --- Promo code -----------------------------------------------------
// Server-validated preview (`POST /promo/validate`); the purchase
// endpoint re-checks the code anyway, so the client never trusts its
// own arithmetic for what gets charged.
const promo = usePromoCode(() => props.event.id, quantity)
const {
input: promoInput,
state: promoState,
isChecking: promoChecking,
codeToSend: promoCodeToSend,
} = promo
const hasDiscount = computed(
() => promoState.value.status === 'applied' && promoState.value.total < promoState.value.subtotal,
)
const promoDiscountAmount = computed(() =>
promoState.value.status === 'applied' ? promoState.value.discount : 0,
)
/** What the buyer will actually be charged for the current quantity. */
const effectiveTotal = computed(() =>
promoState.value.status === 'applied' ? promoState.value.total : totalPrice.value,
)
const isZeroTotal = computed(() => effectiveTotal.value <= 0)
// A 100 %-off code turns a paid ticket into a free claim, which stays
// login-gated (events#29). Prompt inline — swapping the whole form would
// hide the Remove button the guest needs to back out of the code.
const guestNeedsLoginForDiscount = computed(
() => !isFree.value && isZeroTotal.value && !isAuthenticated.value,
)
function onPromoInput(event: Event) {
const el = event.target as HTMLInputElement
const value = normalizePromoCode(el.value)
el.value = value
promoInput.value = value
}
function isPromoError(err: unknown): boolean {
return err instanceof Error && /promo code/i.test(err.message)
}
async function copyInvoice() {
if (!paymentRequest.value) return
try {
@ -87,16 +157,69 @@ async function copyInvoice() {
}
}
// --- Contact (delivery email) form ---------------------------------
// Every ticket carries an email so the extension can deliver it (and
// so Stripe's checkout is prefilled + locked to the same address).
// Guests additionally need a name — that pair is their identity on
// the backend. Confirm-email guards against typos on the one field a
// guest cannot fix afterwards.
const contactSchema = computed(() =>
toTypedSchema(
z
.object({
name: isAuthenticated.value
? z.string().optional().default('')
: z.string().trim().min(1, t('events.purchase.contact.nameRequired')).max(100),
email: z.string().trim().email(t('events.purchase.contact.emailInvalid')),
confirmEmail: z.string().trim(),
})
.superRefine((v, ctx) => {
if (v.email.toLowerCase() !== v.confirmEmail.toLowerCase()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmEmail'],
message: t('events.purchase.contact.emailMismatch'),
})
}
}),
),
)
const form = useForm({
validationSchema: contactSchema,
initialValues: { name: '', email: '', confirmEmail: '' },
})
// The dialog stays mounted across opens — reseed on every open so a
// login/logout between purchases is reflected.
watch(
() => props.isOpen,
(open) => {
if (!open) return
form.resetForm({
values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' },
})
promo.clear()
},
{ immediate: true },
)
const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion()
const selectedMethodId = ref<string>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
// Empty until the dialog opens: the watcher below picks the default per
// rail availability + auth state, so a guest lands on Card, not Lightning.
const selectedMethodId = ref<string>('')
const isFiatPending = ref(false)
const fiatError = ref<string | null>(null)
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
const enabledRails = computed(() =>
effectivePaymentMethods({
allowFiat: props.event.allow_fiat,
paymentMethods: props.event.payment_methods,
}),
)
const canChooseFiat = computed(() => enabledRails.value.includes('fiat'))
const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats',
)
@ -124,7 +247,9 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
}
const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const lightning: PaymentMethodEntry = {
const entries: PaymentMethodEntry[] = []
if (enabledRails.value.includes('lightning')) {
entries.push({
id: 'lightning',
rail: 'lightning',
label: 'Lightning',
@ -134,116 +259,169 @@ const paymentMethods = computed<PaymentMethodEntry[]>(() => {
!isPriceInSats.value && lightningSats.value
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined,
})
}
if (!props.event.allow_fiat) return [lightning]
if (canChooseFiat.value) {
if (providers.value.length > 0) {
const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
for (const id of providers.value) {
const meta = providerMeta(id)
return {
entries.push({
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,
{
} else {
// Guests (and sessions without provider info) can't enumerate the
// organizer's providers. Show a generic Card button and let the
// backend pick the organizer's default at request time.
entries.push({
id: 'fiat',
rail: 'fiat',
label: 'Card',
label: t('events.purchase.card'),
icon: CreditCard,
available: true,
},
]
})
}
}
return entries
})
// Default rail: guests lean to card when it's on offer (no wallet to
// pay a bolt11 with); everyone else takes the first enabled rail.
watch(
[paymentMethods, () => props.isOpen, isAuthenticated],
([methods, open, authed]) => {
if (!open || methods.length === 0) return
const current = methods.find((m) => m.id === selectedMethodId.value)
if (current) return
const preferred = !authed ? methods.find((m) => m.rail === 'fiat') : undefined
selectedMethodId.value = (preferred ?? methods[0]).id
},
{ immediate: true },
)
const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
)
async function handlePurchase() {
if (!canPurchase.value) return
fiatError.value = null
const onSubmit = form.handleSubmit(async (contact) => {
await handlePurchase(contact)
})
async function handlePurchase(contact: { name?: string; email: string }) {
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') {
const isGuest = !isAuthenticated.value
const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined
const promoCode = promoCodeToSend.value
if (guestNeedsLoginForDiscount.value) return
// Nothing to charge (100 %-off code on a paid event): skip the rail
// choice entirely — the backend issues the tickets already-paid and
// the composable lands on the success screen from `invoice.paid`.
if (isZeroTotal.value && !isFree.value) {
try {
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
await purchaseTicketForEvent(props.event.id, {
quantity: quantity.value,
contact: guestContact,
email: contact.email,
promoCode,
})
} catch (err) {
console.error('Error purchasing ticket:', err)
console.error('Error claiming discounted ticket:', err)
if (isPromoError(err)) void promo.revalidate()
}
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.
// Lightning path: the composable creates the invoice + starts polling.
// The buyer pays from their LNbits wallet or an external one on the
// invoice screen (restaurant pattern), so no auto-pay here.
if (method.rail === 'lightning') {
try {
await purchaseTicketForEvent(props.event.id, {
quantity: quantity.value,
contact: guestContact,
email: contact.email,
promoCode,
})
} catch (err) {
console.error('Error purchasing ticket:', err)
if (isPromoError(err)) void promo.revalidate()
}
return
}
// Fiat path: POST with the chosen provider, remember what we're
// waiting for, then hand the tab to the provider's checkout. Stripe
// brings the buyer back to the event page with ?checkout=success&
// tickets=… (or ?checkout=cancelled) — see EventDetailPage.
try {
isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const accessToken = isGuest ? undefined : (lnbitsAPI?.getAccessToken?.() || undefined)
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,
const invoice = await ticketApi.requestTicket(props.event.id, {
userId: isGuest ? undefined : currentUser.value?.id,
name: guestContact?.name,
email: contact.email,
accessToken,
{
frontendUrl: frontendRoot(),
paymentMethod: 'fiat',
fiatProvider: method.provider,
quantity: quantity.value,
},
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
promoCode,
})
// The backend may still short-circuit to the free path (e.g. a code
// that became 100 % between preview and pay): no checkout URL, the
// tickets are already issued — show them instead of an error.
if (invoice.paid) {
paymentHash.value = invoice.paymentHash
await finalizePurchasedTickets(invoice.ticketIds ?? [])
isFiatPending.value = false
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 {
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = t('events.purchase.noCheckoutUrl')
isFiatPending.value = false
return
}
const pending: PendingCheckout = {
eventId: props.event.id,
paymentHash: invoice.paymentHash,
ticketIds: invoice.ticketIds ?? [],
email: contact.email,
ts: Date.now(),
}
try {
localStorage.setItem(PENDING_CHECKOUT_KEY, JSON.stringify(pending))
} catch {
// Storage unavailable (private mode) — the return URL still
// carries the ticket ids, so the flow survives without it.
}
window.location.assign(invoice.fiatPaymentRequest)
// Keep the spinner up until the navigation lands.
} catch (err) {
fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed')
isFiatPending.value = false
if (isPromoError(err)) void promo.revalidate()
}
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
}
function handleClose() {
emit('update:isOpen', false)
resetPaymentState()
selectedMethodId.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
promo.clear()
selectedMethodId.value = ''
fiatError.value = null
isFiatPending.value = false
quantity.value = 1
copiedInvoice.value = false
}
@ -262,38 +440,43 @@ onUnmounted(() => {
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
</DialogTitle>
<DialogDescription>
<span v-if="quantity > 1">
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
</span>
<span v-else-if="isFree">
Get a free ticket for <strong>{{ event.name }}</strong>
<span v-if="isFree">
<template v-if="quantity > 1">{{ quantity }} free tickets for</template>
<template v-else>Get a free ticket for</template>
<strong>{{ event.name }}</strong>
</span>
<span v-else>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
<template v-if="quantity > 1">{{ quantity }} tickets for</template>
<template v-else>Purchase a ticket for</template>
<strong>{{ event.name }}</strong> ·
<s v-if="hasDiscount" class="text-muted-foreground">{{ formatEventPrice(totalPrice, event.currency) }}</s>
<span :class="{ 'text-primary font-medium': hasDiscount }">
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
</span>
</span>
</DialogDescription>
</DialogHeader>
<!-- Authentication Check -->
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
<!-- Free tickets need an account (anti-abuse). Paid tickets don't. -->
<div v-if="guestNeedsLogin" class="py-4 text-center space-y-4">
<div class="flex justify-center">
<User class="w-12 h-12 text-muted-foreground" />
</div>
<div class="space-y-2">
<h3 class="text-lg font-semibold">Login Required</h3>
<h3 class="text-lg font-semibold">{{ t('events.purchase.loginRequiredTitle') }}</h3>
<p class="text-sm text-muted-foreground">
Please log in to your account to purchase tickets using your wallet.
{{ t('events.purchase.loginRequiredFree') }}
</p>
</div>
<Button @click="handleClose" variant="outline">
Close
</Button>
<div class="flex gap-2 justify-center">
<Button @click="() => $router.push('/login')">{{ t('events.detail.logIn') }}</Button>
<Button @click="handleClose" variant="outline">Close</Button>
</div>
</div>
<!-- User Info and Purchase -->
<div v-else-if="!paymentHash" class="py-4 space-y-4">
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<!-- Contact + payment details, then submit -->
<form v-else-if="!paymentHash" class="py-4 space-y-4" @submit="onSubmit">
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<User class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Purchasing as:</span>
@ -310,8 +493,108 @@ onUnmounted(() => {
</div>
</div>
<!-- Wallet Information -->
<!-- Contact: who the ticket is for + where to send it -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<Mail class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('events.purchase.contact.title') }}</span>
</div>
<FormField v-if="!isAuthenticated" v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.name') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" autocomplete="name" :placeholder="t('events.purchase.contact.namePlaceholder')" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="email">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.email') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="email" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
</FormControl>
<FormDescription class="text-xs">{{ t('events.purchase.contact.emailHint') }}</FormDescription>
<p class="text-xs font-medium text-destructive">{{ t('events.purchase.contact.spamHint') }}</p>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="confirmEmail">
<FormItem>
<FormLabel>{{ t('events.purchase.contact.confirmEmail') }} *</FormLabel>
<FormControl>
<Input v-bind="componentField" type="email" inputmode="email" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="email@example.com" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Promo code (paid events only). Apply asks the backend for
the discounted total; the badge + struck-through price
reflect the server's numbers, never a client guess. -->
<div v-if="!isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="flex items-center gap-2">
<Tag class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t('events.purchase.promo.label') }}</span>
</div>
<div v-if="promoState.status === 'applied' || promoState.status === 'unverified'" class="flex items-center justify-between gap-2">
<Badge :variant="promoState.status === 'applied' ? 'default' : 'secondary'" class="font-mono">
<template v-if="promoState.status === 'applied'">
{{ t('events.purchase.promo.applied', { code: promoState.code, percent: promoState.discountPercent }) }}
</template>
<template v-else>{{ promoState.code }}</template>
</Badge>
<Button type="button" variant="ghost" size="sm" class="gap-1 h-7" @click="promo.clear()">
<X class="w-3.5 h-3.5" />
{{ t('events.purchase.promo.remove') }}
</Button>
</div>
<div v-else class="flex gap-2">
<Input
:model-value="promoInput"
:placeholder="t('events.purchase.promo.placeholder')"
class="font-mono uppercase"
autocapitalize="characters"
autocomplete="off"
spellcheck="false"
:disabled="promoChecking"
@input="onPromoInput"
@keydown.enter.prevent="promo.apply()"
/>
<Button
type="button"
variant="outline"
class="shrink-0"
:disabled="!promoInput || promoChecking"
@click="promo.apply()"
>
<Loader2 v-if="promoChecking" class="w-4 h-4 mr-2 animate-spin" />
{{ promoChecking ? t('events.purchase.promo.checking') : t('events.purchase.promo.apply') }}
</Button>
</div>
<p v-if="promoState.status === 'invalid'" class="text-xs text-destructive">
{{ t('events.purchase.promo.invalid') }}
</p>
<p v-else-if="promoState.status === 'unverified'" class="text-xs text-muted-foreground">
{{ t('events.purchase.promo.unverified') }}
</p>
<Alert v-if="guestNeedsLoginForDiscount" class="mt-2">
<User class="w-4 h-4" />
<AlertDescription class="flex flex-col gap-2">
<span>{{ t('events.purchase.promo.loginRequiredDiscount') }}</span>
<Button type="button" size="sm" class="w-fit" @click="() => $router.push('/login')">
{{ t('events.detail.logIn') }}
</Button>
</AlertDescription>
</Alert>
</div>
<!-- Wallet Information (logged-in Lightning buyers) -->
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<Wallet class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Wallet Status:</span>
@ -330,7 +613,7 @@ onUnmounted(() => {
<Badge v-else variant="secondary" class="text-xs">Empty</Badge>
</div>
</div>
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-primary">
<Zap class="w-4 h-4" />
<span>Auto-payment available</span>
</div>
@ -348,7 +631,7 @@ onUnmounted(() => {
</div>
<!-- Quantity selector — backend caps at 10. One invoice for
the whole purchase, one ticket row representing N seats. -->
the whole purchase, N ticket rows (one per attendee). -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2">
@ -385,26 +668,34 @@ onUnmounted(() => {
<span class="text-sm text-muted-foreground">
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span>
<span class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span>
<span class="text-sm font-medium">
<template v-if="isFree">Free</template>
<template v-else-if="hasDiscount">
<s class="text-muted-foreground font-normal mr-1">{{ formatEventPrice(totalPrice, event.currency) }}</s>
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
</template>
<template v-else>{{ formatEventPrice(totalPrice, event.currency) }}</template>
</span>
</div>
<div v-if="hasDiscount && promoState.status === 'applied'" class="flex justify-between text-primary">
<span class="text-sm">{{ t('events.purchase.promo.discountLine', { code: promoState.code }) }}</span>
<span class="text-sm font-medium">−{{ formatEventPrice(promoDiscountAmount, event.currency) }}</span>
</div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="totalPrice"
:amount="effectiveTotal"
from="sat"
:to="event.fiat_currency"
prefix="Equivalent ~"
suffix=" if paid in fiat"
suffix=" if paid by card"
/>
</div>
</div>
<!-- Payment method selector (only shown when fiat is enabled
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 && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
<!-- Payment method selector — one button per enabled rail /
configured fiat provider. Hidden when only one rail is on
(nothing to choose) and for free tickets. -->
<div v-if="paymentMethods.length > 1 && !isZeroTotal" 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.
@ -422,43 +713,29 @@ onUnmounted(() => {
{{ 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>
<Button
v-else
@click="handlePurchase"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
type="submit"
:disabled="isLoading || isFiatPending || promoChecking || guestNeedsLoginForDiscount || (paymentMethods.length === 0 && !isZeroTotal)"
class="w-full"
>
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<template v-else-if="isFree">
<template v-else-if="isZeroTotal">
<Ticket class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
</template>
<template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout
{{ t('events.purchase.continueTo', { provider: selectedMethod.label }) }}
</template>
<template v-else>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
</template>
</Button>
</div>
<p v-if="selectedMethod?.rail === 'fiat' && !isZeroTotal" class="text-xs text-muted-foreground text-center">
{{ t('events.purchase.fiatRedirectHint') }}
</p>
</form>
<!-- Lightning invoice — restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the
@ -469,8 +746,13 @@ onUnmounted(() => {
<div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground">
<template v-if="hasWalletWithBalance">
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
</template>
<template v-else>
Scan with any Lightning wallet, or open it in your wallet app.
</template>
</p>
</div>
@ -489,7 +771,7 @@ onUnmounted(() => {
<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) }}
{{ formatEventPrice(effectiveTotal, event.currency) }}
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
({{ quantity }} tickets)
</span>
@ -554,21 +836,20 @@ onUnmounted(() => {
</div>
</div>
<!-- Success state. QRs live in My Tickets — no need to
pre-render them here; this view's job is to confirm the
purchase landed and route the buyer to where they actually
interact with their tickets. -->
<!-- Success state. Logged-in buyers get routed to My Tickets
(QRs live there); guests have no My Tickets, so their QRs
render right here alongside the "emailed to" note. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
<div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" />
<Ticket class="w-12 h-12 text-primary" />
</div>
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-green-600">
<h3 class="text-lg font-semibold text-primary">
{{ purchasedTicketIds.length > 1
? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }}
</h3>
<p class="text-sm text-muted-foreground">
<p v-if="isAuthenticated" 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.
@ -577,10 +858,31 @@ onUnmounted(() => {
Your ticket is now in My Tickets.
</span>
</p>
<p v-if="form.values.email" class="text-sm text-muted-foreground">
{{ t('events.checkout.emailedTo', { email: form.values.email }) }}
</p>
</div>
<div v-if="!isAuthenticated" class="w-full space-y-3">
<div
v-for="(id, idx) in purchasedTicketIds"
:key="id"
class="bg-muted/50 rounded-lg p-3 flex flex-col items-center gap-2"
>
<span v-if="purchasedTicketIds.length > 1" class="text-xs text-muted-foreground">
{{ t('events.checkout.ticketN', { n: idx + 1, total: purchasedTicketIds.length }) }}
</span>
<div class="overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="ticketQRCodes[id]" :src="ticketQRCodes[id]" :alt="`Ticket ${id}`" class="block h-40 w-40" />
</div>
<RouterLink :to="`/events/ticket/${id}`" class="text-xs text-primary underline underline-offset-2">
{{ t('events.checkout.viewTicket') }}
</RouterLink>
</div>
</div>
<div class="space-y-3 w-full">
<Button @click="() => $router.push('/my-tickets')" class="w-full">
<Button v-if="isAuthenticated" @click="() => $router.push('/my-tickets')" class="w-full">
View My Tickets
</Button>
<Button variant="outline" @click="handleClose" class="w-full">

View file

@ -13,9 +13,10 @@ export function useMyEvents() {
// and merge them into the feed. Otherwise an event that drops to
// `proposed` after a non-admin edit disappears from the user's view
// entirely — they'd be unable to find it to make a follow-up edit or
// monitor its approval status. Public approved events from other
// users take precedence on dedup (server is the source of truth for
// the public view).
// monitor its approval status. For the user's OWN events the
// authenticated record wins on dedup: it is the full `Event` (incl.
// `extra.promo_codes`, which public responses omit), and it is what
// the edit dialog must prefill from.
const fetchAll = async (): Promise<TicketedEvent[]> => {
const publicEvents = (await ticketApi.fetchTicketedEvents()) as TicketedEvent[]
@ -26,9 +27,11 @@ export function useMyEvents() {
try {
const myEvents = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
const seen = new Set(publicEvents.map((e) => e.id))
const own = myEvents.filter((e) => !seen.has(e.id))
return [...publicEvents, ...own]
const own = new Map(myEvents.map((e) => [e.id, e]))
const merged = publicEvents.map((e) => own.get(e.id) ?? e)
const publicIds = new Set(publicEvents.map((e) => e.id))
const ownOnly = myEvents.filter((e) => !publicIds.has(e.id))
return [...merged, ...ownOnly]
} catch (err) {
// Falling back to just the public feed is acceptable — the user
// can still browse, they just won't see their own pending events.

View file

@ -0,0 +1,127 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCodes'
/**
* Buyer-side promo state for the purchase dialog.
*
* - `idle` no code entered / cleared
* - `checking` validate request in flight
* - `applied` server confirmed the code; totals are authoritative
* - `invalid` server answered but the code is not valid for this
* event/quantity (unknown, inactive, exhausted, 0 %)
* - `unverified` older extension without the validate route (404/405)
* or a network failure: keep the code and let the
* purchase endpoint decide — it still discounts on
* every extension version.
*/
export type PromoState =
| { status: 'idle' }
| { status: 'checking'; code: string }
| {
status: 'applied'
code: string
discountPercent: number
subtotal: number
discount: number
total: number
currency: string
}
| { status: 'invalid'; code: string }
| { status: 'unverified'; code: string }
const REVALIDATE_DEBOUNCE_MS = 300
export function usePromoCode(eventId: MaybeRefOrGetter<string>, quantity: Ref<number>) {
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
/** What the buyer typed (kept upper-case by the input handler). */
const input = ref('')
const state = ref<PromoState>({ status: 'idle' })
// Monotonic request counter so a slow response for a previous code or
// quantity can never overwrite a newer result.
let requestSeq = 0
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const isChecking = computed(() => state.value.status === 'checking')
/** Code to put on the purchase request. `invalid` deliberately sends
* nothing so the buyer is not surprised by a 400 at pay time. */
const codeToSend = computed<string | undefined>(() => {
const s = state.value
return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined
})
const appliedTotal = computed<number | null>(() =>
state.value.status === 'applied' ? state.value.total : null,
)
async function validate(code: string): Promise<void> {
const seq = ++requestSeq
state.value = { status: 'checking', code }
try {
const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value)
if (seq !== requestSeq) return
const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0]
if (!hit) {
state.value = { status: 'invalid', code }
return
}
state.value = {
status: 'applied',
code: hit.code,
discountPercent: hit.discount_percent ?? 0,
subtotal: totals.subtotal,
discount: totals.discount,
total: totals.total,
currency: totals.currency,
}
} catch (err) {
if (seq !== requestSeq) return
if (isUnsupportedEndpointError(err)) {
state.value = { status: 'unverified', code }
return
}
// Network / 5xx: don't block the purchase on an advisory call.
console.warn('[usePromoCode] validate failed, sending code unverified:', err)
state.value = { status: 'unverified', code }
}
}
async function apply(): Promise<void> {
const code = normalizePromoCode(input.value)
if (!code) return
input.value = code
await validate(code)
}
function clear(): void {
requestSeq++
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = null
input.value = ''
state.value = { status: 'idle' }
}
/** Re-check the current code (quantity changed, or the purchase
* endpoint rejected it — e.g. the last use went to someone else). */
async function revalidate(): Promise<void> {
const s = state.value
if (s.status === 'idle' || s.status === 'checking') return
await validate(s.code)
}
watch(quantity, () => {
const s = state.value
if (s.status === 'idle') return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
void revalidate()
}, REVALIDATE_DEBOUNCE_MS)
})
return { input, state, isChecking, codeToSend, appliedTotal, apply, clear, revalidate }
}

View file

@ -6,6 +6,24 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets'
import { frontendRoot } from '../lib/frontendRoot'
import { renderTicketQr } from '../lib/ticketQr'
/** Guest identity for a purchase without an LNbits account. */
export interface PurchaseContact {
name: string
email: string
}
export interface PurchaseTicketOptions {
quantity?: number
/** Guest buyer (no account). Required when not authenticated. */
contact?: PurchaseContact
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */
email?: string
/** Promo code to redeem (already normalised by the dialog). */
promoCode?: string
}
export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth()
@ -64,11 +82,7 @@ export function useTicketPurchase() {
async function generateTicketQRCode(ticketId: string) {
try {
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, {
width: 128,
margin: 2
})
const dataUrl = await renderTicketQr(ticketId, { width: 160 })
ticketQRCode.value = dataUrl
return dataUrl
} catch (error) {
@ -128,9 +142,13 @@ export function useTicketPurchase() {
async function purchaseTicketForEvent(
eventId: string,
options: { quantity?: number } = {},
options: PurchaseTicketOptions = {},
) {
if (!canPurchase.value || !currentUser.value) {
// Guests are identified by name + email; logged-in buyers by user id.
// Either is enough for a paid ticket — free tickets stay login-gated
// in the dialog (events#29: no anonymous free issuance).
const isGuest = !canPurchase.value || !currentUser.value
if (isGuest && !options.contact) {
throw new Error('User must be authenticated to purchase tickets')
}
@ -150,12 +168,15 @@ export function useTicketPurchase() {
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const invoice = await ticketApi.requestTicket(
eventId,
currentUser.value!.id,
accessToken,
{ quantity: options.quantity },
)
const invoice = await ticketApi.requestTicket(eventId, {
userId: isGuest ? undefined : currentUser.value!.id,
name: isGuest ? options.contact?.name : undefined,
email: isGuest ? options.contact?.email : options.email,
accessToken: isGuest ? undefined : accessToken,
frontendUrl: frontendRoot(),
quantity: options.quantity,
promoCode: options.promoCode,
})
// Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip
@ -304,6 +325,10 @@ export function useTicketPurchase() {
handleOpenLightningWallet,
resetPaymentState,
cleanup,
generateTicketQRCode
generateTicketQRCode,
// Exposed so the dialog's fiat branch can land a 100 %-off purchase
// (backend returns `paid: true`, no checkout URL) on the same
// success screen as the Lightning / free paths.
finalizePurchasedTickets
}
}

View file

@ -28,6 +28,8 @@ export interface EventTicket {
name?: string | null
registered: boolean
registeredAt: string | null
/** Promo code redeemed on this ticket, when the extension reports it. */
promoCode?: string | null
}
/** Counts + roster snapshot for the event, sourced from the backend. */
@ -110,6 +112,7 @@ export function useTicketScanner(eventId: Ref<string>) {
name: t.name ?? null,
registered: t.registered,
registeredAt: t.registered_at,
promoCode: t.applied_promo_code ?? null,
})),
}
} catch (e) {

View file

@ -51,6 +51,18 @@ export const eventsModule = createModulePlugin({
requiresAuth: false,
},
},
{
// Public ticket page — target of the emailed ticket link. Listed
// before /events/:id for readability; the router can't confuse the
// two anyway (different segment counts).
path: '/events/ticket/:ticketId',
name: 'event-ticket',
component: () => import('./views/TicketPage.vue'),
meta: {
title: 'Ticket',
requiresAuth: false,
},
},
{
path: '/events/:id',
name: 'event-detail',

View file

@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { frontendRoot } from './frontendRoot'
describe('frontendRoot', () => {
it('joins origin and base path without a trailing slash', () => {
expect(frontendRoot('https://demo.aiolabs.dev', '/events/')).toBe('https://demo.aiolabs.dev/events')
})
it('collapses a root base to the bare origin', () => {
expect(frontendRoot('http://localhost:5173', '/')).toBe('http://localhost:5173')
})
it('tolerates a base without leading slash', () => {
expect(frontendRoot('https://app.ariege.io', 'oyez/')).toBe('https://app.ariege.io/oyez')
})
})

View file

@ -0,0 +1,20 @@
/**
* This app's public root — origin + Vite base path, no trailing slash.
*
* Sent to the events extension as `frontend_url` so the backend can
* compose the Stripe success / cancel URLs and the emailed ticket link
* under the webapp (e.g. `https://demo.aiolabs.dev/events`,
* `https://app.ariege.io/oyez`) instead of the LNbits host. The
* extension appends the same path shapes the LNbits UI uses
* (`/events/{event_id}`, `/events/ticket/{ticket_id}`), which the events
* router serves under that base.
*
* Parameters exist for tests; callers use the defaults.
*/
export function frontendRoot(
origin: string = window.location.origin,
base: string = import.meta.env.BASE_URL ?? '/',
): string {
const path = base.startsWith('/') ? base : `/${base}`
return `${origin}${path}`.replace(/\/+$/, '')
}

View file

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { effectivePaymentMethods } from './paymentMethods'
describe('effectivePaymentMethods', () => {
it('falls back to the legacy allowFiat rule when no list is published', () => {
expect(effectivePaymentMethods({ allowFiat: false })).toEqual(['lightning'])
expect(effectivePaymentMethods({ allowFiat: true })).toEqual(['lightning', 'fiat'])
expect(effectivePaymentMethods(undefined)).toEqual(['lightning'])
})
it('honours an explicit list, including fiat-only events', () => {
expect(effectivePaymentMethods({ allowFiat: true, paymentMethods: ['fiat'] })).toEqual(['fiat'])
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['lightning', 'fiat'] }))
.toEqual(['lightning', 'fiat'])
})
it('drops unknown rails and falls back when nothing known remains', () => {
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain'] })).toEqual(['lightning'])
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain', 'fiat'] })).toEqual(['fiat'])
})
})

View file

@ -0,0 +1,22 @@
export type PaymentRailId = 'lightning' | 'fiat'
const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat']
/**
* Rails a buyer may use for an event.
*
* Mirrors `effective_payment_methods()` in the events extension: an
* explicit `paymentMethods` list wins; otherwise the legacy rule applies
* — Lightning always, fiat only when the organizer set `allowFiat`.
* Unknown rail ids are dropped so a future backend value can't render
* a button the dialog has no handler for.
*/
export function effectivePaymentMethods(
info: { allowFiat?: boolean; paymentMethods?: string[] } | null | undefined,
): PaymentRailId[] {
const explicit = (info?.paymentMethods ?? []).filter(
(m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m),
)
if (explicit.length > 0) return explicit
return info?.allowFiat ? ['lightning', 'fiat'] : ['lightning']
}

View file

@ -0,0 +1,38 @@
/**
* What the buyer is waiting on while the tab is away at the fiat
* provider's checkout. Written right before the redirect; read back by
* EventDetailPage when Stripe returns with `?checkout=success` (the
* URL carries the ticket ids too — this record is the belt-and-braces
* copy that also remembers the delivery email for the confirmation
* screen). Cleared once the return dialog sees the tickets paid, or on
* `?checkout=cancelled`.
*/
export const PENDING_CHECKOUT_KEY = 'events:pending-checkout'
export interface PendingCheckout {
eventId: string
paymentHash: string
ticketIds: string[]
email?: string
ts: number
}
export function readPendingCheckout(): PendingCheckout | null {
try {
const raw = localStorage.getItem(PENDING_CHECKOUT_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PendingCheckout
if (!parsed || typeof parsed.eventId !== 'string') return null
return parsed
} catch {
return null
}
}
export function clearPendingCheckout(): void {
try {
localStorage.removeItem(PENDING_CHECKOUT_KEY)
} catch {
// ignore
}
}

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
applyPercent,
fromWirePromoCodes,
hasPromoErrors,
isUnsupportedEndpointError,
normalizePromoCode,
toWirePromoCodes,
validatePromoRows,
type PromoCodeRow,
} from './promoCodes'
function row(overrides: Partial<PromoCodeRow> = {}): PromoCodeRow {
return { code: 'HALF', discount_percent: 50, max_uses: null, active: true, ...overrides }
}
describe('normalizePromoCode', () => {
it('trims and upper-cases, and tolerates null', () => {
expect(normalizePromoCode(' summer24 ')).toBe('SUMMER24')
expect(normalizePromoCode(null)).toBe('')
expect(normalizePromoCode(undefined)).toBe('')
})
})
describe('validatePromoRows', () => {
it('accepts a well-formed row', () => {
const errors = validatePromoRows([row()])
expect(errors).toEqual([{}])
expect(hasPromoErrors(errors)).toBe(false)
})
it('flags an empty code', () => {
expect(validatePromoRows([row({ code: ' ' })])[0].code).toBe('events.create.promo.errors.required')
})
it('flags duplicates case-insensitively on both rows', () => {
const errors = validatePromoRows([row({ code: 'half' }), row({ code: ' HALF ' })])
expect(errors[0].code).toBe('events.create.promo.errors.duplicate')
expect(errors[1].code).toBe('events.create.promo.errors.duplicate')
expect(hasPromoErrors(errors)).toBe(true)
})
it('requires the percentage to be within 1..100', () => {
for (const pct of [null, 0, 101, Number.NaN]) {
expect(validatePromoRows([row({ discount_percent: pct })])[0].discount_percent)
.toBe('events.create.promo.errors.percentRange')
}
expect(validatePromoRows([row({ discount_percent: 100 })])[0].discount_percent).toBeUndefined()
})
it('allows blank max uses and rejects zero / fractions', () => {
expect(validatePromoRows([row({ max_uses: null })])[0].max_uses).toBeUndefined()
expect(validatePromoRows([row({ max_uses: 3 })])[0].max_uses).toBeUndefined()
expect(validatePromoRows([row({ max_uses: 0 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin')
expect(validatePromoRows([row({ max_uses: 1.5 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin')
})
})
describe('toWirePromoCodes / fromWirePromoCodes', () => {
it('normalises codes, drops used_count and maps blank max uses to null', () => {
expect(toWirePromoCodes([row({ code: ' half ', used_count: 4 }), row({ code: 'CAP', max_uses: 2 })])).toEqual([
{ code: 'HALF', discount_percent: 50, active: true, max_uses: null },
{ code: 'CAP', discount_percent: 50, active: true, max_uses: 2 },
])
})
it('round-trips the wire shape and tolerates the older three-field shape', () => {
expect(fromWirePromoCodes([{ code: 'OLD', discount_percent: 20, active: false }])).toEqual([
{ code: 'OLD', discount_percent: 20, max_uses: null, active: false, used_count: 0 },
])
expect(fromWirePromoCodes(undefined)).toEqual([])
expect(fromWirePromoCodes([{ code: 'CAP', discount_percent: 10, active: true, max_uses: 0, used_count: 2 }]))
.toEqual([{ code: 'CAP', discount_percent: 10, max_uses: null, active: true, used_count: 2 }])
})
})
describe('applyPercent', () => {
it('rounds sats down to whole units and fiat to two decimals', () => {
expect(applyPercent(333, 15, 'sat')).toBe(283)
expect(applyPercent(59.97, 33, 'EUR')).toBe(40.18)
expect(applyPercent(1000, 100, 'sats')).toBe(0)
})
})
describe('isUnsupportedEndpointError', () => {
it('recognises 404/405 and nothing else', () => {
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 404 }))).toBe(true)
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 405 }))).toBe(true)
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 400 }))).toBe(false)
expect(isUnsupportedEndpointError(new Error('x'))).toBe(false)
expect(isUnsupportedEndpointError(null)).toBe(false)
})
})

View file

@ -0,0 +1,125 @@
import type { PromoCode } from '../types/ticket'
/**
* Promo-code helpers shared by the organizer editor and the buyer
* dialog. Pure functions, no Vue — mirror `promo.py` in the events
* extension by name so the two sides stay recognisably in step:
*
* normalizePromoCode ↔ promo.normalize_code
* applyPercent ↔ promo.round_amount / basket_totals (display fallback only)
*
* The server's `POST /events/api/v1/promo/validate/{event_id}` is the
* source of truth for the discounted total; `applyPercent` exists so
* the UI can still show *something* against an older extension.
*/
/** One editor row. `discount_percent` / `max_uses` are `null` while the
* organizer is still typing (empty inputs), never `0`-as-empty. */
export interface PromoCodeRow {
code: string
discount_percent: number | null
/** `null` = unlimited (backend `max_uses: None`). */
max_uses: number | null
active: boolean
/** Derived by the backend from paid tickets; read-only in the editor. */
used_count?: number
}
/** Per-row validation messages as i18n keys (the component runs them
* through `t()`); a missing field means the field is fine. */
export interface PromoRowErrors {
code?: string
discount_percent?: string
max_uses?: string
}
const ERROR_KEYS = {
required: 'events.create.promo.errors.required',
duplicate: 'events.create.promo.errors.duplicate',
percentRange: 'events.create.promo.errors.percentRange',
maxUsesMin: 'events.create.promo.errors.maxUsesMin',
} as const
/** Trim + upper-case, the same normalisation the backend applies before
* matching, so what the buyer sees in the input is what gets matched. */
export function normalizePromoCode(raw: string | null | undefined): string {
return (raw ?? '').trim().toUpperCase()
}
/**
* Editor rules — stricter than the backend on purpose: the backend
* accepts 0 % (a no-op the buyer would see as "code not valid"), the
* editor requires 1–100 so an organizer cannot create a dud by accident.
*/
export function validatePromoRows(rows: PromoCodeRow[]): PromoRowErrors[] {
const seen = new Map<string, number>()
for (const row of rows) {
const code = normalizePromoCode(row.code)
if (code) seen.set(code, (seen.get(code) ?? 0) + 1)
}
return rows.map((row) => {
const errors: PromoRowErrors = {}
const code = normalizePromoCode(row.code)
if (!code) {
errors.code = ERROR_KEYS.required
} else if ((seen.get(code) ?? 0) > 1) {
errors.code = ERROR_KEYS.duplicate
}
const pct = row.discount_percent
if (pct === null || !Number.isFinite(pct) || pct < 1 || pct > 100) {
errors.discount_percent = ERROR_KEYS.percentRange
}
const max = row.max_uses
if (max !== null && (!Number.isInteger(max) || max < 1)) {
errors.max_uses = ERROR_KEYS.maxUsesMin
}
return errors
})
}
export function hasPromoErrors(errors: PromoRowErrors[]): boolean {
return errors.some((e) => Object.keys(e).length > 0)
}
/** Editor rows → wire `extra.promo_codes`. Drops the read-only
* `used_count` (the backend recomputes it) and blank max-uses → `null`. */
export function toWirePromoCodes(rows: PromoCodeRow[]): PromoCode[] {
return rows.map((row) => ({
code: normalizePromoCode(row.code),
discount_percent: row.discount_percent ?? 0,
active: row.active,
max_uses: row.max_uses && row.max_uses > 0 ? Math.trunc(row.max_uses) : null,
}))
}
/** Wire `extra.promo_codes` → editor rows. Tolerates the pre-`max_uses`
* shape (older extension) and a missing list (public records). */
export function fromWirePromoCodes(codes: PromoCode[] | null | undefined): PromoCodeRow[] {
return (codes ?? []).map((c) => ({
code: c.code,
discount_percent: c.discount_percent ?? 0,
max_uses: c.max_uses && c.max_uses > 0 ? c.max_uses : null,
active: c.active ?? true,
used_count: c.used_count ?? 0,
}))
}
/**
* Display-only fallback for the discounted total when the validate
* endpoint is unavailable. Rounds like the backend: whole sats, two
* decimals for fiat.
*/
export function applyPercent(subtotal: number, percent: number, currency: string): number {
const raw = subtotal * (1 - percent / 100)
const unit = currency.toLowerCase()
if (unit === 'sat' || unit === 'sats') return Math.max(0, Math.trunc(raw))
return Math.max(0, Math.round(raw * 100) / 100)
}
/** An older extension without the validate route answers 404 (or 405
* when the path exists for another method). Anything else is a real
* error. */
export function isUnsupportedEndpointError(err: unknown): boolean {
const status = (err as { status?: unknown } | null)?.status
return status === 404 || status === 405
}

View file

@ -0,0 +1,61 @@
import brandLogo from '@brand-app-logo?url'
/**
* Render the scannable `ticket://<id>` QR every ticket surface uses
* (purchase success, Stripe return dialog, ticket page, My Tickets).
*
* Error-correction level H (30 % recoverable) leaves room for the brand
* logo the events extension also composites into its emailed PNG, so
* the in-app and emailed images look alike. The overlay is best-effort:
* if canvas or the logo asset is unavailable the plain QR is returned.
*/
export async function renderTicketQr(
ticketId: string,
options: { width?: number; logo?: boolean } = {},
): Promise<string> {
const width = options.width ?? 200
const qrcode = await import('qrcode')
const plain = await qrcode.toDataURL(`ticket://${ticketId}`, {
width,
margin: 2,
errorCorrectionLevel: 'H',
color: { dark: '#000000', light: '#FFFFFF' },
})
if (options.logo === false) return plain
try {
return await overlayLogo(plain, width)
} catch {
return plain
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`Could not load ${src}`))
img.src = src
})
}
async function overlayLogo(qrDataUrl: string, width: number): Promise<string> {
if (typeof document === 'undefined') return qrDataUrl
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = width
const ctx = canvas.getContext('2d')
if (!ctx) return qrDataUrl
const [qr, logo] = await Promise.all([loadImage(qrDataUrl), loadImage(brandLogo)])
ctx.drawImage(qr, 0, 0, width, width)
// Logo ≤ 20 % of the QR width on a white pad ≈ 26 % — well inside the
// 30 % damage budget of level H, matching the backend's PNG endpoint.
const logoSize = Math.round(width * 0.2)
const padSize = Math.round(width * 0.26)
const center = width / 2
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(center - padSize / 2, center - padSize / 2, padSize, padSize)
ctx.drawImage(logo, center - logoSize / 2, center - logoSize / 2, logoSize, logoSize)
return canvas.toDataURL('image/png')
}

View file

@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TicketApiService } from './TicketApiService'
const BASE = 'https://lnbits.test'
function okJson(body: unknown) {
return { ok: true, json: async () => body } as Response
}
describe('TicketApiService.requestTicket', () => {
const fetchMock = vi.fn()
let api: TicketApiService
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
})
afterEach(() => vi.unstubAllGlobals())
function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
}
it('sends name + email and no user_id / Authorization for guests', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', fiat_payment_request: 'https://checkout', is_fiat: true, ticket_ids: ['a', 'b'] }))
const res = await api.requestTicket('evt', {
name: 'Guest', email: 'g@example.com', frontendUrl: 'https://demo.aiolabs.dev/events',
paymentMethod: 'fiat', quantity: 2,
})
const { url, body, headers } = lastCall()
expect(url).toBe(`${BASE}/events/api/v1/tickets/evt`)
expect(body).toEqual({
name: 'Guest', email: 'g@example.com', frontend_url: 'https://demo.aiolabs.dev/events',
payment_method: 'fiat', quantity: 2,
})
expect(body.user_id).toBeUndefined()
expect(headers.Authorization).toBeUndefined()
expect(headers['X-API-KEY']).toBe('inst-key')
expect(res).toMatchObject({ isFiat: true, fiatPaymentRequest: 'https://checkout', ticketIds: ['a', 'b'] })
})
it('sends user_id + email with a Bearer token for logged-in buyers', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
await api.requestTicket('evt', {
userId: 'u1', email: 'me@example.com', accessToken: 'tok', frontendUrl: 'http://localhost:5173',
})
const { body, headers } = lastCall()
expect(body).toEqual({ user_id: 'u1', email: 'me@example.com', frontend_url: 'http://localhost:5173' })
expect(headers.Authorization).toBe('Bearer tok')
})
it('surfaces the backend detail string as the error message', async () => {
fetchMock.mockResolvedValue({ ok: false, json: async () => ({ detail: 'Payment method not enabled for this event.' }) } as Response)
await expect(api.requestTicket('evt', { name: 'G', email: 'g@x.io', paymentMethod: 'fiat' }))
.rejects.toThrow('Payment method not enabled for this event.')
})
})
describe('TicketApiService.getTicket / resendTicketEmail', () => {
const fetchMock = vi.fn()
let api: TicketApiService
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'k' })
})
afterEach(() => vi.unstubAllGlobals())
it('maps the anonymous PublicTicket shape', async () => {
fetchMock.mockResolvedValue(okJson({ event: 'evt', name: 'G', registered: false, paid: true, time: 't', reg_timestamp: 'r' }))
const t = await api.getTicket('tid')
expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/events/api/v1/tickets/tid`)
expect(t).toEqual({ eventId: 'evt', name: 'G', registered: false, paid: true, time: 't', regTimestamp: 'r' })
})
it('maps the structured resend result and tolerates the legacy bare-ticket shape', async () => {
const ticket = { id: 'tid', wallet: 'w', event: 'evt', name: 'G', email: 'g@x.io', user_id: null, registered: false, paid: true, time: 't', reg_timestamp: 'r', extra: {} }
fetchMock.mockResolvedValueOnce(okJson({ ticket, email: { attempted: true, sent: false, error: 'smtp down' }, nostr: { attempted: false, sent: false, error: null } }))
const r = await api.resendTicketEmail('tid', 'admin')
expect(r.ticket.id).toBe('tid')
expect(r.email).toEqual({ attempted: true, sent: false, error: 'smtp down' })
expect(r.nostr.attempted).toBe(false)
fetchMock.mockResolvedValueOnce(okJson(ticket))
const legacy = await api.resendTicketEmail('tid', 'admin')
expect(legacy.ticket.id).toBe('tid')
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
})
})
describe('TicketApiService promo codes', () => {
const fetchMock = vi.fn()
let api: TicketApiService
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
})
afterEach(() => vi.unstubAllGlobals())
function lastCall() {
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
}
it('validatePromoCode posts the v2 shape with an upper-cased code', async () => {
const totals = {
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat',
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }],
}
fetchMock.mockResolvedValue(okJson(totals))
const res = await api.validatePromoCode('evt', ' half ', 2)
const { url, init, body } = lastCall()
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`)
expect(init.method).toBe('POST')
expect(body).toEqual({ codes: ['HALF'], quantity: 2 })
expect(res).toEqual(totals)
})
it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response)
await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 })
})
it('requestTicket forwards promo_code verbatim', async () => {
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 })
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 })
})
it('createEvent sends extra.promo_codes verbatim', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'evt' }))
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }]
await api.createEvent(
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } },
'inkey',
)
const { body, headers } = lastCall()
expect(body.extra.promo_codes).toEqual(promo_codes)
expect(headers['X-API-KEY']).toBe('inkey')
})
})

View file

@ -1,19 +1,45 @@
import type {
BasketTotals,
EventTicket,
EventTicketExtra,
CreateTicketRequest,
PaymentMethod,
PublicTicket,
TicketPurchaseInvoice,
TicketPaymentStatus,
TicketResendResult,
TicketedEvent,
CreateEventRequest,
PromoValidateRequest,
} from '../types/ticket'
import { normalizePromoCode } from '../lib/promoCodes'
export interface TicketApiConfig {
baseUrl: string
apiKey: string
}
/** Options for `TicketApiService.requestTicket`. */
export interface RequestTicketOptions {
/** LNbits user id — logged-in buyers. */
userId?: string
/** Guest buyer name (required together with `email` when no `userId`). */
name?: string
/** Delivery email — required for guests, optional for logged-in buyers. */
email?: string
/** Bearer token; forwarded only when present. */
accessToken?: string
/** App root for return URLs + emailed link (see `lib/frontendRoot.ts`). */
frontendUrl?: string
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
}
/**
* Database-backed ticketing API service.
* Talks to the LNbits events extension for ticket inventory,
@ -57,29 +83,27 @@ export class TicketApiService {
* = 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.
* Identity: pass `userId` for a logged-in buyer (plus `email` so the
* ticket can be delivered by mail), or `name` + `email` for a guest.
* The endpoint itself is anonymous; `accessToken` is only forwarded
* when present.
*
* Additional ticket metadata (promo code, refund address, nostr
* identifier for DM delivery) can be supplied via `options`.
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
* the fiat path (the event must have that rail enabled — the backend
* 400s otherwise). `fiatProvider` is optional — backend picks the
* organizer's configured default when omitted. `frontendUrl` is this
* app's root (see `lib/frontendRoot.ts`) and drives the Stripe
* return URLs + emailed ticket link.
*/
async requestTicket(
eventId: string,
userId: 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
} = {},
options: RequestTicketOptions = {},
): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId }
const body: CreateTicketRequest = {}
if (options.userId) body.user_id = options.userId
if (options.name) body.name = options.name
if (options.email) body.email = options.email
if (options.frontendUrl) body.frontend_url = options.frontendUrl
if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode
@ -87,14 +111,14 @@ export class TicketApiService {
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (options.accessToken) headers['Authorization'] = `Bearer ${options.accessToken}`
const data = await this.request(
`/events/api/v1/tickets/${eventId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
headers,
body: JSON.stringify(body),
}
)
@ -112,6 +136,33 @@ export class TicketApiService {
}
}
/**
* Anonymous read of one ticket — paid / registered state only, no
* PII. Used by the Stripe return dialog to poll settlement and by
* the public ticket page the emailed link opens.
*/
async getTicket(ticketId: string): Promise<PublicTicket> {
const t = await this.request(`/events/api/v1/tickets/${ticketId}`, { method: 'GET' })
return {
eventId: t.event,
name: t.name ?? null,
registered: Boolean(t.registered),
paid: Boolean(t.paid),
time: t.time,
regTimestamp: t.reg_timestamp,
}
}
/**
* Anonymous public event detail (`PublicEvent`). Note the backend
* answers 410 for sold-out / closed events — callers that only need
* a display name should try the Nostr store first and treat a
* failure here as non-fatal.
*/
async getPublicEvent(eventId: string): Promise<TicketedEvent> {
return this.request(`/events/api/v1/events/${eventId}`, { method: 'GET' })
}
/**
* Check whether a ticket payment has been confirmed.
*/
@ -226,22 +277,30 @@ 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.
* Ticket plus per-channel delivery results (events ext >= v1.6.1-aio.8;
* older extensions returned the bare Ticket, which is tolerated).
*
* Endpoint added upstream in v1.6.1 (PR #51).
* Endpoint added upstream in v1.6.1 (PR #51); structured result in v1.6.8.
*/
async resendTicketEmail(
ticketId: string,
adminKey: string,
): Promise<EventTicket> {
const t = await this.request(
): Promise<TicketResendResult> {
const r = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`,
{
method: 'POST',
headers: { 'X-API-KEY': adminKey },
}
)
const t = r.ticket ?? r
const delivery = (d: any) => ({
attempted: Boolean(d?.attempted),
sent: Boolean(d?.sent),
error: d?.error ?? null,
})
return {
ticket: {
id: t.id,
wallet: t.wallet,
eventId: t.event,
@ -253,6 +312,9 @@ export class TicketApiService {
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined,
},
email: delivery(r.email),
nostr: delivery(r.nostr),
}
}
@ -276,6 +338,8 @@ export class TicketApiService {
name?: string | null
registered: boolean
registered_at: string | null
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
applied_promo_code?: string | null
}>
}> {
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
@ -284,6 +348,22 @@ export class TicketApiService {
})
}
/**
* Preview what a promo code does to a purchase of `quantity` tickets
* (`POST /events/api/v1/promo/validate/{event_id}`, anonymous). The
* backend applies the first valid code; an empty `discounts_applied`
* means "not valid". Older extensions 404 — the thrown error carries
* `status` so callers can fall back to "applied at checkout".
*/
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> {
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity }
return this.request(`/events/api/v1/promo/validate/${eventId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
/**
* Mark a paid ticket as registered at the door. Organizer-only —
* requires the event-owning wallet's admin_key. Backend rejects
@ -382,7 +462,9 @@ export class TicketApiService {
: Array.isArray(error.detail)
? error.detail[0]?.msg ?? 'Request failed'
: 'Request failed'
throw new Error(errorMessage)
// Carry the HTTP status so callers can tell "route missing on an
// older extension" (404) apart from "bad input" (400).
throw Object.assign(new Error(errorMessage), { status: response.status })
}
return response.json()

View file

@ -84,6 +84,8 @@ export interface EventTicketInfo {
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
/** Rails the organizer enabled (see TicketTags.paymentMethods). */
paymentMethods?: string[]
}
function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | undefined {
@ -98,6 +100,7 @@ function ticketTagsToInfo(ticket: TicketTags | undefined): EventTicketInfo | und
total: ticket.available !== undefined ? ticket.available + ticket.sold : undefined,
allowFiat: ticket.allowFiat,
fiatCurrency: ticket.fiatCurrency,
paymentMethods: ticket.paymentMethods,
}
}

View file

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { parseCalendarTimeEvent, NIP52_KINDS } from './nip52'
import type { NostrEvent } from 'nostr-tools'
function makeEvent(extraTags: string[][]): NostrEvent {
return {
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
id: 'id',
pubkey: 'pk',
created_at: 1_700_000_000,
content: '',
sig: '',
tags: [
['d', 'evt1'],
['title', 'Test'],
['start', '1700000000'],
['tickets_currency', 'EUR'],
['tickets_price', '20'],
...extraTags,
],
} as NostrEvent
}
describe('parseTicketTags — tickets_payment_methods', () => {
it('parses the comma list into paymentMethods', () => {
const parsed = parseCalendarTimeEvent(makeEvent([['tickets_payment_methods', 'lightning, fiat']]))
expect(parsed?.ticket?.paymentMethods).toEqual(['lightning', 'fiat'])
})
it('leaves paymentMethods undefined when the tag is absent (legacy events)', () => {
const parsed = parseCalendarTimeEvent(makeEvent([['tickets_allow_fiat', 'true']]))
expect(parsed?.ticket?.allowFiat).toBe(true)
expect(parsed?.ticket?.paymentMethods).toBeUndefined()
})
})

View file

@ -36,6 +36,10 @@ export interface TicketTags {
allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string
/** Rails the organizer enabled, from `tickets_payment_methods`
* ("lightning,fiat"). Undefined on events published before the
* tag existed — callers fall back to the allowFiat rule. */
paymentMethods?: string[]
}
/**
@ -137,6 +141,7 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
const soldStr = getTagValue(tags, 'tickets_sold')
const priceStr = getTagValue(tags, 'tickets_price')
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
const paymentMethodsStr = getTagValue(tags, 'tickets_payment_methods')
return {
available: availableStr != null ? Number(availableStr) : undefined,
@ -145,6 +150,9 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
currency,
allowFiat: allowFiatStr === 'true',
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
paymentMethods: paymentMethodsStr != null
? paymentMethodsStr.split(',').map((m) => m.trim()).filter(Boolean)
: undefined,
}
}

View file

@ -10,6 +10,38 @@ export interface PromoCode {
code: string
discount_percent: number
active: boolean
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
* same name as upstream v2). */
max_uses?: number | null
/** Paid tickets that used this code — derived by the backend on read,
* never sent back. */
used_count?: number
}
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
* v2 shape; `quantity` stands in for v2's `items` since this fork has
* no ticket types). */
export interface PromoValidateRequest {
codes: string[]
quantity: number
}
export interface BasketDiscount {
code: string
discount_percent: number | null
/** Always null on this fork (percent-only); kept for the v2 shape. */
discount_fixed: number | null
amount_saved: number
}
/** Response of the promo validate endpoint. An empty `discounts_applied`
* means none of the submitted codes is valid for this event/quantity. */
export interface BasketTotals {
subtotal: number
discount: number
total: number
discounts_applied: BasketDiscount[]
currency: string
}
/**
@ -19,13 +51,20 @@ export interface PromoCode {
* in upstream v1.4.0 (PR #50) and v1.6.0.
*/
export interface EventExtra {
promo_codes: PromoCode[]
/** Present on organizer-authenticated records only: public event
* responses (`/events/public`, `/events/{id}`) no longer carry the
* code list (events ext >= v1.6.1-aio.12). */
promo_codes?: PromoCode[]
conditional: boolean
min_tickets: number
email_notifications: boolean
nostr_notifications: boolean
notification_subject: string
notification_body: string
/** Rails the organizer enabled (`lightning`, `fiat`). Empty / absent
* means the legacy rule: Lightning always, fiat iff `allow_fiat`.
* Same shape as upstream v2's `extra.payment_methods`. */
payment_methods?: string[]
}
export interface EventTicketExtra {
@ -165,17 +204,22 @@ export interface CreateEventRequest {
}
/**
* 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.
* Body for `POST /tickets/{event_id}`. Identity is `user_id` (logged-in
* buyer, optionally with `email` for ticket delivery) OR the
* `name`+`email` pair (guest buyer). `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. `frontend_url` is this app's root so the
* backend can build the Stripe return URLs and the emailed ticket link
* under the webapp instead of the LNbits host.
*/
export interface CreateTicketRequest {
name?: string
email?: string
user_id?: string
/** App root (origin + base path, no trailing slash). Origin must be
* on the LNbits instance's CORS allowlist or the backend 400s. */
frontend_url?: string
promo_code?: string
refund_address?: string
nostr_identifier?: string
@ -184,3 +228,33 @@ export interface CreateTicketRequest {
/** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number
}
/**
* Anonymous view of a ticket from `GET /tickets/{ticket_id}`. The
* backend deliberately omits id / email / user_id; the id is whatever
* the caller asked for.
*/
export interface PublicTicket {
eventId: string
name: string | null
registered: boolean
paid: boolean
time: string
regTimestamp: string
}
export interface NotificationDeliveryResult {
attempted: boolean
sent: boolean
error: string | null
}
/**
* Structured response of `POST /tickets/{ticket_id}/resend-email`
* (events ext >= v1.6.1-aio.8, mirrors upstream v1.6.8).
*/
export interface TicketResendResult {
ticket: EventTicket
email: NotificationDeliveryResult
nostr: NotificationDeliveryResult
}

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useElementVisibility } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
@ -14,6 +15,8 @@ import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import CheckoutReturnDialog from '../components/CheckoutReturnDialog.vue'
import { clearPendingCheckout, readPendingCheckout } from '../lib/pendingCheckout'
import { useAuth } from '@/composables/useAuthService'
import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets'
@ -116,6 +119,7 @@ const purchaseEvent = computed(() => {
currency: a.ticketInfo.currency,
allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency,
payment_methods: a.ticketInfo.paymentMethods,
}
})
@ -142,7 +146,10 @@ const isPast = computed(() => {
const showPurchaseDialog = ref(false)
function openPurchaseDialog() {
if (!isAuthenticated.value) {
// Paid tickets are open to guests (name + email identity). Free
// claims still need an account so seats can't be drained anonymously.
const isFreeEvent = (purchaseEvent.value?.price_per_ticket ?? 0) <= 0
if (!isAuthenticated.value && isFreeEvent) {
toastService.info(t('events.detail.loginToBuyTickets'), {
action: {
label: t('events.detail.logIn'),
@ -154,6 +161,75 @@ function openPurchaseDialog() {
showPurchaseDialog.value = true
}
// --- Return from the fiat provider ---------------------------------
// Stripe sends the buyer back here with ?checkout=success&tickets=a,b
// (ids pre-generated by the extension before the session was created)
// or ?checkout=cancelled. The query is stripped immediately so the
// ticket ids don't linger in history; the localStorage record written
// before the redirect is the fallback source and carries the email.
const showReturnDialog = ref(false)
const returnTicketIds = ref<string[]>([])
const returnEmail = ref<string | undefined>(undefined)
function handleCheckoutReturn() {
const status = route.query.checkout
if (status !== 'success' && status !== 'cancelled') return
const pending = readPendingCheckout()
const samePurchase = pending?.eventId === eventId
void router.replace({ query: {} })
if (status === 'cancelled') {
clearPendingCheckout()
toastService.info(t('events.checkout.cancelled'))
return
}
const fromQuery = String(route.query.tickets ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const ids = fromQuery.length > 0 ? fromQuery : samePurchase ? pending!.ticketIds : []
if (ids.length === 0) return
returnTicketIds.value = ids
returnEmail.value = samePurchase ? pending?.email : undefined
showReturnDialog.value = true
}
onMounted(handleCheckoutReturn)
// --- Sticky buy bar --------------------------------------------------
// The ticket block sits right under the date/location so it is above the
// fold on most screens, but long descriptions still scroll it away. When
// it leaves the viewport a compact bar with the price + the same CTA
// pins above the bottom nav so the primary action is never more than a
// tap away. Hidden while a dialog is open (the dialog is the action).
const ticketBlockRef = ref<HTMLElement | null>(null)
const ticketBlockVisible = useElementVisibility(ticketBlockRef)
const priceLabel = computed(() => {
const info = event.value?.ticketInfo
if (!info) return ''
return info.price === 0 ? t('events.detail.free') : `${info.price} ${info.currency}`
})
const buyLabel = computed(() =>
ownedPaidCount.value > 0
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
: t('events.detail.buyTicket', 'Buy ticket'),
)
const showStickyCta = computed(
() =>
!!event.value?.ticketInfo &&
!ownedLnbitsEvent.value &&
!isPast.value &&
(canBuyTicket.value || ownedPaidCount.value > 0) &&
!ticketBlockVisible.value &&
!showPurchaseDialog.value &&
!showReturnDialog.value,
)
// 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.
@ -167,7 +243,7 @@ function goToMyTickets() {
</script>
<template>
<div class="container mx-auto py-6 px-4 max-w-3xl">
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }">
<!-- Top bar — back-link only. Edit moves into the title row as a
prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. -->
@ -276,13 +352,6 @@ function goToMyTickets() {
</div>
</div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ event.description }}</p>
</div>
<!-- Host's primary CTA is to scan tickets at the door. Lives
OUTSIDE the ticketInfo gate so it appears even when the
event was published without AIO ticket tags — a host always
@ -302,8 +371,15 @@ function goToMyTickets() {
by the calendar→Event converter from the AIO custom
tickets_* tags on the published event). Skipped for the
host entirely — they have the Scan CTA above and don't
need a Buy CTA for their own event. -->
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
need a Buy CTA for their own event. Placed ABOVE the
description on purpose: the buy action is the reason most
visitors open the page, and a long description must not
push it below the fold (a sticky bar covers the rest). -->
<div
v-if="event.ticketInfo && !ownedLnbitsEvent"
ref="ticketBlockRef"
class="space-y-3"
>
<div
v-if="ownedPaidCount > 0"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
@ -333,14 +409,8 @@ function goToMyTickets() {
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ ownedPaidCount > 0
? t('events.detail.buyAnotherTicket', 'Buy another ticket')
: t('events.detail.buyTicket', 'Buy ticket') }}
<span class="ml-2 opacity-80 font-normal">
{{ event.ticketInfo.price === 0
? t('events.detail.free')
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
</span>
{{ buyLabel }}
<span class="ml-2 opacity-80 font-normal">{{ priceLabel }}</span>
</Button>
<p class="text-xs text-muted-foreground text-center">
<span v-if="event.ticketInfo.available === undefined">
@ -359,6 +429,13 @@ function goToMyTickets() {
</p>
</div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ event.description }}</p>
</div>
<PurchaseTicketDialog
v-if="purchaseEvent"
:is-open="showPurchaseDialog"
@ -366,6 +443,67 @@ function goToMyTickets() {
@update:is-open="showPurchaseDialog = $event"
/>
<CheckoutReturnDialog
:is-open="showReturnDialog"
:event-id="eventId"
:ticket-ids="returnTicketIds"
:email="returnEmail"
@update:is-open="showReturnDialog = $event"
/>
<!-- Sticky buy bar — see showStickyCta. Pinned flush above the
bottom nav via the height it publishes (--bottom-nav-height,
BottomNav.vue) and mirrors the inline CTA. -->
<!-- Teleported to <body>: inside the page the bar would inherit the
parent's space-y margin (margins offset fixed boxes too) and
float 24px above the nav. -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
leave-active-class="transition duration-150 ease-in"
leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="showStickyCta && event"
class="fixed inset-x-0 z-40 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
style="bottom: var(--bottom-nav-height)"
role="region"
:aria-label="t('events.detail.tickets')"
>
<div class="container mx-auto max-w-3xl px-4 py-2.5 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground truncate">{{ event.title }}</p>
<p class="text-xs text-muted-foreground truncate">
<span v-if="ownedPaidCount > 0">
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</span>
<span v-else-if="event.ticketInfo?.available === undefined">
{{ priceLabel }} · {{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else>
{{ priceLabel }} · {{ t('events.detail.ticketsRemainingOfTotal', { count: event.ticketInfo.available, total: event.ticketInfo.total }) }}
</span>
</p>
</div>
<Button
v-if="canBuyTicket"
class="gap-1.5 shrink-0"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
{{ buyLabel }}
</Button>
<Button v-else class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
</div>
</div>
</Transition>
</Teleport>
<!-- External references -->
<div v-if="event.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { renderTicketQr } from '../lib/ticketQr'
import { RouterLink, onBeforeRouteLeave } from 'vue-router'
import { useUserTickets } from '../composables/useUserTickets'
import { useEvents } from '../composables/useEvents'
@ -134,13 +135,7 @@ async function generateQRCode(ticketId: string) {
if (qrCodes.value[ticketId]) return qrCodes.value[ticketId]
try {
const qrcode = await import('qrcode')
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, {
width: 200,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' }
})
const dataUrl = await renderTicketQr(ticketId, { width: 200 })
qrCodes.value[ticketId] = dataUrl
return dataUrl
} catch (error) {

View file

@ -104,8 +104,9 @@ const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
{
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'id', weight: 0.3 },
{ name: 'name', weight: 0.6 },
{ name: 'id', weight: 0.25 },
{ name: 'promoCode', weight: 0.15 },
],
threshold: 0.3,
ignoreLocation: true,
@ -296,6 +297,14 @@ function fmtTime(iso: string) {
<span v-if="ticket.name" class="font-medium text-foreground">
{{ ticket.name }}
</span>
<Badge
v-if="ticket.promoCode"
variant="outline"
class="text-[10px] font-mono px-1.5"
:title="ticket.promoCode"
>
{{ ticket.promoCode }}
</Badge>
</div>
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
{{ ticket.id }}

View file

@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Ticket, CheckCircle2, Clock, CreditCard, Loader2, AlertCircle } from 'lucide-vue-next'
import { injectService, tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { EventsNostrService } from '../services/EventsNostrService'
import { useEventsStore } from '../stores/events'
import { renderTicketQr } from '../lib/ticketQr'
import type { PublicTicket } from '../types/ticket'
/**
* Public ticket page — where the emailed link lands
* (`<frontend_url>/events/ticket/{ticket_id}`). Anonymous: the ticket
* id in the URL is the door credential, the same token the QR carries,
* exactly like the LNbits UI's own /events/ticket/{id} page.
*/
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const ticketId = route.params.ticketId as string
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const store = useEventsStore()
const ticket = ref<PublicTicket | null>(null)
const eventName = ref<string | null>(null)
const qr = ref<string | null>(null)
const isLoading = ref(true)
const notFound = ref(false)
const status = computed(() => {
if (!ticket.value) return null
if (!ticket.value.paid) return { label: t('events.ticket.pending'), icon: Clock, variant: 'secondary' as const }
if (ticket.value.registered) return { label: t('events.ticket.registered'), icon: CheckCircle2, variant: 'default' as const }
return { label: t('events.ticket.paid'), icon: CreditCard, variant: 'default' as const }
})
// Event name resolution, cheapest first: the Nostr store (d-tag ==
// LNbits event id), a scoped relay query, then the public REST event
// (which 410s for sold-out / closed events — non-fatal here).
async function resolveEventName(eventId: string) {
const cached = store.getEventById(eventId)
if (cached) {
eventName.value = cached.title
return
}
const nostr = tryInjectService<EventsNostrService>(SERVICE_TOKENS.EVENTS_NOSTR_SERVICE)
if (nostr) {
try {
const results = await nostr.queryCalendarEvents({ dTags: [eventId] })
store.upsertEvents(results)
const hit = store.getEventById(eventId)
if (hit) {
eventName.value = hit.title
return
}
} catch {
// fall through
}
}
try {
const pub = await ticketApi.getPublicEvent(eventId)
eventName.value = pub.name
} catch {
eventName.value = null
}
}
onMounted(async () => {
try {
ticket.value = await ticketApi.getTicket(ticketId)
} catch {
notFound.value = true
isLoading.value = false
return
}
isLoading.value = false
void resolveEventName(ticket.value.eventId)
try {
qr.value = await renderTicketQr(ticketId, { width: 240 })
} catch {
qr.value = null
}
})
function goToEvent() {
if (ticket.value) router.push(`/events/${ticket.value.eventId}`)
else router.push('/events')
}
</script>
<template>
<div class="container mx-auto py-6 px-4 max-w-md">
<div class="flex items-center mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goToEvent">
<ArrowLeft class="w-4 h-4" />
{{ t('events.ticket.backToEvent') }}
</Button>
</div>
<div v-if="isLoading" class="py-16 flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 class="w-8 h-8 animate-spin" />
<span class="text-sm">{{ t('events.ticket.loading') }}</span>
</div>
<div v-else-if="notFound" class="py-16 flex flex-col items-center gap-3 text-center">
<AlertCircle class="w-10 h-10 text-destructive" />
<p class="text-sm text-muted-foreground">{{ t('events.ticket.notFound') }}</p>
</div>
<div v-else-if="ticket" class="bg-card border border-border rounded-xl p-5 space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-lg font-semibold flex items-center gap-2">
<Ticket class="w-5 h-5 shrink-0" />
{{ t('events.ticket.title') }}
</h1>
<p class="text-sm text-muted-foreground truncate">
{{ eventName ?? t('events.ticket.event') }}
</p>
</div>
<Badge v-if="status" :variant="status.variant" class="gap-1 shrink-0">
<component :is="status.icon" class="w-3.5 h-3.5" />
{{ status.label }}
</Badge>
</div>
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<img v-if="qr" :src="qr" :alt="`Ticket ${ticketId}`" class="block h-60 w-60" />
<div v-else class="h-60 w-60 flex items-center justify-center">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
</div>
<div class="text-sm space-y-1">
<div v-if="ticket.name" class="flex justify-between gap-3">
<span class="text-muted-foreground">{{ t('events.ticket.holder') }}</span>
<span class="font-medium truncate">{{ ticket.name }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">ID</span>
<span class="font-mono text-xs">{{ ticketId }}</span>
</div>
</div>
<p class="text-xs text-muted-foreground text-center">
{{ ticket.paid ? t('events.ticket.showAtDoor') : t('events.ticket.pending') }}
</p>
</div>
</div>
</template>

View file

@ -198,13 +198,12 @@ function back() {
</section>
<!-- Sticky bottom bar with qty stepper + add-to-cart.
Sits flush above BottomNav (h-14 + safe-area inset) on every
breakpoint. Previously `bottom-16` left an 8px gap on mobile
(BottomNav is h-14 = 56px, not 64px), and `sm:bottom-0` made
it overlap the nav on desktop. -->
Sits flush above BottomNav on every breakpoint via the height
the nav publishes (--bottom-nav-height, BottomNav.vue) — no
hand-derived h-14 + safe-area arithmetic to drift. -->
<div
class="fixed inset-x-0 z-20 border-t border-border bg-background/95 px-4 py-3 backdrop-blur"
style="bottom: calc(3.5rem + env(safe-area-inset-bottom))"
style="bottom: var(--bottom-nav-height)"
>
<div class="container mx-auto flex max-w-2xl items-center gap-3">
<div class="inline-flex items-center rounded-full border border-border bg-card">

142
vite.chatelet.config.ts Normal file
View file

@ -0,0 +1,142 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
import {
brand,
brandAlias,
brandAppBannerAliasEntry,
brandAppLogoAliasEntry,
brandAssetsPlugin,
brandHubLogoAliasEntry,
brandManifestName,
resolveAppBanner,
} from './vite-branding'
/**
* SPA fallback: rewrite dev-server requests to chatelet.html.
*/
function chateletHtmlPlugin(): Plugin {
return {
name: 'chatelet-html-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
const path = req.url ? req.url.split('?')[0] : ''
if (
req.url &&
!req.url.startsWith('/@') &&
!req.url.startsWith('/src/') &&
!req.url.startsWith('/node_modules/') &&
!path.includes('.')
) {
req.url = '/chatelet.html'
}
next()
})
},
}
}
/**
* Vite config for the standalone chatelet app (guest room booking).
*
* Set VITE_BASE_PATH to deploy under a path prefix:
* VITE_BASE_PATH=/chatelet/ → app.domain/chatelet/ (shared auth)
* (default: /) → standalone subdomain
*/
const APP_NAME = brandManifestName()
process.env.VITE_APP_NAME = APP_NAME
process.env.VITE_APP_BANNER = resolveAppBanner('chatelet') ? '1' : ''
export default defineConfig(({ mode }) => ({
base: process.env.VITE_BASE_PATH || '/',
cacheDir: 'node_modules/.vite-chatelet',
server: {
port: 5188,
strictPort: true,
},
plugins: [
brandAssetsPlugin(),
chateletHtmlPlugin(),
vue(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: false,
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
navigateFallback: 'chatelet.html',
navigateFallbackAllowlist: [
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
],
},
includeAssets: [
'icons/favicon.ico',
'icons/apple-touch-icon.png',
'icons/icon-192.png',
'icons/icon-512.png',
'icons/icon-maskable-192.png',
'icons/icon-maskable-512.png',
],
manifest: {
name: APP_NAME,
short_name: brand.shortName ?? APP_NAME,
description: 'Book a room',
theme_color: brand.themeColor ?? '#1f2937',
background_color: brand.backgroundColor ?? '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: process.env.VITE_BASE_PATH || '/',
scope: process.env.VITE_BASE_PATH || '/',
id: 'aiolabs-chatelet',
categories: ['travel', 'lifestyle'],
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
}),
ViteImageOptimizer({
jpg: { quality: 80 },
png: { quality: 80 },
webp: { lossless: true },
}),
mode === 'analyze' &&
visualizer({
open: true,
filename: 'dist-chatelet/stats.html',
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias: [
brandAppLogoAliasEntry('chatelet'),
brandAppBannerAliasEntry('chatelet'),
brandHubLogoAliasEntry(),
...Object.entries(brandAlias).map(([find, replacement]) => ({ find, replacement })),
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
],
},
build: {
outDir: 'dist-chatelet',
rollupOptions: {
input: 'chatelet.html',
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['radix-vue', '@vueuse/core'],
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
},
},
},
chunkSizeWarningLimit: 1000,
},
}))