@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,83 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockFormatters, mockPlaces } from "./menu-mocks";
3
+ import { VenueGradeDimensions } from "./venue-grade-dimensions";
4
+
5
+ const labels = {
6
+ food: "Food",
7
+ coffee_quality: "Coffee",
8
+ drinks_quality: "Drinks",
9
+ service: "Service",
10
+ value: "Value",
11
+ ambiance: "Ambiance",
12
+ portion: "Portion",
13
+ consistency: "Consistency",
14
+ cleanliness: "Cleanliness",
15
+ };
16
+
17
+ const meta: Meta<typeof VenueGradeDimensions> = {
18
+ title: "Menu/VenueGradeDimensions",
19
+ component: VenueGradeDimensions,
20
+ tags: ["autodocs"],
21
+ decorators: [
22
+ (Story) => (
23
+ <div className="w-[720px] bg-background p-4">
24
+ <Story />
25
+ </div>
26
+ ),
27
+ ],
28
+ parameters: {
29
+ docs: {
30
+ description: {
31
+ component:
32
+ "Per-dimension review scores as a row of bars. A dimension the reviews never mention is ABSENT from the payload, not zero, and exactly one of food / coffee_quality / drinks_quality is scored per place — so a component drawing a fixed row of seven would invent readings for a third of the catalogue. It iterates the canonical order, skips what is missing, and lays out however many survive. Scores arrive 0–100 and display over ten.",
33
+ },
34
+ },
35
+ },
36
+ };
37
+
38
+ export default meta;
39
+ type Story = StoryObj<typeof VenueGradeDimensions>;
40
+
41
+ export const Default: Story = {
42
+ args: {
43
+ dimensions: mockPlaces[0].reviews_digest!.grade!.dimensions!,
44
+ labels,
45
+ formatScore: mockFormatters.score,
46
+ },
47
+ };
48
+
49
+ /**
50
+ * A coffee bar. It is graded on `coffee_quality` and has no food score at all —
51
+ * the row is four columns, not seven with a gap.
52
+ */
53
+ export const CoffeeBar: Story = {
54
+ args: {
55
+ dimensions: mockPlaces[2].reviews_digest!.grade!.dimensions!,
56
+ labels,
57
+ formatScore: mockFormatters.score,
58
+ },
59
+ };
60
+
61
+ /** Thin evidence: only two dimensions were mentioned often enough to score. */
62
+ export const Sparse: Story = {
63
+ args: {
64
+ dimensions: { food: 78, value: 64 },
65
+ labels,
66
+ formatScore: mockFormatters.score,
67
+ },
68
+ };
69
+
70
+ /** `order` narrows as well as reorders — here, quality and value only. */
71
+ export const Narrowed: Story = {
72
+ args: {
73
+ dimensions: mockPlaces[0].reviews_digest!.grade!.dimensions!,
74
+ labels,
75
+ order: ["food", "value"],
76
+ formatScore: mockFormatters.score,
77
+ },
78
+ };
79
+
80
+ /** Nothing scored: the component renders nothing rather than an empty row. */
81
+ export const Empty: Story = {
82
+ args: { dimensions: {}, labels, formatScore: mockFormatters.score },
83
+ };
@@ -0,0 +1,91 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { PlaceGradeDimensions } from "./place-types";
3
+
4
+ /**
5
+ * VenueGradeDimensions — the per-dimension review scores as a row of bars.
6
+ *
7
+ * ## Why this renders what it is given rather than a fixed row
8
+ *
9
+ * A dimension the reviews never mention is ABSENT from the payload, not zero,
10
+ * and exactly one of `food` / `coffee_quality` / `drinks_quality` is scored per
11
+ * place — a coffee bar has no food score and a restaurant has no coffee one.
12
+ * So a component drawing seven fixed columns would invent readings for a third
13
+ * of the catalogue. It iterates `order`, skips what is missing, and lays out
14
+ * however many survive.
15
+ *
16
+ * Scores arrive 0–100 and display on the 0–10 scale the rest of the product
17
+ * grades on, so the bar width is the raw value and the numeral is that value
18
+ * over ten. `formatScore` is injected because a decimal separator is
19
+ * locale-dependent.
20
+ *
21
+ * Server-safe.
22
+ */
23
+
24
+ export type VenueGradeDimensionKey = keyof PlaceGradeDimensions;
25
+
26
+ /** Canonical order. Quality leads, and the three quality keys are mutually exclusive. */
27
+ export const DIMENSION_ORDER: VenueGradeDimensionKey[] = [
28
+ "food",
29
+ "coffee_quality",
30
+ "drinks_quality",
31
+ "service",
32
+ "value",
33
+ "ambiance",
34
+ "portion",
35
+ "consistency",
36
+ "cleanliness",
37
+ ];
38
+
39
+ export type VenueGradeDimensionsProps = {
40
+ dimensions: PlaceGradeDimensions;
41
+ /** Dimension key → translated label. Keys with no label fall back to the key. */
42
+ labels: Partial<Record<VenueGradeDimensionKey, string>>;
43
+ /** Override the canonical order, or narrow which dimensions show at all. */
44
+ order?: VenueGradeDimensionKey[];
45
+ /** 0–100 → display numeral, e.g. `(v) => (v / 10).toFixed(1)`. */
46
+ formatScore: (value: number) => string;
47
+ className?: string;
48
+ };
49
+
50
+ export function VenueGradeDimensions({
51
+ dimensions,
52
+ labels,
53
+ order = DIMENSION_ORDER,
54
+ formatScore,
55
+ className,
56
+ }: VenueGradeDimensionsProps) {
57
+ const present = order
58
+ .map((key) => ({ key, value: dimensions[key] }))
59
+ .filter((entry): entry is { key: VenueGradeDimensionKey; value: number } =>
60
+ typeof entry.value === "number"
61
+ );
62
+
63
+ if (present.length === 0) return null;
64
+
65
+ return (
66
+ <dl
67
+ className={cn(
68
+ "grid grid-cols-3 gap-2.5 sm:grid-cols-4 lg:flex lg:gap-2.5",
69
+ className
70
+ )}
71
+ >
72
+ {present.map(({ key, value }) => (
73
+ <div key={key} className="flex flex-col gap-1.5 lg:flex-1">
74
+ <dd className="text-[15px] font-extrabold tracking-[-0.02em] tabular-nums">
75
+ {formatScore(value)}
76
+ </dd>
77
+ <div
78
+ aria-hidden
79
+ className="relative block h-[3px] overflow-hidden rounded-full bg-muted"
80
+ >
81
+ <span
82
+ className="absolute inset-y-0 left-0 rounded-full bg-[var(--gradient-primary,currentColor)]"
83
+ style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
84
+ />
85
+ </div>
86
+ <dt className="text-[10.5px] text-muted-foreground">{labels[key] ?? key}</dt>
87
+ </div>
88
+ ))}
89
+ </dl>
90
+ );
91
+ }
@@ -0,0 +1,59 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockPlaces } from "./menu-mocks";
3
+ import { VenueQuotes } from "./venue-quotes";
4
+
5
+ const highlights = mockPlaces[0].reviews_digest!.highlights!;
6
+
7
+ const meta: Meta<typeof VenueQuotes> = {
8
+ title: "Menu/VenueQuotes",
9
+ component: VenueQuotes,
10
+ tags: ["autodocs"],
11
+ decorators: [
12
+ (Story) => (
13
+ <div className="w-[720px] bg-background p-4">
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ parameters: {
19
+ docs: {
20
+ description: {
21
+ component:
22
+ "The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in serif italic against the sans everything else uses. `onCite` turns a quote into a button opening the reviews behind it: `review_ids` is what makes the claim checkable, and a quote nobody can trace is just copy.",
23
+ },
24
+ },
25
+ },
26
+ };
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof VenueQuotes>;
30
+
31
+ /** Inert prose. Correct on a surface with nowhere to open. */
32
+ export const Default: Story = { args: { quotes: highlights } };
33
+
34
+ /** With `onCite` each quote becomes a button onto its own evidence. */
35
+ export const Citable: Story = {
36
+ args: {
37
+ quotes: highlights,
38
+ onCite: () => {},
39
+ citeLabel: (count) => `Read ${count} reviews`,
40
+ },
41
+ };
42
+
43
+ /** A quote with no `review_ids` stays inert even when `onCite` is passed. */
44
+ export const MixedEvidence: Story = {
45
+ args: {
46
+ quotes: [highlights[0], { text: "Worth the walk from the centre." }],
47
+ onCite: () => {},
48
+ citeLabel: (count) => `Read ${count} reviews`,
49
+ },
50
+ };
51
+
52
+ export const Single: Story = { args: { quotes: [highlights[0]] } };
53
+
54
+ export const Limited: Story = {
55
+ args: {
56
+ quotes: [...highlights, { text: "A third line the surface has no room for." }],
57
+ limit: 2,
58
+ },
59
+ };
@@ -0,0 +1,64 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { CitedText } from "./place-types";
3
+
4
+ /**
5
+ * VenueQuotes — the digest's standout lines, as pull-quotes.
6
+ *
7
+ * These are `reviews_digest.highlights`: one to three short positives already
8
+ * resolved into the request locale. They are the only place on a hub where a
9
+ * reviewer speaks in their own register, so they are set in serif italic
10
+ * against the sans everything else uses.
11
+ *
12
+ * `onCite` turns a quote into a button that opens the reviews behind it —
13
+ * `review_ids` is what makes the claim checkable, and a quote nobody can trace
14
+ * is just copy. Without the handler the quote renders as inert prose, which is
15
+ * correct on surfaces that have nowhere to open.
16
+ *
17
+ * No directive: inert quotes render on the server; passing `onCite` needs a
18
+ * client caller.
19
+ */
20
+
21
+ export type VenueQuotesProps = {
22
+ quotes: CitedText[];
23
+ /** Opens the reviews behind one quote. Omit to render inert prose. */
24
+ onCite?: (quote: CitedText) => void;
25
+ /** Accessible name for the cite action, e.g. `(n) => "Read 5 reviews"`. */
26
+ citeLabel?: (reviewCount: number) => string;
27
+ /** Cap on how many render. The digest ships at most three. */
28
+ limit?: number;
29
+ className?: string;
30
+ };
31
+
32
+ export function VenueQuotes({ quotes, onCite, citeLabel, limit, className }: VenueQuotesProps) {
33
+ const shown = typeof limit === "number" ? quotes.slice(0, limit) : quotes;
34
+ if (shown.length === 0) return null;
35
+
36
+ return (
37
+ <div className={cn("grid gap-3 lg:grid-cols-2", className)}>
38
+ {shown.map((quote, index) => {
39
+ const citations = quote.review_ids?.length ?? 0;
40
+ const canCite = !!onCite && citations > 0;
41
+
42
+ const text = (
43
+ <blockquote className="border-l-2 border-border pl-3 font-serif text-[13px] italic leading-[1.45] text-muted-foreground">
44
+ {`“${quote.text}”`}
45
+ </blockquote>
46
+ );
47
+
48
+ if (!canCite) return <div key={index}>{text}</div>;
49
+
50
+ return (
51
+ <button
52
+ key={index}
53
+ type="button"
54
+ onClick={() => onCite(quote)}
55
+ aria-label={citeLabel?.(citations)}
56
+ className="rounded-md text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
57
+ >
58
+ {text}
59
+ </button>
60
+ );
61
+ })}
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,78 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { UtensilsCrossed } from "lucide-react";
3
+ import { Button } from "./button";
4
+ import { mockPlaces } from "./menu-mocks";
5
+ import { VenueWall } from "./venue-wall";
6
+
7
+ const hrefs = Object.fromEntries(
8
+ mockPlaces.map((place) => [place.slug, `/places/${place.slug}`])
9
+ );
10
+
11
+ const meta: Meta<typeof VenueWall> = {
12
+ title: "Menu/VenueWall",
13
+ component: VenueWall,
14
+ tags: ["autodocs"],
15
+ decorators: [
16
+ (Story) => (
17
+ <div className="w-[1100px] bg-background p-4">
18
+ <Story />
19
+ </div>
20
+ ),
21
+ ],
22
+ parameters: {
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "The catalogue's scale, stated as a row of the venues in it. A closing beat rather than a browse surface: the avatars are named and linked, but the module's job is to make \"we read every menu in the city\" land as something specific. It shows a handful and counts the rest, because a wall long enough to be exhaustive stops reading as places.",
27
+ },
28
+ },
29
+ },
30
+ };
31
+
32
+ export default meta;
33
+ type Story = StoryObj<typeof VenueWall>;
34
+
35
+ export const Default: Story = {
36
+ args: {
37
+ places: mockPlaces,
38
+ hrefs,
39
+ kicker: "397 MENUS IN CLUJ-NAPOCA, READ IN FULL",
40
+ kickerIcon: <UtensilsCrossed className="h-3 w-3" />,
41
+ headline: "Every menu in the city, priced to the portion",
42
+ body: "45,140 dishes and drinks with what they cost, how big they are, and a mention count on every one of them.",
43
+ overflowLabel: "+393 more",
44
+ action: <Button size="sm" variant="secondary">Ask Bruno</Button>,
45
+ },
46
+ };
47
+
48
+ /** Avatars only — the wall inside a section that already has a header. */
49
+ export const Bare: Story = {
50
+ args: { places: mockPlaces, hrefs, overflowLabel: "+393 more" },
51
+ };
52
+
53
+ /** Inert avatars, for a render with nowhere to navigate. */
54
+ export const Static: Story = {
55
+ args: {
56
+ places: mockPlaces,
57
+ kicker: "397 MENUS READ IN FULL",
58
+ headline: "Every menu in the city, priced to the portion",
59
+ overflowLabel: "+393 more",
60
+ },
61
+ };
62
+
63
+ export const Mobile: Story = {
64
+ args: {
65
+ places: mockPlaces.slice(0, 3),
66
+ hrefs,
67
+ kicker: "397 MENUS READ IN FULL",
68
+ headline: "Every menu in the city, priced to the portion",
69
+ overflowLabel: "+394 more",
70
+ },
71
+ decorators: [
72
+ (Story) => (
73
+ <div className="w-[390px] bg-background p-3">
74
+ <Story />
75
+ </div>
76
+ ),
77
+ ],
78
+ };
@@ -0,0 +1,185 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { MenuLinkProps, MenuLinkComponent } from "./menu-types";
3
+ import type { PlacePreview } from "./place-types";
4
+
5
+ /**
6
+ * VenueWall — the catalogue's scale, stated as a row of the venues in it.
7
+ *
8
+ * A closing beat rather than a browse surface: the avatars are named and
9
+ * linked, but the module's job is to make "we read every menu in the city"
10
+ * land as something specific. It shows a handful and counts the rest, because
11
+ * a wall long enough to be exhaustive stops reading as places.
12
+ *
13
+ * Server-safe.
14
+ */
15
+
16
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
17
+ export type VenueWallLinkProps = MenuLinkProps;
18
+
19
+ function DefaultLink({ href, ...props }: VenueWallLinkProps) {
20
+ return <a href={href} {...props} />;
21
+ }
22
+
23
+ export type VenueWallProps = {
24
+ places: PlacePreview[];
25
+ /** Destination per place, keyed by slug. Without one an avatar is inert. */
26
+ hrefs?: Record<string, string>;
27
+ kicker?: string;
28
+ kickerIcon?: React.ReactNode;
29
+ headline?: string;
30
+ body?: string;
31
+ /** Composed overflow chip, e.g. "+393 more". */
32
+ overflowLabel?: string;
33
+ /**
34
+ * Tiles for the mosaic ground — the same licensed stills the hero uses.
35
+ * Without them the wall falls back to the flat diagonal texture, which is
36
+ * the degraded state rather than the design.
37
+ */
38
+ tileUrls?: string[];
39
+ /** The action — pass a rendered link or button. */
40
+ action?: React.ReactNode;
41
+ className?: string;
42
+ linkComponent?: MenuLinkComponent;
43
+ };
44
+
45
+ export function VenueWall({
46
+ places,
47
+ hrefs,
48
+ kicker,
49
+ kickerIcon,
50
+ headline,
51
+ body,
52
+ overflowLabel,
53
+ tileUrls,
54
+ action,
55
+ linkComponent: LinkComponent = DefaultLink,
56
+ className,
57
+ }: VenueWallProps) {
58
+ return (
59
+ <div
60
+ className={cn(
61
+ "relative flex flex-col gap-4 overflow-hidden rounded-[20px] bg-[oklch(0.21_0.012_55)] p-5 text-white lg:p-8",
62
+ className
63
+ )}
64
+ >
65
+ {tileUrls?.length ? (
66
+ <>
67
+ <div aria-hidden className="absolute inset-0 grid auto-rows-fr grid-cols-4 gap-0.5 lg:grid-cols-6">
68
+ {Array.from({ length: 18 }, (_, cell) => (
69
+ // eslint-disable-next-line @next/next/no-img-element
70
+ <img
71
+ key={cell}
72
+ src={tileUrls[cell % tileUrls.length]}
73
+ alt=""
74
+ loading="lazy"
75
+ decoding="async"
76
+ className="h-full w-full object-cover"
77
+ />
78
+ ))}
79
+ </div>
80
+ {/* Heavier than the hero's: this block's copy sits over the full
81
+ width rather than in a left column. */}
82
+ <div
83
+ aria-hidden
84
+ className="absolute inset-0 bg-[oklch(0.16_0.012_55_/_0.9)]"
85
+ />
86
+ </>
87
+ ) : (
88
+ <div
89
+ aria-hidden
90
+ className="absolute inset-0 bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.18]"
91
+ />
92
+ )}
93
+
94
+ <div className="relative z-[1] flex flex-col gap-3">
95
+ {kicker && (
96
+ <span className="inline-flex w-max items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-[11px] font-extrabold tracking-[0.11em]">
97
+ {kickerIcon}
98
+ {kicker}
99
+ </span>
100
+ )}
101
+
102
+ {headline && (
103
+ <h3 className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.1] tracking-[-0.025em] lg:text-[40px]">
104
+ {headline}
105
+ </h3>
106
+ )}
107
+
108
+ {body && <p className="hidden max-w-[540px] text-sm leading-[1.55] opacity-85 lg:block">{body}</p>}
109
+ </div>
110
+
111
+ {/* Below `lg` the crowd is a stack: the first few faces overlapping, then
112
+ the count. Eight named chips wrapped over four lines were most of the
113
+ banner on a phone and said nothing the count does not. */}
114
+ <div aria-hidden className="relative z-[1] flex items-center gap-3 lg:hidden">
115
+ <div className="flex">
116
+ {places.slice(0, 5).map((place, index) => (
117
+ <span
118
+ key={place.slug}
119
+ className={cn(
120
+ "h-9 w-9 shrink-0 overflow-hidden rounded-full bg-white/20 ring-2 ring-[oklch(0.21_0.012_55)]",
121
+ index > 0 && "-ml-2.5"
122
+ )}
123
+ >
124
+ {place.thumbnail_url && (
125
+ // eslint-disable-next-line @next/next/no-img-element
126
+ <img src={place.thumbnail_url} alt="" className="h-full w-full object-cover" loading="lazy" />
127
+ )}
128
+ </span>
129
+ ))}
130
+ </div>
131
+ {overflowLabel && <span className="text-[13px] font-medium opacity-85">{overflowLabel}</span>}
132
+ </div>
133
+
134
+ <ul className="relative z-[1] hidden flex-wrap items-center gap-2 lg:flex">
135
+ {places.map((place) => {
136
+ const avatar = (
137
+ <>
138
+ <span className="h-7 w-7 shrink-0 overflow-hidden rounded-full bg-white/20">
139
+ {place.thumbnail_url && (
140
+ // eslint-disable-next-line @next/next/no-img-element
141
+ <img
142
+ src={place.thumbnail_url}
143
+ alt=""
144
+ className="h-full w-full object-cover"
145
+ loading="lazy"
146
+ />
147
+ )}
148
+ </span>
149
+ <span className="truncate text-[12.5px] font-medium">{place.name}</span>
150
+ </>
151
+ );
152
+
153
+ const chipClasses =
154
+ "inline-flex max-w-[220px] items-center gap-2 rounded-full bg-white/10 py-1 pl-1 pr-3.5 backdrop-blur-[6px]";
155
+
156
+ return (
157
+ <li key={place.slug}>
158
+ {hrefs?.[place.slug] ? (
159
+ <LinkComponent
160
+ href={hrefs[place.slug]}
161
+ className={cn(
162
+ chipClasses,
163
+ "transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
164
+ )}
165
+ >
166
+ {avatar}
167
+ </LinkComponent>
168
+ ) : (
169
+ <span className={chipClasses}>{avatar}</span>
170
+ )}
171
+ </li>
172
+ );
173
+ })}
174
+
175
+ {overflowLabel && (
176
+ <li className="inline-flex h-9 items-center rounded-full bg-white/10 px-4 text-[12.5px] opacity-80">
177
+ {overflowLabel}
178
+ </li>
179
+ )}
180
+ </ul>
181
+
182
+ {action && <div className="relative z-[1] mt-1">{action}</div>}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,113 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Calendar, PanelRightOpen } from "lucide-react";
3
+ import { Button } from "./button";
4
+ import { WeekdayThemeBanner, type WeekdayThemeDay } from "./weekday-theme-banner";
5
+
6
+ const days: WeekdayThemeDay[] = [
7
+ { code: "MON", name: "Meatless Monday", shortName: "Meatless", count: 84 },
8
+ { code: "TUE", name: "Taco Tuesday", shortName: "Taco", count: 26 },
9
+ { code: "WED", name: "Salad Wednesday", shortName: "Salad", count: 132 },
10
+ { code: "THU", name: "Burger Thursday", shortName: "Burger", count: 186 },
11
+ { code: "FRI", name: "Pizza Friday", shortName: "Pizza", count: 214 },
12
+ { code: "SAT", name: "Brunch Saturday", shortName: "Brunch", count: 96 },
13
+ { code: "SUN", name: "Roast Sunday", shortName: "Roast", count: 41 },
14
+ ];
15
+
16
+ const drinkDays: WeekdayThemeDay[] = [
17
+ { code: "MON", name: "Mulled Monday", shortName: "Mulled", count: 22 },
18
+ { code: "TUE", name: "Tap Takeover", shortName: "Tap", count: 41 },
19
+ { code: "WED", name: "Wine Wednesday", shortName: "Wine", count: 52 },
20
+ { code: "THU", name: "Spritz Thursday", shortName: "Spritz", count: 64 },
21
+ { code: "FRI", name: "Negroni Friday", shortName: "Negroni", count: 38 },
22
+ { code: "SAT", name: "Sour Saturday", shortName: "Sour", count: 29 },
23
+ { code: "SUN", name: "Slow Coffee Sunday", shortName: "Coffee", count: 34 },
24
+ ];
25
+
26
+ const meta: Meta<typeof WeekdayThemeBanner> = {
27
+ title: "Menu/WeekdayThemeBanner",
28
+ component: WeekdayThemeBanner,
29
+ tags: ["autodocs"],
30
+ decorators: [
31
+ (Story) => (
32
+ <div className="w-[1100px] bg-background p-4">
33
+ <Story />
34
+ </div>
35
+ ),
36
+ ],
37
+ parameters: {
38
+ docs: {
39
+ description: {
40
+ component:
41
+ "The day's editorial theme, with the whole week beside it. Showing all seven is the point: a banner that only says \"it is Burger Thursday\" is an assertion, while one showing Wednesday was salads and Friday is pizza reads as a standing rhythm worth coming back for — and the other six days are the only way to browse to one. `activeDay` is resolved by the CALLER from the reader's clock, because a server in UTC and a reader in Bucharest disagree about the date for two hours a day.",
42
+ },
43
+ },
44
+ },
45
+ };
46
+
47
+ export default meta;
48
+ type Story = StoryObj<typeof WeekdayThemeBanner>;
49
+
50
+ export const Default: Story = {
51
+ args: {
52
+ days,
53
+ activeDay: 3,
54
+ kicker: "TODAY IS THURSDAY",
55
+ kickerIcon: <Calendar className="h-3 w-3" />,
56
+ body: "186 of them across 11 kitchens, from 32 lei. Two do a vegetarian version and four keep the grill on past midnight.",
57
+ weekLabel: "THE WEEK",
58
+ action: (
59
+ <Button size="sm" variant="secondary">
60
+ <PanelRightOpen className="h-3.5 w-3.5" />
61
+ See today&apos;s picks
62
+ </Button>
63
+ ),
64
+ actionNote: "Bruno picks nine, ranked on review mentions",
65
+ dayHref: (day) => `/menu?theme=${day.code.toLowerCase()}`,
66
+ },
67
+ };
68
+
69
+ /** The drinks tab, same component. */
70
+ export const DrinksTab: Story = {
71
+ args: {
72
+ days: drinkDays,
73
+ activeDay: 2,
74
+ kicker: "TODAY IS WEDNESDAY",
75
+ kickerIcon: <Calendar className="h-3 w-3" />,
76
+ body: "Fifty-two by the glass across nineteen bars, from 16 lei.",
77
+ weekLabel: "THE WEEK",
78
+ },
79
+ };
80
+
81
+ /** `onDayChange` instead of `dayHref` — the rail selects in place. Needs a client caller. */
82
+ export const Selectable: Story = {
83
+ args: { days, activeDay: 4, kicker: "TODAY IS FRIDAY", weekLabel: "THE WEEK", onDayChange: () => {} },
84
+ };
85
+
86
+ /** No counts: themes without a number behind them still read. */
87
+ export const WithoutCounts: Story = {
88
+ args: {
89
+ days: days.map(({ count: _count, ...day }) => day),
90
+ activeDay: 0,
91
+ kicker: "TODAY IS MONDAY",
92
+ weekLabel: "THE WEEK",
93
+ },
94
+ };
95
+
96
+ export const Mobile: Story = {
97
+ args: {
98
+ days,
99
+ activeDay: 3,
100
+ kicker: "TODAY IS THURSDAY",
101
+ kickerIcon: <Calendar className="h-3 w-3" />,
102
+ body: "186 of them across 11 kitchens, from 32 lei.",
103
+ weekLabel: "THE WEEK",
104
+ action: <Button size="sm" variant="secondary">See today&apos;s picks</Button>,
105
+ },
106
+ decorators: [
107
+ (Story) => (
108
+ <div className="w-[390px] bg-background p-3">
109
+ <Story />
110
+ </div>
111
+ ),
112
+ ],
113
+ };