diff --git a/docs/02-modules/events-module/guest-checkout.md b/docs/02-modules/events-module/guest-checkout.md new file mode 100644 index 0000000..25c31f7 --- /dev/null +++ b/docs/02-modules/events-module/guest-checkout.md @@ -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 ` | +| 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 | `/events/{event_id}?checkout=success&tickets=` | +| cancel | `/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://` 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: {...} }`). diff --git a/docs/02-modules/index.md b/docs/02-modules/index.md index 8b0fda2..a91b2bb 100644 --- a/docs/02-modules/index.md +++ b/docs/02-modules/index.md @@ -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** diff --git a/src/assets/index.css b/src/assets/index.css index d4da37b..990e6ee 100644 --- a/src/assets/index.css +++ b/src/assets/index.css @@ -96,6 +96,10 @@ /* Default palette: Catppuccin (Latte for light, Mocha for dark). Other palettes are scoped via :root[data-theme=""] 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); diff --git a/src/components/layout/BottomNav.vue b/src/components/layout/BottomNav.vue index e23fd98..465437c 100644 --- a/src/components/layout/BottomNav.vue +++ b/src/components/layout/BottomNav.vue @@ -1,6 +1,8 @@ + + diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index 3f50231..ea10109 100644 --- a/src/modules/events/components/CreateEventDialog.vue +++ b/src/modules/events/components/CreateEventDialog.vue @@ -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(SERVICE_TOKENS.IMAGE_UPLO const availableCurrencies = ref(['sat']) const loadingCurrencies = ref(false) const selectedCategories = ref([]) +// Promo codes live outside vee-validate like categories: a plain list +// with its own pure validator (`validatePromoRows`), gating submit below. +const promoRows = ref([]) +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) => { -
-
-

Payment methods

-

- Lightning is always available. Enable fiat to also accept - card and bank payments through your configured provider. -

-
-
- - Lightning — always on -
- -
+ + +
+ Payment methods + + Pick the rails buyers can pay with. Turn Lightning off for a + card-only sale. + +
+
+ + + + + + + + + + + + Your LNbits user has no fiat provider configured. Open + LNbits → Account → Fiat providers and add Stripe, PayPal, + or Square to enable card payments. + + + + + +
+ +
+ {{ opt.label }} +

{{ opt.hint }}

+
+
+
+
+
+ +
+
+ + + + Fiat currency + + + + Currency card buyers are charged in. + + + + + + + + + + + + -
+ +
-

Login Required

+

{{ t('events.purchase.loginRequiredTitle') }}

- Please log in to your account to purchase tickets using your wallet. + {{ t('events.purchase.loginRequiredFree') }}

- +
+ + +
- -
-
+ +
+
Purchasing as: @@ -310,8 +493,108 @@ onUnmounted(() => {
- +
+
+ + {{ t('events.purchase.contact.title') }} +
+ + + {{ t('events.purchase.contact.name') }} * + + + + + + + + + {{ t('events.purchase.contact.email') }} * + + + + {{ t('events.purchase.contact.emailHint') }} +

{{ t('events.purchase.contact.spamHint') }}

+ +
+
+ + + {{ t('events.purchase.contact.confirmEmail') }} * + + + + + + +
+ + +
+
+ + {{ t('events.purchase.promo.label') }} +
+ +
+ + + + + +
+
+ + +
+ +

+ {{ t('events.purchase.promo.invalid') }} +

+

+ {{ t('events.purchase.promo.unverified') }} +

+ + + + + {{ t('events.purchase.promo.loginRequiredDiscount') }} + + + +
+ + +
Wallet Status: @@ -330,7 +613,7 @@ onUnmounted(() => { Empty
-
+
Auto-payment available
@@ -348,7 +631,7 @@ onUnmounted(() => {
+ the whole purchase, N ticket rows (one per attendee). -->
Tickets:
@@ -385,26 +668,34 @@ onUnmounted(() => { {{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} - {{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }} + + + + + +
+
+ {{ t('events.purchase.promo.discountLine', { code: promoState.code }) }} + −{{ formatEventPrice(promoDiscountAmount, event.currency) }}
- -
+ +
Payment method

Both methods charge the same amount via different rails. @@ -422,43 +713,29 @@ onUnmounted(() => { {{ error || fiatError }}

- -
-
-
Ready to pay with {{ fiatProviderLabel }}
-

- Opens the provider's checkout in a new tab. Your ticket - appears in My Tickets once the payment settles. -

-
- -
-