diff --git a/src/modules/activities/components/PurchaseTicketDialog.vue b/src/modules/activities/components/PurchaseTicketDialog.vue index 22eb1b8..b162a45 100644 --- a/src/modules/activities/components/PurchaseTicketDialog.vue +++ b/src/modules/activities/components/PurchaseTicketDialog.vue @@ -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(() => { Payment Details: + + +
+ Tickets: +
+ + {{ quantity }} + +
+
+
Event: {{ event.name }}
- Price: - {{ formatEventPrice(event.price_per_ticket, event.currency) }} + + {{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} + + {{ formatEventPrice(totalPrice, event.currency) }}
{ :disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)" class="w-full" > - - - + + +
- -
-
-

Payment Required

-

- Processing payment with your wallet... -

-

- Scan the QR code with your Lightning wallet to complete the payment + +

+
+

Pay the invoice

+

+ Scan with any Lightning wallet, or tap the button below to + pay from your LNbits wallet.

-
- Lightning payment QR code -
- -
- - -
-
-
- - {{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }} - -
-

- Payment will be confirmed automatically once received -

+ +
+
+ Lightning payment QR code
+
+ Amount + + {{ formatEventPrice(totalPrice, event.currency) }} + + ({{ quantity }} tickets) + + +
+
+ + +
+
+ + + +

+ Your LNbits wallet is empty — pay with an external wallet + using the QR or "Open in wallet" above. +

+ +
+
+
+ + Waiting for payment… + +
+

+ Confirmation lands automatically — no need to refresh. +

diff --git a/src/modules/activities/composables/useTicketPurchase.ts b/src/modules/activities/composables/useTicketPurchase.ts index 0145fd9..edeb6da 100644 --- a/src/modules/activities/composables/useTicketPurchase.ts +++ b/src/modules/activities/composables/useTicketPurchase.ts @@ -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(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 { + 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, diff --git a/src/modules/activities/services/TicketApiService.ts b/src/modules/activities/services/TicketApiService.ts index b3174a1..6086d51 100644 --- a/src/modules/activities/services/TicketApiService.ts +++ b/src/modules/activities/services/TicketApiService.ts @@ -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 { 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}`, diff --git a/src/modules/activities/types/ticket.ts b/src/modules/activities/types/ticket.ts index bd1985c..85ead95 100644 --- a/src/modules/activities/types/ticket.ts +++ b/src/modules/activities/types/ticket.ts @@ -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 }