feat: simplify event creation form, add location + categories

- Only title and start date are required
- Description, location, categories, image, tickets visible by default
- End date and promo codes in collapsible "More options" section
- Categories use badge toggles matching the activities module
- Use ScrollArea for proper shadcn scrolling
- Update CreateEventRequest and TicketedEvent types for new fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-27 18:07:44 +02:00
commit dcb26db685
2 changed files with 195 additions and 110 deletions

View file

@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import {
Dialog,
@ -22,6 +23,9 @@ import {
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
@ -29,11 +33,17 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Calendar, Loader2 } from 'lucide-vue-next'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Calendar, Loader2, ChevronDown, MapPin } from 'lucide-vue-next'
import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
import { ALL_CATEGORIES } from '../types/category'
interface Props {
open: boolean
@ -46,22 +56,18 @@ const emit = defineEmits<{
'event-created': []
}>()
const { t } = useI18n()
const formSchema = toTypedSchema(z.object({
name: z.string().min(1, "Event name is required").max(200, "Name too long"),
info: z.string().min(1, "Event description is required").max(2000, "Description too long"),
event_start_date: z.string().min(1, "Event start date is required"),
event_end_date: z.string().min(1, "Event end date is required"),
currency: z.string().default("sats"),
amount_tickets: z.number().min(1, "Must have at least 1 ticket").max(100000, "Too many tickets"),
price_per_ticket: z.number().min(0, "Price must be 0 or higher"),
banner: z.string().optional(),
}).refine((data) => {
const startDate = new Date(data.event_start_date)
const endDate = new Date(data.event_end_date)
return startDate <= endDate
}, {
message: "Event start date must be before or equal to end date",
path: ["event_end_date"]
name: z.string().min(1, "Title is required").max(200, "Title too long"),
info: z.string().max(2000, "Description too long").optional().default(''),
event_start_date: z.string().min(1, "Start date is required"),
event_end_date: z.string().optional().default(''),
location: z.string().max(500).optional().default(''),
banner: z.string().optional().default(''),
currency: z.string().default("sat"),
amount_tickets: z.number().min(0).max(100000).default(0),
price_per_ticket: z.number().min(0).default(0),
}))
const form = useForm({
@ -71,18 +77,21 @@ const form = useForm({
info: '',
event_start_date: '',
event_end_date: '',
currency: 'sats',
amount_tickets: 100,
price_per_ticket: 1000,
banner: ''
location: '',
banner: '',
currency: 'sat',
amount_tickets: 0,
price_per_ticket: 0,
}
})
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null
const availableCurrencies = ref<string[]>(['sats'])
const availableCurrencies = ref<string[]>(['sat'])
const loadingCurrencies = ref(false)
const selectedCategories = ref<string[]>([])
const showMoreOptions = ref(false)
watch(() => props.open, async (isOpen) => {
if (isOpen && ticketApi && !loadingCurrencies.value) {
@ -95,6 +104,10 @@ watch(() => props.open, async (isOpen) => {
loadingCurrencies.value = false
}
}
if (!isOpen) {
selectedCategories.value = []
showMoreOptions.value = false
}
})
const { resetForm, meta } = form
@ -103,6 +116,15 @@ const isLoading = ref(false)
const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
function toggleCategory(cat: string) {
const idx = selectedCategories.value.indexOf(cat)
if (idx >= 0) {
selectedCategories.value.splice(idx, 1)
} else {
selectedCategories.value.push(cat)
}
}
const onSubmit = form.handleSubmit(async (formValues) => {
if (!isFormValid.value) return
@ -121,14 +143,25 @@ const onSubmit = form.handleSubmit(async (formValues) => {
isLoading.value = true
try {
const eventData: CreateEventRequest = {
...formValues,
name: formValues.name,
event_start_date: formValues.event_start_date,
wallet: preferredWallet.id,
closing_date: formValues.event_end_date
}
// Optional fields only include if provided
if (formValues.info) eventData.info = formValues.info
if (formValues.event_end_date) eventData.event_end_date = formValues.event_end_date
if (formValues.location) eventData.location = formValues.location
if (formValues.banner) eventData.banner = formValues.banner
if (formValues.currency) eventData.currency = formValues.currency
if (formValues.amount_tickets) eventData.amount_tickets = formValues.amount_tickets
if (formValues.price_per_ticket) eventData.price_per_ticket = formValues.price_per_ticket
if (selectedCategories.value.length > 0) eventData.categories = selectedCategories.value
await props.onCreateEvent(eventData)
toastService.success('Event created successfully!')
toastService.success('Event submitted!')
resetForm()
selectedCategories.value = []
emit('update:open', false)
emit('event-created')
} catch (error) {
@ -142,6 +175,7 @@ const onSubmit = form.handleSubmit(async (formValues) => {
const handleOpenChange = (open: boolean) => {
if (!open && !isLoading.value) {
resetForm()
selectedCategories.value = []
}
emit('update:open', open)
}
@ -149,43 +183,34 @@ const handleOpenChange = (open: boolean) => {
<template>
<Dialog :open="open" @update:open="handleOpenChange">
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogContent class="max-w-lg max-h-[90vh] p-0">
<DialogHeader class="px-6 pt-6 pb-2">
<DialogTitle class="flex items-center gap-2">
<Calendar class="w-5 h-5" />
Create New Event
Create Event
</DialogTitle>
<DialogDescription>
Create a new event with ticket sales. All fields are required.
Only a title and start date are required.
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-6 mt-4">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Event Name *</FormLabel>
<FormControl>
<Input placeholder="Enter event name" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<ScrollArea class="max-h-[70vh] px-6 pb-6">
<form @submit="onSubmit" class="space-y-4">
<!-- Title (required) -->
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input placeholder="e.g. Bitcoin Meetup" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="info">
<FormItem>
<FormLabel>Event Description *</FormLabel>
<FormControl>
<Textarea placeholder="Describe your event..." rows="3" v-bind="componentField" />
</FormControl>
<FormDescription>Provide details about your event</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Start date (required) -->
<FormField v-slot="{ componentField }" name="event_start_date">
<FormItem>
<FormLabel>Event Starts *</FormLabel>
<FormLabel>Start date *</FormLabel>
<FormControl>
<Input type="date" :min="today" v-bind="componentField" />
</FormControl>
@ -193,83 +218,138 @@ const handleOpenChange = (open: boolean) => {
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="event_end_date">
<!-- Description (optional, visible) -->
<FormField v-slot="{ componentField }" name="info">
<FormItem>
<FormLabel>Event Ends *</FormLabel>
<FormLabel>Description</FormLabel>
<FormControl>
<Input type="date" :min="today" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Total Tickets *</FormLabel>
<FormControl>
<Input type="number" min="1" max="100000" placeholder="100" v-bind="componentField" />
<Textarea placeholder="What's this event about?" rows="2" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<!-- Location (optional, visible) -->
<FormField v-slot="{ componentField }" name="location">
<FormItem>
<FormLabel>Price per Ticket *</FormLabel>
<FormLabel class="flex items-center gap-1">
<MapPin class="w-3.5 h-3.5" />
Location
</FormLabel>
<FormControl>
<Input type="number" min="0" step="0.01" placeholder="1000" v-bind="componentField" />
<Input placeholder="e.g. Salle des fetes, Foix" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<!-- Categories (optional, visible) -->
<div class="space-y-2">
<p class="text-sm font-medium">Categories</p>
<div class="flex flex-wrap gap-1.5">
<Badge
v-for="cat in ALL_CATEGORIES"
:key="cat"
:variant="selectedCategories.includes(cat) ? 'default' : 'outline'"
class="cursor-pointer text-xs capitalize"
@click="toggleCategory(cat)"
>
{{ t(`activities.categories.${cat}`, cat) }}
</Badge>
</div>
</div>
<!-- Image URL (optional, visible) -->
<FormField v-slot="{ componentField }" name="banner">
<FormItem>
<FormLabel>Currency</FormLabel>
<FormLabel>Image URL</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<Input type="url" placeholder="https://example.com/image.jpg" v-bind="componentField" />
</FormControl>
<FormDescription>
<span v-if="loadingCurrencies">Loading currencies...</span>
<span v-else>Currency for ticket pricing</span>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</div>
<FormField v-slot="{ componentField }" name="banner">
<FormItem>
<FormLabel>Banner Image URL (Optional)</FormLabel>
<FormControl>
<Input type="url" placeholder="https://example.com/banner.jpg" v-bind="componentField" />
</FormControl>
<FormDescription>URL to an image for your event banner</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Tickets (optional, visible) -->
<div class="grid grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="amount_tickets">
<FormItem>
<FormLabel>Tickets</FormLabel>
<FormControl>
<Input type="number" min="0" max="100000" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = unlimited</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
Cancel
</Button>
<Button type="submit" :disabled="isLoading || !isFormValid">
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ isLoading ? 'Creating...' : 'Create Event' }}
</Button>
</div>
</form>
<FormField v-slot="{ componentField }" name="price_per_ticket">
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">0 = free</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="sat" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in availableCurrencies" :key="c" :value="c">
{{ c }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- More options (collapsible) -->
<Collapsible v-model:open="showMoreOptions">
<CollapsibleTrigger as-child>
<Button type="button" variant="ghost" size="sm" class="w-full justify-between text-muted-foreground">
More options
<ChevronDown class="w-4 h-4 transition-transform" :class="{ 'rotate-180': showMoreOptions }" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="space-y-4 pt-2">
<Separator />
<FormField v-slot="{ componentField }" name="event_end_date">
<FormItem>
<FormLabel>End date</FormLabel>
<FormControl>
<Input type="date" :min="today" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">Defaults to start date if not set</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</CollapsibleContent>
</Collapsible>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
Cancel
</Button>
<Button type="submit" :disabled="isLoading || !isFormValid">
<Loader2 v-if="isLoading" class="w-4 h-4 mr-2 animate-spin" />
{{ isLoading ? 'Submitting...' : 'Submit Event' }}
</Button>
</div>
</form>
</ScrollArea>
</DialogContent>
</Dialog>
</template>

View file

@ -50,26 +50,31 @@ export interface TicketedEvent {
wallet: string
name: string
info: string
closing_date: string
closing_date: string | null
event_start_date: string
event_end_date: string
event_end_date: string | null
currency: string
amount_tickets: number
price_per_ticket: number
time: string
sold: number
banner: string | null
location: string | null
categories: string[]
status: string
}
export interface CreateEventRequest {
wallet: string
wallet?: string
name: string
info: string
closing_date: string
info?: string
closing_date?: string
event_start_date: string
event_end_date: string
currency: string
amount_tickets: number
price_per_ticket: number
event_end_date?: string
currency?: string
amount_tickets?: number
price_per_ticket?: number
banner?: string | null
location?: string | null
categories?: string[]
}