feat(activities): multi-ticket purchase + restaurant-style invoice screen

Two related UX changes for the buy flow:

1. Quantity selector in PurchaseTicketDialog (1-10). The total
   line updates as the buyer steps the count up/down; the fiat
   conversion preview reflects the totalled amount. Backend caps
   the upper bound (HTTP 400 if anyone tries to bypass via curl).

2. Restaurant-style invoice screen: when the invoice is generated,
   we drop the "single Pay-with-Wallet button" auto-pay path and
   show the QR + amount + Copy + "Open in wallet" together,
   restaurant OrderInvoiceCard-style. Below that, a "Pay from my
   LNbits wallet" button appears when the buyer is signed in with
   a funded wallet — same screen, two paths, buyer picks at the
   moment they see the invoice. The poll already started fires on
   either path.

useTicketPurchase exposes `payCurrentInvoiceWithWallet()` so the
dialog can trigger the wallet-pay path explicitly without going
through purchaseTicketForEvent again. purchaseTicketForEvent no
longer auto-pays — it just creates the invoice + starts polling.

CreateTicketRequest grows `quantity?` (1..10) and requestTicket
forwards it. Quantity is only sent when > 1 so existing flows
stay byte-identical on the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-23 22:09:44 +02:00
commit a116357c57
4 changed files with 205 additions and 57 deletions

View file

