@bearmenu/ui 0.8.8 → 0.8.9

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 (113) hide show
  1. package/dist/chunk-62ES6N32.js +27 -0
  2. package/dist/chunk-AXV5O2IZ.js +58 -0
  3. package/dist/chunk-Q6QH52LJ.js +38 -0
  4. package/dist/chunk-TH6W2PWG.js +213 -0
  5. package/dist/chunk-THOVPZNN.js +37 -0
  6. package/dist/chunk-YS6BHKZO.js +92 -0
  7. package/dist/components/alert.d.ts +2 -2
  8. package/dist/components/badge.d.ts +1 -1
  9. package/dist/components/button.d.ts +1 -1
  10. package/dist/components/command.d.ts +7 -7
  11. package/dist/components/concept-compare.d.ts +73 -0
  12. package/dist/components/concept-compare.js +162 -0
  13. package/dist/components/event-card.js +1 -1
  14. package/dist/components/event-compact-row.d.ts +1 -1
  15. package/dist/components/event-compact-row.js +1 -1
  16. package/dist/components/event-detail-panel.d.ts +171 -25
  17. package/dist/components/event-detail-panel.js +471 -174
  18. package/dist/components/event-list-row-wide.js +1 -1
  19. package/dist/components/event-list-row.js +1 -1
  20. package/dist/components/hub-surface.d.ts +27 -0
  21. package/dist/components/hub-surface.js +20 -0
  22. package/dist/components/menu-family-card.d.ts +59 -0
  23. package/dist/components/menu-family-card.js +121 -0
  24. package/dist/components/menu-field.d.ts +57 -0
  25. package/dist/components/menu-field.js +3 -0
  26. package/dist/components/menu-hub-hero.d.ts +65 -0
  27. package/dist/components/menu-hub-hero.js +103 -0
  28. package/dist/components/menu-intent-tiles.d.ts +31 -0
  29. package/dist/components/menu-intent-tiles.js +50 -0
  30. package/dist/components/menu-item.d.ts +127 -0
  31. package/dist/components/menu-item.js +5 -0
  32. package/dist/components/menu-mocks.d.ts +55 -0
  33. package/dist/components/menu-mocks.js +379 -0
  34. package/dist/components/menu-types.d.ts +245 -0
  35. package/dist/components/menu-types.js +1 -0
  36. package/dist/components/motion-icon-set.js +15 -3
  37. package/dist/components/open-now-list.d.ts +40 -0
  38. package/dist/components/open-now-list.js +69 -0
  39. package/dist/components/place-types.d.ts +112 -1
  40. package/dist/components/ranked-venue-list.d.ts +49 -0
  41. package/dist/components/ranked-venue-list.js +87 -0
  42. package/dist/components/signature-chip.d.ts +37 -0
  43. package/dist/components/signature-chip.js +4 -0
  44. package/dist/components/signature-line-list.d.ts +45 -0
  45. package/dist/components/signature-line-list.js +99 -0
  46. package/dist/components/snap-rail.d.ts +24 -4
  47. package/dist/components/snap-rail.js +66 -8
  48. package/dist/components/spinner.d.ts +2 -2
  49. package/dist/components/stat-banner.d.ts +47 -0
  50. package/dist/components/stat-banner.js +61 -0
  51. package/dist/components/tag.d.ts +2 -2
  52. package/dist/components/text.d.ts +3 -3
  53. package/dist/components/venue-busyness-strip.d.ts +35 -0
  54. package/dist/components/venue-busyness-strip.js +37 -0
  55. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  56. package/dist/components/venue-grade-dimensions.js +3 -0
  57. package/dist/components/venue-quotes.d.ts +32 -0
  58. package/dist/components/venue-quotes.js +3 -0
  59. package/dist/components/venue-signature-block.d.ts +39 -0
  60. package/dist/components/venue-signature-block.js +6 -0
  61. package/dist/components/venue-spotlight.d.ts +74 -0
  62. package/dist/components/venue-spotlight.js +138 -0
  63. package/dist/components/venue-wall.d.ts +41 -0
  64. package/dist/components/venue-wall.js +118 -0
  65. package/dist/components/weekday-theme-banner.d.ts +70 -0
  66. package/dist/components/weekday-theme-banner.js +127 -0
  67. package/package.json +1 -1
  68. package/src/components/colour-dictionary.stories.tsx +227 -0
  69. package/src/components/concept-compare.stories.tsx +104 -0
  70. package/src/components/concept-compare.tsx +276 -0
  71. package/src/components/event-compact-row.tsx +1 -1
  72. package/src/components/event-detail-panel.stories.tsx +205 -97
  73. package/src/components/event-detail-panel.test.tsx +181 -0
  74. package/src/components/event-detail-panel.tsx +703 -185
  75. package/src/components/hub-surface.stories.tsx +68 -0
  76. package/src/components/hub-surface.tsx +40 -0
  77. package/src/components/menu-family-card.stories.tsx +112 -0
  78. package/src/components/menu-family-card.tsx +211 -0
  79. package/src/components/menu-field.stories.tsx +74 -0
  80. package/src/components/menu-field.tsx +199 -0
  81. package/src/components/menu-hub-hero.stories.tsx +92 -0
  82. package/src/components/menu-hub-hero.tsx +187 -0
  83. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  84. package/src/components/menu-intent-tiles.tsx +76 -0
  85. package/src/components/menu-item.tsx +416 -0
  86. package/src/components/menu-mocks.ts +417 -0
  87. package/src/components/menu-types.ts +259 -0
  88. package/src/components/motion-icon-set.tsx +30 -3
  89. package/src/components/open-now-list.stories.tsx +76 -0
  90. package/src/components/open-now-list.tsx +127 -0
  91. package/src/components/place-types.ts +112 -0
  92. package/src/components/ranked-venue-list.stories.tsx +80 -0
  93. package/src/components/ranked-venue-list.tsx +177 -0
  94. package/src/components/signature-chip.tsx +77 -0
  95. package/src/components/signature-line-list.stories.tsx +85 -0
  96. package/src/components/signature-line-list.tsx +136 -0
  97. package/src/components/snap-rail.tsx +90 -9
  98. package/src/components/stat-banner.stories.tsx +100 -0
  99. package/src/components/stat-banner.tsx +117 -0
  100. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  101. package/src/components/venue-busyness-strip.tsx +64 -0
  102. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  103. package/src/components/venue-grade-dimensions.tsx +91 -0
  104. package/src/components/venue-quotes.stories.tsx +59 -0
  105. package/src/components/venue-quotes.tsx +64 -0
  106. package/src/components/venue-signature-block.stories.tsx +68 -0
  107. package/src/components/venue-signature-block.tsx +71 -0
  108. package/src/components/venue-spotlight.stories.tsx +141 -0
  109. package/src/components/venue-spotlight.tsx +236 -0
  110. package/src/components/venue-wall.stories.tsx +78 -0
  111. package/src/components/venue-wall.tsx +185 -0
  112. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  113. package/src/components/weekday-theme-banner.tsx +231 -0
