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 (
)
}
/**
* 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 (