feat(activities): themed date/time pickers + end-after-start guard

Swap the native <input type="date|time"> controls in CreateEventDialog
for the shared DatePicker / TimePicker components so the form looks
like the rest of the shadcn UI instead of browser chrome.

While there:

- End date auto-mirrors start date on pick (and re-mirrors if the
  existing end has fallen behind the new start), so a one-day event
  needs no extra clicks.
- Zod superRefine rejects end < start, comparing the folded date+time
  string so equal-date / later-time is enforced too.
- Move End date/time out of the "More options" collapsible into the
  main form flow (drops Collapsible / Separator / ChevronDown / the
  showMoreOptions ref).
- End time label reads "End time (optional)" to make the field's
  status obvious.
- Banner image label is plain markup (not <FormItem>) since it's
  managed via bannerImages ref outside vee-validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-20 19:36:52 +02:00
commit 012f364a7a

View file

@ -24,7 +24,6 @@ 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,
@ -33,15 +32,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Calendar, Loader2, ChevronDown, MapPin } from 'lucide-vue-next'
import { Calendar, Loader2, MapPin } from 'lucide-vue-next'
import { toastService } from '@/core/services/ToastService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
import DatePicker from '@/modules/base/components/DatePicker.vue'
import TimePicker from '@/modules/base/components/TimePicker.vue'
import type { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
import type { TicketApiService } from '../services/TicketApiService'
import type { CreateEventRequest } from '../types/ticket'
@ -60,18 +56,45 @@ const emit = defineEmits<{
const { t } = useI18n()
const formSchema = toTypedSchema(z.object({
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_start_time: z.string().optional().default(''),
event_end_date: z.string().optional().default(''),
event_end_time: z.string().optional().default(''),
location: z.string().max(500).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),
}))
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
// into the events-extension wire format: date-only when no time given,
// ISO 8601 datetime otherwise. The publisher switches NIP-52 kinds on
// the "T" delimiter. Hoisted above the schema so the validation refine
// can reuse it.
function foldDateTime(date: string, time: string): string {
if (!date) return ''
return time ? `${date}T${time}` : date
}
const formSchema = toTypedSchema(
z
.object({
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_start_time: z.string().optional().default(''),
event_end_date: z.string().optional().default(''),
event_end_time: z.string().optional().default(''),
location: z.string().max(500).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),
})
.superRefine((v, ctx) => {
// End must not precede start. Compare on the folded date+time
// string so equal-date / later-time is enforced too.
if (!v.event_end_date) return
const start = foldDateTime(v.event_start_date, v.event_start_time)
const end = foldDateTime(v.event_end_date, v.event_end_time)
if (start && end && end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['event_end_date'],
message: 'End must be on or after start',
})
}
})
)
const form = useForm({
validationSchema: formSchema,
@ -94,14 +117,21 @@ interface BannerImage extends UploadedImage {
}
const bannerImages = ref<BannerImage[]>([])
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
// into the events-extension wire format: date-only when no time given,
// ISO 8601 datetime otherwise. The publisher switches NIP-52 kinds on
// the "T" delimiter.
function foldDateTime(date: string, time: string): string {
if (!date) return ''
return time ? `${date}T${time}` : 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
// requires no extra clicks. Don't overwrite an end date the user
// already set *after* the start only fill when empty or when the
// existing end has fallen behind the new start.
watch(
() => form.values.event_start_date,
(start, prev) => {
if (!start) return
const end = form.values.event_end_date
if (!end || end < start || end === prev) {
form.setFieldValue('event_end_date', start)
}
}
)
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null
@ -110,7 +140,6 @@ const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLO
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) {
@ -125,7 +154,6 @@ watch(() => props.open, async (isOpen) => {
}
if (!isOpen) {
selectedCategories.value = []
showMoreOptions.value = false
}
})
@ -240,21 +268,67 @@ const handleOpenChange = (open: boolean) => {
<!-- Start date (required) + optional time -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="event_start_date">
<FormField v-slot="{ value, handleChange }" name="event_start_date">
<FormItem class="sm:col-span-2 min-w-0">
<FormLabel>Start date *</FormLabel>
<FormControl>
<Input type="date" :min="today" class="w-full" v-bind="componentField" />
<DatePicker
:model-value="value as string ?? ''"
:min="today"
placeholder="Pick a date"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="event_start_time">
<FormField v-slot="{ value, handleChange }" name="event_start_time">
<FormItem class="min-w-0">
<FormLabel>Start time</FormLabel>
<FormControl>
<Input type="time" class="w-full" v-bind="componentField" />
<TimePicker
:model-value="value as string ?? ''"
clearable
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- End date + optional time. Auto-mirrors start date until
the user moves it forward; cross-field rule enforces
end >= start in the Zod schema. -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<FormField v-slot="{ value, handleChange }" name="event_end_date">
<FormItem class="sm:col-span-2 min-w-0">
<FormLabel>End date</FormLabel>
<FormControl>
<DatePicker
:model-value="value as string ?? ''"
:min="(form.values.event_start_date as string) || today"
placeholder="Pick a date"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" name="event_end_time">
<FormItem class="min-w-0">
<FormLabel>
End time
<span class="text-muted-foreground font-normal">(optional)</span>
</FormLabel>
<FormControl>
<TimePicker
:model-value="value as string ?? ''"
clearable
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -371,42 +445,6 @@ const handleOpenChange = (open: boolean) => {
</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 />
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<FormField v-slot="{ componentField }" name="event_end_date">
<FormItem class="sm:col-span-2 min-w-0">
<FormLabel>End date</FormLabel>
<FormControl>
<Input type="date" :min="today" class="w-full" v-bind="componentField" />
</FormControl>
<FormDescription class="text-xs">Defaults to start date</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="event_end_time">
<FormItem class="min-w-0">
<FormLabel>End time</FormLabel>
<FormControl>
<Input type="time" class="w-full" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</CollapsibleContent>
</Collapsible>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">