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:
parent
41d0d27b6f
commit
e861abfcbc
4 changed files with 205 additions and 57 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue