Compare commits

...

5 commits

Author SHA1 Message Date
874d2a9ed8 build: add workbox-window as explicit devDependency
Required for pnpm strict-hoisting builds (used by aiolabs/server-deploy
NixOS builds). Without it as a direct dep, Rollup can't resolve
`workbox-window` from vite-plugin-pwa's virtual:pwa-register module —
npm's flat hoisting masked this previously.
2026-05-23 11:11:33 +02:00
b7dbdc0f97 build: switch from npm to pnpm
- Replace package-lock.json with pnpm-lock.yaml
- Add packageManager: pnpm@10.33.0
- Allowlist postinstall scripts for esbuild, sharp, vue-demi, electron,
  electron-winstaller via pnpm.onlyBuiltDependencies
- Pin nostr-tools to 2.15.0 (was ^2.10.4 resolving to 2.15.0 via npm).
  A fresh pnpm resolve drifted to 2.23.5, which the regtest nostrrelay
  extension can't parse; upgrade deferred to a follow-up issue covering
  the matching server-side fix.
2026-05-23 10:36:34 +02:00
dfc4ad7322 feat(activities): notification config on event create + edit
CreateEventDialog gains a collapsible "Buyer notifications" section
exposing the EventExtra fields added upstream in v1.4.0 / v1.6.0:

- email_notifications + nostr_notifications switches — opt buyers
  into email and NIP-04 Nostr DM ticket confirmations.
- notification_subject + notification_body inputs — let organizers
  customize the message. Empty falls back to extension defaults.

Submit handler builds `extra` by overlaying onto the existing
event.extra so unrelated fields the LNbits admin UI sets
(promo_codes, conditional, min_tickets) survive the round-trip
through the webapp. Populate-from-event mirrors the same.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:32:00 +02:00
8e54716ef0 feat(activities): expose fiat checkout on event create + purchase
Both sides of the fiat-payment surface introduced by events v1.4.0:

CreateEventDialog — organizer-side opt-in:
- New "Accept fiat payments" switch (allow_fiat) + fiat currency
  picker (USD/EUR/GBP/CHF). Toggle is always shipped on
  create/edit so a true→false flip propagates correctly.
- Hint copy notes that the host's LNbits admin needs a configured
  fiat provider (Stripe etc.) for the toggle to actually work at
  purchase time.

PurchaseTicketDialog — buyer-side method selector:
- Two-button selector (Lightning / Fiat) shown only when the event
  has `allow_fiat=true`. Hidden entirely for Lightning-only events.
- Lightning path: unchanged (uses useTicketPurchase composable).
- Fiat path: posts to the API with `payment_method=fiat`, then
  surfaces a "Open <provider> checkout" button that opens the
  returned fiat_payment_request URL in a new tab. Payment
  confirmation happens via webhook on the backend; ticket appears
  in My Tickets on next reload.

EventsPage threads the new fiat fields through `selectedEvent` so
the dialog sees them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:29:08 +02:00
94c54a5301 feat(activities): align types + API service with events v1.6.1
The backend rebase brought in PR #50 (fiat checkout + email/Nostr
ticket notifications), v1.6.0 (custom notification subject/body), and
PR #51 (resend-email endpoint). The webapp types lagged.

Aligns the type surface in src/modules/activities/types/ticket.ts:

- EventExtra (with notification toggles + custom subject/body), promo
  codes, conditional event config.
- ActivityTicketExtra mirroring backend's TicketExtra (nostr_identifier,
  email/nostr notification_sent flags, refund state).
- TicketedEvent + CreateEventRequest gain allow_fiat, fiat_currency, extra.
- TicketPurchaseInvoice extended for fiat: paymentRequest now optional,
  fiatPaymentRequest + fiatProvider + isFiat added. **Closes a latent
  blocker**: a backend response with is_fiat=true would have lost the
  fiat URL during deserialization (silent crash on QR generation).
- New CreateTicketRequest type for the POST /tickets/{id} body, with
  the v1.6.1 payment_method + fiat_provider + nostr_identifier fields.

TicketApiService:
- requestTicket() accepts the new optional fields (paymentMethod,
  fiatProvider, promoCode, refundAddress, nostrIdentifier) and
  deserializes the full TicketPurchaseInvoice shape including fiat.
- fetchUserTickets() / validateTicket() / new resendTicketEmail()
  thread the extra metadata through.

