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' }}
{{ formatEventPrice(totalPrice, event.currency) }}
+
+ {{ isZeroTotal ? 'Free' : formatEventPrice(effectiveTotal, event.currency) }}
+
+ {{ t('events.purchase.promo.invalid') }} +
++ {{ t('events.purchase.promo.unverified') }} +
+ +Both methods charge the same amount via different rails. @@ -556,11 +715,11 @@ onUnmounted(() => { -
+
{{ t('events.purchase.fiatRedirectHint') }}
@@ -612,7 +771,7 @@ onUnmounted(() => {