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:
parent
38492ad592
commit
dcb26db685
2 changed files with 195 additions and 110 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue