@bearmenu/ui 0.8.8 → 0.8.10

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 (206) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/chunk-2XCZJWTV.js +121 -0
  3. package/dist/chunk-5RWTOT5Y.js +72 -0
  4. package/dist/chunk-62ES6N32.js +27 -0
  5. package/dist/chunk-AKQ57NXY.js +58 -0
  6. package/dist/chunk-CLLY6XB3.js +41 -0
  7. package/dist/chunk-DTXNDLSE.js +125 -0
  8. package/dist/chunk-F6FMZJDG.js +113 -0
  9. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  10. package/dist/chunk-MZL7CGCL.js +109 -0
  11. package/dist/chunk-Q6QH52LJ.js +38 -0
  12. package/dist/chunk-SY6RV476.js +48 -0
  13. package/dist/chunk-TH6W2PWG.js +213 -0
  14. package/dist/chunk-UDN7UHJW.js +60 -0
  15. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  16. package/dist/components/alert.d.ts +2 -2
  17. package/dist/components/badge.d.ts +1 -1
  18. package/dist/components/concept-compare-rail.d.ts +50 -0
  19. package/dist/components/concept-compare-rail.js +5 -0
  20. package/dist/components/concept-compare.d.ts +110 -0
  21. package/dist/components/concept-compare.js +238 -0
  22. package/dist/components/concept-convergence.d.ts +41 -0
  23. package/dist/components/concept-convergence.js +53 -0
  24. package/dist/components/convergence.d.ts +25 -0
  25. package/dist/components/convergence.js +4 -0
  26. package/dist/components/date-input.d.ts +1 -1
  27. package/dist/components/event-card.js +1 -1
  28. package/dist/components/event-compact-row.d.ts +1 -1
  29. package/dist/components/event-compact-row.js +1 -1
  30. package/dist/components/event-detail-panel.d.ts +171 -25
  31. package/dist/components/event-detail-panel.js +472 -175
  32. package/dist/components/event-index-row.js +1 -1
  33. package/dist/components/event-list-row-wide.js +2 -2
  34. package/dist/components/event-list-row.js +2 -2
  35. package/dist/components/explainer-steps.d.ts +34 -0
  36. package/dist/components/explainer-steps.js +36 -0
  37. package/dist/components/glass-ground.d.ts +66 -0
  38. package/dist/components/glass-ground.js +3 -0
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +30 -0
  41. package/dist/components/hue-chip.d.ts +70 -0
  42. package/dist/components/hue-chip.js +3 -0
  43. package/dist/components/kitchen-dossier.d.ts +88 -0
  44. package/dist/components/kitchen-dossier.js +187 -0
  45. package/dist/components/menu-dead-end.d.ts +56 -0
  46. package/dist/components/menu-dead-end.js +49 -0
  47. package/dist/components/menu-family-block.d.ts +99 -0
  48. package/dist/components/menu-family-block.js +155 -0
  49. package/dist/components/menu-family-card.d.ts +65 -0
  50. package/dist/components/menu-family-card.js +79 -0
  51. package/dist/components/menu-field.d.ts +57 -0
  52. package/dist/components/menu-field.js +93 -0
  53. package/dist/components/menu-filter-chips.d.ts +86 -0
  54. package/dist/components/menu-filter-chips.js +122 -0
  55. package/dist/components/menu-group-rows.d.ts +53 -0
  56. package/dist/components/menu-group-rows.js +67 -0
  57. package/dist/components/menu-hub-hero.d.ts +65 -0
  58. package/dist/components/menu-hub-hero.js +103 -0
  59. package/dist/components/menu-index-header.d.ts +37 -0
  60. package/dist/components/menu-index-header.js +68 -0
  61. package/dist/components/menu-index-list.d.ts +48 -0
  62. package/dist/components/menu-index-list.js +96 -0
  63. package/dist/components/menu-intent-hero.d.ts +69 -0
  64. package/dist/components/menu-intent-hero.js +87 -0
  65. package/dist/components/menu-intent-tiles.d.ts +31 -0
  66. package/dist/components/menu-intent-tiles.js +50 -0
  67. package/dist/components/menu-item.d.ts +127 -0
  68. package/dist/components/menu-item.js +5 -0
  69. package/dist/components/menu-kitchen-list.d.ts +82 -0
  70. package/dist/components/menu-kitchen-list.js +5 -0
  71. package/dist/components/menu-mocks.d.ts +90 -0
  72. package/dist/components/menu-mocks.js +504 -0
  73. package/dist/components/menu-types.d.ts +421 -0
  74. package/dist/components/menu-types.js +1 -0
  75. package/dist/components/motion-icon-controls.js +2 -2
  76. package/dist/components/motion-icon-set.js +21 -9
  77. package/dist/components/motion-icon.d.ts +22 -1
  78. package/dist/components/motion-icon.js +1 -1
  79. package/dist/components/open-now-list.d.ts +40 -0
  80. package/dist/components/open-now-list.js +69 -0
  81. package/dist/components/photo-ground.d.ts +64 -0
  82. package/dist/components/photo-ground.js +3 -0
  83. package/dist/components/place-about-tab.js +2 -2
  84. package/dist/components/place-details-mobile.js +2 -2
  85. package/dist/components/place-types.d.ts +112 -1
  86. package/dist/components/qa-block.d.ts +36 -0
  87. package/dist/components/qa-block.js +24 -0
  88. package/dist/components/scroll-fade.js +3 -121
  89. package/dist/components/signature-chip.d.ts +37 -0
  90. package/dist/components/signature-chip.js +4 -0
  91. package/dist/components/snap-rail.d.ts +48 -5
  92. package/dist/components/snap-rail.js +3 -51
  93. package/dist/components/stat-banner.d.ts +47 -0
  94. package/dist/components/stat-banner.js +61 -0
  95. package/dist/components/story.d.ts +34 -0
  96. package/dist/components/story.js +3 -0
  97. package/dist/components/tag.d.ts +2 -2
  98. package/dist/components/text.d.ts +1 -1
  99. package/dist/components/top-comparison.d.ts +28 -0
  100. package/dist/components/top-comparison.js +76 -0
  101. package/dist/components/top-cross-link.d.ts +34 -0
  102. package/dist/components/top-cross-link.js +101 -0
  103. package/dist/components/top-ranking-entry.d.ts +53 -0
  104. package/dist/components/top-ranking-entry.js +217 -0
  105. package/dist/components/top-ranking-hero.d.ts +61 -0
  106. package/dist/components/top-ranking-hero.js +99 -0
  107. package/dist/components/top-ranking-legend.d.ts +69 -0
  108. package/dist/components/top-ranking-legend.js +124 -0
  109. package/dist/components/top-types.d.ts +109 -0
  110. package/dist/components/top-types.js +1 -0
  111. package/dist/components/venue-grade-dimensions.d.ts +38 -0
  112. package/dist/components/venue-grade-dimensions.js +3 -0
  113. package/dist/components/venue-quotes.d.ts +32 -0
  114. package/dist/components/venue-quotes.js +3 -0
  115. package/dist/components/venue-wall.d.ts +41 -0
  116. package/dist/components/venue-wall.js +118 -0
  117. package/dist/components/weekday-theme-banner.d.ts +70 -0
  118. package/dist/components/weekday-theme-banner.js +127 -0
  119. package/dist/components/weekend-panel.js +2 -2
  120. package/package.json +1 -1
  121. package/src/components/colour-dictionary.stories.tsx +227 -0
  122. package/src/components/concept-compare-rail.tsx +131 -0
  123. package/src/components/concept-compare.stories.tsx +172 -0
  124. package/src/components/concept-compare.tsx +443 -0
  125. package/src/components/concept-convergence.stories.tsx +53 -0
  126. package/src/components/concept-convergence.tsx +90 -0
  127. package/src/components/convergence.stories.tsx +54 -0
  128. package/src/components/convergence.tsx +177 -0
  129. package/src/components/event-compact-row.tsx +1 -1
  130. package/src/components/event-detail-panel.stories.tsx +205 -97
  131. package/src/components/event-detail-panel.test.tsx +181 -0
  132. package/src/components/event-detail-panel.tsx +703 -185
  133. package/src/components/explainer-steps.stories.tsx +46 -0
  134. package/src/components/explainer-steps.tsx +74 -0
  135. package/src/components/glass-ground.tsx +129 -0
  136. package/src/components/hub-surface.stories.tsx +68 -0
  137. package/src/components/hub-surface.tsx +50 -0
  138. package/src/components/hue-chip.stories.tsx +76 -0
  139. package/src/components/hue-chip.test.tsx +62 -0
  140. package/src/components/hue-chip.tsx +99 -0
  141. package/src/components/kitchen-dossier.stories.tsx +108 -0
  142. package/src/components/kitchen-dossier.tsx +322 -0
  143. package/src/components/menu-dead-end.stories.tsx +67 -0
  144. package/src/components/menu-dead-end.tsx +127 -0
  145. package/src/components/menu-family-block.stories.tsx +91 -0
  146. package/src/components/menu-family-block.tsx +314 -0
  147. package/src/components/menu-family-card.stories.tsx +120 -0
  148. package/src/components/menu-family-card.tsx +191 -0
  149. package/src/components/menu-field.stories.tsx +74 -0
  150. package/src/components/menu-field.tsx +199 -0
  151. package/src/components/menu-filter-chips.stories.tsx +107 -0
  152. package/src/components/menu-filter-chips.tsx +238 -0
  153. package/src/components/menu-group-rows.stories.tsx +54 -0
  154. package/src/components/menu-group-rows.tsx +133 -0
  155. package/src/components/menu-hub-hero.stories.tsx +92 -0
  156. package/src/components/menu-hub-hero.tsx +187 -0
  157. package/src/components/menu-index-header.stories.tsx +72 -0
  158. package/src/components/menu-index-header.tsx +125 -0
  159. package/src/components/menu-index-list.stories.tsx +50 -0
  160. package/src/components/menu-index-list.tsx +149 -0
  161. package/src/components/menu-intent-hero.stories.tsx +67 -0
  162. package/src/components/menu-intent-hero.tsx +189 -0
  163. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  164. package/src/components/menu-intent-tiles.tsx +76 -0
  165. package/src/components/menu-item.tsx +416 -0
  166. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  167. package/src/components/menu-kitchen-list.test.tsx +78 -0
  168. package/src/components/menu-kitchen-list.tsx +284 -0
  169. package/src/components/menu-mocks.ts +592 -0
  170. package/src/components/menu-types.ts +442 -0
  171. package/src/components/motion-icon-controls.tsx +11 -11
  172. package/src/components/motion-icon-set.tsx +36 -8
  173. package/src/components/motion-icon.test.tsx +23 -1
  174. package/src/components/motion-icon.tsx +20 -0
  175. package/src/components/open-now-list.stories.tsx +76 -0
  176. package/src/components/open-now-list.tsx +127 -0
  177. package/src/components/photo-ground.tsx +147 -0
  178. package/src/components/place-types.ts +112 -0
  179. package/src/components/qa-block.stories.tsx +43 -0
  180. package/src/components/qa-block.tsx +60 -0
  181. package/src/components/signature-chip.tsx +77 -0
  182. package/src/components/snap-rail.tsx +133 -9
  183. package/src/components/stat-banner.stories.tsx +100 -0
  184. package/src/components/stat-banner.tsx +117 -0
  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.stories.tsx +83 -0
  199. package/src/components/venue-grade-dimensions.tsx +91 -0
  200. package/src/components/venue-quotes.stories.tsx +59 -0
  201. package/src/components/venue-quotes.tsx +64 -0
  202. package/src/components/venue-wall.stories.tsx +78 -0
  203. package/src/components/venue-wall.tsx +185 -0
  204. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  205. package/src/components/weekday-theme-banner.tsx +231 -0
  206. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,187 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * MenuHubHero — the claim that opens a menu hub, proven by live figures.
