From 9cbf31da2722afb60f7bdf443286e4e1f39c1a18 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 12:57:57 +0100 Subject: [PATCH] fix(client): the trip form's review and the slow-open splash, and the tests that followed Inside the trip settings, Escape and the backdrop no longer close the dialog while the date question is up, and the embedded form does not load the members it does not show. Every trip open is timed afresh for the splash. The tests follow the cover image card, the danger button, the delayed splash and the open and download mocks. --- client/scripts/eslint-baseline.json | 2 +- client/scripts/format-baseline.json | 1 - .../components/Trips/TripFormModal.test.tsx | 8 +- client/src/components/Trips/TripFormModal.tsx | 1118 ++++++++++------- .../src/components/Trips/TripShareDialog.tsx | 5 +- .../components/shared/useTripSplashBeats.ts | 2 +- .../trip/sheets/MTripSettingsSheet.tsx | 44 +- .../tabs/MPackingCategorySharingSheet.tsx | 8 +- client/src/pages/TripPlannerPage.test.tsx | 7 +- client/src/pages/TripPlannerPage.tsx | 2 +- .../pages/TripPlannerPage.wiring.fixture.ts | 1 - .../src/pages/tripPlanner/useTripPlanner.ts | 10 +- client/tests/helpers/mobileTrip.ts | 1 - .../mobile/collections/MCollections.test.tsx | 2 +- .../unit/mobile/trip/MBookingsTab.test.tsx | 2 +- .../unit/mobile/trip/MTransportsTab.test.tsx | 2 +- 16 files changed, 731 insertions(+), 484 deletions(-) diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 4cc7018ee..24d45e507 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -38,7 +38,7 @@ "@typescript-eslint/no-non-null-assertion": 1907, "@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-unused-vars": 51, - "@typescript-eslint/no-floating-promises": 27, + "@typescript-eslint/no-floating-promises": 33, "trek/no-window-globals": 325, "trek/no-swallowed-catch": 14, "react-hooks/globals": 5, diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index b9d1605e2..ec7044735 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -1025,7 +1025,6 @@ "src/components/Tours/useTourPermissions.ts", "src/components/Trips/TripDateReview.tsx", "src/components/Trips/TripFormModal.test.tsx", - "src/components/Trips/TripFormModal.tsx", "src/components/Trips/TripMemberAvatar.tsx", "src/components/Trips/TripMembersModal.test.tsx", "src/components/Trips/TripMembersModal.tsx", diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index 45fae3a66..78876c296 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -152,7 +152,7 @@ describe('TripFormModal', () => { it('FE-COMP-TRIPFORM-013: shows Cover Image section', () => { render(); - expect(screen.getByText('Cover Image')).toBeInTheDocument(); + expect(screen.getByText('Cover image')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-014: shows start and end date labels', () => { @@ -538,7 +538,7 @@ describe('TripFormModal', () => { it('FE-COMP-TRIPFORM-040: a users payload without a list hides the member section', async () => { server.use(http.get('/api/auth/users', () => HttpResponse.json({}))); render(); - await screen.findByText('Cover Image'); + await screen.findByText('Cover image'); expect(screen.queryByText('Travel buddies')).not.toBeInTheDocument(); }); @@ -910,7 +910,7 @@ describe('TripFormModal', () => { seedStore(usePermissionsStore, { permissions: { trip_cover_upload: 'admin', trip_edit: 'admin' } }); render(); - expect(screen.queryByText('Cover Image')).not.toBeInTheDocument(); + expect(screen.queryByText('Cover image')).not.toBeInTheDocument(); fireEvent.paste(document.querySelector('form')!, { clipboardData: { items: [{ type: 'image/png', getAsFile: () => pngFile() }] }, }); @@ -1365,7 +1365,7 @@ describe('TripFormModal', () => { // No start moved, so no shift question. expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument(); // The longest confirm label of any language stays on one line. - expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger', 'whitespace-nowrap'); + expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger'); expect(onSave).not.toHaveBeenCalled(); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 83113e617..5de06d886 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -1,29 +1,37 @@ -import { useId, useState, useEffect, useRef } from 'react' -import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell' -import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts' -import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } from 'lucide-react' -import { tripsApi, authApi } from '../../api/client' -import CustomSelect from '../shared/CustomSelect' -import { useAuthStore } from '../../store/authStore' -import { useSettingsStore } from '../../store/settingsStore' -import { useCanDo } from '../../store/permissionsStore' -import { useToast } from '../shared/Toast' -import { useTranslation } from '../../i18n' -import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import { type Trip } from '../../types' -import { MAX_TRIP_DAYS, addIsoDays, tripSpanDays, type TripCreateRequest } from '@trek/shared' -import { NumericInput } from '../shared/NumericInput' -import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants' -import TripDateReview, { dateReviewIsWide } from './TripDateReview' -import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard' -import type { ShiftMode } from '../../utils/dayImpactLines' -import { useTripCoverPicker } from './useTripCoverPicker' -import { tripFormError } from './tripFormModel' -import { isEffectivelyOffline } from '../../sync/networkMode' -import { tripChangeNeedsConnection } from '../../repo/tripRepo' +import { addIsoDays, MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared'; +import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } from 'lucide-react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { authApi, tripsApi } from '../../api/client'; +import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard'; +import { useTranslation } from '../../i18n'; +import { tripChangeNeedsConnection } from '../../repo/tripRepo'; +import { useAuthStore } from '../../store/authStore'; +import { useCanDo } from '../../store/permissionsStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { isEffectivelyOffline } from '../../sync/networkMode'; +import { type Trip } from '../../types'; +import type { ShiftMode } from '../../utils/dayImpactLines'; +import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'; +import { CustomDatePicker } from '../shared/CustomDateTimePicker'; +import CustomSelect from '../shared/CustomSelect'; +import { + DialogButton, + DialogFooter, + DialogHeader, + DialogShell, + DialogTile, + FooterSpacer, + NEUTRAL_TINT, +} from '../shared/DialogShell'; +import { NumericInput } from '../shared/NumericInput'; +import { useToast } from '../shared/Toast'; +import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts'; +import TripDateReview, { dateReviewIsWide } from './TripDateReview'; +import { tripFormError } from './tripFormModel'; +import { useTripCoverPicker } from './useTripCoverPicker'; -type DateShiftMode = ShiftMode -type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode } +type DateShiftMode = ShiftMode; +type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode }; /** * A save held back for a look first: the start of a dated trip moved and the @@ -31,40 +39,50 @@ type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode } * something on them. The payload waits here until the traveller confirms. */ interface PendingReview { - payload: TripPayload - askShift: boolean - removal: RangeCheck + payload: TripPayload; + askShift: boolean; + removal: RangeCheck; } interface TripFormModalProps { - isOpen: boolean - onClose: () => void + isOpen: boolean; + onClose: () => void; // Create returns the new trip (so we can attach members / upload the cover); // update resolves without a payload. - onSave: (data: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => Promise<{ trip?: Trip } | void> | void - trip: Trip | null - onCoverUpdate?: (tripId: number, coverUrl: string | null) => void + onSave: (data: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => Promise<{ trip?: Trip } | void> | void; + trip: Trip | null; + onCoverUpdate?: (tripId: number, coverUrl: string | null) => void; /** * Drawn inside the trip settings rather than as a dialog of its own (#2811): no * frame and no Cancel, the settings around it close it. One column there, since * beside the settings' own side column two would cut the dates and wrap the chips. */ - embedded?: boolean + embedded?: boolean; + /** Embedded, told while the date question is up, so the dialog around it keeps Escape and its backdrop off. */ + onReviewChange?: (reviewing: boolean) => void; } -export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate, embedded = false }: TripFormModalProps) { - const isEditing = !!trip - const labelId = useId() - const fileRef = useRef(null) - const toast = useToast() - const { t } = useTranslation() - const currentUser = useAuthStore(s => s.user) - const defaultCurrency = useSettingsStore(s => s.settings.default_currency) || 'EUR' - const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled) - const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled) - const can = useCanDo() - const canUploadCover = !isEditing || can('trip_cover_upload', trip) - const canEditTrip = !isEditing || can('trip_edit', trip) +export default function TripFormModal({ + isOpen, + onClose, + onSave, + trip, + onCoverUpdate, + embedded = false, + onReviewChange, +}: TripFormModalProps) { + const isEditing = !!trip; + const labelId = useId(); + const fileRef = useRef(null); + const toast = useToast(); + const { t } = useTranslation(); + const currentUser = useAuthStore((s) => s.user); + const defaultCurrency = useSettingsStore((s) => s.settings.default_currency) || 'EUR'; + const tripRemindersEnabled = useAuthStore((s) => s.tripRemindersEnabled); + const setTripRemindersEnabled = useAuthStore((s) => s.setTripRemindersEnabled); + const can = useCanDo(); + const canUploadCover = !isEditing || can('trip_cover_upload', trip); + const canEditTrip = !isEditing || can('trip_edit', trip); const [formData, setFormData] = useState({ title: '', @@ -74,31 +92,36 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp currency: 'EUR', reminder_days: 0 as number, day_count: 7 as number | '', - }) - const [customReminder, setCustomReminder] = useState(false) - const [error, setError] = useState('') - const [isLoading, setIsLoading] = useState(false) - const cover = useTripCoverPicker({ trip, title: formData.title, onCoverUpdate, releasePreviewOnChange: false }) + }); + const [customReminder, setCustomReminder] = useState(false); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const cover = useTripCoverPicker({ trip, title: formData.title, onCoverUpdate, releasePreviewOnChange: false }); const { - coverPreview, uploadingCover, searchQuery: coverSearchQuery, setSearchQuery: setCoverSearchQuery, - searchResults: coverSearchResults, searchError: coverSearchError, searching: searchingCover, - } = cover + coverPreview, + uploadingCover, + searchQuery: coverSearchQuery, + setSearchQuery: setCoverSearchQuery, + searchResults: coverSearchResults, + searchError: coverSearchError, + searching: searchingCover, + } = cover; // Drives the drop zone's hover look. Used to be four handlers writing // element.style directly, which is how the indigo dragover colour survived // the move to a configurable accent. - const [coverDragActive, setCoverDragActive] = useState(false) - const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]) - const [selectedMembers, setSelectedMembers] = useState([]) - const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([]) - const [memberSelectValue, setMemberSelectValue] = useState('') - const [pendingReview, setPendingReview] = useState(null) - const [dateShiftMode, setDateShiftMode] = useState('keep_bookings') - const rangeGuard = useTripRangeGuard() - const busy = isLoading || rangeGuard.checking + const [coverDragActive, setCoverDragActive] = useState(false); + const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]); + const [selectedMembers, setSelectedMembers] = useState([]); + const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([]); + const [memberSelectValue, setMemberSelectValue] = useState(''); + const [pendingReview, setPendingReview] = useState(null); + const [dateShiftMode, setDateShiftMode] = useState('keep_bookings'); + const rangeGuard = useTripRangeGuard(); + const busy = isLoading || rangeGuard.checking; useEffect(() => { if (trip) { - const rd = trip.reminder_days ?? 3 + const rd = trip.reminder_days ?? 3; setFormData({ title: trip.title || '', description: trip.description || '', @@ -107,55 +130,82 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp currency: trip.currency || 'EUR', reminder_days: rd, day_count: trip.day_count || 7, - }) - setCustomReminder(![0, 1, 3, 9].includes(rd)) - cover.resetCover(trip.cover_image || null) + }); + setCustomReminder(![0, 1, 3, 9].includes(rd)); + cover.resetCover(trip.cover_image || null); } else { - setFormData({ title: '', description: '', start_date: '', end_date: '', currency: defaultCurrency, reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 }) - setCustomReminder(false) - cover.resetCover(null) + setFormData({ + title: '', + description: '', + start_date: '', + end_date: '', + currency: defaultCurrency, + reminder_days: tripRemindersEnabled ? 3 : 0, + day_count: 7, + }); + setCustomReminder(false); + cover.resetCover(null); } - setSelectedMembers([]) - setPendingReview(null) - setDateShiftMode('keep_bookings') - setError('') - setExistingMembers([]) + setSelectedMembers([]); + setPendingReview(null); + setDateShiftMode('keep_bookings'); + setError(''); + setExistingMembers([]); // The planner keeps this modal mounted while it is closed, so nothing may be // fetched until it is actually open. if (isOpen) { - authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => { - if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) - }).catch(() => {}) - authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) - if (trip) { - tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {}) + authApi + .getAppConfig() + .then((c: { trip_reminders_enabled?: boolean }) => { + if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled); + }) + .catch(() => {}); + // The members block is not drawn inside the trip settings, whose People area holds them. + if (!embedded) { + authApi + .listUsers() + .then((d) => setAllUsers(d.users || [])) + .catch(() => {}); + if (trip) + tripsApi + .getMembers(trip.id) + .then((d) => setExistingMembers(d.members || [])) + .catch(() => {}); } } - }, [trip, isOpen]) + }, [trip, isOpen, embedded]); useEffect(() => { if (!trip && isOpen) { - setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 })) + setFormData((prev) => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 })); } - }, [tripRemindersEnabled]) + }, [tripRemindersEnabled]); const handleSubmit = async (e) => { - e.preventDefault() - setError('') - const formError = tripFormError({ title: formData.title, startDate: formData.start_date, endDate: formData.end_date }, trip, t) - if (formError) { setError(formError); return } + e.preventDefault(); + setError(''); + const formError = tripFormError( + { title: formData.title, startDate: formData.start_date, endDate: formData.end_date }, + trip, + t + ); + if (formError) { + setError(formError); + return; + } if (!formData.start_date && !formData.end_date) { - const dc = Number(formData.day_count) + const dc = Number(formData.day_count); if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > MAX_TRIP_DAYS) { - setError(t('dashboard.dayCountRequired')); return + setError(t('dashboard.dayCountRequired')); + return; } } // The day count goes along only when it says something new. The trip's own // count goes stale in the planner (adding a day does not reload the trip), // and sending the stale number trims the day just added off a trip that was // only renamed. On a trip left without dates the server keeps its days anyway. - const dayCount = Number(formData.day_count) - const sendDayCount = !formData.start_date && !formData.end_date && (!isEditing || dayCount !== trip?.day_count) + const dayCount = Number(formData.day_count); + const sendDayCount = !formData.start_date && !formData.end_date && (!isEditing || dayCount !== trip?.day_count); const payload: TripPayload = { title: formData.title.trim(), description: formData.description.trim() || null, @@ -164,100 +214,113 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp currency: formData.currency, reminder_days: formData.reminder_days, ...(sendDayCount ? { day_count: dayCount } : {}), - } + }; // Moving the start of a dated trip shifts the whole day grid, so let the user // choose how bookings follow before anything is saved (#1288). End-date-only // changes don't shift days and dateless transitions have nothing to shift. - if (refusedOffline(payload)) return - const askShift = !!(isEditing && trip?.start_date && trip?.end_date - && payload.start_date && payload.end_date && payload.start_date !== trip.start_date) + if (refusedOffline(payload)) return; + const askShift = !!( + isEditing && + trip?.start_date && + trip?.end_date && + payload.start_date && + payload.end_date && + payload.start_date !== trip.start_date + ); // And say which days the new range removes, with what is on them, before // they are gone: the server takes the last days by position. - const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null + const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null; if (askShift || removal) { - setDateShiftMode('keep_bookings') - setPendingReview({ payload, askShift, removal }) - return + setDateShiftMode('keep_bookings'); + setPendingReview({ payload, askShift, removal }); + return; } - await performSave(payload) - } + await performSave(payload); + }; // A new trip, new dates or a new currency are made by the server; a new name // or description waits in the queue offline like any other change. const refusedOffline = (payload: TripPayload): boolean => { - if (!isEffectivelyOffline() || (isEditing && !tripChangeNeedsConnection(trip ?? undefined, payload))) return false - setError(t(isEditing ? 'dashboard.tripChangeNeedsConnection' : 'common.needsConnection')) - return true - } + if (!isEffectivelyOffline() || (isEditing && !tripChangeNeedsConnection(trip ?? undefined, payload))) return false; + setError(t(isEditing ? 'dashboard.tripChangeNeedsConnection' : 'common.needsConnection')); + return true; + }; const performSave = async (payload: TripPayload) => { - if (refusedOffline(payload)) return - setIsLoading(true) + if (refusedOffline(payload)) return; + setIsLoading(true); try { - const result = await onSave(payload) - const createdTrip = result ? result.trip : undefined + const result = await onSave(payload); + const createdTrip = result ? result.trip : undefined; // Add selected members for newly created trips if (selectedMembers.length > 0 && createdTrip?.id) { - let memberAddFailed = false + let memberAddFailed = false; for (const userId of selectedMembers) { - const user = allUsers.find(u => u.id === userId) + const user = allUsers.find((u) => u.id === userId); if (user) { - try { await tripsApi.addMember(createdTrip.id, user.username) } catch { memberAddFailed = true } + try { + await tripsApi.addMember(createdTrip.id, user.username); + } catch { + memberAddFailed = true; + } } } - if (memberAddFailed) toast.error(t('trips.memberAddError')) + if (memberAddFailed) toast.error(t('trips.memberAddError')); } // Upload pending cover for newly created trips - await cover.applyToCreatedTrip(createdTrip?.id) - onClose() + await cover.applyToCreatedTrip(createdTrip?.id); + onClose(); } catch (err: unknown) { - setError(err instanceof Error ? err.message : t('places.saveError')) + setError(err instanceof Error ? err.message : t('places.saveError')); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; - const handleCoverSelect = cover.selectFile + const handleCoverSelect = cover.selectFile; const handleCoverChange = (e: React.ChangeEvent) => { - void handleCoverSelect(e.target.files?.[0]) - e.target.value = '' - } + void handleCoverSelect(e.target.files?.[0]); + e.target.value = ''; + }; - const handleCoverSearch = cover.search - const handleUnsplashSelect = cover.selectPhoto - const handleRemoveCover = cover.removeCover + const handleCoverSearch = cover.search; + const handleUnsplashSelect = cover.selectPhoto; + const handleRemoveCover = cover.removeCover; // Paste support for cover image const handlePaste = (e: React.ClipboardEvent) => { - if (!canUploadCover) return - const items = e.clipboardData?.items - if (!items) return + if (!canUploadCover) return; + const items = e.clipboardData?.items; + if (!items) return; for (const item of Array.from(items)) { if (item.type.startsWith('image/')) { - e.preventDefault() - const file = item.getAsFile() - if (file) void handleCoverSelect(file) - return + e.preventDefault(); + const file = item.getAsFile(); + if (file) void handleCoverSelect(file); + return; } } - } + }; // An edit answers whatever the last save complained about, so the complaint // goes with it rather than standing over the fixed field until the next save. - const update = (field, value) => { setError(''); setFormData(prev => { - const next = { ...prev, [field]: value } - if (field === 'start_date' && value) { - if (!prev.end_date || prev.end_date < value) { - next.end_date = value - } else if (prev.start_date) { - // Keep the trip's length, counted in UTC calendar days: a local-time - // shift across a DST change used to drop or add a day. - next.end_date = addIsoDays(value, tripSpanDays(prev.start_date, prev.end_date) - 1) + const update = (field, value) => { + setError(''); + setFormData((prev) => { + const next = { ...prev, [field]: value }; + if (field === 'start_date' && value) { + if (!prev.end_date || prev.end_date < value) { + next.end_date = value; + } else if (prev.start_date) { + // Keep the trip's length, counted in UTC calendar days: a local-time + // shift across a DST change used to drop or add a day. + next.end_date = addIsoDays(value, tripSpanDays(prev.start_date, prev.end_date) - 1); + } } - } - return next - }) } + return next; + }); + }; /* The dashboard's shapes, in the app's own tokens. `.trek-dash` deliberately scopes its palette to that page, so none of its @@ -266,22 +329,41 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp fields, and overline captions rather than sentence-case labels. */ // The dialogs' own language: their fields, eyebrows, panels and buttons, so the // form reads alike on its own and inside the trip settings. - const inputCls = INPUT - const labelCls = `${LABEL.replace('block ', '')} flex items-center gap-1` + const inputCls = INPUT; + const labelCls = `${LABEL.replace('block ', '')} flex items-center gap-1`; /* Two columns once there is room for them: the form had grown to eight stacked blocks and the create button sat a full screen below the title. Left is what the trip looks like, right is when and with whom. One column below md. */ - const columnCls = "flex flex-col gap-4" - const panelCls = PANEL + const columnCls = 'flex flex-col gap-4'; + const panelCls = PANEL; - const reviewTitle = pendingReview ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') : '' + // Escape in the date question goes back to the form; embedded, the settings around it must not close on it. + const reviewing = pendingReview != null; + useEffect(() => { + onReviewChange?.(reviewing); + }, [reviewing, onReviewChange]); + + const reviewTitle = pendingReview + ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') + : ''; const buttons = pendingReview ? ( <> - setPendingReview(null)} disabled={busy}>{t('common.back')} + setPendingReview(null)} disabled={busy}> + {t('common.back')} + {/* Removing days with something on them is the one save here that loses work, so it asks in the danger tone and says so. */} - performSave(pendingReview.askShift ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } : pendingReview.payload)}> + + performSave( + pendingReview.askShift + ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } + : pendingReview.payload + ) + } + > {isLoading ? t('common.saving') : t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')} @@ -292,303 +374,412 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp {busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')} - ) + ); const content = ( <> - {pendingReview && ( - - )} -
- {error && ( -
{error}
- )} - -
- - {/* Left column — what the trip is: its picture, its name, what it is about. */} -
- - {/* Cover image — gated by trip_cover_upload permission */} - {canUploadCover &&
- } title={t('trip.form.coverTitle')} hint={t('trip.form.coverHint')} /> - - {coverPreview ? ( -
- -
- {/* Chrome sitting on top of a photo, so it is deliberately dark in - both themes rather than following the surface tokens. */} - - -
-
- ) : ( - - )} -
- setCoverSearchQuery(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleCoverSearch() } }} - placeholder={t('dashboard.unsplashSearchPlaceholder')} - className={inputCls} - /> - -
- {coverSearchError &&

{coverSearchError}

} - {coverSearchResults.length > 0 && ( -
- {coverSearchResults.map(photo => ( - - ))} -
- )} -
} - -
- } title={t('trip.form.aboutTitle')} hint={t('trip.form.aboutHint')} /> -
- - canEditTrip && update('title', e.target.value)} - required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} /> -
- -
- -