feat(activities): ticket purchase + Nostr-driven inventory sync #71
4 changed files with 205 additions and 57 deletions
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>
commit
a116357c57
|
|
@ -7,7 +7,7 @@ import { useTicketPurchase } from '../composables/useTicketPurchase'
|
||||||
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, 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 { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
|
||||||
import PaymentMethodSelector, {
|
import PaymentMethodSelector, {
|
||||||
type PaymentMethod as PaymentMethodEntry,
|
type PaymentMethod as PaymentMethodEntry,
|
||||||
|
|
@ -41,6 +41,7 @@ const {
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
paymentHash,
|
paymentHash,
|
||||||
|
paymentRequest,
|
||||||
qrCode,
|
qrCode,
|
||||||
isPaymentPending,
|
isPaymentPending,
|
||||||
isPayingWithWallet,
|
isPayingWithWallet,
|
||||||
|
|
@ -48,6 +49,7 @@ const {
|
||||||
userWallets,
|
userWallets,
|
||||||
hasWalletWithBalance,
|
hasWalletWithBalance,
|
||||||
purchaseTicketForEvent,
|
purchaseTicketForEvent,
|
||||||
|
payCurrentInvoiceWithWallet,
|
||||||
handleOpenLightningWallet,
|
handleOpenLightningWallet,
|
||||||
resetPaymentState,
|
resetPaymentState,
|
||||||
cleanup,
|
cleanup,
|
||||||
|
|
@ -56,6 +58,31 @@ const {
|
||||||
showTicketQR
|
showTicketQR
|
||||||
} = useTicketPurchase()
|
} = 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 { providers, providerMeta } = useFiatProviders()
|
||||||
const { convert } = usePriceConversion()
|
const { convert } = usePriceConversion()
|
||||||
|
|
||||||
|
|
@ -147,10 +174,13 @@ async function handlePurchase() {
|
||||||
const method = selectedMethod.value
|
const method = selectedMethod.value
|
||||||
if (!method) return
|
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') {
|
if (method.rail === 'lightning') {
|
||||||
try {
|
try {
|
||||||
await purchaseTicketForEvent(props.event.id)
|
await purchaseTicketForEvent(props.event.id, { quantity: quantity.value })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error purchasing ticket:', err)
|
console.error('Error purchasing ticket:', err)
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +207,11 @@ async function handlePurchase() {
|
||||||
props.event.id,
|
props.event.id,
|
||||||
userId,
|
userId,
|
||||||
accessToken,
|
accessToken,
|
||||||
{ paymentMethod: 'fiat', fiatProvider: method.provider },
|
{
|
||||||
|
paymentMethod: 'fiat',
|
||||||
|
fiatProvider: method.provider,
|
||||||
|
quantity: quantity.value,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
||||||
fiatError.value = 'Fiat provider did not return a checkout URL.'
|
fiatError.value = 'Fiat provider did not return a checkout URL.'
|
||||||
|
|
@ -206,6 +240,8 @@ function handleClose() {
|
||||||
fiatRedirectUrl.value = null
|
fiatRedirectUrl.value = null
|
||||||
fiatProviderLabel.value = null
|
fiatProviderLabel.value = null
|
||||||
fiatError.value = null
|
fiatError.value = null
|
||||||
|
quantity.value = 1
|
||||||
|
copiedInvoice.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
@ -297,18 +333,50 @@ onUnmounted(() => {
|
||||||
<CreditCard class="w-4 h-4 text-muted-foreground" />
|
<CreditCard class="w-4 h-4 text-muted-foreground" />
|
||||||
<span class="text-sm font-medium">Payment Details:</span>
|
<span class="text-sm font-medium">Payment Details:</span>
|
||||||
</div>
|
</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="space-y-1">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-muted-foreground">Event:</span>
|
<span class="text-sm text-muted-foreground">Event:</span>
|
||||||
<span class="text-sm font-medium">{{ event.name }}</span>
|
<span class="text-sm font-medium">{{ event.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-muted-foreground">Price:</span>
|
<span class="text-sm text-muted-foreground">
|
||||||
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
|
{{ quantity > 1 ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium">{{ formatEventPrice(totalPrice, event.currency) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<PriceConversionPreview
|
<PriceConversionPreview
|
||||||
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
||||||
:amount="event.price_per_ticket"
|
:amount="totalPrice"
|
||||||
from="sat"
|
from="sat"
|
||||||
:to="event.fiat_currency"
|
:to="event.fiat_currency"
|
||||||
prefix="Equivalent ~"
|
prefix="Equivalent ~"
|
||||||
|
|
@ -363,54 +431,111 @@ onUnmounted(() => {
|
||||||
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
|
:disabled="isLoading || isFiatPending || (selectedMethod?.rail === 'lightning' && !canPurchase)"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading || isFiatPending" class="animate-spin mr-2">⚡</span>
|
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
<span v-else-if="selectedMethod?.rail === 'fiat'" class="flex items-center gap-2">
|
<template v-else-if="selectedMethod?.rail === 'fiat'">
|
||||||
<CreditCard class="w-4 h-4" />
|
<CreditCard class="w-4 h-4 mr-2" />
|
||||||
Continue to {{ selectedMethod.label }} checkout
|
Continue to {{ selectedMethod.label }} checkout
|
||||||
</span>
|
</template>
|
||||||
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
|
<template v-else>
|
||||||
<Zap class="w-4 h-4" />
|
<Zap class="w-4 h-4 mr-2" />
|
||||||
Pay with Wallet
|
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
|
||||||
</span>
|
</template>
|
||||||
<span v-else>Generate Payment Request</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment QR Code and Status -->
|
<!-- Lightning invoice — restaurant-style. Shows QR + amount,
|
||||||
<div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4">
|
with both pay paths visible at once: tap-to-pay from the
|
||||||
<div class="text-center space-y-2">
|
LNbits wallet, scan with an external wallet, or hand off
|
||||||
<h3 class="text-lg font-semibold">Payment Required</h3>
|
via lightning: URI on mobile. Polling fires whichever
|
||||||
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
|
path the buyer takes. -->
|
||||||
Processing payment with your wallet...
|
<div v-else-if="paymentHash && !showTicketQR" class="py-4 space-y-4">
|
||||||
</p>
|
<div class="text-center space-y-1">
|
||||||
<p v-else class="text-sm text-muted-foreground">
|
<h3 class="text-lg font-semibold">Pay the invoice</h3>
|
||||||
Scan the QR code with your Lightning wallet to complete the payment
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Scan with any Lightning wallet, or tap the button below to
|
||||||
|
pay from your LNbits wallet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4">
|
<!-- QR + amount + copy/open buttons (restaurant
|
||||||
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
|
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>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
<div class="space-y-3 w-full">
|
<span class="text-xs text-muted-foreground">Amount</span>
|
||||||
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
|
<span class="font-mono text-sm font-semibold text-primary">
|
||||||
<Wallet class="w-4 h-4 mr-2" />
|
{{ formatEventPrice(totalPrice, event.currency) }}
|
||||||
Open in Lightning Wallet
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div v-if="isPaymentPending" class="text-center space-y-2">
|
<!-- 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="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>
|
<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">
|
<span class="text-sm text-muted-foreground">
|
||||||
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
|
Waiting for payment…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Payment will be confirmed automatically once received
|
Confirmation lands automatically — no need to refresh.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ticket QR Code (After Successful Purchase) -->
|
<!-- Ticket QR Code (After Successful Purchase) -->
|
||||||
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
|
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
if (!canPurchase.value || !currentUser.value) {
|
||||||
throw new Error('User must be authenticated to purchase tickets')
|
throw new Error('User must be authenticated to purchase tickets')
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +96,7 @@ export function useTicketPurchase() {
|
||||||
ticketQRCode.value = null
|
ticketQRCode.value = null
|
||||||
purchasedTicketId.value = null
|
purchasedTicketId.value = null
|
||||||
showTicketQR.value = false
|
showTicketQR.value = false
|
||||||
|
currentEventId.value = eventId
|
||||||
|
|
||||||
// Get the invoice via TicketApiService
|
// Get the invoice via TicketApiService
|
||||||
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
const lnbitsAPI = injectService(SERVICE_TOKENS.LNBITS_API) as any
|
||||||
|
|
@ -96,7 +105,8 @@ export function useTicketPurchase() {
|
||||||
const invoice = await ticketApi.requestTicket(
|
const invoice = await ticketApi.requestTicket(
|
||||||
eventId,
|
eventId,
|
||||||
currentUser.value!.id,
|
currentUser.value!.id,
|
||||||
accessToken
|
accessToken,
|
||||||
|
{ quantity: options.quantity },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Backend now returns either a Lightning invoice or a fiat
|
// Backend now returns either a Lightning invoice or a fiat
|
||||||
|
|
@ -119,18 +129,12 @@ export function useTicketPurchase() {
|
||||||
// Generate QR code for payment
|
// Generate QR code for payment
|
||||||
await generateQRCode(bolt11)
|
await generateQRCode(bolt11)
|
||||||
|
|
||||||
// Try to pay with wallet if available
|
// Restaurant-style: don't auto-pay. Surface the QR + amount and
|
||||||
if (hasWalletWithBalance.value) {
|
// let the buyer pick "Pay with my LNbits wallet" vs "Open in
|
||||||
try {
|
// external wallet" on the same screen. The composable just
|
||||||
await payWithWallet(bolt11)
|
// starts polling so when payment lands (from any path) the UI
|
||||||
|
// advances to the ticket-QR success state.
|
||||||
await startPaymentStatusCheck(eventId, invoice.paymentHash)
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return invoice
|
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) {
|
async function startPaymentStatusCheck(eventId: string, hash: string) {
|
||||||
isPaymentPending.value = true
|
isPaymentPending.value = true
|
||||||
let checkInterval: number | null = null
|
let checkInterval: number | null = null
|
||||||
|
|
@ -219,6 +236,7 @@ export function useTicketPurchase() {
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
purchaseTicketForEvent,
|
purchaseTicketForEvent,
|
||||||
|
payCurrentInvoiceWithWallet,
|
||||||
handleOpenLightningWallet,
|
handleOpenLightningWallet,
|
||||||
resetPaymentState,
|
resetPaymentState,
|
||||||
cleanup,
|
cleanup,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,8 @@ export class TicketApiService {
|
||||||
promoCode?: string
|
promoCode?: string
|
||||||
refundAddress?: string
|
refundAddress?: string
|
||||||
nostrIdentifier?: string
|
nostrIdentifier?: string
|
||||||
|
/** Number of tickets to buy on this invoice. Backend caps at 10. */
|
||||||
|
quantity?: number
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<TicketPurchaseInvoice> {
|
): Promise<TicketPurchaseInvoice> {
|
||||||
const body: CreateTicketRequest = { user_id: userId }
|
const body: CreateTicketRequest = { user_id: userId }
|
||||||
|
|
@ -83,6 +85,7 @@ export class TicketApiService {
|
||||||
if (options.promoCode) body.promo_code = options.promoCode
|
if (options.promoCode) body.promo_code = options.promoCode
|
||||||
if (options.refundAddress) body.refund_address = options.refundAddress
|
if (options.refundAddress) body.refund_address = options.refundAddress
|
||||||
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
|
||||||
|
|
||||||
const data = await this.request(
|
const data = await this.request(
|
||||||
`/events/api/v1/tickets/${eventId}`,
|
`/events/api/v1/tickets/${eventId}`,
|
||||||
|
|
|
||||||
|
|
@ -169,4 +169,6 @@ export interface CreateTicketRequest {
|
||||||
nostr_identifier?: string
|
nostr_identifier?: string
|
||||||
payment_method?: PaymentMethod
|
payment_method?: PaymentMethod
|
||||||
fiat_provider?: string
|
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