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