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:
parent
93c05104df
commit
012f364a7a
1 changed files with 107 additions and 69 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue