diff --git a/client/src/components/Planner/reservationFormModel.test.ts b/client/src/components/Planner/reservationFormModel.test.ts index e980949bc..82958d0de 100644 --- a/client/src/components/Planner/reservationFormModel.test.ts +++ b/client/src/components/Planner/reservationFormModel.test.ts @@ -112,6 +112,46 @@ describe('reservation form fields', () => { }); }); + it('FE-PLANNER-RESFORM-017: a hotel booking without its own times shows the ones its stay keeps', () => { + // The stay's check-in and check-out live on the accommodation; a booking that + // never had them written into its own metadata showed the "15:00" hint as if + // it were the saved time. + const acc = { + id: 9, + place_id: 4, + start_day_id: 2, + end_day_id: 3, + check_in: '15:00', + check_in_end: '22:00', + check_out: '11:00', + } as Accommodation; + const res = buildReservation({ + type: 'hotel', + accommodation_id: 9, + metadata: JSON.stringify({ price: '2184' }), + } as Partial); + expect(reservationFieldsFrom(res, [acc], [], false)).toMatchObject({ + meta_check_in_time: '15:00', + meta_check_out_time: '11:00', + }); + expect(reservationFieldsFrom(res, [acc], [], true)).toMatchObject({ meta_check_in_end_time: '22:00' }); + // The booking's own times still win. + const own = buildReservation({ + type: 'hotel', + accommodation_id: 9, + metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }), + } as Partial); + expect(reservationFieldsFrom(own, [acc], [], false)).toMatchObject({ + meta_check_in_time: '14:00', + meta_check_out_time: '10:00', + }); + // Nothing anywhere stays empty, so the hint shows as a hint. + expect(reservationFieldsFrom(buildReservation({ type: 'hotel' }), [], [], false)).toMatchObject({ + meta_check_in_time: '', + meta_check_out_time: '', + }); + }); + it('FE-PLANNER-RESFORM-004: an import under review resolves its stay days and keeps its source details', () => { const pf = { title: 'Inn', diff --git a/client/src/components/Planner/reservationFormModel.ts b/client/src/components/Planner/reservationFormModel.ts index 47b83760c..0f5cfb864 100644 --- a/client/src/components/Planner/reservationFormModel.ts +++ b/client/src/components/Planner/reservationFormModel.ts @@ -97,9 +97,13 @@ export function reservationFieldsFrom( url: res.url || '', place_id: res.place_id || '', accommodation_id: res.accommodation_id || '', - meta_check_in_time: meta.check_in_time || '', - ...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || '' } : {}), - meta_check_out_time: meta.check_out_time || '', + // The stay's own times when the booking carries none: a hotel booked before + // the times were written on it, or whose stay was edited from the plan, + // keeps them on the accommodation, and the empty field showed the + // "15:00" hint as if it were the saved check-in. + meta_check_in_time: meta.check_in_time || acc?.check_in || '', + ...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || acc?.check_in_end || '' } : {}), + meta_check_out_time: meta.check_out_time || acc?.check_out || '', hotel_place_id: acc?.place_id || '', hotel_start_day: acc?.start_day_id || '', hotel_end_day: acc?.end_day_id || '', diff --git a/client/src/components/shared/StarRating.test.tsx b/client/src/components/shared/StarRating.test.tsx index c02fe01b5..03f217bae 100644 --- a/client/src/components/shared/StarRating.test.tsx +++ b/client/src/components/shared/StarRating.test.tsx @@ -70,13 +70,23 @@ describe('Stars', () => { const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length expect(filled()).toBe(1) - fireEvent.mouseEnter(screen.getByRole('radio', { name: '4' })) + fireEvent.pointerEnter(screen.getByRole('radio', { name: '4' }), { pointerType: 'mouse' }) expect(filled()).toBe(4) - fireEvent.mouseLeave(container.querySelector('[role="radiogroup"]')!) + fireEvent.pointerLeave(container.querySelector('[role="radiogroup"]')!, { pointerType: 'mouse' }) expect(filled()).toBe(1) }) + it('FE-W4STAR-016: a finger previews nothing, so a tap cannot leave the stars filled', () => { + // A tap fires an enter with no leave after it: the preview used to stick, + // and an unrated place showed five full stars beside "Not rated yet". + const { container } = render( {}} />) + const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length + + fireEvent.pointerEnter(screen.getByRole('radio', { name: '5' }), { pointerType: 'touch' }) + expect(filled()).toBe(0) + }) + it('FE-W4STAR-008: focus previews and blur clears the preview', () => { const { container } = render( {}} />) const filled = () => Array.from(container.querySelectorAll('span > span')).filter(o => (o as HTMLElement).style.width).length diff --git a/client/src/components/shared/StarRating.tsx b/client/src/components/shared/StarRating.tsx index a693c386c..d3117ecf6 100644 --- a/client/src/components/shared/StarRating.tsx +++ b/client/src/components/shared/StarRating.tsx @@ -29,7 +29,7 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }: role={onRate ? 'radiogroup' : undefined} aria-label={ariaLabel} style={{ display: 'inline-flex', gap: 2, cursor: onRate ? 'pointer' : 'default' }} - onMouseLeave={() => setHover(null)} + onPointerLeave={() => setHover(null)} > {[1, 2, 3, 4, 5].map(i => { // Fractional fill for averages: full below, empty above, clipped between. @@ -52,7 +52,11 @@ export function Stars({ value, size = 15, onRate, myRating = null, ariaLabel }: role="radio" aria-checked={myRating === i} aria-label={`${i}`} - onMouseEnter={() => setHover(i)} + // A preview for a pointer that hovers (a mouse, a trackpad, a pen held + // above the screen). A finger has no hover: the enter it fires on a + // tap has no leave after it, so the preview stuck and an unrated + // place showed five full stars beside "Not rated yet". + onPointerEnter={e => { if (e.pointerType !== 'touch') setHover(i) }} onFocus={() => setHover(i)} onBlur={() => setHover(null)} onClick={() => onRate(myRating === i ? null : i)} diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx index e58fc7bd1..af1261743 100644 --- a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx +++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx @@ -5,14 +5,13 @@ import { useCanDo } from '../../../store/permissionsStore' import { useSettingsStore } from '../../../store/settingsStore' import { useAuthStore } from '../../../store/authStore' import MChip from '../../components/MChip' -import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker' import CustomSelect from '../../../components/shared/CustomSelect' import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants' import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel' import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared' import MSheet from '../../components/MSheet' -import { FIELD_AREA_CLS } from '../trip/sheets/PlSheetChrome' -import { Eyebrow, SHEET_FIELD, SHEET_PRIMARY, SHEET_ROUND_PRIMARY, SHEET_SECONDARY, SheetSection, TileHeader } from '../trip/sheets/MTripSheetUi' +import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetBody, FormSheetFooter, FormSheetHeader, SheetDateField } from '../trip/sheets/PlSheetChrome' +import { SHEET_FIELD, SHEET_ROUND_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi' import MDayImpactList from '../../components/MDayImpactList' import MConfirmSheet from '../settings/MConfirmSheet' import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard' @@ -31,9 +30,6 @@ interface MNewTripSheetProps { onArchive?: () => void } -/** A date picker in the sheets' round field. */ -const DATE_FIELD = 'flex h-[42px] items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]' - /** The reminder presets the desktop form offers; anything else is a custom number of days. */ const REMINDER_PRESETS = [0, 1, 3, 9] @@ -151,108 +147,100 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o return ( <> - -
- } - title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')} - sub={isEditing ? trip?.title : undefined} - onClose={onClose} - closeLabel={t('common.cancel')} - /> -
+ + -
+ {/* Flat fields under their labels, as in every other form sheet: no + cards inside the card. */} + {error && ( -
+
{error}
)} - - { if (canEditTrip) { setError(''); setTitle(e.target.value) } }} - readOnly={!canEditTrip} - placeholder={t('dashboard.tripTitlePlaceholder')} - aria-label={t('dashboard.tripTitle')} - className={`${SHEET_FIELD} w-full flex-none font-semibold`} - /> - {t('dashboard.tripDescription')} -