diff --git a/client/src/components/Planner/bookings/BookingCard.tsx b/client/src/components/Planner/bookings/BookingCard.tsx index 514fa987c..dcc6bd231 100644 --- a/client/src/components/Planner/bookings/BookingCard.tsx +++ b/client/src/components/Planner/bookings/BookingCard.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react' import { Pencil, Trash2, Wallet } from 'lucide-react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -13,10 +12,10 @@ import PluginFrame from '../../Plugins/PluginFrame' import { PluginCardFooter } from '../../Plugins/PluginContributions' import { fmtTransitDuration } from '../transitDisplay' import { formatMoney } from '../../../utils/formatters' -import { parseMeta, typeInfo, displayTitle, type CostTotal } from './bookingsModel' +import { parseMeta, displayTitle, type CostTotal } from './bookingsModel' import type { BookingFacts } from './bookingFacts' import { - AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, RoundAction, StatusDot, TravelerChips, TypeChip, + AirTrailPill, BOX, Block, Field, FileRows, ReviewPill, RoundAction, RouteBox, StatusDot, TravelerChips, TypeChip, WhenFields, fs, toneOf, toneTint, } from './bookingParts' import { TransitLegs, transitSummary } from './transitParts' @@ -47,7 +46,6 @@ export interface BookingCardProps { export default function BookingCard(p: BookingCardProps) { const { r, facts } = p const { t, locale } = useTranslation() - const info = typeInfo(r.type) const tone = toneOf(r) const transit = r.type === 'transit' @@ -72,18 +70,7 @@ export default function BookingCard(p: BookingCardProps) {
- {(facts.day || facts.time) && ( - // A day range needs the whole width; the time then goes below it. -
- {facts.day && ( - - {facts.day.label} - {facts.day.date && {facts.day.date}} - - )} - {facts.time && {facts.time}} -
- )} + {transit ? : ( <> @@ -92,18 +79,7 @@ export default function BookingCard(p: BookingCardProps) { {r.confirmation_number} )} - {facts.endpoints.length >= 2 && ( - -
- {facts.endpoints.map((ep, i) => ( - - {i > 0 && } - {ep.name} - - ))} -
-
- )} + {facts.legCodes.length > 0 && (
@@ -181,16 +157,6 @@ export default function BookingCard(p: BookingCardProps) { ) } -/** Content longer than a field (a route, notes, people, files) under its own label, like every field. */ -function Block({ label, children }: { label: string; children: ReactNode }) { - return ( -
- {label} - {children} -
- ) -} - /** A transit journey as the dialog shows it, compacted: its totals as fields, then leg by leg. */ function TransitBody({ r }: { r: Reservation }) { const { t } = useTranslation() diff --git a/client/src/components/Planner/bookings/bookingParts.tsx b/client/src/components/Planner/bookings/bookingParts.tsx index e5fcfa881..484261807 100644 --- a/client/src/components/Planner/bookings/bookingParts.tsx +++ b/client/src/components/Planner/bookings/bookingParts.tsx @@ -10,6 +10,7 @@ import { Tooltip } from '../../shared/Tooltip' import { PILL } from '../../shared/DialogShell' import { useToast } from '../../shared/Toast' import { parseMeta, typeInfo } from './bookingsModel' +import type { BookingFacts } from './bookingFacts' // The phone's card language on the desktop: small Geist eyebrows, framed value // boxes, count pills. Sizes scale with the user's text size setting. @@ -46,6 +47,52 @@ export function Field({ label, children, className = '', tabular = false }: { la ) } +/** Content longer than a field (a route, notes, people, files) under its own label, like every field. */ +export function Block({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +/** When a booking is: its day or day range beside its time, the time under a range that needs the width. */ +export function WhenFields({ facts }: { facts: BookingFacts }) { + const { t } = useTranslation() + if (!facts.day && !facts.time) return null + return ( +
+ {facts.day && ( + + {facts.day.label} + {facts.day.date && {facts.day.date}} + + )} + {facts.time && {facts.time}} +
+ ) +} + +/** The stops of a journey in one framed line, the type's icon between them. */ +export function RouteBox({ type, facts }: { type: string; facts: BookingFacts }) { + const { t } = useTranslation() + if (facts.endpoints.length < 2) return null + const info = typeInfo(type) + return ( + +
+ {facts.endpoints.map((ep, i) => ( + + {i > 0 && } + {ep.name} + + ))} +
+
+ ) +} + export function CountPill({ children }: { children: ReactNode }) { return ( diff --git a/client/src/components/shared/PublicLanguagePicker.tsx b/client/src/components/shared/PublicLanguagePicker.tsx index 52426b84e..fdbb2a4f9 100644 --- a/client/src/components/shared/PublicLanguagePicker.tsx +++ b/client/src/components/shared/PublicLanguagePicker.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' +import { ChevronDown, Globe } from 'lucide-react' import { SUPPORTED_LANGUAGES } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAnchoredPosition } from '../../hooks/useAnchoredPosition' @@ -13,6 +14,11 @@ interface PublicLanguagePickerProps { locale?: string | null open: boolean onOpenChange: React.Dispatch> + /** + * `overlay` pins the pill over a dark hero (the journey page); `bar` sits in + * the flow of a light top bar, as the trip page's does. + */ + variant?: 'overlay' | 'bar' } /** @@ -26,7 +32,7 @@ interface PublicLanguagePickerProps { * trigger now, which no ancestor can clip, and it caps its own height at the room * the viewport actually has: the treatment `CustomSelect` already gets. */ -export default function PublicLanguagePicker({ locale, open, onOpenChange }: PublicLanguagePickerProps) { +export default function PublicLanguagePicker({ locale, open, onOpenChange, variant = 'overlay' }: PublicLanguagePickerProps) { const wrapRef = useRef(null) const menuRef = useRef(null) // matchWidth stays on: the anchored box's `width` is the trigger's own width, @@ -61,28 +67,45 @@ export default function PublicLanguagePicker({ locale, open, onOpenChange }: Pub ? Math.max(8, (typeof window === 'undefined' ? 0 : window.innerWidth) - anchored.left - anchored.width) : 8 + const label = SUPPORTED_LANGUAGES.find(l => l.value === current)?.label || 'Language' + return ( -
- +
+ {variant === 'bar' ? ( + + ) : ( + + )} {open && createPortal(
{ // After data loads, trip name appears await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); }); }); @@ -109,7 +109,7 @@ describe('SharedTripPage', () => { renderSharedTrip('test-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); }); }); @@ -150,7 +150,7 @@ describe('SharedTripPage', () => { renderSharedTrip('test-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); // Map container should be rendered @@ -163,7 +163,7 @@ describe('SharedTripPage', () => { renderSharedTrip('test-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); const bookingsTab = screen.getByRole('button', { name: /bookings/i }); @@ -180,7 +180,7 @@ describe('SharedTripPage', () => { renderSharedTrip('test-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); expect(screen.queryByRole('button', { name: /packing/i })).toBeNull(); @@ -212,7 +212,7 @@ describe('SharedTripPage', () => { renderSharedTrip('packing-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); const packingTab = screen.getByRole('button', { name: /packing/i }); @@ -251,7 +251,7 @@ describe('SharedTripPage', () => { renderSharedTrip('budget-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); const budgetTab = screen.getByRole('button', { name: /costs/i }); @@ -291,7 +291,7 @@ describe('SharedTripPage', () => { renderSharedTrip('collab-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); const collabTab = screen.getByRole('button', { name: /chat/i }); @@ -305,8 +305,8 @@ describe('SharedTripPage', () => { }); }); - describe('FE-PAGE-SHARED-013: Day card expands when clicked', () => { - it('reveals place names after clicking a collapsed day card header', async () => { + describe('FE-PAGE-SHARED-013: a day opens unfolded and folds with its chevron', () => { + it('lists the places of the day at once, hides them when folded and shows them again', async () => { const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One', notes: null }; const place = { id: 201, trip_id: 1, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, category_id: null, image_url: null, address: null }; @@ -335,19 +335,17 @@ describe('SharedTripPage', () => { renderSharedTrip('expand-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); - // Eiffel Tower is only in the mocked map tooltip (1 occurrence) - expect(screen.getAllByText('Eiffel Tower')).toHaveLength(1); + // Open like the planner's days: the map tooltip and the row in the day. + expect(screen.getAllByText('Eiffel Tower')).toHaveLength(2); - // Click the day card header to expand it - fireEvent.click(screen.getByText('Day One')); + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })); + await waitFor(() => expect(screen.getAllByText('Eiffel Tower')).toHaveLength(1)); - // Now Eiffel Tower also appears in the expanded day content - await waitFor(() => { - expect(screen.getAllByText('Eiffel Tower')).toHaveLength(2); - }); + fireEvent.click(screen.getByRole('button', { name: 'Expand' })); + await waitFor(() => expect(screen.getAllByText('Eiffel Tower')).toHaveLength(2)); }); }); @@ -356,7 +354,7 @@ describe('SharedTripPage', () => { renderSharedTrip('test-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); // Language picker button shows current language @@ -382,7 +380,7 @@ describe('SharedTripPage', () => { renderSharedTrip('test-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); expect(screen.getByText(/shared via/i)).toBeInTheDocument(); @@ -416,7 +414,7 @@ describe('SharedTripPage', () => { renderSharedTrip('bookings-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole('button', { name: /bookings/i })); @@ -469,7 +467,7 @@ describe('SharedTripPage', () => { renderSharedTrip('multileg-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); // Expand the day to reveal the timeline @@ -490,7 +488,7 @@ describe('SharedTripPage', () => { renderSharedTrip('multileg-bookings-token'); await waitFor(() => { - expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole('button', { name: /bookings/i })); @@ -524,13 +522,10 @@ describe('SharedTripPage', () => { ); renderSharedTrip('test-token'); // The untitled day shows the German label "Tag 1", proving the hardcoded English - // "Day 1" was replaced by the i18n key t('dayplan.dayN'). It appears twice since - // the day picker above the map was added (#1962): once as a chip, once on the card. - await waitFor(() => expect(screen.getAllByText('Tag 1')).toHaveLength(2)); - const labels = screen.getAllByText('Tag 1'); - // One of each: the picker chip carries aria-pressed, the day card's header aria-expanded. - expect(labels.filter((el) => el.closest('[aria-pressed]'))).toHaveLength(1); - expect(labels.filter((el) => el.closest('[aria-expanded]'))).toHaveLength(1); + // "Day 1" was replaced by the i18n key t('dayplan.dayN'). The map's select names + // the whole trip until a day is picked, so the card is the one place it shows. + await waitFor(() => expect(screen.getAllByText('Tag 1')).toHaveLength(1)); + expect(screen.getByText('Tag 1').closest('[aria-pressed]')).not.toBeNull(); }); }); @@ -555,7 +550,7 @@ describe('SharedTripPage', () => { ); renderSharedTrip('cad-token'); - await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: /costs/i })); await waitFor(() => expect(screen.getByText('Hotel')).toBeInTheDocument()); @@ -588,7 +583,7 @@ describe('SharedTripPage', () => { ); renderSharedTrip('mixed-token'); - await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: /costs/i })); await waitFor(() => expect(screen.getByText('Dinner')).toBeInTheDocument()); @@ -622,17 +617,18 @@ describe('SharedTripPage', () => { ); renderSharedTrip('booked-token'); - await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: /costs/i })); await waitFor(() => expect(screen.getByText('Aparthotel Silver')).toBeInTheDocument()); // Row and its category both read the booked value; the trip total adds the // 100 CHF tram pass to it. - await waitFor(() => expect(screen.getAllByText('685.26 CHF')).toHaveLength(2)); + // The row, its category card and the category's line in the overview. + await waitFor(() => expect(screen.getAllByText('685.26 CHF')).toHaveLength(3)); expect(screen.getByText('785.26 CHF')).toBeInTheDocument(); expect(screen.queryByText(/703\.6/)).toBeNull(); // What was entered stays beside the converted row, as the Costs list shows it. - expect(screen.getByText('· 801.76 USD')).toBeInTheDocument(); + expect(screen.getByText('801.76 USD')).toBeInTheDocument(); }); it('reads a same-day bill in the display currency exactly as typed, with the trip currency\'s quote', async () => { @@ -661,11 +657,11 @@ describe('SharedTripPage', () => { ); renderSharedTrip('same-day-token'); - await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: /costs/i })); - await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(3)); - expect(screen.queryByText(/^· /)).toBeNull(); + // Total, overview, category card and row, and no second "entered" line under the row. + await waitFor(() => expect(screen.getAllByText('12,345.67 USD')).toHaveLength(4)); }); it('reads a bill in the display currency through the trip currency too, beside what was entered', async () => { @@ -692,14 +688,14 @@ describe('SharedTripPage', () => { ); renderSharedTrip('display-token'); - await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument()); fireEvent.click(screen.getByRole('button', { name: /costs/i })); - await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(2)); - expect(screen.getAllByText('780.86 USD')).toHaveLength(2); + await waitFor(() => expect(screen.getAllByText('113.95 USD')).toHaveLength(3)); + expect(screen.getAllByText('780.86 USD')).toHaveLength(3); expect(screen.getByText('894.81 USD')).toBeInTheDocument(); - expect(screen.getByText('· 801.76 USD')).toBeInTheDocument(); - expect(screen.getByText('· 100.00 CHF')).toBeInTheDocument(); + expect(screen.getByText('801.76 USD')).toBeInTheDocument(); + expect(screen.getByText('100.00 CHF')).toBeInTheDocument(); }); }); @@ -737,14 +733,20 @@ describe('SharedTripPage', () => { async function open(token: string, body: Record) { serve(token, body); renderSharedTrip(token); - await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Shared Paris Trip' })).toBeInTheDocument()); } function coverStyle(): string { - const layer = document.querySelector('div[style*="background-image"]') as HTMLElement | null; - return layer?.style.backgroundImage ?? ''; + return document.querySelector('[data-testid="shared-hero"] img')?.getAttribute('src') ?? ''; } + /** The select over the map and its step buttons. */ + const dayPicker = () => within(screen.getByTestId('map-day-picker')); + const nextDay = () => fireEvent.click(dayPicker().getByRole('button', { name: 'Next day' })); + const prevDay = () => fireEvent.click(dayPicker().getByRole('button', { name: 'Previous day' })); + /** The card of one day, found by its title. */ + const dayCard = (title: string) => within(screen.getByText(title).closest('article') as HTMLElement); + /** The page formats dates through the active locale (en-US in tests). */ const fmtDate = (iso: string, opts: Intl.DateTimeFormatOptions) => new Date(iso).toLocaleDateString('en-US', opts); @@ -802,8 +804,9 @@ describe('SharedTripPage', () => { dayNotes: {}, }); - // share_map is undefined, so the Plan tab (the !== false branch) stays visible. - expect(screen.getByRole('button', { name: /plan/i })).toBeInTheDocument(); + // share_map is undefined, so the plan (the !== false branch) shows; with nothing + // else shared there is no tab bar to choose from. + expect(screen.queryByRole('button', { name: /plan/i })).toBeNull(); expect(screen.queryByRole('button', { name: /bookings/i })).toBeNull(); expect(screen.getByTestId('map-container')).toBeInTheDocument(); // The day card still renders even though reservations/accommodations are absent. @@ -836,12 +839,9 @@ describe('SharedTripPage', () => { accommodations: [{ id: 3, place_name: 'Hotel Lutetia', start_day_id: 5, end_day_id: 5 }], })); - // Twice since the day picker landed (#1962): the chip above the map and the card. - const dayLabels = screen.getAllByText('Day 1'); - expect(dayLabels).toHaveLength(2); - // One of each: the picker chip carries aria-pressed, the day card's header aria-expanded. - expect(dayLabels.filter((el) => el.closest('[aria-pressed]'))).toHaveLength(1); - expect(dayLabels.filter((el) => el.closest('[aria-expanded]'))).toHaveLength(1); + // On the card's head, which is also what opens the day on the map. + expect(screen.getAllByText('Day 1')).toHaveLength(1); + expect(screen.getByText('Day 1').closest('[aria-pressed]')).not.toBeNull(); expect(screen.getByText('Hotel Lutetia')).toBeInTheDocument(); expect(screen.getByText('0 places')).toBeInTheDocument(); // No date row for an undated day. @@ -893,7 +893,6 @@ describe('SharedTripPage', () => { // The count matches the rendered rows — the assignment whose place is gone is left out. expect(screen.getByText('3 places')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Day One')); await waitFor(() => expect(screen.getByText('Rue de Rivoli')).toBeInTheDocument()); expect((document.querySelector('img[src="/uploads/places/louvre.jpg"]') as HTMLImageElement)).toBeInTheDocument(); @@ -901,27 +900,30 @@ describe('SharedTripPage', () => { expect(screen.getByText('Along the river')).toBeInTheDocument(); // The bare place shows neither line and no time badge. expect(screen.getByText('Mystery Stop')).toBeInTheDocument(); - expect(screen.getByText(/09:00 – 11:00/)).toBeInTheDocument(); + expect(screen.getByText('09:00 → 11:00')).toBeInTheDocument(); expect(screen.getByText(/^12:00$/)).toBeInTheDocument(); }); - it('only maps the selected day and refits the map to it', async () => { + it('only maps the day its card opens, and the whole trip again on a second click', async () => { + const pantheon = { id: 204, name: 'Pantheon', lat: 48.846, lng: 2.346, category_id: null, image_url: null }; await open('mapday-token', payload({ days: [day], - places: [withImage, withDescription], + places: [withImage, withDescription, pantheon], assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: withImage }] }, })); - // Unselected: both places are candidates, but only the geocoded one has a marker. - expect(screen.getAllByText('Louvre')).toHaveLength(1); + // Whole trip: every geocoded place has a marker, the Pantheon too, though no day visits it. + expect(screen.getByText('Pantheon')).toBeInTheDocument(); expect(screen.queryByText('Seine Walk')).toBeNull(); - fireEvent.click(screen.getByText('Day One')); - await waitFor(() => expect(screen.getAllByText('Louvre')).toHaveLength(2)); + // Once picked, the select over the map names the day as well; the card is the one in an article. + const cardTitle = () => screen.getAllByText('Day One').find(el => el.closest('article')) as HTMLElement; + fireEvent.click(cardTitle()); + await waitFor(() => expect(screen.queryByText('Pantheon')).toBeNull()); + expect(cardTitle().closest('[aria-pressed]')).toHaveAttribute('aria-pressed', 'true'); - // Collapsing again restores the trip-wide marker set. - fireEvent.click(screen.getByText('Day One')); - await waitFor(() => expect(screen.getAllByText('Louvre')).toHaveLength(1)); + fireEvent.click(cardTitle()); + await waitFor(() => expect(screen.getByText('Pantheon')).toBeInTheDocument()); }); }); @@ -947,7 +949,7 @@ describe('SharedTripPage', () => { }, })); - fireEvent.click(screen.getByRole('button', { name: 'Day 1' })); + nextDay(); await waitFor(() => expect(iconHtml().some((h: string) => h.includes('>1<'))).toBe(true)); const html = iconHtml(); @@ -970,10 +972,10 @@ describe('SharedTripPage', () => { }, })); - fireEvent.click(screen.getByRole('button', { name: 'Day 1' })); - await waitFor(() => expect(iconHtml().some((h: string) => h.includes('1 \u00b7 3'))).toBe(true)); + nextDay(); + await waitFor(() => expect(iconHtml().some((h: string) => h.includes('>1, 3<'))).toBe(true)); // One marker for the repeated place, not two with the same React key. - expect(iconHtml().filter((h: string) => h.includes('1 \u00b7 3'))).toHaveLength(1); + expect(iconHtml().filter((h: string) => h.includes('>1, 3<'))).toHaveLength(1); }); it('leaves the trip-wide pool unnumbered, since it has no order to show', async () => { @@ -1002,10 +1004,10 @@ describe('SharedTripPage', () => { // No day selected: no line, even though the trip has two geocoded places. expect(screen.queryByTestId('route-line')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: 'Day 1' })); + nextDay(); await waitFor(() => expect(screen.getByTestId('route-line')).toBeInTheDocument()); - fireEvent.click(screen.getByRole('button', { name: 'All' })); + prevDay(); await waitFor(() => expect(screen.queryByTestId('route-line')).toBeNull()); }); @@ -1016,7 +1018,7 @@ describe('SharedTripPage', () => { assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] }, })); - fireEvent.click(screen.getByRole('button', { name: 'Day 1' })); + nextDay(); await waitFor(() => expect(screen.getAllByText('Louvre').length).toBeGreaterThan(1)); expect(screen.queryByTestId('route-line')).toBeNull(); }); @@ -1037,22 +1039,37 @@ describe('SharedTripPage', () => { // All: both geocoded places are on the map, so Orsay's tooltip is present. expect(screen.getByText('Orsay')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Day 1' })); + nextDay(); await waitFor(() => expect(screen.queryByText('Orsay')).toBeNull()); - fireEvent.click(screen.getByRole('button', { name: 'All' })); + prevDay(); await waitFor(() => expect(screen.getByText('Orsay')).toBeInTheDocument()); }); - it('marks the active chip for assistive tech', async () => { + it('names the open day in the select and presses its card, and stops at either end', async () => { await open('pressed-token', payload({ days: [day], places: [louvre], assignments: {} })); - expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true'); - fireEvent.click(screen.getByRole('button', { name: 'Day 1' })); - await waitFor(() => - expect(screen.getByRole('button', { name: 'Day 1' })).toHaveAttribute('aria-pressed', 'true'), - ); - expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false'); + expect(dayPicker().getByText('Whole trip')).toBeInTheDocument(); + expect(dayPicker().getByRole('button', { name: 'Previous day' })).toBeDisabled(); + + nextDay(); + await waitFor(() => expect(dayPicker().getByText('Day One')).toBeInTheDocument()); + const card = screen.getAllByText('Day One').find(el => el.closest('article')); + expect(card?.closest('[aria-pressed]')).toHaveAttribute('aria-pressed', 'true'); + expect(dayPicker().getByRole('button', { name: 'Next day' })).toBeDisabled(); + }); + + it('opens a day picked from the list of the select', async () => { + await open('select-token', payload({ + days: [day], + places: [louvre, orsay], + assignments: { '7': [{ id: 301, day_id: 7, place_id: 201, order_index: 0, place: louvre }] }, + })); + + fireEvent.click(dayPicker().getByText('Whole trip')); + const option = screen.getAllByText('Day One').find(el => !el.closest('article') && !el.closest('[data-testid="map-day-picker"]')); + fireEvent.click(option as HTMLElement); + await waitFor(() => expect(screen.queryByText('Orsay')).toBeNull()); }); }); @@ -1122,16 +1139,20 @@ describe('SharedTripPage', () => { ], })); - fireEvent.click(screen.getByText('Day One')); - await waitFor(() => expect(screen.getByText('Buy museum pass')).toBeInTheDocument()); expect(screen.getByText('08:30')).toBeInTheDocument(); expect(screen.getByText('Anything goes')).toBeInTheDocument(); - expect(screen.getByText('Air France · AF1 · CDG → TXL')).toBeInTheDocument(); - expect(screen.getByText(/Flight home · 18:00–20:30/)).toBeInTheDocument(); - expect(screen.getByText('ICE 599 · Gl. 7')).toBeInTheDocument(); - expect(screen.getByText(/^ICE 599 · 09:15$/)).toBeInTheDocument(); + // Each fact on its own, beside the time pill. + expect(screen.getByText('Flight home')).toBeInTheDocument(); + expect(screen.getByText('18:00 → 20:30')).toBeInTheDocument(); + expect(screen.getByText('Air France')).toBeInTheDocument(); + expect(screen.getByText('AF1')).toBeInTheDocument(); + expect(screen.getByText('CDG → TXL')).toBeInTheDocument(); + // The title and the train number are the same words here. + expect(screen.getAllByText('ICE 599')).toHaveLength(2); + expect(screen.getByText('Platform 7')).toBeInTheDocument(); + expect(screen.getByText('09:15')).toBeInTheDocument(); // An unmapped transport type falls back to the generic ticket row with no subtitle. expect(screen.getByText('Harbour ferry')).toBeInTheDocument(); }); @@ -1155,10 +1176,10 @@ describe('SharedTripPage', () => { ], })); - fireEvent.click(screen.getByText('Day One')); - - await waitFor(() => expect(screen.getByText(/Bus 100 · 07:00/)).toBeInTheDocument()); - expect(screen.getByText(/Regio · 08:00/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('Bus 100')).toBeInTheDocument()); + expect(screen.getByText('07:00')).toBeInTheDocument(); + expect(screen.getByText('Regio')).toBeInTheDocument(); + expect(screen.getByText('08:00')).toBeInTheDocument(); // Only the airline is known, so no airport pair is appended. expect(screen.getByText('KLM')).toBeInTheDocument(); }); @@ -1180,9 +1201,9 @@ describe('SharedTripPage', () => { ], })); - fireEvent.click(screen.getByText('Day One')); - - await waitFor(() => expect(screen.getByText('ICE 73 · Gl. 3 · Berlin → Basel')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('ICE 73')).toBeInTheDocument()); + expect(screen.getByText('Platform 3')).toBeInTheDocument(); + expect(screen.getByText('Berlin → Basel')).toBeInTheDocument(); // The second leg has neither platform nor stations, so only its number shows. expect(screen.getByText('EC 51')).toBeInTheDocument(); }); @@ -1204,10 +1225,12 @@ describe('SharedTripPage', () => { ], })); - fireEvent.click(screen.getByText('Day One')); - - await waitFor(() => expect(screen.getByText('Emirates · EK46 · FRA → DXB')).toBeInTheDocument()); - expect(screen.getByText('Emirates · EK350')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('EK46')).toBeInTheDocument()); + expect(screen.getByText('FRA → DXB')).toBeInTheDocument(); + expect(screen.getAllByText('Emirates')).toHaveLength(2); + // The second leg has no airports of its own, so it states its number and no route. + expect(screen.getByText('EK350')).toBeInTheDocument(); + expect(screen.getAllByText(/ → [A-Z]{3}$/)).toHaveLength(1); }); }); @@ -1239,14 +1262,15 @@ describe('SharedTripPage', () => { await waitFor(() => expect(screen.getByText('Museum entry')).toBeInTheDocument()); expect( - screen.getByText(fmtDate('2026-07-03T00:00:00Z', { day: 'numeric', month: 'short', timeZone: 'UTC' })), + screen.getByText(fmtDate('2026-07-03T00:00:00Z', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })), ).toBeInTheDocument(); expect(screen.getByText('14:00')).toBeInTheDocument(); expect(screen.getByText('Louvre')).toBeInTheDocument(); expect(screen.getByText('Pending')).toBeInTheDocument(); - // No date/time/location for the car — only the metadata airline line survives. - expect(screen.getByText('Sixt X9')).toBeInTheDocument(); + // No date/time/location for the car, only its metadata, one field each. + expect(screen.getByText('Sixt')).toBeInTheDocument(); + expect(screen.getByText('X9')).toBeInTheDocument(); expect(screen.getAllByText('Confirmed')).toHaveLength(3); // Metadata-less bookings render the title only, no meta line. expect(screen.getByText('Night bus')).toBeInTheDocument(); @@ -1274,10 +1298,11 @@ describe('SharedTripPage', () => { await waitFor(() => expect(screen.getByText('IC 8 Platform 12 Bern → Zurich')).toBeInTheDocument()); expect(screen.getByText('S3')).toBeInTheDocument(); - // A bare date without a time renders the date chip only. + // A bare date without a time renders the date field only. expect( - screen.getByText(fmtDate('2026-07-04T00:00:00Z', { day: 'numeric', month: 'short', timeZone: 'UTC' })), + screen.getByText(fmtDate('2026-07-04T00:00:00Z', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })), ).toBeInTheDocument(); + expect(screen.queryByText('Time')).toBeNull(); }); it('omits the route of a flight leg without airports', async () => { @@ -1311,6 +1336,7 @@ describe('SharedTripPage', () => { expect(screen.queryByText('Confirmed')).toBeNull(); expect(screen.queryByTestId('map-container')).toBeNull(); + expect(screen.getByText('No bookings shared yet')).toBeInTheDocument(); }); }); @@ -1329,8 +1355,11 @@ describe('SharedTripPage', () => { await waitFor(() => expect(screen.getByText('Bathroom')).toBeInTheDocument()); expect(screen.getByText('Other')).toBeInTheDocument(); - expect(screen.getByText('Toothbrush')).toHaveStyle({ textDecoration: 'line-through' }); - expect(screen.getByText('Towel')).toHaveStyle({ textDecoration: 'none' }); + expect(screen.getByText('Toothbrush')).toHaveClass('line-through'); + expect(screen.getByText('Towel')).not.toHaveClass('line-through'); + // One of three packed, overall and in its category. + expect(screen.getByText('1 of 3 packed (33%)')).toBeInTheDocument(); + expect(screen.getByText('1/2')).toBeInTheDocument(); }); it('renders nothing when the packing list is empty', async () => { @@ -1340,6 +1369,7 @@ describe('SharedTripPage', () => { fireEvent.click(screen.getByRole('button', { name: /packing/i })); expect(screen.queryByText('Other')).toBeNull(); + expect(screen.getByText('The packing list is still empty')).toBeInTheDocument(); }); }); @@ -1358,9 +1388,11 @@ describe('SharedTripPage', () => { fireEvent.click(screen.getByRole('button', { name: /costs/i })); await waitFor(() => expect(screen.getByText('Hostel')).toBeInTheDocument()); - expect(screen.getByText('Stay')).toBeInTheDocument(); - expect(screen.getByText('Other')).toBeInTheDocument(); - expect(screen.getByText('—')).toBeInTheDocument(); + // Each category twice: its line in the overview and its own card. + expect(screen.getAllByText('Stay')).toHaveLength(2); + expect(screen.getAllByText('Other')).toHaveLength(2); + // The priceless row states no amount rather than a placeholder. + expect(screen.getByText('Museum pass').closest('li')?.textContent).toBe('Museum pass'); // The priceless row contributes 0, so the total equals the single priced row. expect(screen.getAllByText('80.00 GBP').length).toBeGreaterThanOrEqual(2); }); @@ -1388,6 +1420,7 @@ describe('SharedTripPage', () => { fireEvent.click(screen.getByRole('button', { name: /costs/i })); expect(screen.queryByText('Total Costs')).toBeNull(); + expect(screen.getByText('No expenses yet')).toBeInTheDocument(); }); }); @@ -1405,7 +1438,8 @@ describe('SharedTripPage', () => { fireEvent.click(screen.getByRole('button', { name: /chat/i })); await waitFor(() => expect(screen.getByText('Morning')).toBeInTheDocument()); - expect(screen.getByText(/Chat · 3 messages/)).toBeInTheDocument(); + const head = screen.getByRole('heading', { name: 'Chat' }).parentElement as HTMLElement; + expect(within(head).getByText('3')).toBeInTheDocument(); // Two distinct days -> exactly one separator each, none for the second message // of day one. const sep = (iso: string) => fmtDate(iso, { weekday: 'short', day: 'numeric', month: 'short' }); @@ -1423,7 +1457,8 @@ describe('SharedTripPage', () => { })); fireEvent.click(screen.getByRole('button', { name: /chat/i })); - expect(screen.queryByText(/messages/)).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Chat' })).toBeNull(); + expect(screen.getByText('No messages yet')).toBeInTheDocument(); }); }); @@ -1448,7 +1483,7 @@ describe('SharedTripPage', () => { { id: 11, trip_id: 1, day_number: 3, date: '2026-07-03', title: 'Day Three' }, ]; - it('skips the days in between and leaves that day collapsed', async () => { + it('skips the days in between and leaves that day empty', async () => { await open('parking-token', payload({ days, reservations: [ @@ -1459,15 +1494,13 @@ describe('SharedTripPage', () => { ], })); - // Only one day is expanded at a time, so each day is checked on its own. - fireEvent.click(screen.getByText('Day One')); - await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument()); - - fireEvent.click(screen.getByText('Day Two')); - await waitFor(() => expect(screen.queryByText(/Airport Parking/)).toBeNull()); - - fireEvent.click(screen.getByText('Day Three')); - await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument()); + // Every day is open, so each card is checked on its own. + expect(dayCard('Day One').getByText('Airport Parking')).toBeInTheDocument(); + expect(dayCard('Day Two').queryByText('Airport Parking')).toBeNull(); + expect(dayCard('Day Two').getByText('No places planned for this day')).toBeInTheDocument(); + expect(dayCard('Day Three').getByText('Airport Parking')).toBeInTheDocument(); + // The drop-off and the pickup say which end of the stay they are. + expect(dayCard('Day One').getByText('Drop-off')).toBeInTheDocument(); }); }); @@ -1496,7 +1529,6 @@ describe('SharedTripPage', () => { accommodations: [{ id: 7, place_id: 602, start_day_id: 41, end_day_id: 41, place_name: 'Hotel Adlon' }], })); - fireEvent.click(screen.getByText('Day One')); // The stop the traveller placed is listed, address and all. await waitFor(() => expect(screen.getByText('77 Rue de Varenne')).toBeInTheDocument()); // The booked night is not: it is already on the day as its own chip. @@ -1523,7 +1555,7 @@ describe('SharedTripPage', () => { accommodations: [{ id: 7, place_id: 602, start_day_id: 41, end_day_id: 41, place_name: 'Hotel Adlon' }], })); - fireEvent.click(screen.getByRole('button', { name: 'Day 1' })); + nextDay(); await waitFor(() => expect(iconHtml().some((h: string) => h.includes('>1<'))).toBe(true)); // Rodin is 1 and Orsay is 2, as in the planner; nothing wears 3 and the hotel's @@ -1560,7 +1592,6 @@ describe('SharedTripPage', () => { it('renders every field the owner filled, as separate lines', async () => { await open('detail-token', payload({ days, assignments })); - fireEvent.click(screen.getByText('Day One')); // The name is also on the map marker's tooltip, so the list row is the one with the address under it. await waitFor(() => expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument()); expect(screen.getAllByText('Louvre').length).toBeGreaterThan(0); @@ -1597,7 +1628,6 @@ describe('SharedTripPage', () => { }], }; await open('bare-token', payload({ days, assignments: bare })); - fireEvent.click(screen.getByText('Day One')); await waitFor(() => expect(screen.getByText('Somewhere')).toBeInTheDocument()); expect(screen.queryByRole('link', { name: /website/i })).toBeNull(); @@ -1624,7 +1654,7 @@ describe('SharedTripPage', () => { }, ], })); - fireEvent.click(screen.getByText('Bookings')); + fireEvent.click(screen.getByRole('button', { name: 'Bookings' })); await waitFor(() => expect(screen.getByText('Night train')).toBeInTheDocument()); expect(screen.getByText('Meet at the north entrance')).toBeInTheDocument(); @@ -1640,16 +1670,15 @@ describe('SharedTripPage', () => { // ── #2345: the header has to clip the decoration it bleeds ────────────── - describe('FE-PAGE-SHARED-042: the header clips the circles it bleeds (#2345)', () => { - it('does not let them widen the page', async () => { - await open('overflow-token', payload({})); + describe('FE-PAGE-SHARED-042: the hero clips its cover (#2345)', () => { + it('does not let it widen the page', async () => { + await open('overflow-token', payload({ trip: { id: 1, title: 'Shared Paris Trip', cover_image: 'c.jpg' } })); - // Both circles sit outside the header on purpose, so the page only stays - // as wide as the viewport if the header itself is the clip. The header is - // the first gradient panel on the page and carries the trip title. - const header = document.querySelector('div[style*="linear-gradient(135deg"]'); - expect(header?.textContent).toContain('Shared Paris Trip'); - expect(header?.style.overflow).toBe('hidden'); + // The cover fills the hero edge to edge, so the page only stays as wide as the + // viewport if the hero itself is the clip. + const hero = screen.getByTestId('shared-hero'); + expect(hero.textContent).toContain('Shared Paris Trip'); + expect(hero).toHaveClass('overflow-hidden'); }); }); @@ -1748,7 +1777,7 @@ describe('SharedTripPage', () => { fireEvent.click(await screen.findByRole('button', { name: /try again/i })); - await waitFor(() => expect(screen.getByText('Past The Wall')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Past The Wall' })).toBeInTheDocument()); expect(calls).toBe(2); }); @@ -1775,7 +1804,7 @@ describe('SharedTripPage', () => { const retry = await screen.findByRole('button', { name: /try again/i }); fireEvent.click(retry); - await waitFor(() => expect(screen.getByText('Back Online Trip')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Back Online Trip' })).toBeInTheDocument()); expect(calls).toBe(2); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 2080dcab2..93d1aaf60 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -1,98 +1,20 @@ -import L from 'leaflet'; -import { - Bus, - Car, - Clock, - FileText, - Hotel, - Luggage, - Map, - MapPin, - MessageCircle, - Plane, - Ship, - Ticket, - Train, - Wallet, -} from 'lucide-react'; -import { createElement, useEffect, useRef } from 'react'; -import { renderIconMarkup } from '../utils/iconMarkup'; -import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet'; -import MarkerClusterGroup from 'react-leaflet-cluster'; -// MapView brings these sheets for the planner, and this page never mounts it. -import 'leaflet.markercluster/dist/MarkerCluster.css'; -import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; -import { CLUSTER_OPTIONS, createClusterIcon } from '../components/Map/markerCluster'; -import { getCategoryIcon } from '../components/shared/categoryIcons'; -import PublicLanguagePicker from '../components/shared/PublicLanguagePicker'; -import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults'; -import VectorBasemap from '../components/Map/VectorBasemap'; -import { convertBooked, convertedLine } from '../hooks/useExchangeRates'; +import { Map, MessageCircle, PackageCheck, Ticket, Wallet } from 'lucide-react'; import { useTranslation } from '../i18n'; -import { avatarSrc } from '../utils/avatarSrc'; -import { safeHexColor } from '../utils/safeColor'; -import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge'; -import { isDayInAccommodationRange } from '../utils/dayOrder'; -import { getFlightLegs, getTrainLegs } from '../utils/flightLegs'; -import { currencyDecimals, splitReservationDateTime } from '../utils/formatters'; -import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport'; -import { resolveBasemap } from '../utils/tileUrl'; import { useSharedTrip } from './sharedTrip/useSharedTrip'; -import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails'; -import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails'; import { SharedTripErrorScreen } from './sharedTrip/SharedTripErrorScreen'; - -const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }; - -// Injected into Leaflet's marker HTML, where CSS variables cannot reach - the same -// reason MapView.tsx is exempt from theme:lint outright. -const ORDER_BADGE_STYLE = 'position:absolute;bottom:-4px;right:-4px;min-width:16px;height:16px;border-radius:8px;padding:0 3px;background:rgba(255,255,255,0.94);border:1.5px solid rgba(0,0,0,0.15);box-shadow:0 1px 4px rgba(0,0,0,0.18);display:flex;align-items:center;justify-content:center;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;white-space:nowrap;'; // theme-lint-disable - -function createMarkerIcon(place: any, orderNumbers?: number[] | null) { - const cat = place.category; - // This page answers without a guard, so an unescaped colour here reaches - // people who have no account on the instance at all. - // The payload carries the category in two shapes: nested on a day's assignments, - // flat on the trip-wide pool. Reading only the nested one dropped every marker - // outside a day selection to the placeholder colour. - const color = safeHexColor(cat?.color ?? place.category_color, '#6366f1'); - const CatIcon = getCategoryIcon(cat?.icon ?? place.category_icon); - const iconSvg = renderIconMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' })); - // Position in the day's order, same contract as the planner: a stop the day - // visits twice shows both, e.g. "1 . 3". The values are array indices, never payload. - const badge = orderNumbers?.length - ? `${orderNumbers.join(' \u00b7 ')}` - : ''; - return L.divIcon({ - className: '', - iconSize: [28, 28], - iconAnchor: [14, 14], - html: `
${iconSvg}${badge}
`, - }); -} - -function FitBoundsToPlaces({ places, framedOnMount }: { places: any[]; framedOnMount: boolean }) { - const map = useMap(); - const fitRan = useRef(false); - // The page rebuilds this array on every render (a Page body may not memoise), so - // keying the effect on the coordinates rather than the array identity is what stops - // an unrelated re-render - the language picker, a late FX response - from throwing - // the viewer's pan and zoom away. - const fitKey = places.map((p) => `${p.lat},${p.lng}`).join('|'); - useEffect(() => { - if (places.length === 0) return; - // The map already opened framed on these places; fitting again would only re-do it. - // Picking a day afterwards still refits to that day. - if (!fitRan.current && framedOnMount) { - fitRan.current = true; - return; - } - fitRan.current = true; - const bounds = L.latLngBounds(places.map((p) => [p.lat, p.lng])); - map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }); - }, [fitKey, map]); // eslint-disable-line react-hooks/exhaustive-deps - return null; -} +import { + PAGE_WIDTH, + SharedFooter, + SharedHero, + SharedLoading, + SharedTabBar, + SharedTopBar, + type HeroStat, + type SharedTab, +} from './sharedTrip/SharedChrome'; +import { SharedPlanView } from './sharedTrip/SharedPlanView'; +import { SharedBookingsView } from './sharedTrip/SharedBookingsView'; +import { SharedChatView, SharedCostsView, SharedPackingView } from './sharedTrip/SharedListsViews'; export default function SharedTripPage() { const { t, locale } = useTranslation(); @@ -106,6 +28,9 @@ export default function SharedTripPage() { convert, selectedDay, setSelectedDay, + pickDayOnMap, + collapsedDays, + toggleDay, activeTab, setActiveTab, showLangPicker, @@ -113,1134 +38,60 @@ export default function SharedTripPage() { } = useSharedTrip(); if (error) return ; + if (!data) return ; - if (!data) - return ( -
-
- -
- ); + const { trip, permissions } = data; + const reservations = data.reservations || []; - const { - trip, - days, - assignments, - dayNotes, - places, - reservations, - accommodations, - packing, - budget, - categories, - permissions, - collab, - cartoApiKey, - } = data; - const sortedDays = [...(days || [])].sort((a: any, b: any) => a.day_number - b.day_number); + const tabs: SharedTab[] = [ + ...(permissions?.share_map !== false ? [{ id: 'plan', label: t('shared.tabPlan'), icon: Map }] : []), + ...(permissions?.share_bookings ? [{ id: 'bookings', label: t('shared.tabBookings'), icon: Ticket }] : []), + ...(permissions?.share_packing ? [{ id: 'packing', label: t('shared.tabPacking'), icon: PackageCheck }] : []), + ...(permissions?.share_budget ? [{ id: 'budget', label: t('shared.tabBudget'), icon: Wallet }] : []), + ...(permissions?.share_collab ? [{ id: 'collab', label: t('shared.tabChat'), icon: MessageCircle }] : []), + ]; - // Map places. In day mode each stop carries its position in the day's order; the - // trip-wide pool has none (it arrives by created_at). The index runs over the full - // sorted assignment list, like the planner does, so a stop without coordinates still - // consumes a number and the app and the share link agree on what "3" means. - // - // The stop a booked night wrote onto its check-in day heads that day since the - // reseat, and the planner's numbers leave it out (the day list below does too). So - // the numbers and the day line are counted over the traveller's own stops, or the - // hotel wore badge 1, every real stop read one higher than in the app, and the line - // set off from where the day ends. Its pin stays on the day, unnumbered, the way the - // planner keeps the hotel on the map. - const dayAssignments = selectedDay - ? [...(assignments[String(selectedDay)] || [])].sort((a: any, b: any) => a.order_index - b.order_index) - : []; - const dayStops = dayAssignments.filter(a => a.accommodation_id == null); - const dayOrderMap: Record = {}; - dayStops.forEach((a, i) => { - if (!a.place?.id) return; - (dayOrderMap[a.place.id] ||= []).push(i + 1); - }); - // The places these assignments sit on, in their order, each drawn once. - const locatedPlaces = (list: typeof dayAssignments) => { - const seen = new Set(); - return list.map(a => a.place).filter(p => { - if (!p?.lat || !p?.lng || seen.has(p.id)) return false; - seen.add(p.id); - return true; - }); - }; - const dayPlaces = locatedPlaces(dayAssignments); - const dayLine = locatedPlaces(dayStops); - const mapPlaces = selectedDay ? dayPlaces : (places || []).filter((p: any) => p?.lat && p?.lng); - - // Open framed on the trip's places instead of on Paris. MapContainer only reads center/zoom - // at mount, so recomputing this per render is free — and the fit below takes over from there. - const framed = computeMapViewport(mapPlaces, { - tileSize: TILE_SIZE_RASTER, - padding: { top: 40, right: 40, bottom: 40, left: 40 }, - }); - const initialView = framed ?? { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM }; - - // A visitor of a share link has no settings of their own, so the basemap is the - // app default: OpenFreeMap, a vector style that needs no key at all. The owner's - // CARTO key still travels in the payload and is still applied, because the - // fallback is only a fallback — a raster template reaching this page keeps - // working, and without the key CARTO would stamp "API KEY REQUIRED" over it. - const basemap = resolveBasemap(null, OFM_POSITRON, cartoApiKey); + const stats: HeroStat[] = [ + { key: 'days', value: data.days?.length || 0, label: t('dashboard.days') }, + { key: 'places', value: data.places?.length || 0, label: t('dashboard.places') }, + { key: 'bookings', value: permissions?.share_bookings ? reservations.length : 0, label: t('trip.tabs.reservations') }, + ].filter(s => s.value > 0); return ( -
- {/* Header */} -
- {/* Cover image background */} - {trip.cover_image && ( -
- )} - {/* Background decoration */} -
-
+
+ - {/* Logo */} -
- TREK +
+ + +
+ {activeTab === 'plan' && ( + + )} + {activeTab === 'bookings' && ( + + )} + {activeTab === 'packing' && } + {activeTab === 'budget' && ( + + )} + {activeTab === 'collab' && }
+
-
- Travel Resource & Exploration Kit -
- -

- {trip.title} -

- - {trip.description && ( -
- {trip.description} -
- )} - - {(trip.start_date || trip.end_date) && ( -
- - {[trip.start_date, trip.end_date] - .filter(Boolean) - .map((d: string) => - new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { - day: 'numeric', - month: 'short', - year: 'numeric', - timeZone: 'UTC', - }) - ) - .join(' — ')} - - {days?.length > 0 && ( - · - )} - {days?.length > 0 && ( - - {days.length} {t('shared.days')} - - )} -
- )} - -
- {t('shared.readOnly')} -
- - -
- -
- {/* Tabs */} -
- {[ - ...(permissions?.share_map !== false ? [{ id: 'plan', label: t('shared.tabPlan'), Icon: Map }] : []), - ...(permissions?.share_bookings ? [{ id: 'bookings', label: t('shared.tabBookings'), Icon: Ticket }] : []), - ...(permissions?.share_packing ? [{ id: 'packing', label: t('shared.tabPacking'), Icon: Luggage }] : []), - ...(permissions?.share_budget ? [{ id: 'budget', label: t('shared.tabBudget'), Icon: Wallet }] : []), - ...(permissions?.share_collab ? [{ id: 'collab', label: t('shared.tabChat'), Icon: MessageCircle }] : []), - ].map((tab) => ( - - ))} -
- - {/* Map */} - {activeTab === 'plan' && ( - <> - {/* Day picker at the map. Same setter as the day card below, so the map and - the expanded day can never disagree. Without it the only way to narrow the - map down was a control 300px further down the page. */} - {sortedDays.length > 0 && ( -
- {[null, ...sortedDays.map((d: any) => d.id)].map((id: number | null, i: number) => { - const active = selectedDay === id; - return ( - - ); - })} -
- )} -
- - {basemap.kind === 'vector' ? ( - - ) : ( - - )} - - {selectedDay && dayLine.length > 1 && ( - [p.lat, p.lng])} - // Dashed and straight on purpose: it shows the order of the day's stops, - // not the roads between them. A real route would mean sending the - // itinerary to a third party for every anonymous visitor of a shared - // link, with no way for the trip's owner to opt out. - pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.8, dashArray: '6 8', lineCap: 'round' }} // theme-lint-disable - interactive={false} - /> - )} - {/* Clustered like the planner's map, so nearby stops stay tappable (#2343). */} - - {mapPlaces.map((p: any) => ( - - {p.name} - - ))} - - -
- - {/* Day Plan */} -
- {sortedDays.map((day: any, di: number) => { - // Without the booked nights: the day already shows each of them as its - // own chip, and the stop a booking writes for the drive would be that - // same hotel a second time. There is no road trip view on a shared link, - // so the stop has nothing else to do here. - const da = (assignments[String(day.id)] || []).filter((a: any) => a.accommodation_id == null); - // A share can still carry an assignment for a deleted place. The timeline - // skips those rows, so the header must not count them either. - const dayPlaceCount = da.filter((a: any) => a.place).length; - const notes = dayNotes[String(day.id)] || []; - const dayAssignmentIds: number[] = da.map((a: any) => a.id); - const dayTransport = getTransportForDay({ - reservations: reservations || [], - dayId: day.id, - dayAssignmentIds, - days: sortedDays, - }); - const dayAccs = (accommodations || []).filter((a: any) => - isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays) - ); - - // The shared link has to say what the app says: a multi-day parking only - // shows up on its drop-off and pickup day (#1937). Filtered here rather - // than skipped in the loop below so the day body isn't gated open on a - // row that never renders. - const merged = getMergedItems({ - dayAssignments: da, - dayNotes: notes, - dayTransports: dayTransport, - dayId: day.id, - }).filter(item => !(item.type === 'transport' && hidesOnMiddleDay(item.data, day.id))); - - return ( -
- - - {selectedDay === day.id && merged.length > 0 && ( -
- {merged.map((item: any) => { - if (item.type === 'transport') { - const r = item.data; - const TIcon = TRANSPORT_ICONS[r.type] || Ticket; - const meta = - typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : r.metadata || {}; - const time = splitReservationDateTime(r.reservation_time).time ?? ''; - const endTime = splitReservationDateTime(r.reservation_end_time).time ?? ''; - let sub = ''; - if (r.type === 'flight') { - if (r.__leg) { - // One leg of a multi-leg flight — show this segment's own route/flight number. - sub = [ - r.__leg.airline, - r.__leg.flight_number, - r.__leg.from || r.__leg.to - ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') - : '', - ] - .filter(Boolean) - .join(' · '); - } else { - sub = [ - meta.airline, - meta.flight_number, - meta.departure_airport && meta.arrival_airport - ? `${meta.departure_airport} → ${meta.arrival_airport}` - : '', - ] - .filter(Boolean) - .join(' · '); - } - } else if (r.type === 'train') { - if (r.__leg) { - // One leg of a multi-leg train — show this segment's own train/route. - sub = [ - r.__leg.train_number, - r.__leg.platform ? `Gl. ${r.__leg.platform}` : '', - r.__leg.from || r.__leg.to - ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') - : '', - ] - .filter(Boolean) - .join(' · '); - } else { - sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''] - .filter(Boolean) - .join(' · '); - } - } - return ( -
-
- -
-
-
- {r.title} - {time ? ` · ${time}${endTime ? `–${endTime}` : ''}` : ''} -
- {sub && ( -
- {sub} -
- )} -
-
- ); - } - if (item.type === 'note') { - return ( -
- -
-
- {item.data.text} -
- {item.data.time && ( -
- {item.data.time} -
- )} -
-
- ); - } - const place = item.data.place; - if (!place) return null; - const cat = categories?.find((c: any) => c.id === place.category_id); - return ( -
-
- {place.image_url ? ( - - ) : ( - - )} -
-
-
- {place.name} -
- -
- {place.place_time && ( - - - {place.place_time} - {place.end_time ? ` – ${place.end_time}` : ''} - - )} -
- ); - })} -
- )} -
- ); - })} -
- - )} - - {/* Bookings */} - {activeTab === 'bookings' && (reservations || []).length > 0 && ( -
- {(reservations || []).map((r: any) => { - const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : r.metadata || {}; - const TIcon = TRANSPORT_ICONS[r.type] || Ticket; - const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time); - const time = rTime ?? ''; - const date = rDate - ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { - day: 'numeric', - month: 'short', - timeZone: 'UTC', - }) - : ''; - return ( -
-
- -
-
-
- {r.title} -
-
- {date && {date}} - {time && {time}} - {r.location && {r.location}} - {r.type === 'flight' - ? getFlightLegs(r).map((leg, i) => ( - - {[ - leg.airline, - leg.flight_number, - leg.from || leg.to ? [leg.from, leg.to].filter(Boolean).join(' → ') : '', - ] - .filter(Boolean) - .join(' ')} - - )) - : r.type === 'train' - ? getTrainLegs(r).map((leg, i) => ( - - {[ - leg.train_number, - leg.platform ? `${t('reservations.meta.platform')} ${leg.platform}` : '', - leg.from || leg.to ? [leg.from, leg.to].filter(Boolean).join(' → ') : '', - ] - .filter(Boolean) - .join(' ')} - - )) - : meta.airline && ( - - {meta.airline} {meta.flight_number || ''} - - )} -
- -
- - {r.status === 'confirmed' ? t('shared.confirmed') : t('shared.pending')} - -
- ); - })} -
- )} - - {/* Packing */} - {activeTab === 'packing' && (packing || []).length > 0 && ( -
- {Object.entries( - (packing || []).reduce((g: any, i: any) => { - const c = i.category || t('shared.other'); - (g[c] = g[c] || []).push(i); - return g; - }, {}) - ).map(([cat, items]: [string, any]) => ( -
-
- {cat} -
- {items.map((item: any) => ( -
- - {item.name} - -
- ))} -
- ))} -
- )} - - {/* Budget */} - {activeTab === 'budget' && - (budget || []).length > 0 && - (() => { - // Pre-rework rows store currency = NULL ("the trip's own currency"). Each - // expense is read the way CostsPanel reads it (#2525): at the rate frozen when - // it was entered, into the trip currency, then into the owner's display base. - // Reading it at today's rate alone made the shared page disagree with the - // trip's own Costs tab. - type Expense = { total_price?: number | string | null; currency?: string | null; exchange_rate?: number | null }; - const tripCurrency = String(trip.currency || base).toUpperCase(); - const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0; - const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert); - // Whole cents: a converted amount otherwise printed a third decimal. - const money = (v: number) => - v.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - // What was entered, beside a row shown converted, as the Costs list prints it. - const enteredOf = (i: Expense): string | null => { - const entered = convertedLine(amountOf(i), i.currency, i.exchange_rate, tripCurrency, base, valueOf(i))?.entered; - if (!entered) return null; - const d = currencyDecimals(entered.currency); - return `${entered.amount.toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d })} ${entered.currency}`; - }; - const grouped = (budget || []).reduce((g: any, i: any) => { - const c = i.category || t('shared.other'); - (g[c] = g[c] || []).push(i); - return g; - }, {}); - const sumIn = (items: any[]) => items.reduce((s: number, i: any) => s + valueOf(i), 0); - const total = sumIn(budget || []); - return ( -
- {/* Total card */} -
-
- {t('shared.totalBudget')} -
-
- {money(total)} {base} -
-
- {/* By category */} - {Object.entries(grouped).map(([cat, items]: [string, any]) => ( -
-
- - {cat} - - - {money(sumIn(items))} {base} - -
- {items.map((item: any) => ( -
- - {item.name} - {item.total_price && enteredOf(item) ? ( - · {enteredOf(item)} - ) : null} - - - {item.total_price - ? `${money(valueOf(item))} ${base}` - : '—'} - -
- ))} -
- ))} -
- ); - })()} - - {/* Collab Chat */} - {activeTab === 'collab' && (collab || []).length > 0 && ( -
-
- - - {t('shared.tabChat')} · {(collab || []).length} {t('shared.messages')} - -
-
- {(collab || []).map((msg: any, i: number) => { - const prevMsg = i > 0 ? collab[i - 1] : null; - const showDate = - !prevMsg || new Date(msg.created_at).toDateString() !== new Date(prevMsg.created_at).toDateString(); - return ( -
- {showDate && ( -
- {new Date(msg.created_at).toLocaleDateString(locale, { - weekday: 'short', - day: 'numeric', - month: 'short', - })} -
- )} -
-
- {msg.avatar ? ( - - ) : ( - (msg.username || '?')[0].toUpperCase() - )} -
-
-
- - {msg.username} - - - {new Date(msg.created_at).toLocaleTimeString(locale, { - hour: '2-digit', - minute: '2-digit', - })} - -
-
- {msg.text} -
-
-
-
- ); - })} -
-
- )} - - {/* Footer */} -
-
- TREK - - {t('shared.sharedVia')} TREK - -
-
- Made with ♥ by Maurice ·{' '} - - GitHub - -
-
-
+
); } diff --git a/client/src/pages/sharedTrip/SharedBookingDetails.tsx b/client/src/pages/sharedTrip/SharedBookingDetails.tsx index 5c80d0e1e..2359d1932 100644 --- a/client/src/pages/sharedTrip/SharedBookingDetails.tsx +++ b/client/src/pages/sharedTrip/SharedBookingDetails.tsx @@ -1,4 +1,7 @@ -import { ExternalLink, FileText } from 'lucide-react' +import { ExternalLink } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { fs } from '../../components/shared/DialogShell' +import { BOX, Block } from '../../components/Planner/bookings/bookingParts' import { isHttpUrl, linkHost } from './sharedTripModel' /** @@ -16,29 +19,25 @@ import { isHttpUrl, linkHost } from './sharedTripModel' * to have done it. */ export function SharedBookingDetails({ notes, url }: { notes?: string | null; url?: string | null }) { + const { t } = useTranslation() const note = notes?.trim() || null const link = isHttpUrl(url) ? url.trim() : null if (!note && !link) return null return ( -
+ <> {note && ( -
{/* theme-lint-disable — public page, no user theme */} - - {note} -
+ +
{note}
+
)} {link && ( - - - {linkHost(link)} - + + + + {linkHost(link)} + + )} -
+ ) } diff --git a/client/src/pages/sharedTrip/SharedBookingsView.tsx b/client/src/pages/sharedTrip/SharedBookingsView.tsx new file mode 100644 index 000000000..5912fa34c --- /dev/null +++ b/client/src/pages/sharedTrip/SharedBookingsView.tsx @@ -0,0 +1,98 @@ +import { Ticket, Train } from 'lucide-react' +import type { Day, Reservation } from '../../types' +import { fs } from '../../components/shared/DialogShell' +import { SoftPill } from '../../components/Planner/planParts' +import { BOX, Block, CountPill, Field, RouteBox, TypeChip, WhenFields, toneColor, toneOf, toneTint } from '../../components/Planner/bookings/bookingParts' +import { bookingFacts } from '../../components/Planner/bookings/bookingFacts' +import { displayTitle } from '../../components/Planner/bookings/bookingsModel' +import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import { TRANSPORT_TYPES } from '../../utils/dayMerge' +import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs' +import { EmptySection, SectionTitle } from './SharedChrome' +import { SharedBookingDetails } from './SharedBookingDetails' +import { legFacts } from './sharedTripModel' + +interface SharedBookingsViewProps { + reservations: Reservation[] + days: Day[] + tripCurrency?: string | null +} + +/** + * The Bookings tab: the journeys first, then the stays, tables and tickets, + * each as the booking card of the planner without anything to press. The + * server has already held back the confirmation codes, the travellers and + * the prices; the card only lays out what arrived. + */ +export function SharedBookingsView({ reservations, days, tripCurrency }: SharedBookingsViewProps) { + const { t } = useTranslation() + if (reservations.length === 0) return } text={t('shared.emptyBookings')} /> + const transports = reservations.filter(r => TRANSPORT_TYPES.has(r.type)) + const bookings = reservations.filter(r => !TRANSPORT_TYPES.has(r.type)) + return ( +
+ {transports.length > 0 && ( +
+ } title={t('trip.tabs.transports')}>{transports.length} + +
+ )} + {bookings.length > 0 && ( +
+ } title={t('trip.tabs.reservations')}>{bookings.length} + +
+ )} +
+ ) +} + +function CardGrid({ list, days, tripCurrency }: { list: Reservation[]; days: Day[]; tripCurrency?: string | null }) { + return ( +
+ {list.map(r => )} +
+ ) +} + +function SharedBookingCard({ r, days, tripCurrency }: { r: Reservation; days: Day[]; tripCurrency?: string | null }) { + const { t, locale } = useTranslation() + const timeFormat = useSettingsStore(s => s.settings.time_format) + const facts = bookingFacts(r, { t, locale, timeFormat, days, assignmentLookup: {}, tripCurrency, hasLinkedCost: true }) + const tone = toneOf(r) + const platform = t('reservations.meta.platform') + const legs = r.type === 'flight' ? getFlightLegs(r) : r.type === 'train' ? getTrainLegs(r) : [] + return ( +
+
+ + + {displayTitle(r)} + {tone !== 'transit' && ( + {tone === 'confirmed' ? t('shared.confirmed') : t('shared.pending')} + )} +
+
+ + + {legs.length > 1 ? ( + // A stopover booking lists each leg with its own carrier, number and route. + +
+ {legs.map((leg, i) => ( +
{legFacts(leg, platform).join(' ')}
+ ))} +
+
+ ) : facts.cells.length > 0 && ( +
+ {facts.cells.map((c, i) => {c.value})} +
+ )} + {facts.place && {facts.place}} + +
+
+ ) +} diff --git a/client/src/pages/sharedTrip/SharedChrome.tsx b/client/src/pages/sharedTrip/SharedChrome.tsx new file mode 100644 index 000000000..fcbd76ec8 --- /dev/null +++ b/client/src/pages/sharedTrip/SharedChrome.tsx @@ -0,0 +1,200 @@ +import type { ComponentType, ReactNode } from 'react' +import { CalendarDays, Eye, Heart } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { fs } from '../../components/shared/DialogShell' +import { SlidingTabs } from '../../components/shared/SlidingTabs' +import { Tooltip } from '../../components/shared/Tooltip' +import PublicLanguagePicker from '../../components/shared/PublicLanguagePicker' +import { coverSrc, formatDateRange } from './sharedTripModel' + +/** + * The frame of the public trip page: the top bar the planner has, a hero made + * of the trip's cover, the tab bar that stays in reach while the page scrolls, + * and the footer. The page pins itself to the light look (applyAppearance + * skips /shared), so the tokens here resolve to their light values. + */ + +export const PAGE_WIDTH = 'mx-auto w-full max-w-[1280px] px-4 sm:px-6' + +// Over a photo nothing but white type on a dark scrim reads, whatever the +// photo is; without a cover the same dark ground keeps the hero one piece. +const HERO_GROUND = 'linear-gradient(135deg, #0b1020 0%, #1e293b 55%, #334155 100%)' // theme-lint-disable: the hero's own ground under white type +const HERO_SCRIM = 'linear-gradient(180deg, rgba(0,0,0,0.08) 0%, rgba(0,0,0,0.28) 45%, rgba(0,0,0,0.74) 100%)' // theme-lint-disable: a scrim over a photo +const GLASS = 'inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/15 px-3 py-1 font-semibold text-white backdrop-blur-md' // theme-lint-disable: glass over a photo + +interface TopBarProps { + title: string + locale: string + langOpen: boolean + onLangOpenChange: React.Dispatch> +} + +/** The planner's top bar, for a reader: the brand and the trip, read-only, and the language. */ +export function SharedTopBar({ title, locale, langOpen, onLangOpenChange }: TopBarProps) { + const { t } = useTranslation() + return ( +
+
+ TREK + TREK + / + {title} +
+ + + + {t('shared.readOnly')} + + + +
+
+
+ ) +} + +export interface HeroStat { + key: string + value: number + label: string +} + +interface HeroProps { + trip: { title: string; description?: string | null; cover_image?: string | null; start_date?: string | null; end_date?: string | null } + stats: HeroStat[] +} + +/** The trip as a postcard: its cover, its dates, its name, what the owner wrote about it, and how big it is. */ +export function SharedHero({ trip, stats }: HeroProps) { + const { locale } = useTranslation() + const cover = coverSrc(trip.cover_image) + const dates = formatDateRange(trip.start_date, trip.end_date, locale) + return ( +
+ {cover && } +
+
+ {dates && ( + + + {dates} + + )} +

{trip.title}

+ {trip.description && ( +

{trip.description}

// theme-lint-disable: white type over a photo + )} + {stats.length > 0 && ( +
+ {stats.map(s => ( + + {s.value} + {s.label} + + ))} +
+ )} +
+
+ ) +} + +export interface SharedTab { + id: string + label: string + icon: ComponentType<{ size?: number; className?: string }> +} + +/** The tabs, in a raised pill that stays at the top while the page scrolls under it. */ +export function SharedTabBar({ tabs, active, onChange }: { tabs: SharedTab[]; active: string; onChange: (id: string) => void }) { + if (tabs.length < 2) return null + return ( +
+
+
+ ({ id: tab.id, label: {tab.label}, ariaLabel: tab.label, icon: tab.icon }))} + activeTab={active} + onChange={onChange} + /> +
+
+
+ ) +} + +/** A section's own head: its name, a count, and whatever else it says about itself. */ +export function SectionTitle({ icon, title, children }: { icon: ReactNode; title: string; children?: ReactNode }) { + return ( +
+ {icon} +

{title}

+ {children} +
+ ) +} + +/** What a section says when the owner shared it but there is nothing in it yet. */ +export function EmptySection({ icon, text }: { icon: ReactNode; text: string }) { + return ( +
+ {icon} +

{text}

+
+ ) +} + +export function SharedFooter() { + const { t } = useTranslation() + return ( +
+
+
+ TREK +
+
+ {t('shared.sharedVia')} TREK +
+
{t('shared.footerTagline')}
+
+
+
+ + Made with by Maurice + + + GitHub + +
+
+
+ ) +} + +/** The page's shape while the payload is on its way, so nothing jumps when it lands. */ +export function SharedLoading() { + return ( +
+
+
+
+
+
+
+ {[0, 1, 2].map(i =>
)} +
+
+
+
+
+ ) +} diff --git a/client/src/pages/sharedTrip/SharedDayCard.tsx b/client/src/pages/sharedTrip/SharedDayCard.tsx new file mode 100644 index 000000000..b3b1b059b --- /dev/null +++ b/client/src/pages/sharedTrip/SharedDayCard.tsx @@ -0,0 +1,210 @@ +import { ChevronDown, ChevronRight, Hotel } from 'lucide-react' +import { fs, NEUTRAL_TINT } from '../../components/shared/DialogShell' +import { getCategoryIcon } from '../../components/shared/categoryIcons' +import { Tooltip } from '../../components/shared/Tooltip' +import { SoftPill, TimePill, tintOf } from '../../components/Planner/planParts' +import { TypeTile } from '../../components/Planner/bookings/bookingParts' +import { typeInfo } from '../../components/Planner/bookings/bookingsModel' +import { getNoteIcon } from '../../components/Planner/DayPlanSidebar.constants' +import { noteSurface } from '../../components/Planner/noteSurface' +import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import { getDisplayTimeForDay, getSpanPhase, type MergedItem } from '../../utils/dayMerge' +import { formatTime, splitReservationDateTime } from '../../utils/formatters' +import { safeHexColor } from '../../utils/safeColor' +import { SharedPlaceDetails, type SharedPlaceLike } from './SharedPlaceDetails' +import { spanLabelKey, transportFacts, type SharedTransport } from './sharedTripModel' + +const DAY_HEAD_SELECTED = 'color-mix(in srgb, var(--accent) 12%, transparent)' + +export interface SharedDayCardProps { + day: { id: number; title?: string | null; date?: string | null; day_number: number } + index: number + selected: boolean + collapsed: boolean + onSelect: () => void + onToggleCollapse: () => void + /** The day's rows, merged and ordered the way the planner orders them. */ + items: MergedItem[] + stays: { id: number; place_name?: string | null; start_day_id?: number | null; end_day_id?: number | null }[] + placeCount: number + stopNumber: Record + categories: { id: number; color?: string | null; icon?: string | null }[] +} + +/** + * One day as the planner draws it: a head band with the day's number on a + * raised tile, its name, date and stays, and the plan below it. Clicking the + * head opens the day on the map; the chevron folds the plan away. + */ +export function SharedDayCard(p: SharedDayCardProps) { + const { t, locale } = useTranslation() + const { day } = p + const date = day.date + ? new Date(`${day.date}T00:00:00Z`).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) + : null + + return ( +
+
+ + +
+ + {!p.collapsed && ( +
+ {p.items.length === 0 ? ( +

{t('dayplan.emptyDay')}

+ ) : ( + p.items.map(item => { + if (item.type === 'transport') return + if (item.type === 'note') return + if (!item.data.place) return null + return ( + c.id === item.data.place.category_id) ?? item.data.place.category ?? null} + /> + ) + }) + )} +
+ )} +
+ ) +} + +/** A stay on this day, its check-in green and its check-out red, as the planner marks them. */ +function StayPill({ stay, dayId }: { stay: SharedDayCardProps['stays'][number]; dayId: number }) { + const { t } = useTranslation() + const isCheckIn = stay.start_day_id === dayId + const isCheckOut = stay.end_day_id === dayId + const state = isCheckOut && !isCheckIn ? t('day.checkOut') : isCheckIn ? t('day.checkIn') : null + const tone = isCheckOut && !isCheckIn ? 'text-danger' : isCheckIn ? 'text-success' : 'text-content-faint' + const name = stay.place_name || '' + return ( + + + + {name} + + + ) +} + +interface PlaceRowProps { + place: SharedPlaceLike & { id: number; image_url?: string | null } + notes?: string | null + number?: number + category: { color?: string | null; icon?: string | null } | null +} + +function PlaceRow({ place, notes, number, category }: PlaceRowProps) { + const { locale } = useTranslation() + const timeFormat = useSettingsStore(s => s.settings.time_format) + const color = safeHexColor(category?.color, '#6366f1') + const CatIcon = getCategoryIcon(category?.icon) + const time = place.place_time + ? `${formatTime(place.place_time, locale, timeFormat)}${place.end_time ? ` → ${formatTime(place.end_time, locale, timeFormat)}` : ''}` + : null + return ( +
+ + + {place.image_url + ? + : } + + {number != null && ( + + {number} + + )} + +
+
+ {place.name} + {time && {time}} +
+ +
+
+ ) +} + +function TransportRow({ r, dayId }: { r: SharedTransport; dayId: number }) { + const { t, locale } = useTranslation() + const timeFormat = useSettingsStore(s => s.settings.time_format) + const color = typeInfo(r.type).color + // A leg carries its own times; the booking's span decides which of its two a day shows. + const phase = r.__leg ? 'single' : getSpanPhase(r, dayId) + const start = splitReservationDateTime(r.__leg ? r.reservation_time : getDisplayTimeForDay(r, dayId)).time + const end = phase === 'single' ? splitReservationDateTime(r.reservation_end_time).time : null + const time = [start ? formatTime(start, locale, timeFormat) : '', end ? formatTime(end, locale, timeFormat) : ''].filter(Boolean).join(' → ') + const spanKey = spanLabelKey(r.type, phase) + const facts = transportFacts(r, t('reservations.meta.platform')) + return ( +
+ +
+
{r.title}
+ {(time || spanKey || facts.length > 0) && ( +
+ {spanKey && {t(spanKey)}} + {time && {time}} + {facts.map(f => {f})} +
+ )} +
+
+ ) +} + +function NoteRow({ note }: { note: { id: number; text: string; time?: string | null; icon?: string | null; color?: string | null } }) { + const skin = noteSurface(note.color) + const Icon = getNoteIcon(note.icon) + return ( +
+ + + +
+
{note.text}
+ {note.time &&
{note.time}
} +
+
+ ) +} diff --git a/client/src/pages/sharedTrip/SharedListsViews.tsx b/client/src/pages/sharedTrip/SharedListsViews.tsx new file mode 100644 index 000000000..4427befc2 --- /dev/null +++ b/client/src/pages/sharedTrip/SharedListsViews.tsx @@ -0,0 +1,204 @@ +import { CheckCircle2, Circle, MessageCircle, PackageCheck, Wallet } from 'lucide-react' +import { fs, NEUTRAL_TINT } from '../../components/shared/DialogShell' +import { CountPill } from '../../components/Planner/bookings/bookingParts' +import { TripMemberAvatar } from '../../components/Trips/TripMemberAvatar' +import { convertBooked, convertedLine } from '../../hooks/useExchangeRates' +import { useTranslation } from '../../i18n' +import { avatarSrc } from '../../utils/avatarSrc' +import { currencyDecimals } from '../../utils/formatters' +import { EmptySection, SectionTitle } from './SharedChrome' +import { groupInOrder } from './sharedTripModel' + +const CARD = 'overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm' +const CARD_HEAD = 'flex items-center gap-2 border-b border-edge-faint px-4 py-3' + +/** A thin bar for a share of a whole: packed of all, or a category of the total. */ +function Meter({ value, className = '' }: { value: number; className?: string }) { + return ( +
+
+
+ ) +} + +// ── Packing ──────────────────────────────────────────────────────────────── + +interface PackingItem { id: number; name: string; category?: string | null; checked?: number | boolean | null; quantity?: number | null } + +export function SharedPackingView({ items }: { items: PackingItem[] }) { + const { t } = useTranslation() + if (items.length === 0) return } text={t('shared.emptyPacking')} /> + const packed = items.filter(i => i.checked).length + const groups = groupInOrder(items, i => i.category || t('shared.other')) + return ( +
+
+ } title={t('shared.tabPacking')}> + + {t('packing.progress', { packed, total: items.length, percent: Math.round((packed / items.length) * 100) })} + + + +
+
+ {groups.map(([category, list]) => { + const done = list.filter(i => i.checked).length + return ( +
+
+

{category}

+ {done}/{list.length} +
+
    + {list.map(item => ( +
  • + {item.checked + ? + : } + {item.name} + {(item.quantity ?? 1) > 1 && ×{item.quantity}} +
  • + ))} +
+
+ ) + })} +
+
+ ) +} + +// ── Costs ────────────────────────────────────────────────────────────────── + +interface Expense { id: number; name: string; category?: string | null; total_price?: number | string | null; currency?: string | null; exchange_rate?: number | null } + +interface CostsProps { + items: Expense[] + /** The currency the owner reads Costs in. */ + base: string + tripCurrency: string + convert: (amount: number, from: string | null | undefined) => number +} + +/** + * The Costs tab. Pre-rework rows store currency = NULL ("the trip's own + * currency"). Each expense is read the way CostsPanel reads it (#2525): at the + * rate frozen when it was entered, into the trip currency, then into the + * owner's display base. Reading it at today's rate alone made the shared page + * disagree with the trip's own Costs tab. + */ +export function SharedCostsView({ items, base, tripCurrency, convert }: CostsProps) { + const { t, locale } = useTranslation() + if (items.length === 0) return } text={t('shared.emptyCosts')} /> + const amountOf = (i: Expense) => Number.parseFloat(String(i.total_price ?? '')) || 0 + const valueOf = (i: Expense) => convertBooked(amountOf(i), i.currency, i.exchange_rate, tripCurrency, convert) + // In the reader's own number format with the currency's code: a public page has + // no idea where its reader is, and "$" alone does not say which dollar. + // Whole cents: a converted amount otherwise printed a third decimal. + const money = (v: number) => `${v.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${base}` + // What was entered, beside a row shown converted, as the Costs list prints it. + const enteredOf = (i: Expense): string | null => { + const entered = convertedLine(amountOf(i), i.currency, i.exchange_rate, tripCurrency, base, valueOf(i))?.entered + if (!entered) return null + const d = currencyDecimals(entered.currency) + return `${entered.amount.toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d })} ${entered.currency}` + } + const sumOf = (list: Expense[]) => list.reduce((s, i) => s + valueOf(i), 0) + const total = sumOf(items) + const groups = groupInOrder(items, i => i.category || t('shared.other')) + return ( +
+
+
+ } title={t('shared.totalBudget')}>{items.length} +
{money(total)}
+
+
    + {groups.map(([category, list]) => { + const sum = sumOf(list) + return ( +
  • +
    + {category} + {money(sum)} +
    + 0 ? sum / total : 0} /> +
  • + ) + })} +
+
+
+ {groups.map(([category, list]) => ( +
+
+

{category}

+ {money(sumOf(list))} +
+
    + {list.map(item => { + const entered = item.total_price ? enteredOf(item) : null + return ( +
  • +
    +
    {item.name}
    + {entered &&
    {entered}
    } +
    + {!!item.total_price && {money(valueOf(item))}} +
  • + ) + })} +
+
+ ))} +
+
+ ) +} + +// ── Chat ─────────────────────────────────────────────────────────────────── + +interface ChatMessage { id: number; username?: string | null; avatar?: string | null; text: string; created_at: string } + +export function SharedChatView({ messages }: { messages: ChatMessage[] }) { + const { t, locale } = useTranslation() + if (messages.length === 0) return } text={t('shared.emptyChat')} /> + return ( +
+
+ +

{t('shared.tabChat')}

+ {messages.length} +
+
+ {messages.map((msg, i) => { + const prev = i > 0 ? messages[i - 1] : null + const showDate = !prev || new Date(msg.created_at).toDateString() !== new Date(prev.created_at).toDateString() + return ( +
+ {showDate && ( +
+ + {new Date(msg.created_at).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })} + +
+ )} +
+ +
+
+ {msg.username} + + {new Date(msg.created_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })} + +
+
{msg.text}
+
+
+
+ ) + })} +
+
+ ) +} diff --git a/client/src/pages/sharedTrip/SharedMap.tsx b/client/src/pages/sharedTrip/SharedMap.tsx new file mode 100644 index 000000000..945142bb1 --- /dev/null +++ b/client/src/pages/sharedTrip/SharedMap.tsx @@ -0,0 +1,203 @@ +import L from 'leaflet' +import { createElement, useEffect, useRef, type ReactNode } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet' +import MarkerClusterGroup from 'react-leaflet-cluster' +// MapView brings these sheets for the planner, and this page never mounts it. +import 'leaflet.markercluster/dist/MarkerCluster.css' +import 'leaflet.markercluster/dist/MarkerCluster.Default.css' +import { CLUSTER_OPTIONS, createClusterIcon } from '../../components/Map/markerCluster' +import VectorBasemap from '../../components/Map/VectorBasemap' +import { getCategoryIcon } from '../../components/shared/categoryIcons' +import CustomSelect from '../../components/shared/CustomSelect' +import { Tooltip as HintTooltip } from '../../components/shared/Tooltip' +import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../../constants/mapDefaults' +import { useTranslation } from '../../i18n' +import { renderIconMarkup } from '../../utils/iconMarkup' +import { computeMapViewport, TILE_SIZE_RASTER } from '../../utils/mapViewport' +import { safeHexColor } from '../../utils/safeColor' +import { resolveBasemap } from '../../utils/tileUrl' + +// Injected into Leaflet's marker HTML, where CSS variables cannot reach, the same +// reason MapView.tsx is exempt from theme:lint outright. +const ORDER_BADGE_STYLE = 'position:absolute;bottom:-4px;right:-4px;min-width:16px;height:16px;border-radius:8px;padding:0 3px;background:rgba(255,255,255,0.94);border:1.5px solid rgba(0,0,0,0.15);box-shadow:0 1px 4px rgba(0,0,0,0.18);display:flex;align-items:center;justify-content:center;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;white-space:nowrap;' // theme-lint-disable + +export interface MapPlace { + id: number + name: string + lat: number + lng: number + category?: { color?: string | null; icon?: string | null } | null + category_color?: string | null + category_icon?: string | null +} + +function createMarkerIcon(place: MapPlace, orderNumbers?: number[] | null) { + const cat = place.category + // This page answers without a guard, so an unescaped colour here reaches + // people who have no account on the instance at all. + // The payload carries the category in two shapes: nested on a day's assignments, + // flat on the trip-wide pool. Reading only the nested one dropped every marker + // outside a day selection to the placeholder colour. + const color = safeHexColor(cat?.color ?? place.category_color, '#6366f1') + const CatIcon = getCategoryIcon(cat?.icon ?? place.category_icon) + const iconSvg = renderIconMarkup(createElement(CatIcon, { size: 14, strokeWidth: 2, color: 'white' })) + // Position in the day's order, same contract as the planner: a stop the day + // visits twice shows both, e.g. "1, 3". The values are array indices, never payload. + const badge = orderNumbers?.length + ? `${orderNumbers.join(', ')}` + : '' + return L.divIcon({ + className: '', + iconSize: [30, 30], + iconAnchor: [15, 15], + html: `
${iconSvg}${badge}
`, + }) +} + +function FitBoundsToPlaces({ places, framedOnMount }: { places: MapPlace[]; framedOnMount: boolean }) { + const map = useMap() + const fitRan = useRef(false) + // The list is rebuilt on every render, so keying the effect on the coordinates + // rather than the array identity is what stops an unrelated re-render (the + // language picker, a late FX response) from throwing the viewer's pan and zoom away. + const fitKey = places.map(p => `${p.lat},${p.lng}`).join('|') + useEffect(() => { + if (places.length === 0) return + // The map already opened framed on these places; fitting again would only re-do it. + // Picking a day afterwards still refits to that day. + if (!fitRan.current && framedOnMount) { + fitRan.current = true + return + } + fitRan.current = true + const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng])) + map.fitBounds(bounds, { padding: [48, 48], maxZoom: 14 }) + }, [fitKey, map]) // eslint-disable-line react-hooks/exhaustive-deps + return null +} + +interface SharedMapProps { + places: MapPlace[] + /** The selected day's stops in order, for the dashed line between them. */ + line: MapPlace[] + orderByPlace: Record + cartoApiKey?: string | null + days: { id: number; day_number: number; title?: string | null; date?: string | null }[] + selectedDay: number | null + onSelectDay: (id: number | null) => void +} + +/** + * The trip on a map, with the days as chips floating over its top edge the + * way the planner floats its filters: the same setter as the day cards, so the + * map and the list can never disagree on which day is open. + */ +export function SharedMap({ places, line, orderByPlace, cartoApiKey, days, selectedDay, onSelectDay }: SharedMapProps) { + // Open framed on the trip's places instead of on Paris. MapContainer only reads + // center/zoom at mount, so recomputing this per render is free. + const framed = computeMapViewport(places, { tileSize: TILE_SIZE_RASTER, padding: { top: 64, right: 48, bottom: 48, left: 48 } }) + const initialView = framed ?? { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM } + // A visitor of a share link has no settings of their own, so the basemap is the + // app default: OpenFreeMap, a vector style that needs no key at all. The owner's + // CARTO key still travels in the payload and is still applied, because a raster + // template reaching this page keeps working, and without the key CARTO would + // stamp "API KEY REQUIRED" over it. + const basemap = resolveBasemap(null, OFM_POSITRON, cartoApiKey) + + return ( + // `isolate` keeps Leaflet's pane z-indexes inside the card, under the sticky tab bar. +
+ + {basemap.kind === 'vector' ? ( + + ) : ( + + )} + + {selectedDay && line.length > 1 && ( + [p.lat, p.lng])} + // Dashed and straight on purpose: it shows the order of the day's stops, + // not the roads between them. A real route would mean sending the + // itinerary to a third party for every anonymous visitor of a shared + // link, with no way for the trip's owner to opt out. + pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '6 8', lineCap: 'round' }} // theme-lint-disable + interactive={false} + /> + )} + {/* Clustered like the planner's map, so nearby stops stay tappable (#2343). */} + + {places.map(p => ( + + {p.name} + + ))} + + + + {days.length > 0 && } +
+ ) +} + +/** + * Which day the map shows, as a select over the map's top edge with a step + * back and forth beside it: a trip of three weeks does not fit a row of chips. + */ +function DayPicker({ days, selectedDay, onSelectDay }: Pick) { + const { t, locale } = useTranslation() + const order: (number | null)[] = [null, ...days.map(d => d.id)] + const at = Math.max(0, order.indexOf(selectedDay)) + const options = [ + { value: 'all', label: t('shared.wholeTrip') }, + ...days.map(d => ({ + value: d.id, + label: d.title || t('dayplan.dayN', { n: d.day_number }), + badge: d.date ? new Date(`${d.date}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : undefined, + })), + ] + return ( + // The select's own frame would stack a second box inside the pill, so its trigger + // goes flat here and the pill is the frame. +
+ onSelectDay(order[at - 1])}> + + + onSelectDay(v === 'all' ? null : Number(v))} + options={options} + size="sm" + style={{ width: 210, minWidth: 0 }} + /> + onSelectDay(order[at + 1])}> + + +
+ ) +} + +function StepButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) { + return ( + + + + ) +} diff --git a/client/src/pages/sharedTrip/SharedPlaceDetails.tsx b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx index 0526ed858..a71532eb4 100644 --- a/client/src/pages/sharedTrip/SharedPlaceDetails.tsx +++ b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx @@ -1,5 +1,8 @@ import { Clock, ExternalLink, FileText, Globe, MapPin, Phone } from 'lucide-react' import { useTranslation } from '../../i18n' +import { fs } from '../../components/shared/DialogShell' +import { Tooltip } from '../../components/shared/Tooltip' +import { WhiteBadge } from '../../components/Planner/planParts' import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps' import { formatDurationMinutes, isHttpUrl } from './sharedTripModel' @@ -18,6 +21,9 @@ import { formatDurationMinutes, isHttpUrl } from './sharedTripModel' * link leaves the page, in a new tab, with no opener. The server has already * decided what a public viewer may see; this only renders what arrived. */ +/** A way off the page: a white pill like the planner's facts, quiet until hovered. */ +export const LINK_PILL = 'inline-flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-card px-2 py-[2px] font-geist font-medium text-content-secondary shadow-sm transition-colors hover:text-content' + export interface SharedPlaceLike { name: string address?: string | null @@ -46,53 +52,36 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl const hasLinks = !!(website || phone || mapsUrl) return ( -
+
{place.address && ( -
{/* theme-lint-disable — public page, no user theme */} - - {place.address} +
+ + {place.address}
)} {place.description && ( -
{/* theme-lint-disable — public page, no user theme */} - {place.description} -
- )} - {dayNote && ( -
{/* theme-lint-disable — public page, no user theme */} - - {dayNote} -
- )} - {placeNote && ( -
{/* theme-lint-disable — public page, no user theme */} - - {placeNote} -
+
{place.description}
)} + {dayNote && } + {placeNote && } {(duration || hasLinks) && ( -
{/* theme-lint-disable — public page, no user theme */} - {duration && ( - - - {duration} - - )} +
+ {duration && }>{duration}} {mapsUrl && ( - {/* theme-lint-disable — public page, no user theme */} - + + {t('planner.openGoogleMaps')} )} {website && ( - {/* theme-lint-disable — public page, no user theme */} - + + {t('places.formWebsite')} )} {phone && ( - {/* theme-lint-disable — public page, no user theme */} - + + {phone} )} @@ -101,3 +90,15 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
) } + +/** A note on the stop, the day's own a shade stronger than the place's, named in its tooltip. */ +function NoteLine({ label, text, strong = false }: { label: string; text: string; strong?: boolean }) { + return ( + +
+ + {text} +
+
+ ) +} diff --git a/client/src/pages/sharedTrip/SharedPlanView.tsx b/client/src/pages/sharedTrip/SharedPlanView.tsx new file mode 100644 index 000000000..53502cda2 --- /dev/null +++ b/client/src/pages/sharedTrip/SharedPlanView.tsx @@ -0,0 +1,123 @@ +import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../../utils/dayMerge' +import { isDayInAccommodationRange } from '../../utils/dayOrder' +import { SharedDayCard } from './SharedDayCard' +import { SharedMap, type MapPlace } from './SharedMap' +import { stopNumbers } from './sharedTripModel' + +interface Assignment { + id: number + order_index?: number | null + accommodation_id?: number | null + notes?: string | null + place?: (MapPlace & { category_id?: number | null }) | null +} + +interface SharedPlanViewProps { + // The share payload is an open-ended snapshot, typed loosely at the hook. + data: { + days?: { id: number; day_number: number; title?: string | null; date?: string | null }[] + assignments?: Record + dayNotes?: Record + places?: MapPlace[] + reservations?: { id: number; type: string; title: string; day_id?: number | null; end_day_id?: number | null }[] + accommodations?: { id: number; place_name?: string | null; start_day_id?: number | null; end_day_id?: number | null }[] + categories?: { id: number; color?: string | null; icon?: string | null }[] + cartoApiKey?: string | null + } + selectedDay: number | null + onSelectDay: (id: number | null) => void + onPickDayOnMap: (id: number | null) => void + collapsedDays: ReadonlySet + onToggleDay: (id: number) => void +} + +/** + * The Plan tab: the days as the planner's cards on the left, the map beside + * them and staying in view while they scroll. On a phone the map comes first + * and the days follow under it. + */ +export function SharedPlanView({ data, selectedDay, onSelectDay, onPickDayOnMap, collapsedDays, onToggleDay }: SharedPlanViewProps) { + const days = [...(data.days || [])].sort((a, b) => a.day_number - b.day_number) + const assignments = data.assignments || {} + + // The stop a booked night wrote onto its check-in day heads that day since the + // reseat, and the planner's numbers leave it out (the day list does too). So the + // numbers and the day line are counted over the traveller's own stops, or the + // hotel wore badge 1, every real stop read one higher than in the app, and the + // line set off from where the day ends. Its pin stays on the day, unnumbered, + // the way the planner keeps the hotel on the map. + const stopsOf = (dayId: number) => (assignments[String(dayId)] || []).filter(a => a.accommodation_id == null) + const dayAssignments = selectedDay + ? [...(assignments[String(selectedDay)] || [])].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + : [] + const selectedNumbers = stopNumbers(selectedDay ? stopsOf(selectedDay) : []) + // The places these assignments sit on, in their order, each drawn once. + const located = (list: Assignment[]) => { + const seen = new Set() + return list.map(a => a.place).filter((p): p is MapPlace => { + if (!p?.lat || !p?.lng || seen.has(p.id)) return false + seen.add(p.id) + return true + }) + } + const mapPlaces = selectedDay ? located(dayAssignments) : (data.places || []).filter(p => p?.lat && p?.lng) + const dayLine = located(dayAssignments.filter(a => a.accommodation_id == null)) + + return ( +
+
+ +
+ +
+ {days.map((day, index) => { + // Without the booked nights: the day already shows each of them as its + // own pill, and the stop a booking writes for the drive would be that + // same hotel a second time. There is no road trip view on a shared link, + // so the stop has nothing else to do here. + const stops = stopsOf(day.id) + const transports = getTransportForDay({ + reservations: data.reservations || [], + dayId: day.id, + dayAssignmentIds: stops.map(a => a.id), + days, + }) + // The shared link has to say what the app says: a multi-day parking only + // shows up on its drop-off and pickup day (#1937). + const items = getMergedItems({ + dayAssignments: stops, + dayNotes: data.dayNotes?.[String(day.id)] || [], + dayTransports: transports, + dayId: day.id, + }).filter(item => !(item.type === 'transport' && hidesOnMiddleDay(item.data, day.id))) + return ( + onSelectDay(selectedDay === day.id ? null : day.id)} + onToggleCollapse={() => onToggleDay(day.id)} + items={items} + stays={(data.accommodations || []).filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))} + // A share can still carry an assignment for a deleted place. The list + // skips those rows, so the count must not count them either. + placeCount={stops.filter(a => a.place).length} + stopNumber={stopNumbers(stops).byAssignment} + categories={data.categories || []} + /> + ) + })} +
+
+ ) +} diff --git a/client/src/pages/sharedTrip/sharedTripModel.test.ts b/client/src/pages/sharedTrip/sharedTripModel.test.ts index f71078c67..38f04ac19 100644 --- a/client/src/pages/sharedTrip/sharedTripModel.test.ts +++ b/client/src/pages/sharedTrip/sharedTripModel.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest' import { AxiosError, AxiosHeaders } from 'axios' -import { formatDurationMinutes, isHttpUrl, isSharedTripPayload, linkHost, sharedTripLoadError } from './sharedTripModel' +import { + coverSrc, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, + sharedTripLoadError, spanLabelKey, stopNumbers, transportFacts, +} from './sharedTripModel' describe('sharedTripModel (#2320)', () => { it('accepts http and https and nothing else', () => { @@ -98,3 +101,61 @@ describe('isSharedTripPayload (#2505)', () => { expect(isSharedTripPayload({ trip: 'Lisbon' })).toBe(false) }) }) + +describe('sharedTripModel, the redesigned page', () => { + it('reads a cover in each of the three shapes the column holds', () => { + expect(coverSrc(null)).toBeNull() + expect(coverSrc('https://cdn.example/a.jpg')).toBe('https://cdn.example/a.jpg') + expect(coverSrc('/uploads/covers/b.jpg')).toBe('/uploads/covers/b.jpg') + expect(coverSrc('c.jpg')).toBe('/uploads/c.jpg') + }) + + it('prints a range with an arrow, a single date alone, and nothing without dates', () => { + expect(formatDateRange('2026-07-07', '2026-07-25', 'en-US')).toBe('Jul 7, 2026 → Jul 25, 2026') + expect(formatDateRange('2026-07-07', null, 'en-US')).toBe('Jul 7, 2026') + expect(formatDateRange(null, undefined, 'en-US')).toBeNull() + }) + + it('numbers stops by their order, counts a stop without a place, and gives a revisited place both numbers', () => { + const { byAssignment, byPlace } = stopNumbers([ + { id: 3, order_index: 2, place: { id: 10 } }, + { id: 1, order_index: 0, place: { id: 10 } }, + { id: 2, order_index: 1, place: null }, + { id: 4, order_index: 3, place: { id: 11 } }, + ]) + expect(byAssignment).toEqual({ 1: 1, 2: 2, 3: 3, 4: 4 }) + expect(byPlace).toEqual({ 10: [1, 3], 11: [4] }) + }) + + it('groups in the order the groups first appear', () => { + expect(groupInOrder(['b1', 'a1', 'b2'], v => v[0])).toEqual([['b', ['b1', 'b2']], ['a', ['a1']]]) + }) + + it('names each end of a span the way the planner does, and a single day not at all', () => { + expect(spanLabelKey('flight', 'single')).toBeNull() + expect(spanLabelKey('flight', 'start')).toBe('reservations.span.departure') + expect(spanLabelKey('flight', 'middle')).toBe('reservations.span.inTransit') + expect(spanLabelKey('car', 'end')).toBe('reservations.span.return') + expect(spanLabelKey('parking', 'start')).toBe('reservations.span.dropOff') + expect(spanLabelKey('parking', 'end')).toBe('reservations.span.pickup') + expect(spanLabelKey('hotel', 'middle')).toBe('reservations.span.ongoing') + }) + + it('states the facts of a transport row, those of the leg itself for a leg, and none for other types', () => { + expect(transportFacts({ type: 'flight', metadata: { airline: 'LH', flight_number: '190', departure_airport: 'FRA', arrival_airport: 'BER' } }, 'Platform')) + .toEqual(['LH', '190', 'FRA → BER']) + expect(transportFacts({ type: 'flight', metadata: JSON.stringify({ airline: 'KLM', departure_airport: 'AMS' }) }, 'Platform')).toEqual(['KLM']) + expect(transportFacts({ type: 'flight', metadata: {}, __leg: { index: 1, airline: 'EK', flight_number: '46', from: 'FRA', to: null } }, 'Platform')) + .toEqual(['EK', '46', 'FRA']) + expect(transportFacts({ type: 'train', metadata: '{"train_number":"ICE 5","platform":"7"}' }, 'Platform')).toEqual(['ICE 5', 'Platform 7']) + expect(transportFacts({ type: 'train', metadata: {}, __leg: { index: 0, train_number: 'EC 51', from: 'Basel', to: 'Milano' } }, 'Gleis')) + .toEqual(['EC 51', 'Basel → Milano']) + expect(transportFacts({ type: 'train', metadata: 'not json' }, 'Platform')).toEqual([]) + expect(transportFacts({ type: 'bus', metadata: { airline: 'Flix' } }, 'Platform')).toEqual([]) + }) + + it('lists a leg with its carrier, number, platform and route', () => { + expect(legFacts({ train_number: 'IC 8', platform: '12', from: 'Bern', to: 'Zurich' }, 'Platform')).toEqual(['IC 8', 'Platform 12', 'Bern → Zurich']) + expect(legFacts({ airline: 'Emirates', flight_number: 'EK350' }, 'Platform')).toEqual(['Emirates', 'EK350']) + }) +}) diff --git a/client/src/pages/sharedTrip/sharedTripModel.ts b/client/src/pages/sharedTrip/sharedTripModel.ts index b6ae81a26..06bdcb2a8 100644 --- a/client/src/pages/sharedTrip/sharedTripModel.ts +++ b/client/src/pages/sharedTrip/sharedTripModel.ts @@ -82,3 +82,132 @@ export function isSharedTripPayload(payload: unknown): boolean { function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } + +/** + * Where a trip's cover lives. The column holds whichever of three shapes the + * upload path wrote over the years: an absolute URL, a root path, or a bare + * file name under the uploads directory. + */ +export function coverSrc(cover: string | null | undefined): string | null { + if (!cover) return null + if (cover.startsWith('http') || cover.startsWith('/')) return cover + return `/uploads/${cover}` +} + +/** A date range the way the booking cards print one: "7 Jul 2026 → 25 Jul 2026". */ +export function formatDateRange(start: string | null | undefined, end: string | null | undefined, locale: string): string | null { + const fmt = (d: string) => new Date(`${d}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) + const dates = [start, end].filter((d): d is string => !!d) + if (dates.length === 0) return null + return dates.map(fmt).join(' → ') +} + +export interface StopLike { + id: number + order_index?: number | null + place?: { id: number } | null +} + +/** + * The numbers a day's stops wear, on the map and in the list alike. They run + * over the full sorted list, like the planner's, so a stop whose place has no + * coordinates still takes its number and "3" means the same stop everywhere. + * A place the day visits twice gets both numbers. + */ +export function stopNumbers(stops: StopLike[]): { byAssignment: Record; byPlace: Record } { + const byAssignment: Record = {} + const byPlace: Record = {} + ;[...stops] + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + .forEach((a, i) => { + byAssignment[a.id] = i + 1 + if (a.place?.id != null) (byPlace[a.place.id] ||= []).push(i + 1) + }) + return { byAssignment, byPlace } +} + +/** Items in groups by a key, the groups in the order their first item came. */ +export function groupInOrder(items: readonly T[], keyOf: (item: T) => string): [string, T[]][] { + const groups = new Map() + for (const item of items) { + const key = keyOf(item) + const list = groups.get(key) + if (list) list.push(item) + else groups.set(key, [item]) + } + return [...groups.entries()] +} + +export type SpanPhase = 'single' | 'start' | 'middle' | 'end' + +/** What a day in the middle of a booking's span calls it, as the planner says it. */ +export function spanLabelKey(type: string, phase: SpanPhase): string | null { + if (phase === 'single') return null + const pick = (start: string, end: string, middle: string) => `reservations.span.${phase === 'start' ? start : phase === 'end' ? end : middle}` + if (type === 'flight') return pick('departure', 'arrival', 'inTransit') + if (type === 'car') return pick('pickup', 'return', 'active') + if (type === 'parking') return pick('dropOff', 'pickup', 'ongoing') + return pick('start', 'end', 'ongoing') +} + +export interface LegLike { + airline?: string | null + flight_number?: string | null + train_number?: string | null + platform?: string | null + from?: string | null + to?: string | null +} + +const route = (from?: string | null, to?: string | null) => (from || to ? [from, to].filter(Boolean).join(' → ') : '') + +/** + * The facts a transport row states under its title: carrier, number, route + * and platform, each on its own so the row can set them apart. A leg of a + * multi-leg flight or train states its own, not the whole booking's. + */ +export interface SharedTransport { + id: number + type: string + title: string + day_id?: number | null + end_day_id?: number | null + reservation_time?: string | null + reservation_end_time?: string | null + metadata?: unknown + /** Set on the rows a multi-leg flight or train is split into, one per leg. */ + __leg?: (LegLike & { index: number }) | null +} + +export function transportFacts( + r: Pick, + platformLabel: string, +): string[] { + const meta = (typeof r.metadata === 'string' ? safeJson(r.metadata) : r.metadata || {}) as Record + const platform = (p?: string | null) => (p ? `${platformLabel} ${p}` : '') + let facts: (string | null | undefined)[] = [] + if (r.type === 'flight') { + facts = r.__leg + ? [r.__leg.airline, r.__leg.flight_number, route(r.__leg.from, r.__leg.to)] + : [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? route(meta.departure_airport, meta.arrival_airport) : ''] + } else if (r.type === 'train') { + facts = r.__leg + ? [r.__leg.train_number, platform(r.__leg.platform), route(r.__leg.from, r.__leg.to)] + : [meta.train_number, platform(meta.platform)] + } + return facts.filter((f): f is string => !!f) +} + +/** One leg of a flight or train, as the booking card lists it. */ +export function legFacts(leg: LegLike, platformLabel: string): string[] { + return [leg.airline, leg.flight_number, leg.train_number, leg.platform ? `${platformLabel} ${leg.platform}` : '', route(leg.from, leg.to)] + .filter((f): f is string => !!f) +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text || '{}') + } catch { + return {} + } +} diff --git a/client/src/pages/sharedTrip/useSharedTrip.ts b/client/src/pages/sharedTrip/useSharedTrip.ts index 975568d4f..bdc557747 100644 --- a/client/src/pages/sharedTrip/useSharedTrip.ts +++ b/client/src/pages/sharedTrip/useSharedTrip.ts @@ -24,6 +24,8 @@ export function useSharedTrip() { const [selectedDay, setSelectedDay] = useState(null) const [activeTab, setActiveTab] = useState('plan') const [showLangPicker, setShowLangPicker] = useState(false) + // Every day opens unfolded, as the planner's do; a day the reader folds stays folded. + const [collapsedDays, setCollapsedDays] = useState>(() => new Set()) useEffect(() => { if (!token) return @@ -80,6 +82,30 @@ export function useSharedTrip() { // Anchored on the trip currency's quote, as the Costs tab converts (#2525). const { convert } = useExchangeRates(base, data?.trip?.currency) + const toggleDay = (id: number) => { + setCollapsedDays(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + // A day picked on the map also brings its card into view where the two sit + // side by side. On a phone the list is below the map, and scrolling to it + // would take away the map the reader just asked to see. + const pickDayOnMap = (id: number | null) => { + setSelectedDay(id) + if (id == null || typeof window === 'undefined' || !window.matchMedia?.('(min-width: 1024px)').matches) return + setCollapsedDays(prev => { + if (!prev.has(id)) return prev + const next = new Set(prev) + next.delete(id) + return next + }) + document.getElementById(`shared-day-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + return { data, error, @@ -89,6 +115,9 @@ export function useSharedTrip() { convert, selectedDay, setSelectedDay, + pickDayOnMap, + collapsedDays, + toggleDay, activeTab, setActiveTab, showLangPicker, diff --git a/shared/src/i18n/ar/shared.ts b/shared/src/i18n/ar/shared.ts index f246b203d..3138dec49 100644 --- a/shared/src/i18n/ar/shared.ts +++ b/shared/src/i18n/ar/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'تمت المشاركة عبر', 'shared.confirmed': 'مؤكد', 'shared.pending': 'قيد الانتظار', + 'shared.footerTagline': 'مخطط الرحلات الذي تستضيفه بنفسك. مفتوح المصدر.', + 'shared.emptyBookings': 'لا توجد حجوزات مشتركة بعد', + 'shared.emptyPacking': 'قائمة الأمتعة لا تزال فارغة', + 'shared.emptyCosts': 'لا توجد نفقات بعد', + 'shared.emptyChat': 'لا توجد رسائل بعد', + 'shared.wholeTrip': 'الرحلة كاملة', }; export default shared; diff --git a/shared/src/i18n/az/shared.ts b/shared/src/i18n/az/shared.ts index ec6c70b23..f9649ef4b 100644 --- a/shared/src/i18n/az/shared.ts +++ b/shared/src/i18n/az/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Paylaşım vasitəsi:', 'shared.confirmed': 'Təsdiqlənib', 'shared.pending': 'Gözləyir', + 'shared.footerTagline': 'Özünüz host etdiyiniz səyahət planlayıcısı. Açıq mənbə.', + 'shared.emptyBookings': 'Hələ paylaşılan bron yoxdur', + 'shared.emptyPacking': 'Əşya siyahısı hələ boşdur', + 'shared.emptyCosts': 'Hələ xərc yoxdur', + 'shared.emptyChat': 'Hələ mesaj yoxdur', + 'shared.wholeTrip': 'Bütün səyahət', }; export default shared; \ No newline at end of file diff --git a/shared/src/i18n/br/shared.ts b/shared/src/i18n/br/shared.ts index 563c3e2a6..57dde8413 100644 --- a/shared/src/i18n/br/shared.ts +++ b/shared/src/i18n/br/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Compartilhado via', 'shared.confirmed': 'Confirmado', 'shared.pending': 'Pendente', + 'shared.footerTagline': 'O planejador de viagens que você mesmo hospeda. Código aberto.', + 'shared.emptyBookings': 'Nenhuma reserva compartilhada ainda', + 'shared.emptyPacking': 'A lista de bagagem ainda está vazia', + 'shared.emptyCosts': 'Nenhuma despesa ainda', + 'shared.emptyChat': 'Nenhuma mensagem ainda', + 'shared.wholeTrip': 'Viagem inteira', }; export default shared; diff --git a/shared/src/i18n/ca/shared.ts b/shared/src/i18n/ca/shared.ts index 44010f57b..525f13c0a 100644 --- a/shared/src/i18n/ca/shared.ts +++ b/shared/src/i18n/ca/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Compartit via', 'shared.confirmed': 'Confirmat', 'shared.pending': 'Pendent', + 'shared.footerTagline': 'El planificador de viatges que allotges tu mateix. Codi obert.', + 'shared.emptyBookings': "Encara no s'ha compartit cap reserva", + 'shared.emptyPacking': "La llista d'equipatge encara és buida", + 'shared.emptyCosts': 'Encara no hi ha despeses', + 'shared.emptyChat': 'Encara no hi ha missatges', + 'shared.wholeTrip': 'Tot el viatge', }; export default shared; diff --git a/shared/src/i18n/cs/shared.ts b/shared/src/i18n/cs/shared.ts index b4c425e7d..b600499d7 100644 --- a/shared/src/i18n/cs/shared.ts +++ b/shared/src/i18n/cs/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Sdíleno přes', 'shared.confirmed': 'Potvrzeno', 'shared.pending': 'Čeká na potvrzení', + 'shared.footerTagline': 'Plánovač cest, který si hostujete sami. Open source.', + 'shared.emptyBookings': 'Zatím nejsou sdíleny žádné rezervace', + 'shared.emptyPacking': 'Seznam věcí k zabalení je zatím prázdný', + 'shared.emptyCosts': 'Zatím žádné výdaje', + 'shared.emptyChat': 'Zatím žádné zprávy', + 'shared.wholeTrip': 'Celá cesta', }; export default shared; diff --git a/shared/src/i18n/de/shared.ts b/shared/src/i18n/de/shared.ts index 9cf01bfd7..3236c4ef2 100644 --- a/shared/src/i18n/de/shared.ts +++ b/shared/src/i18n/de/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Geteilt über', 'shared.confirmed': 'Bestätigt', 'shared.pending': 'Ausstehend', + 'shared.footerTagline': 'Der Reiseplaner, den du selbst hostest. Open Source.', + 'shared.emptyBookings': 'Noch keine Buchungen geteilt', + 'shared.emptyPacking': 'Die Packliste ist noch leer', + 'shared.emptyCosts': 'Noch keine Ausgaben', + 'shared.emptyChat': 'Noch keine Nachrichten', + 'shared.wholeTrip': 'Ganze Reise', }; export default shared; diff --git a/shared/src/i18n/en/shared.ts b/shared/src/i18n/en/shared.ts index 5f568858e..233776254 100644 --- a/shared/src/i18n/en/shared.ts +++ b/shared/src/i18n/en/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Shared via', 'shared.confirmed': 'Confirmed', 'shared.pending': 'Pending', + 'shared.footerTagline': 'The travel planner you host yourself. Open source.', + 'shared.emptyBookings': 'No bookings shared yet', + 'shared.emptyPacking': 'The packing list is still empty', + 'shared.emptyCosts': 'No expenses yet', + 'shared.emptyChat': 'No messages yet', + 'shared.wholeTrip': 'Whole trip', }; export default shared; diff --git a/shared/src/i18n/es/shared.ts b/shared/src/i18n/es/shared.ts index fba549cde..c4556f5c7 100644 --- a/shared/src/i18n/es/shared.ts +++ b/shared/src/i18n/es/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Compartido vía', 'shared.confirmed': 'Confirmado', 'shared.pending': 'Pendiente', + 'shared.footerTagline': 'El planificador de viajes que alojas tú mismo. Código abierto.', + 'shared.emptyBookings': 'Aún no se han compartido reservas', + 'shared.emptyPacking': 'La lista de equipaje aún está vacía', + 'shared.emptyCosts': 'Aún no hay gastos', + 'shared.emptyChat': 'Aún no hay mensajes', + 'shared.wholeTrip': 'Todo el viaje', }; export default shared; diff --git a/shared/src/i18n/et/shared.ts b/shared/src/i18n/et/shared.ts index 086e1892c..85cd7a602 100644 --- a/shared/src/i18n/et/shared.ts +++ b/shared/src/i18n/et/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Jagatud rakendusega', 'shared.confirmed': 'Kinnitatud', 'shared.pending': 'Ootel', + 'shared.footerTagline': 'Reisiplaneerija, mida majutad ise. Avatud lähtekoodiga.', + 'shared.emptyBookings': 'Broneeringuid pole veel jagatud', + 'shared.emptyPacking': 'Pakkimisnimekiri on veel tühi', + 'shared.emptyCosts': 'Kulusid pole veel', + 'shared.emptyChat': 'Sõnumeid pole veel', + 'shared.wholeTrip': 'Kogu reis', }; export default shared; diff --git a/shared/src/i18n/fr/shared.ts b/shared/src/i18n/fr/shared.ts index 5617203e6..1e5b53e6e 100644 --- a/shared/src/i18n/fr/shared.ts +++ b/shared/src/i18n/fr/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Partagé via', 'shared.confirmed': 'Confirmé', 'shared.pending': 'En attente', + 'shared.footerTagline': 'Le planificateur de voyage que vous hébergez vous-même. Open source.', + 'shared.emptyBookings': 'Aucune réservation partagée pour le moment', + 'shared.emptyPacking': 'La liste de bagages est encore vide', + 'shared.emptyCosts': 'Aucune dépense pour le moment', + 'shared.emptyChat': 'Aucun message pour le moment', + 'shared.wholeTrip': 'Tout le voyage', }; export default shared; diff --git a/shared/src/i18n/gr/shared.ts b/shared/src/i18n/gr/shared.ts index 5fd1b1a04..8c2be61a2 100644 --- a/shared/src/i18n/gr/shared.ts +++ b/shared/src/i18n/gr/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Κοινοποιήθηκε μέσω', 'shared.confirmed': 'Επιβεβαιωμένο', 'shared.pending': 'Σε εκκρεμότητα', + 'shared.footerTagline': 'Ο σχεδιαστής ταξιδιών που φιλοξενείτε μόνοι σας. Ανοιχτού κώδικα.', + 'shared.emptyBookings': 'Δεν έχουν κοινοποιηθεί κρατήσεις ακόμη', + 'shared.emptyPacking': 'Η λίστα αποσκευών είναι ακόμη άδεια', + 'shared.emptyCosts': 'Δεν υπάρχουν έξοδα ακόμη', + 'shared.emptyChat': 'Δεν υπάρχουν μηνύματα ακόμη', + 'shared.wholeTrip': 'Όλο το ταξίδι', }; export default shared; diff --git a/shared/src/i18n/hu/shared.ts b/shared/src/i18n/hu/shared.ts index a5ffaa131..246779509 100644 --- a/shared/src/i18n/hu/shared.ts +++ b/shared/src/i18n/hu/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Megosztva:', 'shared.confirmed': 'Megerősítve', 'shared.pending': 'Függőben', + 'shared.footerTagline': 'Az utazástervező, amelyet te magad üzemeltetsz. Nyílt forráskódú.', + 'shared.emptyBookings': 'Még nincs megosztott foglalás', + 'shared.emptyPacking': 'A csomaglista még üres', + 'shared.emptyCosts': 'Még nincsenek kiadások', + 'shared.emptyChat': 'Még nincsenek üzenetek', + 'shared.wholeTrip': 'Teljes utazás', }; export default shared; diff --git a/shared/src/i18n/id/shared.ts b/shared/src/i18n/id/shared.ts index 164c26392..b2511da6a 100644 --- a/shared/src/i18n/id/shared.ts +++ b/shared/src/i18n/id/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Dibagikan via', 'shared.confirmed': 'Dikonfirmasi', 'shared.pending': 'Menunggu', + 'shared.footerTagline': 'Perencana perjalanan yang Anda hosting sendiri. Sumber terbuka.', + 'shared.emptyBookings': 'Belum ada pemesanan yang dibagikan', + 'shared.emptyPacking': 'Daftar barang bawaan masih kosong', + 'shared.emptyCosts': 'Belum ada pengeluaran', + 'shared.emptyChat': 'Belum ada pesan', + 'shared.wholeTrip': 'Seluruh perjalanan', }; export default shared; diff --git a/shared/src/i18n/it/shared.ts b/shared/src/i18n/it/shared.ts index bf52af3a9..cf1218daf 100644 --- a/shared/src/i18n/it/shared.ts +++ b/shared/src/i18n/it/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Condiviso tramite', 'shared.confirmed': 'Confermato', 'shared.pending': 'In attesa', + 'shared.footerTagline': 'Il pianificatore di viaggi che ospiti tu stesso. Open source.', + 'shared.emptyBookings': 'Nessuna prenotazione condivisa finora', + 'shared.emptyPacking': 'La lista bagagli è ancora vuota', + 'shared.emptyCosts': 'Nessuna spesa finora', + 'shared.emptyChat': 'Nessun messaggio finora', + 'shared.wholeTrip': 'Tutto il viaggio', }; export default shared; diff --git a/shared/src/i18n/ja/shared.ts b/shared/src/i18n/ja/shared.ts index 152f2a58c..9bcae022a 100644 --- a/shared/src/i18n/ja/shared.ts +++ b/shared/src/i18n/ja/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': '共有元', 'shared.confirmed': '確定', 'shared.pending': '保留', + 'shared.footerTagline': '自分でホストする旅行プランナー。オープンソース。', + 'shared.emptyBookings': '共有された予約はまだありません', + 'shared.emptyPacking': '持ち物リストはまだ空です', + 'shared.emptyCosts': 'まだ支出はありません', + 'shared.emptyChat': 'まだメッセージはありません', + 'shared.wholeTrip': '旅行全体', }; export default shared; diff --git a/shared/src/i18n/ko/shared.ts b/shared/src/i18n/ko/shared.ts index db2f61f94..92d82e976 100644 --- a/shared/src/i18n/ko/shared.ts +++ b/shared/src/i18n/ko/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': '공유 경로', 'shared.confirmed': '확정됨', 'shared.pending': '대기 중', + 'shared.footerTagline': '직접 호스팅하는 여행 플래너. 오픈 소스.', + 'shared.emptyBookings': '아직 공유된 예약이 없습니다', + 'shared.emptyPacking': '짐 목록이 아직 비어 있습니다', + 'shared.emptyCosts': '아직 지출이 없습니다', + 'shared.emptyChat': '아직 메시지가 없습니다', + 'shared.wholeTrip': '전체 여행', }; export default shared; diff --git a/shared/src/i18n/nl/shared.ts b/shared/src/i18n/nl/shared.ts index 25898db7c..b442ba2a5 100644 --- a/shared/src/i18n/nl/shared.ts +++ b/shared/src/i18n/nl/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Gedeeld via', 'shared.confirmed': 'Bevestigd', 'shared.pending': 'In afwachting', + 'shared.footerTagline': 'De reisplanner die je zelf host. Open source.', + 'shared.emptyBookings': 'Nog geen boekingen gedeeld', + 'shared.emptyPacking': 'De paklijst is nog leeg', + 'shared.emptyCosts': 'Nog geen uitgaven', + 'shared.emptyChat': 'Nog geen berichten', + 'shared.wholeTrip': 'Hele reis', }; export default shared; diff --git a/shared/src/i18n/pl/shared.ts b/shared/src/i18n/pl/shared.ts index 999a349ff..f99e676dd 100644 --- a/shared/src/i18n/pl/shared.ts +++ b/shared/src/i18n/pl/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Udostępnione przez', 'shared.confirmed': 'Potwierdzone', 'shared.pending': 'Oczekujące', + 'shared.footerTagline': 'Planer podróży, który hostujesz sam. Open source.', + 'shared.emptyBookings': 'Nie udostępniono jeszcze żadnych rezerwacji', + 'shared.emptyPacking': 'Lista pakowania jest jeszcze pusta', + 'shared.emptyCosts': 'Brak wydatków', + 'shared.emptyChat': 'Brak wiadomości', + 'shared.wholeTrip': 'Cała podróż', }; export default shared; diff --git a/shared/src/i18n/ru/shared.ts b/shared/src/i18n/ru/shared.ts index 9e4709de4..69db2c8bd 100644 --- a/shared/src/i18n/ru/shared.ts +++ b/shared/src/i18n/ru/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Поделено через', 'shared.confirmed': 'Подтверждено', 'shared.pending': 'Ожидает', + 'shared.footerTagline': 'Планировщик путешествий, который вы размещаете сами. Открытый исходный код.', + 'shared.emptyBookings': 'Бронирований пока нет', + 'shared.emptyPacking': 'Список вещей пока пуст', + 'shared.emptyCosts': 'Расходов пока нет', + 'shared.emptyChat': 'Сообщений пока нет', + 'shared.wholeTrip': 'Всё путешествие', }; export default shared; diff --git a/shared/src/i18n/sk/shared.ts b/shared/src/i18n/sk/shared.ts index 565aae1ec..999ac67fa 100644 --- a/shared/src/i18n/sk/shared.ts +++ b/shared/src/i18n/sk/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Zdieľané cez', 'shared.confirmed': 'Potvrdené', 'shared.pending': 'Čaká na potvrdenie', + 'shared.footerTagline': 'Plánovač ciest, ktorý si hostujete sami. Open source.', + 'shared.emptyBookings': 'Zatiaľ nie sú zdieľané žiadne rezervácie', + 'shared.emptyPacking': 'Zoznam vecí na zbalenie je zatiaľ prázdny', + 'shared.emptyCosts': 'Zatiaľ žiadne výdavky', + 'shared.emptyChat': 'Zatiaľ žiadne správy', + 'shared.wholeTrip': 'Celá cesta', }; export default shared; diff --git a/shared/src/i18n/sv/shared.ts b/shared/src/i18n/sv/shared.ts index 8f98981a2..0bdfcae3c 100644 --- a/shared/src/i18n/sv/shared.ts +++ b/shared/src/i18n/sv/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Delad via', 'shared.confirmed': 'Godkänt', 'shared.pending': 'Väntande', + 'shared.footerTagline': 'Reseplaneraren du driver själv. Öppen källkod.', + 'shared.emptyBookings': 'Inga bokningar delade ännu', + 'shared.emptyPacking': 'Packlistan är fortfarande tom', + 'shared.emptyCosts': 'Inga utgifter ännu', + 'shared.emptyChat': 'Inga meddelanden ännu', + 'shared.wholeTrip': 'Hela resan', }; export default shared; diff --git a/shared/src/i18n/th/shared.ts b/shared/src/i18n/th/shared.ts index b0c222ed7..972ef1d59 100644 --- a/shared/src/i18n/th/shared.ts +++ b/shared/src/i18n/th/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': "แชร์ผ่าน", 'shared.confirmed': "ยืนยันแล้ว", 'shared.pending': "รอดำเนินการ", + 'shared.footerTagline': 'เครื่องมือวางแผนการเดินทางที่คุณโฮสต์เอง โอเพนซอร์ส', + 'shared.emptyBookings': 'ยังไม่มีการจองที่แชร์', + 'shared.emptyPacking': 'รายการของที่ต้องแพ็กยังว่างอยู่', + 'shared.emptyCosts': 'ยังไม่มีค่าใช้จ่าย', + 'shared.emptyChat': 'ยังไม่มีข้อความ', + 'shared.wholeTrip': 'ทั้งทริป', }; export default shared; diff --git a/shared/src/i18n/tr/shared.ts b/shared/src/i18n/tr/shared.ts index 7b4b9e717..57532d35b 100644 --- a/shared/src/i18n/tr/shared.ts +++ b/shared/src/i18n/tr/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Şununla paylaşıldı:', 'shared.confirmed': 'Onaylandı', 'shared.pending': 'Beklemede', + 'shared.footerTagline': 'Kendi sunucunda barındırdığın seyahat planlayıcısı. Açık kaynak.', + 'shared.emptyBookings': 'Henüz paylaşılan rezervasyon yok', + 'shared.emptyPacking': 'Bavul listesi henüz boş', + 'shared.emptyCosts': 'Henüz harcama yok', + 'shared.emptyChat': 'Henüz mesaj yok', + 'shared.wholeTrip': 'Tüm seyahat', }; export default shared; diff --git a/shared/src/i18n/uk/shared.ts b/shared/src/i18n/uk/shared.ts index a679a6807..c7fde4d31 100644 --- a/shared/src/i18n/uk/shared.ts +++ b/shared/src/i18n/uk/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Поділено через', 'shared.confirmed': 'Підтверджено', 'shared.pending': 'Очікує', + 'shared.footerTagline': 'Планувальник подорожей, який ви розміщуєте самі. Відкритий код.', + 'shared.emptyBookings': 'Бронювань поки немає', + 'shared.emptyPacking': 'Список речей поки порожній', + 'shared.emptyCosts': 'Витрат поки немає', + 'shared.emptyChat': 'Повідомлень поки немає', + 'shared.wholeTrip': 'Уся подорож', }; export default shared; diff --git a/shared/src/i18n/vi/shared.ts b/shared/src/i18n/vi/shared.ts index 95ecf8b5a..b78935f33 100644 --- a/shared/src/i18n/vi/shared.ts +++ b/shared/src/i18n/vi/shared.ts @@ -21,5 +21,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': 'Được chia sẻ qua', 'shared.confirmed': 'Đã xác nhận', 'shared.pending': 'Chưa giải quyết', + 'shared.footerTagline': 'Trình lập kế hoạch du lịch do bạn tự lưu trữ. Mã nguồn mở.', + 'shared.emptyBookings': 'Chưa có đặt chỗ nào được chia sẻ', + 'shared.emptyPacking': 'Danh sách hành lý vẫn còn trống', + 'shared.emptyCosts': 'Chưa có khoản chi nào', + 'shared.emptyChat': 'Chưa có tin nhắn nào', + 'shared.wholeTrip': 'Cả chuyến đi', }; export default shared; diff --git a/shared/src/i18n/zh-TW/shared.ts b/shared/src/i18n/zh-TW/shared.ts index 8ece885d0..946edf5b9 100644 --- a/shared/src/i18n/zh-TW/shared.ts +++ b/shared/src/i18n/zh-TW/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': '透過以下分享', 'shared.confirmed': '已確認', 'shared.pending': '待確認', + 'shared.footerTagline': '由你自行架設的旅行規劃工具。開源。', + 'shared.emptyBookings': '尚未分享任何預訂', + 'shared.emptyPacking': '行李清單還是空的', + 'shared.emptyCosts': '尚無支出', + 'shared.emptyChat': '尚無訊息', + 'shared.wholeTrip': '整趟旅程', }; export default shared; diff --git a/shared/src/i18n/zh/shared.ts b/shared/src/i18n/zh/shared.ts index 663aed7f2..483d6f435 100644 --- a/shared/src/i18n/zh/shared.ts +++ b/shared/src/i18n/zh/shared.ts @@ -20,5 +20,11 @@ const shared: TranslationStrings = { 'shared.sharedVia': '通过以下分享', 'shared.confirmed': '已确认', 'shared.pending': '待确认', + 'shared.footerTagline': '由你自己托管的旅行规划工具。开源。', + 'shared.emptyBookings': '尚未共享任何预订', + 'shared.emptyPacking': '行李清单还是空的', + 'shared.emptyCosts': '暂无支出', + 'shared.emptyChat': '暂无消息', + 'shared.wholeTrip': '整个行程', }; export default shared; diff --git a/wiki/Public-Share-Links.md b/wiki/Public-Share-Links.md index c319b68c7..876560024 100644 --- a/wiki/Public-Share-Links.md +++ b/wiki/Public-Share-Links.md @@ -48,11 +48,17 @@ A public viewer has no account, so there is no "their" display currency to use. ## What the public viewer shows -The shared trip page renders a branded read-only interface with a dark hero header showing the trip title, description, and date range. A tab bar at the top provides access to the sections you enabled. The viewer can switch the display language using a language picker in the top-right corner. +The shared trip page speaks the planner's design. A light top bar carries the TREK logo, the trip's name, a **Read-only shared view** pill and the language picker, so a viewer can read the trip in their own language. Below it the trip opens as a postcard: its cover image, the date range, the title, the description and how big the trip is (days, places and, when Bookings is shared, bookings). The tab bar under the hero stays at the top of the window while the page scrolls, and offers only the sections you enabled. With nothing but the plan shared there is no tab bar at all. -The Plan tab appears whenever **Map & Plan** is on — which is every link created through the share UI, since that toggle is locked on there. A link whose `share_map` flag was turned off outside the UI (through the REST API or the `create_share_link` MCP tool, both of which take it as a plain boolean) has no Plan tab at all: the server withholds the days, places, assignments and notes entirely, and the viewer opens on the first section the owner did share. A **day picker** sits directly above the map — an **All** pill plus one pill per day (**Day 1**, **Day 2**, …) — and drives the same selection as the day cards below it, so the map and the expanded day never disagree. Pick a day and its stops are numbered on the map in visiting order (a place the day returns to shows both positions on one marker, e.g. `1 · 3`) and joined by a dashed connector. That connector is a straight line showing sequence, not a driving route: TREK will not send a shared itinerary to a third-party routing service on an anonymous visitor's behalf. On **All** the map shows every geocoded place as an unnumbered pin with no connector. On either view, pins that sit close together are grouped into a cluster bubble with their count, the same way the planner's map does it, so the stops of a busy day stay tappable on a phone; click a cluster to zoom in on its members, and at maximum zoom it fans them out. Below the map sits a collapsible day-by-day itinerary (with places, notes, and transport inline when Bookings is enabled) and accommodation badges per day. +The Plan tab appears whenever **Map & Plan** is on, which is every link created through the share UI, since that toggle is locked on there. A link whose `share_map` flag was turned off outside the UI (through the REST API or the `create_share_link` MCP tool, both of which take it as a plain boolean) has no Plan tab at all: the server withholds the days, places, assignments and notes entirely, and the viewer opens on the first section the owner did share. -The Chat tab (when enabled via `share_collab`) shows chat messages grouped by date with sender avatars. Viewers cannot send messages. +On a wide screen the Plan tab puts the days on the left and the map on the right, and the map stays in view while the days scroll past it. On a phone the map comes first and the days follow under it. Every day is a card like the planner's: its number on a tile, its name and date, its stays with check-in and check-out marked, and how many places it has. The plan of the day is open underneath, places with their photo or category colour, address, description, notes, planned time and links to Google Maps, the website and the phone number, and transports and notes as tinted rows. The chevron folds a day away. + +Which day the map shows is picked in the select at the map's top edge, with a step back and forth beside it, or by clicking a day's head; clicking it again goes back to the whole trip. A picked day numbers its stops on the map in visiting order, the same numbers the places wear in the list (a place the day returns to shows both, e.g. `1, 3`), and joins them with a dashed connector. That connector is a straight line showing sequence, not a driving route: TREK will not send a shared itinerary to a third-party routing service on an anonymous visitor's behalf. The whole trip shows every geocoded place as an unnumbered pin with no connector. On either view, pins that sit close together are grouped into a cluster bubble with their count, the same way the planner's map does it, so the stops of a busy day stay tappable on a phone; click a cluster to zoom in on its members, and at maximum zoom it fans them out. + +The Bookings tab lists the transports first and the other bookings after them, as the booking cards of the planner: status, type, title, day and time, route, carrier and numbers, location, note and link. Confirmation codes, travellers and prices never leave the server. + +The Packing tab shows how much is packed and one card per category. The Costs tab opens with the total and each category's share of it, then one card per category with its expenses. The Chat tab (when enabled via `share_collab`) shows the messages as bubbles, grouped by date, with the sender's avatar. Viewers cannot send messages. ## Revoking a share link