import React, { useEffect, useId, useRef, useState } from 'react' import { Accessibility, ArrowRight, Bike, Building2, 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' import PlaceImagePicker, { type PlaceImageOwn } from './PlaceImagePicker' import { chosenImage, offersPictures, type ChosenImage } from './placeImageChoice' import { cacheKeyFor, readCachedEnrichment, storeEnrichment, type PlaceDetailsSelection } from './placeEnrichment' export { __clearEnrichmentCacheForTests, type PlaceDetailsSelection } from './placeEnrichment' interface PlaceDetailsColumnProps { selection: PlaceDetailsSelection | null /** Currently chosen hero image, so the picked tile can show as picked. */ selectedImageUrl?: string | null onPickImage: (url: string | null) => void /** * The editor's own pictures (upload, the current one, attached images) and * which tile is chosen; without it only the sources' pictures are offered. */ image?: { own: PlaceImageOwn; chosen: ChosenImage } onAdoptDescription: (text: string) => void /** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */ onHours?: (hours: PlaceHours) => 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 } 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 (

{children}

) } /** * 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 (
{label} {children}
) } return (

{label}

{children}
) } export default function PlaceDetailsColumn({ selection, selectedImageUrl, onPickImage, onAdoptDescription, onHours, hasDescription, language, timeFormat = '24h', locale = 'en-US', fluid = false, variant = 'sheet', image, t, }: PlaceDetailsColumnProps): React.ReactElement { const dialog = variant === 'dialog' const adoptBlockedId = useId() const [data, setData] = useState(null) const [state, setState] = useState('idle') const abortRef = useRef(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 = readCachedEnrichment(selection, language) if (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 | 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 storeEnrichment(selection, language, 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(), []) // Handed to the form once per answer; a ref so a new callback identity does not re-send it. const onHoursRef = useRef(onHours) useEffect(() => { onHoursRef.current = onHours }) useEffect(() => { if (state === 'ready' && data?.hours) onHoursRef.current?.(data.hours) }, [state, data]) 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. ) } /** The picker under its label; nothing while there is no picture to offer. */ function PictureBlock({ photos, chosen, onPickImage, own, dialog, t, }: { photos: PlacePhotoCandidate[] chosen: ChosenImage onPickImage: (url: string | null) => void own?: PlaceImageOwn dialog: boolean t: TranslationFn }): React.ReactElement | null { if (!offersPictures(photos, own)) return null const picker = return ( {dialog ?
{picker}
: picker}
) } /** * 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['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' } } } /** * 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 (
{[1, 2, 3, 4, 5].map((step) => ( ))} {rating.value.toFixed(1)} {rating.count != null && ( ({rating.count.toLocaleString(locale)}) )}
) } /** * 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 (
{expanded && (
    {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 (
  • {times}
  • ) } return (
  • {day} {/* Wraps rather than truncates: this is the view someone opened on purpose, and split shifts run long. */} {isUnknownHoursLine(times) ? '–' : times}
  • ) })}
)}
) } const FACT_ICONS: Record = { 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 (
{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 = ( <> {label} ) // 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 ( {href ? ( {body} ) : ( {body} )} ) } 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 ? ( {body} ) : ( {body} ) })}
) } /** * 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 (
{aboutBrand && (

{t('places.details.aboutBrandNote')}

)}

{description.text}

{description.sourceUrl ? ( {sourceLabel} ) : ( sourceLabel )} {description.license && (dialog ? {description.license} : ` · ${description.license}`)}

) }