feat(events): promo codes — organizer editor and server-validated checkout discount #153

Merged
padreug merged 6 commits from feat/events-promo-codes into dev 2026-09-13 16:55:15 +00:00
2 changed files with 203 additions and 6 deletions
Showing only changes of commit 4162e0af99 - Show all commits

feat(events): promo codes editor in the create/edit event dialog

Collapsible "Promo codes" section: rows of code (upper-cased as typed),
discount %, max uses (blank = unlimited), active switch, remove; per-row
errors from validatePromoRows gate submit alongside the vee-validate form.
extra.promo_codes is always sent explicitly (empty editor = clear); edit
mode prefills from the authenticated record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
Padreug 2026-09-10 12:22:13 +02:00

View file

@ -26,7 +26,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' 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 { ScrollArea } from '@/components/ui/scroll-area'
import { import {
Select, Select,
@ -55,6 +55,14 @@ import type { ImageUploadService, UploadedImage } from '@/modules/base/services/
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest, TicketedEvent } from '../types/ticket' import type { CreateEventRequest, TicketedEvent } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category' import { ALL_CATEGORIES } from '../types/category'
import PromoCodesEditor from './PromoCodesEditor.vue'
import {
fromWirePromoCodes,
hasPromoErrors,
toWirePromoCodes,
validatePromoRows,
type PromoCodeRow,
} from '../lib/promoCodes'
interface Props { interface Props {
open: boolean 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. // edit-mode population with start-date side effects mid-setValues.
const isPopulating = ref(false) const isPopulating = ref(false)
const notificationsOpen = ref(false) const notificationsOpen = ref(false)
const promoOpen = ref(false)
// Auto-mirror end date to start: when the user picks a start date, // 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 // 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 availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false) const loadingCurrencies = ref(false)
const selectedCategories = ref<string[]>([]) 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) { async function populateFromEvent(event: TicketedEvent) {
isPopulating.value = true isPopulating.value = true
@ -301,6 +314,10 @@ async function populateFromEvent(event: TicketedEvent) {
notification_body: event.extra?.notification_body ?? '', notification_body: event.extra?.notification_body ?? '',
}) })
selectedCategories.value = [...(event.categories ?? [])] 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) { if (event.banner) {
// Re-render the stored banner via its pict-rs file ID. delete_token // 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 // is intentionally empty: we don't own the original upload's token
@ -339,11 +356,13 @@ watch(() => props.open, async (isOpen) => {
} }
} else { } else {
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
promoOpen.value = false
} }
}) })
const { resetForm, meta } = form const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid) const isFormValid = computed(() => meta.value.valid && !hasPromoErrors(promoErrors.value))
const isLoading = ref(false) const isLoading = ref(false)
const today = computed(() => format(new Date(), 'yyyy-MM-dd')) 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 (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
// Notification config goes inside the `extra` envelope. On edit // Notification config + promo codes go inside the `extra` envelope.
// overlay onto the existing event.extra so unrelated fields the // On edit overlay onto the existing event.extra so fields only the
// LNbits admin UI sets (promo_codes, conditional, min_tickets) // LNbits admin UI sets (conditional, min_tickets) survive the
// survive the round-trip. // round-trip. promo_codes is always sent explicitly: what the
// organizer sees in the editor is the truth, an empty list clears.
eventData.extra = { eventData.extra = {
...(props.event?.extra ?? {}), ...(props.event?.extra ?? {}),
payment_methods: formValues.payment_methods, payment_methods: formValues.payment_methods,
@ -425,6 +445,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
nostr_notifications: formValues.nostr_notifications, nostr_notifications: formValues.nostr_notifications,
notification_subject: formValues.notification_subject, notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body, notification_body: formValues.notification_body,
promo_codes: toWirePromoCodes(promoRows.value),
} }
if (isEditMode.value) { if (isEditMode.value) {
@ -455,6 +476,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
resetForm() resetForm()
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
bannerImages.value = [] bannerImages.value = []
emit('update:open', false) emit('update:open', false)
} catch (error) { } catch (error) {
@ -474,6 +496,7 @@ const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) { if (!open && !isLoading.value) {
resetForm() resetForm()
selectedCategories.value = [] selectedCategories.value = []
promoRows.value = []
bannerImages.value = [] bannerImages.value = []
} }
emit('update:open', open) emit('update:open', open)
@ -854,6 +877,27 @@ const handleOpenChange = (open: boolean) => {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </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 <!-- Actions. Stack full-width on narrow viewports so a wide
localized "Submit Event" label can't push the Cancel localized "Submit Event" label can't push the Cancel
button out of the dialog. Submit reads top-down on button out of the dialog. Submit reads top-down on

View 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>