diff --git a/chatelet.html b/chatelet.html new file mode 100644 index 0000000..b142652 --- /dev/null +++ b/chatelet.html @@ -0,0 +1,19 @@ + + + + + + + + + + + %VITE_APP_NAME% + + + + +
+ + + 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/package.json b/package.json index d40f262..8604d9e 100644 --- a/package.json +++ b/package.json @@ -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", 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/chatelet-app/App.vue b/src/chatelet-app/App.vue new file mode 100644 index 0000000..0b1977e --- /dev/null +++ b/src/chatelet-app/App.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/chatelet-app/app.config.ts b/src/chatelet-app/app.config.ts new file mode 100644 index 0000000..da75c6f --- /dev/null +++ b/src/chatelet-app/app.config.ts @@ -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 diff --git a/src/chatelet-app/app.ts b/src/chatelet-app/app.ts new file mode 100644 index 0000000..13a76af --- /dev/null +++ b/src/chatelet-app/app.ts @@ -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 = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/chatelet-app/main.ts b/src/chatelet-app/main.ts new file mode 100644 index 0000000..790c248 --- /dev/null +++ b/src/chatelet-app/main.ts @@ -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() diff --git a/src/chatelet-app/views/SettingsPage.vue b/src/chatelet-app/views/SettingsPage.vue new file mode 100644 index 0000000..22fcde0 --- /dev/null +++ b/src/chatelet-app/views/SettingsPage.vue @@ -0,0 +1,55 @@ + + + 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/chatelet/views/ChateletPage.vue b/src/modules/chatelet/views/ChateletPage.vue new file mode 100644 index 0000000..04418ff --- /dev/null +++ b/src/modules/chatelet/views/ChateletPage.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/modules/events/components/CheckoutReturnDialog.vue b/src/modules/events/components/CheckoutReturnDialog.vue new file mode 100644 index 0000000..c09571b --- /dev/null +++ b/src/modules/events/components/CheckoutReturnDialog.vue @@ -0,0 +1,182 @@ + + + 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. -

-
- -
-