mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +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}
|
||||
|
||||
Reference in New Issue
Block a user