useTicketPurchase composable rejects fiat responses with a clear
error (the QR-and-bolt11 path doesn't know fiat); the eventual UI
selector will live in PurchaseTicketDialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:23:59 +02:00
9 changed files with 10396 additions and 15033 deletions

15013
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -59,7 +59,7 @@
"light-bolt11-decoder": "^3.2.0", "light-bolt11-decoder": "^3.2.0",
"lucide-vue-next": "^0.474.0", "lucide-vue-next": "^0.474.0",
"ngeohash": "^0.6.3", "ngeohash": "^0.6.3",
"nostr-tools": "^2.10.4", "nostr-tools": "2.15.0",
"pinia": "^2.3.1", "pinia": "^2.3.1",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@ -106,7 +106,8 @@
"vite-plugin-inspect": "^0.8.3", "vite-plugin-inspect": "^0.8.3",
"vite-plugin-pwa": "^0.21.1", "vite-plugin-pwa": "^0.21.1",
"vue-tsc": "^2.2.0", "vue-tsc": "^2.2.0",
"web-push": "^3.6.7" "web-push": "^3.6.7",
"workbox-window": "^7.3.0"
}, },
"build": { "build": {
"appId": "com.yourdomain.aio-shadcn-vite", "appId": "com.yourdomain.aio-shadcn-vite",
@ -138,5 +139,15 @@
"directories": { "directories": {
"output": "dist_electron" "output": "dist_electron"
} }
},
"packageManager": "pnpm@10.33.0",
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"electron-winstaller",
"esbuild",
"sharp",
"vue-demi"
]
} }
} }

