feat(assistant): booking and stay cards, and a read of the bookings with their details

This commit is contained in:
Maurice
2026-10-22 15:50:50 +02:00
parent b52990bf27
commit b4e188fbfc
67 changed files with 6336 additions and 103 deletions
@@ -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<BookingCardData, 'change'>;
/** 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 (
<>
<span>{format.day(leg.departsDay)}</span>
<span>{format.clock(leg.departs)}</span>
{leg.departs && end.arrives && <RangeArrow />}
<span>{format.clock(end.arrives)}</span>
</>
);
}
/** 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 (
<>
<span>{format.day(start)}</span>
{start && end && <RangeArrow />}
<span>{format.day(end)}</span>
{body.nights ? <span>{t('assistant.card.booking.nights', { count: body.nights })}</span> : 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 (
<span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-caption tabular-nums text-content-faint">
{body.shape === 'ticket' && <TicketWhen body={body} />}
{body.shape === 'stay' && <StayWhen body={body} />}
{body.shape === 'venue' && (
<>
<span>{format.day(body.day)}</span>
<span>{format.clock(body.starts)}</span>
</>
)}
{item.tripTitle && <span>{t('assistant.card.booking.inTrip', { trip: item.tripTitle })}</span>}
</span>
);
}
/** 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 (
<CardRow
lead={<TypeTile type={item.type} size={24} />}
title={
<span className="flex min-w-0 items-center gap-1.5">
<bdi title={item.title} className={`line-clamp-1 ${card.change === 'remove' ? 'line-through' : ''}`}>
{item.title}
</bdi>
<StatusMark item={item} />
</span>
}
facts={<RowFacts item={item} />}
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 (
<div className="flex flex-col gap-2">
{items.map((item, i) => (
<BookingCard key={`${item.reservationId ?? 'new'}-${i}`} item={item} card={card} actions={actions} />
))}
<CardMore count={more} labelKey="assistant.card.booking.more" target={moreTarget(items)} />
</div>
);
}
return (
<section
aria-label={t('assistant.card.booking.list')}
className={`trek-assistant-enter rounded-xl border bg-surface-card px-3 pb-2 pt-2.5 shadow-card ${
card.change === 'create' ? 'border-dashed border-edge' : 'border-edge-faint'
}`}
>
<div className="flex items-center gap-1.5">
<span className="text-caption font-semibold text-content-secondary">{t('assistant.card.booking.list')}</span>
<WhiteBadge>{items.length + more}</WhiteBadge>
</div>
<CardRows>
{items.map((item, i) => (
<BookingRow key={`${item.reservationId ?? 'new'}-${i}`} item={item} card={card} />
))}
</CardRows>
<CardMore count={more} labelKey="assistant.card.booking.more" target={moreTarget(items)} />
</section>
);
}
/** 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 <BookingList items={items} more={more} card={{ change: 'none' }} actions />;
}
@@ -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<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', density: 'roomy', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
function answer(items: BookingItem[], view: Partial<AssistantView> = {}, more = 0) {
const cards = [{ toolCallId: 'tc-1', card: cardOf(items, 'none', more) }];
return inView(<BookingBlock cards={cards} message={MESSAGE} />, 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(<BookingChangeBody card={toCreate} item={changeItem(toCreate)} />);
expect(created.container.querySelector('section')?.className).toContain('border-dashed');
created.unmount();
const toRemove = cardOf([DINNER], 'remove');
const removed = inView(<BookingChangeBody card={toRemove} item={changeItem(toRemove)} />);
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(
<>
<BookingChangeFacts card={card} item={changeItem(card)} />
<BookingChangeBody card={card} item={changeItem(card)} />
</>
);
expect(screen.getAllByRole('img', { name: 'Changed' })).toHaveLength(2);
expect(screen.getByText('20:00')).toBeInTheDocument();
const created = cardOf([DINNER], 'create');
const { container } = inView(<BookingChangeFacts card={created} item={changeItem(created)} />);
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(<BookingChangeBody card={card} item={changeItem(card)} />, { onOpen: vi.fn() });
expect(screen.queryByRole('button', { name: 'Open the booking' })).not.toBeInTheDocument();
expect(screen.getByText('Frankfurt to New York')).toBeInTheDocument();
});
});
@@ -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<BookingCardData, 'change'>;
const FRAME: Record<Card['change'], string> = {
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 (
<Tooltip label={label}>
<button
type="button"
onClick={onClick}
aria-label={label}
className={`grid ${size} flex-none place-items-center rounded-full bg-surface-card text-content-muted opacity-60 transition-opacity hover:text-content focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100`}
>
<Icon size={14} aria-hidden="true" className={turns ? 'rtl:-scale-x-100' : undefined} />
</button>
</Tooltip>
);
}
/** 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 && <HeadAction label={t('assistant.change.showOnMap')} icon={MapPin} onClick={() => onOpen(map)} />}
{open && <HeadAction label={t(openKey)} icon={ArrowUpRight} turns onClick={() => 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 (
<section
aria-label={item.title}
className={`trek-assistant-enter group overflow-hidden rounded-xl border bg-surface-card shadow-card ${FRAME[card.change]}`}
>
<BookingHead item={item} card={card} actions={actions ? <CardActions item={item} /> : null} />
<div className="px-3 pb-2.5 pt-2">
{item.tripTitle && (
<p className="m-0 mb-1 text-caption text-content-muted">
{t('assistant.card.booking.inTrip', { trip: item.tripTitle })}
</p>
)}
{body.shape === 'ticket' && <TicketBody item={item} body={body} card={card} />}
{body.shape === 'stay' && <StayBody item={item} body={body} card={card} />}
{body.shape === 'venue' && <VenueBody item={item} body={body} card={card} />}
<BookingFooter item={item} card={card} />
</div>
</section>
);
}
@@ -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 <BookingList items={card.items} more={card.more} card={card} actions={false} />;
}
/** 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 <AssistantChangeFields rows={item.preview.rows} />;
}
@@ -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<BookingItem['status']>;
/**
* 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<BookingItem, 'type' | 'status'>): 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<BookingStatus, { labelKey: string }> = {
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<BookingCard, 'change'>, 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;
@@ -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<BookingCardData, 'change'>;
/** The arrow of a range (a check-in window, Monday to Sunday), turned for right-to-left. */
export function RangeArrow() {
return <ArrowRight size={9} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />;
}
/** The small accent dot of a fact an update changes. */
function ChangedDot() {
const { t } = useTranslation();
const label = t('assistant.card.common.changed');
return (
<span
role="img"
aria-label={label}
title={label}
className="inline-block h-1.5 w-1.5 flex-none rounded-full bg-accent"
/>
);
}
/**
* The status dot in the planner's tones (its `toneOf`), its word in the
* tooltip; a transit hop has the dot without a word, a stay no booking names
* neither.
*/
export function StatusMark({ item }: { item: BookingItem }) {
const { t } = useTranslation();
const tone = toneOfItem(item);
if (!tone) return null;
const dot = <span className="block h-2 w-2 rounded-full" style={{ background: toneColor(tone) }} />;
if (!item.status) return <span className="grid h-[22px] w-[22px] flex-none place-items-center">{dot}</span>;
const label = t(STATUS_LABEL[item.status].labelKey);
return (
<Tooltip label={label}>
<span role="img" aria-label={label} className="grid h-[22px] w-[22px] flex-none place-items-center">
{dot}
</span>
</Tooltip>
);
}
/** A stay's picture through TREK's photo proxy, else its type tile at `tile`. */
function StayPhoto({ item, body, size, tile }: { item: BookingItem; body: BookingStay; size: number; tile: number }) {
const ref = useRef<HTMLSpanElement>(null);
const photo = useFoundPlacePhoto({ photoKey: body.photoKey, lat: body.lat, lng: body.lng, name: item.title }, ref);
return (
<span ref={ref} className="flex flex-none">
{photo ? (
<img src={photo} alt="" className="rounded-lg object-cover" style={{ width: size, height: size }} />
) : (
<TypeTile type={item.type} size={tile} />
)}
</span>
);
}
/** The lead of the head band: a stay's photo (56, 44 when compact), else the type tile. */
function HeadLead({ item }: { item: BookingItem }) {
const { density } = useAssistantView();
const compact = density === 'compact';
if (item.body.shape === 'stay') {
return <StayPhoto item={item} body={item.body} size={compact ? 44 : 56} tile={compact ? 32 : 40} />;
}
return <TypeTile type={item.type} size={compact ? 24 : 28} />;
}
/**
* The head band: the type tile (a stay's photo with its address under the
* title), the title, the review mark, the status dot and the card's actions,
* on the tint of the status as on the planner's booking cards.
*/
export function BookingHead({ item, card, actions }: { item: BookingItem; card: Card; actions?: ReactNode }) {
const { t } = useTranslation();
const tone = toneOfItem(item);
const removed = card.change === 'remove';
const address = item.body.shape === 'stay' ? item.body.address : null;
return (
<div
className="flex items-center gap-2 border-b border-edge-faint px-3 py-2"
style={{ background: tone ? toneTint(tone) : undefined }}
>
<HeadLead item={item} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-1.5">
<bdi
title={item.title}
className={`line-clamp-2 text-body font-semibold leading-tight text-content ${removed ? 'line-through' : ''}`}
>
{item.title}
</bdi>
{item.needsReview && <ReviewPill />}
{isChanged(card, item, 'title') && <ChangedDot />}
{removed && <span className="sr-only">{t('assistant.card.common.willDelete')}</span>}
</span>
{address && (
<bdi title={address} className="line-clamp-2 text-caption text-content-muted">
{address}
</bdi>
)}
</span>
<StatusMark item={item} />
{isChanged(card, item, 'status') && <ChangedDot />}
{actions}
</div>
);
}
/** A fact as a white soft pill with its glyph, the accent dot when an update changes it. */
function Pill({
icon: Icon,
tone = 'neutral',
changed = false,
label,
children,
}: {
icon: LucideIcon;
tone?: PillTone;
changed?: boolean;
label?: string;
children: ReactNode;
}) {
return (
<SoftPill
tone={tone}
className="max-w-full tabular-nums"
icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" className="flex-none" />}
>
{label && <span className="sr-only">{label}</span>}
<span className="flex min-w-0 items-center gap-1" aria-hidden={label ? true : undefined}>
{children}
</span>
{changed && <ChangedDot />}
</SoftPill>
);
}
/** The day a booking falls on: its date, and its number in the trip beside it. */
function DayPills({ day, changed }: { day: BookingLeg['departsDay']; changed: boolean }) {
const format = useBookingFormat();
const date = format.day(day);
const number = format.dayNumber(day);
if (!date) return null;
return (
<>
<Pill icon={CalendarDays} changed={changed}>
<bdi className="truncate">{date}</bdi>
</Pill>
{number && <SoftPill>{number}</SoftPill>}
</>
);
}
/** What every booking ends with: its price, that a reference is kept, and who travels. */
export function BookingFooter({ item, card }: { item: BookingItem; card: Card }) {
const { t } = useTranslation();
const format = useBookingFormat();
if (!item.price && !item.hasReference && item.travelers.length === 0) return null;
return (
<div className="mt-1.5 flex items-center gap-2">
<CardFacts>
{item.price && (
<Pill icon={Wallet} changed={isChanged(card, item, 'price')}>
{format.money(item.price)}
</Pill>
)}
{item.hasReference && (
<Tooltip label={t('assistant.card.booking.referenceHint')}>
<span className="inline-flex">
<Pill icon={BadgeCheck} tone="success" changed={isChanged(card, item, 'reference')}>
{t('assistant.card.booking.reference')}
</Pill>
</span>
</Tooltip>
)}
</CardFacts>
<span className="ms-auto flex flex-none items-center gap-1">
<PeopleStack people={item.travelers} />
{item.moreTravelers > 0 && (
<span className="text-caption font-semibold tabular-nums text-content-faint">+{item.moreTravelers}</span>
)}
{isChanged(card, item, 'travelers') && <ChangedDot />}
</span>
</div>
);
}
/** "08:15 → 11:05 +1" for a ticket without a route (a hire car, a taxi). */
function TimesPill({ leg, later }: { leg: BookingLeg; later: number | null }) {
const format = useBookingFormat();
if (!leg.departs && !leg.arrives) return null;
return (
<TimePill>
<span>{format.clock(leg.departs)}</span>
{leg.departs && leg.arrives && <RangeArrow />}
<span>{format.clock(leg.arrives)}</span>
{later !== null && <Later count={later} />}
</TimePill>
);
}
/** "+1" after an arrival on a later day, the day count in words in its tooltip. */
function Later({ count }: { count: number }) {
const { t } = useTranslation();
const label = t('assistant.card.booking.arrivesLater', { count });
return (
<Tooltip label={label}>
<sup aria-label={label} className="ms-0.5 text-caption font-semibold text-content-muted">
+{count}
</sup>
</Tooltip>
);
}
/** The route of a ticket as the planner draws it: times above, codes on a dashed line, names below. */
function RouteLine({ item, body, later }: { item: BookingItem; body: BookingTicket; later: number | null }) {
const format = useBookingFormat();
const { density } = useAssistantView();
const first = body.stops[0];
const last = body.stops[body.stops.length - 1];
const leg = body.legs[0] as BookingLeg;
const end = body.legs[body.legs.length - 1] as BookingLeg;
const info = typeInfo(item.type);
const time = `font-semibold tabular-nums text-content ${density === 'compact' ? 'text-body' : 'text-subtitle'}`;
if (!first || !last) return null;
return (
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5">
<span className={time}>{format.clock(leg.departs)}</span>
<span className="truncate text-center text-caption text-content-muted">{format.minutes(body.minutes)}</span>
<span className={`${time} text-end`}>
{format.clock(end.arrives)}
{later !== null && <Later count={later} />}
</span>
<span className="text-caption font-bold text-content">{first.code ?? ''}</span>
<span className="flex items-center gap-1" aria-hidden="true">
<span className="h-px flex-1 border-t border-dashed border-edge" />
<info.Icon size={13} strokeWidth={2.2} className="flex-none rtl:-scale-x-100" style={{ color: info.color }} />
<span className="h-px flex-1 border-t border-dashed border-edge" />
</span>
<span className="text-end text-caption font-bold text-content">{last.code ?? ''}</span>
<bdi title={first.name} className="truncate text-caption text-content-faint">
{first.name}
</bdi>
<span />
<bdi title={last.name} className="truncate text-end text-caption text-content-faint">
{last.name}
</bdi>
</div>
);
}
/** Who flies a leg and where one sits: "Lufthansa LH 101", then "Seat 12A". */
function LegCarrier({ leg }: { leg: BookingLeg }) {
const { t } = useTranslation();
const carrier = [leg.carrier, leg.number].filter(Boolean).join(' ');
if (!carrier && !leg.seat) return null;
return (
<span className="ms-auto flex min-w-0 items-center gap-2 text-content-muted">
{carrier && (
<bdi title={carrier} className="truncate">
{carrier}
</bdi>
)}
{leg.seat && <span className="flex-none">{t('assistant.card.booking.seat', { seat: leg.seat })}</span>}
</span>
);
}
/** A stopover booking leg by leg, the time between two legs as a quiet pill. */
function LegRows({ body }: { body: BookingTicket }) {
const { t } = useTranslation();
const format = useBookingFormat();
const layovers = layoversOf(body);
if (layovers.length === 0) return null;
return (
<ol className="m-0 mt-2 flex list-none flex-col gap-1 p-0">
{body.legs.map((leg, i) => {
const from = body.stops[i];
const to = body.stops[i + 1];
const stop = layovers[i];
return (
<li key={i} className="flex flex-col gap-1">
<span className="flex flex-wrap items-center gap-x-2 text-caption tabular-nums text-content-secondary">
<span className="font-semibold">{from?.code ?? from?.name}</span>
<span>{format.clock(leg.departs)}</span>
<RangeArrow />
<span className="font-semibold">{to?.code ?? to?.name}</span>
<span>{format.clock(leg.arrives)}</span>
<LegCarrier leg={leg} />
</span>
{stop && (
<span className="self-center rounded-full bg-surface-secondary px-2 text-caption text-content-muted">
{stop.minutes
? t('assistant.card.booking.layoverAt', { duration: format.minutes(stop.minutes), place: stop.place })
: stop.place}
</span>
)}
</li>
);
})}
</ol>
);
}
interface TicketFactsProps {
item: BookingItem;
body: BookingTicket;
card: Card;
later: number | null;
}
/** The facts under a ticket: its times without a route, its day, carrier, seat, platform and stops. */
function TicketFacts({ item, body, card, later }: TicketFactsProps) {
const { t } = useTranslation();
const first = body.legs[0] as BookingLeg;
const single = body.legs.length === 1 ? first : null;
const stops = stopsOf(body);
const carrier = single ? [single.carrier, single.number].filter(Boolean).join(' ') : '';
return (
<CardFacts>
{body.stops.length < 2 && <TimesPill leg={first} later={later} />}
<DayPills day={first.departsDay} changed={isChanged(card, item, 'day') || isChanged(card, item, 'time')} />
{carrier && (
<Pill icon={Ticket}>
<bdi className="truncate">{carrier}</bdi>
</Pill>
)}
{single?.seat && <SoftPill>{t('assistant.card.booking.seat', { seat: single.seat })}</SoftPill>}
{single?.platform && <SoftPill>{t('assistant.card.booking.platform', { platform: single.platform })}</SoftPill>}
{stops !== null && (
<Pill icon={Route} changed={isChanged(card, item, 'route')}>
{stops === 0 ? t('assistant.card.booking.direct') : t('assistant.card.booking.stops', { count: stops })}
</Pill>
)}
</CardFacts>
);
}
/** A flight, a train, a hire car: its route with times and codes, its legs, then its facts. */
export function TicketBody({ item, body, card }: { item: BookingItem; body: BookingTicket; card: Card }) {
const first = body.legs[0] as BookingLeg;
const last = body.legs[body.legs.length - 1] as BookingLeg;
const later = daysLater(first.departsDay, last.arrivesDay);
return (
<>
{body.stops.length >= 2 && <RouteLine item={item} body={body} later={later} />}
<LegRows body={body} />
<TicketFacts item={item} body={body} card={card} later={later} />
</>
);
}
/** Where a venue is, on two lines at most, the whole text in its tooltip. */
function PlaceLine({ text, changed }: { text: string | null; changed: boolean }) {
if (!text) return null;
return (
<p className="m-0 flex items-start gap-1 text-caption text-content-muted">
<MapPin size={11} aria-hidden="true" className="mt-0.5 flex-none" />
<bdi title={text} className="line-clamp-2">
{text}
</bdi>
{changed && <ChangedDot />}
</p>
);
}
/** The nights of a stay as a range of days, the trip's day numbers in its tooltip. */
function StayRange({ item, body, card }: { item: BookingItem; body: BookingStay; card: Card }) {
const { t } = useTranslation();
const format = useBookingFormat();
const start = body.checkIn ? body.checkIn.day : null;
const end = body.checkOut ? body.checkOut.day : null;
const from = format.day(start);
const to = format.day(end);
if (!from && !to) return null;
const range = from && to ? t('assistant.card.booking.range', { from, to }) : from || to;
const numbers = [format.dayNumber(start), format.dayNumber(end)].filter(Boolean).join(' → ');
return (
<Tooltip label={numbers || range}>
<span className="inline-flex">
<Pill icon={CalendarDays} changed={isChanged(card, item, 'stay')} label={range}>
<bdi className="truncate">{from}</bdi>
{from && to && <RangeArrow />}
<bdi className="truncate">{to}</bdi>
</Pill>
</span>
</Tooltip>
);
}
/** Check-in from (to the end of its window) in green, check-out by in red, as the planner's day heads. */
function StayTimes({ item, body, card }: { item: BookingItem; body: BookingStay; card: Card }) {
const { t } = useTranslation();
const format = useBookingFormat();
const checkIn = body.checkIn && body.checkIn.from ? body.checkIn : null;
const checkOut = body.checkOut && body.checkOut.by ? body.checkOut.by : null;
return (
<>
{checkIn && (
<Pill icon={LogIn} tone="success" changed={isChanged(card, item, 'checkIn')}>
{t('assistant.card.booking.checkInAt', { time: format.clock(checkIn.from) })}
{checkIn.until && <RangeArrow />}
{checkIn.until && <span>{format.clock(checkIn.until)}</span>}
</Pill>
)}
{checkOut && (
<Pill icon={LogOut} tone="danger" changed={isChanged(card, item, 'checkOut')}>
{t('assistant.card.booking.checkOutAt', { time: format.clock(checkOut) })}
</Pill>
)}
</>
);
}
/** A hotel or any stay: its nights, the check-in window and check-out (its address sits in the head). */
export function StayBody({ item, body, card }: { item: BookingItem; body: BookingStay; card: Card }) {
const { t } = useTranslation();
return (
<>
<CardFacts>
<StayRange item={item} body={body} card={card} />
{body.nights !== null && body.nights > 0 && (
<Pill icon={Moon}>{t('assistant.card.booking.nights', { count: body.nights })}</Pill>
)}
<StayTimes item={item} body={body} card={card} />
</CardFacts>
</>
);
}
/** A table, a show, a tour: where it is, its day and its time. */
export function VenueBody({ item, body, card }: { item: BookingItem; body: BookingVenue; card: Card }) {
const format = useBookingFormat();
return (
<>
<PlaceLine text={body.location} changed={isChanged(card, item, 'location')} />
<CardFacts>
<DayPills day={body.day} changed={isChanged(card, item, 'day')} />
{body.starts && (
<Pill icon={Clock} changed={isChanged(card, item, 'time')}>
<span>{format.clock(body.starts)}</span>
{body.ends && <RangeArrow />}
{body.ends && <span>{format.clock(body.ends)}</span>}
</Pill>
)}
</CardFacts>
</>
);
}
@@ -0,0 +1,174 @@
import type { AssistantApplied, BookingCard, BookingItem, BookingTicket } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { daysLater, isChanged, itemsOf, layoversOf, stopsOf, toneOfItem } from './bookingCardModel';
import { BOOKING_LOOK, bookingSpotOf, bookingTarget } from './bookingLook';
/**
* FE-AI-BOOKLOOK-001 to FE-AI-BOOKLOOK-008
*
* The pure side of the booking cards: what an applied booking or stay opens,
* where it sits on the map, which entity a card is about, and the ticket
* arithmetic (stops, stopovers, a later arrival) the card draws from.
*/
const day = (id: number | null, number: number | null, date: string | null) => ({ id, number, date });
const TICKET: BookingTicket = {
shape: 'ticket',
stops: [
{ name: 'Frankfurt', code: 'FRA', lat: 50, lng: 8 },
{ name: 'Munich', code: 'MUC', lat: 48, lng: 11 },
{ name: 'New York', code: 'JFK', lat: 40, lng: -73 },
],
legs: [
{
carrier: null,
number: 'LH 101',
departs: '23:10',
departsDay: day(31, 1, '2026-06-03'),
arrives: '23:55',
arrivesDay: day(31, 1, '2026-06-03'),
minutes: 45,
seat: null,
platform: null,
},
{
carrier: null,
number: 'LH 410',
departs: '01:05',
departsDay: day(32, 2, '2026-06-04'),
arrives: '05:00',
arrivesDay: day(32, 2, '2026-06-04'),
minutes: null,
seat: null,
platform: null,
},
],
minutes: null,
mappable: true,
};
const BASE: Omit<BookingItem, 'body'> = {
tripId: 4,
tripTitle: null,
reservationId: 501,
type: 'flight',
title: 'Frankfurt to New York',
status: 'pending',
needsReview: false,
hasReference: false,
price: null,
travelers: [],
moreTravelers: 0,
changed: ['time'],
};
const FLIGHT: BookingItem = { ...BASE, body: TICKET };
const STAY: BookingItem = {
...BASE,
reservationId: null,
type: 'hotel',
body: {
shape: 'stay',
accommodationId: 601,
placeId: 12,
address: null,
lat: null,
lng: null,
photoKey: null,
checkIn: { day: day(31, 1, '2026-06-03'), from: null, until: null },
checkOut: null,
nights: null,
},
};
const card = (items: BookingItem[], change: BookingCard['change'] = 'none'): BookingCard => ({
kind: 'booking',
change,
items,
more: 0,
});
const applied = (entity: AssistantApplied['entity'], ids: number[]): AssistantApplied => ({
toolCallId: 'tc-1',
entity,
ids,
dayId: 33,
undoUntil: null,
});
describe('booking look', () => {
it('FE-AI-BOOKLOOK-001: an applied booking opens in its tab; one of another trip opens that trip', () => {
const open = BOOKING_LOOK.entities.reservation.open;
expect(open(applied('reservation', [501]), card([FLIGHT]))).toEqual({
kind: 'tab',
tab: 'transports',
params: { booking: '501' },
});
expect(bookingTarget({ ...FLIGHT, type: 'restaurant', tripId: 9, tripTitle: 'Vienna' }, 7)).toEqual({
kind: 'page',
path: '/trips/9?tab=buchungen&booking=7',
});
expect(open(applied('reservation', [501]), null)).toBeNull();
});
it('FE-AI-BOOKLOOK-002: on the map a ticket shows its day, a stay its place, no card the day', () => {
const onMap = BOOKING_LOOK.entities.reservation.onMap;
expect(onMap(applied('reservation', [501]), card([FLIGHT]))).toEqual({ placeId: null, dayId: 31 });
expect(onMap(applied('reservation', [502]), card([STAY]))).toEqual({ placeId: 12, dayId: null });
expect(onMap(applied('reservation', [502]), null)).toEqual({ placeId: null, dayId: 33 });
expect(bookingSpotOf({ ...FLIGHT, tripTitle: 'Vienna' })).toBeNull();
});
it('FE-AI-BOOKLOOK-003: an applied stay shows its place and opens on its check-in day', () => {
const look = BOOKING_LOOK.entities.accommodation;
expect(look.onMap(applied('accommodation', [601]), card([STAY]))).toEqual({ placeId: 12, dayId: null });
expect(look.open(applied('accommodation', [601]), card([STAY]))).toEqual({
kind: 'map',
placeId: null,
dayId: 31,
});
expect(look.open(applied('accommodation', [601]), null)).toEqual({ kind: 'map', placeId: null, dayId: 33 });
});
it('FE-AI-BOOKLOOK-004: a lone stay is a stay, anything else the booking; confirming needs edit rights', () => {
const look = BOOKING_LOOK.cards.booking;
expect(look.entity(card([STAY]))).toBe('accommodation');
expect(look.entity(card([{ ...STAY, reservationId: 502 }]))).toBe('reservation');
expect(look.entity(card([FLIGHT]))).toBe('reservation');
expect(look.followUps.find((followUp) => followUp.id === 'bookingsConfirm')?.edits).toBe(true);
});
it('FE-AI-BOOKLOOK-005: a ticket counts stops, stopovers across midnight and a later arrival', () => {
expect(stopsOf(TICKET)).toBe(1);
expect(stopsOf({ ...TICKET, stops: [], legs: TICKET.legs.slice(0, 1) })).toBeNull();
expect(layoversOf(TICKET)).toEqual([{ place: 'MUC', minutes: 70 }]);
expect(layoversOf({ ...TICKET, stops: TICKET.stops.slice(0, 2) })).toEqual([]);
expect(daysLater(day(31, 1, '2026-06-03'), day(32, 2, '2026-06-04'))).toBe(1);
expect(daysLater(day(null, 1, null), day(null, 3, null))).toBe(2);
expect(daysLater(day(31, 1, '2026-06-03'), day(31, 1, '2026-06-03'))).toBeNull();
});
it('FE-AI-BOOKLOOK-006: only an update marks what it changes', () => {
expect(isChanged(card([FLIGHT], 'update'), FLIGHT, 'time')).toBe(true);
expect(isChanged(card([FLIGHT], 'update'), FLIGHT, 'status')).toBe(false);
expect(isChanged(card([FLIGHT], 'create'), FLIGHT, 'time')).toBe(false);
});
it("FE-AI-BOOKLOOK-007: an answer's booking cards read as one list with what they left out counted", () => {
const entries = [
{ toolCallId: 'a', card: { ...card([FLIGHT]), more: 2 } },
{ toolCallId: 'b', card: card([STAY]) },
];
expect(itemsOf(entries)).toEqual({ items: [FLIGHT, STAY], more: 2 });
});
it("FE-AI-BOOKLOOK-008: the dot takes the planner's tones: transit info, confirmed green, the rest amber", () => {
expect(toneOfItem({ type: 'transit', status: null })).toBe('transit');
expect(toneOfItem({ type: 'flight', status: 'confirmed' })).toBe('confirmed');
expect(toneOfItem({ type: 'flight', status: 'pending' })).toBe('pending');
expect(toneOfItem({ type: 'restaurant', status: 'cancelled' })).toBe('pending');
expect(toneOfItem({ type: 'hotel', status: null })).toBeNull();
});
});
@@ -1,23 +1,124 @@
import { Ticket } from 'lucide-react';
import { isTransportReservationType, type AssistantApplied, type BookingItem, type TrekCard } from '@trek/shared';
import { BedDouble, CalendarCog, Hotel, Ticket } from 'lucide-react';
import type { PackLook } from '../cardTypes';
import type { EntityTarget, PackLook } from '../cardTypes';
/**
* What the bookings bring to the chat's pure look: the verbs, tool names and
* steps they add, what an applied change of theirs opens, and the look of each
* of their kinds. Empty but for the booking entity until the booking cards fill
* it. A look stays pure, icons and literals only: the chat's main chunk reads
* every look, so whatever a look imports ships with it.
* What the bookings bring to the chat's pure look: the verbs and tool names
* of their changes, the reading steps, what an applied booking or stay opens
* (the booking in its tab, a stay on its check-in day) and the follow-ups of
* a booking answer. A look stays pure, icons and literals only: the chat's
* main chunk reads every look, so whatever a look imports ships with it.
*/
/** The first booking a card holds; an applied change's card holds one. */
function bookingItemOf(card: TrekCard | null): BookingItem | null {
return card?.kind === 'booking' ? (card.items[0] ?? null) : null;
}
/** The tab a booking lives in: the transports for anything travelled on, else the bookings. */
export function bookingTabOf(type: string): 'transports' | 'buchungen' {
return isTransportReservationType(type) ? 'transports' : 'buchungen';
}
/**
* Where "Open" takes a booking: its tab with the booking opened, on this
* trip; a booking of another trip (the upcoming list spans every trip) opens
* that trip's planner. `map` shows its route on the plan instead.
*/
export function bookingTarget(item: BookingItem, reservationId: number, map = false): EntityTarget {
const params = map ? { booking: String(reservationId), map: '1' } : { booking: String(reservationId) };
const tab = map ? 'plan' : bookingTabOf(item.type);
if (item.tripTitle !== null) {
return { kind: 'page', path: `/trips/${item.tripId}?${new URLSearchParams({ tab, ...params }).toString()}` };
}
return { kind: 'tab', tab, params };
}
/** The spot a booking has on the plan's map: a stay's or a venue's place, else the day it starts. */
export function bookingSpotOf(item: BookingItem | null): { placeId: number | null; dayId: number | null } | null {
if (!item || item.tripTitle !== null) return null;
const body = item.body;
if (body.shape === 'stay') {
if (body.placeId !== null) return { placeId: body.placeId, dayId: null };
return body.checkIn?.day.id ? { placeId: null, dayId: body.checkIn.day.id } : null;
}
if (body.shape === 'venue') {
if (body.placeId !== null) return { placeId: body.placeId, dayId: null };
return body.day?.id ? { placeId: null, dayId: body.day.id } : null;
}
const dayId = body.legs[0]?.departsDay?.id ?? null;
return dayId !== null ? { placeId: null, dayId } : null;
}
function reservationOnMap(applied: AssistantApplied, card: TrekCard | null) {
return bookingSpotOf(bookingItemOf(card)) ?? { placeId: null, dayId: applied.dayId };
}
function reservationOpen(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null {
const item = bookingItemOf(card);
const id = applied.ids[0] ?? item?.reservationId ?? null;
return item && id !== null ? bookingTarget(item, id) : null;
}
/** The check-in day of an applied stay, where the planner's day head shows it. */
function stayDayOf(applied: AssistantApplied, card: TrekCard | null): number | null {
const body = bookingItemOf(card)?.body;
return (body?.shape === 'stay' ? body.checkIn?.day.id : null) ?? applied.dayId;
}
export const BOOKING_LOOK = {
verbs: {},
tools: {},
steps: {},
verbs: {
addStay: { Icon: BedDouble, tone: 'success', labelKey: 'assistant.change.verb.addStay' },
updateStay: { Icon: CalendarCog, tone: 'info', labelKey: 'assistant.change.verb.updateStay' },
updateReservation: { Icon: Ticket, tone: 'info', labelKey: 'assistant.change.verb.updateReservation' },
},
tools: {
set_reservation_travelers: { labelKey: 'assistant.tool.set_reservation_travelers' },
link_hotel_accommodation: { labelKey: 'assistant.tool.link_hotel_accommodation' },
update_transport: { labelKey: 'assistant.tool.update_transport' },
delete_transport: { labelKey: 'assistant.tool.delete_transport' },
delete_accommodation: { labelKey: 'assistant.tool.delete_accommodation' },
reorder_reservations: { labelKey: 'assistant.tool.reorder_reservations' },
import_airtrail_flights: { labelKey: 'assistant.tool.import_airtrail_flights' },
},
steps: {
list_reservations: { stepKey: 'assistant.step.bookings' },
list_accommodations: { stepKey: 'assistant.step.stays' },
list_upcoming_reservations: { stepKey: 'assistant.step.upcoming' },
},
entities: {
// An applied booking shows on the map by its day, as before.
reservation: { Icon: Ticket, onMap: (applied) => ({ placeId: null, dayId: applied.dayId }) },
reservation: {
Icon: Ticket,
openLabelKey: 'assistant.card.booking.open',
onMap: reservationOnMap,
open: reservationOpen,
},
accommodation: {
Icon: Hotel,
openLabelKey: 'assistant.card.booking.openStay',
onMap: (applied, card) => {
const spot = bookingSpotOf(bookingItemOf(card));
return spot ?? { placeId: null, dayId: applied.dayId };
},
open: (applied, card) => {
const dayId = stayDayOf(applied, card);
return dayId !== null ? { kind: 'map', placeId: null, dayId } : null;
},
},
},
cards: {
booking: {},
booking: {
// A stay no booking links is a stay; anything else is the booking.
entity: (card) => {
const item = bookingItemOf(card);
return item?.body.shape === 'stay' && item.reservationId === null ? 'accommodation' : 'reservation';
},
followUps: [
{ id: 'bookingsOpen', labelKey: 'assistant.followUp.bookingsOpen' },
{ id: 'bookingsNights', labelKey: 'assistant.followUp.bookingsNights' },
{ id: 'bookingsConfirm', labelKey: 'assistant.followUp.bookingsConfirm', edits: true },
],
},
},
} satisfies PackLook<'booking'>;
@@ -1,12 +1,20 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
import type { CardView } from '../cardTypes';
import BookingBlock from './BookingBlock';
import { BookingChangeBody, BookingChangeFacts } from './BookingChangeSlots';
/**
* How the kinds of the bookings draw in the chat; each one draws nothing until
* its cards are built. This file only maps each kind to its view: the
* components live in sibling files, since a file that exports anything besides
* How the bookings draw in the chat: an answer's bookings as one block, and
* a change's booking as a card under its row, which names it in place of the
* row's subject. This file only maps the kind to its view: the components
* live in sibling files, since a file that exports anything besides
* components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
*/
export const BOOKING_VIEWS = {
booking: PENDING_VIEW,
booking: {
Block: BookingBlock,
Body: BookingChangeBody,
Facts: BookingChangeFacts,
ownsSubject: true,
},
} satisfies { [K in 'booking']: CardView<K> };
@@ -0,0 +1,31 @@
import type { CardDay, CardMoney } from '@trek/shared';
import { useMemo } from 'react';
import { useTranslation } from '../../../../i18n';
import { useCardFormat } from '../parts/useCardFormat';
/**
* How a booking card writes its days, clocks, durations and prices: the
* user's language and 12 or 24 hour setting, as the planner's booking cards
* do. A day reads as its date, a trip planned without dates as "Day 3".
*/
export function useBookingFormat() {
const { t } = useTranslation();
const format = useCardFormat();
return useMemo(
() => ({
/** "Wed 3 Jun", or "Day 3" without a date; empty for no day. */
day: (day: CardDay | null): string => {
if (day?.date) return format.date(day.date);
return day?.number != null ? t('dayplan.dayN', { n: day.number }) : '';
},
/** "Day 3" beside a date, only when the trip numbers its days and the date is shown too. */
dayNumber: (day: CardDay | null): string =>
day?.date && day.number != null ? t('dayplan.dayN', { n: day.number }) : '',
clock: (value: string | null): string => (value ? format.clock(value) : ''),
minutes: (value: number | null): string => (value ? format.minutes(value) : ''),
money: (value: CardMoney): string => format.money(value),
}),
[format, t]
);
}
@@ -1,9 +1,19 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { type RefObject, useEffect, useState } from 'react';
import { fetchPhoto, getCached } from '../../services/photoService';
import { useAuthStore } from '../../store/authStore';
/**
* What a picture is asked for by: a found place, or a stay a booking card
* shows (its Google place id, its position and its name).
*/
interface PhotoPlace {
photoKey: string | null;
lat: number | null;
lng: number | null;
name: string;
}
/** The only pictures a found place shows: TREK's own photo proxy, never an address from the search. */
const PROXY_PREFIX = '/api/maps/place-photo/';
@@ -13,7 +23,7 @@ const PROXY_PREFIX = '/api/maps/place-photo/';
* a picture of its surroundings, the same street scene for every restaurant
* in a district; such a card shows its category instead.
*/
function photoKeyOf(place: AssistantFoundPlace): string | null {
function photoKeyOf(place: PhotoPlace): string | null {
return place.photoKey && !place.photoKey.startsWith('coords:') ? place.photoKey : null;
}
@@ -31,7 +41,7 @@ function proxiedPhoto(url: string | null | undefined): string | null {
* are off, for a place known only by its position, and for any answer that is
* not the proxy's own address.
*/
export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<Element | null>): string | null {
export function useFoundPlacePhoto(place: PhotoPlace, ref: RefObject<Element | null>): string | null {
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
const key = photoKeyOf(place);
const [src, setSrc] = useState<string | null>(() => (key ? proxiedPhoto(getCached(key)?.photoUrl) : null));
@@ -56,7 +66,7 @@ export function useFoundPlacePhoto(place: AssistantFoundPlace, ref: RefObject<El
useEffect(() => {
if (!visible || !enabled || !key) return;
let cancelled = false;
fetchPhoto(key, key, place.lat, place.lng, place.name, (entry) => {
fetchPhoto(key, key, place.lat ?? undefined, place.lng ?? undefined, place.name, (entry) => {
if (!cancelled) setSrc(proxiedPhoto(entry.photoUrl));
});
return () => {
+65
View File
@@ -0,0 +1,65 @@
import { renderHook } from '@testing-library/react';
import type { Reservation } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { useBookingLink } from './useBookingLink';
/**
* FE-HOOK-BOOKLINK-001 to FE-HOOK-BOOKLINK-004
*
* A booking the chat points at: taken once the trip's bookings are there,
* then dropped from the URL, an unknown one dropped without a word.
*/
const booking = (id: number, tripId = 5) => ({ id, trip_id: tripId, type: 'flight' }) as Reservation;
function setup(query: string, reservations: Reservation[], loaded: boolean) {
const searchParams = new URLSearchParams(query);
const setSearchParams = vi.fn((update: (params: URLSearchParams) => URLSearchParams) => update(searchParams));
const take = vi.fn();
const hook = renderHook(
({ list, done }) =>
useBookingLink({
searchParams,
setSearchParams: setSearchParams as never,
tripId: 5,
reservations: list,
loaded: done,
take,
}),
{ initialProps: { list: reservations, done: loaded } }
);
return { searchParams, setSearchParams, take, hook };
}
describe('useBookingLink', () => {
it('FE-HOOK-BOOKLINK-001: opens a booking of this trip once and drops the link', () => {
const { searchParams, take, hook } = setup('tab=transports&booking=7', [booking(7)], false);
expect(take).toHaveBeenCalledTimes(1);
expect(take).toHaveBeenCalledWith(booking(7), false);
expect(searchParams.has('booking')).toBe(false);
expect(searchParams.get('tab')).toBe('transports');
hook.rerender({ list: [booking(7)], done: true });
expect(take).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-BOOKLINK-002: hands on map=1 and drops it with the link', () => {
const { searchParams, take } = setup('booking=7&map=1', [booking(7)], true);
expect(take).toHaveBeenCalledWith(booking(7), true);
expect(searchParams.has('map')).toBe(false);
});
it("FE-HOOK-BOOKLINK-003: waits for the trip's bookings, then drops an id it does not hold", () => {
const { searchParams, take, hook } = setup('booking=9', [booking(9, 4)], false);
expect(searchParams.get('booking')).toBe('9');
hook.rerender({ list: [booking(7)], done: true });
expect(take).not.toHaveBeenCalled();
expect(searchParams.has('booking')).toBe(false);
});
it('FE-HOOK-BOOKLINK-004: does nothing without a link', () => {
const { setSearchParams, take } = setup('tab=plan', [booking(7)], true);
expect(setSearchParams).not.toHaveBeenCalled();
expect(take).not.toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { Reservation } from '@trek/shared';
import { useEffect, useRef } from 'react';
import type { SetURLSearchParams } from 'react-router';
interface BookingLinkOptions {
searchParams: URLSearchParams;
setSearchParams: SetURLSearchParams;
tripId: number;
reservations: readonly Reservation[];
/** True once this trip's bookings have loaded, so an id it does not hold can be dropped. */
loaded: boolean;
/** Opens the booking: its detail, or with `onMap` its route on the plan. */
take: (booking: Reservation, onMap: boolean) => void;
}
/**
* A booking the chat points at (`?booking=<id>`, `&map=1` for its route on
* the plan), for the desktop planner and the phone alike: once the trip's
* bookings are there it is handed to `take`, then the link leaves the URL. It
* is taken once, so a render before the URL has dropped it does not open it
* twice; an id the trip does not hold is dropped without a word.
*/
export function useBookingLink({
searchParams,
setSearchParams,
tripId,
reservations,
loaded,
take,
}: BookingLinkOptions): void {
const requested = searchParams.get('booking');
const onMap = searchParams.get('map') === '1';
const id = requested === null ? null : Number(requested);
const booking = id === null ? undefined : reservations.find((r) => r.id === id && r.trip_id === tripId);
const settled = !!booking || loaded;
const taken = useRef<string | null>(null);
useEffect(() => {
if (requested === null) {
taken.current = null;
return;
}
const link = `${requested}:${onMap}`;
if (!settled || taken.current === link) return;
taken.current = link;
if (booking) take(booking, onMap);
setSearchParams(
(params) => {
params.delete('booking');
params.delete('map');
return params;
},
{ replace: true }
);
}, [requested, settled, booking, onMap, take, setSearchParams]);
}
@@ -12,6 +12,9 @@ import { runsOnDay } from '../../../../utils/reservationRoutes'
import { filesFor } from '../../../../utils/reservationFiles'
import { bookingDayLabel, segmentCodeLabel } from '../../../../components/Planner/transportDetailModel'
import type { Reservation } from '../../../../types'
import { useSearchParams } from 'react-router'
import { isTransportReservationType } from '@trek/shared'
import { useBookingLink } from '../../../../hooks/useBookingLink'
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
interface TransportSheetPayload {
@@ -59,6 +62,40 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
const payload = (shell.sheet?.payload ?? {}) as TransportSheetPayload
const liveRes = planner.reservations.find(r => r.id === payload.reservationId) ?? null
// The map with a booking's route drawn on it: the route switched on, the sheet
// closed, the plan tab and the map shown. The map draws the day on the chips, and
// the Transports tab lists the whole trip, so the booking may run on a different
// day. Moved last, because entering the map picks the day the shell was already
// holding. A day inside the booking's span, the all-days view and a booking bound
// to no day are left as they are.
const revealOnMap = (r: Reservation) => {
if (!planner.visibleConnections.includes(r.id)) planner.toggleConnection(r.id)
shell.closeSheet()
if (shell.trTab !== 'plan') shell.setTrTab('plan')
if (shell.view !== 'map') shell.toggleView()
const bookingDayId = r.day_id ?? r.end_day_id
if (planner.selectedDayId != null && bookingDayId != null && !runsOnDay(r, planner.selectedDayId, planner.days)) {
planner.handleSelectDay(bookingDayId, false)
planner.setExpandedDayIds(new Set([bookingDayId]))
}
}
// A transport the chat points at (?booking=) opens here, or with &map=1 its route on
// the map; any other booking stays on its tab.
const [searchParams, setSearchParams] = useSearchParams()
useBookingLink({
searchParams,
setSearchParams,
tripId: planner.tripId,
reservations: planner.reservations,
loaded: !planner.isLoading && planner.trip?.id === planner.tripId,
take: (booking, toMap) => {
if (!isTransportReservationType(booking.type)) return
if (toMap) revealOnMap(booking)
else shell.openSheet('transport', { reservationId: booking.id })
},
})
const canEditDays = planner.can('day_edit', planner.trip)
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
@@ -117,19 +154,7 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
planner.toggleConnection(res.id)
return
}
if (!switchedOn) planner.toggleConnection(res.id)
shell.closeSheet()
if (shell.trTab !== 'plan') shell.setTrTab('plan')
if (shell.view !== 'map') shell.toggleView()
// The map draws the day on the chips, and the Transports tab lists the whole trip, so
// the booking may run on a different day. Moved last, because entering the map picks
// the day the shell was already holding. A day inside the booking's span, the
// all-days view and a booking bound to no day are left as they are.
const bookingDayId = res.day_id ?? res.end_day_id
if (planner.selectedDayId != null && bookingDayId != null && !runsOnDay(res, planner.selectedDayId, planner.days)) {
planner.handleSelectDay(bookingDayId, false)
planner.setExpandedDayIds(new Set([bookingDayId]))
}
revealOnMap(res)
}
const editTransport = () => {
@@ -0,0 +1,62 @@
import { renderHook } from '@testing-library/react';
import type { Reservation } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import type { DeepLinkProps } from './deepLinkTypes';
import { useBookingDeepLink } from './useBookingDeepLink';
/**
* FE-PLANNER-BOOKLINK-001 to FE-PLANNER-BOOKLINK-003
*
* The desktop planner's way into a booking the chat points at: its detail, or
* its route drawn on the plan, a route already drawn left as it is and its
* departure day picked.
*/
const FLIGHT = { id: 7, trip_id: 5, type: 'flight', day_id: 31 } as Reservation;
function setup(query: string, onMap = false) {
const searchParams = new URLSearchParams(query);
const setSearchParams = vi.fn((update: (params: URLSearchParams) => URLSearchParams) => update(searchParams));
const planner = {
tripId: 5,
reservations: [FLIGHT],
isLoading: false,
trip: { id: 5 },
openBookingDetail: vi.fn(),
showBookingOnMap: vi.fn(),
isBookingOnMap: vi.fn(() => onMap),
handleSelectDay: vi.fn(),
};
renderHook(() =>
useBookingDeepLink({
planner: planner as unknown as DeepLinkProps['planner'],
workspace: {} as DeepLinkProps['workspace'],
searchParams,
setSearchParams: setSearchParams as never,
})
);
return { planner, searchParams };
}
describe('useBookingDeepLink', () => {
it('FE-PLANNER-BOOKLINK-001: ?booking= opens the booking detail and leaves the URL', () => {
const { planner, searchParams } = setup('booking=7');
expect(planner.openBookingDetail).toHaveBeenCalledWith(FLIGHT);
expect(planner.showBookingOnMap).not.toHaveBeenCalled();
expect(searchParams.has('booking')).toBe(false);
});
it('FE-PLANNER-BOOKLINK-002: with map=1 the route is drawn on the plan', () => {
const { planner } = setup('booking=7&map=1');
expect(planner.showBookingOnMap).toHaveBeenCalledWith(FLIGHT);
expect(planner.openBookingDetail).not.toHaveBeenCalled();
});
it('FE-PLANNER-BOOKLINK-003: a route already drawn is not switched off again; the plan goes to its day', () => {
const { planner, searchParams } = setup('booking=7&map=1', true);
expect(planner.showBookingOnMap).not.toHaveBeenCalled();
expect(planner.handleSelectDay).toHaveBeenCalledWith(31);
expect(searchParams.has('map')).toBe(false);
});
});
@@ -1,9 +1,27 @@
import { useBookingLink } from '../../../hooks/useBookingLink';
import type { DeepLinkProps } from './deepLinkTypes';
/**
* Where the bookings open a booking the chat points at. Nothing yet: the booking cards
* fill it in, and until then the planner keeps its own links.
* A booking the chat points at, on the desktop planner: its detail opens, the
* same view a click on it in the plan or in its tab opens; with `&map=1` its
* route is drawn on the plan instead. A route already shown stays shown, and
* the plan goes to the day it departs.
*/
export function useBookingDeepLink(_links: DeepLinkProps): void {
// Filled in by the booking cards.
export function useBookingDeepLink({ planner, searchParams, setSearchParams }: DeepLinkProps): void {
const { tripId, reservations, isLoading, trip, openBookingDetail, showBookingOnMap, isBookingOnMap } = planner;
useBookingLink({
searchParams,
setSearchParams,
tripId,
reservations,
loaded: !isLoading && trip?.id === tripId,
take: (booking, onMap) => {
if (!onMap) openBookingDetail(booking);
else if (!isBookingOnMap(booking)) showBookingOnMap(booking);
else {
const dayId = booking.day_id ?? booking.end_day_id ?? null;
if (dayId !== null) planner.handleSelectDay(dayId);
}
},
});
}