5
+ *
6
+ * A statement rather than a count ("Nobody should have to walk in blind"),
7
+ * over a dark ground, with the catalogue's real numbers under it and one proof
8
+ * line that demonstrates what the catalogue can do that a single menu cannot.
9
+ *
10
+ * ## Why the ground is a texture and not a photograph
11
+ *
12
+ * Menu-item photography is AI-generated and never shown, and one venue
13
+ * photograph behind a claim about every venue in the city argues the wrong
14
+ * thing. The tile grid reads as "many menus" without being any of them.
15
+ * Callers that DO have a real, licensed image for the slot can pass
16
+ * `backgroundUrl` — venue photography, never a plate.
17
+ *
18
+ * ## Figures
19
+ *
20
+ * `figures` is a list, not four fixed slots: the drinks tab has different ones
21
+ * and the count changes when a number turns out to have nothing behind it.
22
+ * Every value is a pre-formatted string because thousands separators are
23
+ * locale-dependent and the DS owns no locale.
24
+ *
25
+ * Server-safe.
26
+ */
27
+
28
+ export type MenuHubHeroFigure = {
29
+ /** Pre-formatted, e.g. "19,062". */
30
+ value: string;
31
+ label: string;
32
+ };
33
+
34
+ export type MenuHubHeroProps = {
35
+ /** Small caps line above the headline, e.g. "397 MENUS IN CLUJ-NAPOCA, READ IN FULL". */
36
+ kicker?: string;
37
+ /** Icon for the kicker, resolved by the caller. */
38
+ kickerIcon?: React.ReactNode;
39
+ headline: string;
40
+ body?: string;
41
+ figures?: MenuHubHeroFigure[];
42
+ /**
43
+ * The proof line — one demonstrated fact, e.g. "A margherita: 87 kitchens
44
+ * make one, 10 to 120 lei". Composed by the caller; clause order is
45
+ * locale-dependent.
46
+ */
47
+ proof?: React.ReactNode;
48
+ proofIcon?: React.ReactNode;
49
+ /** The action beside the proof line. Pass a rendered link or button. */
50
+ action?: React.ReactNode;
51
+ /** Real, licensed photograph for the ground. Venue imagery only — see above. */
52
+ backgroundUrl?: string;
53
+ /**
54
+ * Tiles for the mosaic ground — generic, cinematic, licensed stills, served
55
+ * from the consumer's own origin.
56
+ *
57
+ * A mosaic rather than one photograph because the hero's claim is about EVERY
58
+ * kitchen in the city: a single image makes it about one of them. Given fewer
59
+ * tiles than cells the list repeats, so a caller can ship six and still fill
60
+ * a 1440px hero.
61
+ */
62
+ tileUrls?: string[];
63
+ className?: string;
64
+ };
65
+
66
+ /** Enough cells to fill the widest hero without the grid reading as a pattern. */
67
+ const TEXTURE_CELLS = 28;
68
+
69
+ export function MenuHubHero({
70
+ kicker,
71
+ kickerIcon,
72
+ headline,
73
+ body,
74
+ figures,
75
+ proof,
76
+ proofIcon,
77
+ action,
78
+ backgroundUrl,
79
+ tileUrls,
80
+ className,
81
+ }: MenuHubHeroProps) {
82
+ return (
83
+ <div
84
+ className={cn(
85
+ "relative flex min-h-[260px] overflow-hidden rounded-[20px] bg-[oklch(0.21_0.012_55)] lg:min-h-[440px]",
86
+ className
87
+ )}
88
+ >
89
+ {backgroundUrl ? (
90
+ // eslint-disable-next-line @next/next/no-img-element
91
+ <img
92
+ src={backgroundUrl}
93
+ alt=""
94
+ aria-hidden
95
+ className="absolute inset-0 h-full w-full object-cover opacity-[0.42]"
96
+ />
97
+ ) : (
98
+ <div
99
+ aria-hidden
100
+ className="absolute inset-0 grid auto-rows-fr grid-cols-4 gap-0.5 lg:grid-cols-7"
101
+ >
102
+ {Array.from({ length: TEXTURE_CELLS }, (_, cell) => {
103
+ const url = tileUrls?.length ? tileUrls[cell % tileUrls.length] : undefined;
104
+
105
+ return url ? (
106
+ // eslint-disable-next-line @next/next/no-img-element
107
+ <img
108
+ key={cell}
109
+ src={url}
110
+ alt=""
111
+ loading="lazy"
112
+ decoding="async"
113
+ className="h-full w-full object-cover"
114
+ />
115
+ ) : (
116
+ <div
117
+ key={cell}
118
+ className="bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.42]"
119
+ />
120
+ );
121
+ })}
122
+ </div>
123
+ )}
124
+
125
+ {/*
126
+ Heavier over a photo mosaic than it was over flat texture: twelve
127
+ cinematic stills at full strength turn a claim into a collage and the
128
+ headline stops being the loudest thing on the banner. This keeps the
129
+ depth and gives the type its contrast back.
130
+ */}
131
+ <div
132
+ aria-hidden
133
+ className={cn(
134
+ "absolute inset-0",
135
+ tileUrls?.length || backgroundUrl
136
+ ? "bg-[linear-gradient(100deg,oklch(0.16_0.012_55_/_0.96)_0%,oklch(0.16_0.012_55_/_0.88)_52%,oklch(0.16_0.012_55_/_0.72)_100%)]"
137
+ : "bg-[linear-gradient(100deg,oklch(0.18_0.012_55_/_0.95)_0%,oklch(0.18_0.012_55_/_0.72)_52%,oklch(0.18_0.012_55_/_0.3)_100%)]"
138
+ )}
139
+ />
140
+
141
+ <div className="relative z-[1] flex w-full flex-col justify-center gap-3 p-5 text-white lg:gap-4 lg:px-12 lg:py-11">
142
+ {kicker && (
143
+ <span className="inline-flex w-max items-center gap-[7px] rounded-full bg-white/15 px-3 py-1.5 text-[11px] font-extrabold tracking-[0.11em]">
144
+ {kickerIcon}
145
+ {kicker}
146
+ </span>
147
+ )}
148
+
149
+ <h1 className="max-w-[640px] font-serif text-[32px] font-semibold leading-[1.04] tracking-[-0.025em] lg:text-[50px]">
150
+ {headline}
151
+ </h1>
152
+
153
+ {body && (
154
+ <p className="hidden max-w-[540px] text-sm leading-[1.6] opacity-[0.84] lg:block lg:text-[15.5px]">
155
+ {body}
156
+ </p>
157
+ )}
158
+
159
+ {figures && figures.length > 0 && (
160
+ // Two by two on the phone: four figures in one row wrapped to three and an orphan.
161
+ <div className="grid grid-cols-2 gap-x-6 gap-y-3 pt-3 lg:flex lg:flex-wrap lg:gap-x-8 lg:gap-y-4 lg:pt-1.5">
162
+ {figures.map((figure) => (
163
+ <div key={figure.label}>
164
+ <div className="text-2xl font-black leading-none tracking-[-0.03em] tabular-nums lg:text-[32px]">
165
+ {figure.value}
166
+ </div>
167
+ <div className="mt-[5px] text-xs opacity-[0.72]">{figure.label}</div>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ )}
172
+
173
+ {(proof || action) && (
174
+ <div className="mt-auto flex flex-wrap items-center gap-3 pt-5">
175
+ {proof && (
176
+ <span className="inline-flex min-h-11 items-center gap-2.5 rounded-full bg-white/10 px-4 py-2 text-[13px] backdrop-blur-[6px]">
177
+ {proofIcon}
178
+ {proof}
179
+ </span>
180
+ )}
181
+ {action}
182
+ </div>
183
+ )}
184
+ </div>
185
+ </div>
186
+ );
187
+ }
@@ -0,0 +1,72 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { UtensilsCrossed, Wine } from "lucide-react";
3
+ import { MenuIndexHeader, MenuJumpStrip } from "./menu-index-header";
4
+ import { SegmentedNav } from "./segmented-nav";
5
+ import { mockIndexEntries } from "./menu-mocks";
6
+
7
+ const scopeSwitch = (
8
+ <SegmentedNav
9
+ label="Menu scope"
10
+ activeId="food"
11
+ items={[
12
+ { id: "food", href: "/menu/cuisines", label: "Food", icon: <UtensilsCrossed className="h-4 w-4" /> },
13
+ { id: "drinks", href: "/menu/drinks/families", label: "Drinks", icon: <Wine className="h-4 w-4" /> },
14
+ ]}
15
+ />
16
+ );
17
+
18
+ const meta: Meta<typeof MenuIndexHeader> = {
19
+ title: "Menu/MenuIndexHeader",
20
+ component: MenuIndexHeader,
21
+ tags: ["autodocs"],
22
+ args: {
23
+ eyebrow: "MENU · CLUJ-NAPOCA",
24
+ title: "Browse by cuisine",
25
+ count: "38 cuisines",
26
+ subtitle: "Kitchens, the plates that define them, and what they cost.",
27
+ scopeSwitch,
28
+ },
29
+ decorators: [
30
+ (Story) => (
31
+ <div className="w-[1100px] bg-background p-4">
32
+ <Story />
33
+ </div>
34
+ ),
35
+ ],
36
+ parameters: {
37
+ docs: {
38
+ description: {
39
+ component:
40
+ "The hub's own section title, opened up — so the page reads as that section rather than as somewhere new, which is what the *All 38 cuisines* link promised. The scope switch is a slot, not a prop set: the two scopes are two ROUTES, so the caller passes a `SegmentedNav` with real hrefs and the DS stays out of routing.",
41
+ },
42
+ },
43
+ },
44
+ };
45
+
46
+ export default meta;
47
+ type Story = StoryObj<typeof MenuIndexHeader>;
48
+
49
+ export const Default: Story = {};
50
+
51
+ export const Drinks: Story = {
52
+ args: {
53
+ title: "Where to go out",
54
+ count: "5 kinds of night",
55
+ subtitle: "Bars, the glass that defines them, and what it costs.",
56
+ },
57
+ };
58
+
59
+ /** The strip lists only the families drawn in full — otherwise it is the index, twice. */
60
+ export const JumpStrip: StoryObj<typeof MenuJumpStrip> = {
61
+ render: () => <MenuJumpStrip entries={mockIndexEntries.slice(0, 8)} ariaLabel="Jump to a cuisine" />,
62
+ };
63
+
64
+ export const Mobile: Story = {
65
+ decorators: [
66
+ (Story) => (
67
+ <div className="w-[390px] bg-background p-3">
68
+ <Story />
69
+ </div>
70
+ ),
71
+ ],
72
+ };
@@ -0,0 +1,125 @@
1
+ import { cn } from "../lib/utils";
2
+ import { ScrollFade } from "./scroll-fade";
3
+ import type { MenuIndexEntry, MenuLinkComponent, MenuLinkProps } from "./menu-types";
4
+
5
+ /**
6
+ * MenuIndexHeader — the head of a browse index.
7
+ *
8
+ * The hub's own section title, opened up: the page reads as that section rather
9
+ * than as a new place, which is the whole promise of the "All 38 cuisines" link
10
+ * that got the reader here.
11
+ *
12
+ * The scope switch is a slot rather than a prop set, because the two scopes are
13
+ * two ROUTES — the caller passes a `<SegmentedNav>` with real hrefs and the DS
14
+ * stays out of routing.
15
+ */
16
+
17
+ function DefaultLink({ href, ...props }: MenuLinkProps) {
18
+ return <a href={href} {...props} />;
19
+ }
20
+
21
+ export type MenuIndexHeaderProps = {
22
+ /** Small caps over the title, e.g. "MENU · CLUJ-NAPOCA". */
23
+ eyebrow?: string;
24
+ title: string;
25
+ /** Sits beside the title, e.g. "38 cuisines". Pluralised by the caller. */
26
+ count?: string;
27
+ subtitle?: string;
28
+ /** The Food / Drinks control. A `<SegmentedNav>`, composed by the caller. */
29
+ scopeSwitch?: React.ReactNode;
30
+ className?: string;
31
+ };
32
+
33
+ export function MenuIndexHeader({
34
+ eyebrow,
35
+ title,
36
+ count,
37
+ subtitle,
38
+ scopeSwitch,
39
+ className,
40
+ }: MenuIndexHeaderProps) {
41
+ return (
42
+ <div className={cn("flex flex-col gap-4 lg:flex-row lg:items-start lg:gap-8", className)}>
43
+ <div className="min-w-0 flex-1">
44
+ {eyebrow && (
45
+ <p className="text-[11px] font-bold uppercase tracking-[0.1em] text-muted-foreground">
46
+ {eyebrow}
47
+ </p>
48
+ )}
49
+ <h1 className="mt-1.5 text-[30px] font-black leading-[1.05] tracking-[-0.035em] lg:text-[40px]">
50
+ {title}
51
+ {count && (
52
+ <span className="ml-2.5 align-middle text-base font-semibold text-muted-foreground lg:text-lg">
53
+ {count}
54
+ </span>
55
+ )}
56
+ </h1>
57
+ {subtitle && (
58
+ <p className="mt-2 max-w-[60ch] text-sm text-muted-foreground">{subtitle}</p>
59
+ )}
60
+ </div>
61
+
62
+ {/* Sticky under the header on the phone: the list below is several screens
63
+ and a reader who wants the other scope should not have to scroll back
64
+ up to a control they can see from anywhere else on the page. */}
65
+ {scopeSwitch && (
66
+ <div className="sticky top-0 z-10 -mx-[var(--page-gutter)] bg-background px-[var(--page-gutter)] py-2 lg:static lg:mx-0 lg:bg-transparent lg:px-0 lg:py-0">
67
+ {scopeSwitch}
68
+ </div>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ /**
75
+ * MenuJumpStrip — the page's contents, as chips.
76
+ *
77
+ * It answers "move between families without going back to the hub", and it
78
+ * doubles as a table of contents: each chip carries the family's mark and its
79
+ * count, so the strip says how long the page is before the reader scrolls it.
80
+ *
81
+ * Only the families drawn in full belong here. A strip listing every value in
82
+ * the index below would be the index, twice.
83
+ */
84
+ export type MenuJumpStripProps = {
85
+ entries: MenuIndexEntry[];
86
+ formatCount?: (value: number) => string;
87
+ ariaLabel?: string;
88
+ linkComponent?: MenuLinkComponent;
89
+ className?: string;
90
+ };
91
+
92
+ export function MenuJumpStrip({
93
+ entries,
94
+ formatCount = String,
95
+ ariaLabel,
96
+ linkComponent: LinkComponent = DefaultLink,
97
+ className,
98
+ }: MenuJumpStripProps) {
99
+ return (
100
+ <ScrollFade
101
+ orientation="horizontal"
102
+ size="sm"
103
+ className={className}
104
+ scrollClassName="scrollbar-hide"
105
+ >
106
+ <nav aria-label={ariaLabel} className="flex items-center gap-2">
107
+ {entries.map((entry) => (
108
+ <LinkComponent
109
+ key={entry.slug}
110
+ href={entry.href}
111
+ className="inline-flex h-9 shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-border bg-card px-4 text-sm transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
112
+ >
113
+ <span
114
+ aria-hidden
115
+ className={cn("h-2.5 w-2.5 shrink-0 rounded-[3px]", !entry.gradient && "bg-border")}
116
+ style={entry.gradient ? { backgroundImage: entry.gradient } : undefined}
117
+ />
118
+ {entry.name}
119
+ <span className="tabular-nums text-muted-foreground">{formatCount(entry.count)}</span>
120
+ </LinkComponent>
121
+ ))}
122
+ </nav>
123
+ </ScrollFade>
124
+ );
125
+ }
@@ -0,0 +1,50 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MenuIndexList } from "./menu-index-list";
3
+ import { mockIndexEntries } from "./menu-mocks";
4
+
5
+ const meta: Meta<typeof MenuIndexList> = {
6
+ title: "Menu/MenuIndexList",
7
+ component: MenuIndexList,
8
+ tags: ["autodocs"],
9
+ args: {
10
+ entries: mockIndexEntries,
11
+ title: `All ${mockIndexEntries.length} cuisines`,
12
+ subtitle: "Ordered by kitchens — every one has its own page",
13
+ },
14
+ decorators: [
15
+ (Story) => (
16
+ <div className="w-[1100px] bg-background p-4">
17
+ <Story />
18
+ </div>
19
+ ),
20
+ ],
21
+ parameters: {
22
+ docs: {
23
+ description: {
24
+ component:
25
+ "The complete set, four across — and the reason the page below it can be progressive. This block carries a crawlable link to **every** destination the city has, so the richer blocks underneath are free to load ten at a time behind a *show more* without hiding anything a crawler needs. Get that backwards and the page silently stops linking to the twenty-eight it is supposed to index. Note the counts overlap: cuisines are non-exclusive tags (Cluj's thirty-eight sum to 3,182 against 767 places), so this is a ranked list of ways in, never a breakdown.",
26
+ },
27
+ },
28
+ },
29
+ };
30
+
31
+ export default meta;
32
+ type Story = StoryObj<typeof MenuIndexList>;
33
+
34
+ export const Default: Story = {};
35
+
36
+ /** The long tail, which is where a layout tuned on the head of the list breaks. */
37
+ export const Tail: Story = {
38
+ args: { entries: mockIndexEntries.slice(-8), title: "The tail" },
39
+ };
40
+
41
+ /** Two columns, not one: thirty-eight rows in one column is four screens. */
42
+ export const Mobile: Story = {
43
+ decorators: [
44
+ (Story) => (
45
+ <div className="w-[390px] bg-background p-3">
46
+ <Story />
47
+ </div>
48
+ ),
49
+ ],
50
+ };
@@ -0,0 +1,149 @@
1
+ import { ChevronRight } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Separator } from "./separator";
4
+ import { Skeleton } from "./skeleton";
5
+ import type { MenuIndexEntry, MenuLinkComponent, MenuLinkProps } from "./menu-types";
6
+
7
+ /**
8
+ * MenuIndexList — the complete set, four across.
9
+ *
10
+ * ## This block is why the page can afford to be progressive
11
+ *
12
+ * It carries a crawlable link to EVERY destination the city has — all
13
+ * thirty-eight cuisines, not the ten the page draws in full below. That makes
14
+ * it the page's entire link-equity job, which in turn means the rich blocks
15
+ * underneath are free to load ten at a time behind a "show more": nothing a
16
+ * crawler needs is hidden behind that control, because it is all up here.
17
+ *
18
+ * Get this backwards — put the whole set behind the disclosure and only ten
19
+ * links in the markup — and the page silently stops linking to twenty-eight
20
+ * pages it is supposed to be the index for.
21
+ *
22
+ * ## Ordered by count, and the counts overlap
23
+ *
24
+ * Cuisines are non-exclusive tags: Cluj's thirty-eight sum to 3,182 against 767
25
+ * places, because a trattoria is Italian AND pizza AND international. So this
26
+ * is a ranked list of ways in, never a partition, and no copy near it may total
27
+ * the numbers or call them a breakdown.
28
+ */
29
+
30
+ /** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
31
+ export type MenuIndexListLinkProps = MenuLinkProps;
32
+
33
+ function DefaultLink({ href, ...props }: MenuIndexListLinkProps) {
34
+ return <a href={href} {...props} />;
35
+ }
36
+
37
+ export type MenuIndexListProps = {
38
+ entries: MenuIndexEntry[];
39
+ /** e.g. "All 38 cuisines". Composed and pluralised by the caller. */
40
+ title: string;
41
+ /** One line under it, e.g. "Ordered by kitchens — every one has its own page". */
42
+ subtitle?: string;
43
+ /** Locale-aware integer formatter. Defaults to `String`. */
44
+ formatCount?: (value: number) => string;
45
+ linkComponent?: MenuLinkComponent;
46
+ className?: string;
47
+ };
48
+
49
+ export function MenuIndexList({
50
+ entries,
51
+ title,
52
+ subtitle,
53
+ formatCount = String,
54
+ linkComponent: LinkComponent = DefaultLink,
55
+ className,
56
+ }: MenuIndexListProps) {
57
+ return (
58
+ <section
59
+ className={cn(
60
+ "rounded-[18px] border border-border bg-card px-3.5 py-4 lg:px-5 lg:py-5",
61
+ className
62
+ )}
63
+ >
64
+ <div className="pb-2">
65
+ <h2 className="text-[15px] font-bold tracking-[-0.01em]">{title}</h2>
66
+ {subtitle && (
67
+ <p className="mt-0.5 text-[12.5px] text-muted-foreground">{subtitle}</p>
68
+ )}
69
+ </div>
70
+
71
+ {/*
72
+ Two columns on a phone, four from `lg`. One column at 390px turns
73
+ thirty-eight rows into four screens of scrolling for a list whose whole
74
+ job is to be scanned; two halves that, and the row still holds a name of
75
+ realistic length beside its count.
76
+ */}
77
+ <ul className="grid grid-cols-2 gap-x-5 lg:grid-cols-4 lg:gap-x-8">
78
+ {entries.map((entry) => (
79
+ <li key={entry.slug}>
80
+ {/* The DS `Separator`, above every row including the first.
81
+ It was `border-t … first:border-t-0 lg:first:border-t`, which
82
+ only exempted the very FIRST entry — so in the two-column phone
83
+ layout the top-left row had no rule and the top-right one, its
84
+ own neighbour, did. Ruling every row is both uniform and simpler
85
+ than restoring the exception per breakpoint. */}
86
+ <Separator />
87
+ <LinkComponent
88
+ href={entry.href}
89
+ className="group flex items-center gap-2.5 py-2.5 transition-colors hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none"
90
+ >
91
+ <span
92
+ aria-hidden
93
+ className={cn(
94
+ "h-2.5 w-2.5 shrink-0 rounded-[3px]",
95
+ !entry.gradient && "bg-border"
96
+ )}
97
+ style={entry.gradient ? { backgroundImage: entry.gradient } : undefined}
98
+ />
99
+ <span className="min-w-0 flex-1 truncate text-[13.5px]">{entry.name}</span>
100
+ <span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">
101
+ {formatCount(entry.count)}
102
+ </span>
103
+ <ChevronRight
104
+ className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-accent motion-reduce:transition-none"
105
+ aria-hidden
106
+ />
107
+ </LinkComponent>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ </section>
112
+ );
113
+ }
114
+
115
+ // ─── Skeleton ───────────────────────────────────────────────────────────────
116
+
117
+ /** Two columns on a phone, four from `lg` — the grid the list itself renders. */
118
+ export function MenuIndexListSkeleton({
119
+ rows = 24,
120
+ className,
121
+ }: {
122
+ rows?: number;
123
+ className?: string;
124
+ }) {
125
+ return (
126
+ <section
127
+ className={cn(
128
+ "rounded-[18px] border border-border bg-card px-3.5 py-4 lg:px-5 lg:py-5",
129
+ className
130
+ )}
131
+ aria-hidden
132
+ >
133
+ <Skeleton className="h-[15px] w-[140px]" />
134
+ <Skeleton className="mt-1.5 h-3 w-[240px]" />
135
+ <ul className="mt-2 grid grid-cols-2 gap-x-5 lg:grid-cols-4 lg:gap-x-8">
136
+ {Array.from({ length: rows }, (_, index) => (
137
+ <li key={index}>
138
+ <Separator />
139
+ <div className="flex items-center gap-2.5 py-2.5">
140
+ <Skeleton className="h-2.5 w-2.5 rounded-[3px]" />
141
+ <Skeleton className="h-3.5 flex-1" />
142
+ <Skeleton className="h-3 w-6" />
143
+ </div>
144
+ </li>
145
+ ))}
146
+ </ul>
147
+ </section>
148
+ );
149
+ }
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Clock, Pizza } from "lucide-react";
3
+ import { MenuIntentHero } from "./menu-intent-hero";
4
+ import { mockGradients, mockIntentFigures } from "./menu-mocks";
5
+
6
+ const meta: Meta<typeof MenuIntentHero> = {
7
+ title: "Menu/MenuIntentHero",
8
+ component: MenuIntentHero,
9
+ tags: ["autodocs"],
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-[1100px] bg-background p-4">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ parameters: {
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "The head of an intent page. Every number in it is real and every one of them is a control — tapping a figure applies it as a filter rather than scrolling somewhere, so the hero is also the first filter. A figure with no `onSelect` renders as a plain fact, which is normally the total, since filtering to *all of them* is not a state. Distinct from `MenuHubHero`: that one introduces a city's whole menu and carries a search field; this one introduces a single intent.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof MenuIntentHero>;
29
+
30
+ const base = {
31
+ title: "Italian tonight",
32
+ kicker: "TONIGHT · OPEN NOW OR LATER",
33
+ kickerIcon: <Clock className="h-3 w-3" />,
34
+ description:
35
+ "Two hundred and forty-seven kitchens in Cluj-Napoca cook Italian. Fifty-eight are still serving, four past midnight. Ranked by how often the reviews name their dishes.",
36
+ figures: mockIntentFigures,
37
+ gradient: mockGradients.pasta,
38
+ icon: <Pizza />,
39
+ };
40
+
41
+ export const Default: Story = { args: base };
42
+
43
+ /** No constraint: the kicker states the kind instead, and the figures still filter. */
44
+ export const Bare: Story = {
45
+ args: { ...base, title: "Italian", kicker: "CUISINE", kickerIcon: undefined },
46
+ };
47
+
48
+ /**
49
+ * Four across at 390px, and the paragraph stays.
50
+ *
51
+ * The board hid it under 640px. It is clamped here instead — it is the page's
52
+ * only prose and what the description meta is built from, so `display: none` on
53
+ * the one indexed sentence costs more than the three lines it saves.
54
+ */
55
+ export const Mobile: Story = {
56
+ args: base,
57
+ decorators: [
58
+ (Story) => (
59
+ <div className="w-[390px] bg-background p-3">
60
+ <Story />
61
+ </div>
62
+ ),
63
+ ],
64
+ };
65
+
66
+ /** No gradient — visibly unstyled rather than white text on white. */
67
+ export const NoGradient: Story = { args: { ...base, gradient: undefined, icon: undefined } };