mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
feat(planner): fold the place inspector to its head and keep the place selected (#3039)
On a landscape phone or a small tablet the inspector covered most of the map, and the only way to clear it was closing it, which deselected the place. A chevron in the head now folds the card to the avatar and name; the next place opens in full. The head band moves to PlaceInspectorHead so the inspector stays inside its size budget.
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2910,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1287,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1129,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
|
||||
@@ -1831,3 +1831,43 @@ describe('PlaceInspector blur booking codes (#2457)', () => {
|
||||
expect(isBlurred(screen.getByText(/TABLE-SECRET/))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Folding the card over the map (#3039) ─────────────────────────────────────
|
||||
|
||||
describe('PlaceInspector folded to its head (#3039)', () => {
|
||||
it('FE-PLANNER-INSPECTOR-112: Collapse folds the card to its name and keeps the place open; Expand unfolds it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(<PlaceInspector {...defaultProps} onClose={onClose} collapsible />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
|
||||
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Champ de Mars, Paris')).toBeNull();
|
||||
// Hidden, not unmounted: the body and its plugin frames keep their state.
|
||||
expect(screen.getByTestId('inspector-scroll')).toHaveClass('hidden');
|
||||
expect(container.querySelector('footer')).toHaveClass('hidden');
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }));
|
||||
|
||||
expect(screen.getByText('Champ de Mars, Paris')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('inspector-scroll')).toHaveClass('flex');
|
||||
expect(container.querySelector('footer')).toHaveClass('flex');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-113: the next place opens in full, and a card that may not fold offers no button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<PlaceInspector {...defaultProps} collapsible />);
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
|
||||
const louvre = buildPlace({ id: 2, name: 'Louvre', address: 'Rue de Rivoli, Paris', lat: 48.86, lng: 2.34 });
|
||||
rerender(<PlaceInspector {...defaultProps} place={louvre} collapsible />);
|
||||
expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
rerender(<PlaceInspector {...defaultProps} place={louvre} />);
|
||||
expect(screen.queryByRole('button', { name: 'Collapse' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,8 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Mail, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
|
||||
import { Clock, MapPin, ExternalLink, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
|
||||
import { hoursLines, periodsFromWeek } from './placeHours'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import PlacePhotoGallery from './PlacePhotoGallery'
|
||||
import { BlurredCode } from '../shared/BookingCode'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import TrackColorPicker from '../shared/TrackColorPicker'
|
||||
@@ -23,13 +21,11 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { placeToSaveTarget } from '../Collections/saveTarget'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import PluginFrame from '../Plugins/PluginFrame'
|
||||
import AssistantAskButton from '../Assistant/AssistantAskButton'
|
||||
import type { Place, Category, Day, Reservation, TripFile, AssignmentsMap, DistanceUnit } from '../../types'
|
||||
import type { CollectionStatus } from '@trek/shared'
|
||||
import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/formatters'
|
||||
@@ -44,10 +40,11 @@ import { resolveOpenNow, resolvePlaceTimeZone, getWeekdayIndex, type OpeningPeri
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
import type { EndDayControlProps } from '../Roadtrip/EndDayControl'
|
||||
import VisitControls, { type RoadtripStayControl } from '../Roadtrip/VisitControls'
|
||||
import { DialogButton, DialogSection, DeleteButton, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { DialogButton, DialogSection, DeleteButton, FooterSpacer, fs } from '../shared/DialogShell'
|
||||
import { BOX, Field, TypeTile, toneOf, toneTint, useOpenFile } from './bookings/bookingParts'
|
||||
import { parseMeta } from './bookings/bookingsModel'
|
||||
import { SoftPill, TimePill, tintOf } from './planParts'
|
||||
import { SoftPill } from './planParts'
|
||||
import { InspectorHead } from './PlaceInspectorHead'
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
|
||||
import { participantsWith, participantsWithout, splitParticipants, usePlaceFileUpload } from './usePlaceActions'
|
||||
|
||||
@@ -188,6 +185,8 @@ interface PlaceInspectorProps extends MapCardPlacement {
|
||||
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
|
||||
/** Cast/clear the current user's star vote (#1435); enables the rating row. */
|
||||
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
|
||||
/** The card over the map folds to its head (#3039); the phone's modal sheet does not. */
|
||||
collapsible?: boolean
|
||||
// ── Collection-mode props ──
|
||||
collectionStatus?: CollectionStatus
|
||||
onCopyToTrip?: () => void
|
||||
@@ -195,9 +194,6 @@ interface PlaceInspectorProps extends MapCardPlacement {
|
||||
onRemoveFromList?: () => void
|
||||
}
|
||||
|
||||
/** The small pill of the head band, the size of a SoftPill but raised on the tint. */
|
||||
const HEAD_PILL = 'inline-flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-card px-2 py-[2px] font-semibold text-content shadow-sm'
|
||||
|
||||
/**
|
||||
* The card a selected place opens over the map, in the planner's dialog
|
||||
* language: a head band tinted by its category with the avatar, the name, the
|
||||
@@ -209,7 +205,7 @@ export default function PlaceInspector({
|
||||
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
|
||||
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate,
|
||||
leftWidth = 0, rightWidth = 0, clearMapPill = false, cardRef,
|
||||
leftWidth = 0, rightWidth = 0, clearMapPill = false, cardRef, collapsible,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
|
||||
}: PlaceInspectorProps) {
|
||||
// Editing the place is a place right. The planner hands the handlers over regardless, and a member
|
||||
@@ -256,6 +252,8 @@ export default function PlaceInspector({
|
||||
const saveVersion = useSaveToCollectionStore(s => s.version)
|
||||
const [savedInCollection, setSavedInCollection] = useState(false)
|
||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
||||
// Folded by place, so the next place selected opens in full.
|
||||
const [collapsedPlaceId, setCollapsedPlaceId] = useState<number | null>(null)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameValue, setNameValue] = useState('')
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -364,6 +362,7 @@ export default function PlaceInspector({
|
||||
const description = place.description || googleDetails?.summary
|
||||
const showRating = (mode === 'trip' && !!onRate) || !!shortReview
|
||||
const mapCard = centredMapCard(leftWidth, rightWidth, { clearPill: clearMapPill })
|
||||
const collapsed = collapsedPlaceId === place.id
|
||||
|
||||
return (
|
||||
<div ref={cardRef}
|
||||
@@ -384,11 +383,12 @@ export default function PlaceInspector({
|
||||
time={place.place_time ? `${formatTime(place.place_time, locale, timeFormat)}${place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}` : null}
|
||||
editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue}
|
||||
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
|
||||
onUpdatePlace={onUpdatePlace}
|
||||
photoCredit={<PhotoCredit imageUrl={place.image_url} />} onUpdatePlace={onUpdatePlace} collapsed={collapsed}
|
||||
onToggleCollapsed={collapsible ? () => setCollapsedPlaceId(collapsed ? null : place.id) : undefined}
|
||||
onClose={onClose} />
|
||||
|
||||
{/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */}
|
||||
<div data-testid="inspector-scroll" className="flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto overscroll-contain px-4 py-3.5 empty:hidden">
|
||||
<div data-testid="inspector-scroll" className={`${collapsed ? 'hidden' : 'flex'} min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto overscroll-contain px-4 py-3.5 empty:hidden`}>
|
||||
|
||||
{/* Collaborative rating (#1435): every member's own vote, shown as the
|
||||
average, and the first review worth quoting. */}
|
||||
@@ -476,7 +476,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
|
||||
{/* Footer: the day action and the ways out on the left, delete and edit on the right. */}
|
||||
<footer className="flex flex-none flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-2.5">
|
||||
<footer className={`${collapsed ? 'hidden' : 'flex'} flex-none flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-2.5`}>
|
||||
{/* Collection mode — copy to trip + per-place status */}
|
||||
{mode === 'collection' && onCopyToTrip && (
|
||||
<DialogButton variant="primary" onClick={onCopyToTrip} icon={<Copy size={14} strokeWidth={2} />} aria-label={t('collections.copyToTrip')}>
|
||||
@@ -569,150 +569,6 @@ function MarkdownBox({ children }: { children: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface InspectorHeadProps {
|
||||
place: Place
|
||||
category: Category | undefined
|
||||
openNow: boolean | null
|
||||
phone: string | null | undefined
|
||||
/** Typed in by hand (#2472); there is no looked-up one. */
|
||||
email?: string | null
|
||||
rating: number | null
|
||||
ratingCount: number | null
|
||||
price: string | null
|
||||
time: string | null
|
||||
editingName: boolean
|
||||
nameInputRef: React.RefObject<HTMLInputElement | null>
|
||||
nameValue: string
|
||||
setNameValue: (value: string) => void
|
||||
commitNameEdit: () => void
|
||||
handleNameKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||
startNameEdit: () => void
|
||||
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band: the avatar with its open/closed ring and photo credit, the
|
||||
* name (a double-click renames it), the address, and the facts as pills. The
|
||||
* avatar opens the place's photos; its picture is changed in the editor.
|
||||
*/
|
||||
function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef,
|
||||
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onClose }: InspectorHeadProps) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm'
|
||||
const hasCoords = !!(place.lat && place.lng)
|
||||
const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || !!email || hasCoords
|
||||
return (
|
||||
<header className="flex-none border-b border-edge-faint px-4 pb-3 pt-3.5" style={{ background: category?.color ? tintOf(category.color) : NEUTRAL_TINT }}>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="flex w-[57px] flex-none flex-col items-center">
|
||||
<div className={`rounded-full p-[2.5px] ${ring}`}>
|
||||
<PlacePhotoGallery place={place} language={language} label={t('places.openPhotos')} className="rounded-full">
|
||||
<PlaceAvatar place={place} category={category} size={52} />
|
||||
</PlacePhotoGallery>
|
||||
</div>
|
||||
<PhotoCredit imageUrl={place.image_url} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{editingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
value={nameValue}
|
||||
onChange={e => setNameValue(e.target.value)}
|
||||
onBlur={commitNameEdit}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
aria-label={t('places.formName')}
|
||||
className="-mx-1.5 block w-[calc(100%+12px)] rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
) : (
|
||||
<h2 onDoubleClick={startNameEdit}
|
||||
className={`m-0 break-words font-bold leading-snug tracking-[-0.01em] text-content ${onUpdatePlace ? 'cursor-text' : ''}`}
|
||||
style={fs(17, 'subtitle')}>
|
||||
{place.name}
|
||||
</h2>
|
||||
)}
|
||||
{place.address && (
|
||||
// One line, whatever its length; the whole address is in the tooltip.
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<MapPin size={12} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<Tooltip label={place.address}>
|
||||
<span className="min-w-0 truncate">{place.address}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPills && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{openNow !== null && (
|
||||
<SoftPill tone={openNow ? 'success' : 'danger'} icon={<span className="h-1.5 w-1.5 flex-none rounded-full bg-current" />}>
|
||||
{openNow ? t('inspector.opened') : t('inspector.closed')}
|
||||
</SoftPill>
|
||||
)}
|
||||
{category && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<span className="h-2 w-2 flex-none rounded-full" style={{ background: category.color || 'var(--text-faint)' }} />
|
||||
{category.name}
|
||||
</span>
|
||||
)}
|
||||
{time && <TimePill>{time}</TimePill>}
|
||||
{rating != null && rating > 0 && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Star size={11} strokeWidth={2} fill="currentColor" className="flex-none text-warning" />
|
||||
{rating.toFixed(1)}
|
||||
{ratingCount ? <span className="font-medium text-content-faint">({ratingCount.toLocaleString(locale)})</span> : null}
|
||||
</span>
|
||||
)}
|
||||
{price && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Banknote size={11} strokeWidth={2} className="flex-none text-success" />
|
||||
{price}
|
||||
</span>
|
||||
)}
|
||||
{/* Where the place came from, when it did not come from somebody typing it: a stay
|
||||
accepted out of their own recordings. The mark alone — the name beside it is
|
||||
already the place's name, and a word here would only repeat the tooltip. */}
|
||||
{place.source === 'dawarich' && (
|
||||
<Tooltip label={t('dawarich.place.fromDawarich')} placement="top">
|
||||
<span role="img" aria-label={t('dawarich.place.fromDawarich')} className={`${HEAD_PILL} px-1 py-[3px]`}>
|
||||
<span className="inline-flex overflow-hidden rounded-[4px]"><DawarichIcon size={13} /></span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{phone && (
|
||||
<a href={`tel:${phone}`} className={`${HEAD_PILL} hover:text-content-secondary`} style={fs(10.5)}>
|
||||
<Phone size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
{phone}
|
||||
</a>
|
||||
)}
|
||||
{email && (
|
||||
<a href={`mailto:${email}`} className={`${HEAD_PILL} min-w-0 hover:text-content-secondary`} style={fs(10.5)}>
|
||||
<Mail size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<span className="truncate">{email}</span>
|
||||
</a>
|
||||
)}
|
||||
{hasCoords && (
|
||||
<span className="hidden flex-none items-center rounded-full border border-edge-faint px-2 py-[1px] font-geist tabular-nums text-content-faint sm:inline-flex" style={fs(10.5)}>
|
||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AssistantAskButton place={place} />
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button type="button" onClick={onClose} aria-label={t('common.close')}
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content">
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface PlaceBookingsAndPeopleProps {
|
||||
selectedAssignmentId: number | null
|
||||
placeId: number
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Banknote, ChevronDown, ChevronUp, Mail, MapPin, Phone, Star, X } from 'lucide-react';
|
||||
import type { KeyboardEvent, ReactNode, RefObject } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Category, Place } from '../../types';
|
||||
import AssistantAskButton from '../Assistant/AssistantAskButton';
|
||||
import DawarichIcon from '../shared/DawarichIcon';
|
||||
import { NEUTRAL_TINT, fs } from '../shared/DialogShell';
|
||||
import PlaceAvatar from '../shared/PlaceAvatar';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import PlacePhotoGallery from './PlacePhotoGallery';
|
||||
import { SoftPill, TimePill, tintOf } from './planParts';
|
||||
|
||||
/** The round buttons at the end of the band. */
|
||||
const ROUND_BUTTON =
|
||||
'grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm transition-colors hover:text-content';
|
||||
|
||||
/** The small pill of the head band, the size of a SoftPill but raised on the tint. */
|
||||
const HEAD_PILL =
|
||||
'inline-flex flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-card px-2 py-[2px] font-semibold text-content shadow-sm';
|
||||
|
||||
interface InspectorHeadProps {
|
||||
place: Place;
|
||||
category: Category | undefined;
|
||||
openNow: boolean | null;
|
||||
phone: string | null | undefined;
|
||||
/** Typed in by hand (#2472); there is no looked-up one. */
|
||||
email?: string | null;
|
||||
rating: number | null;
|
||||
ratingCount: number | null;
|
||||
price: string | null;
|
||||
time: string | null;
|
||||
/** Who made the avatar's picture, under it; the inspector looks it up. */
|
||||
photoCredit: ReactNode;
|
||||
/** Folded to the name alone, the place still selected on the map (#3039). */
|
||||
collapsed: boolean;
|
||||
/** Folds and unfolds the card; without it the card does not fold. */
|
||||
onToggleCollapsed?: () => void;
|
||||
editingName: boolean;
|
||||
nameInputRef: RefObject<HTMLInputElement | null>;
|
||||
nameValue: string;
|
||||
setNameValue: (value: string) => void;
|
||||
commitNameEdit: () => void;
|
||||
handleNameKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
|
||||
startNameEdit: () => void;
|
||||
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band of the place inspector: the avatar with its open/closed ring and photo
|
||||
* credit, the name (a double-click renames it), the address, and the facts as pills.
|
||||
* The avatar opens the place's photos; its picture is changed in the editor.
|
||||
*
|
||||
* Folded, it is a slim bar of the avatar and the name over the map, so the place can stay
|
||||
* selected, its marker highlighted, while the map around it is panned (#3039).
|
||||
*/
|
||||
export function InspectorHead({
|
||||
place,
|
||||
category,
|
||||
openNow,
|
||||
phone,
|
||||
email,
|
||||
rating,
|
||||
ratingCount,
|
||||
price,
|
||||
time,
|
||||
photoCredit,
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
editingName,
|
||||
nameInputRef,
|
||||
nameValue,
|
||||
setNameValue,
|
||||
commitNameEdit,
|
||||
handleNameKeyDown,
|
||||
startNameEdit,
|
||||
onUpdatePlace,
|
||||
onClose,
|
||||
}: InspectorHeadProps) {
|
||||
const { t, locale, language } = useTranslation();
|
||||
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm';
|
||||
const hasCoords = !!(place.lat && place.lng);
|
||||
const hasPills =
|
||||
openNow !== null ||
|
||||
!!category ||
|
||||
!!time ||
|
||||
rating != null ||
|
||||
!!price ||
|
||||
place.source === 'dawarich' ||
|
||||
!!phone ||
|
||||
!!email ||
|
||||
hasCoords;
|
||||
return (
|
||||
<header
|
||||
className={`flex-none px-4 ${collapsed ? 'py-2.5' : 'border-b border-edge-faint pb-3 pt-3.5'}`}
|
||||
style={{ background: category?.color ? tintOf(category.color) : NEUTRAL_TINT }}
|
||||
>
|
||||
<div className={`flex gap-3.5 ${collapsed ? 'items-center' : 'items-start'}`}>
|
||||
<div className={`flex flex-none flex-col items-center ${collapsed ? '' : 'w-[57px]'}`}>
|
||||
<div className={`rounded-full p-[2.5px] ${ring}`}>
|
||||
<PlacePhotoGallery
|
||||
place={place}
|
||||
language={language}
|
||||
label={t('places.openPhotos')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<PlaceAvatar place={place} category={category} size={collapsed ? 32 : 52} />
|
||||
</PlacePhotoGallery>
|
||||
</div>
|
||||
{!collapsed && photoCredit}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{editingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onBlur={commitNameEdit}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
aria-label={t('places.formName')}
|
||||
className="-mx-1.5 block w-[calc(100%+12px)] rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
onDoubleClick={startNameEdit}
|
||||
className={`m-0 font-bold leading-snug tracking-[-0.01em] text-content ${collapsed ? 'truncate' : 'break-words'} ${onUpdatePlace ? 'cursor-text' : ''}`}
|
||||
style={fs(17, 'subtitle')}
|
||||
>
|
||||
{place.name}
|
||||
</h2>
|
||||
)}
|
||||
{!collapsed && place.address && (
|
||||
// One line, whatever its length; the whole address is in the tooltip.
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<MapPin size={12} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<Tooltip label={place.address}>
|
||||
<span className="min-w-0 truncate">{place.address}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && hasPills && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{openNow !== null && (
|
||||
<SoftPill
|
||||
tone={openNow ? 'success' : 'danger'}
|
||||
icon={<span className="h-1.5 w-1.5 flex-none rounded-full bg-current" />}
|
||||
>
|
||||
{openNow ? t('inspector.opened') : t('inspector.closed')}
|
||||
</SoftPill>
|
||||
)}
|
||||
{category && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<span
|
||||
className="h-2 w-2 flex-none rounded-full"
|
||||
style={{ background: category.color || 'var(--text-faint)' }}
|
||||
/>
|
||||
{category.name}
|
||||
</span>
|
||||
)}
|
||||
{time && <TimePill>{time}</TimePill>}
|
||||
{rating != null && rating > 0 && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Star size={11} strokeWidth={2} fill="currentColor" className="flex-none text-warning" />
|
||||
{rating.toFixed(1)}
|
||||
{ratingCount ? (
|
||||
<span className="font-medium text-content-faint">({ratingCount.toLocaleString(locale)})</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
{price && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Banknote size={11} strokeWidth={2} className="flex-none text-success" />
|
||||
{price}
|
||||
</span>
|
||||
)}
|
||||
{/* Where the place came from, when it did not come from somebody typing it: a stay
|
||||
accepted out of their own recordings. The mark alone — the name beside it is
|
||||
already the place's name, and a word here would only repeat the tooltip. */}
|
||||
{place.source === 'dawarich' && (
|
||||
<Tooltip label={t('dawarich.place.fromDawarich')} placement="top">
|
||||
<span
|
||||
role="img"
|
||||
aria-label={t('dawarich.place.fromDawarich')}
|
||||
className={`${HEAD_PILL} px-1 py-[3px]`}
|
||||
>
|
||||
<span className="inline-flex overflow-hidden rounded-[4px]">
|
||||
<DawarichIcon size={13} />
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{phone && (
|
||||
<a href={`tel:${phone}`} className={`${HEAD_PILL} hover:text-content-secondary`} style={fs(10.5)}>
|
||||
<Phone size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
{phone}
|
||||
</a>
|
||||
)}
|
||||
{email && (
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className={`${HEAD_PILL} min-w-0 hover:text-content-secondary`}
|
||||
style={fs(10.5)}
|
||||
>
|
||||
<Mail size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<span className="truncate">{email}</span>
|
||||
</a>
|
||||
)}
|
||||
{hasCoords && (
|
||||
<span
|
||||
className="hidden flex-none items-center rounded-full border border-edge-faint px-2 py-[1px] font-geist tabular-nums text-content-faint sm:inline-flex"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && <AssistantAskButton place={place} />}
|
||||
{/* Folding is not closing: the place stays selected, and only the close deselects it. */}
|
||||
{onToggleCollapsed && (
|
||||
<Tooltip label={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? t('common.expand') : t('common.collapse')}
|
||||
className={ROUND_BUTTON}
|
||||
>
|
||||
{collapsed ? <ChevronUp size={15} strokeWidth={2.2} /> : <ChevronDown size={15} strokeWidth={2.2} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button type="button" onClick={onClose} aria-label={t('common.close')} className={ROUND_BUTTON}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -547,7 +547,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
leftWidth={isMobile ? 0 : leftPanelPx}
|
||||
rightWidth={isMobile ? 0 : rightPanelPx}
|
||||
clearMapPill cardRef={pageState.inspectorCard.measure}
|
||||
clearMapPill collapsible cardRef={pageState.inspectorCard.measure}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ Click a place in the places column, in a day, or on the map, and its inspector o
|
||||
|
||||

|
||||
|
||||
- **The head band** shows the photo (a click opens it in a gallery, see [The photo gallery](#the-photo-gallery)), with a green ring while the place is open and a red one while it is closed, the name (double-click it to rename the place), the address on one line with the full address in a tooltip, and pills: **Open** or **Closed**, the category, the time of the visit, the Google rating, the phone number to call, the e-mail address, and the coordinates. **X** closes the inspector.
|
||||
- **The head band** shows the photo (a click opens it in a gallery, see [The photo gallery](#the-photo-gallery)), with a green ring while the place is open and a red one while it is closed, the name (double-click it to rename the place), the address on one line with the full address in a tooltip, and pills: **Open** or **Closed**, the category, the time of the visit, the Google rating, the phone number to call, the e-mail address, and the coordinates. The chevron (**Collapse**) folds the inspector to a slim bar with the photo and the name, so the place stays selected and its marker highlighted while you pan the map; **Expand** unfolds it again, and the next place you pick opens in full. **X** closes the inspector and deselects the place.
|
||||
- **Rating**: every member's vote, see [Rating a place](#rating-a-place).
|
||||
- **Description**, **Notes** and **Notes for this day**, rendered as Markdown.
|
||||
- **Bookings** pinned to this stop, as small cards with the status, date, time and booking code. For a hotel, the stays booked there are listed too (see [Accommodations](Accommodations)). Click a card (*Open booking*) to open the booking's detail. Next to them, **Participants** says who joins this stop.
|
||||
|
||||
Reference in New Issue
Block a user