diff --git a/frontend-v2/package.json b/frontend-v2/package.json index 8084736f..6d6eb119 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@next/eslint-plugin-next": "^16.1.1", + "@tailwindcss/forms": "^0.5.11", "@types/lodash-es": "^4.17.12", "@types/node": "^20.17.17", "@types/react": "^19.0.8", diff --git a/frontend-v2/pnpm-lock.yaml b/frontend-v2/pnpm-lock.yaml index 9fb0961a..e0988a0d 100644 --- a/frontend-v2/pnpm-lock.yaml +++ b/frontend-v2/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@next/eslint-plugin-next': specifier: ^16.1.1 version: 16.1.1 + '@tailwindcss/forms': + specifier: ^0.5.11 + version: 0.5.11(tailwindcss@3.4.17) '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -848,6 +851,11 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tailwindcss/forms@0.5.11': + resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' + '@tanstack/devtools-event-client@0.4.0': resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==} engines: {node: '>=18'} @@ -1877,6 +1885,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3186,6 +3198,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@tailwindcss/forms@0.5.11(tailwindcss@3.4.17)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 3.4.17 + '@tanstack/devtools-event-client@0.4.0': {} '@tanstack/form-core@0.41.4': @@ -4463,6 +4480,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mini-svg-data-uri@1.4.4: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 diff --git a/frontend-v2/src/app/event/attendee-input-field.tsx b/frontend-v2/src/app/event/attendee-input-field.tsx index c72a62ba..f73b422a 100644 --- a/frontend-v2/src/app/event/attendee-input-field.tsx +++ b/frontend-v2/src/app/event/attendee-input-field.tsx @@ -121,7 +121,7 @@ export const AttendeeInputField = ({ name="attendee" placeholder="" className={cn( - 'w-full transition-colors duration-300 border-2', + 'w-full transition-colors duration-300', isDuplicate || isError ? 'text-red-500 border-red-500 focus:border-red-500' : isNewName diff --git a/frontend-v2/src/app/event/event-form.tsx b/frontend-v2/src/app/event/event-form.tsx index 621141d9..0df2a4f0 100644 --- a/frontend-v2/src/app/event/event-form.tsx +++ b/frontend-v2/src/app/event/event-form.tsx @@ -42,6 +42,15 @@ const EVENT_TYPES = [ const DEFAULT_FIELD_COUNT = 5 const MIN_EMPTY_FIELDS = 1 +// Get today's date in YYYY-MM-DD format in the browser's local timezone +const getTodayDate = () => { + const today = new Date() + const year = today.getFullYear() + const month = String(today.getMonth() + 1).padStart(2, '0') + const day = String(today.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + // Zod schema for form validation. const attendeeSchema = z.object({ name: z.string().refine( @@ -124,7 +133,6 @@ export const EventForm = ({ mode }: EventFormProps) => { ) if (match) { const newEventId = match[2] - // Update URL to include the new event ID. const newPath = isConnection ? `/coaching/${newEventId}` : `/event/${newEventId}` @@ -138,24 +146,35 @@ export const EventForm = ({ mode }: EventFormProps) => { // Reset the form's dirty state after successful save. // This prevents "unsaved changes" warning after successful save. - // TanStack form has a bug requiring the `keepDefaultValues` option: - // https://github.com/TanStack/form/issues/1798. - form.reset( - { - eventName: variables.event_name, - eventType: variables.event_type, - eventDate: variables.event_date, - suppressSurvey: variables.suppress_survey, - attendees: (result.attendees ?? []) - .map((name) => ({ name })) - .concat( - Array(MIN_EMPTY_FIELDS) - .fill(null) - .map(() => ({ name: '' })), - ), - }, - { keepDefaultValues: true }, - ) + + // Use the attendee order from the current form state, not from the + // server response (result.attendees). The server returns attendees in + // arbitrary database order, but we want to preserve the user's input order. + // This matches the behavior of the legacy Vue version. + const savedAttendeeNames = form.state.values.attendees + .map((a) => a.name.trim()) + .filter((n) => n !== '') + + const newValues = { + eventName: variables.event_name, + eventType: variables.event_type, + eventDate: variables.event_date, + suppressSurvey: variables.suppress_survey, + attendees: savedAttendeeNames + .map((name) => ({ name })) + .concat( + Array(MIN_EMPTY_FIELDS) + .fill(null) + .map(() => ({ name: '' })), + ), + } + + // Use keepDefaultValues to work around TanStack Form bug: + // https://github.com/TanStack/form/issues/1798 + form.reset(newValues, { keepDefaultValues: true }) + + // Manually update defaultValues since keepDefaultValues prevents it. + form.options.defaultValues = newValues // Refresh activist list to include newly created activists. // This ensures they appear in autocomplete suggestions. @@ -175,10 +194,13 @@ export const EventForm = ({ mode }: EventFormProps) => { if (eventId && !eventData) { throw new Error('Expected event data to be prefetched') } + + // Default to today's date for new events to avoid Safari showing a confusing + // placeholder. Users can easily change it if needed. return { eventName: eventData?.event_name || '', eventType: eventData?.event_type || (isConnection ? 'Connection' : ''), - eventDate: eventData?.event_date || '', + eventDate: eventData?.event_date || getTodayDate(), // For new events, non-SF Bay chapters default to not sending surveys. suppressSurvey: eventData?.suppress_survey ?? user.ChapterID !== SF_BAY_CHAPTER_ID, @@ -249,8 +271,8 @@ export const EventForm = ({ mode }: EventFormProps) => { }) const checkForDuplicate = (value: string, currentIndex: number): boolean => { - const attendees = form.state.values.attendees - const matches = attendees.filter( + const currentAttendees = form.state.values.attendees + const matches = currentAttendees.filter( (a, idx) => idx !== currentIndex && a.name === value, ) return matches.length > 0 @@ -259,6 +281,7 @@ export const EventForm = ({ mode }: EventFormProps) => { // Subscribe to form state to reactively show/hide the survey checkbox. const eventType = useStore(form.store, (state) => state.values.eventType) const eventName = useStore(form.store, (state) => state.values.eventName) + const isDirty = useStore(form.store, (state) => state.isDirty) // Predicts whether the server will send a survey by default. const shouldShowSuppressSurveyCheckbox = useMemo(() => { @@ -290,16 +313,16 @@ export const EventForm = ({ mode }: EventFormProps) => { }) }, [eventName, eventType, user.ChapterID]) + // Subscribe to attendees changes to reactively update the count + const attendees = useStore(form.store, (state) => state.values.attendees) const attendeeCount = useMemo( - () => - form.state.values.attendees.filter((a) => a.name.trim() !== '').length, - [form.state.values.attendees], + () => attendees.filter((a) => a.name.trim() !== '').length, + [attendees], ) const ensureMinimumEmptyFields = () => { - const emptyCount = form.state.values.attendees.filter( - (it) => !it.name.length, - ).length + const currentAttendees = form.state.values.attendees + const emptyCount = currentAttendees.filter((it) => !it.name.length).length if (emptyCount < MIN_EMPTY_FIELDS) { form.pushFieldValue('attendees', { name: '' }) } @@ -308,7 +331,7 @@ export const EventForm = ({ mode }: EventFormProps) => { // Warn before leaving with unsaved changes. useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (form.state.isDirty) { + if (isDirty) { // `preventDefault()` + setting `returnValue` triggers the // browser's native unsaved changes warning dialog. Modern // browsers ignore custom messages in returnValue for security, @@ -320,15 +343,10 @@ export const EventForm = ({ mode }: EventFormProps) => { window.addEventListener('beforeunload', handleBeforeUnload) return () => window.removeEventListener('beforeunload', handleBeforeUnload) - }, [form.state.isDirty]) + }, [isDirty]) const setDateToToday = () => { - // Get today's date in YYYY-MM-DD format in the browser's local timezone - const today = new Date() - const year = today.getFullYear() - const month = String(today.getMonth() + 1).padStart(2, '0') - const day = String(today.getDate()).padStart(2, '0') - form.setFieldValue('eventDate', `${year}-${month}-${day}`) + form.setFieldValue('eventDate', getTodayDate()) } // Only show loading for activist list since event data is prefetched during SSR @@ -389,7 +407,7 @@ export const EventForm = ({ mode }: EventFormProps) => { onChange={(e) => field.handleChange(e.target.value)} onBlur={field.handleBlur} className={cn( - 'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50', + 'h-9 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50', field.state.meta.errors[0] && 'border-red-500', )} > @@ -509,7 +527,7 @@ export const EventForm = ({ mode }: EventFormProps) => {
- {form.state.isDirty && ( + {isDirty && ( Unsaved changes )}
diff --git a/frontend-v2/src/app/layout.tsx b/frontend-v2/src/app/layout.tsx index 115fc3a8..0300155a 100644 --- a/frontend-v2/src/app/layout.tsx +++ b/frontend-v2/src/app/layout.tsx @@ -31,8 +31,18 @@ export default async function RootLayout({ {/* Top padding is to make room for the fixed navbar. */} - - {children} + + {/* + Bottom padding uses inline style because Safari doesn't respect + pb-[3.25rem] Tailwind class when combined with body's background styles. + Inline styles work reliably across all browsers. + */} +
+ {children} +
diff --git a/frontend-v2/src/components/ui/input.tsx b/frontend-v2/src/components/ui/input.tsx index 6c738ffc..77160864 100644 --- a/frontend-v2/src/components/ui/input.tsx +++ b/frontend-v2/src/components/ui/input.tsx @@ -8,7 +8,7 @@ const Input = React.forwardRef>(