Merge pull request 'feat(events): promo codes — organizer editor and server-validated checkout discount' (#153) from feat/events-promo-codes into dev
Reviewed-on: #153
This commit is contained in:
commit
8b5baeb06d
17 changed files with 1001 additions and 34 deletions
|
|
@ -58,6 +58,27 @@ const messages: LocaleMessages = {
|
|||
events: {
|
||||
title: 'Events',
|
||||
createNew: 'Create Event',
|
||||
create: {
|
||||
promo: {
|
||||
title: 'Promo codes',
|
||||
hint: 'Percentage discounts buyers can enter at checkout. Codes are never shown publicly.',
|
||||
empty: 'No promo codes yet.',
|
||||
code: 'Code',
|
||||
codePlaceholder: 'EARLYBIRD',
|
||||
discount: 'Discount %',
|
||||
maxUses: 'Max uses',
|
||||
active: 'Active',
|
||||
used: 'Used {count}',
|
||||
add: 'Add code',
|
||||
removeLabel: 'Remove code {code}',
|
||||
errors: {
|
||||
required: 'Enter a code',
|
||||
duplicate: 'Duplicate code',
|
||||
percentRange: 'Between 1 and 100',
|
||||
maxUsesMin: 'Must be 1 or more',
|
||||
},
|
||||
},
|
||||
},
|
||||
noEvents: 'No events found',
|
||||
filters: {
|
||||
all: 'All',
|
||||
|
|
@ -137,6 +158,18 @@ const messages: LocaleMessages = {
|
|||
fiatFailed: 'Card checkout failed',
|
||||
loginRequiredTitle: 'Login required',
|
||||
loginRequiredFree: 'Free tickets need an account so seats cannot be claimed anonymously. Log in to get yours.',
|
||||
promo: {
|
||||
label: 'Promo code',
|
||||
placeholder: 'Enter code',
|
||||
apply: 'Apply',
|
||||
remove: 'Remove',
|
||||
checking: 'Checking…',
|
||||
applied: '{code} −{percent}%',
|
||||
discountLine: 'Discount ({code})',
|
||||
invalid: 'This code is not valid for this purchase.',
|
||||
unverified: 'Could not verify the code — it will be applied at checkout if valid.',
|
||||
loginRequiredDiscount: 'This code makes your ticket free. Free tickets need an account — log in to claim it.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Your details',
|
||||
name: 'Name',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,27 @@ const messages: LocaleMessages = {
|
|||
events: {
|
||||
title: 'Eventos',
|
||||
createNew: 'Crear evento',
|
||||
create: {
|
||||
promo: {
|
||||
title: 'Códigos promocionales',
|
||||
hint: 'Descuentos porcentuales que los compradores pueden introducir al pagar. Los códigos nunca se muestran públicamente.',
|
||||
empty: 'Aún no hay códigos promocionales.',
|
||||
code: 'Código',
|
||||
codePlaceholder: 'EARLYBIRD',
|
||||
discount: 'Descuento %',
|
||||
maxUses: 'Usos máx.',
|
||||
active: 'Activo',
|
||||
used: 'Usado {count}',
|
||||
add: 'Añadir código',
|
||||
removeLabel: 'Eliminar el código {code}',
|
||||
errors: {
|
||||
required: 'Introduce un código',
|
||||
duplicate: 'Código duplicado',
|
||||
percentRange: 'Entre 1 y 100',
|
||||
maxUsesMin: 'Debe ser 1 o más',
|
||||
},
|
||||
},
|
||||
},
|
||||
noEvents: 'No se encontraron eventos',
|
||||
filters: {
|
||||
all: 'Todas',
|
||||
|
|
@ -137,6 +158,18 @@ const messages: LocaleMessages = {
|
|||
fiatFailed: 'El pago con tarjeta falló',
|
||||
loginRequiredTitle: 'Inicio de sesión requerido',
|
||||
loginRequiredFree: 'Los boletos gratuitos requieren una cuenta para evitar reservas anónimas. Inicia sesión para obtener el tuyo.',
|
||||
promo: {
|
||||
label: 'Código promocional',
|
||||
placeholder: 'Introduce el código',
|
||||
apply: 'Aplicar',
|
||||
remove: 'Quitar',
|
||||
checking: 'Comprobando…',
|
||||
applied: '{code} −{percent}%',
|
||||
discountLine: 'Descuento ({code})',
|
||||
invalid: 'Este código no es válido para esta compra.',
|
||||
unverified: 'No se pudo verificar el código; se aplicará al pagar si es válido.',
|
||||
loginRequiredDiscount: 'Este código hace que tu boleto sea gratis. Los boletos gratuitos requieren una cuenta: inicia sesión para reclamarlo.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Tus datos',
|
||||
name: 'Nombre',
|
||||
|
|
|
|||
|
|
@ -58,6 +58,27 @@ const messages: LocaleMessages = {
|
|||
events: {
|
||||
title: 'Événements',
|
||||
createNew: 'Créer un événement',
|
||||
create: {
|
||||
promo: {
|
||||
title: 'Codes promo',
|
||||
hint: 'Remises en pourcentage que les acheteurs peuvent saisir au paiement. Les codes ne sont jamais affichés publiquement.',
|
||||
empty: 'Aucun code promo pour le moment.',
|
||||
code: 'Code',
|
||||
codePlaceholder: 'EARLYBIRD',
|
||||
discount: 'Remise %',
|
||||
maxUses: 'Utilisations max.',
|
||||
active: 'Actif',
|
||||
used: 'Utilisé {count}',
|
||||
add: 'Ajouter un code',
|
||||
removeLabel: 'Supprimer le code {code}',
|
||||
errors: {
|
||||
required: 'Saisissez un code',
|
||||
duplicate: 'Code en double',
|
||||
percentRange: 'Entre 1 et 100',
|
||||
maxUsesMin: 'Doit être 1 ou plus',
|
||||
},
|
||||
},
|
||||
},
|
||||
noEvents: 'Aucun événement trouvé',
|
||||
filters: {
|
||||
all: 'Tout',
|
||||
|
|
@ -137,6 +158,18 @@ const messages: LocaleMessages = {
|
|||
fiatFailed: 'Le paiement par carte a échoué',
|
||||
loginRequiredTitle: 'Connexion requise',
|
||||
loginRequiredFree: 'Les billets gratuits nécessitent un compte pour éviter les réservations anonymes. Connectez-vous pour obtenir le vôtre.',
|
||||
promo: {
|
||||
label: 'Code promo',
|
||||
placeholder: 'Saisir le code',
|
||||
apply: 'Appliquer',
|
||||
remove: 'Retirer',
|
||||
checking: 'Vérification…',
|
||||
applied: '{code} −{percent}%',
|
||||
discountLine: 'Remise ({code})',
|
||||
invalid: "Ce code n'est pas valable pour cet achat.",
|
||||
unverified: "Impossible de vérifier le code — il sera appliqué au paiement s'il est valable.",
|
||||
loginRequiredDiscount: 'Ce code rend votre billet gratuit. Les billets gratuits nécessitent un compte : connectez-vous pour le réclamer.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Vos coordonnées',
|
||||
name: 'Nom',
|
||||
|
|
|
|||
|
|
@ -59,6 +59,27 @@ export interface LocaleMessages {
|
|||
events?: {
|
||||
title: string
|
||||
createNew: string
|
||||
create: {
|
||||
promo: {
|
||||
title: string
|
||||
hint: string
|
||||
empty: string
|
||||
code: string
|
||||
codePlaceholder: string
|
||||
discount: string
|
||||
maxUses: string
|
||||
active: string
|
||||
used: string
|
||||
add: string
|
||||
removeLabel: string
|
||||
errors: {
|
||||
required: string
|
||||
duplicate: string
|
||||
percentRange: string
|
||||
maxUsesMin: string
|
||||
}
|
||||
}
|
||||
}
|
||||
noEvents: string
|
||||
filters: {
|
||||
all: string
|
||||
|
|
@ -112,6 +133,18 @@ export interface LocaleMessages {
|
|||
fiatFailed: string
|
||||
loginRequiredTitle: string
|
||||
loginRequiredFree: string
|
||||
promo: {
|
||||
label: string
|
||||
placeholder: string
|
||||
apply: string
|
||||
remove: string
|
||||
checking: string
|
||||
applied: string
|
||||
discountLine: string
|
||||
invalid: string
|
||||
unverified: string
|
||||
loginRequiredDiscount: string
|
||||
}
|
||||
contact: {
|
||||
title: string
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Bell, ChevronDown } from 'lucide-vue-next'
|
||||
import { Bell, ChevronDown, Tag } from 'lucide-vue-next'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -55,6 +55,14 @@ import type { ImageUploadService, UploadedImage } from '@/modules/base/services/
|
|||
import type { TicketApiService } from '../services/TicketApiService'
|
||||
import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
|
||||
import { ALL_CATEGORIES } from '../types/category'
|
||||
import PromoCodesEditor from './PromoCodesEditor.vue'
|
||||
import {
|
||||
fromWirePromoCodes,
|
||||
hasPromoErrors,
|
||||
toWirePromoCodes,
|
||||
validatePromoRows,
|
||||
type PromoCodeRow,
|
||||
} from '../lib/promoCodes'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
|
|
@ -252,6 +260,7 @@ function splitDateTime(value: string | null | undefined): { date: string; time:
|
|||
// edit-mode population with start-date side effects mid-setValues.
|
||||
const isPopulating = ref(false)
|
||||
const notificationsOpen = ref(false)
|
||||
const promoOpen = ref(false)
|
||||
|
||||
// Auto-mirror end date to start: when the user picks a start date,
|
||||
// surface that same date in the end-date picker so a one-day event
|
||||
|
|
@ -277,6 +286,10 @@ const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLO
|
|||
const availableCurrencies = ref<string[]>(['sat'])
|
||||
const loadingCurrencies = ref(false)
|
||||
const selectedCategories = ref<string[]>([])
|
||||
// Promo codes live outside vee-validate like categories: a plain list
|
||||
// with its own pure validator (`validatePromoRows`), gating submit below.
|
||||
const promoRows = ref<PromoCodeRow[]>([])
|
||||
const promoErrors = computed(() => validatePromoRows(promoRows.value))
|
||||
|
||||
async function populateFromEvent(event: TicketedEvent) {
|
||||
isPopulating.value = true
|
||||
|
|
@ -301,6 +314,10 @@ async function populateFromEvent(event: TicketedEvent) {
|
|||
notification_body: event.extra?.notification_body ?? '',
|
||||
})
|
||||
selectedCategories.value = [...(event.categories ?? [])]
|
||||
// Codes come only on the organizer-authenticated record (public
|
||||
// responses omit them) — useMyEvents / EventDetailPage hand us that one.
|
||||
promoRows.value = fromWirePromoCodes(event.extra?.promo_codes)
|
||||
promoOpen.value = promoRows.value.length > 0
|
||||
if (event.banner) {
|
||||
// Re-render the stored banner via its pict-rs file ID. delete_token
|
||||
// is intentionally empty: we don't own the original upload's token
|
||||
|
|
@ -339,11 +356,13 @@ watch(() => props.open, async (isOpen) => {
|
|||
}
|
||||
} else {
|
||||
selectedCategories.value = []
|
||||
promoRows.value = []
|
||||
promoOpen.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const { resetForm, meta } = form
|
||||
const isFormValid = computed(() => meta.value.valid)
|
||||
const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value))
|
||||
const isLoading = ref(false)
|
||||
|
||||
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
|
||||
|
|
@ -414,10 +433,11 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
|||
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
|
||||
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
|
||||
|
||||
// Notification config goes inside the `extra` envelope. On edit
|
||||
// overlay onto the existing event.extra so unrelated fields the
|
||||
// LNbits admin UI sets (promo_codes, conditional, min_tickets)
|
||||
// survive the round-trip.
|
||||
// Notification config + promo codes go inside the `extra` envelope.
|
||||
// On edit overlay onto the existing event.extra so fields only the
|
||||
// LNbits admin UI sets (conditional, min_tickets) survive the
|
||||
// round-trip. promo_codes is always sent explicitly: what the
|
||||
// organizer sees in the editor is the truth, an empty list clears.
|
||||
eventData.extra = {
|
||||
...(props.event?.extra ?? {}),
|
||||
payment_methods: formValues.payment_methods,
|
||||
|
|
@ -425,6 +445,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
|||
nostr_notifications: formValues.nostr_notifications,
|
||||
notification_subject: formValues.notification_subject,
|
||||
notification_body: formValues.notification_body,
|
||||
promo_codes: toWirePromoCodes(promoRows.value),
|
||||
}
|
||||
|
||||
if (isEditMode.value) {
|
||||
|
|
@ -455,6 +476,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
|
|||
|
||||
resetForm()
|
||||
selectedCategories.value = []
|
||||
promoRows.value = []
|
||||
bannerImages.value = []
|
||||
emit('update:open', false)
|
||||
} catch (error) {
|
||||
|
|
@ -474,6 +496,7 @@ const handleOpenChange = (open: boolean) => {
|
|||
if (!open && !isLoading.value) {
|
||||
resetForm()
|
||||
selectedCategories.value = []
|
||||
promoRows.value = []
|
||||
bannerImages.value = []
|
||||
}
|
||||
emit('update:open', open)
|
||||
|
|
@ -854,6 +877,27 @@ const handleOpenChange = (open: boolean) => {
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Promo codes (collapsible). Percent discounts with an
|
||||
optional redemption cap; the backend hides the list from
|
||||
public responses and enforces active/max_uses at purchase. -->
|
||||
<Collapsible v-model:open="promoOpen">
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Tag class="w-4 h-4" />
|
||||
{{ t('events.create.promo.title') }}
|
||||
<Badge v-if="promoRows.length > 0" variant="secondary" class="text-[10px] px-1.5">
|
||||
{{ promoRows.length }}
|
||||
</Badge>
|
||||
</span>
|
||||
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': promoOpen }" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="pt-2">
|
||||
<PromoCodesEditor v-model="promoRows" :disabled="isLoading" />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Actions. Stack full-width on narrow viewports so a wide
|
||||
localized "Submit Event" label can't push the Cancel
|
||||
button out of the dialog. Submit reads top-down on
|
||||
|
|
|
|||
153
src/modules/events/components/PromoCodesEditor.vue
Normal file
153
src/modules/events/components/PromoCodesEditor.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { normalizePromoCode, validatePromoRows, type PromoCodeRow } from '../lib/promoCodes'
|
||||
|
||||
/**
|
||||
* Organizer-side promo code editor: one row per code with percentage,
|
||||
* optional redemption cap and an active switch. Plain `v-model` over a
|
||||
* `PromoCodeRow[]` (the parent keeps it as a ref next to its vee-validate
|
||||
* form, the same way categories are handled); validation is the pure
|
||||
* `validatePromoRows` so the parent can gate submit on the same rules.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modelValue: PromoCodeRow[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [rows: PromoCodeRow[]] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const errors = computed(() => validatePromoRows(props.modelValue))
|
||||
|
||||
function update(index: number, patch: Partial<PromoCodeRow>) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||
)
|
||||
}
|
||||
|
||||
function add() {
|
||||
emit('update:modelValue', [
|
||||
...props.modelValue,
|
||||
{ code: '', discount_percent: null, max_uses: null, active: true },
|
||||
])
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
emit('update:modelValue', props.modelValue.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
/** Empty input → null (so "unset" never becomes 0); otherwise a number. */
|
||||
function toNumberOrNull(value: string | number): number | null {
|
||||
const str = String(value).trim()
|
||||
if (str === '') return null
|
||||
const n = Number(str)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-muted-foreground">{{ t('events.create.promo.hint') }}</p>
|
||||
|
||||
<p v-if="modelValue.length === 0" class="text-sm text-muted-foreground">
|
||||
{{ t('events.create.promo.empty') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="(row, index) in modelValue"
|
||||
:key="index"
|
||||
class="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_5.5rem_5.5rem] gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label :for="`promo-code-${index}`" class="text-xs">{{ t('events.create.promo.code') }}</Label>
|
||||
<Input
|
||||
:id="`promo-code-${index}`"
|
||||
:model-value="row.code"
|
||||
:placeholder="t('events.create.promo.codePlaceholder')"
|
||||
class="font-mono uppercase"
|
||||
autocapitalize="characters"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { code: normalizePromoCode(String($event)) })"
|
||||
/>
|
||||
<p v-if="errors[index]?.code" class="text-xs text-destructive">
|
||||
{{ t(errors[index].code!) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label :for="`promo-pct-${index}`" class="text-xs">{{ t('events.create.promo.discount') }}</Label>
|
||||
<Input
|
||||
:id="`promo-pct-${index}`"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
:model-value="row.discount_percent ?? ''"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { discount_percent: toNumberOrNull($event) })"
|
||||
/>
|
||||
<p v-if="errors[index]?.discount_percent" class="text-xs text-destructive">
|
||||
{{ t(errors[index].discount_percent!) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label :for="`promo-max-${index}`" class="text-xs">{{ t('events.create.promo.maxUses') }}</Label>
|
||||
<Input
|
||||
:id="`promo-max-${index}`"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
:model-value="row.max_uses ?? ''"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { max_uses: toNumberOrNull($event) })"
|
||||
/>
|
||||
<p v-if="errors[index]?.max_uses" class="text-xs text-destructive">
|
||||
{{ t(errors[index].max_uses!) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:id="`promo-active-${index}`"
|
||||
:model-value="row.active"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { active: Boolean($event) })"
|
||||
/>
|
||||
<Label :for="`promo-active-${index}`" class="text-xs">{{ t('events.create.promo.active') }}</Label>
|
||||
<span v-if="(row.used_count ?? 0) > 0" class="text-xs text-muted-foreground">
|
||||
· {{ t('events.create.promo.used', { count: row.used_count }) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('events.create.promo.removeLabel', { code: row.code || '…' })"
|
||||
@click="remove(index)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" class="gap-1.5" :disabled="disabled" @click="add">
|
||||
<Plus class="w-4 h-4" />
|
||||
{{ t('events.create.promo.add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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' }}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span v-if="quantity > 1">
|
||||
{{ quantity }} tickets for <strong>{{ event.name }}</strong> ·
|
||||
{{ isFree ? 'Free' : formatEventPrice(totalPrice, event.currency) }}
|
||||
</span>
|
||||
<span v-else-if="isFree">
|
||||
Get a free ticket for <strong>{{ event.name }}</strong>
|
||||
<span v-if="isFree">
|
||||
<template v-if="quantity > 1">{{ quantity }} free tickets for</template>
|
||||
<template v-else>Get a free ticket for</template>
|
||||
<strong>{{ event.name }}</strong>
|
||||
</span>
|
||||
<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>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -445,8 +530,71 @@ onUnmounted(() => {
|
|||
</FormField>
|
||||
</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) -->
|
||||
<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">
|
||||
<Wallet class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">Wallet Status:</span>
|
||||
|
|
@ -520,11 +668,22 @@ onUnmounted(() => {
|
|||
<span class="text-sm text-muted-foreground">
|
||||
{{ quantity > 1 && !isFree ? `${quantity} × ${formatEventPrice(event.price_per_ticket, event.currency)}` : 'Price' }}
|
||||
</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>
|
||||
<PriceConversionPreview
|
||||
v-if="canChooseFiat && isPriceInSats && event.fiat_currency"
|
||||
:amount="totalPrice"
|
||||
:amount="effectiveTotal"
|
||||
from="sat"
|
||||
:to="event.fiat_currency"
|
||||
prefix="Equivalent ~"
|
||||
|
|
@ -536,7 +695,7 @@ onUnmounted(() => {
|
|||
<!-- Payment method selector — one button per enabled rail /
|
||||
configured fiat provider. Hidden when only one rail is on
|
||||
(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>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Both methods charge the same amount via different rails.
|
||||
|
|
@ -556,11 +715,11 @@ onUnmounted(() => {
|
|||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isLoading || isFiatPending || paymentMethods.length === 0 && !isFree"
|
||||
:disabled="isLoading || isFiatPending || promoChecking || guestNeedsLoginForDiscount || (paymentMethods.length === 0 && !isZeroTotal)"
|
||||
class="w-full"
|
||||
>
|
||||
<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" />
|
||||
{{ quantity > 1 ? `Get ${quantity} tickets` : 'Get ticket' }}
|
||||
</template>
|
||||
|
|
@ -573,7 +732,7 @@ onUnmounted(() => {
|
|||
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
|
||||
</template>
|
||||
</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') }}
|
||||
</p>
|
||||
</form>
|
||||
|
|
@ -612,7 +771,7 @@ onUnmounted(() => {
|
|||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-muted-foreground">Amount</span>
|
||||
<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">
|
||||
({{ quantity }} tickets)
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ export function useMyEvents() {
|
|||
// and merge them into the feed. Otherwise an event that drops to
|
||||
// `proposed` after a non-admin edit disappears from the user's view
|
||||
// entirely — they'd be unable to find it to make a follow-up edit or
|
||||
// monitor its approval status. Public approved events from other
|
||||
// users take precedence on dedup (server is the source of truth for
|
||||
// the public view).
|
||||
// monitor its approval status. For the user's OWN events the
|
||||
// authenticated record wins on dedup: it is the full `Event` (incl.
|
||||
// `extra.promo_codes`, which public responses omit), and it is what
|
||||
// the edit dialog must prefill from.
|
||||
const fetchAll = async (): Promise<TicketedEvent[]> => {
|
||||
const publicEvents = (await ticketApi.fetchTicketedEvents()) as TicketedEvent[]
|
||||
|
||||
|
|
@ -26,9 +27,11 @@ export function useMyEvents() {
|
|||
|
||||
try {
|
||||
const myEvents = (await ticketApi.fetchMyEvents(invoiceKey)) as TicketedEvent[]
|
||||
const seen = new Set(publicEvents.map((e) => e.id))
|
||||
const own = myEvents.filter((e) => !seen.has(e.id))
|
||||
return [...publicEvents, ...own]
|
||||
const own = new Map(myEvents.map((e) => [e.id, e]))
|
||||
const merged = publicEvents.map((e) => own.get(e.id) ?? e)
|
||||
const publicIds = new Set(publicEvents.map((e) => e.id))
|
||||
const ownOnly = myEvents.filter((e) => !publicIds.has(e.id))
|
||||
return [...merged, ...ownOnly]
|
||||
} catch (err) {
|
||||
// Falling back to just the public feed is acceptable — the user
|
||||
// can still browse, they just won't see their own pending events.
|
||||
|
|
|
|||
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
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ export interface EventTicket {
|
|||
name?: string | null
|
||||
registered: boolean
|
||||
registeredAt: string | null
|
||||
/** Promo code redeemed on this ticket, when the extension reports it. */
|
||||
promoCode?: string | null
|
||||
}
|
||||
|
||||
/** Counts + roster snapshot for the event, sourced from the backend. */
|
||||
|
|
@ -110,6 +112,7 @@ export function useTicketScanner(eventId: Ref<string>) {
|
|||
name: t.name ?? null,
|
||||
registered: t.registered,
|
||||
registeredAt: t.registered_at,
|
||||
promoCode: t.applied_promo_code ?? null,
|
||||
})),
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
93
src/modules/events/lib/promoCodes.spec.ts
Normal file
93
src/modules/events/lib/promoCodes.spec.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyPercent,
|
||||
fromWirePromoCodes,
|
||||
hasPromoErrors,
|
||||
isUnsupportedEndpointError,
|
||||
normalizePromoCode,
|
||||
toWirePromoCodes,
|
||||
validatePromoRows,
|
||||
type PromoCodeRow,
|
||||
} from './promoCodes'
|
||||
|
||||
function row(overrides: Partial<PromoCodeRow> = {}): PromoCodeRow {
|
||||
return { code: 'HALF', discount_percent: 50, max_uses: null, active: true, ...overrides }
|
||||
}
|
||||
|
||||
describe('normalizePromoCode', () => {
|
||||
it('trims and upper-cases, and tolerates null', () => {
|
||||
expect(normalizePromoCode(' summer24 ')).toBe('SUMMER24')
|
||||
expect(normalizePromoCode(null)).toBe('')
|
||||
expect(normalizePromoCode(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePromoRows', () => {
|
||||
it('accepts a well-formed row', () => {
|
||||
const errors = validatePromoRows([row()])
|
||||
expect(errors).toEqual([{}])
|
||||
expect(hasPromoErrors(errors)).toBe(false)
|
||||
})
|
||||
|
||||
it('flags an empty code', () => {
|
||||
expect(validatePromoRows([row({ code: ' ' })])[0].code).toBe('events.create.promo.errors.required')
|
||||
})
|
||||
|
||||
it('flags duplicates case-insensitively on both rows', () => {
|
||||
const errors = validatePromoRows([row({ code: 'half' }), row({ code: ' HALF ' })])
|
||||
expect(errors[0].code).toBe('events.create.promo.errors.duplicate')
|
||||
expect(errors[1].code).toBe('events.create.promo.errors.duplicate')
|
||||
expect(hasPromoErrors(errors)).toBe(true)
|
||||
})
|
||||
|
||||
it('requires the percentage to be within 1..100', () => {
|
||||
for (const pct of [null, 0, 101, Number.NaN]) {
|
||||
expect(validatePromoRows([row({ discount_percent: pct })])[0].discount_percent)
|
||||
.toBe('events.create.promo.errors.percentRange')
|
||||
}
|
||||
expect(validatePromoRows([row({ discount_percent: 100 })])[0].discount_percent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows blank max uses and rejects zero / fractions', () => {
|
||||
expect(validatePromoRows([row({ max_uses: null })])[0].max_uses).toBeUndefined()
|
||||
expect(validatePromoRows([row({ max_uses: 3 })])[0].max_uses).toBeUndefined()
|
||||
expect(validatePromoRows([row({ max_uses: 0 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin')
|
||||
expect(validatePromoRows([row({ max_uses: 1.5 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toWirePromoCodes / fromWirePromoCodes', () => {
|
||||
it('normalises codes, drops used_count and maps blank max uses to null', () => {
|
||||
expect(toWirePromoCodes([row({ code: ' half ', used_count: 4 }), row({ code: 'CAP', max_uses: 2 })])).toEqual([
|
||||
{ code: 'HALF', discount_percent: 50, active: true, max_uses: null },
|
||||
{ code: 'CAP', discount_percent: 50, active: true, max_uses: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips the wire shape and tolerates the older three-field shape', () => {
|
||||
expect(fromWirePromoCodes([{ code: 'OLD', discount_percent: 20, active: false }])).toEqual([
|
||||
{ code: 'OLD', discount_percent: 20, max_uses: null, active: false, used_count: 0 },
|
||||
])
|
||||
expect(fromWirePromoCodes(undefined)).toEqual([])
|
||||
expect(fromWirePromoCodes([{ code: 'CAP', discount_percent: 10, active: true, max_uses: 0, used_count: 2 }]))
|
||||
.toEqual([{ code: 'CAP', discount_percent: 10, max_uses: null, active: true, used_count: 2 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPercent', () => {
|
||||
it('rounds sats down to whole units and fiat to two decimals', () => {
|
||||
expect(applyPercent(333, 15, 'sat')).toBe(283)
|
||||
expect(applyPercent(59.97, 33, 'EUR')).toBe(40.18)
|
||||
expect(applyPercent(1000, 100, 'sats')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUnsupportedEndpointError', () => {
|
||||
it('recognises 404/405 and nothing else', () => {
|
||||
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 404 }))).toBe(true)
|
||||
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 405 }))).toBe(true)
|
||||
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 400 }))).toBe(false)
|
||||
expect(isUnsupportedEndpointError(new Error('x'))).toBe(false)
|
||||
expect(isUnsupportedEndpointError(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
125
src/modules/events/lib/promoCodes.ts
Normal file
125
src/modules/events/lib/promoCodes.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { PromoCode } from '../types/ticket'
|
||||
|
||||
/**
|
||||
* Promo-code helpers shared by the organizer editor and the buyer
|
||||
* dialog. Pure functions, no Vue — mirror `promo.py` in the events
|
||||
* extension by name so the two sides stay recognisably in step:
|
||||
*
|
||||
* normalizePromoCode ↔ promo.normalize_code
|
||||
* applyPercent ↔ promo.round_amount / basket_totals (display fallback only)
|
||||
*
|
||||
* The server's `POST /events/api/v1/promo/validate/{event_id}` is the
|
||||
* source of truth for the discounted total; `applyPercent` exists so
|
||||
* the UI can still show *something* against an older extension.
|
||||
*/
|
||||
|
||||
/** One editor row. `discount_percent` / `max_uses` are `null` while the
|
||||
* organizer is still typing (empty inputs), never `0`-as-empty. */
|
||||
export interface PromoCodeRow {
|
||||
code: string
|
||||
discount_percent: number | null
|
||||
/** `null` = unlimited (backend `max_uses: None`). */
|
||||
max_uses: number | null
|
||||
active: boolean
|
||||
/** Derived by the backend from paid tickets; read-only in the editor. */
|
||||
used_count?: number
|
||||
}
|
||||
|
||||
/** Per-row validation messages as i18n keys (the component runs them
|
||||
* through `t()`); a missing field means the field is fine. */
|
||||
export interface PromoRowErrors {
|
||||
code?: string
|
||||
discount_percent?: string
|
||||
max_uses?: string
|
||||
}
|
||||
|
||||
const ERROR_KEYS = {
|
||||
required: 'events.create.promo.errors.required',
|
||||
duplicate: 'events.create.promo.errors.duplicate',
|
||||
percentRange: 'events.create.promo.errors.percentRange',
|
||||
maxUsesMin: 'events.create.promo.errors.maxUsesMin',
|
||||
} as const
|
||||
|
||||
/** Trim + upper-case, the same normalisation the backend applies before
|
||||
* matching, so what the buyer sees in the input is what gets matched. */
|
||||
export function normalizePromoCode(raw: string | null | undefined): string {
|
||||
return (raw ?? '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor rules — stricter than the backend on purpose: the backend
|
||||
* accepts 0 % (a no-op the buyer would see as "code not valid"), the
|
||||
* editor requires 1–100 so an organizer cannot create a dud by accident.
|
||||
*/
|
||||
export function validatePromoRows(rows: PromoCodeRow[]): PromoRowErrors[] {
|
||||
const seen = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
const code = normalizePromoCode(row.code)
|
||||
if (code) seen.set(code, (seen.get(code) ?? 0) + 1)
|
||||
}
|
||||
return rows.map((row) => {
|
||||
const errors: PromoRowErrors = {}
|
||||
const code = normalizePromoCode(row.code)
|
||||
if (!code) {
|
||||
errors.code = ERROR_KEYS.required
|
||||
} else if ((seen.get(code) ?? 0) > 1) {
|
||||
errors.code = ERROR_KEYS.duplicate
|
||||
}
|
||||
const pct = row.discount_percent
|
||||
if (pct === null || !Number.isFinite(pct) || pct < 1 || pct > 100) {
|
||||
errors.discount_percent = ERROR_KEYS.percentRange
|
||||
}
|
||||
const max = row.max_uses
|
||||
if (max !== null && (!Number.isInteger(max) || max < 1)) {
|
||||
errors.max_uses = ERROR_KEYS.maxUsesMin
|
||||
}
|
||||
return errors
|
||||
})
|
||||
}
|
||||
|
||||
export function hasPromoErrors(errors: PromoRowErrors[]): boolean {
|
||||
return errors.some((e) => Object.keys(e).length > 0)
|
||||
}
|
||||
|
||||
/** Editor rows → wire `extra.promo_codes`. Drops the read-only
|
||||
* `used_count` (the backend recomputes it) and blank max-uses → `null`. */
|
||||
export function toWirePromoCodes(rows: PromoCodeRow[]): PromoCode[] {
|
||||
return rows.map((row) => ({
|
||||
code: normalizePromoCode(row.code),
|
||||
discount_percent: row.discount_percent ?? 0,
|
||||
active: row.active,
|
||||
max_uses: row.max_uses && row.max_uses > 0 ? Math.trunc(row.max_uses) : null,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Wire `extra.promo_codes` → editor rows. Tolerates the pre-`max_uses`
|
||||
* shape (older extension) and a missing list (public records). */
|
||||
export function fromWirePromoCodes(codes: PromoCode[] | null | undefined): PromoCodeRow[] {
|
||||
return (codes ?? []).map((c) => ({
|
||||
code: c.code,
|
||||
discount_percent: c.discount_percent ?? 0,
|
||||
max_uses: c.max_uses && c.max_uses > 0 ? c.max_uses : null,
|
||||
active: c.active ?? true,
|
||||
used_count: c.used_count ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only fallback for the discounted total when the validate
|
||||
* endpoint is unavailable. Rounds like the backend: whole sats, two
|
||||
* decimals for fiat.
|
||||
*/
|
||||
export function applyPercent(subtotal: number, percent: number, currency: string): number {
|
||||
const raw = subtotal * (1 - percent / 100)
|
||||
const unit = currency.toLowerCase()
|
||||
if (unit === 'sat' || unit === 'sats') return Math.max(0, Math.trunc(raw))
|
||||
return Math.max(0, Math.round(raw * 100) / 100)
|
||||
}
|
||||
|
||||
/** An older extension without the validate route answers 404 (or 405
|
||||
* when the path exists for another method). Anything else is a real
|
||||
* error. */
|
||||
export function isUnsupportedEndpointError(err: unknown): boolean {
|
||||
const status = (err as { status?: unknown } | null)?.status
|
||||
return status === 404 || status === 405
|
||||
}
|
||||
|
|
@ -90,3 +90,57 @@ describe('TicketApiService.getTicket / resendTicketEmail', () => {
|
|||
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TicketApiService promo codes', () => {
|
||||
const fetchMock = vi.fn()
|
||||
let api: TicketApiService
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function lastCall() {
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
|
||||
}
|
||||
|
||||
it('validatePromoCode posts the v2 shape with an upper-cased code', async () => {
|
||||
const totals = {
|
||||
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat',
|
||||
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }],
|
||||
}
|
||||
fetchMock.mockResolvedValue(okJson(totals))
|
||||
const res = await api.validatePromoCode('evt', ' half ', 2)
|
||||
const { url, init, body } = lastCall()
|
||||
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`)
|
||||
expect(init.method).toBe('POST')
|
||||
expect(body).toEqual({ codes: ['HALF'], quantity: 2 })
|
||||
expect(res).toEqual(totals)
|
||||
})
|
||||
|
||||
it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response)
|
||||
await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 })
|
||||
})
|
||||
|
||||
it('requestTicket forwards promo_code verbatim', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
|
||||
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 })
|
||||
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 })
|
||||
})
|
||||
|
||||
it('createEvent sends extra.promo_codes verbatim', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'evt' }))
|
||||
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }]
|
||||
await api.createEvent(
|
||||
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } },
|
||||
'inkey',
|
||||
)
|
||||
const { body, headers } = lastCall()
|
||||
expect(body.extra.promo_codes).toEqual(promo_codes)
|
||||
expect(headers['X-API-KEY']).toBe('inkey')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type {
|
||||
BasketTotals,
|
||||
EventTicket,
|
||||
EventTicketExtra,
|
||||
CreateTicketRequest,
|
||||
|
|
@ -9,7 +10,9 @@ import type {
|
|||
TicketResendResult,
|
||||
TicketedEvent,
|
||||
CreateEventRequest,
|
||||
PromoValidateRequest,
|
||||
} from '../types/ticket'
|
||||
import { normalizePromoCode } from '../lib/promoCodes'
|
||||
|
||||
export interface TicketApiConfig {
|
||||
baseUrl: string
|
||||
|
|
@ -335,6 +338,8 @@ export class TicketApiService {
|
|||
name?: string | null
|
||||
registered: boolean
|
||||
registered_at: string | null
|
||||
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
|
||||
applied_promo_code?: string | null
|
||||
}>
|
||||
}> {
|
||||
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
|
||||
|
|
@ -343,6 +348,22 @@ export class TicketApiService {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview what a promo code does to a purchase of `quantity` tickets
|
||||
* (`POST /events/api/v1/promo/validate/{event_id}`, anonymous). The
|
||||
* backend applies the first valid code; an empty `discounts_applied`
|
||||
* means "not valid". Older extensions 404 — the thrown error carries
|
||||
* `status` so callers can fall back to "applied at checkout".
|
||||
*/
|
||||
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> {
|
||||
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity }
|
||||
return this.request(`/events/api/v1/promo/validate/${eventId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a paid ticket as registered at the door. Organizer-only —
|
||||
* requires the event-owning wallet's admin_key. Backend rejects
|
||||
|
|
@ -441,7 +462,9 @@ export class TicketApiService {
|
|||
: Array.isArray(error.detail)
|
||||
? error.detail[0]?.msg ?? 'Request failed'
|
||||
: 'Request failed'
|
||||
throw new Error(errorMessage)
|
||||
// Carry the HTTP status so callers can tell "route missing on an
|
||||
// older extension" (404) apart from "bad input" (400).
|
||||
throw Object.assign(new Error(errorMessage), { status: response.status })
|
||||
}
|
||||
|
||||
return response.json()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,38 @@ export interface PromoCode {
|
|||
code: string
|
||||
discount_percent: number
|
||||
active: boolean
|
||||
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
|
||||
* same name as upstream v2). */
|
||||
max_uses?: number | null
|
||||
/** Paid tickets that used this code — derived by the backend on read,
|
||||
* never sent back. */
|
||||
used_count?: number
|
||||
}
|
||||
|
||||
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
|
||||
* v2 shape; `quantity` stands in for v2's `items` since this fork has
|
||||
* no ticket types). */
|
||||
export interface PromoValidateRequest {
|
||||
codes: string[]
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface BasketDiscount {
|
||||
code: string
|
||||
discount_percent: number | null
|
||||
/** Always null on this fork (percent-only); kept for the v2 shape. */
|
||||
discount_fixed: number | null
|
||||
amount_saved: number
|
||||
}
|
||||
|
||||
/** Response of the promo validate endpoint. An empty `discounts_applied`
|
||||
* means none of the submitted codes is valid for this event/quantity. */
|
||||
export interface BasketTotals {
|
||||
subtotal: number
|
||||
discount: number
|
||||
total: number
|
||||
discounts_applied: BasketDiscount[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,7 +51,10 @@ export interface PromoCode {
|
|||
* in upstream v1.4.0 (PR #50) and v1.6.0.
|
||||
*/
|
||||
export interface EventExtra {
|
||||
promo_codes: PromoCode[]
|
||||
/** Present on organizer-authenticated records only: public event
|
||||
* responses (`/events/public`, `/events/{id}`) no longer carry the
|
||||
* code list (events ext >= v1.6.1-aio.12). */
|
||||
promo_codes?: PromoCode[]
|
||||
conditional: boolean
|
||||
min_tickets: number
|
||||
email_notifications: boolean
|
||||
|
|
|
|||
|
|
@ -104,8 +104,9 @@ const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
|
|||
{
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.7 },
|
||||
{ name: 'id', weight: 0.3 },
|
||||
{ name: 'name', weight: 0.6 },
|
||||
{ name: 'id', weight: 0.25 },
|
||||
{ name: 'promoCode', weight: 0.15 },
|
||||
],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
|
|
@ -296,6 +297,14 @@ function fmtTime(iso: string) {
|
|||
<span v-if="ticket.name" class="font-medium text-foreground">
|
||||
{{ ticket.name }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="ticket.promoCode"
|
||||
variant="outline"
|
||||
class="text-[10px] font-mono px-1.5"
|
||||
:title="ticket.promoCode"
|
||||
>
|
||||
{{ ticket.promoCode }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||
{{ ticket.id }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue