@bearmenu/ui 0.8.13 → 0.8.15

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.
Files changed (294) hide show
  1. package/README.md +0 -1
  2. package/dist/{chunk-66MAPLXS.js → chunk-2ADCZXZJ.js} +1 -1
  3. package/dist/{chunk-2DEIXTBN.js → chunk-2ZXIXQIU.js} +1 -1
  4. package/dist/{chunk-YOKHBAIH.js → chunk-4OF4T4AI.js} +12 -11
  5. package/dist/{chunk-MZL7CGCL.js → chunk-4XDJXMLF.js} +2 -2
  6. package/dist/{chunk-VAXOQS7W.js → chunk-5FCC5L5F.js} +3 -3
  7. package/dist/{chunk-YYGB4G4Q.js → chunk-CZK2MRS2.js} +1 -1
  8. package/dist/chunk-DJD3EMPK.js +126 -0
  9. package/dist/chunk-EZG2NELX.js +5 -0
  10. package/dist/{chunk-VG3PND3D.js → chunk-FMTJGO76.js} +1 -1
  11. package/dist/{chunk-UXWAOISG.js → chunk-FVSKGCK3.js} +2 -2
  12. package/dist/{chunk-2XCZJWTV.js → chunk-GNCWJWFE.js} +1 -1
  13. package/dist/{chunk-X3WMRKOW.js → chunk-HDR7V6IZ.js} +1 -1
  14. package/dist/{chunk-HSIYYA2E.js → chunk-HQU6QVAN.js} +5 -5
  15. package/dist/chunk-I5V446BX.js +27 -0
  16. package/dist/{chunk-WCTHZKU7.js → chunk-ICOU5QHT.js} +1 -1
  17. package/dist/{chunk-G7IFL4Z4.js → chunk-L3VEMP5A.js} +57 -9
  18. package/dist/chunk-LQWGZIYA.js +5 -0
  19. package/dist/{chunk-XNA2INVQ.js → chunk-MCOHLWRS.js} +2 -2
  20. package/dist/{chunk-3GWWZKX7.js → chunk-MMUBH76A.js} +21 -1
  21. package/dist/{chunk-ZDAHKI3A.js → chunk-MWB7S54O.js} +1 -1
  22. package/dist/{chunk-D5QHEHXJ.js → chunk-NMEVJT5Q.js} +1 -1
  23. package/dist/{chunk-3TNPSN4D.js → chunk-NSNEANOQ.js} +2 -2
  24. package/dist/{chunk-WSRWFA6K.js → chunk-NWZGSLPU.js} +1 -1
  25. package/dist/{chunk-LEAYBICI.js → chunk-OIV7B44M.js} +1 -1
  26. package/dist/{chunk-2CXU5WZO.js → chunk-P43B3ONY.js} +1 -1
  27. package/dist/{chunk-FRPPIXMY.js → chunk-PDTPO6NG.js} +1 -1
  28. package/dist/{chunk-6XMYEZVR.js → chunk-PX6RICS5.js} +1 -1
  29. package/dist/{chunk-6TEEY5YA.js → chunk-S33K4RC5.js} +1 -1
  30. package/dist/{chunk-3VMDCCCX.js → chunk-SC2VUTUI.js} +1 -1
  31. package/dist/{chunk-3NMG3KNA.js → chunk-SXQZ7DLH.js} +3 -3
  32. package/dist/{chunk-UDTG3RSE.js → chunk-T6S2ZBV6.js} +2 -2
  33. package/dist/{chunk-R47NEOSI.js → chunk-V4E333QS.js} +29 -4
  34. package/dist/{chunk-FKNBZF5T.js → chunk-WYTJR2YU.js} +1 -1
  35. package/dist/{chunk-QXHMPJ35.js → chunk-XBBEX4SF.js} +1 -1
  36. package/dist/chunk-XCB3F6SF.js +16 -0
  37. package/dist/{chunk-7BWTYWM3.js → chunk-ZLMRVBD7.js} +1 -1
  38. package/dist/{chunk-P7XTW2CG.js → chunk-ZLPWEHZO.js} +1 -1
  39. package/dist/components/accordion.js +1 -1
  40. package/dist/components/alert-dialog.js +3 -3
  41. package/dist/components/alert.js +2 -2
  42. package/dist/components/aspect-ratio.js +2 -2
  43. package/dist/components/avatar.js +1 -1
  44. package/dist/components/badge.js +2 -2
  45. package/dist/components/breadcrumb.js +1 -1
  46. package/dist/components/button-group.js +1 -1
  47. package/dist/components/button.d.ts +4 -0
  48. package/dist/components/button.js +3 -3
  49. package/dist/components/calendar.js +3 -3
  50. package/dist/components/card.js +3 -3
  51. package/dist/components/carousel.js +3 -3
  52. package/dist/components/category-deck.js +2 -2
  53. package/dist/components/checkbox.js +2 -2
  54. package/dist/components/circular-progress.js +1 -1
  55. package/dist/components/collapsible.js +1 -1
  56. package/dist/components/combobox.js +6 -6
  57. package/dist/components/command.js +3 -3
  58. package/dist/components/concept-card.js +2 -2
  59. package/dist/components/concept-compare-rail.js +3 -3
  60. package/dist/components/concept-compare.d.ts +2 -1
  61. package/dist/components/concept-compare.js +5 -5
  62. package/dist/components/concept-convergence.js +4 -4
  63. package/dist/components/convergence.js +3 -3
  64. package/dist/components/coverflow.js +1 -1
  65. package/dist/components/currency-input.js +2 -2
  66. package/dist/components/date-input.js +2 -2
  67. package/dist/components/day-rail-chip.js +2 -2
  68. package/dist/components/description-list.js +1 -1
  69. package/dist/components/dialog.js +2 -2
  70. package/dist/components/doorway-tiles.d.ts +57 -0
  71. package/dist/components/doorway-tiles.js +86 -0
  72. package/dist/components/drawer.js +1 -1
  73. package/dist/components/drift-rail.js +1 -1
  74. package/dist/components/dropdown-menu.js +1 -1
  75. package/dist/components/empty-state.js +4 -4
  76. package/dist/components/event-card.js +4 -4
  77. package/dist/components/event-compact-row.js +4 -4
  78. package/dist/components/event-day-header.js +2 -2
  79. package/dist/components/event-day-separator.js +3 -3
  80. package/dist/components/event-detail-panel.js +5 -5
  81. package/dist/components/event-hub-hero.js +2 -2
  82. package/dist/components/event-index-row.js +4 -4
  83. package/dist/components/event-list-row-wide.js +6 -6
  84. package/dist/components/event-list-row.js +5 -5
  85. package/dist/components/event-poster-fallback.js +1 -1
  86. package/dist/components/event-types.js +1 -1
  87. package/dist/components/events-empty-state.js +1 -1
  88. package/dist/components/explainer-steps.js +1 -1
  89. package/dist/components/facet-group.js +2 -2
  90. package/dist/components/featured-rail-item.js +2 -2
  91. package/dist/components/field.js +2 -2
  92. package/dist/components/filter-category-row.js +3 -3
  93. package/dist/components/form.js +2 -2
  94. package/dist/components/glass-ground.js +2 -2
  95. package/dist/components/grade-atom.d.ts +3 -0
  96. package/dist/components/grade-atom.js +4 -0
  97. package/dist/components/hour-band.js +4 -4
  98. package/dist/components/hour-group.js +4 -4
  99. package/dist/components/hub-surface.js +1 -1
  100. package/dist/components/hue-chip.js +1 -1
  101. package/dist/components/icon.js +1 -1
  102. package/dist/components/input-group.js +1 -1
  103. package/dist/components/input-otp.js +1 -1
  104. package/dist/components/input.js +2 -2
  105. package/dist/components/item.js +1 -1
  106. package/dist/components/kbd.js +1 -1
  107. package/dist/components/kitchen-dossier.d.ts +2 -1
  108. package/dist/components/kitchen-dossier.js +4 -4
  109. package/dist/components/label.js +2 -2
  110. package/dist/components/lazy-markdown.js +1 -1
  111. package/dist/components/link-button.js +3 -3
  112. package/dist/components/markdown-renderer.js +1 -1
  113. package/dist/components/menu-dead-end.d.ts +2 -1
  114. package/dist/components/menu-dead-end.js +1 -1
  115. package/dist/components/menu-family-block.d.ts +2 -1
  116. package/dist/components/menu-family-block.js +15 -9
  117. package/dist/components/menu-family-card.d.ts +2 -1
  118. package/dist/components/menu-family-card.js +1 -1
  119. package/dist/components/menu-field.js +1 -1
  120. package/dist/components/menu-filter-chips.d.ts +2 -1
  121. package/dist/components/menu-filter-chips.js +3 -3
  122. package/dist/components/menu-group-rows.d.ts +2 -1
  123. package/dist/components/menu-group-rows.js +2 -2
  124. package/dist/components/menu-hub-hero.js +1 -1
  125. package/dist/components/menu-index-header.d.ts +2 -1
  126. package/dist/components/menu-index-header.js +2 -2
  127. package/dist/components/menu-index-list.d.ts +2 -1
  128. package/dist/components/menu-index-list.js +5 -5
  129. package/dist/components/menu-intent-hero.d.ts +3 -2
  130. package/dist/components/menu-intent-hero.js +1 -1
  131. package/dist/components/menu-item.d.ts +2 -1
  132. package/dist/components/menu-item.js +4 -4
  133. package/dist/components/menu-kitchen-list.d.ts +2 -1
  134. package/dist/components/menu-kitchen-list.js +4 -4
  135. package/dist/components/menu-mocks.d.ts +16 -5
  136. package/dist/components/menu-mocks.js +23 -2
  137. package/dist/components/menu-types.d.ts +21 -34
  138. package/dist/components/motion-icon-actions.d.ts +27 -0
  139. package/dist/components/motion-icon-actions.js +336 -0
  140. package/dist/components/motion-icon-controls.js +3 -3
  141. package/dist/components/motion-icon-set.d.ts +13 -1
  142. package/dist/components/motion-icon-set.js +197 -6
  143. package/dist/components/motion-icon.d.ts +15 -1
  144. package/dist/components/motion-icon.js +2 -2
  145. package/dist/components/multi-select-combobox.js +8 -8
  146. package/dist/components/multi-select-menu.js +2 -2
  147. package/dist/components/native-select.js +1 -1
  148. package/dist/components/open-now-list.d.ts +2 -1
  149. package/dist/components/open-now-list.js +2 -2
  150. package/dist/components/pagination.js +3 -3
  151. package/dist/components/phone-frame.js +1 -1
  152. package/dist/components/photo-ground.js +1 -1
  153. package/dist/components/place-about-tab.d.ts +2 -1
  154. package/dist/components/place-about-tab.js +6 -6
  155. package/dist/components/place-card.d.ts +94 -12
  156. package/dist/components/place-card.js +203 -128
  157. package/dist/components/place-details-mobile.d.ts +2 -1
  158. package/dist/components/place-details-mobile.js +7 -7
  159. package/dist/components/place-schedule-week.d.ts +2 -1
  160. package/dist/components/place-schedule-week.js +3 -3
  161. package/dist/components/place-types.d.ts +3 -265
  162. package/dist/components/popover.js +2 -2
  163. package/dist/components/poster-stack.js +1 -1
  164. package/dist/components/poster-tape.js +1 -1
  165. package/dist/components/poster-wall.d.ts +22 -6
  166. package/dist/components/poster-wall.js +197 -12
  167. package/dist/components/progress.js +1 -1
  168. package/dist/components/qa-block.d.ts +2 -1
  169. package/dist/components/qa-block.js +1 -1
  170. package/dist/components/quiet-stretch.js +1 -1
  171. package/dist/components/radio-group.js +1 -1
  172. package/dist/components/rating.js +1 -1
  173. package/dist/components/scroll-area.js +1 -1
  174. package/dist/components/scroll-fade.js +2 -2
  175. package/dist/components/search-bar.js +1 -1
  176. package/dist/components/searchable-select.js +6 -6
  177. package/dist/components/section-header.js +2 -2
  178. package/dist/components/segmented-nav.d.ts +24 -4
  179. package/dist/components/segmented-nav.js +11 -2
  180. package/dist/components/select.js +1 -1
  181. package/dist/components/selection-bar.js +1 -1
  182. package/dist/components/separator.js +2 -2
  183. package/dist/components/sheet.js +1 -1
  184. package/dist/components/signature-chip.js +3 -3
  185. package/dist/components/skeleton-card.js +3 -3
  186. package/dist/components/skeleton.js +2 -2
  187. package/dist/components/slider.js +1 -1
  188. package/dist/components/sliding-panels.js +3 -3
  189. package/dist/components/snap-rail.js +2 -2
  190. package/dist/components/sonner.js +1 -1
  191. package/dist/components/sort-option-list.js +1 -1
  192. package/dist/components/spinner.js +1 -1
  193. package/dist/components/stat-banner.js +1 -1
  194. package/dist/components/stepper.js +1 -1
  195. package/dist/components/sticky-container.js +1 -1
  196. package/dist/components/story.js +1 -1
  197. package/dist/components/switch.js +1 -1
  198. package/dist/components/table.js +1 -1
  199. package/dist/components/tabs.js +2 -2
  200. package/dist/components/tag-group.js +2 -2
  201. package/dist/components/tag.js +2 -2
  202. package/dist/components/text.js +2 -2
  203. package/dist/components/textarea.js +1 -1
  204. package/dist/components/timeline-band.js +4 -4
  205. package/dist/components/timeline-playhead.js +4 -4
  206. package/dist/components/toggle-group.js +2 -2
  207. package/dist/components/toggle.js +2 -2
  208. package/dist/components/tooltip.js +1 -1
  209. package/dist/components/top-comparison.d.ts +2 -1
  210. package/dist/components/top-comparison.js +1 -1
  211. package/dist/components/top-cross-link.d.ts +2 -1
  212. package/dist/components/top-cross-link.js +1 -1
  213. package/dist/components/top-ranking-entry.d.ts +2 -1
  214. package/dist/components/top-ranking-entry.js +6 -6
  215. package/dist/components/top-ranking-hero.d.ts +2 -1
  216. package/dist/components/top-ranking-hero.js +1 -1
  217. package/dist/components/top-ranking-legend.js +1 -1
  218. package/dist/components/top-types.d.ts +3 -1
  219. package/dist/components/use-mobile.js +1 -1
  220. package/dist/components/venue-events-block.js +5 -5
  221. package/dist/components/venue-grade-dimensions.d.ts +2 -1
  222. package/dist/components/venue-grade-dimensions.js +1 -1
  223. package/dist/components/venue-quotes.d.ts +2 -1
  224. package/dist/components/venue-quotes.js +1 -1
  225. package/dist/components/venue-wall.d.ts +2 -1
  226. package/dist/components/venue-wall.js +1 -1
  227. package/dist/components/weekday-theme-banner.js +1 -1
  228. package/dist/components/weekend-panel.js +4 -4
  229. package/dist/hooks/use-drag-swipe.js +1 -1
  230. package/dist/hooks/use-haptic.js +2 -2
  231. package/dist/hooks/use-media-query.js +1 -1
  232. package/dist/hooks/use-mobile.js +1 -1
  233. package/dist/hooks/use-mounted-theme.js +1 -1
  234. package/dist/hooks/use-sticky.js +1 -1
  235. package/dist/lib/card-hover.js +2 -18
  236. package/dist/lib/grade.d.ts +54 -0
  237. package/dist/lib/grade.js +2 -0
  238. package/dist/lib/icon-depth.d.ts +35 -0
  239. package/dist/lib/icon-depth.js +2 -0
  240. package/dist/lib/icon-registry.js +1 -1
  241. package/dist/lib/motion.js +2 -2
  242. package/dist/lib/overlay-pill.d.ts +30 -0
  243. package/dist/lib/overlay-pill.js +2 -0
  244. package/dist/lib/utils.js +1 -1
  245. package/dist/place-types-BHdeMMiG.d.ts +317 -0
  246. package/package.json +1 -1
  247. package/src/components/button.tsx +5 -1
  248. package/src/components/colour-dictionary.stories.tsx +268 -82
  249. package/src/components/doorway-tiles.stories.tsx +83 -0
  250. package/src/components/doorway-tiles.test.tsx +51 -0
  251. package/src/components/doorway-tiles.tsx +225 -0
  252. package/src/components/grade-atom.stories.tsx +81 -0
  253. package/src/components/grade-atom.tsx +256 -0
  254. package/src/components/hue-chip.stories.tsx +2 -2
  255. package/src/components/menu-family-block.stories.tsx +29 -0
  256. package/src/components/menu-family-block.test.tsx +51 -0
  257. package/src/components/menu-family-block.tsx +20 -4
  258. package/src/components/menu-intent-hero.tsx +1 -1
  259. package/src/components/menu-mocks.ts +39 -3
  260. package/src/components/menu-types.ts +17 -33
  261. package/src/components/motion-icon-actions.test.tsx +115 -0
  262. package/src/components/motion-icon-actions.tsx +580 -0
  263. package/src/components/motion-icon-controls.test.tsx +1 -0
  264. package/src/components/motion-icon-controls.tsx +18 -13
  265. package/src/components/motion-icon-set.stories.tsx +62 -3
  266. package/src/components/motion-icon-set.tsx +349 -2
  267. package/src/components/motion-icon.test.tsx +180 -1
  268. package/src/components/motion-icon.tsx +97 -8
  269. package/src/components/place-card.stories.tsx +66 -76
  270. package/src/components/place-card.test.tsx +188 -100
  271. package/src/components/place-card.tsx +356 -148
  272. package/src/components/place-types.ts +17 -4
  273. package/src/components/poster-wall.stories.tsx +84 -0
  274. package/src/components/poster-wall.test.tsx +94 -3
  275. package/src/components/poster-wall.tsx +257 -9
  276. package/src/components/segmented-nav.stories.tsx +72 -1
  277. package/src/components/segmented-nav.test.tsx +96 -0
  278. package/src/components/segmented-nav.tsx +36 -4
  279. package/src/globals.css +26 -0
  280. package/src/hooks/use-haptic.test.tsx +80 -0
  281. package/src/hooks/use-haptic.ts +45 -4
  282. package/src/lib/grade.test.ts +82 -0
  283. package/src/lib/grade.ts +76 -0
  284. package/src/lib/icon-depth.ts +35 -0
  285. package/src/lib/overlay-pill.ts +29 -0
  286. package/dist/components/haptic-button.d.ts +0 -12
  287. package/dist/components/haptic-button.js +0 -17
  288. package/dist/components/menu-family-tiles.d.ts +0 -19
  289. package/dist/components/menu-family-tiles.js +0 -57
  290. package/src/components/haptic-button.stories.tsx +0 -41
  291. package/src/components/haptic-button.tsx +0 -19
  292. package/src/components/menu-family-tiles.stories.tsx +0 -54
  293. package/src/components/menu-family-tiles.test.tsx +0 -30
  294. package/src/components/menu-family-tiles.tsx +0 -132
@@ -1,187 +1,395 @@
1
1
  "use client";
2
2
 
3
- import { memo, useState } from "react";
4
- import { MapPin, Star, Clock, UtensilsCrossed, Wine } from "lucide-react";
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 { Text } from "./text";
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
- export interface PlaceCardProps {
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
- /** Locale tag (e.g. `"en"`, `"ro"`). Reserved for future Intl number/distance formatting. */
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
- function formatDistance(meters?: number): string {
22
- return meters ? `${(meters / 1000).toFixed(1)} km` : "—";
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 formatRating(rating?: { rating: number; count: number }): string | null {
26
- return rating?.rating ? rating.rating.toFixed(1) : null;
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
- onClick,
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 ratingDisplay = formatRating(place.rating);
39
- const reviewCount = place.review_count ?? place.rating?.count;
40
- const showHeader = !!place.thumbnail_url && !imgError;
41
- const distanceMeters = place.location?.distance?.meters;
42
- const schedule = place.today_schedule?.[0] ?? place.hours?.[0];
43
- const scheduleHour = place.open_now ? schedule?.closes_at : schedule?.opens_at;
44
- const scheduleLabel = place.open_now ? labels.until : labels.opensAt;
45
-
46
- const sharedClasses = cn(
47
- "glass rounded-3xl border border-transparent bg-transparent p-0 text-left text-card-foreground transition-transform duration-300 ease-in-out hover:border-border/20 h-full flex flex-col w-full",
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 content = (
54
- <div className="p-4 flex-1 flex flex-col">
55
- {/* SECTION 1 — HEADER */}
56
- <div className="flex items-start gap-3 mb-3">
57
- <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center overflow-hidden flex-shrink-0">
58
- {showHeader ? (
59
- // eslint-disable-next-line @next/next/no-img-element
60
- <img
61
- src={place.thumbnail_url}
62
- alt={place.name}
63
- title={place.name}
64
- width={48}
65
- height={48}
66
- loading={priority ? "eager" : "lazy"}
67
- decoding="async"
68
- className="w-full h-full object-cover"
69
- onError={() => setImgError(true)}
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="w-6 h-6 text-primary/60"
74
- aria-hidden="true"
246
+ className="h-10 w-10 text-[color:var(--accent)]"
247
+ style={{ filter: NEUTRAL_ICON_FILTER }}
248
+ aria-hidden
75
249
  />
76
- )}
77
- </div>
250
+ </div>
251
+ )}
78
252
 
79
- <div className="flex-1 min-w-0">
80
- <Text as="h3" weight="bold" leading="tight" color="default" className="text-lg mb-2">
81
- {place.name}
82
- </Text>
83
- {ratingDisplay !== null && (
84
- <div className="flex items-center gap-1">
85
- <Star className="w-4 h-4 fill-primary text-primary" aria-hidden="true" />
86
- <span className="font-semibold text-sm">{ratingDisplay}</span>
87
- {reviewCount !== undefined && reviewCount !== null && (
88
- <span className="text-xs text-muted-foreground">({reviewCount})</span>
89
- )}
90
- </div>
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
- {/* SECTION 2 PRICE RANGES */}
96
- {(place.price_dishes || place.price_drinks) && (
97
- <div className="mb-3">
98
- <div className="flex items-center gap-4 text-xs">
99
- {place.price_dishes?.price_range_low != null &&
100
- place.price_dishes?.price_range_high != null && (
101
- <div className="flex items-center gap-1 text-primary">
102
- <UtensilsCrossed className="w-4 h-4" aria-hidden="true" />
103
- <span>
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
- {/* SECTION 3 — LOCATION & STATUS */}
124
- <div className="mb-3">
125
- <div className="flex items-center gap-2 text-sm text-primary">
126
- {distanceMeters !== undefined && distanceMeters !== null && (
127
- <>
128
- <div className="flex items-center gap-1">
129
- <MapPin className="w-3 h-3" aria-hidden="true" />
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
- <div className="flex items-center gap-1">
136
- <Clock
137
- className={cn(
138
- "w-3 h-3",
139
- place.open_now ? "text-green-500" : "text-muted-foreground"
140
- )}
141
- aria-hidden="true"
142
- />
143
- <span
144
- className={cn(
145
- "text-xs",
146
- place.open_now ? "text-green-500" : "text-muted-foreground"
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
- {/* SECTION 4 DESCRIPTION */}
166
- {place.description && (
167
- <div className="mb-4">
168
- <Text as="p" variant="caption" color="muted" className="line-clamp-2">
169
- {place.description}
170
- </Text>
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
- PlaceCard.displayName = "PlaceCard";
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
- open: string
190
+ /** The frosted pill on the photo when `open_now === false`. */
180
191
  closed: string
181
- until: string
182
- opensAt: string
183
- currency: string
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
  /**