@bearmenu/ui 0.8.12 → 0.8.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-66MAPLXS.js → chunk-2ADCZXZJ.js} +1 -1
- package/dist/{chunk-2DEIXTBN.js → chunk-2ZXIXQIU.js} +1 -1
- package/dist/{chunk-YOKHBAIH.js → chunk-4OF4T4AI.js} +12 -11
- package/dist/{chunk-MZL7CGCL.js → chunk-4XDJXMLF.js} +2 -2
- package/dist/{chunk-YYGB4G4Q.js → chunk-CZK2MRS2.js} +1 -1
- package/dist/chunk-DJD3EMPK.js +126 -0
- package/dist/chunk-EZG2NELX.js +5 -0
- package/dist/{chunk-VAXOQS7W.js → chunk-F76NCVVR.js} +1 -1
- package/dist/{chunk-VG3PND3D.js → chunk-FMTJGO76.js} +1 -1
- package/dist/{chunk-UXWAOISG.js → chunk-FVSKGCK3.js} +2 -2
- package/dist/{chunk-2XCZJWTV.js → chunk-GNCWJWFE.js} +1 -1
- package/dist/{chunk-X3WMRKOW.js → chunk-HDR7V6IZ.js} +1 -1
- package/dist/{chunk-HSIYYA2E.js → chunk-HQU6QVAN.js} +5 -5
- package/dist/chunk-I5V446BX.js +27 -0
- package/dist/{chunk-WCTHZKU7.js → chunk-ICOU5QHT.js} +1 -1
- package/dist/{chunk-G7IFL4Z4.js → chunk-L3VEMP5A.js} +57 -9
- package/dist/chunk-LQWGZIYA.js +5 -0
- package/dist/{chunk-XNA2INVQ.js → chunk-MCOHLWRS.js} +2 -2
- package/dist/{chunk-3GWWZKX7.js → chunk-MMUBH76A.js} +21 -1
- package/dist/{chunk-ZDAHKI3A.js → chunk-MWB7S54O.js} +1 -1
- package/dist/{chunk-D5QHEHXJ.js → chunk-NMEVJT5Q.js} +1 -1
- package/dist/{chunk-3TNPSN4D.js → chunk-NSNEANOQ.js} +2 -2
- package/dist/{chunk-WSRWFA6K.js → chunk-NWZGSLPU.js} +1 -1
- package/dist/{chunk-LEAYBICI.js → chunk-OIV7B44M.js} +1 -1
- package/dist/{chunk-2CXU5WZO.js → chunk-P43B3ONY.js} +1 -1
- package/dist/{chunk-FRPPIXMY.js → chunk-PDTPO6NG.js} +1 -1
- package/dist/{chunk-6XMYEZVR.js → chunk-PX6RICS5.js} +1 -1
- package/dist/{chunk-6TEEY5YA.js → chunk-S33K4RC5.js} +1 -1
- package/dist/{chunk-3VMDCCCX.js → chunk-SC2VUTUI.js} +1 -1
- package/dist/{chunk-24RFI4RQ.js → chunk-SXQZ7DLH.js} +4 -4
- package/dist/{chunk-UDTG3RSE.js → chunk-T6S2ZBV6.js} +2 -2
- package/dist/{chunk-DTXNDLSE.js → chunk-WYTJR2YU.js} +16 -1
- package/dist/{chunk-QXHMPJ35.js → chunk-XBBEX4SF.js} +1 -1
- package/dist/chunk-XCB3F6SF.js +16 -0
- package/dist/{chunk-7BWTYWM3.js → chunk-ZLMRVBD7.js} +1 -1
- package/dist/{chunk-7A42J73Q.js → chunk-ZLPWEHZO.js} +1 -1
- package/dist/components/accordion.js +1 -1
- package/dist/components/alert-dialog.js +2 -2
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/alert.js +2 -2
- package/dist/components/aspect-ratio.js +2 -2
- package/dist/components/avatar.js +1 -1
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +2 -2
- package/dist/components/breadcrumb.js +1 -1
- package/dist/components/button-group.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/button.js +2 -2
- package/dist/components/calendar.js +2 -2
- package/dist/components/card.d.ts +2 -2
- package/dist/components/card.js +3 -3
- package/dist/components/carousel.js +2 -2
- package/dist/components/category-deck.js +2 -2
- package/dist/components/checkbox.js +2 -2
- package/dist/components/circular-progress.js +1 -1
- package/dist/components/collapsible.js +1 -1
- package/dist/components/combobox.js +5 -5
- package/dist/components/command.d.ts +15 -15
- package/dist/components/command.js +3 -3
- package/dist/components/concept-card.js +2 -2
- package/dist/components/concept-compare-rail.js +3 -3
- package/dist/components/concept-compare.d.ts +2 -1
- package/dist/components/concept-compare.js +5 -5
- package/dist/components/concept-convergence.js +4 -4
- package/dist/components/convergence.js +3 -3
- package/dist/components/coverflow.js +1 -1
- package/dist/components/currency-input.js +2 -2
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/date-input.js +2 -2
- package/dist/components/day-rail-chip.js +2 -2
- package/dist/components/description-list.js +1 -1
- package/dist/components/dialog.js +2 -2
- package/dist/components/doorway-tiles.d.ts +57 -0
- package/dist/components/doorway-tiles.js +86 -0
- package/dist/components/drawer.js +1 -1
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/dropdown-menu.js +1 -1
- package/dist/components/empty-state.js +3 -3
- package/dist/components/event-card.js +4 -4
- package/dist/components/event-compact-row.js +4 -4
- package/dist/components/event-day-header.js +2 -2
- package/dist/components/event-day-separator.js +3 -3
- package/dist/components/event-detail-panel.js +4 -4
- package/dist/components/event-hub-hero.js +2 -2
- package/dist/components/event-index-row.js +4 -4
- package/dist/components/event-list-row-wide.js +5 -5
- package/dist/components/event-list-row.js +5 -5
- package/dist/components/event-poster-fallback.js +1 -1
- package/dist/components/event-types.js +1 -1
- package/dist/components/events-empty-state.js +1 -1
- package/dist/components/explainer-steps.js +1 -1
- package/dist/components/facet-group.js +2 -2
- package/dist/components/featured-rail-item.js +2 -2
- package/dist/components/field.js +2 -2
- package/dist/components/filter-category-row.js +3 -3
- package/dist/components/form.js +2 -2
- package/dist/components/glass-ground.js +2 -2
- package/dist/components/grade-atom.d.ts +3 -0
- package/dist/components/grade-atom.js +4 -0
- package/dist/components/haptic-button.js +2 -2
- package/dist/components/hour-band.js +4 -4
- package/dist/components/hour-group.js +4 -4
- package/dist/components/hub-surface.js +1 -1
- package/dist/components/hue-chip.js +1 -1
- package/dist/components/icon.js +1 -1
- package/dist/components/input-group.js +1 -1
- package/dist/components/input-otp.js +1 -1
- package/dist/components/input.js +2 -2
- package/dist/components/item.d.ts +2 -2
- package/dist/components/item.js +1 -1
- package/dist/components/kbd.js +1 -1
- package/dist/components/kitchen-dossier.d.ts +2 -1
- package/dist/components/kitchen-dossier.js +3 -3
- package/dist/components/label.js +2 -2
- package/dist/components/lazy-markdown.js +1 -1
- package/dist/components/link-button.js +2 -2
- package/dist/components/markdown-renderer.js +1 -1
- package/dist/components/menu-dead-end.d.ts +2 -1
- package/dist/components/menu-dead-end.js +1 -1
- package/dist/components/menu-family-block.d.ts +2 -1
- package/dist/components/menu-family-block.js +14 -8
- package/dist/components/menu-family-card.d.ts +2 -1
- package/dist/components/menu-family-card.js +1 -1
- package/dist/components/menu-field.js +1 -1
- package/dist/components/menu-filter-chips.d.ts +2 -1
- package/dist/components/menu-filter-chips.js +3 -3
- package/dist/components/menu-group-rows.d.ts +2 -1
- package/dist/components/menu-group-rows.js +2 -2
- package/dist/components/menu-hub-hero.js +1 -1
- package/dist/components/menu-index-header.d.ts +2 -1
- package/dist/components/menu-index-header.js +2 -2
- package/dist/components/menu-index-list.d.ts +9 -8
- package/dist/components/menu-index-list.js +4 -4
- package/dist/components/menu-intent-hero.d.ts +3 -2
- package/dist/components/menu-intent-hero.js +1 -1
- package/dist/components/menu-item.d.ts +2 -1
- package/dist/components/menu-item.js +4 -4
- package/dist/components/menu-kitchen-list.d.ts +2 -1
- package/dist/components/menu-kitchen-list.js +4 -4
- package/dist/components/menu-mocks.d.ts +16 -5
- package/dist/components/menu-mocks.js +23 -2
- package/dist/components/menu-types.d.ts +21 -34
- package/dist/components/motion-icon-actions.d.ts +27 -0
- package/dist/components/motion-icon-actions.js +336 -0
- package/dist/components/motion-icon-controls.js +3 -3
- package/dist/components/motion-icon-set.d.ts +13 -1
- package/dist/components/motion-icon-set.js +197 -6
- package/dist/components/motion-icon.d.ts +15 -1
- package/dist/components/motion-icon.js +2 -2
- package/dist/components/multi-select-combobox.js +7 -7
- package/dist/components/multi-select-menu.js +2 -2
- package/dist/components/native-select.js +1 -1
- package/dist/components/open-now-list.d.ts +2 -1
- package/dist/components/open-now-list.js +2 -2
- package/dist/components/pagination.js +2 -2
- package/dist/components/phone-frame.js +1 -1
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-about-tab.d.ts +2 -1
- package/dist/components/place-about-tab.js +6 -6
- package/dist/components/place-card.d.ts +94 -12
- package/dist/components/place-card.js +203 -128
- package/dist/components/place-details-mobile.d.ts +2 -1
- package/dist/components/place-details-mobile.js +7 -7
- package/dist/components/place-schedule-week.d.ts +2 -1
- package/dist/components/place-schedule-week.js +3 -3
- package/dist/components/place-types.d.ts +3 -265
- package/dist/components/popover.js +2 -2
- package/dist/components/poster-stack.js +1 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +22 -6
- package/dist/components/poster-wall.js +197 -12
- package/dist/components/progress.js +1 -1
- package/dist/components/qa-block.d.ts +2 -1
- package/dist/components/qa-block.js +1 -1
- package/dist/components/quiet-stretch.js +1 -1
- package/dist/components/radio-group.js +1 -1
- package/dist/components/rating.js +1 -1
- package/dist/components/scroll-area.js +1 -1
- package/dist/components/scroll-fade.js +2 -2
- package/dist/components/search-bar.js +1 -1
- package/dist/components/searchable-select.js +5 -5
- package/dist/components/section-header.js +2 -2
- package/dist/components/segmented-nav.d.ts +24 -4
- package/dist/components/segmented-nav.js +11 -2
- package/dist/components/select.js +1 -1
- package/dist/components/selection-bar.js +1 -1
- package/dist/components/separator.js +2 -2
- package/dist/components/sheet.js +1 -1
- package/dist/components/signature-chip.js +3 -3
- package/dist/components/skeleton-card.js +3 -3
- package/dist/components/skeleton.js +2 -2
- package/dist/components/slider.js +1 -1
- package/dist/components/sliding-panels.js +2 -2
- package/dist/components/snap-rail.d.ts +0 -5
- package/dist/components/snap-rail.js +2 -2
- package/dist/components/sonner.js +1 -1
- package/dist/components/sort-option-list.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/spinner.js +1 -1
- package/dist/components/stat-banner.js +1 -1
- package/dist/components/stepper.js +1 -1
- package/dist/components/sticky-container.js +1 -1
- package/dist/components/story.js +1 -1
- package/dist/components/switch.js +1 -1
- package/dist/components/table.js +1 -1
- package/dist/components/tabs.js +2 -2
- package/dist/components/tag-group.js +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +2 -2
- package/dist/components/text.d.ts +3 -3
- package/dist/components/text.js +2 -2
- package/dist/components/textarea.js +1 -1
- package/dist/components/timeline-band.js +4 -4
- package/dist/components/timeline-playhead.js +4 -4
- package/dist/components/toggle-group.js +2 -2
- package/dist/components/toggle.js +2 -2
- package/dist/components/tooltip.js +1 -1
- package/dist/components/top-comparison.d.ts +2 -1
- package/dist/components/top-comparison.js +1 -1
- package/dist/components/top-cross-link.d.ts +2 -1
- package/dist/components/top-cross-link.js +1 -1
- package/dist/components/top-ranking-entry.d.ts +2 -1
- package/dist/components/top-ranking-entry.js +6 -6
- package/dist/components/top-ranking-hero.d.ts +2 -1
- package/dist/components/top-ranking-hero.js +1 -1
- package/dist/components/top-ranking-legend.js +1 -1
- package/dist/components/top-types.d.ts +3 -1
- package/dist/components/use-mobile.js +1 -1
- package/dist/components/venue-events-block.js +5 -5
- package/dist/components/venue-grade-dimensions.d.ts +2 -1
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/venue-quotes.d.ts +2 -1
- package/dist/components/venue-quotes.js +1 -1
- package/dist/components/venue-wall.d.ts +2 -1
- package/dist/components/venue-wall.js +1 -1
- package/dist/components/weekday-theme-banner.js +1 -1
- package/dist/components/weekend-panel.js +4 -4
- package/dist/hooks/use-drag-swipe.js +1 -1
- package/dist/hooks/use-haptic.js +1 -1
- package/dist/hooks/use-media-query.js +1 -1
- package/dist/hooks/use-mobile.js +1 -1
- package/dist/hooks/use-mounted-theme.js +1 -1
- package/dist/hooks/use-sticky.js +1 -1
- package/dist/lib/card-hover.js +2 -18
- package/dist/lib/grade.d.ts +54 -0
- package/dist/lib/grade.js +2 -0
- package/dist/lib/icon-depth.d.ts +35 -0
- package/dist/lib/icon-depth.js +2 -0
- package/dist/lib/icon-registry.js +1 -1
- package/dist/lib/motion.js +2 -2
- package/dist/lib/overlay-pill.d.ts +30 -0
- package/dist/lib/overlay-pill.js +2 -0
- package/dist/lib/utils.js +1 -1
- package/dist/place-types-BHdeMMiG.d.ts +317 -0
- package/package.json +1 -1
- package/src/components/colour-dictionary.stories.tsx +268 -82
- package/src/components/doorway-tiles.stories.tsx +83 -0
- package/src/components/doorway-tiles.test.tsx +51 -0
- package/src/components/doorway-tiles.tsx +225 -0
- package/src/components/grade-atom.stories.tsx +81 -0
- package/src/components/grade-atom.tsx +256 -0
- package/src/components/hue-chip.stories.tsx +2 -2
- package/src/components/menu-family-block.stories.tsx +29 -0
- package/src/components/menu-family-block.test.tsx +51 -0
- package/src/components/menu-family-block.tsx +20 -4
- package/src/components/menu-index-list.tsx +7 -7
- package/src/components/menu-intent-hero.tsx +1 -1
- package/src/components/menu-kitchen-list.tsx +4 -1
- package/src/components/menu-mocks.ts +39 -3
- package/src/components/menu-types.ts +17 -33
- package/src/components/motion-icon-actions.test.tsx +115 -0
- package/src/components/motion-icon-actions.tsx +580 -0
- package/src/components/motion-icon-controls.test.tsx +1 -0
- package/src/components/motion-icon-controls.tsx +18 -13
- package/src/components/motion-icon-set.stories.tsx +62 -3
- package/src/components/motion-icon-set.tsx +349 -2
- package/src/components/motion-icon.test.tsx +180 -1
- package/src/components/motion-icon.tsx +97 -8
- package/src/components/place-card.stories.tsx +66 -76
- package/src/components/place-card.test.tsx +188 -100
- package/src/components/place-card.tsx +356 -148
- package/src/components/place-types.ts +17 -4
- package/src/components/poster-wall.stories.tsx +84 -0
- package/src/components/poster-wall.test.tsx +94 -3
- package/src/components/poster-wall.tsx +257 -9
- package/src/components/segmented-nav.stories.tsx +72 -1
- package/src/components/segmented-nav.test.tsx +96 -0
- package/src/components/segmented-nav.tsx +36 -4
- package/src/components/snap-rail.tsx +19 -0
- package/src/globals.css +26 -0
- package/src/lib/grade.test.ts +82 -0
- package/src/lib/grade.ts +76 -0
- package/src/lib/icon-depth.ts +35 -0
- package/src/lib/overlay-pill.ts +29 -0
- package/dist/components/menu-family-tiles.d.ts +0 -19
- package/dist/components/menu-family-tiles.js +0 -57
- package/src/components/menu-family-tiles.stories.tsx +0 -54
- package/src/components/menu-family-tiles.test.tsx +0 -30
- package/src/components/menu-family-tiles.tsx +0 -132
|
@@ -1,187 +1,395 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
memo,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
type KeyboardEvent,
|
|
9
|
+
type MouseEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { MapPin, Star, UtensilsCrossed } from "lucide-react";
|
|
13
|
+
import { motion } from "framer-motion";
|
|
5
14
|
import { cn } from "../lib/utils";
|
|
6
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
HOVER_IMAGE_MOTION,
|
|
17
|
+
HOVER_IMAGE_WRAPPER,
|
|
18
|
+
HOVER_LIFT_GRID_GROUP,
|
|
19
|
+
HOVER_MOTION,
|
|
20
|
+
} from "../lib/card-hover";
|
|
21
|
+
import { showsGradeAtom } from "../lib/grade";
|
|
22
|
+
import { NEUTRAL_ICON_FILTER } from "../lib/icon-depth";
|
|
23
|
+
import { springSnappy } from "../lib/motion";
|
|
24
|
+
import { OVERLAY_PILL } from "../lib/overlay-pill";
|
|
25
|
+
import { Badge } from "./badge";
|
|
26
|
+
import { GradeAtom } from "./grade-atom";
|
|
27
|
+
import { Separator } from "./separator";
|
|
28
|
+
import { Skeleton } from "./skeleton";
|
|
7
29
|
import type { PlaceCardLabels, PlacePreview } from "./place-types";
|
|
8
30
|
|
|
9
|
-
|
|
31
|
+
/**
|
|
32
|
+
* PlaceCard — the 4:3 cover card every place rail and grid reuses.
|
|
33
|
+
*
|
|
34
|
+
* RENDERS AN ANCHOR WHENEVER `href` IS GIVEN. Pass `href` AND `onOpen`: the
|
|
35
|
+
* anchor is real for crawlers, middle-click and "open in new tab", while a
|
|
36
|
+
* plain left-click is intercepted to open the in-page drawer. Without `href`
|
|
37
|
+
* the root is a `role="button"` (Enter / Space open too).
|
|
38
|
+
*
|
|
39
|
+
* Like `event-card.tsx`, this takes a DS-owned view model, injected labels and
|
|
40
|
+
* injected framework pieces — no app schema, no app hooks, no i18n. The link
|
|
41
|
+
* and the image are both injectable: a plain `<a>` / `<img>` by default, and
|
|
42
|
+
* `next/link` / `next/image` in a Next app, so the consumer keeps its srcset,
|
|
43
|
+
* its priority preload and its image loader on the card that is its LCP.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type PlaceCardLinkProps = {
|
|
47
|
+
href: string;
|
|
48
|
+
className?: string;
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
onClick?: (mouseEvent: MouseEvent<HTMLAnchorElement>) => void;
|
|
51
|
+
"aria-label"?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type PlaceCardImageProps = {
|
|
55
|
+
src: string;
|
|
56
|
+
alt: string;
|
|
57
|
+
/** Responsive `sizes` hint; the default `<img>` ignores it. */
|
|
58
|
+
sizes: string;
|
|
59
|
+
/** The LCP candidate — preload / `loading="eager"`. */
|
|
60
|
+
priority: boolean;
|
|
61
|
+
/** Positions the image to fill the 4:3 box (`absolute inset-0 h-full w-full object-cover` + hover motion). */
|
|
62
|
+
className: string;
|
|
63
|
+
onError: () => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Both defaults forward refs: the root is `motion.create(LinkComponent)`, and
|
|
68
|
+
* framer-motion needs the ref to reach the DOM node. An injected link (e.g.
|
|
69
|
+
* `next/link`) must forward its ref too.
|
|
70
|
+
*/
|
|
71
|
+
const DefaultLink = forwardRef<HTMLAnchorElement, PlaceCardLinkProps>(function DefaultLink(
|
|
72
|
+
{ href, ...props },
|
|
73
|
+
ref
|
|
74
|
+
) {
|
|
75
|
+
return <a ref={ref} href={href} {...props} />;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function DefaultImage({ src, alt, priority, className, onError }: PlaceCardImageProps) {
|
|
79
|
+
return (
|
|
80
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
81
|
+
<img
|
|
82
|
+
src={src}
|
|
83
|
+
alt={alt}
|
|
84
|
+
loading={priority ? "eager" : "lazy"}
|
|
85
|
+
decoding="async"
|
|
86
|
+
className={className}
|
|
87
|
+
onError={onError}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type PlaceCardProps = {
|
|
10
93
|
place: PlacePreview;
|
|
11
94
|
labels: PlaceCardLabels;
|
|
12
|
-
/**
|
|
13
|
-
locale?: string;
|
|
14
|
-
/** Optional click handler. When omitted the card renders as a non-interactive div. */
|
|
15
|
-
onClick?: (place: PlacePreview) => void;
|
|
16
|
-
className?: string;
|
|
17
|
-
/** Forwarded to the thumbnail's `loading` attribute (`"eager"` when true). */
|
|
95
|
+
/** Marks this as the LCP candidate — forwarded to `imageComponent`. */
|
|
18
96
|
priority?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Responsive image `sizes` hint. Defaults to a viewport-width formula for
|
|
99
|
+
* grid layouts; rail callers (220px-wide carousels) should pass `"220px"` so
|
|
100
|
+
* the browser doesn't download a viewport-width variant for a tiny card.
|
|
101
|
+
*/
|
|
102
|
+
imageSizes?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Real detail-page URL. When set the card root renders as a crawlable
|
|
105
|
+
* anchor; a plain left-click is still intercepted to call `onOpen`, while
|
|
106
|
+
* modified / middle-clicks navigate. When omitted the root is a
|
|
107
|
+
* `role="button"`.
|
|
108
|
+
*/
|
|
109
|
+
href?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Defaults to a plain `<a>`. Pass a ref-forwarding `next/link` adapter in a
|
|
112
|
+
* Next app. MUST be module-scoped: the root is `motion.create(linkComponent)`
|
|
113
|
+
* keyed on its identity, so an inline component remounts the card (and
|
|
114
|
+
* re-requests its cover) on every render.
|
|
115
|
+
*/
|
|
116
|
+
linkComponent?: React.ComponentType<PlaceCardLinkProps>;
|
|
117
|
+
/** Defaults to a plain `<img>`. Pass a `next/image` adapter in a Next app. Module-scoped, like `linkComponent`. */
|
|
118
|
+
imageComponent?: React.ComponentType<PlaceCardImageProps>;
|
|
119
|
+
/** Opens the in-page drawer / sheet. A left-click on the anchor is prevented in favour of this. */
|
|
120
|
+
onOpen?: (place: PlacePreview) => void;
|
|
121
|
+
/**
|
|
122
|
+
* Optional non-interactive context slot rendered below the place's own
|
|
123
|
+
* info rows, e.g. concept page: "Served as X / 45 RON / 300g". MUST be
|
|
124
|
+
* display-only — no buttons or links — since the card itself is the click
|
|
125
|
+
* target.
|
|
126
|
+
*/
|
|
127
|
+
footer?: ReactNode;
|
|
128
|
+
/**
|
|
129
|
+
* Rendered over the photo's top-left corner as a SIBLING of the interactive
|
|
130
|
+
* root, not a child — so it MAY be a button or link (e.g. a trending marker
|
|
131
|
+
* that opens a popover) without nesting interactives inside the card's
|
|
132
|
+
* anchor / `role="button"`. It lifts with the card via the shared
|
|
133
|
+
* `group/card` wrapper. The caller decides when it renders (fe gates its
|
|
134
|
+
* trending badge on the API's `trending` flag).
|
|
135
|
+
*/
|
|
136
|
+
photoOverlay?: ReactNode;
|
|
137
|
+
className?: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function formatDistance(meters: number): string {
|
|
141
|
+
if (meters < 1000) return `${Math.round(meters)} m`;
|
|
142
|
+
return `${(meters / 1000).toFixed(1)} km`;
|
|
19
143
|
}
|
|
20
144
|
|
|
21
|
-
|
|
22
|
-
|
|
145
|
+
/** Title-row headline metric: the platform grade when the place has one,
|
|
146
|
+
* otherwise the Google star rating (graceful fallback for ungraded places). */
|
|
147
|
+
function HeadlineRating({ place, labels }: { place: PlacePreview; labels: PlaceCardLabels }) {
|
|
148
|
+
const grade = place.reviews_digest?.grade;
|
|
149
|
+
if (showsGradeAtom(grade, "xs")) return <GradeAtom grade={grade} labels={labels.grade} size="xs" />;
|
|
150
|
+
const rating = place.rating?.rating;
|
|
151
|
+
if (rating === undefined) return null;
|
|
152
|
+
return (
|
|
153
|
+
<div className="flex shrink-0 items-center gap-1 text-xs font-medium text-foreground">
|
|
154
|
+
<Star className="h-3 w-3 fill-current" aria-hidden />
|
|
155
|
+
<span className="tabular-nums">{rating.toFixed(1)}</span>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
23
158
|
}
|
|
24
159
|
|
|
25
|
-
function
|
|
26
|
-
|
|
160
|
+
function PriceLevel({ level, label }: { level: number; label: string }) {
|
|
161
|
+
// The schema documents 1–4 but types an int64; an out-of-range value must
|
|
162
|
+
// not turn into a `repeat(-1)` RangeError that unmounts the whole rail.
|
|
163
|
+
const filled = Math.min(Math.max(Math.round(level), 1), 4);
|
|
164
|
+
return (
|
|
165
|
+
<span className="text-muted-foreground" aria-label={label}>
|
|
166
|
+
{"$".repeat(filled)}
|
|
167
|
+
<span className="opacity-30">{"$".repeat(4 - filled)}</span>
|
|
168
|
+
</span>
|
|
169
|
+
);
|
|
27
170
|
}
|
|
28
171
|
|
|
172
|
+
const STATIC_ROOT_CLASSES = `group relative flex w-full select-none flex-col ${HOVER_MOTION} ${HOVER_LIFT_GRID_GROUP}`;
|
|
173
|
+
const ROOT_CLASSES = `${STATIC_ROOT_CLASSES} cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:rounded-xl`;
|
|
174
|
+
|
|
175
|
+
const DEFAULT_SIZES = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";
|
|
176
|
+
|
|
29
177
|
export const PlaceCard = memo(function PlaceCard({
|
|
30
178
|
place,
|
|
31
179
|
labels,
|
|
32
|
-
|
|
180
|
+
priority = false,
|
|
181
|
+
imageSizes = DEFAULT_SIZES,
|
|
182
|
+
href,
|
|
183
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
184
|
+
imageComponent: ImageComponent = DefaultImage,
|
|
185
|
+
onOpen,
|
|
186
|
+
footer,
|
|
187
|
+
photoOverlay,
|
|
33
188
|
className,
|
|
34
|
-
priority,
|
|
35
189
|
}: PlaceCardProps) {
|
|
36
190
|
const [imgError, setImgError] = useState(false);
|
|
191
|
+
// Module-scoped in spirit: memoised on the component identity so the
|
|
192
|
+
// motion-wrapped anchor keeps a stable type across renders (a fresh
|
|
193
|
+
// `motion.create` per render would remount the whole card).
|
|
194
|
+
const MotionLink = useMemo(() => motion.create(LinkComponent), [LinkComponent]);
|
|
37
195
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
onClick &&
|
|
49
|
-
"cursor-pointer [@media(hover:hover)]:hover:-translate-y-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
50
|
-
className
|
|
51
|
-
);
|
|
196
|
+
const coverUrl = imgError ? undefined : place.cover_url;
|
|
197
|
+
const cuisine = place.cuisines?.[0];
|
|
198
|
+
const distance = place.location?.distance?.meters;
|
|
199
|
+
// Neighbourhood is the SSR/primary location signal (API-backed, present
|
|
200
|
+
// without geolocation). "Distance from you" is a client-only enhancement
|
|
201
|
+
// that supersedes it once geolocation is granted.
|
|
202
|
+
const neighbourhood = place.areas?.[0]?.name;
|
|
203
|
+
const priceLevel = place.price_dishes?.price_score ?? place.price_drinks?.price_score;
|
|
204
|
+
const showPrice = priceLevel !== undefined && priceLevel > 0;
|
|
205
|
+
const ariaLabel = labels.viewDetails(place.name);
|
|
52
206
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
207
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
208
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
onOpen?.(place);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Crawlable-anchor path: a plain left-click opens the drawer (progressive
|
|
215
|
+
// enhancement); modified / middle-clicks fall through to real navigation.
|
|
216
|
+
const handleAnchorClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
|
217
|
+
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
onOpen(place);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const body = (
|
|
223
|
+
<>
|
|
224
|
+
<div
|
|
225
|
+
className={cn(
|
|
226
|
+
"relative aspect-[4/3] w-full overflow-hidden rounded-xl",
|
|
227
|
+
HOVER_IMAGE_WRAPPER
|
|
228
|
+
)}
|
|
229
|
+
>
|
|
230
|
+
{coverUrl ? (
|
|
231
|
+
<ImageComponent
|
|
232
|
+
src={coverUrl}
|
|
233
|
+
alt={place.name}
|
|
234
|
+
sizes={imageSizes}
|
|
235
|
+
priority={priority}
|
|
236
|
+
className={`absolute inset-0 h-full w-full object-cover ${HOVER_IMAGE_MOTION} motion-image-enter`}
|
|
237
|
+
onError={() => setImgError(true)}
|
|
238
|
+
/>
|
|
239
|
+
) : (
|
|
240
|
+
/* The neutral grey placeholder every card type shares. The glyph
|
|
241
|
+
sits at `--accent` (the brand brown) rather than `text-muted-
|
|
242
|
+
foreground`, so it reads as intentional instead of as a washed-out
|
|
243
|
+
failed image. */
|
|
244
|
+
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-muted to-border">
|
|
72
245
|
<UtensilsCrossed
|
|
73
|
-
className="
|
|
74
|
-
|
|
246
|
+
className="h-10 w-10 text-[color:var(--accent)]"
|
|
247
|
+
style={{ filter: NEUTRAL_ICON_FILTER }}
|
|
248
|
+
aria-hidden
|
|
75
249
|
/>
|
|
76
|
-
|
|
77
|
-
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
78
252
|
|
|
79
|
-
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
</div>
|
|
253
|
+
{place.open_now === true && (
|
|
254
|
+
<span
|
|
255
|
+
className="absolute right-2.5 top-2.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-white/70"
|
|
256
|
+
aria-hidden
|
|
257
|
+
/>
|
|
258
|
+
)}
|
|
259
|
+
{place.open_now === false && (
|
|
260
|
+
/* `Badge variant="overlay"` + the shared cover geometry: this pill and
|
|
261
|
+
the caller's `photoOverlay` sit on the same photo and must match. */
|
|
262
|
+
<Badge variant="overlay" className={cn(OVERLAY_PILL, "absolute right-2 top-2")}>
|
|
263
|
+
{labels.closed}
|
|
264
|
+
</Badge>
|
|
265
|
+
)}
|
|
93
266
|
</div>
|
|
94
267
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
{place.price_dishes.price_range_low}-{place.price_dishes.price_range_high}{" "}
|
|
105
|
-
{labels.currency}
|
|
106
|
-
</span>
|
|
107
|
-
</div>
|
|
108
|
-
)}
|
|
109
|
-
{place.price_drinks?.price_range_low != null &&
|
|
110
|
-
place.price_drinks?.price_range_high != null && (
|
|
111
|
-
<div className="flex items-center gap-1 text-primary">
|
|
112
|
-
<Wine className="w-4 h-4" aria-hidden="true" />
|
|
113
|
-
<span>
|
|
114
|
-
{place.price_drinks.price_range_low}-{place.price_drinks.price_range_high}{" "}
|
|
115
|
-
{labels.currency}
|
|
116
|
-
</span>
|
|
117
|
-
</div>
|
|
118
|
-
)}
|
|
119
|
-
</div>
|
|
268
|
+
<div className="mt-2.5 flex flex-col">
|
|
269
|
+
{/* Title row: name (left) + rating (right) — title dominates.
|
|
270
|
+
`min-w-0` on the name is what makes `line-clamp-1` actually clamp:
|
|
271
|
+
without it the flex item refuses to shrink below its content, so a
|
|
272
|
+
wide right-hand pill pushed the row past the card edge instead of
|
|
273
|
+
truncating the name. */}
|
|
274
|
+
<div className="mb-1 flex items-baseline justify-between gap-2">
|
|
275
|
+
<p className="min-w-0 line-clamp-1 text-sm font-semibold text-foreground">{place.name}</p>
|
|
276
|
+
<HeadlineRating place={place} labels={labels} />
|
|
120
277
|
</div>
|
|
121
|
-
)}
|
|
122
278
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
<span className="text-xs">{formatDistance(distanceMeters)}</span>
|
|
131
|
-
</div>
|
|
132
|
-
<span>·</span>
|
|
133
|
-
</>
|
|
279
|
+
<div className="flex flex-col gap-0.5">
|
|
280
|
+
{(cuisine || showPrice) && (
|
|
281
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
282
|
+
{cuisine && <span className="capitalize">{cuisine}</span>}
|
|
283
|
+
{cuisine && showPrice && <span className="text-border">·</span>}
|
|
284
|
+
{showPrice && <PriceLevel level={priceLevel} label={labels.priceLevel(priceLevel)} />}
|
|
285
|
+
</div>
|
|
134
286
|
)}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
{place.open_now ? labels.open : labels.closed}
|
|
150
|
-
</span>
|
|
151
|
-
{scheduleHour != null && (
|
|
152
|
-
<span
|
|
153
|
-
className={cn(
|
|
154
|
-
"text-xs",
|
|
155
|
-
place.open_now ? "text-green-500" : "text-muted-foreground"
|
|
156
|
-
)}
|
|
157
|
-
>
|
|
158
|
-
{`· ${scheduleLabel} ${String(scheduleHour).padStart(2, "0")}:00`}
|
|
159
|
-
</span>
|
|
160
|
-
)}
|
|
161
|
-
</div>
|
|
287
|
+
|
|
288
|
+
{/* Location signal: distance when geolocation is granted (client
|
|
289
|
+
enhancement), otherwise the neighbourhood (SSR/primary). */}
|
|
290
|
+
{distance !== undefined ? (
|
|
291
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
292
|
+
<MapPin className="h-3 w-3 shrink-0" aria-hidden />
|
|
293
|
+
<span>{formatDistance(distance)}</span>
|
|
294
|
+
</div>
|
|
295
|
+
) : neighbourhood ? (
|
|
296
|
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
297
|
+
<MapPin className="h-3 w-3 shrink-0" aria-hidden />
|
|
298
|
+
<span className="line-clamp-1">{neighbourhood}</span>
|
|
299
|
+
</div>
|
|
300
|
+
) : null}
|
|
162
301
|
</div>
|
|
302
|
+
|
|
303
|
+
{footer && (
|
|
304
|
+
<>
|
|
305
|
+
<Separator className="my-2" />
|
|
306
|
+
<div>{footer}</div>
|
|
307
|
+
</>
|
|
308
|
+
)}
|
|
163
309
|
</div>
|
|
310
|
+
</>
|
|
311
|
+
);
|
|
164
312
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
313
|
+
// The overlay renders as a SIBLING of the interactive card (not a child) so
|
|
314
|
+
// an interactive marker never nests inside the card's anchor / role="button"
|
|
315
|
+
// (invalid ARIA). Being a sibling, it cannot inherit the card's hover
|
|
316
|
+
// transform — so both it and the card take their lift from the shared
|
|
317
|
+
// `group/card` wrapper instead of their own `:hover`, which is what keeps the
|
|
318
|
+
// marker attached to the photo mid-lift. `left-2 top-2` is the SAME inset as
|
|
319
|
+
// the Closed pill on the opposite corner.
|
|
320
|
+
return (
|
|
321
|
+
<div className={cn("group/card relative w-full", className)}>
|
|
322
|
+
{href ? (
|
|
323
|
+
<MotionLink
|
|
324
|
+
href={href}
|
|
325
|
+
aria-label={ariaLabel}
|
|
326
|
+
onClick={handleAnchorClick}
|
|
327
|
+
whileTap={{ scale: 0.98 }}
|
|
328
|
+
transition={springSnappy}
|
|
329
|
+
className={ROOT_CLASSES}
|
|
330
|
+
>
|
|
331
|
+
{body}
|
|
332
|
+
</MotionLink>
|
|
333
|
+
) : onOpen ? (
|
|
334
|
+
<motion.div
|
|
335
|
+
role="button"
|
|
336
|
+
tabIndex={0}
|
|
337
|
+
aria-label={ariaLabel}
|
|
338
|
+
onClick={() => onOpen(place)}
|
|
339
|
+
onKeyDown={handleKeyDown}
|
|
340
|
+
whileTap={{ scale: 0.98 }}
|
|
341
|
+
transition={springSnappy}
|
|
342
|
+
className={ROOT_CLASSES}
|
|
343
|
+
>
|
|
344
|
+
{body}
|
|
345
|
+
</motion.div>
|
|
346
|
+
) : (
|
|
347
|
+
/* Neither destination nor handler (a fixture in a marketing hero): a
|
|
348
|
+
plain block, not a focusable control that announces and does nothing. */
|
|
349
|
+
<div className={STATIC_ROOT_CLASSES}>{body}</div>
|
|
350
|
+
)}
|
|
351
|
+
{photoOverlay && (
|
|
352
|
+
<div className={cn("absolute left-2 top-2 z-10", HOVER_MOTION, HOVER_LIFT_GRID_GROUP)}>
|
|
353
|
+
{photoOverlay}
|
|
171
354
|
</div>
|
|
172
355
|
)}
|
|
173
356
|
</div>
|
|
174
357
|
);
|
|
175
|
-
|
|
176
|
-
if (onClick) {
|
|
177
|
-
return (
|
|
178
|
-
<button type="button" className={sharedClasses} onClick={() => onClick(place)}>
|
|
179
|
-
{content}
|
|
180
|
-
</button>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return <div className={sharedClasses}>{content}</div>;
|
|
185
358
|
});
|
|
186
359
|
|
|
187
|
-
|
|
360
|
+
export type PlaceCardSkeletonProps = {
|
|
361
|
+
/** Matches `PlaceCardProps["footer"]` — the caller's placeholder for its own footer, under the same divider. */
|
|
362
|
+
footer?: ReactNode;
|
|
363
|
+
className?: string;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Mirrors `<PlaceCard>`'s geometry so nothing shifts when data lands: the 4:3
|
|
368
|
+
* cover, the title row with its right-hand metric chip, the cuisine · price
|
|
369
|
+
* line and the location line.
|
|
370
|
+
*/
|
|
371
|
+
export function PlaceCardSkeleton({ footer, className }: PlaceCardSkeletonProps) {
|
|
372
|
+
return (
|
|
373
|
+
<div aria-hidden="true" className={cn("flex w-full flex-col", className)}>
|
|
374
|
+
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
|
|
375
|
+
<div className="mt-2.5 flex flex-col">
|
|
376
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
377
|
+
<Skeleton className="h-4 w-3/5" />
|
|
378
|
+
<Skeleton className="h-6 w-9 rounded-md" />
|
|
379
|
+
</div>
|
|
380
|
+
{/* `h-4` = the 16px line-height of the card's `text-xs` rows, so the
|
|
381
|
+
two meta lines land without a shift. */}
|
|
382
|
+
<div className="flex flex-col gap-0.5">
|
|
383
|
+
<Skeleton className="h-4 w-2/5" />
|
|
384
|
+
<Skeleton className="h-4 w-1/3" />
|
|
385
|
+
</div>
|
|
386
|
+
{footer && (
|
|
387
|
+
<>
|
|
388
|
+
<Separator className="my-2" />
|
|
389
|
+
<div>{footer}</div>
|
|
390
|
+
</>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { GradeAtomLabels } from './grade-atom'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Structural mirror of the openapi `Place` schema, decoupled from any one app's
|
|
3
5
|
* generated types. Both FE and admin map their domain shapes into this type
|
|
@@ -49,6 +51,13 @@ export type PlacePreview = {
|
|
|
49
51
|
* admin: form's primary photo URL or blob URL).
|
|
50
52
|
*/
|
|
51
53
|
thumbnail_url?: string
|
|
54
|
+
/**
|
|
55
|
+
* The card's cover photo. What `<PlaceCard>` renders in its 4:3 slot; with
|
|
56
|
+
* the default plain `<img>` this must be a loadable URL, with an injected
|
|
57
|
+
* `imageComponent` it is whatever that component accepts (fe passes the
|
|
58
|
+
* bare storage key straight to `next/image`'s loader).
|
|
59
|
+
*/
|
|
60
|
+
cover_url?: string
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export type PlaceHourBlock = { opens_at: number; closes_at: number }
|
|
@@ -69,6 +78,8 @@ export type PlacePriceRange = {
|
|
|
69
78
|
price_range_high?: number
|
|
70
79
|
/** Median price across the place's menu, in the local currency. */
|
|
71
80
|
median_price?: number
|
|
81
|
+
/** 1–4 price level, rendered as `$` glyphs on `<PlaceCard>`. 0 / absent = no signal. */
|
|
82
|
+
price_score?: number
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
/**
|
|
@@ -176,11 +187,13 @@ export type PlaceReviewsDigest = {
|
|
|
176
187
|
|
|
177
188
|
/** Labels passed to `<PlaceCard>`. All translation happens in the caller. */
|
|
178
189
|
export type PlaceCardLabels = {
|
|
179
|
-
|
|
190
|
+
/** The frosted pill on the photo when `open_now === false`. */
|
|
180
191
|
closed: string
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
/** Accessible name of the whole card, e.g. "View details for Casa Gri". */
|
|
193
|
+
viewDetails: (name: string) => string
|
|
194
|
+
/** Accessible name of the `$` price glyphs, e.g. "Price level 2 of 4". */
|
|
195
|
+
priceLevel: (level: number) => string
|
|
196
|
+
grade: GradeAtomLabels
|
|
184
197
|
}
|
|
185
198
|
|
|
186
199
|
/**
|