Files
TREK/client/src/pages/sharedTrip/SharedPlanView.tsx
T
Maurice bbf8d94f9f 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.
2026-09-28 14:40:34 +02:00

124 lines
5.6 KiB
TypeScript

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>
)
}