9944
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,9 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
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 { Switch } from '@/components/ui/switch'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
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,
@ -98,8 +101,14 @@ 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"),
allow_fiat: z.boolean().default(false),
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),
email_notifications: z.boolean().default(false),
nostr_notifications: z.boolean().default(false),
notification_subject: z.string().max(200).default(''),
notification_body: z.string().max(2000).default(''),
}) })
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time // End must not precede start. Compare on the folded date+time
@ -128,8 +137,14 @@ const form = useForm({
event_end_time: '', event_end_time: '',
location: '', location: '',
currency: 'sat', currency: 'sat',
allow_fiat: false,
fiat_currency: 'USD',
amount_tickets: 0, amount_tickets: 0,
price_per_ticket: 0, price_per_ticket: 0,
email_notifications: false,
nostr_notifications: false,
notification_subject: '',
notification_body: '',
} }
}) })
@ -149,6 +164,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
// When `true`, suppress the auto-mirror watcher so we don't clobber an // When `true`, suppress the auto-mirror watcher so we don't clobber an
// 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)
// 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
@ -188,8 +204,14 @@ 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',
allow_fiat: event.allow_fiat ?? false,
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,
email_notifications: event.extra?.email_notifications ?? false,
nostr_notifications: event.extra?.nostr_notifications ?? false,
notification_subject: event.extra?.notification_subject ?? '',
notification_body: event.extra?.notification_body ?? '',
}) })
selectedCategories.value = [...(event.categories ?? [])] selectedCategories.value = [...(event.categories ?? [])]
if (event.banner) { if (event.banner) {
@ -295,10 +317,26 @@ 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 / fiat_currency: always send so the toggle reads
// both directions on edit (a truefalse flip must propagate).
eventData.allow_fiat = formValues.allow_fiat
if (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 goes inside the `extra` envelope. On edit
// overlay onto the existing event.extra so unrelated fields the
// LNbits admin UI sets (promo_codes, conditional, min_tickets)
// survive the round-trip.
eventData.extra = {
...(props.event?.extra ?? {}),
email_notifications: formValues.email_notifications,
nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body,
}
if (isEditMode.value) { if (isEditMode.value) {
if (!props.onUpdateEvent || !props.event?.id) { if (!props.onUpdateEvent || !props.event?.id) {
toastService.error('Update handler missing') toastService.error('Update handler missing')
@ -568,6 +606,108 @@ const handleOpenChange = (open: boolean) => {
</FormField> </FormField>
</div> </div>
<!-- Fiat checkout (organizer opts in). Backend requires the
host's LNbits admin to have configured a fiat provider
(Stripe etc.) under settings.fiat_providers; toggling
this on without one will fail at purchase time. -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end">
<FormField v-slot="{ value, handleChange }" name="allow_fiat">
<FormItem class="sm:col-span-2 flex flex-row items-center justify-between rounded-md border p-3">
<div class="space-y-0.5">
<FormLabel>Accept fiat payments</FormLabel>
<FormDescription class="text-xs">
Buyers can pay with the LNbits instance's configured fiat provider (e.g. Stripe).
</FormDescription>
</div>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="fiat_currency">
<FormItem v-show="form.values.allow_fiat">
<FormLabel>Fiat currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="USD" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="CHF">CHF</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Ticket buyer notifications (collapsible). The backend
sends email + NIP-04 Nostr DM confirmations on
payment when these are on. notification_subject /
body let the organizer customize the message; empty
strings fall back to the extension's defaults. -->
<Collapsible v-model:open="notificationsOpen">
<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">
<Bell class="w-4 h-4" />
Buyer notifications
</span>
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': notificationsOpen }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="space-y-3 pt-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField v-slot="{ value, handleChange }" name="email_notifications">
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3">
<FormLabel class="text-sm">Email confirmation</FormLabel>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="nostr_notifications">
<FormItem class="flex flex-row items-center justify-between rounded-md border p-3">
<FormLabel class="text-sm">Nostr DM confirmation</FormLabel>
<FormControl>
<Switch :model-value="value as boolean" @update:model-value="handleChange" />
</FormControl>
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="notification_subject">
<FormItem>
<FormLabel class="text-sm">Subject</FormLabel>
<FormControl>
<Input placeholder="Your ticket for {event_name}" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">Leave blank to use the default.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="notification_body">
<FormItem>
<FormLabel class="text-sm">Body</FormLabel>
<FormControl>
<Textarea placeholder="See you there!" rows="3" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">
Leave blank to use the default. The ticket link is appended automatically.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</CollapsibleContent>
</Collapsible>
<!-- Actions --> <!-- Actions -->
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading"> <Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">

View file

@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { onUnmounted } from 'vue' import { computed, onUnmounted, ref } from 'vue'
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 { useTicketPurchase } from '../composables/useTicketPurchase' import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { PaymentMethod } from '../types/ticket'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting' import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
interface Props { interface Props {
@ -14,6 +17,9 @@ interface Props {
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
/** Whether the event accepts fiat payments. From v1.4.0+ */
allow_fiat?: boolean
fiat_currency?: string
} }
isOpen: boolean isOpen: boolean
} }
@ -45,19 +51,74 @@ const {
showTicketQR showTicketQR
} = useTicketPurchase() } = useTicketPurchase()
const paymentMethod = ref<PaymentMethod>('lightning')
const fiatRedirectUrl = ref<string | null>(null)
const fiatProviderLabel = ref<string | null>(null)
const isFiatPending = ref(false)
const fiatError = ref<string | null>(null)
const canChooseFiat = computed(() => Boolean(props.event.allow_fiat))
async function handlePurchase() { async function handlePurchase() {
if (!canPurchase.value) return if (!canPurchase.value) return
fiatError.value = null
// Lightning path: existing composable handles QR + wallet auto-pay.
if (paymentMethod.value === 'lightning') {
try { try {
await purchaseTicketForEvent(props.event.id) await purchaseTicketForEvent(props.event.id)
} catch (err) { } catch (err) {
console.error('Error purchasing ticket:', err) console.error('Error purchasing ticket:', err)
} }
return
}
// Fiat path: composable can't drive it (no QR / no bolt11). Hit the
// API directly, then redirect the buyer to the provider's checkout
// URL. Payment confirmation happens via webhook on the backend and
// shows up next time the buyer reloads MyTickets.
try {
isFiatPending.value = true
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
const accessToken = lnbitsAPI?.getAccessToken?.() || ''
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
const currentUser = (lnbitsAPI?.currentUser?.value) || null
const userId = currentUser?.id
if (!userId) {
fiatError.value = 'Missing user id'
return
}
const invoice = await ticketApi.requestTicket(
props.event.id,
userId,
accessToken,
{ paymentMethod: 'fiat' },
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
return
}
fiatRedirectUrl.value = invoice.fiatPaymentRequest
fiatProviderLabel.value = invoice.fiatProvider ?? 'provider'
} catch (err) {
fiatError.value = err instanceof Error ? err.message : 'Fiat checkout failed'
} finally {
isFiatPending.value = false
}
}
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()
paymentMethod.value = 'lightning'
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null
} }
onUnmounted(() => { onUnmounted(() => {
@ -161,16 +222,64 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg"> <!-- Payment method selector (only shown when fiat is enabled
{{ error }} on the event). Hidden entirely for Lightning-only events
to keep the dialog uncluttered. -->
<div v-if="canChooseFiat" class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Payment method</div>
<div class="grid grid-cols-2 gap-2">
<Button
type="button"
:variant="paymentMethod === 'lightning' ? 'default' : 'outline'"
size="sm"
@click="paymentMethod = 'lightning'"
>
<Zap class="w-4 h-4 mr-1.5" />
Lightning
</Button>
<Button
type="button"
:variant="paymentMethod === 'fiat' ? 'default' : 'outline'"
size="sm"
@click="paymentMethod = 'fiat'"
>
<CreditCard class="w-4 h-4 mr-1.5" />
{{ event.fiat_currency ?? 'Fiat' }}
</Button>
</div>
</div>
<div v-if="error || fiatError" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{{ error || fiatError }}
</div>
<!-- Fiat checkout panel shown after a successful fiat
POST when we have a provider URL to redirect to. -->
<div v-if="fiatRedirectUrl" class="space-y-3">
<div class="bg-muted/50 rounded-lg p-4 space-y-2">
<div class="text-sm font-medium">Ready to pay with {{ fiatProviderLabel }}</div>
<p class="text-xs text-muted-foreground">
Opens the provider's checkout in a new tab. Your ticket
appears in My Tickets once the payment settles.
</p>
</div>
<Button @click="openFiatCheckout" class="w-full">
<ExternalLink class="w-4 h-4 mr-2" />
Open {{ fiatProviderLabel }} checkout
</Button>
</div> </div>
<Button <Button
v-else
@click="handlePurchase" @click="handlePurchase"
:disabled="isLoading || !canPurchase" :disabled="isLoading || isFiatPending || (paymentMethod === 'lightning' && !canPurchase)"
class="w-full" class="w-full"
> >
<span v-if="isLoading" class="animate-spin mr-2"></span> <span v-if="isLoading || isFiatPending" class="animate-spin mr-2"></span>
<span v-else-if="paymentMethod === 'fiat'" class="flex items-center gap-2">
<CreditCard class="w-4 h-4" />
Continue to {{ event.fiat_currency ?? 'fiat' }} checkout
</span>
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2"> <span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
<Zap class="w-4 h-4" /> <Zap class="w-4 h-4" />
Pay with Wallet Pay with Wallet

View file

@ -98,16 +98,31 @@ export function useTicketPurchase() {
currentUser.value!.id, currentUser.value!.id,
accessToken accessToken
) )
// Backend now returns either a Lightning invoice or a fiat
// checkout URL (post-events-v1.4.0). This composable only knows
// how to drive the Lightning path; fiat would need a separate
// redirect-to-provider flow that lives in PurchaseTicketDialog
// (it has the user-visible payment-method selector). Reject the
// fiat response here so callers get a clear error instead of a
// silent broken QR.
if (invoice.isFiat || !invoice.paymentRequest) {
throw new Error(
'This event uses fiat checkout. Use the purchase dialog ' +
'to follow the provider link.',
)
}
const bolt11: string = invoice.paymentRequest
paymentHash.value = invoice.paymentHash paymentHash.value = invoice.paymentHash
paymentRequest.value = invoice.paymentRequest paymentRequest.value = bolt11
// Generate QR code for payment // Generate QR code for payment
await generateQRCode(invoice.paymentRequest) await generateQRCode(bolt11)
// Try to pay with wallet if available // Try to pay with wallet if available
if (hasWalletWithBalance.value) { if (hasWalletWithBalance.value) {
try { try {
await payWithWallet(invoice.paymentRequest) await payWithWallet(bolt11)
await startPaymentStatusCheck(eventId, invoice.paymentHash) await startPaymentStatusCheck(eventId, invoice.paymentHash)
} catch (walletError) { } catch (walletError) {
console.log('Wallet payment failed, falling back to manual payment:', walletError) console.log('Wallet payment failed, falling back to manual payment:', walletError)

View file

@ -1,5 +1,8 @@
import type { import type {
ActivityTicket, ActivityTicket,
ActivityTicketExtra,
CreateTicketRequest,
PaymentMethod,
TicketPurchaseInvoice, TicketPurchaseInvoice,
TicketPaymentStatus, TicketPaymentStatus,
TicketedEvent, TicketedEvent,
@ -49,14 +52,38 @@ export class TicketApiService {
} }
/** /**
* Request a ticket purchase (creates a Lightning invoice). * Request a ticket purchase. Returns either a Lightning invoice
* Uses POST /tickets/{event_id} with user_id in body (upstream API). * (`paymentRequest` = bolt11) or a fiat invoice (`fiatPaymentRequest`
* = follow-the-URL string from the configured fiat provider). The
* `isFiat` flag is the discriminator.
*
* `paymentMethod` defaults to "lightning"; pass "fiat" to opt into
* the fiat path (requires the event to have `allow_fiat=true`).
* `fiatProvider` is optional backend picks the user's configured
* default when omitted.
*
* 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,
userId: string, userId: string,
accessToken: string accessToken: string,
options: {
paymentMethod?: PaymentMethod
fiatProvider?: string
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
} = {},
): Promise<TicketPurchaseInvoice> { ): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId }
if (options.paymentMethod) body.payment_method = options.paymentMethod
if (options.fiatProvider) body.fiat_provider = options.fiatProvider
if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
const data = await this.request( const data = await this.request(
`/events/api/v1/tickets/${eventId}`, `/events/api/v1/tickets/${eventId}`,
{ {
@ -65,13 +92,16 @@ export class TicketApiService {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, 'Authorization': `Bearer ${accessToken}`,
}, },
body: JSON.stringify({ user_id: userId }), body: JSON.stringify(body),
} }
) )
return { return {
paymentHash: data.payment_hash, paymentHash: data.payment_hash,
paymentRequest: data.payment_request, paymentRequest: data.payment_request ?? undefined,
fiatPaymentRequest: data.fiat_payment_request ?? undefined,
fiatProvider: data.fiat_provider ?? undefined,
isFiat: Boolean(data.is_fiat),
} }
} }
@ -121,6 +151,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
})) }))
} }
@ -144,6 +175,7 @@ export class TicketApiService {
paid: t.paid, paid: t.paid,
time: t.time, time: t.time,
regTimestamp: t.reg_timestamp, regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
})) }))
} }
@ -183,6 +215,39 @@ export class TicketApiService {
}) })
} }
/**
* Resend the ticket confirmation email for a paid ticket. Requires
* the event's wallet admin key (organizer-only). Returns the updated
* Ticket with the `email_notification_sent` flag refreshed.
*
* Endpoint added upstream in v1.6.1 (PR #51).
*/
async resendTicketEmail(
ticketId: string,
adminKey: string,
): Promise<ActivityTicket> {
const t = await this.request(
`/events/api/v1/tickets/${ticketId}/resend-email`,
{
method: 'POST',
headers: { 'X-API-KEY': adminKey },
}
)
return {
id: t.id,
wallet: t.wallet,
activityId: t.event,
name: t.name,
email: t.email,
userId: t.user_id,
registered: t.registered,
paid: t.paid,
time: t.time,
regTimestamp: t.reg_timestamp,
extra: t.extra as ActivityTicketExtra | undefined,
}
}
/** /**
* Probe whether the current user has LNbits admin privileges. The * Probe whether the current user has LNbits admin privileges. The
* `/all` endpoint is `check_admin`-gated, so a 200 means "admin", * `/all` endpoint is `check_admin`-gated, so a 200 means "admin",

View file

@ -1,7 +1,44 @@
/** /**
* Database-backed ticket types (via LNbits events extension) * Database-backed ticket types (via LNbits events extension).
*
* Wire-format types names match the snake_case fields the events
* extension serves over HTTP. Camel-cased aliases (e.g. ActivityTicket
* below) are the webapp-internal view models after adapter conversion.
*/ */
export interface PromoCode {
code: string
discount_percent: number
active: boolean
}
/**
* EventExtra mirrors the EventExtra Pydantic model in
* `events/models.py`. Carries promo codes, conditional-event config,
* and the per-event notification toggles + custom subject/body added
* in upstream v1.4.0 (PR #50) and v1.6.0.
*/
export interface EventExtra {
promo_codes: PromoCode[]
conditional: boolean
min_tickets: number
email_notifications: boolean
nostr_notifications: boolean
notification_subject: string
notification_body: string
}
export interface ActivityTicketExtra {
applied_promo_code?: string | null
sats_paid?: number | null
refund_address?: string | null
nostr_identifier?: string | null
ticket_base_url?: string | null
email_notification_sent: boolean
nostr_notification_sent: boolean
refunded: boolean
}
export interface ActivityTicket { export interface ActivityTicket {
id: string id: string
wallet: string wallet: string
@ -21,19 +58,40 @@ export interface ActivityTicket {
time: string time: string
/** Registration/scan timestamp */ /** Registration/scan timestamp */
regTimestamp: string regTimestamp: string
/** Optional metadata promo code applied, sats paid, notification
* delivery flags, refund state. May be absent on older tickets. */
extra?: ActivityTicketExtra
} }
export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled' export type TicketStatus = 'pending' | 'paid' | 'registered' | 'cancelled'
export type PaymentMethod = 'lightning' | 'fiat'
export interface TicketPurchaseRequest { export interface TicketPurchaseRequest {
activityId: string activityId: string
userId: string userId: string
accessToken: string accessToken: string
/** Lightning (default) or fiat. Only meaningful if the event has
* `allow_fiat=true` on the backend; otherwise the backend coerces
* to lightning. */
paymentMethod?: PaymentMethod
/** Specific fiat provider id (e.g. "stripe"). Backend picks the
* user's default if omitted. */
fiatProvider?: string
} }
/**
* Server response from `POST /tickets/{event_id}`. Either Lightning
* (`paymentRequest` = bolt11) or fiat (`fiatPaymentRequest` = a URL
* the buyer follows to complete payment with `fiatProvider`).
* `isFiat` is the discriminator.
*/
export interface TicketPurchaseInvoice { export interface TicketPurchaseInvoice {
paymentHash: string paymentHash: string
paymentRequest: string paymentRequest?: string
fiatPaymentRequest?: string
fiatProvider?: string
isFiat: boolean
} }
export interface TicketPaymentStatus { export interface TicketPaymentStatus {
@ -58,6 +116,10 @@ export interface TicketedEvent {
event_start_date: string event_start_date: string
event_end_date: string | null event_end_date: string | null
currency: string currency: string
/** Whether the event accepts fiat payments. Upstream v1.4.0+. */
allow_fiat: boolean
/** Fiat currency code (ISO 4217). Defaults to "GBP" upstream. */
fiat_currency: string
amount_tickets: number amount_tickets: number
price_per_ticket: number price_per_ticket: number
time: string time: string
@ -65,6 +127,7 @@ export interface TicketedEvent {
banner: string | null banner: string | null
location: string | null location: string | null
categories: string[] categories: string[]
extra: EventExtra
status: string status: string
} }
@ -76,9 +139,34 @@ export interface CreateEventRequest {
event_start_date: string event_start_date: string
event_end_date?: string event_end_date?: string
currency?: string currency?: string
allow_fiat?: boolean
fiat_currency?: string
amount_tickets?: number amount_tickets?: number
price_per_ticket?: number price_per_ticket?: number
banner?: string | null banner?: string | null
location?: string | null location?: string | null
categories?: string[] categories?: string[]
/** Optional notification toggles + custom subject/body, promo
* codes, conditional-event config. Backend defaults to a fresh
* EventExtra if omitted. */
extra?: Partial<EventExtra>
}
/**
* Body for `POST /tickets/{event_id}`. Either `user_id` OR the
* `name`+`email` pair is required (backend root_validator enforces
* mutual exclusion). `nostr_identifier` opts the buyer into Nostr DM
* delivery when the event has nostr_notifications enabled. The
* `payment_method` + `fiat_provider` pair selects between Lightning
* and fiat checkout.
*/
export interface CreateTicketRequest {
name?: string
email?: string
user_id?: string
promo_code?: string
refund_address?: string
nostr_identifier?: string
payment_method?: PaymentMethod
fiat_provider?: string
} }

View file

@ -27,6 +27,8 @@ const selectedEvent = ref<{
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
allow_fiat?: boolean
fiat_currency?: string
} | null>(null) } | null>(null)
const showEventDialog = ref(false) const showEventDialog = ref(false)
@ -56,6 +58,8 @@ function handlePurchaseClick(event: {
name: string name: string
price_per_ticket: number price_per_ticket: number
currency: string currency: string
allow_fiat?: boolean
fiat_currency?: string
}) { }) {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedEvent.value = event selectedEvent.value = event