@ -7,7 +7,7 @@ import { useTicketPurchase } from '../composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import { User, Wallet, CreditCard, Zap, Ticket, ExternalLink, Landmark } 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 PaymentMethodSelector, {
type PaymentMethod as PaymentMethodEntry,
@ -41,6 +41,7 @@ const {
isLoading,
error,
paymentHash,
paymentRequest,
qrCode,
isPaymentPending,
isPayingWithWallet,
@ -48,6 +49,7 @@ const {
userWallets,
hasWalletWithBalance,
purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet,
resetPaymentState,
cleanup,
@ -56,6 +58,31 @@ const {
showTicketQR
} = useTicketPurchase()
const MAX_QUANTITY = 10
const quantity = ref(1)
const copiedInvoice = ref(false)
function decreaseQuantity() {
if (quantity.value > 1) quantity.value -= 1
}
function increaseQuantity() {
if (quantity.value < MAX_QUANTITY) quantity.value += 1
}
const totalPrice = computed(() => props.event.price_per_ticket * quantity.value)
async function copyInvoice() {
if (!paymentRequest.value) return
try {
await navigator.clipboard.writeText(paymentRequest.value)
copiedInvoice.value = true
setTimeout(() => (copiedInvoice.value = false), 1500)
} catch {
// Older browsers / insecure contexts; the Open-in-wallet button
// still works as a fallback.
}
}
const { providers, providerMeta } = useFiatProviders()
const { convert } = usePriceConversion()
@ -147,10 +174,13 @@ async function handlePurchase() {
const method = selectedMethod.value
if (!method) return
// Lightning path: existing composable handles QR + wallet auto-pay.
// Lightning path: the composable just creates the invoice + starts
// polling. The buyer picks "Pay with my LNbits wallet" or "Open in
// external wallet" on the invoice screen (restaurant pattern), so
// no auto-pay here.
if (method.rail === 'lightning') {
try {
await purchaseTicketForEvent(props.event.id)
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
} catch (err) {
console.error('Error purchasing ticket:', err)
}
@ -177,7 +207,11 @@ async function handlePurchase() {
props.event.id,
userId,
accessToken,
{ paymentMethod: 'fiat', fiatProvider: method.provider },
{
paymentMethod: 'fiat',
fiatProvider: method.provider,
quantity: quantity.value,
},
)
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
fiatError.value = 'Fiat provider did not return a checkout URL.'
@ -206,6 +240,8 @@ function handleClose() {
fiatRedirectUrl.value = null
fiatProviderLabel.value = null
fiatError.value = null
quantity.value = 1
copiedInvoice.value = false
}
onUnmounted(() => {
@ -297,18 +333,50 @@ onUnmounted(() => {
<CreditCard class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Payment Details:</span>
</div>
<!-- Quantity selector backend caps at 10. One invoice for
the whole purchase, one ticket row representing N seats. -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Tickets:</span>
<div class="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7"
:disabled="quantity <= 1"
@click="decreaseQuantity"
>
<Minus class="h-3.5 w-3.5" />
</Button>
<span class="w-6 text-center text-sm font-medium">{{ quantity }}</span>
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7"
:disabled="quantity >= MAX_QUANTITY"
@click="increaseQuantity"
>
<Plus class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Event:</span>
<span class="text-sm font-medium">{{ event.name }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Price:</span>
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
<span class="text-sm text-muted-foreground">
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
</span>
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
</div>
<PriceConversionPreview
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
:amount="event.price_per_ticket"
:amount="totalPrice"
from="sat"
:to="event.fiat_currency"
prefix="Equivalent ~"
@ -363,52 +431,109 @@ onUnmounted(() => {
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
class="w-full"
>
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2"></span>
<span v-else-if="selectedMethod?.rail === 'fiat'" class="flex items-center gap-2">
<CreditCard class="w-4 h-4" />
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
<template v-else-if="selectedMethod?.rail === 'fiat'">
<CreditCard class="w-4 h-4 mr-2" />
Continue to {{ selectedMethod.label }} checkout
</span>
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
<Zap class="w-4 h-4" />
Pay with Wallet
</span>
<span v-else>Generate Payment Request</span>
</template>
<template v-else>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
</template>
</Button>
</div>
<!-- Payment QR Code and Status -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold">Payment Required</h3>
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
Processing payment with your wallet...
</p>
<p v-else class="text-sm text-muted-foreground">
Scan the QR code with your Lightning wallet to complete the payment
<!-- Lightning invoice restaurant-style. Shows QR + amount,
with both pay paths visible at once: tap-to-pay from the
LNbits wallet, scan with an external wallet, or hand off
via lightning: URI on mobile. Polling fires whichever
path the buyer takes. -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 space-y-4">
<div class="text-center space-y-1">
<h3 class="text-lg font-semibold">Pay the invoice</h3>
<p class="text-sm text-muted-foreground">
Scan with any Lightning wallet, or tap the button below to
pay from your LNbits wallet.
</p>
</div>
<div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4">
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
</div>
<div class="space-y-3 w-full">
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
<Wallet class="w-4 h-4 mr-2" />
Open in Lightning Wallet
</Button>
<div v-if="isPaymentPending" class="text-center space-y-2">
<div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm text-muted-foreground">
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
</span>
</div>
<p class="text-xs text-muted-foreground">
Payment will be confirmed automatically once received
</p>
<!-- QR + amount + copy/open buttons (restaurant
OrderInvoiceCard pattern). The QR keeps a white background
regardless of theme so phone cameras parse it reliably. -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="mx-auto w-fit overflow-hidden rounded-lg border border-border bg-white p-2">
<img
v-if="qrCode"
:src="qrCode"
alt="Lightning payment QR code"
class="block h-56 w-56 sm:h-64 sm:w-64"
/>
</div>
<div class="flex items-baseline justify-between">
<span class="text-xs text-muted-foreground">Amount</span>
<span class="font-mono text-sm font-semibold text-primary">
{{ formatEventPrice(totalPrice, event.currency) }}
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
({{ quantity }} tickets)
</span>
</span>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
class="flex-1 font-mono text-xs"
@click="copyInvoice"
>
<Check v-if="copiedInvoice" class="mr-2 h-3.5 w-3.5" />
<Copy v-else class="mr-2 h-3.5 w-3.5" />
{{ copiedInvoice ? 'Copied' : 'Copy' }}
</Button>
<Button
variant="default"
size="sm"
class="flex-1 text-xs"
@click="handleOpenLightningWallet"
>
<Zap class="mr-2 h-3.5 w-3.5" />
Open in wallet
</Button>
</div>
</div>
<!-- LNbits-wallet pay button only shown when the buyer is
logged in with a funded wallet. Same screen as the QR so
the user can pick either path without having to back out
of the dialog. -->
<Button
v-if="hasWalletWithBalance"
size="lg"
class="w-full"
:disabled="isPayingWithWallet"
@click="payCurrentInvoiceWithWallet"
>
<Loader2 v-if="isPayingWithWallet" class="mr-2 h-4 w-4 animate-spin" />
<Wallet v-else class="mr-2 h-4 w-4" />
{{ isPayingWithWallet ? 'Paying…' : 'Pay from my LNbits wallet' }}
</Button>
<p
v-else-if="userWallets.length > 0"
class="text-center text-xs text-muted-foreground"
>
Your LNbits wallet is empty pay with an external wallet
using the QR or "Open in wallet" above.
</p>
<div v-if="isPaymentPending" class="text-center space-y-1">
<div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm text-muted-foreground">
Waiting for payment
</span>
</div>
<p class="text-xs text-muted-foreground">
Confirmation lands automatically no need to refresh.
</p>
</div>
</div>

View file

@ -75,7 +75,15 @@ export function useTicketPurchase() {
}
}
async function purchaseTicketForEvent(eventId: string) {
/** The event id this composable is currently driving kept so
* `payCurrentInvoiceWithWallet` and `startPaymentStatusCheck` don't
* have to take it as an argument from the UI. */
const currentEventId = ref<string | null>(null)
async function purchaseTicketForEvent(
eventId: string,
options: { quantity?: number } = {},
) {
if (!canPurchase.value || !currentUser.value) {
throw new Error('User must be authenticated to purchase tickets')
}
@ -88,6 +96,7 @@ export function useTicketPurchase() {
ticketQRCode.value = null
purchasedTicketId.value = null
showTicketQR.value = false
currentEventId.value = eventId
// Get the invoice via TicketApiService
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
@ -96,7 +105,8 @@ export function useTicketPurchase() {
const invoice = await ticketApi.requestTicket(
eventId,
currentUser.value!.id,
accessToken
accessToken,
{ quantity: options.quantity },
)
// Backend now returns either a Lightning invoice or a fiat
@ -119,18 +129,12 @@ export function useTicketPurchase() {
// Generate QR code for payment
await generateQRCode(bolt11)
// Try to pay with wallet if available
if (hasWalletWithBalance.value) {
try {
await payWithWallet(bolt11)
await startPaymentStatusCheck(eventId, invoice.paymentHash)
} catch (walletError) {
console.log('Wallet payment failed, falling back to manual payment:', walletError)
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
} else {
await startPaymentStatusCheck(eventId, invoice.paymentHash)
}
// Restaurant-style: don't auto-pay. Surface the QR + amount and
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
// external wallet" on the same screen. The composable just
// starts polling so when payment lands (from any path) the UI
// advances to the ticket-QR success state.
await startPaymentStatusCheck(eventId, invoice.paymentHash)
return invoice
}, {
@ -138,6 +142,19 @@ export function useTicketPurchase() {
})
}
/**
* Trigger LNbits-wallet payment of the invoice this composable is
* currently displaying. Called when the buyer clicks the "Pay from
* my LNbits wallet" button on the invoice screen.
*/
async function payCurrentInvoiceWithWallet(): Promise<void> {
if (!paymentRequest.value) return
await payWithWallet(paymentRequest.value)
// Polling is already running from purchaseTicketForEvent — when
// the payment lands, it advances to showTicketQR. No need to
// restart it here.
}
async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true
let checkInterval: number | null = null
@ -219,6 +236,7 @@ export function useTicketPurchase() {
// Actions
purchaseTicketForEvent,
payCurrentInvoiceWithWallet,
handleOpenLightningWallet,
resetPaymentState,
cleanup,

View file

@ -75,6 +75,8 @@ export class TicketApiService {
promoCode?: string
refundAddress?: string
nostrIdentifier?: string
/** Number of tickets to buy on this invoice. Backend caps at 10. */
quantity?: number
} = {},
): Promise<TicketPurchaseInvoice> {
const body: CreateTicketRequest = { user_id: userId }
@ -83,6 +85,7 @@ export class TicketApiService {
if (options.promoCode) body.promo_code = options.promoCode
if (options.refundAddress) body.refund_address = options.refundAddress
if (options.nostrIdentifier) body.nostr_identifier = options.nostrIdentifier
if (options.quantity && options.quantity > 1) body.quantity = options.quantity
const data = await this.request(
`/events/api/v1/tickets/${eventId}`,

View file

@ -169,4 +169,6 @@ export interface CreateTicketRequest {
nostr_identifier?: string
payment_method?: PaymentMethod
fiat_provider?: string
/** Number of tickets on this invoice (backend bounds 1..10). */
quantity?: number
}