feat(events): promo code entry with server-validated total at checkout
usePromoCode drives an Apply flow against the validate endpoint (states idle / checking / applied / invalid / unverified; quantity changes re-validate with a debounce; stale responses are ignored; 404 from an older extension keeps the code and sends it unverified). The dialog shows the struck-through original and the discounted total in the header, price block, conversion preview and invoice amount; the code rides on both the Lightning and card requests. A 100 %-off code on the card rail lands on the success screen via finalizePurchasedTickets instead of the "no checkout URL" error, and a guest whose total becomes zero gets an inline log-in prompt (free claims stay login-gated, events#29) rather than a swapped form. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
This commit is contained in:
parent
4162e0af99
commit
a1b9aaf2a2
3 changed files with 311 additions and 18 deletions
|
|
@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||||
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 { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
|
|
@ -17,10 +18,12 @@ import {
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
import { useTicketPurchase } from '../composables/useTicketPurchase'
|
||||||
|
import { usePromoCode } from '../composables/usePromoCode'
|
||||||
|
import { normalizePromoCode } from '../lib/promoCodes'
|
||||||
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, Landmark, Minus, Plus, Copy, Check, Loader2, Mail } from 'lucide-vue-next'
|
import { User, Wallet, CreditCard, Zap, Ticket, Landmark, Minus, Plus, Copy, Check, Loader2, Mail, Tag, X } 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,
|
||||||
|
|
@ -74,7 +77,8 @@ const {
|
||||||
cleanup,
|
cleanup,
|
||||||
purchasedTicketIds,
|
purchasedTicketIds,
|
||||||
ticketQRCodes,
|
ticketQRCodes,
|
||||||
showTicketQR
|
showTicketQR,
|
||||||
|
finalizePurchasedTickets
|
||||||
} = useTicketPurchase()
|
} = useTicketPurchase()
|
||||||
|
|
||||||
const MAX_QUANTITY = 10
|
const MAX_QUANTITY = 10
|
||||||
|
|
@ -100,6 +104,47 @@ const isFree = computed(() => props.event.price_per_ticket <= 0)
|
||||||
// capacity (events#29).
|
// capacity (events#29).
|
||||||
const guestNeedsLogin = computed(() => isFree.value && !isAuthenticated.value)
|
const guestNeedsLogin = computed(() => isFree.value && !isAuthenticated.value)
|
||||||
|
|
||||||
|
// --- Promo code -----------------------------------------------------
|
||||||
|
// Server-validated preview (`POST /promo/validate`); the purchase
|
||||||
|
// endpoint re-checks the code anyway, so the client never trusts its
|
||||||
|
// own arithmetic for what gets charged.
|
||||||
|
const promo = usePromoCode(() => props.event.id, quantity)
|
||||||
|
const {
|
||||||
|
input: promoInput,
|
||||||
|
state: promoState,
|
||||||
|
isChecking: promoChecking,
|
||||||
|
codeToSend: promoCodeToSend,
|
||||||
|
} = promo
|
||||||
|
|
||||||
|
const hasDiscount = computed(
|
||||||
|
() => promoState.value.status === 'applied' && promoState.value.total < promoState.value.subtotal,
|
||||||
|
)
|
||||||
|
const promoDiscountAmount = computed(() =>
|
||||||
|
promoState.value.status === 'applied' ? promoState.value.discount : 0,
|
||||||
|
)
|
||||||
|
/** What the buyer will actually be charged for the current quantity. */
|
||||||
|
const effectiveTotal = computed(() =>
|
||||||
|
promoState.value.status === 'applied' ? promoState.value.total : totalPrice.value,
|
||||||
|
)
|
||||||
|
const isZeroTotal = computed(() => effectiveTotal.value <= 0)
|
||||||
|
// A 100 %-off code turns a paid ticket into a free claim, which stays
|
||||||
|
// login-gated (events#29). Prompt inline — swapping the whole form would
|
||||||
|
// hide the Remove button the guest needs to back out of the code.
|
||||||
|
const guestNeedsLoginForDiscount = computed(
|
||||||
|
() => !isFree.value && isZeroTotal.value && !isAuthenticated.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function onPromoInput(event: Event) {
|
||||||
|
const el = event.target as HTMLInputElement
|
||||||
|
const value = normalizePromoCode(el.value)
|
||||||
|
el.value = value
|
||||||
|
promoInput.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromoError(err: unknown): boolean {
|
||||||
|
return err instanceof Error && /promo code/i.test(err.message)
|
||||||
|
}
|
||||||
|
|
||||||
async function copyInvoice() {
|
async function copyInvoice() {
|
||||||
if (!paymentRequest.value) return
|
if (!paymentRequest.value) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -154,6 +199,7 @@ watch(
|
||||||
form.resetForm({
|
form.resetForm({
|
||||||
values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' },
|
values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' },
|
||||||
})
|
})
|
||||||
|
promo.clear()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
@ -273,6 +319,27 @@ async function handlePurchase(contact: { name?: string; email: string }) {
|
||||||
|
|
||||||
const isGuest = !isAuthenticated.value
|
const isGuest = !isAuthenticated.value
|
||||||
const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined
|
const guestContact = isGuest ? { name: contact.name ?? '', email: contact.email } : undefined
|
||||||
|
const promoCode = promoCodeToSend.value
|
||||||
|
|
||||||
|
if (guestNeedsLoginForDiscount.value) return
|
||||||
|
|
||||||
|
// Nothing to charge (100 %-off code on a paid event): skip the rail
|
||||||
|
// choice entirely — the backend issues the tickets already-paid and
|
||||||
|
// the composable lands on the success screen from `invoice.paid`.
|
||||||
|
if (isZeroTotal.value && !isFree.value) {
|
||||||
|
try {
|
||||||
|
await purchaseTicketForEvent(props.event.id, {
|
||||||
|
quantity: quantity.value,
|
||||||
|
contact: guestContact,
|
||||||
|
email: contact.email,
|
||||||
|
promoCode,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error claiming discounted ticket:', err)
|
||||||
|
if (isPromoError(err)) void promo.revalidate()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Lightning path: the composable creates the invoice + starts polling.
|
// Lightning path: the composable creates the invoice + starts polling.
|
||||||
// The buyer pays from their LNbits wallet or an external one on the
|
// The buyer pays from their LNbits wallet or an external one on the
|
||||||
|
|
@ -283,9 +350,11 @@ async function handlePurchase(contact: { name?: string; email: string }) {
|
||||||
quantity: quantity.value,
|
quantity: quantity.value,
|
||||||
contact: guestContact,
|
contact: guestContact,
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
|
promoCode,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error purchasing ticket:', err)
|
console.error('Error purchasing ticket:', err)
|
||||||
|
if (isPromoError(err)) void promo.revalidate()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +377,17 @@ async function handlePurchase(contact: { name?: string; email: string }) {
|
||||||
paymentMethod: 'fiat',
|
paymentMethod: 'fiat',
|
||||||
fiatProvider: method.provider,
|
fiatProvider: method.provider,
|
||||||
quantity: quantity.value,
|
quantity: quantity.value,
|
||||||
|
promoCode,
|
||||||
})
|
})
|
||||||
|
// The backend may still short-circuit to the free path (e.g. a code
|
||||||
|
// that became 100 % between preview and pay): no checkout URL, the
|
||||||
|
// tickets are already issued — show them instead of an error.
|
||||||
|
if (invoice.paid) {
|
||||||
|
paymentHash.value = invoice.paymentHash
|
||||||
|
await finalizePurchasedTickets(invoice.ticketIds ?? [])
|
||||||
|
isFiatPending.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
if (!invoice.isFiat || !invoice.fiatPaymentRequest) {
|
||||||
fiatError.value = t('events.purchase.noCheckoutUrl')
|
fiatError.value = t('events.purchase.noCheckoutUrl')
|
||||||
isFiatPending.value = false
|
isFiatPending.value = false
|
||||||
|
|
@ -332,12 +411,14 @@ async function handlePurchase(contact: { name?: string; email: string }) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed')
|
fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed')
|
||||||
isFiatPending.value = false
|
isFiatPending.value = false
|
||||||
|
if (isPromoError(err)) void promo.revalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
emit('update:isOpen', false)
|
emit('update:isOpen', false)
|
||||||
resetPaymentState()
|
resetPaymentState()
|
||||||
|
promo.clear()
|
||||||
selectedMethodId.value = ''
|
selectedMethodId.value = ''
|
||||||
fiatError.value = null
|
fiatError.value = null
|
||||||
isFiatPending.value = false
|
isFiatPending.value = false
|
||||||
|
|
@ -359,15 +440,19 @@ onUnmounted(() => {
|
||||||
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
|
{{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<span v-if="quantity > 1">
|
<span v-if="isFree">
|
||||||
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
|
<template v-if="quantity > 1">{{ quantity }} free tickets for</template>
|
||||||
{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
|
<template v-else>Get a free ticket for</template>
|
||||||
</span>
|
<strong>{{ event.name }}</strong>
|
||||||
<span v-else-if="isFree">
|
|
||||||
Get a free ticket for <strong>{{ event.name }}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
|
<template v-if="quantity > 1">{{ quantity }} tickets for</template>
|
||||||
|
<template v-else>Purchase a ticket for</template>
|
||||||
|
<strong>{{ event.name }}</strong> ·
|
||||||
|
<s v-if="hasDiscount" class="text-muted-foreground">{{ formatEventPrice(totalPrice, event.currency) }}</s>
|
||||||
|
<span :class="{ 'text-primary font-medium': hasDiscount }">
|
||||||
|
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -445,8 +530,71 @@ onUnmounted(() => {
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Promo code (paid events only). Apply asks the backend for
|
||||||
|
the discounted total; the badge + struck-through price
|
||||||
|
reflect the server's numbers, never a client guess. -->
|
||||||
|
<div v-if="!isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Tag class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm font-medium">{{ t('events.purchase.promo.label') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="promoState.status === 'applied' || promoState.status === 'unverified'" class="flex items-center justify-between gap-2">
|
||||||
|
<Badge :variant="promoState.status === 'applied' ? 'default' : 'secondary'" class="font-mono">
|
||||||
|
<template v-if="promoState.status === 'applied'">
|
||||||
|
{{ t('events.purchase.promo.applied', { code: promoState.code, percent: promoState.discountPercent }) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ promoState.code }}</template>
|
||||||
|
</Badge>
|
||||||
|
<Button type="button" variant="ghost" size="sm" class="gap-1 h-7" @click="promo.clear()">
|
||||||
|
<X class="w-3.5 h-3.5" />
|
||||||
|
{{ t('events.purchase.promo.remove') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="promoInput"
|
||||||
|
:placeholder="t('events.purchase.promo.placeholder')"
|
||||||
|
class="font-mono uppercase"
|
||||||
|
autocapitalize="characters"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:disabled="promoChecking"
|
||||||
|
@input="onPromoInput"
|
||||||
|
@keydown.enter.prevent="promo.apply()"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="shrink-0"
|
||||||
|
:disabled="!promoInput || promoChecking"
|
||||||
|
@click="promo.apply()"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="promoChecking" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
{{ promoChecking ? t('events.purchase.promo.checking') : t('events.purchase.promo.apply') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="promoState.status === 'invalid'" class="text-xs text-destructive">
|
||||||
|
{{ t('events.purchase.promo.invalid') }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="promoState.status === 'unverified'" class="text-xs text-muted-foreground">
|
||||||
|
{{ t('events.purchase.promo.unverified') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Alert v-if="guestNeedsLoginForDiscount" class="mt-2">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
<AlertDescription class="flex flex-col gap-2">
|
||||||
|
<span>{{ t('events.purchase.promo.loginRequiredDiscount') }}</span>
|
||||||
|
<Button type="button" size="sm" class="w-fit" @click="() => $router.push('/login')">
|
||||||
|
{{ t('events.detail.logIn') }}
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Wallet Information (logged-in Lightning buyers) -->
|
<!-- Wallet Information (logged-in Lightning buyers) -->
|
||||||
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
<div v-if="isAuthenticated && selectedMethod?.rail === 'lightning' && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Wallet class="w-4 h-4 text-muted-foreground" />
|
<Wallet class="w-4 h-4 text-muted-foreground" />
|
||||||
<span class="text-sm font-medium">Wallet Status:</span>
|
<span class="text-sm font-medium">Wallet Status:</span>
|
||||||
|
|
@ -520,11 +668,22 @@ onUnmounted(() => {
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-medium">{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}</span>
|
<span class="text-sm font-medium">
|
||||||
|
<template v-if="isFree">Free</template>
|
||||||
|
<template v-else-if="hasDiscount">
|
||||||
|
<s class="text-muted-foreground font-normal mr-1">{{ formatEventPrice(totalPrice, event.currency) }}</s>
|
||||||
|
{{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ formatEventPrice(totalPrice, event.currency) }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasDiscount && promoState.status === 'applied'" class="flex justify-between text-primary">
|
||||||
|
<span class="text-sm">{{ t('events.purchase.promo.discountLine', { code: promoState.code }) }}</span>
|
||||||
|
<span class="text-sm font-medium">−{{ formatEventPrice(promoDiscountAmount, event.currency) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<PriceConversionPreview
|
<PriceConversionPreview
|
||||||
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
||||||
:amount="totalPrice"
|
:amount="effectiveTotal"
|
||||||
from="sat"
|
from="sat"
|
||||||
:to="event.fiat_currency"
|
:to="event.fiat_currency"
|
||||||
prefix="Equivalent ~"
|
prefix="Equivalent ~"
|
||||||
|
|
@ -536,7 +695,7 @@ onUnmounted(() => {
|
||||||
<!-- Payment method selector — one button per enabled rail /
|
<!-- Payment method selector — one button per enabled rail /
|
||||||
configured fiat provider. Hidden when only one rail is on
|
configured fiat provider. Hidden when only one rail is on
|
||||||
(nothing to choose) and for free tickets. -->
|
(nothing to choose) and for free tickets. -->
|
||||||
<div v-if="paymentMethods.length > 1 && !isFree" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
<div v-if="paymentMethods.length > 1 && !isZeroTotal" class="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
<div class="text-sm font-medium">Payment method</div>
|
<div class="text-sm font-medium">Payment method</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Both methods charge the same amount via different rails.
|
Both methods charge the same amount via different rails.
|
||||||
|
|
@ -556,11 +715,11 @@ onUnmounted(() => {
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || isFiatPending || paymentMethods.length === 0 && !isFree"
|
:disabled="isLoading || isFiatPending || promoChecking || guestNeedsLoginForDiscount || (paymentMethods.length === 0 && !isZeroTotal)"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 v-if="isLoading || isFiatPending" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
<template v-else-if="isFree">
|
<template v-else-if="isZeroTotal">
|
||||||
<Ticket class="w-4 h-4 mr-2" />
|
<Ticket class="w-4 h-4 mr-2" />
|
||||||
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
|
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -573,7 +732,7 @@ onUnmounted(() => {
|
||||||
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
|
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<p v-if="selectedMethod?.rail === 'fiat' && !isFree" class="text-xs text-muted-foreground text-center">
|
<p v-if="selectedMethod?.rail === 'fiat' && !isZeroTotal" class="text-xs text-muted-foreground text-center">
|
||||||
{{ t('events.purchase.fiatRedirectHint') }}
|
{{ t('events.purchase.fiatRedirectHint') }}
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -612,7 +771,7 @@ onUnmounted(() => {
|
||||||
<div class="flex items-baseline justify-between">
|
<div class="flex items-baseline justify-between">
|
||||||
<span class="text-xs text-muted-foreground">Amount</span>
|
<span class="text-xs text-muted-foreground">Amount</span>
|
||||||
<span class="font-mono text-sm font-semibold text-primary">
|
<span class="font-mono text-sm font-semibold text-primary">
|
||||||
{{ formatEventPrice(totalPrice, event.currency) }}
|
{{ formatEventPrice(effectiveTotal, event.currency) }}
|
||||||
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
|
<span v-if="quantity > 1" class="text-muted-foreground font-normal">
|
||||||
({{ quantity }} tickets)
|
({{ quantity }} tickets)
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
127
src/modules/events/composables/usePromoCode.ts
Normal file
127
src/modules/events/composables/usePromoCode.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||||
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
|
import { isUnsupportedEndpointError, normalizePromoCode } from '../lib/promoCodes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buyer-side promo state for the purchase dialog.
|
||||||
|
*
|
||||||
|
* - `idle` no code entered / cleared
|
||||||
|
* - `checking` validate request in flight
|
||||||
|
* - `applied` server confirmed the code; totals are authoritative
|
||||||
|
* - `invalid` server answered but the code is not valid for this
|
||||||
|
* event/quantity (unknown, inactive, exhausted, 0 %)
|
||||||
|
* - `unverified` older extension without the validate route (404/405)
|
||||||
|
* or a network failure: keep the code and let the
|
||||||
|
* purchase endpoint decide — it still discounts on
|
||||||
|
* every extension version.
|
||||||
|
*/
|
||||||
|
export type PromoState =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'checking'; code: string }
|
||||||
|
| {
|
||||||
|
status: 'applied'
|
||||||
|
code: string
|
||||||
|
discountPercent: number
|
||||||
|
subtotal: number
|
||||||
|
discount: number
|
||||||
|
total: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
| { status: 'invalid'; code: string }
|
||||||
|
| { status: 'unverified'; code: string }
|
||||||
|
|
||||||
|
const REVALIDATE_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
export function usePromoCode(eventId: MaybeRefOrGetter<string>, quantity: Ref<number>) {
|
||||||
|
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService
|
||||||
|
|
||||||
|
/** What the buyer typed (kept upper-case by the input handler). */
|
||||||
|
const input = ref('')
|
||||||
|
const state = ref<PromoState>({ status: 'idle' })
|
||||||
|
|
||||||
|
// Monotonic request counter so a slow response for a previous code or
|
||||||
|
// quantity can never overwrite a newer result.
|
||||||
|
let requestSeq = 0
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const isChecking = computed(() => state.value.status === 'checking')
|
||||||
|
|
||||||
|
/** Code to put on the purchase request. `invalid` deliberately sends
|
||||||
|
* nothing so the buyer is not surprised by a 400 at pay time. */
|
||||||
|
const codeToSend = computed<string | undefined>(() => {
|
||||||
|
const s = state.value
|
||||||
|
return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const appliedTotal = computed<number | null>(() =>
|
||||||
|
state.value.status === 'applied' ? state.value.total : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function validate(code: string): Promise<void> {
|
||||||
|
const seq = ++requestSeq
|
||||||
|
state.value = { status: 'checking', code }
|
||||||
|
try {
|
||||||
|
const totals = await ticketApi.validatePromoCode(toValue(eventId), code, quantity.value)
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
const hit = totals.discounts_applied.find((d) => d.code === code) ?? totals.discounts_applied[0]
|
||||||
|
if (!hit) {
|
||||||
|
state.value = { status: 'invalid', code }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.value = {
|
||||||
|
status: 'applied',
|
||||||
|
code: hit.code,
|
||||||
|
discountPercent: hit.discount_percent ?? 0,
|
||||||
|
subtotal: totals.subtotal,
|
||||||
|
discount: totals.discount,
|
||||||
|
total: totals.total,
|
||||||
|
currency: totals.currency,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
if (isUnsupportedEndpointError(err)) {
|
||||||
|
state.value = { status: 'unverified', code }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Network / 5xx: don't block the purchase on an advisory call.
|
||||||
|
console.warn('[usePromoCode] validate failed, sending code unverified:', err)
|
||||||
|
state.value = { status: 'unverified', code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply(): Promise<void> {
|
||||||
|
const code = normalizePromoCode(input.value)
|
||||||
|
if (!code) return
|
||||||
|
input.value = code
|
||||||
|
await validate(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
requestSeq++
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = null
|
||||||
|
input.value = ''
|
||||||
|
state.value = { status: 'idle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-check the current code (quantity changed, or the purchase
|
||||||
|
* endpoint rejected it — e.g. the last use went to someone else). */
|
||||||
|
async function revalidate(): Promise<void> {
|
||||||
|
const s = state.value
|
||||||
|
if (s.status === 'idle' || s.status === 'checking') return
|
||||||
|
await validate(s.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(quantity, () => {
|
||||||
|
const s = state.value
|
||||||
|
if (s.status === 'idle') return
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debounceTimer = null
|
||||||
|
void revalidate()
|
||||||
|
}, REVALIDATE_DEBOUNCE_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { input, state, isChecking, codeToSend, appliedTotal, apply, clear, revalidate }
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,8 @@ export interface PurchaseTicketOptions {
|
||||||
contact?: PurchaseContact
|
contact?: PurchaseContact
|
||||||
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */
|
/** Delivery email for a logged-in buyer (guests carry it in `contact`). */
|
||||||
email?: string
|
email?: string
|
||||||
|
/** Promo code to redeem (already normalised by the dialog). */
|
||||||
|
promoCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTicketPurchase() {
|
export function useTicketPurchase() {
|
||||||
|
|
@ -173,6 +175,7 @@ export function useTicketPurchase() {
|
||||||
accessToken: isGuest ? undefined : accessToken,
|
accessToken: isGuest ? undefined : accessToken,
|
||||||
frontendUrl: frontendRoot(),
|
frontendUrl: frontendRoot(),
|
||||||
quantity: options.quantity,
|
quantity: options.quantity,
|
||||||
|
promoCode: options.promoCode,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Free tickets (price 0 / 100%-off promo): the backend already
|
// Free tickets (price 0 / 100%-off promo): the backend already
|
||||||
|
|
@ -322,6 +325,10 @@ export function useTicketPurchase() {
|
||||||
handleOpenLightningWallet,
|
handleOpenLightningWallet,
|
||||||
resetPaymentState,
|
resetPaymentState,
|
||||||
cleanup,
|
cleanup,
|
||||||
generateTicketQRCode
|
generateTicketQRCode,
|
||||||
|
// Exposed so the dialog's fiat branch can land a 100 %-off purchase
|
||||||
|
// (backend returns `paid: true`, no checkout URL) on the same
|
||||||
|
// success screen as the Lightning / free paths.
|
||||||
|
finalizePurchasedTickets
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue