diff --git a/client/src/components/Assistant/cards/booking/BookingBlock.tsx b/client/src/components/Assistant/cards/booking/BookingBlock.tsx new file mode 100644 index 000000000..dd7416577 --- /dev/null +++ b/client/src/components/Assistant/cards/booking/BookingBlock.tsx @@ -0,0 +1,163 @@ +import type { + AssistantMessage, + BookingCard as BookingCardData, + BookingItem, + BookingLeg, + BookingStay, + BookingTicket, +} from '@trek/shared'; + +import { useTranslation } from '../../../../i18n'; +import { TypeTile } from '../../../Planner/bookings/bookingParts'; +import { WhiteBadge } from '../../../Planner/planParts'; +import { useAssistantView } from '../../assistantViewContext'; +import type { CardEntry, EntityTarget } from '../cardTypes'; +import { CardMore, CardRow, CardRows } from '../parts/CardRows'; +import BookingCard from './BookingCard'; +import { FULL_CARDS, itemsOf } from './bookingCardModel'; +import { RangeArrow, StatusMark } from './bookingCardParts'; +import { bookingTabOf, bookingTarget } from './bookingLook'; +import { useBookingFormat } from './useBookingFormat'; + +/** + * The bookings of one answer, or of one change, as one block: up to two whole + * cards stacked, from three on one framed list in the plan sidebar's look, so + * a file with five bookings stays short and nothing folds away. What the + * answer left out is counted as a way to the bookings tab. + */ + +type Card = Pick; + +/** When a ticket goes: its day, departure and arrival. */ +function TicketWhen({ body }: { body: BookingTicket }) { + const format = useBookingFormat(); + const leg = body.legs[0] as BookingLeg; + const end = body.legs[body.legs.length - 1] as BookingLeg; + return ( + <> + {format.day(leg.departsDay)} + {format.clock(leg.departs)} + {leg.departs && end.arrives && } + {format.clock(end.arrives)} + + ); +} + +/** When a stay is: check-in day to check-out day, and its nights. */ +function StayWhen({ body }: { body: BookingStay }) { + const { t } = useTranslation(); + const format = useBookingFormat(); + const start = body.checkIn ? body.checkIn.day : null; + const end = body.checkOut ? body.checkOut.day : null; + return ( + <> + {format.day(start)} + {start && end && } + {format.day(end)} + {body.nights ? {t('assistant.card.booking.nights', { count: body.nights })} : null} + + ); +} + +/** The second line of a row: when, in a few words, and another trip's name. */ +function RowFacts({ item }: { item: BookingItem }) { + const { t } = useTranslation(); + const format = useBookingFormat(); + const body = item.body; + return ( + + {body.shape === 'ticket' && } + {body.shape === 'stay' && } + {body.shape === 'venue' && ( + <> + {format.day(body.day)} + {format.clock(body.starts)} + + )} + {item.tripTitle && {t('assistant.card.booking.inTrip', { trip: item.tripTitle })}} + + ); +} + +/** One booking as a row of the list: its type, its title and status, when it is. */ +function BookingRow({ item, card }: { item: BookingItem; card: Card }) { + const { onOpen } = useAssistantView(); + const target = item.reservationId !== null ? bookingTarget(item, item.reservationId) : null; + return ( + } + title={ + + + {item.title} + + + + } + facts={} + onActivate={onOpen && target ? () => onOpen(target) : undefined} + /> + ); +} + +/** + * Where "N more" leads: the tab of the bookings it left out, the transports + * when all shown are; a list spanning other trips (the upcoming bookings) leads + * to the dashboard, which lists every trip. + */ +function moreTarget(items: readonly BookingItem[]): EntityTarget { + if (items.some((item) => item.tripTitle !== null)) return { kind: 'page', path: '/dashboard' }; + const tab = items.every((item) => bookingTabOf(item.type) === 'transports') ? 'transports' : 'buchungen'; + return { kind: 'tab', tab }; +} + +/** Bookings as whole cards (two at most) or as a framed list. */ +export function BookingList({ + items, + more, + card, + actions, +}: { + items: readonly BookingItem[]; + more: number; + card: Card; + actions: boolean; +}) { + const { t } = useTranslation(); + if (items.length === 0) return null; + if (items.length <= FULL_CARDS) { + return ( +
+ {items.map((item, i) => ( + + ))} + +
+ ); + } + return ( +
+
+ {t('assistant.card.booking.list')} + {items.length + more} +
+ + {items.map((item, i) => ( + + ))} + + +
+ ); +} + +/** The answer block: every booking card of the group read as one list. */ +export default function BookingBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) { + const { items, more } = itemsOf(cards); + return ; +} diff --git a/client/src/components/Assistant/cards/booking/BookingCard.test.tsx b/client/src/components/Assistant/cards/booking/BookingCard.test.tsx new file mode 100644 index 000000000..4e7fd8356 --- /dev/null +++ b/client/src/components/Assistant/cards/booking/BookingCard.test.tsx @@ -0,0 +1,415 @@ +/** + * FE-AI-BOOKING-001 to FE-AI-BOOKING-019 + * + * The booking cards in the chat: a ticket with its route, times and legs, a + * stay with its nights and check-in window, a venue with its place and time; + * the status dot, the review mark and the travellers; the frame of a create, + * an update and a removal; a list from three bookings on; and the ways to the + * plan's map and to the booking itself, in the window and on the phone. + */ +import type { + AssistantMessage, + BookingCard as BookingCardData, + BookingItem, + BookingLeg, + BookingStay, + BookingTicket, +} from '@trek/shared'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildUser } from '../../../../../tests/helpers/factories'; +import { fireEvent, render, screen, waitFor, within } from '../../../../../tests/helpers/render'; +import { fetchPhoto } from '../../../../services/photoService'; +import { useAuthStore } from '../../../../store/authStore'; +import { useSettingsStore } from '../../../../store/settingsStore'; +import type { ChangeItem } from '../../assistantParts'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import BookingBlock from './BookingBlock'; +import { BookingChangeBody, BookingChangeFacts } from './BookingChangeSlots'; + +vi.mock('../../../../services/photoService', () => ({ + fetchPhoto: vi.fn(), + getCached: vi.fn(() => undefined), +})); + +const AUTH = useAuthStore.getState(); +const SETTINGS = useSettingsStore.getState(); +const RealObserver = globalThis.IntersectionObserver; + +const day = (id: number, number: number, date: string) => ({ id, number, date }); + +const FLIGHT: BookingItem = { + tripId: 4, + tripTitle: null, + reservationId: 501, + type: 'flight', + title: 'Frankfurt to New York', + status: 'confirmed', + needsReview: false, + hasReference: true, + price: { amount: 420, currency: 'EUR' }, + travelers: [ + { id: 7, name: 'mia', avatarKey: null }, + { id: 8, name: 'jon', avatarKey: null }, + ], + moreTravelers: 0, + changed: [], + body: { + shape: 'ticket', + stops: [ + { name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.57 }, + { name: 'New York', code: 'JFK', lat: 40.64, lng: -73.78 }, + ], + legs: [ + { + carrier: 'Lufthansa', + number: 'LH 400', + departs: '08:15', + departsDay: day(31, 1, '2026-06-03'), + arrives: '11:05', + arrivesDay: day(32, 2, '2026-06-04'), + minutes: 530, + seat: '12A', + platform: null, + }, + ], + minutes: 530, + mappable: true, + }, +}; + +const STOPOVER: BookingItem = { + ...FLIGHT, + reservationId: 502, + body: { + shape: 'ticket', + stops: [ + { name: 'Frankfurt', code: 'FRA', lat: 50.03, lng: 8.57 }, + { name: 'Munich', code: 'MUC', lat: 48.35, lng: 11.79 }, + { name: 'New York', code: 'JFK', lat: 40.64, lng: -73.78 }, + ], + legs: [ + { + carrier: 'Lufthansa', + number: 'LH 101', + departs: '08:15', + departsDay: day(31, 1, '2026-06-03'), + arrives: '09:10', + arrivesDay: day(31, 1, '2026-06-03'), + minutes: 55, + seat: '12A', + platform: null, + }, + { + carrier: 'Lufthansa', + number: 'LH 410', + departs: '10:30', + departsDay: day(31, 1, '2026-06-03'), + arrives: '13:05', + arrivesDay: day(31, 1, '2026-06-03'), + minutes: null, + seat: null, + platform: null, + }, + ], + minutes: null, + mappable: true, + }, +}; + +const STAY: BookingItem = { + tripId: 4, + tripTitle: null, + reservationId: null, + type: 'hotel', + title: 'Mercure Hotel Berlin City', + status: null, + needsReview: true, + hasReference: false, + price: null, + travelers: [], + moreTravelers: 0, + changed: [], + body: { + shape: 'stay', + accommodationId: 601, + placeId: 12, + address: 'Invalidenstraße 38', + lat: 52.53, + lng: 13.38, + photoKey: 'ChIJmercure', + checkIn: { day: day(31, 1, '2026-06-03'), from: '15:00', until: '20:00' }, + checkOut: { day: day(33, 3, '2026-06-05'), by: '11:00' }, + nights: 2, + }, +}; + +const DINNER: BookingItem = { + ...STAY, + reservationId: 503, + type: 'restaurant', + title: 'Dinner at Borchardt', + status: 'pending', + needsReview: false, + body: { + shape: 'venue', + day: day(32, 2, '2026-06-04'), + starts: '19:00', + ends: '21:30', + location: 'Borchardt', + placeId: 13, + lat: 52.51, + lng: 13.39, + }, +}; + +const MESSAGE: AssistantMessage = { + id: 'm1', + role: 'assistant', + status: 'complete', + createdAt: '2026-06-01T10:00:00.000Z', + parts: [], +}; + +function cardOf(items: BookingItem[], change: BookingCardData['change'] = 'none', more = 0): BookingCardData { + return { kind: 'booking', change, items, more }; +} + +function inView(ui: ReactNode, view: Partial = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', density: 'roomy', ...view }; + return render({ui}); +} + +function answer(items: BookingItem[], view: Partial = {}, more = 0) { + const cards = [{ toolCallId: 'tc-1', card: cardOf(items, 'none', more) }]; + return inView(, view); +} + +function changeItem(card: BookingCardData): ChangeItem { + return { + toolCallId: 'tc-w', + preview: { + toolCallId: 'tc-w', + verbKey: 'updateReservation', + subject: 'Dinner', + rows: [{ op: 'change', labelKey: 'time', before: '19:00', after: '20:00' }], + dayId: null, + placeIds: [], + undoable: false, + effect: null, + }, + applied: null, + state: 'pending', + card, + }; +} + +beforeEach(() => { + useAuthStore.setState({ user: buildUser({ id: 7 }), placesPhotosEnabled: true }); +}); + +afterEach(() => { + globalThis.IntersectionObserver = RealObserver; + useAuthStore.setState(AUTH, true); + useSettingsStore.setState(SETTINGS, true); + vi.clearAllMocks(); +}); + +describe('booking cards', () => { + it('FE-AI-BOOKING-001: a ticket shows its codes, times, duration, carrier, seat and a +1 for a later arrival', () => { + answer([FLIGHT]); + expect(screen.getByText('FRA')).toBeInTheDocument(); + expect(screen.getByText('JFK')).toBeInTheDocument(); + expect(screen.getByText('08:15')).toBeInTheDocument(); + expect(screen.getByText('8h 50m')).toBeInTheDocument(); + expect(screen.getByText('Lufthansa LH 400')).toBeInTheDocument(); + expect(screen.getByText('Seat 12A')).toBeInTheDocument(); + expect(screen.getByText('Direct')).toBeInTheDocument(); + expect(screen.getByLabelText('Arrives 1 day later')).toHaveTextContent('+1'); + expect(screen.getByText('Reference saved')).toBeInTheDocument(); + expect(screen.queryByText('ABC123')).not.toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-002: clock times follow the 12 hour setting', () => { + useSettingsStore.setState((s) => ({ settings: { ...s.settings, time_format: '12h' } })); + answer([FLIGHT]); + expect(screen.getByText('8:15 AM')).toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-003: a stopover booking lists its legs and the time between them', () => { + answer([STOPOVER]); + expect(screen.getByText(/LH 101/)).toBeInTheDocument(); + expect(screen.getByText(/LH 410/)).toBeInTheDocument(); + expect(screen.getByText('1h 20m in MUC')).toBeInTheDocument(); + expect(screen.getByText('1 stop')).toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-004: a stay shows its nights, check-in green, check-out red, its arrow turned for RTL', () => { + answer([STAY]); + expect(screen.getByText('2 nights')).toBeInTheDocument(); + const checkIn = screen.getByText(/Check-in 15:00/).closest('span.inline-flex') as HTMLElement; + expect(checkIn.className).toContain('text-success'); + const checkOut = screen.getByText(/Check-out 11:00/).closest('span.inline-flex') as HTMLElement; + expect(checkOut.className).toContain('text-danger'); + // The range is read out in words; the dates and the arrow beside it are hidden from screen readers. + const range = screen.getByText(/to Fri/); + expect(range.className).toContain('sr-only'); + const shown = range.nextElementSibling as HTMLElement; + expect(shown).toHaveAttribute('aria-hidden', 'true'); + expect(shown.querySelector('svg')?.getAttribute('class')).toContain('rtl:-scale-x-100'); + expect(screen.getByText('Invalidenstraße 38')).toBeInTheDocument(); + }); + + it("FE-AI-BOOKING-005: a stay's picture comes only through TREK's photo proxy", async () => { + globalThis.IntersectionObserver = class { + private readonly callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + } + observe() { + this.callback([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver); + } + disconnect() {} + unobserve() {} + takeRecords() { + return []; + } + } as unknown as typeof IntersectionObserver; + vi.mocked(fetchPhoto).mockImplementation((key, _id, _lat, _lng, _name, callback) => { + const photoUrl = key === 'ChIJmercure' ? '/api/maps/place-photo/ChIJmercure' : 'https://maps.example/x.jpg'; + callback?.({ photoUrl, thumbDataUrl: null }); + }); + const otherBody: BookingStay = { ...(STAY.body as BookingStay), photoKey: 'ChIJother' }; + const other: BookingItem = { ...STAY, title: 'Other', body: otherBody }; + const { container } = answer([STAY, other]); + await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(1)); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/maps/place-photo/ChIJmercure'); + expect(fetchPhoto).toHaveBeenCalledWith('ChIJmercure', 'ChIJmercure', 52.53, 13.38, STAY.title, expect.anything()); + }); + + it('FE-AI-BOOKING-006: a venue shows where it is and its time range', () => { + answer([DINNER]); + expect(screen.getByText('Borchardt')).toBeInTheDocument(); + expect(screen.getByText('19:00')).toBeInTheDocument(); + expect(screen.getByText('21:30')).toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-007: the status dot names its status, none without one; one to check wears its review mark', () => { + answer([FLIGHT, { ...DINNER, status: 'cancelled' }]); + expect(screen.getByRole('img', { name: 'Confirmed' })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Cancelled' })).toBeInTheDocument(); + answer([STAY]); + expect(screen.queryByRole('img', { name: 'Pending' })).not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Review' })).toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-017: a stopover of unknown length names only its place; each leg names its carrier and seat', () => { + const body = STOPOVER.body as BookingTicket; + const onward = { ...(body.legs[1] as BookingLeg), departs: null }; + answer([{ ...STOPOVER, body: { ...body, legs: [body.legs[0] as BookingLeg, onward] } }]); + expect(screen.queryByText(/ in MUC/)).not.toBeInTheDocument(); + expect(screen.getAllByText('MUC').some((el) => el.className.includes('rounded-full'))).toBe(true); + expect(screen.getByText('Lufthansa LH 101')).toBeInTheDocument(); + expect(screen.getByText('Seat 12A')).toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-018: a transit hop has a dot with no word; a stay its photo slot in the head with the address', () => { + answer([{ ...FLIGHT, type: 'transit', status: null }]); + expect(screen.queryByRole('img', { name: /Confirmed|Pending|Cancelled/ })).not.toBeInTheDocument(); + answer([STAY]); + const head = screen.getByText('Mercure Hotel Berlin City').closest('div') as HTMLElement; + expect(within(head).getByText('Invalidenstraße 38')).toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-019: "N more" of a list spanning other trips leads to the dashboard', () => { + const onOpen = vi.fn(); + answer([{ ...DINNER, tripId: 9, tripTitle: 'Vienna' }, FLIGHT], { onOpen }, 3); + fireEvent.click(screen.getByText('3 more bookings')); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/dashboard' }); + }); + + it('FE-AI-BOOKING-008: the travellers stack names everyone, the signed-in user as You', () => { + answer([FLIGHT]); + expect(screen.getByRole('img', { name: 'You, jon' })).toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-009: a booking to create sits in a dashed frame, one to delete is struck and faded', () => { + const toCreate = cardOf([DINNER], 'create'); + const created = inView(); + expect(created.container.querySelector('section')?.className).toContain('border-dashed'); + created.unmount(); + const toRemove = cardOf([DINNER], 'remove'); + const removed = inView(); + expect(removed.container.querySelector('section')?.className).toContain('opacity-60'); + expect(screen.getByText('Dinner at Borchardt').className).toContain('line-through'); + expect(screen.getByText('Will be deleted')).toBeInTheDocument(); + }); + + it('FE-AI-BOOKING-010: an update marks the facts it changes and keeps their before and after on the row', () => { + const card = cardOf([{ ...DINNER, changed: ['time', 'status'] }], 'update'); + inView( + <> + + + + ); + expect(screen.getAllByRole('img', { name: 'Changed' })).toHaveLength(2); + expect(screen.getByText('20:00')).toBeInTheDocument(); + const created = cardOf([DINNER], 'create'); + const { container } = inView(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-AI-BOOKING-011: three bookings or more read as one list, with the rest counted as a way to the tab', () => { + const onOpen = vi.fn(); + answer([FLIGHT, STAY, DINNER], { onOpen }, 2); + const list = screen.getByRole('region', { name: 'Bookings' }); + expect(within(list).getByText('5')).toBeInTheDocument(); + expect(within(list).getByText('2 nights')).toBeInTheDocument(); + fireEvent.click(within(list).getByText('2 more bookings')); + expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'buchungen' }); + fireEvent.click(within(list).getByText('Frankfurt to New York')); + expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'transports', params: { booking: '501' } }); + }); + + it('FE-AI-BOOKING-012: Open goes to the booking in its tab and Show on map draws its route on the plan', () => { + const onOpen = vi.fn(); + answer([FLIGHT], { onOpen }); + fireEvent.click(screen.getByRole('button', { name: 'Open the booking' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'transports', params: { booking: '501' } }); + fireEvent.click(screen.getByRole('button', { name: 'Show on map' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'plan', params: { booking: '501', map: '1' } }); + }); + + it('FE-AI-BOOKING-013: a stay shows its place on the map and opens on its check-in day', () => { + const onOpen = vi.fn(); + answer([STAY], { onOpen }); + fireEvent.click(screen.getByRole('button', { name: 'Show on map' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null }); + fireEvent.click(screen.getByRole('button', { name: 'Show in the plan' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: null, dayId: 31 }); + }); + + it("FE-AI-BOOKING-014: a booking of another trip names that trip and opens that trip's planner", () => { + const onOpen = vi.fn(); + answer([{ ...DINNER, tripId: 9, tripTitle: 'Vienna' }], { onOpen }); + expect(screen.getByText('In Vienna')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open the booking' })); + expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/trips/9?tab=buchungen&booking=503' }); + }); + + it('FE-AI-BOOKING-015: on the phone the actions are tall; without a way to open them there are none', () => { + answer([FLIGHT], { variant: 'fullscreen', onOpen: vi.fn() }); + expect(screen.getByRole('button', { name: 'Open the booking' }).className).toContain('h-10'); + answer([DINNER]); + expect(screen.queryAllByRole('button', { name: 'Open the booking' })).toHaveLength(1); + }); + + it('FE-AI-BOOKING-016: inside a change card the booking has no actions of its own', () => { + const card = cardOf([FLIGHT], 'none'); + inView(, { onOpen: vi.fn() }); + expect(screen.queryByRole('button', { name: 'Open the booking' })).not.toBeInTheDocument(); + expect(screen.getByText('Frankfurt to New York')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Assistant/cards/booking/BookingCard.tsx b/client/src/components/Assistant/cards/booking/BookingCard.tsx new file mode 100644 index 000000000..e0c018b5f --- /dev/null +++ b/client/src/components/Assistant/cards/booking/BookingCard.tsx @@ -0,0 +1,118 @@ +import type { BookingCard as BookingCardData, BookingItem } from '@trek/shared'; +import { ArrowUpRight, MapPin, type LucideIcon } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { Tooltip } from '../../../shared/Tooltip'; +import { useAssistantView } from '../../assistantViewContext'; +import type { EntityTarget } from '../cardTypes'; +import { BookingFooter, BookingHead, StayBody, TicketBody, VenueBody } from './bookingCardParts'; +import { bookingSpotOf, bookingTarget } from './bookingLook'; + +/** + * One booking as a whole card, the way the planner's booking cards look: a + * head band tinted by its status, then the ticket, stay or venue, then its + * price, reference and travellers. A booking that will be created sits in a + * dashed frame, one that will be deleted fades with its title struck. One + * component for the desktop window and the phone. + */ + +type Card = Pick; + +const FRAME: Record = { + none: 'border-edge-faint', + create: 'border-dashed border-edge', + update: 'border-edge-faint', + remove: 'border-edge-faint opacity-60', +}; + +/** Where the card's "Open" goes: the booking in its tab, a stay without a booking on its check-in day. */ +function openTarget(item: BookingItem): EntityTarget | null { + if (item.reservationId !== null) return bookingTarget(item, item.reservationId); + const dayId = item.body.shape === 'stay' ? (item.body.checkIn?.day.id ?? null) : null; + return dayId !== null && item.tripTitle === null ? { kind: 'map', placeId: null, dayId } : null; +} + +/** Where "Show on map" goes: a saved route drawn on the plan, a stay's or a venue's place. */ +function mapTarget(item: BookingItem): EntityTarget | null { + const body = item.body; + if (body.shape === 'ticket') { + return body.mappable && item.reservationId !== null ? bookingTarget(item, item.reservationId, true) : null; + } + const spot = bookingSpotOf(item); + return spot?.placeId != null ? { kind: 'map', ...spot } : null; +} + +interface HeadActionProps { + label: string; + onClick: () => void; + icon: LucideIcon; + /** A pointing glyph turns with the reading direction; a map pin does not. */ + turns?: boolean; +} + +/** A quiet round action on the head band: shown on hover or focus, always on touch screens; tall on the phone. */ +function HeadAction({ label, onClick, icon: Icon, turns = false }: HeadActionProps) { + const { variant } = useAssistantView(); + const size = variant === 'fullscreen' ? 'h-10 w-10' : 'h-[26px] w-[26px]'; + return ( + + + + ); +} + +/** The ways out of a card: the plan's map, and the booking where it lives. */ +function CardActions({ item }: { item: BookingItem }) { + const { t } = useTranslation(); + const { onOpen } = useAssistantView(); + if (!onOpen) return null; + const map = mapTarget(item); + const open = openTarget(item); + const openKey = item.reservationId !== null ? 'assistant.card.booking.open' : 'assistant.card.booking.openStay'; + return ( + <> + {map && onOpen(map)} />} + {open && onOpen(open)} />} + + ); +} + +export default function BookingCard({ + item, + card, + actions = true, +}: { + item: BookingItem; + card: Card; + /** Off inside a change card, whose row has its own ways to the booking. */ + actions?: boolean; +}) { + const { t } = useTranslation(); + const body = item.body; + return ( +
+ : null} /> +
+ {item.tripTitle && ( +

+ {t('assistant.card.booking.inTrip', { trip: item.tripTitle })} +

+ )} + {body.shape === 'ticket' && } + {body.shape === 'stay' && } + {body.shape === 'venue' && } + +
+
+ ); +} diff --git a/client/src/components/Assistant/cards/booking/BookingChangeSlots.tsx b/client/src/components/Assistant/cards/booking/BookingChangeSlots.tsx new file mode 100644 index 000000000..8f36af3b8 --- /dev/null +++ b/client/src/components/Assistant/cards/booking/BookingChangeSlots.tsx @@ -0,0 +1,29 @@ +import type { TrekCardOf } from '@trek/shared'; + +import AssistantChangeFields from '../../AssistantChangeFields'; +import type { ChangeItem } from '../../assistantParts'; +import { BookingList } from './BookingBlock'; + +/** + * The booking card inside a change row. The row keeps its choice, its tile + * and the verb line ("Add accommodation"); the card under it names the + * booking, so the row's bold subject gives way. An update keeps the before + * and after pills of its fields on the faint line; a create or a removal shows + * them in the card alone. + */ + +interface SlotProps { + card: TrekCardOf<'booking'>; + item: ChangeItem; +} + +/** The card under the row; the row itself leads to the booking once it is saved. */ +export function BookingChangeBody({ card }: SlotProps) { + return ; +} + +/** The faint line's pills: an update's old and new values, nothing beside a card that shows them all. */ +export function BookingChangeFacts({ card, item }: SlotProps) { + if (card.change !== 'update' || !item.preview) return null; + return ; +} diff --git a/client/src/components/Assistant/cards/booking/bookingCardModel.ts b/client/src/components/Assistant/cards/booking/bookingCardModel.ts new file mode 100644 index 000000000..a46dcd6e2 --- /dev/null +++ b/client/src/components/Assistant/cards/booking/bookingCardModel.ts @@ -0,0 +1,100 @@ +import type { BookingCard, BookingChangedField, BookingItem, BookingLeg, BookingTicket, CardDay } from '@trek/shared'; + +import type { StatusTone } from '../../../Planner/bookings/bookingParts'; +import type { CardEntry } from '../cardTypes'; + +/** + * The pure side of a booking card: what its status dot says, how many stops + * a ticket makes, how long a stopover lasts, how many days later it lands, + * and how an answer's booking cards read as one list. No React here, so the + * card, its rows and the tests read one answer. + */ + +/** A status a booking card names in words. */ +type BookingStatus = NonNullable; + +/** + * The planner's tone of a booking (its `toneOf`): a transit hop in the info + * tone, a confirmed booking green, any other (pending, cancelled) amber; none + * for a stay no booking names. + */ +export function toneOfItem(item: Pick): StatusTone | null { + if (item.type === 'transit') return 'transit'; + if (!item.status) return null; + return item.status === 'confirmed' ? 'confirmed' : 'pending'; +} + +/** The words for a status, literal keys so the i18n check sees each one; a cancelled one says so in its tooltip. */ +export const STATUS_LABEL: Record = { + confirmed: { labelKey: 'reservations.confirmed' }, + pending: { labelKey: 'reservations.pending' }, + cancelled: { labelKey: 'assistant.card.booking.cancelled' }, +}; + +/** The stops between a ticket's two ends; null when no route is known. */ +export function stopsOf(ticket: BookingTicket): number | null { + if (ticket.stops.length >= 2) return ticket.stops.length - 2; + return ticket.legs.length > 1 ? ticket.legs.length - 1 : null; +} + +function dayNumber(date: string): number { + return Math.round(Date.parse(`${date}T00:00:00Z`) / 86_400_000); +} + +/** How many days after it departs a leg (or a whole ticket, first leg to last) lands; null for the same day. */ +export function daysLater(from: CardDay | null, to: CardDay | null): number | null { + if (from?.date && to?.date) { + const later = dayNumber(to.date) - dayNumber(from.date); + return later > 0 ? later : null; + } + if (from?.number != null && to?.number != null) { + const later = to.number - from.number; + return later > 0 ? later : null; + } + return null; +} + +/** The minutes between two clocks on the days they name (a stopover's arrival and the onward departure). */ +function minutesBetween(arrives: BookingLeg, departs: BookingLeg): number | null { + if (!arrives.arrives || !departs.departs) return null; + const toMinutes = (clock: string) => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5)); + const days = daysLater(arrives.arrivesDay ?? arrives.departsDay, departs.departsDay ?? arrives.arrivesDay) ?? 0; + const minutes = days * 1440 + toMinutes(departs.departs) - toMinutes(arrives.arrives); + return minutes > 0 ? minutes : null; +} + +/** One stopover of a ticket: where, and for how long when both clocks are known. */ +interface Layover { + place: string; + minutes: number | null; +} + +/** The stopovers between a ticket's legs; only when the legs line up with its stops. */ +export function layoversOf(ticket: BookingTicket): Layover[] { + if (ticket.legs.length < 2 || ticket.stops.length !== ticket.legs.length + 1) return []; + return ticket.legs.slice(0, -1).map((leg, i) => { + const stop = ticket.stops[i + 1]; + const next = ticket.legs[i + 1] as BookingLeg; + return { place: stop?.code ?? stop?.name ?? '', minutes: minutesBetween(leg, next) }; + }); +} + +/** Whether an update changes a field; a create or a removal marks nothing. */ +export function isChanged(card: Pick, item: BookingItem, field: BookingChangedField): boolean { + return card.change === 'update' && item.changed.includes(field); +} + +/** An answer's booking cards as one list: every booking in order, and how many the answers left out. */ +export function itemsOf(entries: readonly CardEntry[]): { items: BookingItem[]; more: number } { + const items: BookingItem[] = []; + let more = 0; + for (const entry of entries) { + if (entry.card.kind !== 'booking') continue; + items.push(...entry.card.items); + more += entry.card.more; + } + return { items, more }; +} + +/** At most this many bookings are drawn as whole cards; from one more on they read as a list. */ +export const FULL_CARDS = 2; diff --git a/client/src/components/Assistant/cards/booking/bookingCardParts.tsx b/client/src/components/Assistant/cards/booking/bookingCardParts.tsx new file mode 100644 index 000000000..4d4634591 --- /dev/null +++ b/client/src/components/Assistant/cards/booking/bookingCardParts.tsx @@ -0,0 +1,494 @@ +import type { + BookingCard as BookingCardData, + BookingItem, + BookingLeg, + BookingStay, + BookingTicket, + BookingVenue, +} from '@trek/shared'; +import { + ArrowRight, + BadgeCheck, + CalendarDays, + Clock, + LogIn, + LogOut, + MapPin, + Moon, + Route, + Ticket, + Wallet, + type LucideIcon, +} from 'lucide-react'; +import { useRef, type ReactNode } from 'react'; + +import { useTranslation } from '../../../../i18n'; +import { ReviewPill, toneColor, toneTint, TypeTile } from '../../../Planner/bookings/bookingParts'; +import { typeInfo } from '../../../Planner/bookings/bookingsModel'; +import { SoftPill, TimePill, type PillTone } from '../../../Planner/planParts'; +import { Tooltip } from '../../../shared/Tooltip'; +import { useAssistantView } from '../../assistantViewContext'; +import { useFoundPlacePhoto } from '../../useFoundPlacePhoto'; +import { CardFacts } from '../parts/CardFacts'; +import { PeopleStack } from '../parts/CardPeople'; +import { daysLater, isChanged, layoversOf, STATUS_LABEL, stopsOf, toneOfItem } from './bookingCardModel'; +import { useBookingFormat } from './useBookingFormat'; + +/** + * The pieces of a booking card, in the planner's own booking language: the + * head band tinted by the status, the type tile, the review mark, white soft + * pills for the facts, the travellers' faces at the end. A ticket shows its + * route with times and codes, a stay its nights and check-in window, a venue + * its place and time. Codes and times never truncate; names do, with their + * whole text in a tooltip. + */ + +type Card = Pick; + +/** The arrow of a range (a check-in window, Monday to Sunday), turned for right-to-left. */ +export function RangeArrow() { + return