diff --git a/src/modules/events/components/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue index b3e6209..6726b20 100644 --- a/src/modules/events/components/PurchaseTicketDialog.vue +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' +import { Alert, AlertDescription } from '@/components/ui/alert' import { FormControl, FormDescription, @@ -17,10 +18,12 @@ import { FormMessage, } from '@/components/ui/form' import { useTicketPurchase } from '../composables/useTicketPurchase' +import { usePromoCode } from '../composables/usePromoCode' +import { normalizePromoCode } from '../lib/promoCodes' 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, 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 PaymentMethodSelector, { type PaymentMethod as PaymentMethodEntry, @@ -74,7 +77,8 @@ const { cleanup, purchasedTicketIds, ticketQRCodes, - showTicketQR + showTicketQR, + finalizePurchasedTickets } = useTicketPurchase() const MAX_QUANTITY = 10 @@ -100,6 +104,47 @@ const isFree = computed(() => props.event.price_per_ticket <= 0) // capacity (events#29). 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() { if (!paymentRequest.value) return try { @@ -154,6 +199,7 @@ watch( form.resetForm({ values: { name: '', email: currentUser.value?.email ?? '', confirmEmail: '' }, }) + promo.clear() }, { immediate: true }, ) @@ -273,6 +319,27 @@ async function handlePurchase(contact: { name?: string; email: string }) { const isGuest = !isAuthenticated.value 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. // 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, contact: guestContact, email: contact.email, + promoCode, }) } catch (err) { console.error('Error purchasing ticket:', err) + if (isPromoError(err)) void promo.revalidate() } return } @@ -308,7 +377,17 @@ async function handlePurchase(contact: { name?: string; email: string }) { paymentMethod: 'fiat', fiatProvider: method.provider, 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) { fiatError.value = t('events.purchase.noCheckoutUrl') isFiatPending.value = false @@ -332,12 +411,14 @@ async function handlePurchase(contact: { name?: string; email: string }) { } catch (err) { fiatError.value = err instanceof Error ? err.message : t('events.purchase.fiatFailed') isFiatPending.value = false + if (isPromoError(err)) void promo.revalidate() } } function handleClose() { emit('update:isOpen', false) resetPaymentState() + promo.clear() selectedMethodId.value = '' fiatError.value = null isFiatPending.value = false @@ -359,15 +440,19 @@ onUnmounted(() => { {{ quantity > 1 ? `Purchase ${quantity} tickets` : 'Purchase ticket' }} - - {{ quantity }} tickets for {{ event.name }} · - {{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }} - - - Get a free ticket for {{ event.name }} + + + + {{ event.name }} - Purchase a ticket for {{ event.name }} for {{ formatEventPrice(event.price_per_ticket, event.currency) }} + + + {{ event.name }} · + {{ formatEventPrice(totalPrice, event.currency) }} + + {{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }} + @@ -445,8 +530,71 @@ onUnmounted(() => { + +
+
+ + {{ t('events.purchase.promo.label') }} +
+ +
+ + + + + +
+
+ + +
+ +

+ {{ t('events.purchase.promo.invalid') }} +

+

+ {{ t('events.purchase.promo.unverified') }} +

+ + + + + {{ t('events.purchase.promo.loginRequiredDiscount') }} + + + +
+ -
+
Wallet Status: @@ -520,11 +668,22 @@ onUnmounted(() => { {{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }} - {{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }} + + + + + +
+
+ {{ t('events.purchase.promo.discountLine', { code: promoState.code }) }} + −{{ formatEventPrice(promoDiscountAmount, event.currency) }}
{ -
+
Payment method

Both methods charge the same amount via different rails. @@ -556,11 +715,11 @@ onUnmounted(() => { -

+

{{ t('events.purchase.fiatRedirectHint') }}

@@ -612,7 +771,7 @@ onUnmounted(() => {
Amount - {{ formatEventPrice(totalPrice, event.currency) }} + {{ formatEventPrice(effectiveTotal, event.currency) }} ({{ quantity }} tickets) diff --git a/src/modules/events/composables/usePromoCode.ts b/src/modules/events/composables/usePromoCode.ts new file mode 100644 index 0000000..0642fe7 --- /dev/null +++ b/src/modules/events/composables/usePromoCode.ts @@ -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, quantity: Ref) { + 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({ 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 | 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(() => { + const s = state.value + return s.status === 'applied' || s.status === 'unverified' ? s.code : undefined + }) + + const appliedTotal = computed(() => + state.value.status === 'applied' ? state.value.total : null, + ) + + async function validate(code: string): Promise { + 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 { + 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 { + 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 } +} diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts index ea763d1..5854497 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -21,6 +21,8 @@ export interface PurchaseTicketOptions { contact?: PurchaseContact /** Delivery email for a logged-in buyer (guests carry it in `contact`). */ email?: string + /** Promo code to redeem (already normalised by the dialog). */ + promoCode?: string } export function useTicketPurchase() { @@ -173,6 +175,7 @@ export function useTicketPurchase() { accessToken: isGuest ? undefined : accessToken, frontendUrl: frontendRoot(), quantity: options.quantity, + promoCode: options.promoCode, }) // Free tickets (price 0 / 100%-off promo): the backend already @@ -322,6 +325,10 @@ export function useTicketPurchase() { handleOpenLightningWallet, resetPaymentState, 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 } }