@@ -97,6 +97,20 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
9797 function toggleSection ( section : keyof typeof collapsed ) {
9898 if ( ! isNewEvent ) setCollapsed ( ( c ) => ( { ...c , [ section ] : ! c [ section ] } ) ) ;
9999 }
100+
101+ // Fields the user has interacted with at least once — gates the inline
102+ // per-field error messages so a blank new-event form doesn't light up red
103+ // before anyone's typed anything.
104+ const [ touched , setTouched ] = useState < Record < keyof EventFieldErrors , boolean > > ( {
105+ name : false ,
106+ maxOrders : false ,
107+ pickupLocation : false ,
108+ senders : false ,
109+ items : false ,
110+ } ) ;
111+ function markTouched ( field : keyof EventFieldErrors ) {
112+ setTouched ( ( t ) => ( t [ field ] ? t : { ...t , [ field ] : true } ) ) ;
113+ }
100114 function updateMenuItemField (
101115 index : number ,
102116 field : "title" | "shortTitle" | "description" ,
@@ -209,6 +223,10 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
209223 } ) ,
210224 ) ;
211225
226+ // Only relevant during creation — existing events have no equivalent
227+ // save-gate, so there's nothing for these to guard there.
228+ const fieldErrors = isNewEvent ? getFieldErrors ( internalEvent ) : { } ;
229+
212230 return (
213231 < div className = "w-full py-8 space-y-4" >
214232 { /* Page header */ }
@@ -258,11 +276,17 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
258276 disabled = { ! isNewEvent }
259277 required
260278 pattern = ".{4,}"
279+ aria-invalid = { touched . name && ! ! fieldErrors . name }
280+ className = { touched . name && fieldErrors . name ? "border-red-500 focus-visible:ring-red-500" : "" }
261281 value = { internalEvent . name }
262282 onChange = { ( ev ) =>
263283 updateEvent ( { ...internalEvent , name : ev . target . value , slug : toKebabCase ( ev . target . value ) } )
264284 }
285+ onBlur = { ( ) => markTouched ( "name" ) }
265286 />
287+ { touched . name && fieldErrors . name && (
288+ < p className = "text-xs text-red-500" > { fieldErrors . name } </ p >
289+ ) }
266290 </ div >
267291 < div className = "space-y-1.5" >
268292 < Label htmlFor = "eventSlug" > Slug</ Label >
@@ -275,11 +299,21 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
275299 type = "number"
276300 min = { 1 }
277301 required
302+ aria-invalid = { touched . maxOrders && ! ! fieldErrors . maxOrders }
303+ className = { touched . maxOrders && fieldErrors . maxOrders ? "border-red-500 focus-visible:ring-red-500" : "" }
278304 value = { internalEvent . maxOrders }
279305 onChange = { ( ev ) =>
280306 updateEvent ( { ...internalEvent , maxOrders : parseInt ( ev . target . value ) } )
281307 }
308+ onBlur = { ( ) => markTouched ( "maxOrders" ) }
282309 />
310+ { touched . maxOrders && fieldErrors . maxOrders ? (
311+ < p className = "text-xs text-red-500" > { fieldErrors . maxOrders } </ p >
312+ ) : (
313+ < p className = "text-xs text-gray-400" >
314+ 50 or more is treated as unlimited — order confirmations won't mention a daily cap.
315+ </ p >
316+ ) }
283317 </ div >
284318 < div className = "space-y-1.5" >
285319 < Label htmlFor = "leadCollection" > Lead Collection</ Label >
@@ -330,11 +364,15 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
330364 < MultiSelect
331365 placeholder = "Senders to be used for this event"
332366 value = { internalEvent . senders . map ( ( s ) => ( { label : s , value : s } ) ) }
333- onChange = { ( selected ) =>
334- updateEvent ( { ...internalEvent , senders : selected . map ( ( s ) => s . value ) } )
335- }
367+ onChange = { ( selected ) => {
368+ markTouched ( "senders" ) ;
369+ updateEvent ( { ...internalEvent , senders : selected . map ( ( s ) => s . value ) } ) ;
370+ } }
336371 options = { options }
337372 />
373+ { touched . senders && fieldErrors . senders && (
374+ < p className = "text-xs text-red-500" > { fieldErrors . senders } </ p >
375+ ) }
338376 { internalEvent . senders . length > 0 && (
339377 < Popover >
340378 < PopoverTrigger >
@@ -355,11 +393,17 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
355393 placeholder = "Where to find the booth"
356394 required
357395 pattern = ".{3,}"
396+ aria-invalid = { touched . pickupLocation && ! ! fieldErrors . pickupLocation }
397+ className = { touched . pickupLocation && fieldErrors . pickupLocation ? "border-red-500 focus-visible:ring-red-500" : "" }
358398 value = { internalEvent . pickupLocation }
359399 onChange = { ( ev ) =>
360400 updateEvent ( { ...internalEvent , pickupLocation : ev . target . value } )
361401 }
402+ onBlur = { ( ) => markTouched ( "pickupLocation" ) }
362403 />
404+ { touched . pickupLocation && fieldErrors . pickupLocation && (
405+ < p className = "text-xs text-red-500" > { fieldErrors . pickupLocation } </ p >
406+ ) }
363407 </ div >
364408 < div className = "space-y-1.5" >
365409 < Label htmlFor = "language" > Language</ Label >
@@ -412,6 +456,7 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
412456 menus = { config . menus }
413457 selection = { internalEvent . selection }
414458 onSelectionChange = { ( newSelection ) => {
459+ markTouched ( "items" ) ;
415460 updateEvent ( { ...internalEvent , selection : newSelection } ) ;
416461 if ( ! isNewEvent ) {
417462 // Debounce the save (like updateMenuItemField/
@@ -429,6 +474,9 @@ function EventPage({ params }: { params: Promise<{ slug: string }> }) {
429474 }
430475 } }
431476 />
477+ { touched . items && fieldErrors . items && (
478+ < p className = "text-xs text-red-500 mt-1.5" > { fieldErrors . items } </ p >
479+ ) }
432480 </ CardContent >
433481 ) }
434482 </ Card >
@@ -559,35 +607,51 @@ function toKebabCase(string: string) {
559607 . replace ( / ^ - + | - + $ / g, "" ) ;
560608}
561609
562- function isFormInvalid ( internalEvent : Event ) {
563- return (
564- internalEvent . name ?. length < 4 ||
565- internalEvent . name ?. length > 20 ||
566- internalEvent . maxOrders < 1 ||
567- internalEvent . pickupLocation . length < 3 ||
568- internalEvent . senders ?. length ! < 1 ||
569- internalEvent . selection . items ?. length < 2
570- ) ;
571- }
610+ type EventFieldErrors = {
611+ name ?: string ;
612+ maxOrders ?: string ;
613+ pickupLocation ?: string ;
614+ senders ?: string ;
615+ items ?: string ;
616+ } ;
617+
618+ // Single source of truth for validation, shared by the per-field inline
619+ // messages and the Create Event button's tooltip.
620+ function getFieldErrors ( internalEvent : Event ) : EventFieldErrors {
621+ const errors : EventFieldErrors = { } ;
572622
573- function validateButtonTooltip ( internalEvent : Event ) : string {
574623 if ( internalEvent . name ?. length < 4 ) {
575- return "Event name must be at least 4 characters long" ;
576- }
577- if ( internalEvent . name ?. length > 20 ) {
578- return "Event name must be 20 characters long or less" ;
624+ errors . name = "Event name must be at least 4 characters long" ;
625+ } else if ( internalEvent . name ?. length > 20 ) {
626+ errors . name = "Event name must be 20 characters long or less" ;
579627 }
580628 if ( internalEvent . maxOrders < 1 ) {
581- return "Max orders must be at least 1" ;
629+ errors . maxOrders = "Max orders must be at least 1" ;
582630 }
583631 if ( internalEvent . pickupLocation . length < 3 ) {
584- return "Pickup location must be at least 3 characters long" ;
632+ errors . pickupLocation = "Pickup location must be at least 3 characters long" ;
585633 }
586634 if ( internalEvent . senders ?. length ! < 1 ) {
587- return "At least one sender must be selected" ;
635+ errors . senders = "At least one sender must be selected" ;
588636 }
589637 if ( internalEvent . selection . items ?. length < 2 ) {
590- return "At least two menu items must be selected" ;
638+ errors . items = "At least two menu items must be selected" ;
591639 }
592- return "Create Event" ;
640+ return errors ;
641+ }
642+
643+ function isFormInvalid ( internalEvent : Event ) {
644+ return Object . keys ( getFieldErrors ( internalEvent ) ) . length > 0 ;
645+ }
646+
647+ function validateButtonTooltip ( internalEvent : Event ) : string {
648+ const errors = getFieldErrors ( internalEvent ) ;
649+ return (
650+ errors . name ??
651+ errors . maxOrders ??
652+ errors . pickupLocation ??
653+ errors . senders ??
654+ errors . items ??
655+ "Create Event"
656+ ) ;
593657}
0 commit comments