diff --git a/docs/02-modules/events-module/guest-checkout.md b/docs/02-modules/events-module/guest-checkout.md deleted file mode 100644 index 25c31f7..0000000 --- a/docs/02-modules/events-module/guest-checkout.md +++ /dev/null @@ -1,76 +0,0 @@ -# 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 a91b2bb..8b0fda2 100644 --- a/docs/02-modules/index.md +++ b/docs/02-modules/index.md @@ -182,13 +182,6 @@ 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 990e6ee..d4da37b 100644 --- a/src/assets/index.css +++ b/src/assets/index.css @@ -96,10 +96,6 @@ /* 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 465437c..e23fd98 100644 --- a/src/components/layout/BottomNav.vue +++ b/src/components/layout/BottomNav.vue @@ -1,8 +1,6 @@ - - diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index ea10109..3f50231 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, Tag } from 'lucide-vue-next' +import { Bell, ChevronDown } from 'lucide-vue-next' import { ScrollArea } from '@/components/ui/scroll-area' import { Select, @@ -41,28 +41,12 @@ 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 { 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 FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue' 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 @@ -140,11 +124,7 @@ const formSchema = toTypedSchema( event_end_time: z.string().optional().default(''), location: z.string().max(500).optional().default(''), currency: z.string().default("sat"), - // 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'), + allow_fiat: z.boolean().default(false), 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), @@ -172,7 +152,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.payment_methods.includes('fiat') && isSat && !v.fiat_currency) { + if (v.allow_fiat && isSat && !v.fiat_currency) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['fiat_currency'], @@ -193,7 +173,7 @@ const form = useForm({ event_end_time: '', location: '', currency: 'sat', - payment_methods: ['lightning'], + allow_fiat: false, fiat_currency: 'USD', amount_tickets: 0, price_per_ticket: 0, @@ -204,42 +184,6 @@ 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 } @@ -260,7 +204,6 @@ 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 @@ -286,10 +229,6 @@ 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 @@ -304,7 +243,7 @@ async function populateFromEvent(event: TicketedEvent) { event_end_time: end.time, location: event.location ?? '', currency: event.currency ?? 'sat', - payment_methods: paymentMethodsOf(event), + allow_fiat: event.allow_fiat ?? false, fiat_currency: event.fiat_currency ?? 'USD', amount_tickets: event.amount_tickets ?? 0, price_per_ticket: event.price_per_ticket ?? 0, @@ -314,10 +253,6 @@ 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 @@ -356,13 +291,11 @@ watch(() => props.open, async (isOpen) => { } } else { selectedCategories.value = [] - promoRows.value = [] - promoOpen.value = false } }) const { resetForm, meta } = form -const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value)) +const isFormValid = computed(() => meta.value.valid) const isLoading = ref(false) const today = computed(() => format(new Date(), 'yyyy-MM-dd')) @@ -421,31 +354,27 @@ const onSubmit = form.handleSubmit(async (formValues) => { eventData.banner = null } if (formValues.currency) eventData.currency = formValues.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) { + // 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) { 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 + 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. + // Notification config goes inside the `extra` envelope. On edit + // overlay onto the existing event.extra so unrelated fields the + // LNbits admin UI sets (promo_codes, conditional, min_tickets) + // survive the round-trip. eventData.extra = { ...(props.event?.extra ?? {}), - 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) { @@ -476,7 +405,6 @@ const onSubmit = form.handleSubmit(async (formValues) => { resetForm() selectedCategories.value = [] - promoRows.value = [] bannerImages.value = [] emit('update:open', false) } catch (error) { @@ -496,7 +424,6 @@ const handleOpenChange = (open: boolean) => { if (!open && !isLoading.value) { resetForm() selectedCategories.value = [] - promoRows.value = [] bannerImages.value = [] } emit('update:open', open) @@ -733,87 +660,26 @@ const handleOpenChange = (open: boolean) => { - - -
- 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. - - - +
+
+

Payment methods

+

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

+
+
+ + Lightning — always on +
+ +
- - - - - - - - - -
+ +
-

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

+

Login Required

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

-
- - -
+
- -
-
+ +
+
Purchasing as: @@ -493,108 +310,8 @@ 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: @@ -613,7 +330,7 @@ onUnmounted(() => { Empty
-
+
Auto-payment available
@@ -631,7 +348,7 @@ onUnmounted(() => {
+ the whole purchase, one ticket row representing N seats. -->
Tickets:
@@ -668,34 +385,26 @@ onUnmounted(() => { {{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} - - - - - -
-
- {{ t('events.purchase.promo.discountLine', { code: promoState.code }) }} - −{{ formatEventPrice(promoDiscountAmount, event.currency) }} + {{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
- -
+ +
Payment method

Both methods charge the same amount via different rails. @@ -713,29 +422,43 @@ 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. +

+
+ +
+