Files
TREK/client/src/components/Planner/PlaceDetailsColumn.tsx
T
Maurice bebfeeff97 test(places): the picture chosen in the editor, the gallery and the lightbox
Tests for the picker, the shared image field, staging and saving the picture,
the gallery hook and trigger, the lightbox's dialog, focus and reading
direction, the credit and the enrichment cache; the inspector, phone sheet,
details column, form and wiring tests follow the picture into the editor.
The picker's upload tile and credit line are components of their own, the
heading goes with an empty picker, the gallery's state names its place, and
'No picture' only shows when a picture is being removed.
2026-10-23 21:01:12 +02:00

697 lines
26 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,
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 (
<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,
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<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 = 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<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
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.
<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'}>
{/* The place's picture first: its own ones are there whatever the sources answer. */}
<PictureBlock
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
chosen={image?.chosen ?? chosenImage(selectedImageUrl, null, null)}
onPickImage={onPickImage}
own={image?.own}
dialog={dialog}
t={t}
/>
{/* 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 && (
<>
<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 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 = <PlaceImagePicker photos={photos} chosen={chosen} onPickUrl={onPickImage} own={own} dialog={dialog} t={t} />
return (
<Block label={t('places.details.pickImage')} dialog={dialog}>
{dialog ? <div className={`${COLUMN_CARD} p-2`}>{picker}</div> : picker}
</Block>
)
}
/**
* 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'
}
}
}
/**
* 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-start 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-end 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>
)
}