Files
TREK/client/src/components/Planner/PlaceDetailsColumn.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

878 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useId, useRef, useState } from 'react'
import {
Accessibility,
ArrowRight,
Bike,
Building2,
Check,
ChefHat,
ChevronDown,
ChevronUp,
Clock,
ExternalLink,
ImageIcon,
Landmark,
Leaf,
Loader2,
ScrollText,
SearchX,
ShoppingBag,
Sprout,
Star,
Sun,
Wifi,
} from 'lucide-react'
import type { MapsPlaceEnrichmentResult, PlaceFact, PlaceHours, PlacePhotoCandidate, PlaceRating } from '@trek/shared'
import { mapsApi } from '../../api/client'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { LABEL } from '../shared/dialogParts'
import { COLUMN_CARD, ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from './placeDialogParts'
import type { TranslationFn } from '../../types'
/** The place the column is describing. Null while nothing is selected. */
export interface PlaceDetailsSelection {
placeId?: string
lat: number
lng: number
name: string
/** The picked search result, so the server can skip its own details lookup. */
details?: Record<string, unknown>
}
interface PlaceDetailsColumnProps {
selection: PlaceDetailsSelection | null
/** Currently chosen hero image, so the picked tile can show as picked. */
selectedImageUrl?: string
onPickImage: (url: string | null) => void
onAdoptDescription: (text: string) => void
/** True once the form's description field has something in it. */
hasDescription: boolean
language: string
/** The user's clock preference, so hours read the same as in the inspector. */
timeFormat?: string
/** For grouping the rating count's digits. */
locale?: string
/**
* Full width at every breakpoint. The desktop dialog sits the column beside
* the form (sm:w-80); the mobile sheet stacks it inside a scrolling column,
* where a 320 px cap would leave it hugging the left half of a wide screen.
*/
fluid?: boolean
/**
* 'dialog' is the desktop add place dialog: a grey panel of white cards under
* small eyebrows, and a quiet hint card where the sheet shows the mascot. The
* default is the phone sheet's look, which this prop leaves exactly as it was.
*/
variant?: 'sheet' | 'dialog'
/** False on an instance with no Google key, which is most of them. */
t: TranslationFn
}
/**
* Module-level cache plus sessionStorage, same shape as usePlaceDetails in
* PlaceInspector. Clicking back and forth between two search results must not
* pay for the provider fan-out twice.
*/
const enrichmentCache = new Map<string, MapsPlaceEnrichmentResult>()
/** Test seam: the module-level cache otherwise leaks between cases. */
export function __clearEnrichmentCacheForTests(): void {
enrichmentCache.clear()
}
function readSession(key: string): MapsPlaceEnrichmentResult | undefined {
try {
const raw = sessionStorage.getItem(key)
return raw ? (JSON.parse(raw) as MapsPlaceEnrichmentResult) : undefined
} catch {
return undefined
}
}
function writeSession(key: string, value: MapsPlaceEnrichmentResult): void {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch {
/* private mode / quota — the in-memory cache still helps for this session */
}
}
/**
* Bumped with the server's CACHE_VERSION. sessionStorage outlives a deploy, so
* without it the tab that was open while the fix shipped keeps replaying the
* answer the fix was about — and reports it as still broken.
*/
const ENRICH_CACHE_V = 4
function cacheKeyFor(selection: PlaceDetailsSelection, language: string): string {
const id = selection.placeId || `coords:${selection.lat}:${selection.lng}`
return `enrich_v${ENRICH_CACHE_V}_${id}_${language}`
}
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
/**
* Overline label, the section heading Vacay and the dashboard widgets share.
* Uppercase with wide tracking, muted, no rule underneath.
*/
function Overline({ children }: { children: React.ReactNode }): React.ReactElement {
return (
<p className="text-caption font-semibold uppercase tracking-[0.14em] text-content-faint">{children}</p>
)
}
/**
* One block of the column under its label. The sheet keeps its overline; the
* dialog uses the eyebrow every field of the form beside it wears.
*/
function Block({ label, dialog, children }: { label: React.ReactNode; dialog: boolean; children: React.ReactNode }): React.ReactElement {
if (!dialog) {
return (
<div className="space-y-2">
<Overline>{label}</Overline>
{children}
</div>
)
}
return (
<section className="min-w-0">
<p className={LABEL}>{label}</p>
{children}
</section>
)
}
export default function PlaceDetailsColumn({
selection,
selectedImageUrl,
onPickImage,
onAdoptDescription,
hasDescription,
language,
timeFormat = '24h',
locale = 'en-US',
fluid = false,
variant = 'sheet',
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const dialog = variant === 'dialog'
const adoptBlockedId = useId()
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
const [state, setState] = useState<LoadState>('idle')
const abortRef = useRef<AbortController | null>(null)
const selectionKey = selection ? cacheKeyFor(selection, language) : null
useEffect(() => {
// Abort whatever the previous selection started; its answer is no longer
// about the place on screen.
abortRef.current?.abort()
abortRef.current = null
if (!selection || !selectionKey) {
setData(null)
setState('idle')
return
}
const cached = enrichmentCache.get(selectionKey) ?? readSession(selectionKey)
if (cached) {
enrichmentCache.set(selectionKey, cached)
setData(cached)
setState('ready')
return
}
const controller = new AbortController()
abortRef.current = controller
setState('loading')
setData(null)
// One quiet retry before the error state: on a phone the first attempt
// regularly dies to a network blip rather than a real failure, and an
// error card the user can only clear by re-picking the place is worse than
// a second and a half of extra spinner.
let retried = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
const run = () => {
mapsApi
.placeEnrichment(
{
placeId: selection.placeId,
lat: selection.lat,
lng: selection.lng,
name: selection.name,
lang: language,
details: selection.details,
},
controller.signal,
)
.then((result) => {
if (controller.signal.aborted) return
enrichmentCache.set(selectionKey, result)
writeSession(selectionKey, result)
setData(result)
setState('ready')
})
.catch((err: unknown) => {
if (controller.signal.aborted || (err as { code?: string })?.code === 'ERR_CANCELED') return
if (!retried) {
retried = true
retryTimer = setTimeout(run, 1500)
return
}
setState('error')
})
}
run()
return () => {
controller.abort()
if (retryTimer) clearTimeout(retryTimer)
}
// selectionKey folds in the place id (or its coordinates) and the language,
// which is everything the answer depends on. Depending on `selection` itself
// would refetch whenever the object identity changes without the place
// having changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectionKey, language])
useEffect(() => () => abortRef.current?.abort(), [])
const isEmpty =
state === 'ready' &&
!data?.disabled &&
!data?.photos.length &&
!data?.description &&
!data?.facts.length &&
!data?.hours &&
!data?.rating
return (
// 320px, not 288: the picture grid was doing (288 - 24 - 12) / 3 = 84px
// tiles, which is too small to tell a facade from a foyer. The column is
// stretched to the form's height by the row it sits in, so the extra room
// costs nothing that was being used.
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
{dialog ? (
<ColumnHead icon={<Landmark size={13} strokeWidth={2.2} />}>{t('places.details.title')}</ColumnHead>
) : (
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
<Landmark size={15} className="text-accent" />
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
</div>
)}
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
{/* Nothing picked yet, which is a state rather than a fault: the mascot
waits with an idle look instead of a sad one. The dialog says the
same in a quiet card, and the column keeps its width either way. */}
{!selection && (dialog ? (
<HintCard icon={<ImageIcon size={15} strokeWidth={2} />}>{t('places.details.empty')}</HintCard>
) : (
<EmptyState
scene="idle"
title={t('places.details.empty')}
size={92}
fill
surface="var(--bg-secondary)"
/>
))}
{selection && state === 'loading' && (dialog ? (
<HintCard icon={<Loader2 size={15} className="animate-spin" />}>{t('places.details.loading')}</HintCard>
) : (
<div className="flex items-center gap-2 text-caption text-content-muted">
<Loader2 className="w-4 h-4 animate-spin" />
{t('places.details.loading')}
</div>
))}
{selection && state === 'error' && (dialog
? <HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.error')}</HintCard>
: <p className="text-caption text-content-muted">{t('places.details.error')}</p>)}
{selection && state === 'ready' && data?.disabled && (dialog
? <HintCard icon={<Landmark size={15} strokeWidth={2} />}>{t('places.details.disabled')}</HintCard>
: <p className="text-caption text-content-muted">{t('places.details.disabled')}</p>)}
{/* The mascot rather than a line of grey text and, under it, a paragraph
telling the reader to go and ask an administrator for a Google key.
Nothing came back for this place, which is worth saying once and
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (dialog ? (
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.nothing')}</HintCard>
) : (
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
))}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
<>
<PhotoStrip photos={data?.photos ?? []} selectedImageUrl={selectedImageUrl} onPickImage={onPickImage} dialog={dialog} t={t} />
<RatingRow rating={data?.rating ?? null} locale={locale} dialog={dialog} />
<OpeningHoursBlock
hours={data?.hours ?? null}
lat={selection.lat}
lng={selection.lng}
timeFormat={timeFormat}
dialog={dialog}
t={t}
/>
<FactList facts={data?.facts ?? []} dialog={dialog} t={t} />
<DescriptionBlock description={data?.description ?? null} dialog={dialog} t={t} />
</>
)}
</div>
{/* Pinned, not scrolled. Adopting the text is the only thing this column
asks the reader to do, and it used to sit below a description long
enough to push it out of view — the button was there, just never
where anyone looked. */}
{selection && state === 'ready' && !data?.disabled && data?.description && (
<div className={dialog ? 'shrink-0 border-t border-edge-faint p-2.5' : 'shrink-0 border-t border-edge p-2.5'}>
{dialog ? (
// White like the dialog's other secondary buttons. While it is
// blocked the line under it says why, so it needs no tooltip, and
// that line is its description for assistive tech.
<button
type="button"
onClick={() => onAdoptDescription(data.description!.text)}
disabled={hasDescription}
aria-describedby={hasDescription ? adoptBlockedId : undefined}
className={WHITE_BUTTON}
style={fs(13, 'body')}
>
<ArrowRight className="w-3.5 h-3.5" />
{t('places.details.adopt')}
</button>
) : (
<button
type="button"
onClick={() => onAdoptDescription(data.description!.text)}
disabled={hasDescription}
title={hasDescription ? t('places.details.adoptBlocked') : undefined}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors"
>
<ArrowRight className="w-3.5 h-3.5" />
{t('places.details.adopt')}
</button>
)}
{hasDescription && (
<p id={dialog ? adoptBlockedId : undefined} className="mt-1 text-center text-caption text-content-faint">{t('places.details.adoptBlocked')}</p>
)}
</div>
)}
</aside>
)
}
/** The name to put under a picture. Google gives no author, so it gets its own name. */
function creditOf(photo: PlacePhotoCandidate): string {
return photo.attribution || sourceLabelFor(photo.source)
}
/**
* The picture grid.
*
* A grid rather than one large image: this sits beside a form in a dialog, and
* a picture that takes half the column pushes the facts and the description out
* of sight. Three columns at 320px gives ~96px tiles, which is enough to tell
* the pictures apart while leaving the rest of the column visible.
*
* Only the picture in play is credited in full — crediting all of them at once
* cost two lines each and drowned the column, and the licence obligation
* attaches to the one that gets used. Every tile still carries the full credit
* as its tooltip and links to its source page.
*/
function PhotoStrip({
photos,
selectedImageUrl,
onPickImage,
dialog = false,
t,
}: {
photos: PlacePhotoCandidate[]
selectedImageUrl?: string
onPickImage: (url: string | null) => void
dialog?: boolean
t: TranslationFn
}): React.ReactElement | null {
const [hovered, setHovered] = useState<string | null>(null)
if (photos.length === 0) return null
const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0]
const body = (
<>
<div className="grid grid-cols-3 gap-1.5">
{photos.map((photo) => (
<PhotoTile
key={photo.key}
photo={photo}
selected={selectedImageUrl === photo.url}
onPick={onPickImage}
onHover={setHovered}
dialog={dialog}
t={t}
/>
))}
</div>
<p className={dialog ? 'mt-2 flex min-w-0 items-center gap-2 text-caption leading-tight text-content-faint' : 'text-caption leading-tight text-content-faint truncate'}>
<PhotoCredit photo={shown} dialog={dialog} />
</p>
</>
)
return (
<Block label={t('places.details.pickImage')} dialog={dialog}>
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{body}</div> : body}
</Block>
)
}
function PhotoTile({
photo,
selected,
onPick,
onHover,
dialog = false,
t,
}: {
photo: PlacePhotoCandidate
selected: boolean
onPick: (url: string | null) => void
onHover: (url: string | null) => void
/** On a white card, and without the native tooltip: hovering a tile already moves the credit line under the grid to it. */
dialog?: boolean
t: TranslationFn
}): React.ReactElement {
const credit = creditOf(photo)
return (
<button
type="button"
onClick={() => onPick(selected ? null : photo.url)}
onMouseEnter={() => onHover(photo.url)}
onMouseLeave={() => onHover(null)}
onFocus={() => onHover(photo.url)}
onBlur={() => onHover(null)}
aria-pressed={selected}
aria-label={`${t('places.details.pickImage')} — ${credit}`}
title={dialog ? undefined : `${credit}${photo.license ? ` · ${photo.license}` : ''}`}
className={`group relative block w-full aspect-square overflow-hidden rounded-lg transition-shadow ${
selected
? `ring-2 ring-accent ring-offset-2 ${dialog ? 'ring-offset-surface-card' : 'ring-offset-surface-secondary'}`
: 'ring-1 ring-edge hover:ring-content-muted'
}`}
>
<img
src={photo.url}
alt=""
loading="lazy"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
/>
{selected && (
<span className="absolute top-1 right-1 rounded-full bg-accent p-0.5 shadow-card">
<Check className="w-2.5 h-2.5 text-accent-on" />
</span>
)}
</button>
)
}
/**
* Who the description came from, as the reader would name them.
*
* `website` has no fixed name — it is the place's own site — so it is credited
* by host: "losteria.net" says where the text is from far better than a generic
* word would. Deliberately not a ternary chain: the previous one fell through
* to 'Wikipedia', which would have credited a restaurant's marketing copy to an
* encyclopaedia.
*/
function descriptionSourceLabel(
source: NonNullable<MapsPlaceEnrichmentResult['description']>['source'],
sourceUrl: string | null,
): string {
switch (source) {
case 'google':
return 'Google'
case 'osm':
return 'OpenStreetMap'
case 'wikivoyage':
return 'Wikivoyage'
case 'wikipedia':
return 'Wikipedia'
case 'website':
try {
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
} catch {
return 'Website'
}
}
}
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
if (source === 'google') return 'Google'
if (source === 'wikipedia') return 'Wikipedia'
return 'Wikimedia Commons'
}
/**
* Author and licence for the picture currently in play.
*
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
* without naming its author does not satisfy those terms. When a source hands
* us no author (Google), we say where it came from rather than inventing one.
* Rendered inline — the container supplies the colour and the truncation.
*/
function PhotoCredit({ photo, dialog = false }: { photo: PlacePhotoCandidate; dialog?: boolean }): React.ReactElement {
const credit = creditOf(photo)
if (dialog) {
// Two pieces side by side with room between them, rather than joined by a dot.
return (
<>
<span className="min-w-0 truncate">
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
</span>
{photo.license && (
<span className="flex-none">
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</span>
)}
</>
)
}
return (
<>
{photo.sourceUrl ? (
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{credit}
</a>
) : (
credit
)}
{photo.license && (
<>
{' · '}
{photo.licenseUrl ? (
<a href={photo.licenseUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
{photo.license}
</a>
) : (
photo.license
)}
</>
)}
</>
)
}
/**
* Shown when the free sources found nothing and no Google key is configured.
*
* Both halves matter. With a key there is nothing to suggest, and on a place
* the free sources did describe the same card would just be an advert — which
* is why it lives inside the empty state rather than at the foot of the column.
*
* It also names who to ask: the key is an instance-wide setting, so on most
* installs the person reading this cannot act on it themselves.
*/
/**
* The star rating, as stars.
*
* It used to be a chip reading "3.8 (873)", which is the same information and
* none of the recognition — a row of stars is read at a glance and a decimal in
* a pill is read as text. The count only appears when there is one: Google's
* search results carry a rating without a count, and empty brackets look broken.
*/
function RatingRow({ rating, locale, dialog = false }: { rating: PlaceRating | null; locale: string; dialog?: boolean }): React.ReactElement | null {
if (!rating) return null
const filled = Math.round(rating.value)
return (
<div className={dialog ? 'inline-flex items-center gap-1.5 self-start rounded-full bg-surface-card px-2.5 py-1 shadow-sm ring-1 ring-edge-faint' : 'flex items-center gap-1.5'}>
<span className="flex items-center gap-0.5" aria-hidden>
{[1, 2, 3, 4, 5].map((step) => (
<Star
key={step}
className={`w-3 h-3 ${step <= filled ? 'fill-current text-accent-on' : 'text-content-faint'}`}
/>
))}
</span>
<span className="text-caption font-semibold text-content">{rating.value.toFixed(1)}</span>
{rating.count != null && (
<span className="text-caption text-content-faint">({rating.count.toLocaleString(locale)})</span>
)}
</div>
)
}
/**
* The week's opening hours: today up front, the rest a click away.
*
* This replaces a single chip holding the entire week joined with dots, which
* in a 288px column truncated to "Monday: 11:30 AM – 11:00 PM · Tuesday:…" and
* told a reader nothing at all.
*
* Open/closed is recomputed from the structured periods in the place's own
* timezone rather than taken from whatever the provider reported when the
* payload was cached — read from another continent that flag is wrong twice
* over (#1680). When the periods do not support an answer there is no badge:
* an hours line the parser could not read is not evidence that somewhere is
* closed, and a confident wrong badge is worse than a missing one.
*/
function OpeningHoursBlock({
hours,
lat,
lng,
timeFormat,
dialog = false,
t,
}: {
hours: PlaceHours | null
lat: number
lng: number
timeFormat: string
dialog?: boolean
t: TranslationFn
}): React.ReactElement | null {
const [expanded, setExpanded] = useState(false)
const lines = hours?.weekdayDescriptions
if (!lines?.length) return null
const today = placeWeekdayIndex(new Date(), resolvePlaceTimeZone(lat, lng))
const openNow = resolveOpenNow({ periods: hours?.periods, specialDays: hours?.specialDays }, lat, lng, null)
const [, todayTimes] = splitHoursLine(convertHoursLine(lines[today] ?? '', timeFormat))
const todayLabel = todayTimes && !isUnknownHoursLine(todayTimes) ? todayTimes : t('inspector.showHours')
return (
<Block label={t('inspector.openingHours')} dialog={dialog}>
<div className={dialog ? `${COLUMN_CARD} overflow-hidden` : 'rounded-lg border border-edge bg-surface overflow-hidden'}>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="w-full flex items-center gap-2 px-2.5 py-2 text-left hover:bg-surface-hover transition-colors"
>
<Clock className="w-3.5 h-3.5 shrink-0 text-content-faint" />
<span className="min-w-0 flex-1">
<span className="block text-caption font-medium text-content truncate">{todayLabel}</span>
{openNow !== null && (
<span
className={`mt-1 inline-flex items-center rounded-full px-1.5 py-px text-caption font-semibold ${
openNow ? 'bg-success-soft text-success' : 'bg-danger-soft text-danger'
}`}
>
{openNow ? t('inspector.opened') : t('inspector.closed')}
</span>
)}
</span>
{expanded ? (
<ChevronUp className="w-3.5 h-3.5 shrink-0 text-content-faint" />
) : (
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-content-faint" />
)}
</button>
{expanded && (
<ul className="border-t border-edge-faint px-2.5 py-1.5 space-y-0.5">
{lines.map((line, i) => {
const [day, times] = splitHoursLine(convertHoursLine(line, timeFormat))
// No day part means the provider did not phrase it as
// "Day: times" — render it whole rather than inventing columns.
if (!day) {
return (
<li key={i} className="text-caption text-content-muted break-words">
{times}
</li>
)
}
return (
<li
key={i}
className={`flex items-baseline justify-between gap-2 text-caption ${
i === today ? 'text-content font-semibold' : 'text-content-muted'
}`}
>
<span className="shrink-0">{day}</span>
{/* Wraps rather than truncates: this is the view someone
opened on purpose, and split shifts run long. */}
<span className="min-w-0 text-right break-words tabular-nums">
{isUnknownHoursLine(times) ? '–' : times}
</span>
</li>
)
})}
</ul>
)}
</div>
</Block>
)
}
const FACT_ICONS: Record<PlaceFact['kind'], typeof ChefHat> = {
rating: Star,
cuisine: ChefHat,
openingHours: Clock,
menu: ScrollText,
outdoorSeating: Sun,
takeaway: ShoppingBag,
delivery: Bike,
wheelchair: Accessibility,
vegetarian: Leaf,
vegan: Sprout,
internetAccess: Wifi,
}
/**
* The OpenStreetMap facts, as chips.
*
* For a restaurant this is the whole point of the column: nothing will ever
* write an encyclopaedia entry about it, but its cuisine, its hours and a link
* to its menu are in the map data, and they came along with a lookup that had
* already happened.
*/
function FactList({ facts, dialog = false, t }: { facts: PlaceFact[]; dialog?: boolean; t: TranslationFn }): React.ReactElement | null {
if (facts.length === 0) return null
return (
<Block label={t('places.details.facts')} dialog={dialog}>
<div className="flex flex-wrap gap-1.5">
{facts
.filter((fact) => fact.kind !== 'openingHours' && fact.kind !== 'rating')
.map((fact) => {
const Icon = FACT_ICONS[fact.kind]
const label = fact.value || t(`places.details.fact.${fact.kind}`)
const body = (
<>
<Icon className="w-3 h-3 shrink-0" />
<span className="truncate">{label}</span>
</>
)
// A fact url is a community-edited OSM tag (website:menu and friends),
// so anything but http(s) renders as the plain chip instead of a link.
const href = safeHttpUrl(fact.url)
if (dialog) {
// White pills on the grey column; the label is spelled out in the
// shared tooltip for the chip that had to truncate it.
const pill = 'inline-flex items-center gap-1.5 max-w-full rounded-full bg-surface-card px-2 py-1 text-caption text-content-secondary shadow-sm ring-1 ring-edge-faint'
return (
<Tooltip key={fact.kind} label={label}>
{href ? (
<a href={href} target="_blank" rel="noopener noreferrer" className={`${pill} hover:text-content hover:ring-content-faint transition-colors`}>
{body}
</a>
) : (
<span className={pill}>{body}</span>
)}
</Tooltip>
)
}
const shared = 'inline-flex items-center gap-1.5 max-w-full rounded-full border border-edge bg-surface px-2 py-1 text-caption text-content-secondary'
return href ? (
<a
key={fact.kind}
href={href}
target="_blank"
rel="noopener noreferrer"
title={label}
className={`${shared} hover:border-accent hover:text-content transition-colors`}
>
{body}
</a>
) : (
<span key={fact.kind} title={label} className={shared}>
{body}
</span>
)
})}
</div>
</Block>
)
}
/**
* The description and where it came from.
*
* The adopt button used to live at the bottom of this block, which put it below
* however much prose the source returned — reliably out of view. It is the
* column's only action, so it moved to a pinned footer instead.
*/
function DescriptionBlock({
description,
dialog = false,
t,
}: {
description: MapsPlaceEnrichmentResult['description']
dialog?: boolean
t: TranslationFn
}): React.ReactElement | null {
if (!description) return null
const sourceLabel = descriptionSourceLabel(description.source, description.sourceUrl)
// A description of the chain is not a description of this place, and the
// heading is where that gets said. Without it the reader has no way to tell
// that "a German restaurant chain serving pizza and pasta" is about the
// company rather than the branch they are about to add to their trip.
const aboutBrand = !!description.aboutBrand
return (
<Block label={aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')} dialog={dialog}>
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-lg border border-edge bg-surface p-2.5 space-y-2'}>
{aboutBrand && (
<p className="flex items-start gap-1.5 text-caption leading-tight text-content-faint">
<Building2 className="mt-px h-3 w-3 shrink-0" />
<span>{t('places.details.aboutBrandNote')}</span>
</p>
)}
<p className="text-caption leading-relaxed text-content whitespace-pre-line">{description.text}</p>
<p className={dialog ? 'flex flex-wrap items-center gap-x-2 gap-y-0.5 text-caption leading-tight text-content-faint' : 'text-caption leading-tight text-content-faint'}>
{description.sourceUrl ? (
<a
href={description.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 hover:underline"
>
{sourceLabel}
<ExternalLink className="w-2.5 h-2.5" />
</a>
) : (
sourceLabel
)}
{description.license && (dialog ? <span>{description.license}</span> : ` · ${description.license}`)}
</p>
</div>
</Block>
)
}