diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index 564ec67cc..9be8ad13d 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-080 +// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-084 import React from 'react'; import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -12,6 +12,7 @@ import { usePluginStore } from '../../store/pluginStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'; import DayDetailPanel from './DayDetailPanel'; +import { isBlurred } from '../../../tests/helpers/bookingCodeBlur'; const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' }); @@ -1896,3 +1897,80 @@ describe('DayDetailPanel time format', () => { expect(screen.getByText('11:00')).toBeInTheDocument(); }); }); + +// ── Blur booking codes: the stay's own code and the hotel editor (#2457) ──── + +describe('DayDetailPanel blur booking codes (#2457)', () => { + beforeEach(() => { + server.use(http.get('/api/view-contributions/:view/:tripId', () => HttpResponse.json({ contributions: [] }))); + }); + + const blurOn = (on: boolean) => seedStore(useSettingsStore, { + settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: on }, + }); + const stayWithCode = (confirmation: string) => server.use( + http.get('/api/trips/1/accommodations', () => + HttpResponse.json({ + accommodations: [{ + id: 1, place_id: 5, place_name: 'Code Hotel', place_address: 'Paris', + start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation, + }], + }) + ), + ); + const openHotelEditor = async () => { + await screen.findByText('Code Hotel'); + // header collapse (0), header close (1), pencil (2), remove (3), as in DAYDETAIL-032 + await userEvent.click(screen.getAllByRole('button')[2]); + await waitFor(() => { + expect(document.body.querySelector('[style*="z-index: 99999"]')?.textContent).toMatch(/Edit accommodation/i); + }); + }; + + it('FE-PLANNER-DAYDETAIL-081: the confirmation code of the stay itself is blurred in the details grid', async () => { + blurOn(true); + stayWithCode('HOTEL-SECRET'); + render(); + const code = await screen.findByText('HOTEL-SECRET'); + expect(isBlurred(code)).toBe(true); + }); + + it('FE-PLANNER-DAYDETAIL-082: the confirmation field of the hotel editor is blurred until focused', async () => { + blurOn(true); + stayWithCode('HOTEL-SECRET'); + render(); + await openHotelEditor(); + const field = screen.getByDisplayValue('HOTEL-SECRET') as HTMLInputElement; + expect(isBlurred(field)).toBe(true); + act(() => field.focus()); + expect(isBlurred(field)).toBe(false); + act(() => field.blur()); + expect(isBlurred(field)).toBe(true); + }); + + it('FE-PLANNER-DAYDETAIL-083: with the setting off the stay code and the editor field stay plain', async () => { + blurOn(false); + stayWithCode('HOTEL-PLAIN'); + render(); + expect(isBlurred(await screen.findByText('HOTEL-PLAIN'))).toBe(false); + await openHotelEditor(); + expect(isBlurred(screen.getByDisplayValue('HOTEL-PLAIN'))).toBe(false); + }); + + it('FE-PLANNER-DAYDETAIL-084: a blurred code in the hotel editor still saves unchanged', async () => { + blurOn(true); + let body: Record | null = null; + stayWithCode('HOTEL-SECRET'); + server.use( + http.put('/api/trips/1/accommodations/1', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ accommodation: { id: 1, place_id: 5, place_name: 'Code Hotel', start_day_id: 1, end_day_id: 3, confirmation: 'HOTEL-SECRET' } }); + }), + ); + render(); + await openHotelEditor(); + await userEvent.click(screen.getByText(/Save/i)); + await waitFor(() => expect(body).not.toBeNull()); + expect(body!.confirmation).toBe('HOTEL-SECRET'); + }); +}); diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index b1c4e0f7f..439f66fbc 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -13,6 +13,7 @@ import { useTripStore } from '../../store/tripStore' import { applyStayStops } from '../../store/stayStops' import CustomSelect from '../shared/CustomSelect' import CustomTimePicker from '../shared/CustomTimePicker' +import { BlurredCode, BookingCodeInput } from '../shared/BookingCode' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' import { getLocaleForLanguage, useTranslation } from '../../i18n' @@ -453,7 +454,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays, )} {acc.confirmation && (
-
{acc.confirmation}
+
{acc.confirmation}
{t('day.confirmation')}
@@ -632,7 +633,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD
- setHotelForm(f => ({ ...f, confirmation: e.target.value }))} + setHotelForm(f => ({ ...f, confirmation: e.target.value }))} placeholder="ABC-12345" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', boxSizing: 'border-box', height: 38 }} />
diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index facc0317b..be48aeec0 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -12,6 +12,7 @@ import { usePermissionsStore } from '../../store/permissionsStore'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import type { AssignmentsMap } from '../../types'; +import { isBlurred } from '../../../tests/helpers/bookingCodeBlur'; // ── Module mocks ────────────────────────────────────────────────────────────── @@ -1565,3 +1566,25 @@ describe('PlaceInspector', () => { expect(screen.queryByText('Remove from Day')).toBeNull(); }); }) + +// ── Blur booking codes on the linked-booking strip (#2457) ──────────────────── + +describe('PlaceInspector blur booking codes (#2457)', () => { + const renderWithBooking = (blur: boolean) => { + seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: blur } }); + const res = { ...buildReservation({ id: 3, title: 'Dinner at Jules Verne', status: 'confirmed', confirmation_number: 'TABLE-SECRET' }), assignment_id: 9 }; + render(); + }; + + it('FE-PLANNER-INSPECTOR-103: the booking code on the linked-booking strip is blurred while the setting is on', () => { + renderWithBooking(true); + expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(true); + }); + + it('FE-PLANNER-INSPECTOR-104: with the setting off the strip shows the code plainly', () => { + renderWithBooking(false); + expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(false); + }); +}); diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index aed903a36..5ffbc52f0 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -10,6 +10,7 @@ import { markdownLinkComponents } from '../shared/markdownLink' import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatarUpload from '../shared/PlaceAvatarUpload' +import { BlurredCode } from '../shared/BookingCode' import PlaceRating from '../shared/StarRating' import TrackColorPicker from '../shared/TrackColorPicker' import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors' @@ -960,7 +961,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi {res.confirmation_number && (
{t('reservations.confirmationCode')}
-
{res.confirmation_number}
+
{res.confirmation_number}
)} diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index b129bea73..e5927db5a 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,11 +1,13 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-095 -import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-100 +import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useAddonStore } from '../../store/addonStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { isBlurred } from '../../../tests/helpers/bookingCodeBlur'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, @@ -1566,4 +1568,57 @@ describe('ReservationModal', () => { expect(times[0].value).toBe('16:00'); expect(times[2].value).toBe('10:30'); }); + + // ── Blur booking codes in the edit form (#2457) ───────────────────────────── + + describe('blur booking codes (#2457)', () => { + const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } }); + + it('FE-PLANNER-RESMODAL-096: the booking code field is blurred while the setting is on and the field is not focused', () => { + blurOn(true); + const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-SECRET' }); + render(); + const code = screen.getByDisplayValue('PNR-SECRET'); + expect(isBlurred(code)).toBe(true); + }); + + it('FE-PLANNER-RESMODAL-097: a hotel booking hides its code the same way', () => { + blurOn(true); + const res = buildReservation({ type: 'hotel', title: 'Hotel Adlon', confirmation_number: 'HOTEL-SECRET' }); + render(); + expect(isBlurred(screen.getByDisplayValue('HOTEL-SECRET'))).toBe(true); + }); + + it('FE-PLANNER-RESMODAL-098: focusing the field reveals the code for editing, leaving it hides it again', () => { + blurOn(true); + const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-SECRET' }); + render(); + const code = screen.getByDisplayValue('PNR-SECRET') as HTMLInputElement; + expect(isBlurred(code)).toBe(true); + act(() => code.focus()); + expect(isBlurred(code)).toBe(false); + act(() => code.blur()); + expect(isBlurred(code)).toBe(true); + }); + + it('FE-PLANNER-RESMODAL-099: with the setting off the code stays plain', () => { + blurOn(false); + const res = buildReservation({ type: 'restaurant', confirmation_number: 'PNR-PLAIN' }); + render(); + expect(isBlurred(screen.getByDisplayValue('PNR-PLAIN'))).toBe(false); + }); + + it('FE-PLANNER-RESMODAL-100: a blurred code still saves unchanged, and an edit typed into it is kept', async () => { + blurOn(true); + const onSave = vi.fn().mockResolvedValue(undefined); + const res = buildReservation({ type: 'restaurant', title: 'Dinner', confirmation_number: 'PNR-SECRET' }); + render(); + const code = screen.getByDisplayValue('PNR-SECRET'); + await userEvent.clear(code); + await userEvent.type(code, 'PNR-NEW'); + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0].confirmation_number).toBe('PNR-NEW'); + }); + }); }); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 6fb092ffa..c7d73bdaf 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -6,6 +6,7 @@ import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' +import { BookingCodeInput } from '../shared/BookingCode' import { buildAssignmentOptions } from './assignmentOptions' import AddressInput from './AddressInput' import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react' @@ -559,7 +560,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
- set('confirmation_number', e.target.value)} + set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index c8c651384..beed29cdd 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -1,11 +1,13 @@ -// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-064 -import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; +// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-070 +import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useAddonStore } from '../../store/addonStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import { isBlurred } from '../../../tests/helpers/bookingCodeBlur'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, @@ -1357,4 +1359,83 @@ describe('TransportModal', () => { expect(screen.getByText(/Orsay/)).toBeInTheDocument(); expect(screen.queryByText('Louvre')).not.toBeInTheDocument(); }); + + // ── Blur booking codes in the edit form (#2457) ───────────────────────────── + + describe('blur booking codes (#2457)', () => { + const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } }); + const codeFields = () => screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[]; + + function multiLegTrain(): Reservation { + const res = buildReservation({ id: 32, title: 'Osaka → Kyoto → Nara', type: 'train' }); + return Object.assign(res, { + day_id: 10, + end_day_id: 10, + confirmation_number: 'RAIL-BOOK', + metadata: { + legs: [ + { from: 'Osaka', to: 'Kyoto', train_number: 'JR 1', confirmation_number: 'RAIL-LEG1', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '08:30' }, + { from: 'Kyoto', to: 'Nara', train_number: 'JR 2', confirmation_number: 'RAIL-LEG2', dep_day_id: 10, dep_time: '09:00', arr_day_id: 10, arr_time: '09:45' }, + ], + }, + endpoints: [ + { id: 11, reservation_id: 32, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '08:00' }, + { id: 12, reservation_id: 32, role: 'stop', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:00' }, + { id: 13, reservation_id: 32, role: 'to', sequence: 2, name: 'Nara Station', code: null, lat: 34.68, lng: 135.82, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:45' }, + ], + }) as unknown as Reservation; + } + + it('FE-PLANNER-TRANSMODAL-065: the flight booking code field is blurred while the setting is on', () => { + blurOn(true); + const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' }); + render(); + expect(isBlurred(screen.getByDisplayValue('FLY-SECRET'))).toBe(true); + }); + + it('FE-PLANNER-TRANSMODAL-066: every per-segment code of a stopover flight is blurred as well', () => { + blurOn(true); + render(); + const codes = codeFields(); + expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']); + expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]); + }); + + it('FE-PLANNER-TRANSMODAL-067: every per-segment code of a multi-leg train is blurred as well', () => { + blurOn(true); + render(); + const codes = codeFields(); + expect(codes.map(i => i.value)).toEqual(['RAIL-LEG1', 'RAIL-LEG2', 'RAIL-BOOK']); + expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]); + }); + + it('FE-PLANNER-TRANSMODAL-068: focusing a code field reveals it for editing, leaving it hides it again', () => { + blurOn(true); + const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' }); + render(); + const code = screen.getByDisplayValue('FLY-SECRET') as HTMLInputElement; + expect(isBlurred(code)).toBe(true); + act(() => code.focus()); + expect(isBlurred(code)).toBe(false); + act(() => code.blur()); + expect(isBlurred(code)).toBe(true); + }); + + it('FE-PLANNER-TRANSMODAL-069: with the setting off every code field stays plain', () => { + blurOn(false); + render(); + expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false]); + }); + + it('FE-PLANNER-TRANSMODAL-070: blurred codes still save unchanged', async () => { + blurOn(true); + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + await userEvent.click(screen.getByRole('button', { name: /^Update$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const payload = onSave.mock.calls[0][0]; + expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']); + expect(payload.confirmation_number).toBe('BOOK1'); + }); + }); }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 9e2b330c2..270e07aa9 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router' import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' +import { BookingCodeInput } from '../shared/BookingCode' import CustomTimePicker from '../shared/CustomTimePicker' import AirportSelect, { type Airport } from './AirportSelect' import LocationSelect, { type LocationPoint } from './LocationSelect' @@ -903,7 +904,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel {writesFlightLegs && (
- updateWp({ confirmation_number: e.target.value })} + updateWp({ confirmation_number: e.target.value })} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
)} @@ -984,7 +985,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel {writesTrainLegs && (
- updateWp({ confirmation_number: e.target.value })} + updateWp({ confirmation_number: e.target.value })} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
)} @@ -1119,7 +1120,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
- set('confirmation_number', e.target.value)} + set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
diff --git a/client/src/components/shared/BookingCode.test.tsx b/client/src/components/shared/BookingCode.test.tsx new file mode 100644 index 000000000..32f1b28ec --- /dev/null +++ b/client/src/components/shared/BookingCode.test.tsx @@ -0,0 +1,156 @@ +// FE-COMP-BOOKCODE-001 to FE-COMP-BOOKCODE-014 +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createRef, useState } from 'react' +import { useSettingsStore } from '../../store/settingsStore' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { isBlurred } from '../../../tests/helpers/bookingCodeBlur' +import { useBlurBookingCodes } from '../../hooks/useBlurBookingCodes' +import { BlurredCode, BookingCodeInput } from './BookingCode' + +const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { blur_booking_codes: on } }) + +function Field({ initial = 'PNR-1', ...rest }: { initial?: string; onFocus?: () => void; onBlur?: () => void; className?: string }) { + const [v, setV] = useState(initial) + return setV(e.target.value)} placeholder="ABC-12345" aria-label="code" {...rest} /> +} + +describe('useBlurBookingCodes', () => { + beforeEach(() => resetAllStores()) + + it('FE-COMP-BOOKCODE-001: follows the setting and reads an unset one as off', () => { + seedStore(useSettingsStore, { settings: { blur_booking_codes: undefined } }) + const { result } = renderHook(() => useBlurBookingCodes()) + expect(result.current).toBe(false) + act(() => blurOn(true)) + expect(result.current).toBe(true) + }) +}) + +describe('BookingCodeInput', () => { + beforeEach(() => resetAllStores()) + + it('FE-COMP-BOOKCODE-002: with the setting off the field is a plain text input with the caller classes only', () => { + blurOn(false) + render() + const field = screen.getByLabelText('code') as HTMLInputElement + expect(field.type).toBe('text') + expect(field.className).toBe('w-full border') + expect(isBlurred(field)).toBe(false) + }) + + it('FE-COMP-BOOKCODE-003: without caller classes and the setting off it adds no class attribute at all', () => { + blurOn(false) + render() + expect(screen.getByLabelText('code').hasAttribute('class')).toBe(false) + }) + + it('FE-COMP-BOOKCODE-004: with the setting on a filled field is blurred and keeps the caller classes', () => { + blurOn(true) + render() + const field = screen.getByLabelText('code') as HTMLInputElement + expect(field.value).toBe('PNR-1') + expect(field.className.startsWith('w-full border ')).toBe(true) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-COMP-BOOKCODE-005: focus lifts the blur and leaving the field brings it back', () => { + blurOn(true) + render() + const field = screen.getByLabelText('code') as HTMLInputElement + act(() => field.focus()) + expect(isBlurred(field)).toBe(false) + act(() => field.blur()) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-COMP-BOOKCODE-006: an empty field is never blurred, so its placeholder stays readable', () => { + blurOn(true) + render() + expect(isBlurred(screen.getByPlaceholderText('ABC-12345'))).toBe(false) + }) + + it('FE-COMP-BOOKCODE-007: what is typed into a blurred field is the value, and the caller focus handlers still run', async () => { + blurOn(true) + const onFocus = vi.fn() + const onBlur = vi.fn() + render() + const field = screen.getByLabelText('code') as HTMLInputElement + await userEvent.clear(field) + await userEvent.type(field, 'NEW-2') + expect(field.value).toBe('NEW-2') + expect(onFocus).toHaveBeenCalled() + act(() => field.blur()) + expect(onBlur).toHaveBeenCalled() + expect(isBlurred(field)).toBe(true) + }) + + it('FE-COMP-BOOKCODE-008: the ref reaches the input element', () => { + blurOn(true) + const ref = createRef() + render( {}} />) + expect(ref.current).toBeInstanceOf(HTMLInputElement) + expect(ref.current?.value).toBe('X') + }) +}) + +describe('BlurredCode', () => { + beforeEach(() => resetAllStores()) + + it('FE-COMP-BOOKCODE-009: with the setting off it renders the bare code and no element around it', () => { + blurOn(false) + const { container } = render(
ABC999
) + expect(screen.getByTestId('host').innerHTML).toBe('ABC999') + expect(container.querySelector('button')).toBeNull() + }) + + it('FE-COMP-BOOKCODE-010: with the setting on the code is a blurred toggle that a click reveals and hides again', () => { + blurOn(true) + render(ABC999) + const code = screen.getByRole('button', { name: 'ABC999' }) + expect(code).toHaveAttribute('aria-pressed', 'false') + expect(isBlurred(code)).toBe(true) + fireEvent.click(code) + expect(code).toHaveAttribute('aria-pressed', 'true') + expect(isBlurred(code)).toBe(false) + fireEvent.click(code) + expect(isBlurred(code)).toBe(true) + }) + + it('FE-COMP-BOOKCODE-011: revealing the code does not also trigger the clickable strip around it', () => { + blurOn(true) + const onRowClick = vi.fn() + render(
ABC999
) + fireEvent.click(screen.getByRole('button', { name: 'ABC999' })) + expect(onRowClick).not.toHaveBeenCalled() + }) + + it('FE-COMP-BOOKCODE-012: Enter and Space stay with the code, other keys still reach the strip', () => { + blurOn(true) + const onRowKey = vi.fn() + render(
onRowKey(e.key)}>ABC999
) + const code = screen.getByRole('button', { name: 'ABC999' }) + fireEvent.keyDown(code, { key: 'Enter' }) + fireEvent.keyDown(code, { key: ' ' }) + expect(onRowKey).not.toHaveBeenCalled() + fireEvent.keyDown(code, { key: 'Escape' }) + expect(onRowKey).toHaveBeenCalledWith('Escape') + }) + + it('FE-COMP-BOOKCODE-013: the non-interactive variant is a blurred span with the caller classes, not a button', () => { + blurOn(true) + render() + const code = screen.getByText('#X9') + expect(code.tagName).toBe('SPAN') + expect(code.className.startsWith('font-geist ')).toBe(true) + expect(isBlurred(code)).toBe(true) + expect(screen.getAllByRole('button')).toHaveLength(1) + }) + + it('FE-COMP-BOOKCODE-014: the non-interactive variant is bare text as well once the setting is off', () => { + blurOn(false) + render(Confirmed · #X9) + expect(screen.getByTestId('host').innerHTML).toBe('Confirmed · #X9') + }) +}) diff --git a/client/src/components/shared/BookingCode.tsx b/client/src/components/shared/BookingCode.tsx new file mode 100644 index 000000000..ef8fbc468 --- /dev/null +++ b/client/src/components/shared/BookingCode.tsx @@ -0,0 +1,79 @@ +import { useState, type InputHTMLAttributes, type ReactNode, type Ref } from 'react' +import { useBlurBookingCodes } from '../../hooks/useBlurBookingCodes' + +/** + * The "Blur booking codes" preference (blur_booking_codes) keeps confirmation + * codes unreadable to anyone looking at the screen until the user reaches for + * them. The pieces here carry it to every place that shows or edits a code, so + * a new surface gets the same look and the same way of revealing a code + * without reading the setting on its own. + */ + +// The strength the code boxes in the bookings tabs and the day sheet use. +const BLUR_CLS = 'blur-[4px] select-none' + +const joinCls = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(' ') + +type BookingCodeInputProps = Omit, 'type'> & { + ref?: Ref +} + +/** + * The text field of a form that edits a booking code. While the preference is + * on, a filled field stays blurred until the pointer rests on it or it gets + * focus, so the code is still there to read and change the moment the user + * clicks, taps or tabs into it. An empty field keeps its placeholder readable. + * + * Only a class is added: the value, the handlers and the ref pass through + * untouched, so what gets saved is exactly what was typed, and there is no + * wrapper to upset the grid and flex rows the field sits in. It stays a text + * field on purpose; a password field would bring the browser's password + * manager and a different phone keyboard with it. + */ +export function BookingCodeInput({ className, ...props }: BookingCodeInputProps) { + const blur = useBlurBookingCodes() + const hidden = blur && props.value != null && props.value !== '' + return ( + + ) +} + +/** + * A booking code shown as text. With the preference off it renders the code + * and nothing around it, so the markup of the surface stays what it was. + * + * With the preference on, the code becomes a small toggle that lifts on hover + * and on click, Enter or Space, like the codes in the bookings list. Inside a + * row that is a button itself a second button is not allowed, so + * `interactive={false}` gives a plain blurred span there; the booking the row + * opens is where that code can be read. + */ +export function BlurredCode({ children, interactive = true, className }: { + children: ReactNode + interactive?: boolean + className?: string +}) { + const blur = useBlurBookingCodes() + const [revealed, setRevealed] = useState(false) + if (!blur) return <>{children} + if (!interactive) return {children} + return ( + + ) +} diff --git a/client/src/hooks/useBlurBookingCodes.ts b/client/src/hooks/useBlurBookingCodes.ts new file mode 100644 index 000000000..46922fe6c --- /dev/null +++ b/client/src/hooks/useBlurBookingCodes.ts @@ -0,0 +1,12 @@ +import { useSettingsStore } from '../store/settingsStore' + +/** + * Whether booking codes are drawn blurred for the signed-in user (the + * "Blur booking codes" preference, blur_booking_codes). An unset preference + * reads as off. The pieces in `components/shared/BookingCode` build on it; + * reach for it directly only where a surface has to lay out a code + * differently while it is hidden. + */ +export function useBlurBookingCodes(): boolean { + return useSettingsStore(s => !!s.settings.blur_booking_codes) +} diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx index b86e12e14..803a53cb6 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx @@ -3,6 +3,8 @@ import type { ReactNode, MouseEvent, CSSProperties } from 'react' import PlaceAvatar from '../../../../components/shared/PlaceAvatar' import { getCategoryIcon } from '../../../../components/shared/categoryIcons' import MarkdownText from '../../../../components/shared/MarkdownText' +import { BlurredCode } from '../../../../components/shared/BookingCode' +import { useBlurBookingCodes } from '../../../../hooks/useBlurBookingCodes' import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants' import { noteSurface } from '../../../../components/Planner/noteSurface' import { getDisplayTimeForDay, getSpanPhase } from '../../../../utils/dayMerge' @@ -113,6 +115,7 @@ function AvatarRing({ children, className = '', style }: { children: ReactNode; } const TIME_CHIP = 'flex-none whitespace-nowrap rounded-[6px] bg-[color:var(--m-ic)] px-[6px] py-px font-geist text-[0.65625rem] font-semibold' +const ROW_CODE = 'flex-none whitespace-nowrap font-geist text-[0.71875rem] text-m-muted' // ── b3) Place row ──────────────────────────────────────────────────────────── @@ -131,17 +134,23 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re const place = assignment.place const CatIcon = getCategoryIcon(place?.category?.icon) const time = fmtTime(place?.place_time, chrome) + const blurCodes = useBlurBookingCodes() // One line per booking on the stop (#2201). A single one keeps the bare // status line it always had; once there are several, the title tells them // apart, since "Confirmed" twice over says nothing. - const bookingLines = linkedReservations.map(r => ({ - id: r.id, - text: [ + // A code under the blur setting cannot stay inside the line, because the + // first line goes through the Markdown caption and that only takes a string. + // It is kept apart as `code` and drawn blurred right after the line. + const bookingLines = linkedReservations.map(r => { + const code = r.confirmation_number ? `#${r.confirmation_number}` : '' + const parts = [ linkedReservations.length > 1 ? r.title : '', r.status === 'confirmed' ? t('dayplan.confirmed') : t('dayplan.pendingRes'), - r.confirmation_number ? `#${r.confirmation_number}` : '', - ].filter(Boolean).join(' · '), - })) + ] + return blurCodes && code + ? { id: r.id, text: `${parts.filter(Boolean).join(' · ')} ·`, code } + : { id: r.id, text: [...parts, code].filter(Boolean).join(' · '), code: '' } + }) const sub = bookingLines.length > 0 ? bookingLines[0].text : place?.address || place?.description || '' @@ -191,11 +200,13 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
{time && {time}} {sub && {sub}} + {bookingLines[0]?.code && {bookingLines[0].code}}
)} {bookingLines.slice(1).map(line => (
{line.text} + {line.code && {line.code}}
))} {assignment.notes && ( diff --git a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx index ed6b22b57..7ce0b31e1 100644 --- a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx @@ -4,6 +4,7 @@ import MSheet from '../../../components/MSheet' import MIconBtn from '../../../components/MIconBtn' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' +import { BookingCodeInput } from '../../../../components/shared/BookingCode' import { accommodationsApi } from '../../../../api/client' import { applyStayStops } from '../../../../store/stayStops' import { useTranslation } from '../../../../i18n' @@ -204,8 +205,7 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps {/* Confirmation */} {t('day.confirmation')} - setForm(f => ({ ...f, confirmation: e.target.value }))} placeholder="ABC-12345" diff --git a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx index 1ad7b52bd..9af5d3b7a 100644 --- a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx @@ -17,6 +17,7 @@ import { getTransportForDay, hasCarrierEndpointOnDay } from '../../../../utils/d import { splitReservationDateTime } from '../../../../utils/formatters' import { dayCoMapsUrl, dayGoogleMapsUrl, optimizeDayOrder } from '../lib/dayRoute' import GoogleMapsIcon from '../../../../components/shared/GoogleMapsIcon' +import { BlurredCode } from '../../../../components/shared/BookingCode' import { splitNoteTime } from '../lib/dayNotes' import { weatherIconFor } from '../plan/planTimelineModel' import type { Assignment, DayNote, Reservation } from '../../../../types' @@ -566,7 +567,8 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) { /> {linked.status === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending')} - {linked.confirmation_number ? ` · #${linked.confirmation_number}` : ''} + {/* The strip is a button already, so the code gets the plain blur, not a toggle. */} + {linked.confirmation_number ? <> · {`#${linked.confirmation_number}`} : ''} )} diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index 3661e070f..d29902ae5 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -9,6 +9,7 @@ import { typeToCostCategory } from '@trek/shared' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' +import { BookingCodeInput } from '../../../../components/shared/BookingCode' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import PlFileAttach from './PlFileAttach' import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions' @@ -513,8 +514,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
{t('reservations.confirmationCode')} - set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index 9d41434d2..8644d3244 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -8,6 +8,7 @@ import { orderedEndpoints, parseReservationMetadata } from '../../../../utils/fl import { typeToCostCategory } from '@trek/shared' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' +import { BookingCodeInput } from '../../../../components/shared/BookingCode' import AirportSelect, { type Airport } from '../../../../components/Planner/AirportSelect' import LocationSelect, { type LocationPoint } from '../../../../components/Planner/LocationSelect' import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel' @@ -809,8 +810,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo {writesFlightLegs && (
{t('reservations.confirmationCode')} - updateWp({ confirmation_number: e.target.value })} placeholder={t('reservations.confirmationPlaceholder')} @@ -899,8 +899,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo {writesTrainLegs && (
{t('reservations.confirmationCode')} - updateWp({ confirmation_number: e.target.value })} placeholder={t('reservations.confirmationPlaceholder')} @@ -1029,8 +1028,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
{t('reservations.confirmationCode')} - set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} diff --git a/client/tests/helpers/bookingCodeBlur.ts b/client/tests/helpers/bookingCodeBlur.ts new file mode 100644 index 000000000..8522f6521 --- /dev/null +++ b/client/tests/helpers/bookingCodeBlur.ts @@ -0,0 +1,24 @@ +/** + * Whether a booking code is hidden by the "Blur booking codes" setting + * (blur_booking_codes) at the moment of the call. + * + * The surfaces hide a code in one of two ways: the older desktop ones set an + * inline `filter: blur(...)`, the rest carry a Tailwind `blur-[...]` class. The + * blur may sit on the element holding the code or on the box right around it, + * so the element and its two nearest ancestors are checked. A focus variant + * (`focus:blur-none`) counts as lifted while the element has focus, because + * that is what the browser would render. + */ +export function isBlurred(el: Element | null): boolean { + let node: Element | null = el + for (let depth = 0; node && depth < 3; depth++, node = node.parentElement) { + const h = node as HTMLElement + if (h.style?.filter?.includes('blur(')) return true + const tokens = (typeof h.className === 'string' ? h.className : '').split(/\s+/) + const blurClass = tokens.some(tk => /^blur(-.+)?$/.test(tk) && tk !== 'blur-none') + const liftedByFocus = h === document.activeElement + && tokens.some(tk => /^focus(-visible|-within)?:(blur-none|filter-none)$/.test(tk)) + if (blurClass && !liftedByFocus) return true + } + return false +} diff --git a/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx b/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx index 51608ec13..e122e06b4 100644 --- a/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx @@ -4,9 +4,11 @@ import MAccommodationSheet from '../../../../src/mobile/screens/trip/sheets/MAcc import type { Accommodation, Category, Day, Place } from '../../../../src/types' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { resetAllStores } from '../../../helpers/store' +import { useSettingsStore } from '../../../../src/store/settingsStore' +import { isBlurred } from '../../../helpers/bookingCodeBlur' import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-ACCSH-001 to FE-MOB-ACCSH-025 +// FE-MOB-ACCSH-001 to FE-MOB-ACCSH-029 // The pickers have their own suites; here they only need to be addressable, so // they render as plain controls. data-value keeps the requested value readable @@ -352,4 +354,45 @@ describe('MAccommodationSheet', () => { expect(shell.openSheet).not.toHaveBeenCalled() expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() }) + + // ── Blur booking codes in the stay editor (#2457) ────────────────────────── + + describe('blur booking codes (#2457)', () => { + /** Both sources the phone reads the preference from: the settings store and planner.settings. */ + function plannerWithBlur(on: boolean) { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) + return makePlanner({ + settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km', blur_booking_codes: on }, + }) + } + const codeField = () => screen.getByPlaceholderText('ABC-12345') as HTMLInputElement + + it('FE-MOB-ACCSH-026: the confirmation field of an edited stay is blurred while the setting is on', () => { + setup(plannerWithBlur(true), makeShell({ dayId: 12, accId: 77 })) + expect(codeField()).toHaveValue('ABC-1') + expect(isBlurred(codeField())).toBe(true) + }) + + it('FE-MOB-ACCSH-027: focusing the field reveals the code for editing, leaving it hides it again', () => { + setup(plannerWithBlur(true), makeShell({ dayId: 12, accId: 77 })) + const field = codeField() + act(() => field.focus()) + expect(isBlurred(field)).toBe(false) + act(() => field.blur()) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-MOB-ACCSH-028: with the setting off the code stays plain', () => { + setup(plannerWithBlur(false), makeShell({ dayId: 12, accId: 77 })) + expect(isBlurred(codeField())).toBe(false) + }) + + it('FE-MOB-ACCSH-029: a blurred code still saves unchanged', async () => { + const update = vi.spyOn(accommodationsApi, 'update').mockResolvedValue({}) + setup(plannerWithBlur(true), makeShell({ dayId: 12, accId: 77 })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(update).toHaveBeenCalled()) + expect(update.mock.calls[0][2]).toMatchObject({ confirmation: 'ABC-1' }) + }) + }) }) diff --git a/client/tests/unit/mobile/trip/MDaySheet.test.tsx b/client/tests/unit/mobile/trip/MDaySheet.test.tsx index 9340d451c..f3a2582bf 100644 --- a/client/tests/unit/mobile/trip/MDaySheet.test.tsx +++ b/client/tests/unit/mobile/trip/MDaySheet.test.tsx @@ -9,8 +9,9 @@ import { useTripStore } from '../../../../src/store/tripStore' import type { Accommodation, Assignment, Day, DayNote, Reservation } from '../../../../src/types' import { resetAllStores, seedStore } from '../../../helpers/store' import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' +import { isBlurred } from '../../../helpers/bookingCodeBlur' -// FE-MOB-DAYSH-001 to FE-MOB-DAYSH-028 +// FE-MOB-DAYSH-001 to FE-MOB-DAYSH-030 const DAYS = [ { id: 1, trip_id: 5, day_number: 1, date: '2026-05-01', title: null }, @@ -418,4 +419,22 @@ describe('MDaySheet', () => { await renderSheet(makePlanner({ days, tripAccommodations: [] })) expect(screen.getByRole('dialog', { name: 'Day 2' })).toBeInTheDocument() }) + + // ── Blur booking codes on the stay tile (#2457) ──────────────────────────── + + it('FE-MOB-DAYSH-029: with the blur on, the linked booking code under the stay is blurred like the stay code', async () => { + seedStore(useSettingsStore, { settings: { blur_booking_codes: true } }) + await renderSheet() + // The stay's own code already honours the setting ... + expect(isBlurred(screen.getByText('ABC123'))).toBe(true) + // ... the linked booking's code right below it did not. + expect(isBlurred(screen.getByText(/#X9/))).toBe(true) + }) + + it('FE-MOB-DAYSH-030: with the blur off both codes stay plain', async () => { + seedStore(useSettingsStore, { settings: { blur_booking_codes: false } }) + await renderSheet() + expect(isBlurred(screen.getByText('ABC123'))).toBe(false) + expect(isBlurred(screen.getByText(/#X9/))).toBe(false) + }) }) diff --git a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx index 551a2e5f0..1b473ae84 100644 --- a/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx +++ b/client/tests/unit/mobile/trip/MPlanTimelineRows.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen } from '../../../helpers/render' +import { useSettingsStore } from '../../../../src/store/settingsStore' +import { isBlurred } from '../../../helpers/bookingCodeBlur' import { ConnRow, HotelConnRow, NoteRow, PlaceRow, PlanScheduleRow, ReorderStack, TransitRow, TransportRow, } from '../../../../src/mobile/screens/trip/plan/MPlanTimelineRows' @@ -7,7 +9,7 @@ import type { TransitMeta, TransportEntry } from '../../../../src/mobile/screens import type { PluginDayScheduleItem } from '../../../../src/api/client' import type { Assignment, DayNote, Place, Reservation, RouteSegment, TranslationFn } from '../../../../src/types' -// FE-MOB-PLROW-001 to FE-MOB-PLROW-041 +// FE-MOB-PLROW-001 to FE-MOB-PLROW-044 // Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy. const t: TranslationFn = (key, params) => @@ -531,3 +533,42 @@ describe('NoteRow', () => { expect(onEdit).toHaveBeenCalledTimes(1) }) }) + +// ── Blur booking codes on the plan row (#2457) ──────────────────────────────── + +describe('PlaceRow blur booking codes (#2457)', () => { + const props = { + assignment: assignment(), + fullPlace: undefined, + chrome: chrome(), + reorder: REORDER, + onOpen: vi.fn(), + onEdit: vi.fn(), + onRemove: vi.fn(), + } + const blurOn = (on: boolean) => + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) + + it('FE-MOB-PLROW-042: the booking code in the row subtitle is blurred while the setting is on', () => { + blurOn(true) + const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'ROW-SECRET' } as unknown as Reservation + render() + expect(isBlurred(screen.getByText(/ROW-SECRET/))).toBe(true) + }) + + it('FE-MOB-PLROW-043: the extra booking lines of a stop with several bookings blur their codes too', () => { + blurOn(true) + const first = { id: 31, title: 'Lunch', status: 'confirmed', confirmation_number: 'ROW-ONE' } as unknown as Reservation + const second = { id: 32, title: 'Tour', status: 'pending', confirmation_number: 'ROW-TWO' } as unknown as Reservation + render() + expect(isBlurred(screen.getByText(/ROW-ONE/))).toBe(true) + expect(isBlurred(screen.getByText(/ROW-TWO/))).toBe(true) + }) + + it('FE-MOB-PLROW-044: with the setting off the code stays plain', () => { + blurOn(false) + const linkedRes = { id: 31, status: 'confirmed', confirmation_number: 'ROW-PLAIN' } as unknown as Reservation + render() + expect(isBlurred(screen.getByText(/ROW-PLAIN/))).toBe(false) + }) +}) diff --git a/client/tests/unit/mobile/trip/MReservationSheet.test.tsx b/client/tests/unit/mobile/trip/MReservationSheet.test.tsx index 5b43cdd5f..f2bcce499 100644 --- a/client/tests/unit/mobile/trip/MReservationSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MReservationSheet.test.tsx @@ -2,12 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import MReservationSheet from '../../../../src/mobile/screens/trip/sheets/MReservationSheet' import { useAddonStore } from '../../../../src/store/addonStore' import { useTripStore } from '../../../../src/store/tripStore' +import { useSettingsStore } from '../../../../src/store/settingsStore' +import { isBlurred } from '../../../helpers/bookingCodeBlur' import type { Accommodation, Assignment, Day, Place, Reservation, TripMember } from '../../../../src/types' import { buildPlanner } from '../../../helpers/mobileTrip' import { resetAllStores } from '../../../helpers/store' -import { fireEvent, render, screen, waitFor } from '../../../helpers/render' +import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-RESSH-001 to FE-MOB-RESSH-051 +// FE-MOB-RESSH-001 to FE-MOB-RESSH-061 // Date/time/select pickers have their own suites — here they only have to be // addressable, so they render as plain controls. @@ -807,4 +809,46 @@ describe('MReservationSheet', () => { expect(titleField()).toHaveValue('Cafe Central') expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Manual 3') }) + + // ── Blur booking codes in the edit sheet (#2457) ─────────────────────────── + + describe('blur booking codes (#2457)', () => { + /** Both sources the phone reads the preference from: the settings store and planner.settings. */ + function plannerWithBlur(on: boolean, overrides: Record = {}) { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) + return makePlanner({ + settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km', blur_booking_codes: on }, + ...overrides, + }) + } + const codeField = () => screen.getByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement + + it('FE-MOB-RESSH-058: the booking code field is blurred while the setting is on', () => { + setup(plannerWithBlur(true, { editingReservation: DINNER })) + expect(codeField()).toHaveValue('C1') + expect(isBlurred(codeField())).toBe(true) + }) + + it('FE-MOB-RESSH-059: focusing the field reveals the code for editing, leaving it hides it again', () => { + setup(plannerWithBlur(true, { editingReservation: DINNER })) + const field = codeField() + expect(isBlurred(field)).toBe(true) + act(() => field.focus()) + expect(isBlurred(field)).toBe(false) + act(() => field.blur()) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-MOB-RESSH-060: with the setting off the code stays plain', () => { + setup(plannerWithBlur(false, { editingReservation: DINNER })) + expect(isBlurred(codeField())).toBe(false) + }) + + it('FE-MOB-RESSH-061: a blurred code still saves unchanged', async () => { + const { planner } = setup(plannerWithBlur(true, { editingReservation: DINNER })) + fireEvent.click(submitBtn()) + await waitFor(() => expect(planner.handleSaveReservation).toHaveBeenCalled()) + expect(savedPayload(planner).confirmation_number).toBe('C1') + }) + }) }) diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx index 3d43b062e..af6a9002b 100644 --- a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx @@ -3,12 +3,14 @@ import MTransportFormSheet from '../../../../src/mobile/screens/trip/sheets/MTra import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { useAddonStore } from '../../../../src/store/addonStore' import { useTripStore } from '../../../../src/store/tripStore' +import { useSettingsStore } from '../../../../src/store/settingsStore' +import { isBlurred } from '../../../helpers/bookingCodeBlur' import type { Day, Reservation, TripMember } from '../../../../src/types' import { buildPlanner } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' import { act, fireEvent, render, screen } from '../../../helpers/render' -// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-048 +// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-052 // // The sheet's own pickers (airport/location search, day select, time picker) and // the embedded transit panel are replaced by minimal controlled stand-ins so the @@ -957,4 +959,66 @@ describe('MTransportFormSheet', () => { fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' })) expect(daySelects().map(s => s.value)).toEqual(['13', '13']) }) + + // ── Blur booking codes in the edit sheet (#2457) ─────────────────────────── + + describe('blur booking codes (#2457)', () => { + /** Both sources the phone reads the preference from: the settings store and planner.settings. */ + function plannerWithBlur(on: boolean, overrides: Record = {}) { + useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) + return makePlanner({ + settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km', blur_booking_codes: on }, + ...overrides, + }) + } + const codeFields = () => screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[] + + const layover = () => ({ + id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending', + day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1', + metadata: { + legs: [ + { from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' }, + { from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' }, + ], + }, + endpoints: [ + { id: 1, reservation_id: 45, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null }, + { id: 2, reservation_id: 45, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null }, + { id: 3, reservation_id: 45, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null }, + ], + } as unknown as Reservation) + + it('FE-MOB-TRFRM-049: the booking code and every segment code are blurred while the setting is on', () => { + renderSheet(plannerWithBlur(true, { editingTransport: layover() })) + const codes = codeFields() + expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']) + expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]) + }) + + it('FE-MOB-TRFRM-050: focusing a code field reveals it for editing, leaving it hides it again', () => { + renderSheet(plannerWithBlur(true, { editingTransport: layover() })) + const field = codeFields()[2] + expect(isBlurred(field)).toBe(true) + act(() => field.focus()) + expect(isBlurred(field)).toBe(false) + act(() => field.blur()) + expect(isBlurred(field)).toBe(true) + }) + + it('FE-MOB-TRFRM-051: with the setting off every code field stays plain', () => { + renderSheet(plannerWithBlur(false, { editingTransport: layover() })) + expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false]) + }) + + it('FE-MOB-TRFRM-052: blurred codes still save unchanged', async () => { + const handleSaveTransport = makeSave() + renderSheet(plannerWithBlur(true, { editingTransport: layover(), handleSaveTransport })) + await submit('common.update') + const payload = handleSaveTransport.mock.calls[0][0] + const legs = (payload.metadata as { legs: Record[] }).legs + expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']) + expect(payload.confirmation_number).toBe('BOOK1') + }) + }) })