Compare commits

..

2 commits

Author SHA1 Message Date
33cb75ac77 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-07-20 16:43:24 +02:00
89a778e98b 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-07-20 16:43:23 +02:00
38 changed files with 294 additions and 2754 deletions

View file

@ -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 <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,13 +182,6 @@ interface ModulePlugin {
**See:** [[market-module/index|📖 Market Module Documentation]] **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 ## Module Development
### **Creating a New Module** ### **Creating a New Module**

View file

@ -96,10 +96,6 @@
/* Default palette: Catppuccin (Latte for light, Mocha for dark). /* Default palette: Catppuccin (Latte for light, Mocha for dark).
Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */ Other palettes are scoped via :root[data-theme="<name>"] in themes/*.css. */
:root { :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); --background: oklch(0.9578 0.0058 264.5321);
--foreground: oklch(0.4355 0.0430 279.3250); --foreground: oklch(0.4355 0.0430 279.3250);
--card: oklch(1.0000 0 0); --card: oklch(1.0000 0 0);

View file

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { Component } from 'vue' import type { Component } from 'vue'
import { useResizeObserver } from '@vueuse/core'
export interface BottomTab { export interface BottomTab {
/** Translated label shown under the icon. */ /** Translated label shown under the icon. */
@ -35,22 +33,6 @@ interface Props {
const props = defineProps<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() const router = useRouter()
function onTabClick(tab: BottomTab) { function onTabClick(tab: BottomTab) {
@ -69,7 +51,6 @@ function isTabActive(tab: BottomTab): boolean {
<template> <template>
<nav <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" 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)" style="padding-bottom: env(safe-area-inset-bottom)"
> >

View file

@ -58,27 +58,6 @@ const messages: LocaleMessages = {
events: { events: {
title: 'Events', title: 'Events',
createNew: 'Create Event', 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', noEvents: 'No events found',
filters: { filters: {
all: 'All', all: 'All',
@ -150,65 +129,6 @@ const messages: LocaleMessages = {
pending: 'Pending', pending: 'Pending',
registered: 'Registered', 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: { nav: {
feed: 'Home', feed: 'Home',
calendar: 'Calendar', calendar: 'Calendar',

View file

@ -58,27 +58,6 @@ const messages: LocaleMessages = {
events: { events: {
title: 'Eventos', title: 'Eventos',
createNew: 'Crear evento', 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', noEvents: 'No se encontraron eventos',
filters: { filters: {
all: 'Todas', all: 'Todas',
@ -150,65 +129,6 @@ const messages: LocaleMessages = {
pending: 'Pendiente', pending: 'Pendiente',
registered: 'Registrado', 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: { nav: {
feed: 'Inicio', feed: 'Inicio',
calendar: 'Calendario', calendar: 'Calendario',

View file

@ -58,27 +58,6 @@ const messages: LocaleMessages = {
events: { events: {
title: 'Événements', title: 'Événements',
createNew: 'Créer un événement', 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é', noEvents: 'Aucun événement trouvé',
filters: { filters: {
all: 'Tout', all: 'Tout',
@ -150,65 +129,6 @@ const messages: LocaleMessages = {
pending: 'En attente', pending: 'En attente',
registered: 'Enregistré', 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: { nav: {
feed: 'Accueil', feed: 'Accueil',
calendar: 'Calendrier', calendar: 'Calendrier',

View file

@ -59,27 +59,6 @@ export interface LocaleMessages {
events?: { events?: {
title: string title: string
createNew: 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 noEvents: string
filters: { filters: {
all: string all: string
@ -125,65 +104,6 @@ export interface LocaleMessages {
pending: string pending: string
registered: 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: { nav: {
feed: string feed: string
calendar: string calendar: string

View file

@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { addDays, format } from 'date-fns'
import { MapPin, Users } from 'lucide-vue-next' import { MapPin, Users } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import DatePicker from '@/modules/base/components/DatePicker.vue'
import { useChatelet } from '../composables/useChatelet' import { useChatelet } from '../composables/useChatelet'
import type { AvailabilityResult, Room } from '../types/room' import type { AvailabilityResult, Room } from '../types/room'
@ -22,26 +21,6 @@ const checkOut = ref('')
const checking = ref(false) const checking = ref(false)
const result = ref<AvailabilityResult | null>(null) 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 () => { onMounted(async () => {
try { try {
room.value = await getRoom(route.params.id as string) room.value = await getRoom(route.params.id as string)
@ -107,18 +86,13 @@ async function onCheck() {
<Card> <Card>
<CardContent class="p-4 space-y-3"> <CardContent class="p-4 space-y-3">
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1 min-w-0"> <div class="space-y-1">
<Label>Check-in</Label> <Label for="ci">Check-in</Label>
<DatePicker v-model="checkIn" :min="today" placeholder="Check-in" /> <Input id="ci" type="date" v-model="checkIn" />
</div> </div>
<div class="space-y-1 min-w-0"> <div class="space-y-1">
<Label>Check-out</Label> <Label for="co">Check-out</Label>
<DatePicker <Input id="co" type="date" v-model="checkOut" />
v-model="checkOut"
:min="minCheckOut"
:disabled="!checkIn"
placeholder="Check-out"
/>
</div> </div>
</div> </div>
<Button <Button

View file

@ -1,182 +0,0 @@
<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 { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' 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 { ScrollArea } from '@/components/ui/scroll-area'
import { import {
Select, Select,
@ -41,28 +41,12 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue' import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue' import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue' import TimePicker from '@/modules/base/components/TimePicker.vue'
import { Checkbox } from '@/components/ui/checkbox' import FiatToggleField from '@/modules/base/components/payments/FiatToggleField.vue'
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 { Alert, AlertDescription } from '@/components/ui/alert'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService' import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket' import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category' import { ALL_CATEGORIES } from '../types/category'
import PromoCodesEditor from './PromoCodesEditor.vue'
import {
fromWirePromoCodes,
hasPromoErrors,
toWirePromoCodes,
validatePromoRows,
type PromoCodeRow,
} from '../lib/promoCodes'
interface Props { interface Props {
open: boolean open: boolean
@ -140,11 +124,7 @@ const formSchema = toTypedSchema(
event_end_time: z.string().optional().default(''), event_end_time: z.string().optional().default(''),
location: z.string().max(500).optional().default(''), location: z.string().max(500).optional().default(''),
currency: z.string().default("sat"), currency: z.string().default("sat"),
// Rails buyers may use. `allow_fiat` on the wire is derived from allow_fiat: z.boolean().default(false),
// 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"), fiat_currency: z.string().default("USD"),
amount_tickets: z.number().min(0).max(100000).default(0), amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0), price_per_ticket: z.number().min(0).default(0),
@ -172,7 +152,7 @@ const formSchema = toTypedSchema(
// mirror themselves into fiat_currency automatically. The events // mirror themselves into fiat_currency automatically. The events
// extension uses 'sat' and 'sats' interchangeably — accept both. // extension uses 'sat' and 'sats' interchangeably — accept both.
const isSat = v.currency === 'sat' || v.currency === 'sats' 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({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['fiat_currency'], path: ['fiat_currency'],
@ -193,7 +173,7 @@ const form = useForm({
event_end_time: '', event_end_time: '',
location: '', location: '',
currency: 'sat', currency: 'sat',
payment_methods: ['lightning'], allow_fiat: false,
fiat_currency: 'USD', fiat_currency: 'USD',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 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 { interface BannerImage extends UploadedImage {
isPrimary: boolean 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. // edit-mode population with start-date side effects mid-setValues.
const isPopulating = ref(false) const isPopulating = ref(false)
const notificationsOpen = ref(false) const notificationsOpen = ref(false)
const promoOpen = ref(false)
// Auto-mirror end date to start: when the user picks a start date, // Auto-mirror end date to start: when the user picks a start date,
// surface that same date in the end-date picker so a one-day event // surface that same date in the end-date picker so a one-day event
@ -286,10 +229,6 @@ const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLO
const availableCurrencies = ref<string[]>(['sat']) const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false) const loadingCurrencies = ref(false)
const selectedCategories = ref<string[]>([]) 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) { async function populateFromEvent(event: TicketedEvent) {
isPopulating.value = true isPopulating.value = true
@ -304,7 +243,7 @@ async function populateFromEvent(event: TicketedEvent) {
event_end_time: end.time, event_end_time: end.time,
location: event.location ?? '', location: event.location ?? '',
currency: event.currency ?? 'sat', currency: event.currency ?? 'sat',
payment_methods: paymentMethodsOf(event), allow_fiat: event.allow_fiat ?? false,
fiat_currency: event.fiat_currency ?? 'USD', fiat_currency: event.fiat_currency ?? 'USD',
amount_tickets: event.amount_tickets ?? 0, amount_tickets: event.amount_tickets ?? 0,
price_per_ticket: event.price_per_ticket ?? 0, price_per_ticket: event.price_per_ticket ?? 0,
@ -314,10 +253,6 @@ async function populateFromEvent(event: TicketedEvent) {
notification_body: event.extra?.notification_body ?? '', notification_body: event.extra?.notification_body ?? '',
}) })
selectedCategories.value = [...(event.categories ?? [])] 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) { if (event.banner) {
// Re-render the stored banner via its pict-rs file ID. delete_token // 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 // is intentionally empty: we don't own the original upload's token
@ -356,13 +291,11 @@ watch(() => props.open, async (isOpen) => {
} }
} else { } else {
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
promoOpen.value = false
} }
}) })
const { resetForm, meta } = form const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value)) const isFormValid = computed(() => meta.value.valid)
const isLoading = ref(false) const isLoading = ref(false)
const today = computed(() => format(new Date(), 'yyyy-MM-dd')) const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
@ -421,31 +354,27 @@ const onSubmit = form.handleSubmit(async (formValues) => {
eventData.banner = null eventData.banner = null
} }
if (formValues.currency) eventData.currency = formValues.currency if (formValues.currency) eventData.currency = formValues.currency
// allow_fiat always sends (derived from the rail list) so a // allow_fiat always sends so a true→false flip propagates on edit;
// true→false flip propagates on edit; fiat_currency only sends when // fiat_currency only sends when fiat is on (no point persisting a
// fiat is on (no point persisting a rail-currency the backend won't use). // rail-currency the backend won't use).
const acceptsFiat = formValues.payment_methods.includes('fiat') eventData.allow_fiat = formValues.allow_fiat
eventData.allow_fiat = acceptsFiat if (formValues.allow_fiat && formValues.fiat_currency) {
if (acceptsFiat && formValues.fiat_currency) {
eventData.fiat_currency = formValues.fiat_currency eventData.fiat_currency = formValues.fiat_currency
} }
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
// Notification config + promo codes go inside the `extra` envelope. // Notification config goes inside the `extra` envelope. On edit
// On edit overlay onto the existing event.extra so fields only the // overlay onto the existing event.extra so unrelated fields the
// LNbits admin UI sets (conditional, min_tickets) survive the // LNbits admin UI sets (promo_codes, conditional, min_tickets)
// round-trip. promo_codes is always sent explicitly: what the // survive the round-trip.
// organizer sees in the editor is the truth, an empty list clears.
eventData.extra = { eventData.extra = {
...(props.event?.extra ?? {}), ...(props.event?.extra ?? {}),
payment_methods: formValues.payment_methods,
email_notifications: formValues.email_notifications, email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications, nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject, notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body, notification_body: formValues.notification_body,
promo_codes: toWirePromoCodes(promoRows.value),
} }
if (isEditMode.value) { if (isEditMode.value) {
@ -476,7 +405,6 @@ const onSubmit = form.handleSubmit(async (formValues) => {
resetForm() resetForm()
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
bannerImages.value = [] bannerImages.value = []
emit('update:open', false) emit('update:open', false)
} catch (error) { } catch (error) {
@ -496,7 +424,6 @@ const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) { if (!open && !isLoading.value) {
resetForm() resetForm()
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
bannerImages.value = [] bannerImages.value = []
} }
emit('update:open', open) emit('update:open', open)
@ -733,87 +660,26 @@ const handleOpenChange = (open: boolean) => {
</div> </div>
<!-- ── Payment methods ──────────────────────────────────── --> <!-- ── Payment methods ──────────────────────────────────── -->
<FormField name="payment_methods"> <div class="space-y-3">
<FormItem class="space-y-3">
<div> <div>
<FormLabel class="text-sm font-medium">Payment methods</FormLabel> <p class="text-sm font-medium">Payment methods</p>
<FormDescription class="text-xs"> <p class="text-xs text-muted-foreground">
Pick the rails buyers can pay with. Turn Lightning off for a Lightning is always available. Enable fiat to also accept
card-only sale. card and bank payments through your configured provider.
</FormDescription> </p>
</div> </div>
<div class="space-y-2"> <div class="flex items-center gap-2 text-sm text-muted-foreground">
<FormField <Zap class="w-4 h-4" />
v-for="opt in PAYMENT_METHOD_OPTIONS" <span>Lightning — always on</span>
:key="opt.id" </div>
v-slot="{ value, handleChange }" <FiatToggleField
type="checkbox" allow-fiat-field="allow_fiat"
:value="opt.id" fiat-currency-field="fiat_currency"
:unchecked-value="false" :denomination="form.values.currency ?? 'sat'"
name="payment_methods" :available-fiat-currencies="availableCurrencies.filter(c => c !== 'sat' && c !== 'sats')"
>
<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" :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>
</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 <!-- Ticket buyer notifications (collapsible). The backend
sends email + NIP-04 Nostr DM confirmations on sends email + NIP-04 Nostr DM confirmations on
@ -877,27 +743,6 @@ const handleOpenChange = (open: boolean) => {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </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 <!-- Actions. Stack full-width on narrow viewports so a wide
localized "Submit Event" label can't push the Cancel localized "Submit Event" label can't push the Cancel
button out of the dialog. Submit reads top-down on button out of the dialog. Submit reads top-down on

View file

@ -72,14 +72,8 @@ const isNonApproved = computed(
</script> </script>
<template> <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 <Card
class="relative min-w-0 cursor-pointer hover:shadow-lg transition-shadow duration-200" class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
@click="emit('click', event)" @click="emit('click', event)"
> >
<!-- Wash-out wrapper. The pending/rejected status badge below sits <!-- Wash-out wrapper. The pending/rejected status badge below sits

View file

@ -1,153 +0,0 @@
<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,29 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue' 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { 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 { useTicketPurchase } from '../composables/useTicketPurchase'
import { usePromoCode } from '../composables/usePromoCode'
import { normalizePromoCode } from '../lib/promoCodes'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, Landmark, Minus, Plus, Copy, Check, Loader2, Mail, Tag, X } from 'lucide-vue-next' import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark, Minus, Plus, Copy, Check, Loader2 } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting' import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
import PaymentMethodSelector, { import PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry, type PaymentMethod as PaymentMethodEntry,
@ -31,9 +15,6 @@ import PaymentMethodSelector, {
import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue' import PriceConversionPreview from '@/modules/base/components/payments/PriceConversionPreview.vue'
import { useFiatProviders } from '@/modules/base/composables/useFiatProviders' import { useFiatProviders } from '@/modules/base/composables/useFiatProviders'
import { usePriceConversion } from '@/modules/base/composables/usePriceConversion' 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 { interface Props {
event: { event: {
@ -44,9 +25,6 @@ interface Props {
/** Whether the event accepts fiat payments. From v1.4.0+ */ /** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean allow_fiat?: boolean
fiat_currency?: string 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 isOpen: boolean
} }
@ -57,9 +35,8 @@ interface Emits {
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const { t } = useI18n()
const { isAuthenticated, userDisplay, currentUser } = useAuth() const { isAuthenticated, userDisplay } = useAuth()
const { const {
isLoading, isLoading,
error, error,
@ -68,6 +45,7 @@ const {
qrCode, qrCode,
isPaymentPending, isPaymentPending,
isPayingWithWallet, isPayingWithWallet,
canPurchase,
userWallets, userWallets,
hasWalletWithBalance, hasWalletWithBalance,
purchaseTicketForEvent, purchaseTicketForEvent,
@ -76,9 +54,7 @@ const {
resetPaymentState, resetPaymentState,
cleanup, cleanup,
purchasedTicketIds, purchasedTicketIds,
ticketQRCodes, showTicketQR
showTicketQR,
finalizePurchasedTickets
} = useTicketPurchase() } = useTicketPurchase()
const MAX_QUANTITY = 10 const MAX_QUANTITY = 10
@ -99,52 +75,6 @@ const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
// and label the CTA "Get ticket" instead of "Proceed". // and label the CTA "Get ticket" instead of "Proceed".
const isFree = computed(() => props.event.price_per_ticket <= 0) 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() { async function copyInvoice() {
if (!paymentRequest.value) return if (!paymentRequest.value) return
try { try {
@ -157,69 +87,16 @@ 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 { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion() const { convert } = usePriceConversion()
// Empty until the dialog opens: the watcher below picks the default per const selectedMethodId = ref<string>('lightning')
// rail availability + auth state, so a guest lands on Card, not Lightning. const fiatRedirectUrl = ref<string | null>(null)
const selectedMethodId = ref<string>('') const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false) const isFiatPending = ref(false)
const fiatError = ref<string | null>(null) const fiatError = ref<string | null>(null)
const enabledRails = computed(() => const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
effectivePaymentMethods({
allowFiat: props.event.allow_fiat,
paymentMethods: props.event.payment_methods,
}),
)
const canChooseFiat = computed(() => enabledRails.value.includes('fiat'))
const isPriceInSats = computed( const isPriceInSats = computed(
() => props.event.currency === 'sat' || props.event.currency === 'sats', () => props.event.currency === 'sat' || props.event.currency === 'sats',
) )
@ -247,9 +124,7 @@ function iconFor(hint: 'card' | 'bank' | 'wallet') {
} }
const paymentMethods = computed<PaymentMethodEntry[]>(() => { const paymentMethods = computed<PaymentMethodEntry[]>(() => {
const entries: PaymentMethodEntry[] = [] const lightning: PaymentMethodEntry = {
if (enabledRails.value.includes('lightning')) {
entries.push({
id: 'lightning', id: 'lightning',
rail: 'lightning', rail: 'lightning',
label: 'Lightning', label: 'Lightning',
@ -259,169 +134,116 @@ const paymentMethods = computed<PaymentMethodEntry[]>(() => {
!isPriceInSats.value && lightningSats.value !isPriceInSats.value && lightningSats.value
? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats` ? `≈ ${Math.round(lightningSats.value).toLocaleString()} sats`
: undefined, : undefined,
})
} }
if (canChooseFiat.value) { if (!props.event.allow_fiat) return [lightning]
if (providers.value.length > 0) { if (providers.value.length > 0) {
for (const id of providers.value) { const fiatRails: PaymentMethodEntry[] = providers.value.map((id) => {
const meta = providerMeta(id) const meta = providerMeta(id)
entries.push({ return {
id: `fiat:${id}`, id: `fiat:${id}`,
rail: 'fiat', rail: 'fiat',
provider: id, provider: id,
label: meta.label, label: meta.label,
icon: iconFor(meta.icon), icon: iconFor(meta.icon),
available: true, available: true,
})
} }
} else { })
// Guests (and sessions without provider info) can't enumerate the return [lightning, ...fiatRails]
// organizer's providers. Show a generic Card button and let the }
// backend pick the organizer's default at request time.
entries.push({ // Degenerate fallback — allow_fiat is on but the buyer's session
// can't enumerate the organizer's providers. Show a generic Card
// button and let the backend pick a default at request time.
return [
lightning,
{
id: 'fiat', id: 'fiat',
rail: 'fiat', rail: 'fiat',
label: t('events.purchase.card'), label: 'Card',
icon: CreditCard, icon: CreditCard,
available: true, 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(() => const selectedMethod = computed(() =>
paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0], paymentMethods.value.find((m) => m.id === selectedMethodId.value) ?? paymentMethods.value[0],
) )
const onSubmit = form.handleSubmit(async (contact) => { async function handlePurchase() {
await handlePurchase(contact) if (!canPurchase.value) return
})
async function handlePurchase(contact: { name?: string; email: string }) {
fiatError.value = null fiatError.value = null
const method = selectedMethod.value const method = selectedMethod.value
if (!method) return if (!method) return
const isGuest = !isAuthenticated.value // Lightning path: the composable just creates the invoice + starts
const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined // polling. The buyer picks "Pay with my LNbits wallet" or "Open in
const promoCode = promoCodeToSend.value // external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here.
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,
contact: guestContact,
email: contact.email,
promoCode,
})
} catch (err) {
console.error('Error claiming discounted ticket:', err)
if (isPromoError(err)) void promo.revalidate()
}
return
}
// 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') { if (method.rail === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id, { await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
quantity: quantity.value,
contact: guestContact,
email: contact.email,
promoCode,
})
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
if (isPromoError(err)) void promo.revalidate()
} }
return return
} }
// Fiat path: POST with the chosen provider, remember what we're // Fiat path: composable can't drive it (no QR / no bolt11). Hit the
// waiting for, then hand the tab to the provider's checkout. Stripe // API directly with the chosen provider, then redirect the buyer to
// brings the buyer back to the event page with ?checkout=success& // the provider's checkout URL. Payment confirmation happens via
// tickets=… (or ?checkout=cancelled) — see EventDetailPage. // webhook on the backend and shows up next time the buyer reloads
// MyTickets.
try { try {
isFiatPending.value = true isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = isGuest ? undefined : (lnbitsAPI?.getAccessToken?.() || undefined) const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const invoice = await ticketApi.requestTicket(props.event.id, { const currentUser = (lnbitsAPI?.currentUser?.value) || null
userId: isGuest ? undefined : currentUser.value?.id, const userId = currentUser?.id
name: guestContact?.name, if (!userId) {
email: contact.email, fiatError.value = 'Missing user id'
return
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken, accessToken,
frontendUrl: frontendRoot(), {
paymentMethod: 'fiat', paymentMethod: 'fiat',
fiatProvider: method.provider, fiatProvider: method.provider,
quantity: quantity.value, quantity: quantity.value,
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
}
if (!invoice.isFiat || !invoice.fiatPaymentRequest) { if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = t('events.purchase.noCheckoutUrl') fiatError.value = 'Fiat provider did not return a checkout URL.'
isFiatPending.value = false
return return
} }
const pending: PendingCheckout = { fiatRedirectUrl.value = invoice.fiatPaymentRequest
eventId: props.event.id, fiatProviderLabel.value = invoice.fiatProvider
paymentHash: invoice.paymentHash, ? providerMeta(invoice.fiatProvider).label
ticketIds: invoice.ticketIds ?? [], : method.label
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) { } catch (err) {
fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed') fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
} finally {
isFiatPending.value = false isFiatPending.value = false
if (isPromoError(err)) void promo.revalidate()
} }
} }
function openFiatCheckout() {
if (!fiatRedirectUrl.value) return
window.open(fiatRedirectUrl.value, '_blank', 'noopener,noreferrer')
}
function handleClose() { function handleClose() {
emit('update:isOpen', false) emit('update:isOpen', false)
resetPaymentState() resetPaymentState()
promo.clear() selectedMethodId.value = 'lightning'
selectedMethodId.value = '' fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null fiatError.value = null
isFiatPending.value = false
quantity.value = 1 quantity.value = 1
copiedInvoice.value = false copiedInvoice.value = false
} }
@ -440,43 +262,38 @@ onUnmounted(() => {
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }} {{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<span v-if="isFree"> <span v-if="quantity > 1">
<template v-if="quantity > 1">{{ quantity }} free tickets for</template> {{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
<template v-else>Get a free ticket for</template> {{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
<strong>{{ event.name }}</strong> </span>
<span v-else-if="isFree">
Get a free ticket for <strong>{{ event.name }}</strong>
</span> </span>
<span v-else> <span v-else>
<template v-if="quantity > 1">{{ quantity }} tickets for</template> Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
<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> </span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<!-- Free tickets need an account (anti-abuse). Paid tickets don't. --> <!-- Authentication Check -->
<div v-if="guestNeedsLogin" class="py-4 text-center space-y-4"> <div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
<div class="flex justify-center"> <div class="flex justify-center">
<User class="w-12 h-12 text-muted-foreground" /> <User class="w-12 h-12 text-muted-foreground" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-lg font-semibold">{{ t('events.purchase.loginRequiredTitle') }}</h3> <h3 class="text-lg font-semibold">Login Required</h3>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{{ t('events.purchase.loginRequiredFree') }} Please log in to your account to purchase tickets using your wallet.
</p> </p>
</div> </div>
<div class="flex gap-2 justify-center"> <Button @click="handleClose" variant="outline">
<Button @click="() => $router.push('/login')">{{ t('events.detail.logIn') }}</Button> Close
<Button @click="handleClose" variant="outline">Close</Button> </Button>
</div>
</div> </div>
<!-- Contact + payment details, then submit --> <!-- User Info and Purchase -->
<form v-else-if="!paymentHash" class="py-4 space-y-4" @submit="onSubmit"> <div v-else-if="!paymentHash" class="py-4 space-y-4">
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3"> <div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<User class="w-4 h-4 text-muted-foreground" /> <User class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Purchasing as:</span> <span class="text-sm font-medium">Purchasing as:</span>
@ -493,108 +310,8 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Contact: who the ticket is for + where to send it --> <!-- Wallet Information -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3"> <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"> <div class="flex items-center gap-2">
<Wallet class="w-4 h-4 text-muted-foreground" /> <Wallet class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Wallet Status:</span> <span class="text-sm font-medium">Wallet Status:</span>
@ -613,7 +330,7 @@ onUnmounted(() => {
<Badge v-else variant="secondary" class="text-xs">Empty</Badge> <Badge v-else variant="secondary" class="text-xs">Empty</Badge>
</div> </div>
</div> </div>
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-primary"> <div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
<Zap class="w-4 h-4" /> <Zap class="w-4 h-4" />
<span>Auto-payment available</span> <span>Auto-payment available</span>
</div> </div>
@ -631,7 +348,7 @@ onUnmounted(() => {
</div> </div>
<!-- Quantity selector — backend caps at 10. One invoice for <!-- Quantity selector — backend caps at 10. One invoice for
the whole purchase, N ticket rows (one per attendee). --> the whole purchase, one ticket row representing N seats. -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span> <span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -668,34 +385,26 @@ onUnmounted(() => {
<span class="text-sm text-muted-foreground"> <span class="text-sm text-muted-foreground">
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} {{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span> </span>
<span class="text-sm font-medium"> <span class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span>
<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> </div>
<PriceConversionPreview <PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency" v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="effectiveTotal" :amount="totalPrice"
from="sat" from="sat"
:to="event.fiat_currency" :to="event.fiat_currency"
prefix="Equivalent ~" prefix="Equivalent ~"
suffix=" if paid by card" suffix=" if paid in fiat"
/> />
</div> </div>
</div> </div>
<!-- Payment method selector — one button per enabled rail / <!-- Payment method selector (only shown when fiat is enabled
configured fiat provider. Hidden when only one rail is on on the event). Buttons surface one per configured fiat
(nothing to choose) and for free tickets. --> provider so "Stripe" / "PayPal" / "Square" stand alongside
<div v-if="paymentMethods.length > 1 && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-2"> 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">
<div class="text-sm font-medium">Payment method</div> <div class="text-sm font-medium">Payment method</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Both methods charge the same amount via different rails. Both methods charge the same amount via different rails.
@ -713,29 +422,43 @@ onUnmounted(() => {
{{ error || fiatError }} {{ error || fiatError }}
</div> </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 <Button
type="submit" v-else
:disabled="isLoading || isFiatPending || promoChecking || guestNeedsLoginForDiscount || (paymentMethods.length === 0 && !isZeroTotal)" @click="handlePurchase"
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full" class="w-full"
> >
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" /> <Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<template v-else-if="isZeroTotal"> <template v-else-if="isFree">
<Ticket class="w-4 h-4 mr-2" /> <Ticket class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }} {{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
</template> </template>
<template v-else-if="selectedMethod?.rail === 'fiat'"> <template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" /> <CreditCard class="w-4 h-4 mr-2" />
{{ t('events.purchase.continueTo', { provider: selectedMethod.label }) }} Continue to {{ selectedMethod.label }} checkout
</template> </template>
<template v-else> <template v-else>
<Zap class="w-4 h-4 mr-2" /> <Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }} {{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
</template> </template>
</Button> </Button>
<p v-if="selectedMethod?.rail === 'fiat' && !isZeroTotal" class="text-xs text-muted-foreground text-center"> </div>
{{ t('events.purchase.fiatRedirectHint') }}
</p>
</form>
<!-- Lightning invoice — restaurant-style. Shows QR + amount, <!-- Lightning invoice — restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the with both pay paths visible at once: tap-to-pay from the
@ -746,13 +469,8 @@ onUnmounted(() => {
<div class="text-center space-y-1"> <div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3> <h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
<template v-if="hasWalletWithBalance">
Scan with any Lightning wallet, or tap the button below to Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet. pay from your LNbits wallet.
</template>
<template v-else>
Scan with any Lightning wallet, or open it in your wallet app.
</template>
</p> </p>
</div> </div>
@ -771,7 +489,7 @@ onUnmounted(() => {
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<span class="text-xs text-muted-foreground">Amount</span> <span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-sm font-semibold text-primary"> <span class="font-mono text-sm font-semibold text-primary">
{{ formatEventPrice(effectiveTotal, event.currency) }} {{ formatEventPrice(totalPrice, event.currency) }}
<span v-if="quantity > 1" class="text-muted-foreground font-normal"> <span v-if="quantity > 1" class="text-muted-foreground font-normal">
({{ quantity }} tickets) ({{ quantity }} tickets)
</span> </span>
@ -836,20 +554,21 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Success state. Logged-in buyers get routed to My Tickets <!-- Success state. QRs live in My Tickets — no need to
(QRs live there); guests have no My Tickets, so their QRs pre-render them here; this view's job is to confirm the
render right here alongside the "emailed to" note. --> purchase landed and route the buyer to where they actually
interact with their tickets. -->
<div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4"> <div v-else-if="showTicketQR && purchasedTicketIds.length > 0" class="py-6 flex flex-col items-center gap-4">
<div class="flex justify-center"> <div class="flex justify-center">
<Ticket class="w-12 h-12 text-primary" /> <Ticket class="w-12 h-12 text-green-600" />
</div> </div>
<div class="text-center space-y-2"> <div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-primary"> <h3 class="text-lg font-semibold text-green-600">
{{ purchasedTicketIds.length > 1 {{ purchasedTicketIds.length > 1
? `${purchasedTicketIds.length} tickets purchased!` ? `${purchasedTicketIds.length} tickets purchased!`
: 'Ticket purchased!' }} : 'Ticket purchased!' }}
</h3> </h3>
<p v-if="isAuthenticated" class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
<span v-if="purchasedTicketIds.length > 1"> <span v-if="purchasedTicketIds.length > 1">
Each attendee gets their own scannable QR in My Tickets — Each attendee gets their own scannable QR in My Tickets —
hand them out independently for the door scan. hand them out independently for the door scan.
@ -858,31 +577,10 @@ onUnmounted(() => {
Your ticket is now in My Tickets. Your ticket is now in My Tickets.
</span> </span>
</p> </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>
<div class="space-y-3 w-full"> <div class="space-y-3 w-full">
<Button v-if="isAuthenticated" @click="() => $router.push('/my-tickets')" class="w-full"> <Button @click="() => $router.push('/my-tickets')" class="w-full">
View My Tickets View My Tickets
</Button> </Button>
<Button variant="outline" @click="handleClose" class="w-full"> <Button variant="outline" @click="handleClose" class="w-full">

View file

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

View file

@ -1,127 +0,0 @@
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,24 +6,6 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets' 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() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
@ -82,7 +64,11 @@ export function useTicketPurchase() {
async function generateTicketQRCode(ticketId: string) { async function generateTicketQRCode(ticketId: string) {
try { try {
const dataUrl = await renderTicketQr(ticketId, { width: 160 }) const ticketUrl = `ticket://${ticketId}`
const dataUrl = await paymentService.generateCustomQRCode(ticketUrl, {
width: 128,
margin: 2
})
ticketQRCode.value = dataUrl ticketQRCode.value = dataUrl
return dataUrl return dataUrl
} catch (error) { } catch (error) {
@ -142,13 +128,9 @@ export function useTicketPurchase() {
async function purchaseTicketForEvent( async function purchaseTicketForEvent(
eventId: string, eventId: string,
options: PurchaseTicketOptions = {}, options: { quantity?: number } = {},
) { ) {
// Guests are identified by name + email; logged-in buyers by user id. if (!canPurchase.value || !currentUser.value) {
// 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') throw new Error('User must be authenticated to purchase tickets')
} }
@ -168,15 +150,12 @@ export function useTicketPurchase() {
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || '' const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const invoice = await ticketApi.requestTicket(eventId, { const invoice = await ticketApi.requestTicket(
userId: isGuest ? undefined : currentUser.value!.id, eventId,
name: isGuest ? options.contact?.name : undefined, currentUser.value!.id,
email: isGuest ? options.contact?.email : options.email, accessToken,
accessToken: isGuest ? undefined : accessToken, { quantity: options.quantity },
frontendUrl: frontendRoot(), )
quantity: options.quantity,
promoCode: options.promoCode,
})
// Free tickets (price 0 / 100%-off promo): the backend already // Free tickets (price 0 / 100%-off promo): the backend already
// issued + marked them paid, so there's no invoice to settle. Skip // issued + marked them paid, so there's no invoice to settle. Skip
@ -325,10 +304,6 @@ export function useTicketPurchase() {
handleOpenLightningWallet, handleOpenLightningWallet,
resetPaymentState, resetPaymentState,
cleanup, 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,8 +28,6 @@ export interface EventTicket {
name?: string | null name?: string | null
registered: boolean registered: boolean
registeredAt: string | null 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. */ /** Counts + roster snapshot for the event, sourced from the backend. */
@ -112,7 +110,6 @@ export function useTicketScanner(eventId: Ref<string>) {
name: t.name ?? null, name: t.name ?? null,
registered: t.registered, registered: t.registered,
registeredAt: t.registered_at, registeredAt: t.registered_at,
promoCode: t.applied_promo_code ?? null,
})), })),
} }
} catch (e) { } catch (e) {

View file

@ -51,18 +51,6 @@ export const eventsModule = createModulePlugin({
requiresAuth: false, 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', path: '/events/:id',
name: 'event-detail', name: 'event-detail',

View file

@ -1,16 +0,0 @@
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

@ -1,20 +0,0 @@
/**
* 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

@ -1,21 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,38 +0,0 @@
/**
* 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

@ -1,93 +0,0 @@
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

@ -1,125 +0,0 @@
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

@ -1,61 +0,0 @@
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

@ -1,146 +0,0 @@
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,45 +1,19 @@
import type { import type {
BasketTotals,
EventTicket, EventTicket,
EventTicketExtra, EventTicketExtra,
CreateTicketRequest, CreateTicketRequest,
PaymentMethod, PaymentMethod,
PublicTicket,
TicketPurchaseInvoice, TicketPurchaseInvoice,
TicketPaymentStatus, TicketPaymentStatus,
TicketResendResult,
TicketedEvent, TicketedEvent,
CreateEventRequest, CreateEventRequest,
PromoValidateRequest,
} from '../types/ticket' } from '../types/ticket'
import { normalizePromoCode } from '../lib/promoCodes'
export interface TicketApiConfig { export interface TicketApiConfig {
baseUrl: string baseUrl: string
apiKey: 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. * Database-backed ticketing API service.
* Talks to the LNbits events extension for ticket inventory, * Talks to the LNbits events extension for ticket inventory,
@ -83,27 +57,29 @@ export class TicketApiService {
* = follow-the-URL string from the configured fiat provider). The * = follow-the-URL string from the configured fiat provider). The
* `isFiat` flag is the discriminator. * `isFiat` flag is the discriminator.
* *
* 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.
*
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into * `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
* the fiat path (the event must have that rail enabled — the backend * the fiat path (requires the event to have `allow_fiat=true`).
* 400s otherwise). `fiatProvider` is optional — backend picks the * `fiatProvider` is optional — backend picks the user's configured
* organizer's configured default when omitted. `frontendUrl` is this * default when omitted.
* app's root (see `lib/frontendRoot.ts`) and drives the Stripe *
* return URLs + emailed ticket link. * Additional ticket metadata (promo code, refund address, nostr
* identifier for DM delivery) can be supplied via `options`.
*/ */
async requestTicket( async requestTicket(
eventId: string, eventId: string,
options: RequestTicketOptions = {}, 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
} = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = {} const body: CreateTicketRequest = { user_id: userId }
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.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode if (options.promoCode) body.promo_code = options.promoCode
@ -111,14 +87,14 @@ export class TicketApiService {
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity 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( const data = await this.request(
`/events/api/v1/tickets/${eventId}`, `/events/api/v1/tickets/${eventId}`,
{ {
method: 'POST', method: 'POST',
headers, headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(body), body: JSON.stringify(body),
} }
) )
@ -136,33 +112,6 @@ 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. * Check whether a ticket payment has been confirmed.
*/ */
@ -277,30 +226,22 @@ export class TicketApiService {
/** /**
* Resend the ticket confirmation email for a paid ticket. Requires * Resend the ticket confirmation email for a paid ticket. Requires
* the event's wallet admin key (organizer-only). Returns the updated * the event's wallet admin key (organizer-only). Returns the updated
* Ticket plus per-channel delivery results (events ext >= v1.6.1-aio.8; * Ticket with the `email_notification_sent` flag refreshed.
* older extensions returned the bare Ticket, which is tolerated).
* *
* Endpoint added upstream in v1.6.1 (PR #51); structured result in v1.6.8. * Endpoint added upstream in v1.6.1 (PR #51).
*/ */
async resendTicketEmail( async resendTicketEmail(
ticketId: string, ticketId: string,
adminKey: string, adminKey: string,
): Promise<TicketResendResult> { ): Promise<EventTicket> {
const r = await this.request( const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`, `/events/api/v1/tickets/${ticketId}/resend-email`,
{ {
method: 'POST', method: 'POST',
headers: { 'X-API-KEY': adminKey }, 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 { return {
ticket: {
id: t.id, id: t.id,
wallet: t.wallet, wallet: t.wallet,
eventId: t.event, eventId: t.event,
@ -312,9 +253,6 @@ export class TicketApiService {
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as EventTicketExtra | undefined, extra: t.extra as EventTicketExtra | undefined,
},
email: delivery(r.email),
nostr: delivery(r.nostr),
} }
} }
@ -338,8 +276,6 @@ export class TicketApiService {
name?: string | null name?: string | null
registered: boolean registered: boolean
registered_at: string | null 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`, { return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
@ -348,22 +284,6 @@ 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 — * Mark a paid ticket as registered at the door. Organizer-only —
* requires the event-owning wallet's admin_key. Backend rejects * requires the event-owning wallet's admin_key. Backend rejects
@ -462,9 +382,7 @@ export class TicketApiService {
: Array.isArray(error.detail) : Array.isArray(error.detail)
? error.detail[0]?.msg ?? 'Request failed' ? error.detail[0]?.msg ?? 'Request failed'
: 'Request failed' : 'Request failed'
// Carry the HTTP status so callers can tell "route missing on an throw new Error(errorMessage)
// older extension" (404) apart from "bad input" (400).
throw Object.assign(new Error(errorMessage), { status: response.status })
} }
return response.json() return response.json()

View file

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

View file

@ -1,35 +0,0 @@
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,10 +36,6 @@ export interface TicketTags {
allowFiat: boolean allowFiat: boolean
/** Fiat settle currency when allowFiat is true. */ /** Fiat settle currency when allowFiat is true. */
fiatCurrency?: string 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[]
} }
/** /**
@ -141,7 +137,6 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
const soldStr = getTagValue(tags, 'tickets_sold') const soldStr = getTagValue(tags, 'tickets_sold')
const priceStr = getTagValue(tags, 'tickets_price') const priceStr = getTagValue(tags, 'tickets_price')
const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat') const allowFiatStr = getTagValue(tags, 'tickets_allow_fiat')
const paymentMethodsStr = getTagValue(tags, 'tickets_payment_methods')
return { return {
available: availableStr != null ? Number(availableStr) : undefined, available: availableStr != null ? Number(availableStr) : undefined,
@ -150,9 +145,6 @@ function parseTicketTags(tags: string[][]): TicketTags | undefined {
currency, currency,
allowFiat: allowFiatStr === 'true', allowFiat: allowFiatStr === 'true',
fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'), fiatCurrency: getTagValue(tags, 'tickets_fiat_currency'),
paymentMethods: paymentMethodsStr != null
? paymentMethodsStr.split(',').map((m) => m.trim()).filter(Boolean)
: undefined,
} }
} }

View file

@ -10,38 +10,6 @@ export interface PromoCode {
code: string code: string
discount_percent: number discount_percent: number
active: boolean 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
} }
/** /**
@ -51,20 +19,13 @@ export interface BasketTotals {
* in upstream v1.4.0 (PR #50) and v1.6.0. * in upstream v1.4.0 (PR #50) and v1.6.0.
*/ */
export interface EventExtra { export interface EventExtra {
/** Present on organizer-authenticated records only: public event promo_codes: PromoCode[]
* responses (`/events/public`, `/events/{id}`) no longer carry the
* code list (events ext >= v1.6.1-aio.12). */
promo_codes?: PromoCode[]
conditional: boolean conditional: boolean
min_tickets: number min_tickets: number
email_notifications: boolean email_notifications: boolean
nostr_notifications: boolean nostr_notifications: boolean
notification_subject: string notification_subject: string
notification_body: 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 { export interface EventTicketExtra {
@ -204,22 +165,17 @@ export interface CreateEventRequest {
} }
/** /**
* Body for `POST /tickets/{event_id}`. Identity is `user_id` (logged-in * Body for `POST /tickets/{event_id}`. Either `user_id` OR the
* buyer, optionally with `email` for ticket delivery) OR the * `name`+`email` pair is required (backend root_validator enforces
* `name`+`email` pair (guest buyer). `nostr_identifier` opts the buyer * mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM
* into Nostr DM delivery when the event has nostr_notifications * delivery when the event has nostr_notifications enabled. The
* enabled. The `payment_method` + `fiat_provider` pair selects between * `payment_method` + `fiat_provider` pair selects between Lightning
* Lightning and fiat checkout. `frontend_url` is this app's root so the * and fiat checkout.
* backend can build the Stripe return URLs and the emailed ticket link
* under the webapp instead of the LNbits host.
*/ */
export interface CreateTicketRequest { export interface CreateTicketRequest {
name?: string name?: string
email?: string email?: string
user_id?: 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 promo_code?: string
refund_address?: string refund_address?: string
nostr_identifier?: string nostr_identifier?: string
@ -228,33 +184,3 @@ export interface CreateTicketRequest {
/** Number of tickets on this invoice (backend bounds 1..10). */ /** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number 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,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useElementVisibility } from '@vueuse/core'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
@ -15,8 +14,6 @@ import { useEventDetail } from '../composables/useEventDetail'
import BookmarkButton from '../components/BookmarkButton.vue' import BookmarkButton from '../components/BookmarkButton.vue'
import OrganizerCard from '../components/OrganizerCard.vue' import OrganizerCard from '../components/OrganizerCard.vue'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.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 { useAuth } from '@/composables/useAuthService'
import { useEventsStore } from '../stores/events' import { useEventsStore } from '../stores/events'
import { useOwnedTickets } from '../composables/useOwnedTickets' import { useOwnedTickets } from '../composables/useOwnedTickets'
@ -119,7 +116,6 @@ const purchaseEvent = computed(() => {
currency: a.ticketInfo.currency, currency: a.ticketInfo.currency,
allow_fiat: a.ticketInfo.allowFiat, allow_fiat: a.ticketInfo.allowFiat,
fiat_currency: a.ticketInfo.fiatCurrency, fiat_currency: a.ticketInfo.fiatCurrency,
payment_methods: a.ticketInfo.paymentMethods,
} }
}) })
@ -146,10 +142,7 @@ const isPast = computed(() => {
const showPurchaseDialog = ref(false) const showPurchaseDialog = ref(false)
function openPurchaseDialog() { function openPurchaseDialog() {
// Paid tickets are open to guests (name + email identity). Free if (!isAuthenticated.value) {
// 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'), { toastService.info(t('events.detail.loginToBuyTickets'), {
action: { action: {
label: t('events.detail.logIn'), label: t('events.detail.logIn'),
@ -161,75 +154,6 @@ function openPurchaseDialog() {
showPurchaseDialog.value = true 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 // Re-fetch the user's tickets when the purchase dialog closes (the
// buyer may have just paid). The inventory side updates automatically // buyer may have just paid). The inventory side updates automatically
// via the relay republish from the events extension. // via the relay republish from the events extension.
@ -243,7 +167,7 @@ function goToMyTickets() {
</script> </script>
<template> <template>
<div class="container mx-auto py-6 px-4 max-w-3xl" :class="{ 'pb-24': showStickyCta }"> <div class="container mx-auto py-6 px-4 max-w-3xl">
<!-- Top bar — back-link only. Edit moves into the title row as a <!-- Top bar — back-link only. Edit moves into the title row as a
prominent icon button; Scan moves into the tickets section prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. --> where it replaces the Buy-ticket CTA for the host. -->
@ -352,6 +276,13 @@ function goToMyTickets() {
</div> </div>
</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 <!-- Host's primary CTA is to scan tickets at the door. Lives
OUTSIDE the ticketInfo gate so it appears even when the OUTSIDE the ticketInfo gate so it appears even when the
event was published without AIO ticket tags — a host always event was published without AIO ticket tags — a host always
@ -371,15 +302,8 @@ function goToMyTickets() {
by the calendar→Event converter from the AIO custom by the calendar→Event converter from the AIO custom
tickets_* tags on the published event). Skipped for the tickets_* tags on the published event). Skipped for the
host entirely — they have the Scan CTA above and don't host entirely — they have the Scan CTA above and don't
need a Buy CTA for their own event. Placed ABOVE the need a Buy CTA for their own event. -->
description on purpose: the buy action is the reason most <div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
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 <div
v-if="ownedPaidCount > 0" v-if="ownedPaidCount > 0"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3" class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
@ -409,8 +333,14 @@ function goToMyTickets() {
@click="openPurchaseDialog" @click="openPurchaseDialog"
> >
<Ticket class="w-4 h-4" /> <Ticket class="w-4 h-4" />
{{ buyLabel }} {{ ownedPaidCount > 0
<span class="ml-2 opacity-80 font-normal">{{ priceLabel }}</span> ? 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>
</Button> </Button>
<p class="text-xs text-muted-foreground text-center"> <p class="text-xs text-muted-foreground text-center">
<span v-if="event.ticketInfo.available === undefined"> <span v-if="event.ticketInfo.available === undefined">
@ -429,13 +359,6 @@ function goToMyTickets() {
</p> </p>
</div> </div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ event.description }}</p>
</div>
<PurchaseTicketDialog <PurchaseTicketDialog
v-if="purchaseEvent" v-if="purchaseEvent"
:is-open="showPurchaseDialog" :is-open="showPurchaseDialog"
@ -443,67 +366,6 @@ function goToMyTickets() {
@update:is-open="showPurchaseDialog = $event" @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 --> <!-- External references -->
<div v-if="event.tags.length > 0" class="space-y-2"> <div v-if="event.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases --> <!-- References would go here in future phases -->

View file

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

View file

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

View file

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