mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
0dd07befa7
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.
878 lines
32 KiB
TypeScript
878 lines
32 KiB
TypeScript
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>
|
||
)
|
||
}
|