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:
Maurice
2026-09-28 14:40:34 +02:00
parent 21c068eb43
commit bbf8d94f9f
44 changed files with 1804 additions and 1463 deletions
@@ -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}