@@ -0,0 +1,227 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+
3
+ /**
4
+ * Foundations / Colour dictionary — every colour a menu surface paints, in one
5
+ * table grouped by the forest element it is, with the reason beside it. A page,
6
+ * not a component: the values live in the consuming app (see the notes under
7
+ * the table), this is where a person goes to understand them.
8
+ */
9
+
10
+ type Chip = { label: string; value: string };
11
+
12
+ type Row = {
13
+ element: string;
14
+ why: string;
15
+ /** The three `--menu-field-<family>-<n>` tints, when this element is a card-field family. */
16
+ field?: { family: string; tints: [string, string, string]; draws: string };
17
+ /** `FAMILY_HUES` entries painted by the hub's family cards (chips, price band, photo tint). */
18
+ familyCards: Chip[];
19
+ /** `INTENT_GRADIENTS` entries painted by the hub's intent tiles. */
20
+ intents: Chip[];
21
+ };
22
+
23
+ const ROWS: Row[] = [
24
+ {
25
+ element: "Wheat and honey",
26
+ why: "Grain and what goes on it — crust, pasta, pizza base, pastry, yolk — is this gold before it is cooked. Honey in the comb is the same colour, and so is morning light on toast, which is why brunch and beer (roasted malt) sit here too.",
27
+ field: { family: "larder", tints: ["oklch(0.505 0.11 74)", "oklch(0.555 0.105 80)", "oklch(0.465 0.115 68)"], draws: "bread, pasta, pizza, eggs, sides, sauces, spirits" },
28
+ familyCards: [{ label: "Breakfast & brunch", value: "oklch(0.62 0.14 82)" }, { label: "Bakeries", value: "oklch(0.58 0.11 72)" }, { label: "Beer", value: "oklch(0.62 0.13 86)" }],
29
+ intents: [{ label: "Brunch", value: "oklch(0.62 0.14 82)" }],
30
+ },
31
+ {
32
+ element: "Lake water",
33
+ why: "Fish, shellfish and soup come out of water, and a cocktail is a glass of it. A forest has a lake, not a sea: dark, blue-green, in shade — so the hue stays on the green side (198–220) and never reads as sky or as a pool.",
34
+ field: { family: "sea", tints: ["oklch(0.485 0.095 205)", "oklch(0.535 0.09 198)", "oklch(0.445 0.095 212)"], draws: "fish, shellfish, soups, cocktails" },
35
+ familyCards: [{ label: "Cocktails", value: "oklch(0.47 0.10 205)" }],
36
+ intents: [{ label: "Cocktail bars", value: "oklch(0.47 0.10 205)" }],
37
+ },
38
+ {
39
+ element: "Ember and terracotta",
40
+ why: "Meat is the family cooked over fire: the red of the coals, the crust on beef, cured and smoked meat. Red wine shares the glass and the colour. Terracotta is the same fire one step warmer — the oven's brick and the tomato — which is why Italian sits beside Romanian here.",
41
+ field: { family: "meat", tints: ["oklch(0.49 0.13 28)", "oklch(0.54 0.13 34)", "oklch(0.45 0.13 22)"], draws: "beef, game, cured meat, burgers, red wine" },
42
+ familyCards: [{ label: "Romanian", value: "oklch(0.48 0.16 25)" }, { label: "Italian", value: "oklch(0.56 0.15 38)" }],
43
+ intents: [{ label: "Burgers", value: "oklch(0.48 0.16 25)" }, { label: "Italian tonight", value: "oklch(0.56 0.15 38)" }],
44
+ },
45
+ {
46
+ element: "Maple and amber",
47
+ why: "Sugar turns this colour when it meets heat: caramel, syrup, the top of a crème brûlée, the maple leaf in autumn — and a pizza crust out of the oven, which is why the Pizza card wears it. Beer's amber is the same chemistry, malt roasted, so beer items draw this field even though the beer card wears honey.",
48
+ field: { family: "sweet", tints: ["oklch(0.505 0.14 50)", "oklch(0.555 0.135 58)", "oklch(0.465 0.14 44)"], draws: "desserts, pastry, beer" },
49
+ familyCards: [{ label: "Pizza", value: "oklch(0.52 0.14 58)" }],
50
+ intents: [],
51
+ },
52
+ {
53
+ element: "Fern and pine",
54
+ why: "The leaf itself. Salads, vegetables and the non-alcoholic glass — juice, lemonade, the herb in it — are the green things on the menu and take the forest's own green: pine-deep rather than lime, so it stays a forest and not a lawn.",
55
+ field: { family: "verdant", tints: ["oklch(0.505 0.115 148)", "oklch(0.555 0.11 138)", "oklch(0.465 0.115 156)"], draws: "salads, vegetables, non-alcoholic" },
56
+ familyCards: [{ label: "Vegetarian", value: "oklch(0.52 0.14 148)" }, { label: "Vegan", value: "oklch(0.55 0.12 135)" }],
57
+ intents: [{ label: "Vegan", value: "oklch(0.52 0.14 148)" }],
58
+ },
59
+ {
60
+ element: "Bark and walnut",
61
+ why: "The roasted bean is the colour of tree bark, and a coffee house is made of both — wood and roast. Walnut is the same brown darker: a lit doorway at night, which is what \"open right now\" looks like from the street. Coffee items themselves draw the non-alcoholic field (pine), not this.",
62
+ familyCards: [{ label: "Coffee", value: "oklch(0.44 0.10 52)" }],
63
+ intents: [{ label: "Coffee", value: "oklch(0.44 0.10 52)" }, { label: "Open right now", value: "oklch(0.44 0.08 62)" }],
64
+ },
65
+ {
66
+ element: "Rosehip",
67
+ why: "The forest's berry: the jam in the tart, the fruit on the cake, the glass of red. A red with no purple in it — the magenta and violet it replaced belong to no place a bear has been.",
68
+ familyCards: [{ label: "Wine by the glass", value: "oklch(0.48 0.14 12)" }],
69
+ intents: [{ label: "Patisseries", value: "oklch(0.52 0.15 12)" }],
70
+ },
71
+ {
72
+ element: "Moss in sun",
73
+ why: "The sunlit forest floor. Yellow-green because a terrace is the one intent about being outdoors in daylight; deeper than lime so it is ground, not a highlighter.",
74
+ familyCards: [],
75
+ intents: [{ label: "On a terrace", value: "oklch(0.60 0.13 122)" }],
76
+ },
77
+ {
78
+ element: "Stone",
79
+ why: "No colour of its own. An untagged dish gets it because there is no ingredient to be coloured by; Centru gets it because the town is the one intent that is a place and not a taste. Never a section mark — as a mark, grey reads as absent.",
80
+ field: { family: "neutral", tints: ["oklch(0.505 0.02 60)", "oklch(0.555 0.022 66)", "oklch(0.465 0.018 54)"], draws: "an untagged dish only" },
81
+ familyCards: [],
82
+ intents: [{ label: "In Centru", value: "oklch(0.50 0.03 70)" }],
83
+ },
84
+ ];
85
+
86
+ const NOT_THESE: Chip[] = [
87
+ { label: "sky blue", value: "oklch(0.5 0.13 240)" },
88
+ { label: "teal", value: "oklch(0.55 0.12 190)" },
89
+ { label: "violet", value: "oklch(0.5 0.11 300)" },
90
+ { label: "magenta", value: "oklch(0.52 0.13 340)" },
91
+ { label: "olive-yellow", value: "oklch(0.58 0.12 95)" },
92
+ ];
93
+
94
+ const darker = (value: string) =>
95
+ value.replace(/oklch\(([\d.]+) /, (_m, l: string) => `oklch(${(Number(l) - 0.09).toFixed(3)} `);
96
+
97
+ function Swatch({ label, value, angle = 135, tall }: Chip & { angle?: number; tall?: boolean }) {
98
+ return (
99
+ <div className="flex w-[104px] shrink-0 flex-col gap-1">
100
+ <span
101
+ aria-hidden
102
+ className={tall ? "block h-14 rounded-lg" : "block h-9 rounded-lg"}
103
+ style={{ backgroundImage: `linear-gradient(${angle}deg, ${value}, ${darker(value)})` }}
104
+ />
105
+ <span className="text-[11px] font-semibold leading-tight">{label}</span>
106
+ <code className="text-[9.5px] leading-tight text-muted-foreground">{value.replace("oklch(", "").replace(")", "")}</code>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ const TH = "px-3 py-2 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted-foreground";
112
+ const TD = "px-3 py-3 align-top";
113
+
114
+ function ColourDictionaryPage() {
115
+ return (
116
+ <article className="mx-auto max-w-[1440px] px-6 py-7 text-foreground">
117
+ <p className="text-[11px] font-bold uppercase tracking-[0.09em] text-muted-foreground">Foundations</p>
118
+ <h1 className="mt-0.5 text-[26px] font-extrabold tracking-[-0.02em]">The menu colour dictionary</h1>
119
+ <p className="mt-1 max-w-[90ch] text-[14px] leading-[1.55] text-foreground/85">
120
+ Every hue is a forest object, at full strength — the floor and canopy of a forest and what a bear eats off it.
121
+ Every colour a menu surface paints is in this table, grouped by the element it is, with why it belongs.
122
+ <span className="ml-2 inline-flex items-center gap-2 align-middle text-[12px] text-muted-foreground">
123
+ Not in the forest, so not in the palette:
124
+ {NOT_THESE.map((c) => (
125
+ <span key={c.label} className="inline-flex items-center gap-1 line-through">
126
+ <span aria-hidden className="inline-block h-3.5 w-5 rounded-sm" style={{ background: c.value }} />
127
+ {c.label}
128
+ </span>
129
+ ))}
130
+ </span>
131
+ </p>
132
+
133
+ <div className="mt-5 overflow-x-auto rounded-xl border border-border">
134
+ <table className="w-full min-w-[1180px] border-collapse text-[13px]">
135
+ <thead className="bg-muted/60">
136
+ <tr>
137
+ <th className={`${TH} w-[290px]`}>Element · why it belongs</th>
138
+ <th className={TH}>Card fields · the three tints</th>
139
+ <th className={TH}>Family cards · chips, price band, photo tint</th>
140
+ <th className={TH}>Intent tiles</th>
141
+ </tr>
142
+ </thead>
143
+ <tbody>
144
+ {ROWS.map((row) => (
145
+ <tr key={row.element} className="border-t border-border">
146
+ <td className={TD}>
147
+ <div className="text-[15px] font-extrabold tracking-[-0.01em]">{row.element}</div>
148
+ <p className="mt-1 text-[12.5px] leading-[1.5] text-foreground/85">{row.why}</p>
149
+ </td>
150
+ <td className={TD}>
151
+ {row.field ? (
152
+ <div className="flex flex-col gap-2">
153
+ <div className="flex gap-2">
154
+ {row.field.tints.map((tint, index) => (
155
+ <Swatch key={tint} label={`${row.field!.family}-${index}`} value={tint} tall />
156
+ ))}
157
+ </div>
158
+ <div className="text-[11.5px] text-muted-foreground">
159
+ <span className="font-semibold text-foreground/80">draws</span> {row.field.draws}
160
+ </div>
161
+ </div>
162
+ ) : (
163
+ <span className="text-[12px] text-muted-foreground">—</span>
164
+ )}
165
+ </td>
166
+ <td className={TD}>
167
+ {row.familyCards.length ? (
168
+ <div className="flex gap-2">
169
+ {row.familyCards.map((c) => (
170
+ <Swatch key={c.label} {...c} angle={150} />
171
+ ))}
172
+ </div>
173
+ ) : (
174
+ <span className="text-[12px] text-muted-foreground">—</span>
175
+ )}
176
+ </td>
177
+ <td className={TD}>
178
+ {row.intents.length ? (
179
+ <div className="flex gap-2">
180
+ {row.intents.map((c) => (
181
+ <Swatch key={c.label} {...c} angle={150} />
182
+ ))}
183
+ </div>
184
+ ) : (
185
+ <span className="text-[12px] text-muted-foreground">—</span>
186
+ )}
187
+ </td>
188
+ </tr>
189
+ ))}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+
194
+ <div className="mt-8 grid gap-8 md:grid-cols-3">
195
+ <section>
196
+ <h2 className="text-[15px] font-extrabold">How a colour is chosen</h2>
197
+ <ol className="mt-2 list-decimal space-y-1.5 pl-4 text-[13px] leading-[1.55] text-foreground/85">
198
+ <li><b>What is the family made of, or where is it consumed?</b> Grain, fire, water, sugar, leaf; a night out, a morning, the town.</li>
199
+ <li><b>Which forest element already has that colour — literally?</b> Wheat is gold, embers are red, lake water is blue-green. If nothing in a forest has it, it does not go in; that is how violet and magenta left.</li>
200
+ <li><b>Take it at full strength.</b> An early pass pulled every chroma down to feel earthy and read as faded. Keep lightness 0.44–0.62 so white type at 0.26–0.30 alpha stays readable.</li>
201
+ </ol>
202
+ </section>
203
+ <section>
204
+ <h2 className="text-[15px] font-extrabold">The colour names a family, never an item</h2>
205
+ <p className="mt-2 text-[13px] leading-[1.55] text-foreground/85">
206
+ Sixteen appetizers draw one colour. Two dishes in one family differ only by one of the three tints, keyed on the parent concept so the same dish is the same tint on every menu and after every re-sort. A category's section mark always uses tint <code>-0</code>. Where a family has both a card and an intent tile, they share the value so the two doors to one page agree.
207
+ </p>
208
+ </section>
209
+ <section>
210
+ <h2 className="text-[15px] font-extrabold">Where the values live</h2>
211
+ <p className="mt-2 text-[13px] leading-[1.55] text-foreground/85">
212
+ The design system owns the typography, the falloff and the layering (<code>MenuField</code>, <code>MenuFamilyCard</code>, <code>MenuIntentTiles</code>) and takes a finished colour as a prop. The palette lives in the fe app: the eighteen <code>--menu-field-*</code> tokens in <code>app/globals.css</code>, <code>FAMILY_HUES</code> and <code>INTENT_GRADIENTS</code> in <code>hub-gradients.ts</code>, category → family in <code>category-colors.ts</code>. The Storybook mocks carry the same values so post-generator films what the app paints.
213
+ </p>
214
+ </section>
215
+ </div>
216
+ </article>
217
+ );
218
+ }
219
+
220
+ const meta: Meta<typeof ColourDictionaryPage> = {
221
+ title: "Foundations/Colour dictionary",
222
+ component: ColourDictionaryPage,
223
+ parameters: { layout: "fullscreen" },
224
+ };
225
+ export default meta;
226
+
227
+ export const Page: StoryObj<typeof ColourDictionaryPage> = {};
@@ -0,0 +1,104 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "./button";
3
+ import { ConceptCompare } from "./concept-compare";
4
+ import { mockConcept, mockGradients } from "./menu-mocks";
5
+
6
+ const labels = {
7
+ kind: "DISH CONCEPT",
8
+ alias: "ON THEIR MENU AS",
9
+ perPortion: "per portion",
10
+ cheapest: "cheapest shown",
11
+ };
12
+
13
+ const itemLabels = {
14
+ currency: "lei",
15
+ mentions: (count: number) => `${count} mentions`,
16
+ signature: "SIGNATURE",
17
+ mentioned: "MENTIONED",
18
+ };
19
+
20
+ const formatPrice = (amount: number) => `${amount} lei`;
21
+
22
+ const hrefs = Object.fromEntries(
23
+ (mockConcept.rows ?? []).map((row) => [row.place_slug, `/places/${row.place_slug}`])
24
+ );
25
+
26
+ const meta: Meta<typeof ConceptCompare> = {
27
+ title: "Menu/ConceptCompare",
28
+ component: ConceptCompare,
29
+ tags: ["autodocs"],
30
+ decorators: [
31
+ (Story) => (
32
+ <div className="w-[1000px] bg-background p-4">
33
+ <Story />
34
+ </div>
35
+ ),
36
+ ],
37
+ parameters: {
38
+ docs: {
39
+ description: {
40
+ component:
41
+ "One dish or drink across every kitchen that makes it, under whatever name each of them uses. The row is PLACE-LED: it leads with the venue photograph, which is real and licensed, because the reader is choosing between restaurants. A price bar was drawn here first and measured the one value already printed beside it in words. From `lg` the rows are a comparison table; below it they become the production `MenuItem`, with the kitchen's own wording as the name.",
42
+ },
43
+ },
44
+ },
45
+ };
46
+
47
+ export default meta;
48
+ type Story = StoryObj<typeof ConceptCompare>;
49
+
50
+ export const Default: Story = {
51
+ args: {
52
+ concept: mockConcept,
53
+ labels,
54
+ itemLabels,
55
+ formatPrice,
56
+ gradient: mockGradients.pasta,
57
+ figures: [
58
+ { value: "44", label: "kitchens" },
59
+ { value: "8—44", label: "lei" },
60
+ { value: "168", label: "mentions" },
61
+ ],
62
+ hrefs,
63
+ footnote: "Prices read from each menu as it stands. Portions normalised where the menu states them.",
64
+ actions: <Button size="sm">See all 44</Button>,
65
+ },
66
+ };
67
+
68
+ /** Below `lg` every row is a `MenuItem`, the alias leading. */
69
+ export const Mobile: Story = {
70
+ args: {
71
+ concept: mockConcept,
72
+ labels,
73
+ itemLabels,
74
+ formatPrice,
75
+ gradient: mockGradients.pasta,
76
+ figures: [
77
+ { value: "44", label: "kitchens" },
78
+ { value: "8—44", label: "lei" },
79
+ { value: "168", label: "mentions" },
80
+ ],
81
+ hrefs,
82
+ },
83
+ decorators: [
84
+ (Story) => (
85
+ <div className="w-[390px] bg-background p-3">
86
+ <Story />
87
+ </div>
88
+ ),
89
+ ],
90
+ };
91
+
92
+ /** With the concept's own photograph — the one item surface that shows one. */
93
+ export const WithPhoto: Story = {
94
+ args: {
95
+ concept: mockConcept,
96
+ labels,
97
+ itemLabels,
98
+ formatPrice,
99
+ gradient: mockGradients.pasta,
100
+ imageUrl: "https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=200&h=200&fit=crop",
101
+ figures: [{ value: "44", label: "kitchens" }],
102
+ hrefs,
103
+ },
104
+ };
@@ -0,0 +1,276 @@
1
+ import { MessageSquare, Star } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { MenuField } from "./menu-field";
4
+ import { MenuItem, type MenuItemLabels } from "./menu-item";
5
+ import type { MenuConceptPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
6
+
7
+ /**
8
+ * ConceptCompare — one dish or drink across every kitchen that makes it, under
9
+ * whatever name each of them uses.
10
+ *
11
+ * The keystone module: a single menu can list papanași, only a catalogue can
12
+ * say that seventy kitchens make them under fifty different names between 8 and
13
+ * 44 lei. The alias column is what makes it honest — these are not the same row
14
+ * seventy times, they are seventy differently-named rows we matched, and
15
+ * showing each kitchen's own wording is the evidence for that.
16
+ *
17
+ * ## The row is place-led
18
+ *
19
+ * Each row leads with the VENUE PHOTOGRAPH, which is real and licensed. It
20
+ * replaced a price bar, and the trade is not close: the bar measured the one
21
+ * value already printed beside it in words, while the thing a reader is
22
+ * actually choosing between — the restaurant — had no image at all. Five
23
+ * restaurants that disagree about a name is the story; five bars of similar
24
+ * length is not.
25
+ *
26
+ * ## Two layouts, one component
27
+ *
28
+ * From `lg` the row is a table: rank, venue, alias, mentions, price. Below it,
29
+ * the rows become the production `<MenuItem>` — the alias as the name, the
30
+ * venue underneath — because a five-column table does not survive 390px and
31
+ * the item row already solves this exact shape everywhere else in the app.
32
+ */
33
+
34
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
35
+ export type ConceptCompareLinkProps = MenuLinkProps;
36
+
37
+ function DefaultLink({ href, ...props }: ConceptCompareLinkProps) {
38
+ return <a href={href} {...props} />;
39
+ }
40
+
41
+ export type ConceptCompareFigure = {
42
+ /** Pre-formatted, e.g. "44". */
43
+ value: string;
44
+ label: string;
45
+ };
46
+
47
+ export type ConceptCompareLabels = {
48
+ /** Small caps kicker, e.g. "DISH CONCEPT". */
49
+ kind: string;
50
+ /** Column heading over the aliases, e.g. "ON THEIR MENU AS". */
51
+ alias: string;
52
+ /** Under each price, e.g. "per portion". */
53
+ perPortion?: string;
54
+ /** Badge on the lowest-priced row, e.g. "cheapest shown". */
55
+ cheapest?: string;
56
+ };
57
+
58
+ export type ConceptCompareProps = {
59
+ concept: MenuConceptPreview;
60
+ labels: ConceptCompareLabels;
61
+ /** Passed to the mobile `<MenuItem>` rows. */
62
+ itemLabels: MenuItemLabels;
63
+ formatPrice: (amount: number) => string;
64
+ formatCount?: (value: number) => string;
65
+ /** CSS gradient for the header swatch, when there is no photograph. */
66
+ gradient: string;
67
+ /** A real photograph of the concept, when the matched menu row carries one. */
68
+ imageUrl?: string;
69
+ figures?: ConceptCompareFigure[];
70
+ /** Destination per row, keyed by place slug. */
71
+ hrefs?: Record<string, string>;
72
+ /** The provenance note in the footer. */
73
+ footnote?: string;
74
+ /** Actions in the footer — pass rendered links or buttons. */
75
+ actions?: React.ReactNode;
76
+ linkComponent?: MenuLinkComponent;
77
+ className?: string;
78
+ };
79
+
80
+ export function ConceptCompare({
81
+ concept,
82
+ labels,
83
+ itemLabels,
84
+ formatPrice,
85
+ formatCount,
86
+ gradient,
87
+ imageUrl,
88
+ figures,
89
+ hrefs,
90
+ footnote,
91
+ actions,
92
+ linkComponent: LinkComponent = DefaultLink,
93
+ className,
94
+ }: ConceptCompareProps) {
95
+ const rows = concept.rows ?? [];
96
+ // Derived, not passed: the cheapest row is a fact about THIS list, and a
97
+ // caller computing it separately could disagree with what is rendered.
98
+ const cheapest = rows.reduce<number | undefined>(
99
+ (lowest, row) => (lowest === undefined || row.price < lowest ? row.price : lowest),
100
+ undefined
101
+ );
102
+
103
+ return (
104
+ <div className={cn("overflow-hidden rounded-[18px] border border-border bg-card", className)}>
105
+ <div className="flex flex-col gap-4 p-4 lg:flex-row lg:items-center lg:gap-6 lg:p-5">
106
+ {imageUrl ? (
107
+ // eslint-disable-next-line @next/next/no-img-element
108
+ <img
109
+ src={imageUrl}
110
+ alt=""
111
+ className="aspect-[16/9] w-full shrink-0 rounded-xl object-cover lg:aspect-auto lg:h-[88px] lg:w-[88px]"
112
+ />
113
+ ) : (
114
+ <MenuField
115
+ name={concept.title}
116
+ gradient={gradient}
117
+ seedKey={concept.slug}
118
+ className="aspect-[16/9] w-full shrink-0 rounded-xl lg:aspect-auto lg:h-[88px] lg:w-[88px]"
119
+ />
120
+ )}
121
+
122
+ <div className="min-w-0 flex-1">
123
+ <span className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
124
+ {labels.kind}
125
+ </span>
126
+ <h3 className="mt-1 font-serif text-2xl font-semibold tracking-[-0.02em] lg:text-[30px]">
127
+ {concept.title}
128
+ </h3>
129
+ </div>
130
+
131
+ {figures && figures.length > 0 && (
132
+ <div className="flex shrink-0 gap-6 lg:gap-8">
133
+ {figures.map((figure) => (
134
+ <div key={figure.label} className="text-left lg:text-right">
135
+ <div className="text-xl font-extrabold leading-none tracking-[-0.03em] tabular-nums lg:text-2xl">
136
+ {figure.value}
137
+ </div>
138
+ <div className="mt-1 text-[10.5px] text-muted-foreground">{figure.label}</div>
139
+ </div>
140
+ ))}
141
+ </div>
142
+ )}
143
+ </div>
144
+
145
+ {/* ── Below `lg`: the production item row ─────────────────────────── */}
146
+ <ul className="border-t border-border lg:hidden">
147
+ {rows.map((row) => (
148
+ <li key={row.place_slug} className="border-b border-border last:border-b-0">
149
+ <MenuItem
150
+ item={{
151
+ id: row.place_slug,
152
+ slug: row.place_slug,
153
+ // The kitchen's OWN wording leads the row — that IS the point
154
+ // of the module.
155
+ name: row.alias,
156
+ kind: concept.kind,
157
+ mentions: row.mentions,
158
+ sizes_and_prices: [{ size: 0, price: row.price }],
159
+ }}
160
+ labels={itemLabels}
161
+ formatPrice={formatPrice}
162
+ formatCount={formatCount}
163
+ venueName={row.place_name}
164
+ href={hrefs?.[row.place_slug]}
165
+ linkComponent={LinkComponent}
166
+ className={cn(row.price === cheapest && "bg-accent-subtle/40")}
167
+ />
168
+ </li>
169
+ ))}
170
+ </ul>
171
+
172
+ {/* ── From `lg`: the comparison table ─────────────────────────────── */}
173
+ <ol className="hidden border-t border-border lg:block">
174
+ {rows.map((row, index) => {
175
+ const isCheapest = row.price === cheapest;
176
+
177
+ const content = (
178
+ <>
179
+ <span className="w-5 shrink-0 text-center text-xs font-bold tabular-nums text-muted-foreground">
180
+ {index + 1}
181
+ </span>
182
+
183
+ <span className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)]">
184
+ {row.place_thumbnail_url && (
185
+ // eslint-disable-next-line @next/next/no-img-element
186
+ <img
187
+ src={row.place_thumbnail_url}
188
+ alt=""
189
+ loading="lazy"
190
+ className="h-full w-full object-cover"
191
+ />
192
+ )}
193
+ {row.open_now && (
194
+ <i
195
+ aria-hidden
196
+ className="absolute right-0.5 top-0.5 h-[6px] w-[6px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
197
+ />
198
+ )}
199
+ </span>
200
+
201
+ <span className="min-w-0 w-[200px] shrink-0">
202
+ <span className="block truncate text-sm font-bold tracking-[-0.01em]">
203
+ {row.place_name}
204
+ </span>
205
+ <span className="mt-px flex items-center gap-1.5 text-[11px] text-muted-foreground">
206
+ {row.rating != null && (
207
+ <>
208
+ <Star className="h-2.5 w-2.5" aria-hidden />
209
+ <span className="tabular-nums">{row.rating}</span>
210
+ </>
211
+ )}
212
+ {row.place_area && <span className="truncate">{row.place_area}</span>}
213
+ </span>
214
+ </span>
215
+
216
+ <span className="min-w-0 flex-1">
217
+ <span className="block text-[9.5px] font-bold uppercase tracking-[0.1em] text-muted-foreground">
218
+ {labels.alias}
219
+ </span>
220
+ <span className="block truncate font-serif text-[13px] italic">{row.alias}</span>
221
+ </span>
222
+
223
+ {row.mentions != null && row.mentions > 0 && (
224
+ <span className="inline-flex shrink-0 items-center gap-1 text-[11px] tabular-nums text-muted-foreground">
225
+ <MessageSquare className="h-[11px] w-[11px]" aria-hidden />
226
+ {row.mentions}
227
+ </span>
228
+ )}
229
+
230
+ <span className="w-[92px] shrink-0 text-right">
231
+ <b
232
+ className={cn(
233
+ "block text-sm font-extrabold tabular-nums",
234
+ isCheapest && "text-[var(--free,currentColor)]"
235
+ )}
236
+ >
237
+ {formatPrice(row.price)}
238
+ </b>
239
+ <span className="block text-[10px] text-muted-foreground">
240
+ {isCheapest && labels.cheapest ? labels.cheapest : labels.perPortion}
241
+ </span>
242
+ </span>
243
+ </>
244
+ );
245
+
246
+ const rowClasses = "flex items-center gap-4 px-5 py-3";
247
+
248
+ return (
249
+ <li key={row.place_slug} className="border-b border-border last:border-b-0">
250
+ {hrefs?.[row.place_slug] ? (
251
+ <LinkComponent
252
+ href={hrefs[row.place_slug]}
253
+ className={cn(
254
+ rowClasses,
255
+ "transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
256
+ )}
257
+ >
258
+ {content}
259
+ </LinkComponent>
260
+ ) : (
261
+ <div className={rowClasses}>{content}</div>
262
+ )}
263
+ </li>
264
+ );
265
+ })}
266
+ </ol>
267
+
268
+ {(footnote || actions) && (
269
+ <div className="flex flex-wrap items-center gap-3 p-4">
270
+ {footnote && <span className="text-[11.5px] text-muted-foreground">{footnote}</span>}
271
+ {actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
272
+ </div>
273
+ )}
274
+ </div>
275
+ );
276
+ }
@@ -74,7 +74,7 @@ export type EventCompactRowProps = {
74
74
  * repeating it on each one is noise — the caller has something more
75
75
  * differentiating to say instead (the event's own description). The
76
76
  * timeline keeps the default: there the venue IS what tells rows apart
77
- * across the night. Mirrors `menu-item-row.tsx`'s prop of the same name and
77
+ * across the night. Mirrors `menu-item.tsx`'s prop of the same name and
78
78
  * intent.
79
79
  */
80
80
  subtitle?: string;