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
This commit is contained in:
parent
262e674f9f
commit
4162e0af99
2 changed files with 203 additions and 6 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue