@bearmenu/ui 0.8.9 → 0.8.11

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 (225) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/{chunk-UKBNQO6Q.js → chunk-2CXU5WZO.js} +1 -1
  3. package/dist/chunk-2XCZJWTV.js +121 -0
  4. package/dist/{chunk-62ES6N32.js → chunk-43RC7MHY.js} +1 -1
  5. package/dist/chunk-5RWTOT5Y.js +72 -0
  6. package/dist/{chunk-Q6QH52LJ.js → chunk-6TEEY5YA.js} +2 -2
  7. package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
  8. package/dist/chunk-CLLY6XB3.js +41 -0
  9. package/dist/chunk-DTXNDLSE.js +125 -0
  10. package/dist/chunk-F6FMZJDG.js +113 -0
  11. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  12. package/dist/chunk-H3ZZDX5L.js +61 -0
  13. package/dist/{chunk-H3BV4VAG.js → chunk-JS3EINZD.js} +2 -2
  14. package/dist/chunk-MZL7CGCL.js +109 -0
  15. package/dist/{chunk-TH6W2PWG.js → chunk-UDTG3RSE.js} +1 -1
  16. package/dist/chunk-WCTHZKU7.js +37 -0
  17. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  18. package/dist/components/alert-dialog.js +1 -1
  19. package/dist/components/alert.d.ts +2 -2
  20. package/dist/components/avatar.d.ts +3 -3
  21. package/dist/components/badge.d.ts +1 -1
  22. package/dist/components/badge.js +1 -1
  23. package/dist/components/button.d.ts +2 -2
  24. package/dist/components/button.js +1 -1
  25. package/dist/components/calendar.js +1 -1
  26. package/dist/components/card.d.ts +4 -4
  27. package/dist/components/carousel.js +1 -1
  28. package/dist/components/combobox.js +1 -1
  29. package/dist/components/command.d.ts +2 -2
  30. package/dist/components/concept-compare-rail.d.ts +50 -0
  31. package/dist/components/concept-compare-rail.js +5 -0
  32. package/dist/components/concept-compare.d.ts +75 -38
  33. package/dist/components/concept-compare.js +209 -133
  34. package/dist/components/concept-convergence.d.ts +41 -0
  35. package/dist/components/concept-convergence.js +53 -0
  36. package/dist/components/convergence.d.ts +25 -0
  37. package/dist/components/convergence.js +4 -0
  38. package/dist/components/dialog.d.ts +2 -2
  39. package/dist/components/empty-state.js +1 -1
  40. package/dist/components/event-card.js +1 -1
  41. package/dist/components/event-compact-row.js +1 -1
  42. package/dist/components/event-detail-panel.js +2 -2
  43. package/dist/components/event-index-row.js +1 -1
  44. package/dist/components/event-list-row-wide.js +2 -2
  45. package/dist/components/event-list-row.js +2 -2
  46. package/dist/components/explainer-steps.d.ts +34 -0
  47. package/dist/components/explainer-steps.js +36 -0
  48. package/dist/components/filter-category-row.js +1 -1
  49. package/dist/components/glass-ground.d.ts +61 -0
  50. package/dist/components/glass-ground.js +3 -0
  51. package/dist/components/haptic-button.js +1 -1
  52. package/dist/components/hub-surface.js +11 -1
  53. package/dist/components/hue-chip.d.ts +70 -0
  54. package/dist/components/hue-chip.js +3 -0
  55. package/dist/components/input-group.d.ts +2 -2
  56. package/dist/components/input.d.ts +1 -1
  57. package/dist/components/item.d.ts +4 -4
  58. package/dist/components/kbd.d.ts +2 -2
  59. package/dist/components/kitchen-dossier.d.ts +88 -0
  60. package/dist/components/kitchen-dossier.js +186 -0
  61. package/dist/components/link-button.js +1 -1
  62. package/dist/components/menu-dead-end.d.ts +56 -0
  63. package/dist/components/menu-dead-end.js +49 -0
  64. package/dist/components/menu-family-block.d.ts +98 -0
  65. package/dist/components/menu-family-block.js +153 -0
  66. package/dist/components/menu-family-card.d.ts +33 -27
  67. package/dist/components/menu-family-card.js +41 -85
  68. package/dist/components/menu-field.js +92 -2
  69. package/dist/components/menu-filter-chips.d.ts +86 -0
  70. package/dist/components/menu-filter-chips.js +122 -0
  71. package/dist/components/menu-group-rows.d.ts +53 -0
  72. package/dist/components/menu-group-rows.js +67 -0
  73. package/dist/components/menu-hub-hero.js +10 -5
  74. package/dist/components/menu-index-header.d.ts +37 -0
  75. package/dist/components/menu-index-header.js +68 -0
  76. package/dist/components/menu-index-list.d.ts +48 -0
  77. package/dist/components/menu-index-list.js +96 -0
  78. package/dist/components/menu-intent-hero.d.ts +69 -0
  79. package/dist/components/menu-intent-hero.js +87 -0
  80. package/dist/components/menu-item.js +3 -3
  81. package/dist/components/menu-kitchen-list.d.ts +82 -0
  82. package/dist/components/menu-kitchen-list.js +5 -0
  83. package/dist/components/menu-mocks.d.ts +37 -2
  84. package/dist/components/menu-mocks.js +152 -27
  85. package/dist/components/menu-types.d.ts +182 -6
  86. package/dist/components/motion-icon-controls.js +2 -2
  87. package/dist/components/motion-icon-set.js +6 -6
  88. package/dist/components/motion-icon.d.ts +22 -1
  89. package/dist/components/motion-icon.js +1 -1
  90. package/dist/components/multi-select-combobox.js +2 -2
  91. package/dist/components/native-select.d.ts +2 -2
  92. package/dist/components/pagination.js +1 -1
  93. package/dist/components/photo-ground.d.ts +64 -0
  94. package/dist/components/photo-ground.js +3 -0
  95. package/dist/components/place-about-tab.js +2 -2
  96. package/dist/components/place-details-mobile.js +3 -3
  97. package/dist/components/place-types.d.ts +2 -2
  98. package/dist/components/qa-block.d.ts +36 -0
  99. package/dist/components/qa-block.js +24 -0
  100. package/dist/components/scroll-fade.js +3 -121
  101. package/dist/components/searchable-select.js +1 -1
  102. package/dist/components/select.d.ts +1 -1
  103. package/dist/components/signature-chip.d.ts +2 -2
  104. package/dist/components/signature-chip.js +2 -2
  105. package/dist/components/sliding-panels.js +1 -1
  106. package/dist/components/snap-rail.d.ts +26 -3
  107. package/dist/components/snap-rail.js +3 -109
  108. package/dist/components/spinner.d.ts +4 -4
  109. package/dist/components/story.d.ts +34 -0
  110. package/dist/components/story.js +3 -0
  111. package/dist/components/tag.d.ts +4 -4
  112. package/dist/components/text.d.ts +5 -5
  113. package/dist/components/toggle.d.ts +1 -1
  114. package/dist/components/top-comparison.d.ts +28 -0
  115. package/dist/components/top-comparison.js +76 -0
  116. package/dist/components/top-cross-link.d.ts +34 -0
  117. package/dist/components/top-cross-link.js +101 -0
  118. package/dist/components/top-ranking-entry.d.ts +53 -0
  119. package/dist/components/top-ranking-entry.js +217 -0
  120. package/dist/components/top-ranking-hero.d.ts +61 -0
  121. package/dist/components/top-ranking-hero.js +99 -0
  122. package/dist/components/top-ranking-legend.d.ts +69 -0
  123. package/dist/components/top-ranking-legend.js +124 -0
  124. package/dist/components/top-types.d.ts +109 -0
  125. package/dist/components/top-types.js +1 -0
  126. package/dist/components/venue-grade-dimensions.d.ts +3 -1
  127. package/dist/components/venue-grade-dimensions.js +1 -1
  128. package/dist/components/venue-quotes.d.ts +1 -1
  129. package/dist/components/venue-quotes.js +1 -1
  130. package/dist/components/venue-wall.js +5 -3
  131. package/dist/components/weekday-theme-banner.js +1 -1
  132. package/dist/components/weekend-panel.js +2 -2
  133. package/package.json +1 -1
  134. package/src/components/badge.tsx +1 -1
  135. package/src/components/button.tsx +2 -2
  136. package/src/components/concept-compare-rail.tsx +132 -0
  137. package/src/components/concept-compare.stories.tsx +122 -54
  138. package/src/components/concept-compare.tsx +368 -196
  139. package/src/components/concept-convergence.stories.tsx +53 -0
  140. package/src/components/concept-convergence.tsx +90 -0
  141. package/src/components/convergence.stories.tsx +54 -0
  142. package/src/components/convergence.tsx +177 -0
  143. package/src/components/explainer-steps.stories.tsx +46 -0
  144. package/src/components/explainer-steps.tsx +74 -0
  145. package/src/components/glass-ground.tsx +104 -0
  146. package/src/components/hub-surface.tsx +11 -1
  147. package/src/components/hue-chip.stories.tsx +76 -0
  148. package/src/components/hue-chip.test.tsx +62 -0
  149. package/src/components/hue-chip.tsx +99 -0
  150. package/src/components/kitchen-dossier.stories.tsx +108 -0
  151. package/src/components/kitchen-dossier.tsx +321 -0
  152. package/src/components/menu-dead-end.stories.tsx +67 -0
  153. package/src/components/menu-dead-end.tsx +127 -0
  154. package/src/components/menu-family-block.stories.tsx +91 -0
  155. package/src/components/menu-family-block.tsx +303 -0
  156. package/src/components/menu-family-card.stories.tsx +43 -35
  157. package/src/components/menu-family-card.tsx +115 -142
  158. package/src/components/menu-filter-chips.stories.tsx +107 -0
  159. package/src/components/menu-filter-chips.tsx +238 -0
  160. package/src/components/menu-group-rows.stories.tsx +54 -0
  161. package/src/components/menu-group-rows.tsx +133 -0
  162. package/src/components/menu-hub-hero.test.tsx +39 -0
  163. package/src/components/menu-hub-hero.tsx +17 -5
  164. package/src/components/menu-index-header.stories.tsx +72 -0
  165. package/src/components/menu-index-header.tsx +125 -0
  166. package/src/components/menu-index-list.stories.tsx +50 -0
  167. package/src/components/menu-index-list.tsx +149 -0
  168. package/src/components/menu-intent-hero.stories.tsx +67 -0
  169. package/src/components/menu-intent-hero.tsx +189 -0
  170. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  171. package/src/components/menu-kitchen-list.test.tsx +78 -0
  172. package/src/components/menu-kitchen-list.tsx +284 -0
  173. package/src/components/menu-mocks.ts +198 -23
  174. package/src/components/menu-types.ts +189 -6
  175. package/src/components/motion-icon-controls.tsx +11 -11
  176. package/src/components/motion-icon-set.tsx +6 -5
  177. package/src/components/motion-icon.test.tsx +23 -1
  178. package/src/components/motion-icon.tsx +20 -0
  179. package/src/components/photo-ground.tsx +147 -0
  180. package/src/components/place-types.ts +2 -2
  181. package/src/components/qa-block.stories.tsx +43 -0
  182. package/src/components/qa-block.tsx +60 -0
  183. package/src/components/signature-chip.tsx +3 -3
  184. package/src/components/snap-rail.tsx +58 -15
  185. package/src/components/story.tsx +225 -0
  186. package/src/components/top-comparison.stories.tsx +78 -0
  187. package/src/components/top-comparison.tsx +151 -0
  188. package/src/components/top-cross-link.stories.tsx +84 -0
  189. package/src/components/top-cross-link.tsx +171 -0
  190. package/src/components/top-ranking-entry.stories.tsx +171 -0
  191. package/src/components/top-ranking-entry.test.tsx +105 -0
  192. package/src/components/top-ranking-entry.tsx +425 -0
  193. package/src/components/top-ranking-hero.stories.tsx +76 -0
  194. package/src/components/top-ranking-hero.tsx +218 -0
  195. package/src/components/top-ranking-legend.stories.tsx +78 -0
  196. package/src/components/top-ranking-legend.tsx +239 -0
  197. package/src/components/top-types.ts +110 -0
  198. package/src/components/venue-grade-dimensions.tsx +2 -2
  199. package/src/components/venue-quotes.stories.tsx +1 -1
  200. package/src/components/venue-quotes.tsx +4 -2
  201. package/src/components/venue-wall.tsx +9 -4
  202. package/src/components/weekday-theme-banner.tsx +1 -1
  203. package/dist/chunk-THOVPZNN.js +0 -37
  204. package/dist/chunk-YS6BHKZO.js +0 -92
  205. package/dist/components/ranked-venue-list.d.ts +0 -49
  206. package/dist/components/ranked-venue-list.js +0 -87
  207. package/dist/components/signature-line-list.d.ts +0 -45
  208. package/dist/components/signature-line-list.js +0 -99
  209. package/dist/components/venue-busyness-strip.d.ts +0 -35
  210. package/dist/components/venue-busyness-strip.js +0 -37
  211. package/dist/components/venue-signature-block.d.ts +0 -39
  212. package/dist/components/venue-signature-block.js +0 -6
  213. package/dist/components/venue-spotlight.d.ts +0 -74
  214. package/dist/components/venue-spotlight.js +0 -138
  215. package/src/components/ranked-venue-list.stories.tsx +0 -80
  216. package/src/components/ranked-venue-list.tsx +0 -177
  217. package/src/components/signature-line-list.stories.tsx +0 -85
  218. package/src/components/signature-line-list.tsx +0 -136
  219. package/src/components/venue-busyness-strip.stories.tsx +0 -66
  220. package/src/components/venue-busyness-strip.tsx +0 -64
  221. package/src/components/venue-signature-block.stories.tsx +0 -68
  222. package/src/components/venue-signature-block.tsx +0 -71
  223. package/src/components/venue-spotlight.stories.tsx +0 -141
  224. package/src/components/venue-spotlight.tsx +0 -236
  225. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { MenuKitchenList, type MenuKitchenListLabels } from "./menu-kitchen-list";
4
+ import type { MenuKitchenPreview } from "./menu-types";
5
+
6
+ const labels: MenuKitchenListLabels = {
7
+ openVenue: (name) => `Open ${name}`,
8
+ openDish: (name) => `See ${name}`,
9
+ openNow: "Open now",
10
+ showMore: "Show 10 more",
11
+ };
12
+
13
+ const kitchen: MenuKitchenPreview = {
14
+ place: { id: "p1", slug: "salt-and-stone", name: "Salt & Stone", open_now: true },
15
+ href: "/places/salt-and-stone",
16
+ metaLabel: "courtyard · Centru",
17
+ hoursLabel: "kitchen until 23:00",
18
+ priceLabel: "62 lei",
19
+ dish: {
20
+ name: "Truffle Tagliatelle",
21
+ href: "/menu/dishes/truffle-tagliatelle",
22
+ kicker: "MOST NAMED HERE",
23
+ meta: "17 reviews name it",
24
+ },
25
+ };
26
+
27
+ describe("MenuKitchenList", () => {
28
+ /**
29
+ * The reason this component exists rather than a flag on `RankedVenueList`.
30
+ * If these ever collapse into one link the surface has silently lost the
31
+ * drawer, which is the second half of what the row is for.
32
+ */
33
+ it("gives the row and the dish separate destinations", () => {
34
+ render(<MenuKitchenList kitchens={[kitchen]} labels={labels} />);
35
+
36
+ const venue = screen.getByRole("link", { name: "Open Salt & Stone" });
37
+ const dish = screen.getByRole("link", { name: "See Truffle Tagliatelle" });
38
+
39
+ expect(venue).toHaveAttribute("href", "/places/salt-and-stone");
40
+ expect(dish).toHaveAttribute("href", "/menu/dishes/truffle-tagliatelle");
41
+ // Anchors must be siblings, never nested — nesting is invalid HTML and
42
+ // breaks middle-click on both targets.
43
+ expect(venue.contains(dish)).toBe(false);
44
+ expect(dish.contains(venue)).toBe(false);
45
+ });
46
+
47
+ /**
48
+ * `must_try` arrives from a list endpoint, so roughly a third of entries have
49
+ * no `slug`. Those must render as content, not as a link to nowhere.
50
+ */
51
+ it("renders an inert dish line when the must-try has no menu row behind it", () => {
52
+ const noHref = { ...kitchen, dish: { name: "Risotto ai Funghi", kicker: "MOST NAMED HERE" } };
53
+ render(<MenuKitchenList kitchens={[noHref]} labels={labels} />);
54
+
55
+ expect(screen.getByText("Risotto ai Funghi")).toBeInTheDocument();
56
+ expect(screen.queryByRole("link", { name: /^See / })).not.toBeInTheDocument();
57
+ expect(screen.getByRole("link", { name: "Open Salt & Stone" })).toBeInTheDocument();
58
+ });
59
+
60
+ it("still reads as a venue with no dish at all", () => {
61
+ const bare: MenuKitchenPreview = {
62
+ place: { id: "p2", slug: "green-room", name: "Green Room" },
63
+ href: "/places/green-room",
64
+ };
65
+ render(<MenuKitchenList kitchens={[bare]} labels={labels} />);
66
+
67
+ expect(screen.getByText("Green Room")).toBeInTheDocument();
68
+ expect(screen.getByRole("link", { name: "Open Green Room" })).toBeInTheDocument();
69
+ });
70
+
71
+ it("omits the show-more control until the caller wires one", () => {
72
+ const { rerender } = render(<MenuKitchenList kitchens={[kitchen]} labels={labels} />);
73
+ expect(screen.queryByRole("button", { name: "Show 10 more" })).not.toBeInTheDocument();
74
+
75
+ rerender(<MenuKitchenList kitchens={[kitchen]} labels={labels} onShowMore={() => {}} />);
76
+ expect(screen.getByRole("button", { name: "Show 10 more" })).toBeInTheDocument();
77
+ });
78
+ });
@@ -0,0 +1,284 @@
1
+ import { Check, ChevronRight, Clock, MessageSquare, Star } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Separator } from "./separator";
4
+ import { Skeleton } from "./skeleton";
5
+ import type { MenuKitchenPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
6
+
7
+ /**
8
+ * MenuKitchenList — the intent page's spine.
9
+ *
10
+ * Venues ranked by how often reviews name their dishes, each row carrying the
11
+ * dish that earned it its place. Not a grid of dishes: a shortlist of rooms you
12
+ * could walk into.
13
+ *
14
+ * ## Two targets, and why this is not `<RankedVenueList>`
15
+ *
16
+ * The row goes to the venue. The dish name opens the item drawer — the same
17
+ * drawer the dish tiles open, listing every kitchen that makes it and for how
18
+ * much. Those are two different destinations inside one row, and the hub's
19
+ * `<RankedVenueList>` wraps its ENTIRE row in a single `LinkComponent`, so it
20
+ * structurally cannot express a second one. That is the reason this exists
21
+ * rather than a variant flag on that component.
22
+ *
23
+ * The mechanism is a stretched overlay, as in `<MenuItem>`: the venue link is
24
+ * an absolutely-positioned sibling at `z-[1]`, and the dish link sits above it
25
+ * at `z-[2]`. Nesting one anchor inside another is invalid HTML and breaks
26
+ * middle-click on both.
27
+ *
28
+ * The dish name is underlined so it reads as a target before it is pressed. The
29
+ * chevron belongs to the venue and stays on the right, away from it — an
30
+ * unmarked second target inside a row that already navigates is a trap.
31
+ *
32
+ * ## The filtered state changes the row's reason for being there
33
+ *
34
+ * With a dish or dietary filter on, each row swaps its most-named dish for the
35
+ * one that actually matched, and the kicker changes with it ("MOST NAMED HERE"
36
+ * → "WHY IT MATCHES"). A filtered list whose rows still advertise their
37
+ * most-named dish is claiming the filter matched something it did not.
38
+ *
39
+ * ## Header
40
+ *
41
+ * Deliberately none. The page composes `<SectionHeader>` above it, the same way
42
+ * the events hub does, because the heading here carries a count and a "see the
43
+ * full ranking" link into `/top` that the list knows nothing about.
44
+ */
45
+
46
+ /** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
47
+ export type MenuKitchenListLinkProps = MenuLinkProps;
48
+
49
+ function DefaultLink({ href, ...props }: MenuKitchenListLinkProps) {
50
+ return <a href={href} {...props} />;
51
+ }
52
+
53
+ export type MenuKitchenListLabels = {
54
+ /** Accessible prefix for the row's venue link, e.g. "Open". */
55
+ openVenue: (name: string) => string;
56
+ /** Accessible name for the dish link, e.g. "See Truffle Tagliatelle". */
57
+ openDish: (name: string) => string;
58
+ /** Accessible name for the open-now dot. */
59
+ openNow: string;
60
+ /** Reveals the next page of rows, e.g. "Show 10 more". */
61
+ showMore?: string;
62
+ };
63
+
64
+ export type MenuKitchenListProps = {
65
+ kitchens: MenuKitchenPreview[];
66
+ labels: MenuKitchenListLabels;
67
+ /**
68
+ * Reveals more rows IN PLACE. The intent page pages ten at a time and never
69
+ * navigates for the eleventh — the list is the page.
70
+ */
71
+ onShowMore?: () => void;
72
+ linkComponent?: MenuLinkComponent;
73
+ className?: string;
74
+ };
75
+
76
+ export function MenuKitchenList({
77
+ kitchens,
78
+ labels,
79
+ onShowMore,
80
+ linkComponent = DefaultLink,
81
+ className,
82
+ }: MenuKitchenListProps) {
83
+ return (
84
+ <div className={cn("flex flex-col", className)}>
85
+ <ol>
86
+ {kitchens.map((kitchen, index) => (
87
+ <li key={kitchen.place.slug} className="[&:first-child>div]:pt-0">
88
+ {/* The DS `Separator`, drawn BETWEEN rows rather than a `border-t`
89
+ with a `first:` reset on every row. It lives inside the `<li>`
90
+ because `<ol>` admits only list items, so a sibling element
91
+ between them would be invalid markup. */}
92
+ {index > 0 && <Separator />}
93
+ <KitchenRow kitchen={kitchen} labels={labels} linkComponent={linkComponent} />
94
+ </li>
95
+ ))}
96
+ </ol>
97
+
98
+ {onShowMore && labels.showMore && (
99
+ <button
100
+ type="button"
101
+ onClick={onShowMore}
102
+ className="mt-2 self-start rounded-md py-2 text-[13px] font-bold text-accent transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
103
+ >
104
+ {labels.showMore}
105
+ </button>
106
+ )}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function KitchenRow({
112
+ kitchen,
113
+ labels,
114
+ linkComponent: LinkComponent,
115
+ }: {
116
+ kitchen: MenuKitchenPreview;
117
+ labels: MenuKitchenListLabels;
118
+ linkComponent: MenuLinkComponent;
119
+ }) {
120
+ const { place, dish } = kitchen;
121
+ const openNow = place.open_now === true;
122
+
123
+ return (
124
+ <div className="relative flex flex-wrap items-center gap-[11px] py-3 transition-colors hover:bg-muted/30 motion-reduce:transition-none lg:flex-nowrap lg:gap-4 lg:py-3.5">
125
+ {/* The venue link, stretched. `z-[1]` — the dish link sits above it. */}
126
+ <LinkComponent
127
+ href={kitchen.href}
128
+ aria-label={labels.openVenue(place.name)}
129
+ className="absolute inset-0 z-[1] rounded-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
130
+ />
131
+
132
+ <span className="relative h-[46px] w-[46px] shrink-0 overflow-hidden rounded-[11px] bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)] lg:h-[58px] lg:w-[58px] lg:rounded-[13px]">
133
+ {place.thumbnail_url && (
134
+ // eslint-disable-next-line @next/next/no-img-element
135
+ <img
136
+ src={place.thumbnail_url}
137
+ alt=""
138
+ loading="lazy"
139
+ decoding="async"
140
+ className="h-full w-full object-cover"
141
+ />
142
+ )}
143
+ <span
144
+ title={openNow ? labels.openNow : undefined}
145
+ className={cn(
146
+ "absolute right-[5px] top-[5px] h-[7px] w-[7px] rounded-full ring-2 ring-white/85",
147
+ openNow ? "bg-[var(--free,oklch(0.55_0.13_155))]" : "bg-[oklch(0.8_0.01_60)]"
148
+ )}
149
+ />
150
+ </span>
151
+
152
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5 lg:w-[226px] lg:flex-none">
153
+ <span className="truncate text-[14.5px] font-bold tracking-[-0.01em] lg:text-[15.5px]">
154
+ {place.name}
155
+ </span>
156
+ <span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
157
+ {place.rating && (
158
+ <>
159
+ <Star className="h-[11px] w-[11px] shrink-0" aria-hidden />
160
+ {place.rating.rating}
161
+ {kitchen.metaLabel && " · "}
162
+ </>
163
+ )}
164
+ {kitchen.metaLabel}
165
+ </span>
166
+ {kitchen.hoursLabel && (
167
+ <span
168
+ className={cn(
169
+ "mt-[3px] inline-flex h-[21px] w-max items-center gap-[5px] rounded-md px-2 text-[10.5px] font-bold",
170
+ openNow
171
+ ? "bg-[oklch(0.96_0.02_155)] text-[var(--free,oklch(0.45_0.13_155))]"
172
+ : "bg-muted text-muted-foreground"
173
+ )}
174
+ >
175
+ <Clock className="h-2.5 w-2.5 shrink-0" aria-hidden />
176
+ {kitchen.hoursLabel}
177
+ </span>
178
+ )}
179
+ </div>
180
+
181
+ {dish && (
182
+ /* DESKTOP ONLY, where it is a column of its own beside the venue.
183
+ On the phone it wrapped to a third line under the row — kicker, dish,
184
+ mention count — which turned a four-line venue row into a seven-line
185
+ block and, ten rows down, buried the venue names the list exists to
186
+ be scanned for. The dish is not what a reader is choosing here; it is
187
+ one tap away on the venue's own page, with room to state it.
188
+
189
+ `order-5` puts it last in the visual order without moving it in the
190
+ DOM, where it belongs to the venue. */
191
+ <div className="hidden min-w-0 basis-full pl-[57px] lg:order-none lg:block lg:flex-1 lg:basis-auto lg:pl-0">
192
+ <span
193
+ className={cn(
194
+ "flex items-center gap-1.5 text-[9px] font-extrabold uppercase tracking-[0.1em]",
195
+ dish.matched ? "text-accent" : "text-[oklch(0.72_0.01_60)]"
196
+ )}
197
+ >
198
+ {dish.kicker}
199
+ {dish.matched && dish.matchLabel && (
200
+ <span className="inline-flex items-center gap-1 rounded-[5px] bg-accent-subtle px-1.5 py-px text-[10.5px] font-bold normal-case tracking-normal text-accent">
201
+ <Check className="h-2.5 w-2.5 shrink-0" aria-hidden />
202
+ {dish.matchLabel}
203
+ </span>
204
+ )}
205
+ </span>
206
+
207
+ {dish.href ? (
208
+ <LinkComponent
209
+ href={dish.href}
210
+ aria-label={labels.openDish(dish.name)}
211
+ className="relative z-[2] mt-0.5 block truncate font-serif text-[13.5px] italic underline decoration-[oklch(0.84_0.01_60)] decoration-1 underline-offset-[3px] transition-colors hover:text-accent hover:decoration-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:text-[15px]"
212
+ >
213
+ {dish.name}
214
+ </LinkComponent>
215
+ ) : (
216
+ <span className="mt-0.5 block truncate font-serif text-[13.5px] italic lg:text-[15px]">
217
+ {dish.name}
218
+ </span>
219
+ )}
220
+
221
+ {dish.meta && (
222
+ <span className="mt-[3px] flex items-center gap-1 text-[11.5px] text-muted-foreground">
223
+ {!dish.matched && <MessageSquare className="h-2.5 w-2.5 shrink-0" aria-hidden />}
224
+ {dish.meta}
225
+ </span>
226
+ )}
227
+ </div>
228
+ )}
229
+
230
+ <div className="ml-auto flex shrink-0 items-center gap-[11px] lg:ml-0">
231
+ {kitchen.priceLabel && (
232
+ <span className="text-base font-extrabold tabular-nums text-accent">
233
+ {kitchen.priceLabel}
234
+ </span>
235
+ )}
236
+ <ChevronRight className="h-[15px] w-[15px] shrink-0 text-muted-foreground" aria-hidden />
237
+ </div>
238
+ </div>
239
+ );
240
+ }
241
+
242
+ // ─── Skeleton ───────────────────────────────────────────────────────────────
243
+
244
+ /**
245
+ * The list's own loading state, at the row's own footprint.
246
+ *
247
+ * It lives HERE, beside the component, and that is the whole point: a skeleton
248
+ * kept in the consuming app drifts the moment the row's padding or thumbnail
249
+ * size changes, and the drift is invisible until a page reflows on load. The
250
+ * measurements below are the same literals the row above uses — change one and
251
+ * the diff shows you the other.
252
+ */
253
+ export function MenuKitchenListSkeleton({
254
+ rows = 10,
255
+ className,
256
+ }: {
257
+ rows?: number;
258
+ className?: string;
259
+ }) {
260
+ return (
261
+ <div className={cn("flex flex-col", className)} aria-hidden>
262
+ {Array.from({ length: rows }, (_, index) => (
263
+ <div key={index}>
264
+ {index > 0 && <Separator />}
265
+ <div className="flex flex-wrap items-center gap-[11px] py-3 lg:flex-nowrap lg:gap-4 lg:py-3.5">
266
+ <Skeleton className="h-[46px] w-[46px] rounded-[11px] lg:h-[58px] lg:w-[58px] lg:rounded-[13px]" />
267
+ <div className="flex min-w-0 flex-1 flex-col gap-1.5 lg:w-[226px] lg:flex-none">
268
+ <Skeleton className="h-4 w-[60%]" />
269
+ <Skeleton className="h-3 w-[45%]" />
270
+ <Skeleton className="h-[21px] w-[120px] rounded-md" />
271
+ </div>
272
+ {/* Desktop only, as the real row's dish column now is. */}
273
+ <div className="hidden min-w-0 basis-full pl-[57px] lg:order-none lg:block lg:flex-1 lg:basis-auto lg:pl-0">
274
+ <Skeleton className="h-2.5 w-[90px]" />
275
+ <Skeleton className="mt-1.5 h-4 w-[70%]" />
276
+ <Skeleton className="mt-1.5 h-3 w-[40%]" />
277
+ </div>
278
+ <Skeleton className="ml-auto h-5 w-[56px] lg:ml-0" />
279
+ </div>
280
+ </div>
281
+ ))}
282
+ </div>
283
+ );
284
+ }
@@ -20,10 +20,17 @@
20
20
 
21
21
  import type {
22
22
  MenuConceptPreview,
23
+ MenuFamilyBlockPreview,
23
24
  MenuFamilyPreview,
25
+ MenuFilterChip,
26
+ MenuIndexEntry,
27
+ MenuIntentFigure,
24
28
  MenuIntentTile,
25
29
  MenuItemPreview,
30
+ MenuKitchenPreview,
31
+ MenuLoosenOption,
26
32
  MenuVenuePreview,
33
+ QaItem,
27
34
  } from "./menu-types";
28
35
  import type { PlacePreview } from "./place-types";
29
36
 
@@ -345,18 +352,6 @@ export const mockFamily: MenuFamilyPreview = {
345
352
  price_high: 84,
346
353
  price_median: 49,
347
354
  venue_photo_urls: [],
348
- signature: {
349
- item: {
350
- id: "f1",
351
- slug: "truffle-tagliatelle",
352
- name: "Truffle Tagliatelle",
353
- kind: "dish",
354
- mentions: 17,
355
- sizes_and_prices: [{ size: 320, price: 62 }],
356
- },
357
- why: "Salt & Stone",
358
- href: "/menu/dishes/truffle-tagliatelle",
359
- },
360
355
  };
361
356
 
362
357
  /** The drinks counterpart, and the reason `chip.count` is optional. */
@@ -375,17 +370,6 @@ export const mockDrinkFamily: MenuFamilyPreview = {
375
370
  price_high: 54,
376
371
  price_median: 28,
377
372
  venue_photo_urls: [],
378
- signature: {
379
- item: {
380
- id: "f2",
381
- slug: "house-negroni",
382
- name: "House negroni",
383
- kind: "drink",
384
- mentions: 16,
385
- sizes_and_prices: [{ size: 150, price: 32 }],
386
- },
387
- why: "The Copper Yard",
388
- },
389
373
  };
390
374
 
391
375
  const intentGradient = (hue: number) =>
@@ -415,3 +399,194 @@ export const mockHubLabels = {
415
399
  openNow: "Open now",
416
400
  until: "until",
417
401
  };
402
+
403
+ /* ── intent page ────────────────────────────────────────────────────────── */
404
+
405
+ /**
406
+ * The intent page's kitchen rows.
407
+ *
408
+ * The gaps are the gaps the API actually has, and they are the reason the row
409
+ * degrades the way it does: `must_try` entries arrive from a LIST endpoint, so
410
+ * a third of them carry no `slug` (nothing to open — the dish line goes inert)
411
+ * and none of them carry `mentions` (only `review_ids.length`, which is absent
412
+ * for census items — so the count line disappears rather than reading zero).
413
+ * A layout that survives these survives production.
414
+ */
415
+ export const mockKitchens: MenuKitchenPreview[] = [
416
+ {
417
+ place: placeBySlug("salt-and-stone"),
418
+ href: "/places/salt-and-stone",
419
+ metaLabel: "courtyard · Centru",
420
+ hoursLabel: "kitchen until 23:00",
421
+ priceLabel: "62 lei",
422
+ dish: {
423
+ name: "Truffle Tagliatelle",
424
+ href: "/menu/dishes/truffle-tagliatelle",
425
+ kicker: "MOST NAMED HERE",
426
+ meta: "17 reviews name it",
427
+ },
428
+ },
429
+ {
430
+ place: placeBySlug("the-copper-yard"),
431
+ href: "/places/the-copper-yard",
432
+ metaLabel: "pizzeria · Centru",
433
+ hoursLabel: "kitchen until 23:30",
434
+ priceLabel: "54 lei",
435
+ dish: {
436
+ name: "Tagliatelle cu Hribi",
437
+ href: "/menu/dishes/tagliatelle-cu-hribi",
438
+ kicker: "MOST NAMED HERE",
439
+ meta: "14 reviews name it",
440
+ },
441
+ },
442
+ {
443
+ // Closed, and its must-try has no live menu row — the dish line is inert.
444
+ place: { ...placeBySlug("the-quiet-hours"), open_now: false },
445
+ href: "/places/the-quiet-hours",
446
+ metaLabel: "bistro · Gheorgheni",
447
+ hoursLabel: "closed · opens 12:00",
448
+ priceLabel: "58 lei",
449
+ dish: { name: "Risotto ai Funghi", kicker: "MOST NAMED HERE" },
450
+ },
451
+ {
452
+ // No dish at all: the row must still read as a venue.
453
+ place: placeBySlug("green-room-canteen"),
454
+ href: "/places/green-room-canteen",
455
+ metaLabel: "bistro · Centru",
456
+ priceLabel: "38 lei",
457
+ },
458
+ ];
459
+
460
+ /** The same rows in a filtered state — each answering the filter, not volunteering. */
461
+ export const mockMatchedKitchens: MenuKitchenPreview[] = mockKitchens.slice(0, 3).map((kitchen) => ({
462
+ ...kitchen,
463
+ dish: kitchen.dish && {
464
+ ...kitchen.dish,
465
+ kicker: "WHY IT MATCHES",
466
+ meta: "mid-range · 300g",
467
+ matched: true,
468
+ matchLabel: "vegetarian",
469
+ },
470
+ }));
471
+
472
+ export const mockIntentFigures: MenuIntentFigure[] = [
473
+ { key: "total", value: "247", label: "kitchens" },
474
+ { key: "pizza", value: "120", label: "make a pizza", onSelect: () => {} },
475
+ { key: "pasta", value: "96", label: "make pasta", onSelect: () => {} },
476
+ { key: "open", value: "58", label: "open now", onSelect: () => {} },
477
+ ];
478
+
479
+ /**
480
+ * Note there is no zero-count chip here, and that is not an oversight: the
481
+ * facets endpoint omits values that would match nothing, so a chip can only
482
+ * become `empty` client-side, after it was offered and the reader's next filter
483
+ * emptied it. The `Dead` story constructs that case deliberately.
484
+ */
485
+ export const mockFilterChips: MenuFilterChip[] = [
486
+ { key: "open", label: "Open now", count: 58, active: true },
487
+ { key: "price", label: "Mid-range", count: 96 },
488
+ { key: "terrace", label: "Terrace", count: 31 },
489
+ { key: "veg", label: "Has a vegetarian dish", count: 84 },
490
+ { key: "late", label: "Serving past midnight", count: 4 },
491
+ { key: "centru", label: "Centru", count: 90 },
492
+ ];
493
+
494
+ export const mockLoosenOptions: MenuLoosenOption[] = [
495
+ { key: "open", label: "Drop “Open now”", gainLabel: "+8 kitchens" },
496
+ { key: "price", label: "Drop “Mid-range”", gainLabel: "+7 kitchens" },
497
+ { key: "veg", label: "Drop “Vegetarian”", gainLabel: "+2 kitchens" },
498
+ ];
499
+
500
+ /* ── the browse index ───────────────────────────────────────────────────── */
501
+
502
+ /**
503
+ * The long tail is the point of these fixtures.
504
+ *
505
+ * Cluj's cuisine facet runs from 457 down to 1, and twenty-two of the
506
+ * thirty-eight have fewer than twenty places. A layout tested only on the head
507
+ * of that list looks fine and then ships a page whose last two columns are
508
+ * single digits.
509
+ */
510
+ export const mockIndexEntries: MenuIndexEntry[] = [
511
+ ["Breakfast & brunch", 314],
512
+ ["Romanian", 299],
513
+ ["Bakery", 250],
514
+ ["Italian", 247],
515
+ ["Pizza", 245],
516
+ ["Fast food", 229],
517
+ ["Vegetarian", 172],
518
+ ["American", 153],
519
+ ["Vegan", 108],
520
+ ["Mediterranean", 78],
521
+ ["Seafood", 77],
522
+ ["Eastern European", 67],
523
+ ["Fusion", 61],
524
+ ["Middle Eastern", 58],
525
+ ["BBQ", 57],
526
+ ["Deli", 51],
527
+ ["Steakhouse", 49],
528
+ ["Japanese", 34],
529
+ ["French", 26],
530
+ ["Sushi", 23],
531
+ ["Turkish", 22],
532
+ ["Chinese", 14],
533
+ ["Greek", 12],
534
+ ["Indian", 11],
535
+ ["Thai", 7],
536
+ ["German", 5],
537
+ ["Lebanese", 3],
538
+ ["Portuguese", 1],
539
+ ].map(([name, count], index) => ({
540
+ slug: String(name).toLowerCase().replace(/[^a-z]+/g, "-"),
541
+ name: String(name),
542
+ count: Number(count),
543
+ href: `/menu/cuisines/${String(name).toLowerCase().replace(/[^a-z]+/g, "-")}`,
544
+ gradient: Object.values(mockGradients)[index % 5],
545
+ }));
546
+
547
+ export const mockFamilyBlock: MenuFamilyBlockPreview = {
548
+ slug: "italian",
549
+ name: "Italian",
550
+ venueCount: 247,
551
+ chips: [
552
+ { slug: "pizza", label: "Pizza", count: 120 },
553
+ { slug: "pasta", label: "Pasta", count: 96 },
554
+ { slug: "salad", label: "Salad", count: 88 },
555
+ ],
556
+ priceBandLabel: "22–86 lei",
557
+ photoUrls: [],
558
+ // Three, as the cuisines index sends, and stripped the same way: no dish and
559
+ // no price. The dish column is ~110px inside this card, which truncates the
560
+ // name and wraps its kicker to three lines; the price is already the band
561
+ // above.
562
+ venues: mockKitchens
563
+ .slice(0, 3)
564
+ .map((kitchen) => ({ ...kitchen, dish: undefined, priceLabel: undefined })),
565
+ href: "/menu/cuisines/italian",
566
+ };
567
+
568
+ /** No photographs, no venue, no price — the degraded block still reads. */
569
+ export const mockBareFamilyBlock: MenuFamilyBlockPreview = {
570
+ slug: "portuguese",
571
+ name: "Portuguese",
572
+ venueCount: 1,
573
+ href: "/menu/cuisines/portuguese",
574
+ };
575
+
576
+ export const mockQa: QaItem[] = [
577
+ {
578
+ question: "Which Italian kitchen is best in Cluj?",
579
+ answer:
580
+ "Salt & Stone is named most often — seventeen reviews single out the truffle tagliatelle. The Copper Yard is second on volume and about eight lei cheaper across the menu.",
581
+ },
582
+ {
583
+ question: "What does an Italian main cost?",
584
+ answer:
585
+ "Pasta runs 38 to 84 lei and pizza 30 to 68 across these kitchens. Ninety-six of them sit in the mid-range price band.",
586
+ },
587
+ {
588
+ question: "Anywhere open late?",
589
+ answer:
590
+ "Four keep the kitchen going past midnight — The Copper Yard until 00:00 is the latest of the Italian rooms.",
591
+ },
592
+ ];