mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): booking and stay cards, and a read of the bookings with their details
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user