mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(share): the public trip page in the planner's design
The page a share link opens is rebuilt from top bar to footer: the planner's light top bar with the language picker, the trip as a hero made of its cover, a tab bar that stays in reach, the days as the planner's cards beside a map that stays in view, bookings as the booking cards, packing and costs as cards with their progress and shares, the chat as bubbles, and empty states for sections the owner shared with nothing in them yet. The map picks its day in a select with a step back and forth instead of a row of chips a three-week trip did not fit into.
This commit is contained in:
@@ -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) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 px-3 pb-3 pt-2.5">
|
||||
{(facts.day || facts.time) && (
|
||||
// A day range needs the whole width; the time then goes below it.
|
||||
<div className={`flex gap-2 ${facts.day?.range ? 'flex-col' : ''}`}>
|
||||
{facts.day && (
|
||||
<Field label={t('reservations.date')} className="flex-[1.4]">
|
||||
{facts.day.label}
|
||||
{facts.day.date && <span className="ml-1.5 font-medium text-content-faint">{facts.day.date}</span>}
|
||||
</Field>
|
||||
)}
|
||||
{facts.time && <Field label={t('reservations.time')} className="flex-1" tabular>{facts.time}</Field>}
|
||||
</div>
|
||||
)}
|
||||
<WhenFields facts={facts} />
|
||||
|
||||
{transit ? <TransitBody r={r} /> : (
|
||||
<>
|
||||
@@ -92,18 +79,7 @@ export default function BookingCard(p: BookingCardProps) {
|
||||
<BlurredCode className="font-geist">{r.confirmation_number}</BlurredCode>
|
||||
</Field>
|
||||
)}
|
||||
{facts.endpoints.length >= 2 && (
|
||||
<Block label={t('reservations.routeLabel')}>
|
||||
<div className={`${BOX} flex flex-wrap items-center justify-center gap-2 px-[10px] py-2 font-semibold text-content`} style={fs(12.5, 'body')}>
|
||||
{facts.endpoints.map((ep, i) => (
|
||||
<span key={ep.id ?? i} className="inline-flex min-w-0 items-center gap-2">
|
||||
{i > 0 && <info.Icon size={13} strokeWidth={2.2} className="flex-none" style={{ color: info.color }} />}
|
||||
<span className="truncate">{ep.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
)}
|
||||
<RouteBox type={r.type} facts={facts} />
|
||||
{facts.legCodes.length > 0 && (
|
||||
<Block label={t('reservations.segmentCodes')}>
|
||||
<div className={`${BOX} flex flex-col gap-1 px-[10px] py-2`}>
|
||||
@@ -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 (
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[3px]">{label}</Eyebrow>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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()
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[3px]">{label}</Eyebrow>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className={`flex gap-2 ${facts.day?.range ? 'flex-col' : ''}`}>
|
||||
{facts.day && (
|
||||
<Field label={t('reservations.date')} className="flex-[1.4]">
|
||||
{facts.day.label}
|
||||
{facts.day.date && <span className="ml-1.5 font-medium text-content-faint">{facts.day.date}</span>}
|
||||
</Field>
|
||||
)}
|
||||
{facts.time && <Field label={t('reservations.time')} className="flex-1" tabular>{facts.time}</Field>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Block label={t('reservations.routeLabel')}>
|
||||
<div className={`${BOX} flex flex-wrap items-center justify-center gap-2 px-[10px] py-2 font-semibold text-content`} style={fs(12.5, 'body')}>
|
||||
{facts.endpoints.map((ep, i) => (
|
||||
<span key={ep.id ?? i} className="inline-flex min-w-0 items-center gap-2">
|
||||
{i > 0 && <info.Icon size={13} strokeWidth={2.2} className="flex-none" style={{ color: info.color }} />}
|
||||
<span className="truncate">{ep.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
)
|
||||
}
|
||||
|
||||
export function CountPill({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="whitespace-nowrap rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bold text-content-muted" style={fs(10)}>
|
||||
|
||||
@@ -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<React.SetStateAction<boolean>>
|
||||
/**
|
||||
* `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<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={wrapRef} style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange(v => !v)}
|
||||
className="bg-[rgba(255,255,255,0.1)] text-[rgba(255,255,255,0.7)]"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.find(l => l.value === current)?.label || 'Language'}
|
||||
</button>
|
||||
<div ref={wrapRef} style={variant === 'bar' ? { position: 'relative' } : { position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
{variant === 'bar' ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-edge-faint bg-surface-card px-3 py-1.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<Globe size={13} strokeWidth={2} className="flex-none text-content-faint" aria-hidden />
|
||||
{label}
|
||||
<ChevronDown size={13} strokeWidth={2} className="flex-none text-content-faint" aria-hidden />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange(v => !v)}
|
||||
className="bg-[rgba(255,255,255,0.1)] text-[rgba(255,255,255,0.7)]"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../tests/helpers/render';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { AxiosError } from 'axios';
|
||||
@@ -99,7 +99,7 @@ describe('SharedTripPage', () => {
|
||||
|
||||
// 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<string, unknown>) {
|
||||
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<HTMLElement>('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);
|
||||
});
|
||||
|
||||
|
||||
+63
-1212
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
|
||||
<>
|
||||
{note && (
|
||||
<div className="text-[#374151]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span>{note}</span>
|
||||
</div>
|
||||
<Block label={t('reservations.notes')}>
|
||||
<div className={`${BOX} whitespace-pre-wrap px-[10px] py-2 text-content-secondary [overflow-wrap:anywhere]`} style={fs(12, 'body')}>{note}</div>
|
||||
</Block>
|
||||
)}
|
||||
{link && (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#2563eb]" // theme-lint-disable — public page, no user theme
|
||||
style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none', width: 'fit-content' }}
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
{linkHost(link)}
|
||||
</a>
|
||||
<Block label={t('reservations.urlLabel')}>
|
||||
<a href={link} target="_blank" rel="noopener noreferrer" className={`${BOX} flex items-center gap-1.5 px-[10px] py-[7px] font-semibold text-content hover:bg-surface-hover`} style={fs(12.5, 'body')}>
|
||||
<ExternalLink size={12} strokeWidth={2} className="flex-none text-content-muted" />
|
||||
<span className="truncate">{linkHost(link)}</span>
|
||||
</a>
|
||||
</Block>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <EmptySection icon={<Ticket size={22} />} 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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
{transports.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle icon={<Train size={16} strokeWidth={2} />} title={t('trip.tabs.transports')}><CountPill>{transports.length}</CountPill></SectionTitle>
|
||||
<CardGrid list={transports} days={days} tripCurrency={tripCurrency} />
|
||||
</section>
|
||||
)}
|
||||
{bookings.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle icon={<Ticket size={16} strokeWidth={2} />} title={t('trip.tabs.reservations')}><CountPill>{bookings.length}</CountPill></SectionTitle>
|
||||
<CardGrid list={bookings} days={days} tripCurrency={tripCurrency} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardGrid({ list, days, tripCurrency }: { list: Reservation[]; days: Day[]; tripCurrency?: string | null }) {
|
||||
return (
|
||||
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map(r => <SharedBookingCard key={r.id} r={r} days={days} tripCurrency={tripCurrency} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<article aria-label={displayTitle(r)} className="flex flex-col overflow-hidden rounded-2xl border border-edge-faint bg-surface-card shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-edge-faint px-3 py-2.5" style={{ background: toneTint(tone) }}>
|
||||
<span className="block h-2 w-2 flex-none rounded-full" style={{ background: toneColor(tone) }} />
|
||||
<TypeChip type={r.type} />
|
||||
<span className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13.5, 'body')}>{displayTitle(r)}</span>
|
||||
{tone !== 'transit' && (
|
||||
<SoftPill tone={tone === 'confirmed' ? 'success' : 'warning'}>{tone === 'confirmed' ? t('shared.confirmed') : t('shared.pending')}</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-3 pb-3 pt-2.5">
|
||||
<WhenFields facts={facts} />
|
||||
<RouteBox type={r.type} facts={facts} />
|
||||
{legs.length > 1 ? (
|
||||
// A stopover booking lists each leg with its own carrier, number and route.
|
||||
<Block label={t('transit.itinerary')}>
|
||||
<div className={`${BOX} flex flex-col gap-1 px-[10px] py-2`}>
|
||||
{legs.map((leg, i) => (
|
||||
<div key={i} className="font-medium text-content-secondary" style={fs(12, 'body')}>{legFacts(leg, platform).join(' ')}</div>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
) : facts.cells.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{facts.cells.map((c, i) => <Field key={i} label={c.label}>{c.value}</Field>)}
|
||||
</div>
|
||||
)}
|
||||
{facts.place && <Field label={t('reservations.locationAddress')}>{facts.place}</Field>}
|
||||
<SharedBookingDetails notes={r.notes} url={r.url} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -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<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<header className="border-b border-edge-faint bg-surface-elevated">
|
||||
<div className={`${PAGE_WIDTH} flex h-14 items-center gap-3`}>
|
||||
<img src="/icons/icon-dark.svg" alt="TREK" className="sm:hidden" style={{ height: 22, width: 22 }} />
|
||||
<img src="/logo-dark.svg" alt="TREK" className="hidden sm:block" style={{ height: 26 }} />
|
||||
<span className="hidden text-content-faint sm:inline">/</span>
|
||||
<span className="hidden min-w-0 truncate font-medium text-content-muted sm:inline" style={fs(14, 'body')}>{title}</span>
|
||||
<div className="ml-auto flex flex-none items-center gap-2">
|
||||
<Tooltip label={t('shared.readOnly')}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-tertiary px-3 py-1.5 font-semibold text-content-muted" style={fs(12, 'body')}>
|
||||
<Eye size={13} strokeWidth={2} className="flex-none" aria-hidden />
|
||||
<span className="hidden sm:inline">{t('shared.readOnly')}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<PublicLanguagePicker variant="bar" locale={locale} open={langOpen} onOpenChange={onLangOpenChange} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<section data-testid="shared-hero" className="relative isolate overflow-hidden rounded-[28px] shadow-sm" style={{ background: HERO_GROUND }}>
|
||||
{cover && <img src={cover} alt="" className="absolute inset-0 -z-10 h-full w-full object-cover" />}
|
||||
<div aria-hidden className="absolute inset-0 -z-10" style={{ background: HERO_SCRIM }} />
|
||||
<div className="flex min-h-[170px] flex-col items-start justify-end gap-2.5 p-5 text-white sm:min-h-[200px] sm:px-8 sm:py-6">
|
||||
{dates && (
|
||||
<span className={GLASS} style={fs(12, 'body')}>
|
||||
<CalendarDays size={13} strokeWidth={2} aria-hidden />
|
||||
{dates}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="max-w-4xl text-balance font-bold leading-[1.1] tracking-tight" style={{ fontSize: 'calc(32px * var(--fs-scale-title, 1))' }}>{trip.title}</h1>
|
||||
{trip.description && (
|
||||
<p className="max-w-2xl whitespace-pre-line text-white/80" style={fs(14.5, 'body')}>{trip.description}</p> // theme-lint-disable: white type over a photo
|
||||
)}
|
||||
{stats.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{stats.map(s => (
|
||||
<span key={s.key} className={GLASS} style={fs(12, 'body')}>
|
||||
<span className="font-geist font-bold tabular-nums">{s.value}</span>
|
||||
<span className="font-medium text-white/80">{s.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="sticky top-0 z-30 py-3"
|
||||
style={{ background: 'color-mix(in srgb, var(--bg-secondary) 86%, transparent)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)' }}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<div className="max-w-full rounded-full border border-edge-faint bg-surface-card p-1 shadow-sm">
|
||||
<SlidingTabs
|
||||
tabs={tabs.map(tab => ({ id: tab.id, label: <span className="hidden sm:inline">{tab.label}</span>, ariaLabel: tab.label, icon: tab.icon }))}
|
||||
activeTab={active}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 px-0.5">
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-card text-content-secondary shadow-sm">{icon}</span>
|
||||
<h2 className="font-bold text-content" style={fs(16, 'subtitle')}>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-edge bg-surface-card px-6 py-14 text-center">
|
||||
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-surface-tertiary text-content-faint">{icon}</span>
|
||||
<p className="font-medium text-content-muted" style={fs(13.5, 'body')}>{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SharedFooter() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<footer className="mt-14 border-t border-edge-faint bg-surface-card">
|
||||
<div className={`${PAGE_WIDTH} flex flex-col items-center gap-4 py-8 text-center sm:flex-row sm:justify-between sm:text-left`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/icons/icon.svg" alt="TREK" width="34" height="34" className="rounded-[10px] shadow-sm" />
|
||||
<div>
|
||||
<div className="text-content-muted" style={fs(13, 'body')}>
|
||||
{t('shared.sharedVia')} <strong className="text-content">TREK</strong>
|
||||
</div>
|
||||
<div className="text-content-faint" style={fs(12, 'body')}>{t('shared.footerTagline')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-content-faint" style={fs(12, 'body')}>
|
||||
Made with <Heart size={12} strokeWidth={2.4} className="fill-current text-danger" aria-label="love" /> by Maurice
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-edge-faint bg-surface-card px-3 py-1.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
/** The page's shape while the payload is on its way, so nothing jumps when it lands. */
|
||||
export function SharedLoading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary" aria-busy="true">
|
||||
<div className="h-14 border-b border-edge-faint bg-surface-elevated" />
|
||||
<div className={`${PAGE_WIDTH} animate-pulse pt-5`}>
|
||||
<div className="h-[170px] rounded-[28px] bg-surface-tertiary sm:h-[200px]" />
|
||||
<div className="mx-auto my-4 h-10 w-72 rounded-full bg-surface-tertiary" />
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,440px)_minmax(0,1fr)]">
|
||||
<div className="flex flex-col gap-3">
|
||||
{[0, 1, 2].map(i => <div key={i} className="h-28 rounded-2xl bg-surface-tertiary" />)}
|
||||
</div>
|
||||
<div className="hidden h-[520px] rounded-2xl bg-surface-tertiary lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<number, number>
|
||||
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 (
|
||||
<article
|
||||
id={`shared-day-${day.id}`}
|
||||
className={`scroll-mt-20 overflow-hidden rounded-2xl border border-edge-faint bg-surface-card transition-shadow ${p.selected ? 'shadow-md' : ''}`}
|
||||
>
|
||||
<div className="flex items-start gap-1 py-2.5 pl-2.5 pr-2" style={{ background: p.selected ? DAY_HEAD_SELECTED : NEUTRAL_TINT }}>
|
||||
<button type="button" onClick={p.onSelect} aria-pressed={p.selected} className="flex min-w-0 flex-1 items-start gap-2.5 text-left">
|
||||
<span
|
||||
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] font-geist font-bold tabular-nums shadow-sm ${p.selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-muted'}`}
|
||||
style={fs(12)}
|
||||
>
|
||||
{p.index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 pt-px">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 truncate font-bold text-content" style={fs(13.5, 'body')}>
|
||||
{day.title || t('dayplan.dayN', { n: day.day_number })}
|
||||
</span>
|
||||
{date && <span className="flex-none whitespace-nowrap text-content-faint" style={fs(11)}>{date}</span>}
|
||||
</span>
|
||||
<span className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1">
|
||||
{p.stays.map(stay => <StayPill key={stay.id} stay={stay} dayId={day.id} />)}
|
||||
<SoftPill>{p.placeCount} {t('shared.places')}</SoftPill>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={p.onToggleCollapse}
|
||||
aria-label={p.collapsed ? t('common.expand') : t('common.collapse')}
|
||||
aria-expanded={!p.collapsed}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full text-content-muted hover:bg-surface-card hover:text-content"
|
||||
>
|
||||
{p.collapsed ? <ChevronRight size={15} strokeWidth={2} /> : <ChevronDown size={15} strokeWidth={2} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!p.collapsed && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-edge-faint p-1.5">
|
||||
{p.items.length === 0 ? (
|
||||
<p className="px-2 py-2.5 text-content-faint" style={fs(12, 'body')}>{t('dayplan.emptyDay')}</p>
|
||||
) : (
|
||||
p.items.map(item => {
|
||||
if (item.type === 'transport') return <TransportRow key={item.data.__leg ? `t-${item.data.id}-leg${item.data.__leg.index}` : `t-${item.data.id}`} r={item.data} dayId={day.id} />
|
||||
if (item.type === 'note') return <NoteRow key={`n-${item.data.id}`} note={item.data} />
|
||||
if (!item.data.place) return null
|
||||
return (
|
||||
<PlaceRow
|
||||
key={`p-${item.data.id}`}
|
||||
place={item.data.place}
|
||||
notes={item.data.notes}
|
||||
number={p.stopNumber[item.data.id]}
|
||||
category={p.categories.find(c => c.id === item.data.place.category_id) ?? item.data.place.category ?? null}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Tooltip label={state ? `${state}: ${name}` : name}>
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full bg-surface-card px-2 py-[2px] font-geist text-content-secondary shadow-sm" style={fs(10.5)}>
|
||||
<Hotel size={11} strokeWidth={2} className={`flex-none ${tone}`} />
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-start gap-2.5 rounded-xl px-2 py-2">
|
||||
<span className="relative flex-none">
|
||||
<span className="grid h-10 w-10 place-items-center overflow-hidden rounded-full shadow-sm" style={{ background: color }}>
|
||||
{place.image_url
|
||||
? <img src={place.image_url} alt="" className="h-full w-full object-cover" />
|
||||
: <CatIcon size={16} strokeWidth={2} color="white" />}
|
||||
</span>
|
||||
{number != null && (
|
||||
<span className="absolute -bottom-1 -right-1 grid h-[18px] min-w-[18px] place-items-center rounded-full border border-edge-faint bg-surface-card px-1 font-geist font-bold tabular-nums text-content shadow-sm" style={fs(9.5)}>
|
||||
{number}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className="min-w-0 flex-1 font-semibold text-content" style={{ ...fs(13.5, 'body'), lineHeight: 1.3 }}>{place.name}</span>
|
||||
{time && <TimePill className="mt-px">{time}</TimePill>}
|
||||
</div>
|
||||
<SharedPlaceDetails place={place} assignmentNotes={notes} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-2.5 rounded-xl px-2 py-1.5" style={{ border: `1px solid ${tintOf(color, 24)}`, background: tintOf(color, 7) }}>
|
||||
<TypeTile type={r.type} size={34} raised />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={{ ...fs(13, 'body'), lineHeight: 1.3 }}>{r.title}</div>
|
||||
{(time || spanKey || facts.length > 0) && (
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{spanKey && <SoftPill caps>{t(spanKey)}</SoftPill>}
|
||||
{time && <TimePill>{time}</TimePill>}
|
||||
{facts.map(f => <span key={f} className="whitespace-nowrap font-medium text-content-muted" style={fs(11)}>{f}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-start gap-2.5 rounded-xl px-2 py-2" style={{ background: skin.background, border: `1px solid ${skin.border}` }}>
|
||||
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px]" style={{ background: skin.iconBackground, color: skin.iconColor }}>
|
||||
<Icon size={14} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-1">
|
||||
<div className="whitespace-pre-wrap text-content-secondary [overflow-wrap:anywhere]" style={fs(12.5, 'body')}>{note.text}</div>
|
||||
{note.time && <div className="mt-1"><TimePill>{note.time}</TimePill></div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`h-1.5 overflow-hidden rounded-full bg-surface-tertiary ${className}`}>
|
||||
<div className="h-full rounded-full bg-accent transition-[width]" style={{ width: `${Math.round(Math.min(1, Math.max(0, value)) * 100)}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 <EmptySection icon={<PackageCheck size={22} />} text={t('shared.emptyPacking')} />
|
||||
const packed = items.filter(i => i.checked).length
|
||||
const groups = groupInOrder(items, i => i.category || t('shared.other'))
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={`${CARD} px-5 py-4`}>
|
||||
<SectionTitle icon={<PackageCheck size={16} strokeWidth={2} />} title={t('shared.tabPacking')}>
|
||||
<span className="ml-auto font-medium text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t('packing.progress', { packed, total: items.length, percent: Math.round((packed / items.length) * 100) })}
|
||||
</span>
|
||||
</SectionTitle>
|
||||
<Meter value={packed / items.length} />
|
||||
</div>
|
||||
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(([category, list]) => {
|
||||
const done = list.filter(i => i.checked).length
|
||||
return (
|
||||
<section key={category} className={CARD}>
|
||||
<div className={CARD_HEAD} style={{ background: NEUTRAL_TINT }}>
|
||||
<h3 className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13.5, 'body')}>{category}</h3>
|
||||
<CountPill>{done}/{list.length}</CountPill>
|
||||
</div>
|
||||
<ul className="flex flex-col py-1.5">
|
||||
{list.map(item => (
|
||||
<li key={item.id} className="flex items-center gap-2.5 px-4 py-1.5">
|
||||
{item.checked
|
||||
? <CheckCircle2 size={17} strokeWidth={2} className="flex-none text-success" aria-hidden />
|
||||
: <Circle size={17} strokeWidth={2} className="flex-none text-content-faint" aria-hidden />}
|
||||
<span className={`min-w-0 flex-1 ${item.checked ? 'text-content-faint line-through' : 'text-content'}`} style={fs(13, 'body')}>{item.name}</span>
|
||||
{(item.quantity ?? 1) > 1 && <span className="font-geist font-semibold tabular-nums text-content-faint" style={fs(11)}>×{item.quantity}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 <EmptySection icon={<Wallet size={22} />} 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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={`${CARD} grid gap-5 p-5 md:grid-cols-[minmax(0,280px)_minmax(0,1fr)]`}>
|
||||
<div>
|
||||
<SectionTitle icon={<Wallet size={16} strokeWidth={2} />} title={t('shared.totalBudget')}><CountPill>{items.length}</CountPill></SectionTitle>
|
||||
<div className="font-geist font-bold tabular-nums tracking-tight text-content" style={{ fontSize: 'calc(32px * var(--fs-scale-title, 1))' }}>{money(total)}</div>
|
||||
</div>
|
||||
<ul className="flex flex-col justify-center gap-2.5">
|
||||
{groups.map(([category, list]) => {
|
||||
const sum = sumOf(list)
|
||||
return (
|
||||
<li key={category}>
|
||||
<div className="mb-1 flex items-baseline gap-2" style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content-secondary">{category}</span>
|
||||
<span className="font-geist font-semibold tabular-nums text-content">{money(sum)}</span>
|
||||
</div>
|
||||
<Meter value={total > 0 ? sum / total : 0} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="grid items-start gap-3 md:grid-cols-2">
|
||||
{groups.map(([category, list]) => (
|
||||
<section key={category} className={CARD}>
|
||||
<div className={CARD_HEAD} style={{ background: NEUTRAL_TINT }}>
|
||||
<h3 className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13.5, 'body')}>{category}</h3>
|
||||
<span className="font-geist font-bold tabular-nums text-content-secondary" style={fs(12.5, 'body')}>{money(sumOf(list))}</span>
|
||||
</div>
|
||||
<ul className="flex flex-col divide-y divide-[color:var(--border-faint)]">
|
||||
{list.map(item => {
|
||||
const entered = item.total_price ? enteredOf(item) : null
|
||||
return (
|
||||
<li key={item.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-content" style={fs(13, 'body')}>{item.name}</div>
|
||||
{entered && <div className="font-geist tabular-nums text-content-faint" style={fs(11)}>{entered}</div>}
|
||||
</div>
|
||||
{!!item.total_price && <span className="font-geist font-semibold tabular-nums text-content" style={fs(13, 'body')}>{money(valueOf(item))}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 <EmptySection icon={<MessageCircle size={22} />} text={t('shared.emptyChat')} />
|
||||
return (
|
||||
<section className={CARD}>
|
||||
<div className={CARD_HEAD} style={{ background: NEUTRAL_TINT }}>
|
||||
<MessageCircle size={15} strokeWidth={2} className="text-content-muted" aria-hidden />
|
||||
<h3 className="font-bold text-content" style={fs(13.5, 'body')}>{t('shared.tabChat')}</h3>
|
||||
<CountPill>{messages.length}</CountPill>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
{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 (
|
||||
<div key={msg.id}>
|
||||
{showDate && (
|
||||
<div className="my-2 flex justify-center">
|
||||
<span className="rounded-full bg-surface-tertiary px-3 py-1 font-semibold text-content-muted" style={fs(11)}>
|
||||
{new Date(msg.created_at).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2.5">
|
||||
<TripMemberAvatar username={msg.username || '?'} avatarUrl={msg.avatar ? avatarSrc(msg.avatar) : null} size={30} />
|
||||
<div className="min-w-0 max-w-[85%] rounded-2xl rounded-bl-md bg-surface-secondary px-3.5 py-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-semibold text-content" style={fs(12, 'body')}>{msg.username}</span>
|
||||
<span className="text-content-faint" style={fs(10.5)}>
|
||||
{new Date(msg.created_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap text-content-secondary [overflow-wrap:anywhere]" style={{ ...fs(13, 'body'), lineHeight: 1.5 }}>{msg.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
? `<span style="${ORDER_BADGE_STYLE}font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;">${orderNumbers.join(', ')}</span>`
|
||||
: ''
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
html: `<div style="position:relative;width:30px;height:30px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2.5px solid white;">${iconSvg}${badge}</div>`,
|
||||
})
|
||||
}
|
||||
|
||||
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<number, number[]>
|
||||
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.
|
||||
<div className="relative isolate h-full w-full overflow-hidden rounded-2xl border border-edge-faint bg-surface-tertiary shadow-sm">
|
||||
<MapContainer
|
||||
center={initialView.center}
|
||||
zoom={initialView.zoom}
|
||||
zoomControl={false}
|
||||
// Same reason as the planner map: a vector basemap contributes
|
||||
// no zoom ceiling, and fitBounds below asks for one.
|
||||
maxZoom={MAP_MAX_ZOOM}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{basemap.kind === 'vector' ? (
|
||||
<VectorBasemap style={basemap.style} />
|
||||
) : (
|
||||
<TileLayer url={basemap.url} attribution={attributionForTile(basemap.url)} referrerPolicy="strict-origin-when-cross-origin" />
|
||||
)}
|
||||
<FitBoundsToPlaces places={places} framedOnMount={framed !== null} />
|
||||
{selectedDay && line.length > 1 && (
|
||||
<Polyline
|
||||
positions={line.map(p => [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). */}
|
||||
<MarkerClusterGroup {...CLUSTER_OPTIONS} iconCreateFunction={createClusterIcon}>
|
||||
{places.map(p => (
|
||||
<Marker key={p.id} position={[p.lat, p.lng]} icon={createMarkerIcon(p, orderByPlace[p.id] ?? null)}>
|
||||
<Tooltip>{p.name}</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
|
||||
{days.length > 0 && <DayPicker days={days} selectedDay={selectedDay} onSelectDay={onSelectDay} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SharedMapProps, 'days' | 'selectedDay' | 'onSelectDay'>) {
|
||||
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.
|
||||
<div data-testid="map-day-picker" className="absolute left-3 top-3 z-[1000] flex max-w-[calc(100%-24px)] items-center gap-0.5 rounded-full bg-surface-card p-0.5 shadow-md [&>div>button]:!border-transparent [&>div>button]:!bg-transparent [&>div>button]:!py-[3px]">
|
||||
<StepButton label={t('reservations.timeline.prevDay')} disabled={at === 0} onClick={() => onSelectDay(order[at - 1])}>
|
||||
<ChevronLeft size={14} strokeWidth={2.2} />
|
||||
</StepButton>
|
||||
<CustomSelect
|
||||
value={selectedDay ?? 'all'}
|
||||
onChange={v => onSelectDay(v === 'all' ? null : Number(v))}
|
||||
options={options}
|
||||
size="sm"
|
||||
style={{ width: 210, minWidth: 0 }}
|
||||
/>
|
||||
<StepButton label={t('reservations.timeline.nextDay')} disabled={at === order.length - 1} onClick={() => onSelectDay(order[at + 1])}>
|
||||
<ChevronRight size={14} strokeWidth={2.2} />
|
||||
</StepButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepButton({ label, disabled, onClick, children }: { label: string; disabled: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<HintTooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full text-content-muted transition-colors enabled:hover:bg-surface-hover enabled:hover:text-content disabled:opacity-35"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</HintTooltip>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="shared-place-details" style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
|
||||
<div className="shared-place-details mt-1 flex min-w-0 flex-col gap-1">
|
||||
{place.address && (
|
||||
<div className="text-[#6b7280]" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'flex', alignItems: 'flex-start', gap: 4 }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<MapPin size={10} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>{place.address}</span>
|
||||
<div className="flex items-start gap-1 text-content-faint" style={fs(11)}>
|
||||
<MapPin size={11} strokeWidth={2} className="mt-px flex-none" />
|
||||
<span className="min-w-0 [overflow-wrap:anywhere]">{place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="text-[#4b5563]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
{place.description}
|
||||
</div>
|
||||
)}
|
||||
{dayNote && (
|
||||
<div className="text-[#374151]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }} title={t('places.assignmentNotes')}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span>{dayNote}</span>
|
||||
</div>
|
||||
)}
|
||||
{placeNote && (
|
||||
<div className="text-[#6b7280]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }} title={t('places.formNotes')}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span>{placeNote}</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-content-secondary [overflow-wrap:anywhere]" style={fs(12, 'body')}>{place.description}</div>
|
||||
)}
|
||||
{dayNote && <NoteLine label={t('places.assignmentNotes')} text={dayNote} strong />}
|
||||
{placeNote && <NoteLine label={t('places.formNotes')} text={placeNote} />}
|
||||
{(duration || hasLinks) && (
|
||||
<div className="text-[#6b7280]" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '2px 10px', marginTop: 1 }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
{duration && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
<Clock size={10} />
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
{duration && <WhiteBadge icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>{duration}</WhiteBadge>}
|
||||
{mapsUrl && (
|
||||
<a href={mapsUrl} target="_blank" rel="noopener noreferrer" className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<ExternalLink size={10} />
|
||||
<a href={mapsUrl} target="_blank" rel="noopener noreferrer" className={LINK_PILL} style={fs(10.5)}>
|
||||
<ExternalLink size={10} strokeWidth={2.2} className="flex-none" />
|
||||
{t('planner.openGoogleMaps')}
|
||||
</a>
|
||||
)}
|
||||
{website && (
|
||||
<a href={website} target="_blank" rel="noopener noreferrer" className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<Globe size={10} />
|
||||
<a href={website} target="_blank" rel="noopener noreferrer" className={LINK_PILL} style={fs(10.5)}>
|
||||
<Globe size={10} strokeWidth={2.2} className="flex-none" />
|
||||
{t('places.formWebsite')}
|
||||
</a>
|
||||
)}
|
||||
{phone && (
|
||||
<a href={`tel:${phone.replace(/[^+\d]/g, '')}`} className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
|
||||
<Phone size={10} />
|
||||
<a href={`tel:${phone.replace(/[^+\d]/g, '')}`} className={LINK_PILL} style={fs(10.5)}>
|
||||
<Phone size={10} strokeWidth={2.2} className="flex-none" />
|
||||
{phone}
|
||||
</a>
|
||||
)}
|
||||
@@ -101,3 +90,15 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Tooltip label={label} placement="top">
|
||||
<div className={`flex items-start gap-1.5 rounded-lg bg-surface-secondary px-2 py-1.5 ${strong ? 'text-content-secondary' : 'text-content-muted'}`} style={fs(11.5, 'body')}>
|
||||
<FileText size={11} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" aria-label={label} />
|
||||
<span className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]">{text}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, Assignment[]>
|
||||
dayNotes?: Record<string, { id: number; text: string; time?: string | null; sort_order?: number | null }[]>
|
||||
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<number>
|
||||
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<number>()
|
||||
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 (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,460px)_minmax(0,1fr)]">
|
||||
<div className="h-[340px] lg:sticky lg:top-[76px] lg:order-2 lg:h-[calc(100vh-96px)]">
|
||||
<SharedMap
|
||||
places={mapPlaces}
|
||||
line={dayLine}
|
||||
orderByPlace={selectedNumbers.byPlace}
|
||||
cartoApiKey={data.cartoApiKey}
|
||||
days={days}
|
||||
selectedDay={selectedDay}
|
||||
onSelectDay={onPickDayOnMap}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3 lg:order-1">
|
||||
{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 (
|
||||
<SharedDayCard
|
||||
key={day.id}
|
||||
day={day}
|
||||
index={index}
|
||||
selected={selectedDay === day.id}
|
||||
collapsed={collapsedDays.has(day.id)}
|
||||
onSelect={() => 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 || []}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,3 +82,132 @@ export function isSharedTripPayload(payload: unknown): boolean {
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
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<number, number>; byPlace: Record<number, number[]> } {
|
||||
const byAssignment: Record<number, number> = {}
|
||||
const byPlace: Record<number, number[]> = {}
|
||||
;[...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<T>(items: readonly T[], keyOf: (item: T) => string): [string, T[]][] {
|
||||
const groups = new Map<string, T[]>()
|
||||
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<SharedTransport, 'type' | 'metadata' | '__leg'>,
|
||||
platformLabel: string,
|
||||
): string[] {
|
||||
const meta = (typeof r.metadata === 'string' ? safeJson(r.metadata) : r.metadata || {}) as Record<string, string | undefined>
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export function useSharedTrip() {
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(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<ReadonlySet<number>>(() => 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user