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 { Textarea } from '@/components/ui/textarea'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -33,15 +32,12 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
import { Calendar, Loader2, MapPin } from 'lucide-vue-next'
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible'
|
|
||||||
import { Calendar, Loader2, ChevronDown, MapPin } from 'lucide-vue-next'
|
|
||||||
import { toastService } from '@/core/services/ToastService'
|
import { toastService } from '@/core/services/ToastService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
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 { ImageUploadService, UploadedImage } from '@/modules/base/services/ImageUploadService'
|
||||||
import type { TicketApiService } from '../services/TicketApiService'
|
import type { TicketApiService } from '../services/TicketApiService'
|
||||||
import type { CreateEventRequest } from '../types/ticket'
|
import type { CreateEventRequest } from '../types/ticket'
|
||||||
|
|
@ -60,7 +56,19 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const formSchema = toTypedSchema(z.object({
|
// 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"),
|
name: z.string().min(1, "Title is required").max(200, "Title too long"),
|
||||||
info: z.string().max(2000, "Description too long").optional().default(''),
|
info: z.string().max(2000, "Description too long").optional().default(''),
|
||||||
event_start_date: z.string().min(1, "Start date is required"),
|
event_start_date: z.string().min(1, "Start date is required"),
|
||||||
|
|
@ -71,7 +79,22 @@ const formSchema = toTypedSchema(z.object({
|
||||||
currency: z.string().default("sat"),
|
currency: z.string().default("sat"),
|
||||||
amount_tickets: z.number().min(0).max(100000).default(0),
|
amount_tickets: z.number().min(0).max(100000).default(0),
|
||||||
price_per_ticket: z.number().min(0).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({
|
const form = useForm({
|
||||||
validationSchema: formSchema,
|
validationSchema: formSchema,
|
||||||
|
|
@ -94,14 +117,21 @@ interface BannerImage extends UploadedImage {
|
||||||
}
|
}
|
||||||
const bannerImages = ref<BannerImage[]>([])
|
const bannerImages = ref<BannerImage[]>([])
|
||||||
|
|
||||||
// Fold a date input ("YYYY-MM-DD") and an optional time input ("HH:MM")
|
// Auto-mirror end date to start: when the user picks a start date,
|
||||||
// into the events-extension wire format: date-only when no time given,
|
// surface that same date in the end-date picker so a one-day event
|
||||||
// ISO 8601 datetime otherwise. The publisher switches NIP-52 kinds on
|
// requires no extra clicks. Don't overwrite an end date the user
|
||||||
// the "T" delimiter.
|
// already set *after* the start — only fill when empty or when the
|
||||||
function foldDateTime(date: string, time: string): string {
|
// existing end has fallen behind the new start.
|
||||||
if (!date) return ''
|
watch(
|
||||||
return time ? `${date}T${time}` : date
|
() => 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 paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
|
||||||
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService | null
|
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 availableCurrencies = ref<string[]>(['sat'])
|
||||||
const loadingCurrencies = ref(false)
|
const loadingCurrencies = ref(false)
|
||||||
const selectedCategories = ref<string[]>([])
|
const selectedCategories = ref<string[]>([])
|
||||||
const showMoreOptions = ref(false)
|
|
||||||
|
|
||||||
watch(() => props.open, async (isOpen) => {
|
watch(() => props.open, async (isOpen) => {
|
||||||
if (isOpen && ticketApi && !loadingCurrencies.value) {
|
if (isOpen && ticketApi && !loadingCurrencies.value) {
|
||||||
|
|
@ -125,7 +154,6 @@ watch(() => props.open, async (isOpen) => {
|
||||||
}
|
}
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
showMoreOptions.value = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -240,21 +268,67 @@ const handleOpenChange = (open: boolean) => {
|
||||||
|
|
||||||
<!-- Start date (required) + optional time -->
|
<!-- Start date (required) + optional time -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<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">
|
<FormItem class="sm:col-span-2 min-w-0">
|
||||||
<FormLabel>Start date *</FormLabel>
|
<FormLabel>Start date *</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField v-slot="{ componentField }" name="event_start_time">
|
<FormField v-slot="{ value, handleChange }" name="event_start_time">
|
||||||
<FormItem class="min-w-0">
|
<FormItem class="min-w-0">
|
||||||
<FormLabel>Start time</FormLabel>
|
<FormLabel>Start time</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -371,42 +445,6 @@ const handleOpenChange = (open: boolean) => {
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</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 -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
|
<Button type="button" variant="outline" @click="handleOpenChange(false)" :disabled="isLoading">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue