@bearmenu/ui 0.8.7 → 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 (179) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-62ES6N32.js +27 -0
  3. package/dist/chunk-AHL45DD2.js +145 -0
  4. package/dist/chunk-AXV5O2IZ.js +58 -0
  5. package/dist/chunk-IYDH5IU6.js +66 -0
  6. package/dist/chunk-OPVKL4VS.js +18 -0
  7. package/dist/chunk-Q6QH52LJ.js +38 -0
  8. package/dist/chunk-TH6W2PWG.js +213 -0
  9. package/dist/chunk-THOVPZNN.js +37 -0
  10. package/dist/chunk-YS6BHKZO.js +92 -0
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/button.d.ts +1 -1
  13. package/dist/components/category-deck.js +10 -1
  14. package/dist/components/command.d.ts +7 -7
  15. package/dist/components/concept-compare.d.ts +73 -0
  16. package/dist/components/concept-compare.js +162 -0
  17. package/dist/components/coverflow.d.ts +11 -3
  18. package/dist/components/coverflow.js +34 -51
  19. package/dist/components/date-input.d.ts +1 -1
  20. package/dist/components/drift-rail.d.ts +24 -10
  21. package/dist/components/drift-rail.js +1 -1
  22. package/dist/components/event-card.d.ts +8 -0
  23. package/dist/components/event-card.js +64 -34
  24. package/dist/components/event-compact-row.d.ts +33 -6
  25. package/dist/components/event-compact-row.js +120 -39
  26. package/dist/components/event-day-header.js +3 -1
  27. package/dist/components/event-day-separator.d.ts +7 -1
  28. package/dist/components/event-day-separator.js +3 -2
  29. package/dist/components/event-detail-panel.d.ts +171 -25
  30. package/dist/components/event-detail-panel.js +471 -174
  31. package/dist/components/event-hub-hero.d.ts +25 -4
  32. package/dist/components/event-hub-hero.js +89 -19
  33. package/dist/components/event-list-row-wide.js +1 -1
  34. package/dist/components/event-list-row.d.ts +19 -6
  35. package/dist/components/event-list-row.js +79 -35
  36. package/dist/components/hour-band.d.ts +38 -40
  37. package/dist/components/hour-band.js +55 -27
  38. package/dist/components/hour-group.js +1 -1
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +20 -0
  41. package/dist/components/menu-family-card.d.ts +59 -0
  42. package/dist/components/menu-family-card.js +121 -0
  43. package/dist/components/menu-field.d.ts +57 -0
  44. package/dist/components/menu-field.js +3 -0
  45. package/dist/components/menu-hub-hero.d.ts +65 -0
  46. package/dist/components/menu-hub-hero.js +103 -0
  47. package/dist/components/menu-intent-tiles.d.ts +31 -0
  48. package/dist/components/menu-intent-tiles.js +50 -0
  49. package/dist/components/menu-item.d.ts +127 -0
  50. package/dist/components/menu-item.js +5 -0
  51. package/dist/components/menu-mocks.d.ts +55 -0
  52. package/dist/components/menu-mocks.js +379 -0
  53. package/dist/components/menu-types.d.ts +245 -0
  54. package/dist/components/menu-types.js +1 -0
  55. package/dist/components/motion-icon-set.js +15 -3
  56. package/dist/components/open-now-list.d.ts +40 -0
  57. package/dist/components/open-now-list.js +69 -0
  58. package/dist/components/place-types.d.ts +112 -1
  59. package/dist/components/poster-tape.js +1 -1
  60. package/dist/components/poster-wall.d.ts +3 -3
  61. package/dist/components/poster-wall.js +73 -17
  62. package/dist/components/ranked-venue-list.d.ts +49 -0
  63. package/dist/components/ranked-venue-list.js +87 -0
  64. package/dist/components/section-header.d.ts +16 -1
  65. package/dist/components/section-header.js +26 -7
  66. package/dist/components/signature-chip.d.ts +37 -0
  67. package/dist/components/signature-chip.js +4 -0
  68. package/dist/components/signature-line-list.d.ts +45 -0
  69. package/dist/components/signature-line-list.js +99 -0
  70. package/dist/components/snap-rail.d.ts +54 -0
  71. package/dist/components/snap-rail.js +109 -0
  72. package/dist/components/spinner.d.ts +2 -2
  73. package/dist/components/stat-banner.d.ts +47 -0
  74. package/dist/components/stat-banner.js +61 -0
  75. package/dist/components/text.d.ts +3 -3
  76. package/dist/components/timeline-band.d.ts +20 -2
  77. package/dist/components/timeline-band.js +1 -1
  78. package/dist/components/timeline-playhead.d.ts +26 -0
  79. package/dist/components/timeline-playhead.js +25 -0
  80. package/dist/components/venue-busyness-strip.d.ts +35 -0
  81. package/dist/components/venue-busyness-strip.js +37 -0
  82. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  83. package/dist/components/venue-grade-dimensions.js +3 -0
  84. package/dist/components/venue-quotes.d.ts +32 -0
  85. package/dist/components/venue-quotes.js +3 -0
  86. package/dist/components/venue-signature-block.d.ts +39 -0
  87. package/dist/components/venue-signature-block.js +6 -0
  88. package/dist/components/venue-spotlight.d.ts +74 -0
  89. package/dist/components/venue-spotlight.js +138 -0
  90. package/dist/components/venue-wall.d.ts +41 -0
  91. package/dist/components/venue-wall.js +118 -0
  92. package/dist/components/weekday-theme-banner.d.ts +70 -0
  93. package/dist/components/weekday-theme-banner.js +127 -0
  94. package/dist/components/weekend-panel.d.ts +5 -4
  95. package/dist/components/weekend-panel.js +85 -71
  96. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  97. package/dist/hooks/use-drag-swipe.js +2 -0
  98. package/dist/hooks/use-media-query.d.ts +17 -0
  99. package/dist/hooks/use-media-query.js +2 -0
  100. package/package.json +1 -1
  101. package/src/components/category-deck.tsx +19 -8
  102. package/src/components/colour-dictionary.stories.tsx +227 -0
  103. package/src/components/concept-compare.stories.tsx +104 -0
  104. package/src/components/concept-compare.tsx +276 -0
  105. package/src/components/coverflow.test.tsx +28 -1
  106. package/src/components/coverflow.tsx +37 -43
  107. package/src/components/drift-rail.tsx +167 -42
  108. package/src/components/event-card.tsx +55 -10
  109. package/src/components/event-compact-row.stories.tsx +30 -0
  110. package/src/components/event-compact-row.test.tsx +49 -0
  111. package/src/components/event-compact-row.tsx +162 -38
  112. package/src/components/event-day-header.tsx +3 -1
  113. package/src/components/event-day-separator.tsx +8 -1
  114. package/src/components/event-detail-panel.stories.tsx +205 -97
  115. package/src/components/event-detail-panel.test.tsx +181 -0
  116. package/src/components/event-detail-panel.tsx +703 -185
  117. package/src/components/event-hub-hero.stories.tsx +30 -0
  118. package/src/components/event-hub-hero.tsx +102 -30
  119. package/src/components/event-list-row.stories.tsx +34 -0
  120. package/src/components/event-list-row.test.tsx +53 -0
  121. package/src/components/event-list-row.tsx +135 -87
  122. package/src/components/hour-band.stories.tsx +28 -5
  123. package/src/components/hour-band.tsx +90 -70
  124. package/src/components/hub-surface.stories.tsx +68 -0
  125. package/src/components/hub-surface.tsx +40 -0
  126. package/src/components/menu-family-card.stories.tsx +112 -0
  127. package/src/components/menu-family-card.tsx +211 -0
  128. package/src/components/menu-field.stories.tsx +74 -0
  129. package/src/components/menu-field.tsx +199 -0
  130. package/src/components/menu-hub-hero.stories.tsx +92 -0
  131. package/src/components/menu-hub-hero.tsx +187 -0
  132. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  133. package/src/components/menu-intent-tiles.tsx +76 -0
  134. package/src/components/menu-item.tsx +416 -0
  135. package/src/components/menu-mocks.ts +417 -0
  136. package/src/components/menu-types.ts +259 -0
  137. package/src/components/motion-icon-set.tsx +30 -3
  138. package/src/components/open-now-list.stories.tsx +76 -0
  139. package/src/components/open-now-list.tsx +127 -0
  140. package/src/components/place-types.ts +112 -0
  141. package/src/components/poster-wall.test.tsx +28 -0
  142. package/src/components/poster-wall.tsx +96 -23
  143. package/src/components/ranked-venue-list.stories.tsx +80 -0
  144. package/src/components/ranked-venue-list.tsx +177 -0
  145. package/src/components/section-header.stories.tsx +24 -0
  146. package/src/components/section-header.test.tsx +28 -0
  147. package/src/components/section-header.tsx +45 -7
  148. package/src/components/signature-chip.tsx +77 -0
  149. package/src/components/signature-line-list.stories.tsx +85 -0
  150. package/src/components/signature-line-list.tsx +136 -0
  151. package/src/components/snap-rail.stories.tsx +79 -0
  152. package/src/components/snap-rail.test.tsx +18 -0
  153. package/src/components/snap-rail.tsx +148 -0
  154. package/src/components/stat-banner.stories.tsx +100 -0
  155. package/src/components/stat-banner.tsx +117 -0
  156. package/src/components/timeline-band.tsx +52 -12
  157. package/src/components/timeline-playhead.stories.tsx +30 -0
  158. package/src/components/timeline-playhead.test.tsx +11 -0
  159. package/src/components/timeline-playhead.tsx +46 -0
  160. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  161. package/src/components/venue-busyness-strip.tsx +64 -0
  162. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  163. package/src/components/venue-grade-dimensions.tsx +91 -0
  164. package/src/components/venue-quotes.stories.tsx +59 -0
  165. package/src/components/venue-quotes.tsx +64 -0
  166. package/src/components/venue-signature-block.stories.tsx +68 -0
  167. package/src/components/venue-signature-block.tsx +71 -0
  168. package/src/components/venue-spotlight.stories.tsx +141 -0
  169. package/src/components/venue-spotlight.tsx +236 -0
  170. package/src/components/venue-wall.stories.tsx +78 -0
  171. package/src/components/venue-wall.tsx +185 -0
  172. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  173. package/src/components/weekday-theme-banner.tsx +231 -0
  174. package/src/components/weekend-panel.test.tsx +43 -0
  175. package/src/components/weekend-panel.tsx +142 -108
  176. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  177. package/src/hooks/use-drag-swipe.ts +105 -0
  178. package/src/hooks/use-media-query.ts +31 -0
  179. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockGradients, mockVenues } from "./menu-mocks";
3
+ import { VenueSignatureBlock } from "./venue-signature-block";
4
+
5
+ const labels = {
6
+ currency: "lei",
7
+ mentions: (count: number) => `${count} mentions`,
8
+ signature: "SIGNATURE",
9
+ mentioned: "MENTIONED",
10
+ };
11
+
12
+ const meta: Meta<typeof VenueSignatureBlock> = {
13
+ title: "Menu/VenueSignatureBlock",
14
+ component: VenueSignatureBlock,
15
+ tags: ["autodocs"],
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="w-[520px] bg-background p-4">
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "\"What it is known for\": the one dish or drink a venue's reviews single out, on a tinted band. It is a `MenuItem` with a kicker — the same row the venue's own menu renders, so the item does not look like a different object here. The kicker sits above the row rather than inside it; inline at 9.5px it competed with the dish name for the same line.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof VenueSignatureBlock>;
35
+
36
+ export const Default: Story = {
37
+ args: {
38
+ item: mockVenues[0].signature!.item,
39
+ labels,
40
+ formatPrice: (amount: number) => String(amount),
41
+ kicker: "WHAT IT IS KNOWN FOR",
42
+ markGradient: mockGradients.meat,
43
+ subtitle: mockVenues[0].signature!.why,
44
+ href: "/menu/dishes/chicken-wings",
45
+ },
46
+ };
47
+
48
+ /** A drink signature — same block, different scope. */
49
+ export const Drink: Story = {
50
+ args: {
51
+ item: mockVenues[2].signature!.item,
52
+ labels,
53
+ formatPrice: (amount: number) => String(amount),
54
+ kicker: "WHAT THEY POUR",
55
+ markGradient: mockGradients.cocktail,
56
+ },
57
+ };
58
+
59
+ /** Inert: no `href`. Used inside a surface that is itself a link. */
60
+ export const Static: Story = {
61
+ args: {
62
+ item: mockVenues[1].signature!.item,
63
+ labels,
64
+ formatPrice: (amount: number) => String(amount),
65
+ kicker: "WHAT IT IS KNOWN FOR",
66
+ markGradient: mockGradients.pasta,
67
+ },
68
+ };
@@ -0,0 +1,71 @@
1
+ import { cn } from "../lib/utils";
2
+ import { MenuItem, type MenuItemLabels, type MenuItemLinkProps } from "./menu-item";
3
+ import type { MenuItemPreview } from "./menu-types";
4
+
5
+ /**
6
+ * VenueSignatureBlock — "what it is known for": the one dish or drink a venue's
7
+ * reviews single out, on a tinted band.
8
+ *
9
+ * ## It is a `<MenuItem>` with a kicker
10
+ *
11
+ * It used to be its own layout — swatch, label, name, price, all hand-placed —
12
+ * and it drifted from the row every other surface renders. It is now the
13
+ * production row on an accent ground, so a dish looks like itself here, in the
14
+ * "known for" list, and on the venue's own menu.
15
+ *
16
+ * The kicker sits ABOVE the row rather than inside it. Inline at 9.5px it
17
+ * competed with the dish name for the same line, which is how it ended up
18
+ * stacked one word per line.
19
+ */
20
+
21
+ export type VenueSignatureBlockProps = {
22
+ item: MenuItemPreview;
23
+ labels: MenuItemLabels;
24
+ formatPrice: (amount: number) => string;
25
+ formatCount?: (value: number) => string;
26
+ /** Small caps kicker, e.g. "WHAT IT IS KNOWN FOR". */
27
+ kicker: string;
28
+ /** The row's category mark, as a CSS `background-image` value. */
29
+ markGradient?: string;
30
+ /** Reviewers' reason, in place of the item description. */
31
+ subtitle?: string;
32
+ href?: string;
33
+ linkComponent?: React.ComponentType<MenuItemLinkProps>;
34
+ className?: string;
35
+ };
36
+
37
+ export function VenueSignatureBlock({
38
+ item,
39
+ labels,
40
+ formatPrice,
41
+ formatCount,
42
+ kicker,
43
+ markGradient,
44
+ subtitle,
45
+ href,
46
+ linkComponent,
47
+ className,
48
+ }: VenueSignatureBlockProps) {
49
+ return (
50
+ <div className={cn("overflow-hidden rounded-xl bg-accent-subtle py-1", className)}>
51
+ <span className="block px-4 pt-2 text-[9.5px] font-bold uppercase tracking-[0.09em] text-muted-foreground">
52
+ {kicker}
53
+ </span>
54
+
55
+ <MenuItem
56
+ item={item}
57
+ labels={labels}
58
+ formatPrice={formatPrice}
59
+ formatCount={formatCount}
60
+ markGradient={markGradient}
61
+ subtitle={subtitle}
62
+ showSignatureBadge={false}
63
+ href={href}
64
+ linkComponent={linkComponent}
65
+ // The band is already the emphasis; the row must not also take a hover
66
+ // fill inside it.
67
+ className="min-h-0 md:rounded-none md:hover:bg-transparent md:hover:shadow-none"
68
+ />
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,141 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "./button";
3
+ import { mockFormatters, mockGradients, mockPlaces, mockVenues } from "./menu-mocks";
4
+ import { VenueSpotlight } from "./venue-spotlight";
5
+
6
+
7
+ const itemLabels = {
8
+ currency: "lei",
9
+ mentions: (count: number) => `${count} mentions`,
10
+ signature: "SIGNATURE",
11
+ mentioned: "MENTIONED",
12
+ };
13
+ const formatPrice = (amount: number) => String(amount);
14
+
15
+ const labels = {
16
+ photoCredit: "venue photograph",
17
+ openUntil: "Kitchen open until 23:00",
18
+ signatureKicker: "WHAT IT IS KNOWN FOR",
19
+ reviewCount: "318 reviews",
20
+ dimensions: {
21
+ food: "Food",
22
+ coffee_quality: "Coffee",
23
+ drinks_quality: "Drinks",
24
+ service: "Service",
25
+ value: "Value",
26
+ ambiance: "Ambiance",
27
+ portion: "Portion",
28
+ consistency: "Consistency",
29
+ },
30
+ tags: { casual: "casual", cozy: "cozy", family_friendly: "family friendly" },
31
+ valueVerdict: "Good value",
32
+ busyness: "Usually not busy until 18:00 — the rush is seven to eight.",
33
+ cite: (count: number) => `Read ${count} reviews`,
34
+ };
35
+
36
+ const meta: Meta<typeof VenueSpotlight> = {
37
+ title: "Menu/VenueSpotlight",
38
+ component: VenueSpotlight,
39
+ tags: ["autodocs"],
40
+ decorators: [
41
+ (Story) => (
42
+ <div className="w-[1100px] bg-background p-4">
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ parameters: {
48
+ docs: {
49
+ description: {
50
+ component:
51
+ "One venue given real space: photograph, what it is known for, what reviewers say, how it grades, and when it fills up. The photograph is the point — this is the one module on a menu hub that shows an image, and it can, because venue photography is real and licensed where item photography is generated. Composed from `VenueSignatureBlock`, `VenueQuotes`, `VenueGradeDimensions` and `VenueBusynessStrip`, each exported on its own for consumers building a narrower variant.",
52
+ },
53
+ },
54
+ },
55
+ };
56
+
57
+ export default meta;
58
+ type Story = StoryObj<typeof VenueSpotlight>;
59
+
60
+ export const Default: Story = {
61
+ args: {
62
+ place: mockPlaces[0],
63
+ href: mockVenues[0].href,
64
+ labels,
65
+ meta: "pub · Centru · 318 reviews read",
66
+ signature: mockVenues[0].signature,
67
+ signatureGradient: mockGradients.meat,
68
+ formatScore: mockFormatters.score,
69
+ itemLabels,
70
+ formatPrice,
71
+ action: <Button size="sm">Open the menu</Button>,
72
+ },
73
+ };
74
+
75
+ /** A coffee bar: no food dimension, and a `frequent` signature rather than a signature one. */
76
+ export const CoffeeBar: Story = {
77
+ args: {
78
+ place: mockPlaces[2],
79
+ href: mockVenues[2].href,
80
+ labels: { ...labels, openUntil: "Open until 20:00", reviewCount: "96 reviews" },
81
+ meta: "coffee · Centru",
82
+ signature: mockVenues[2].signature,
83
+ signatureGradient: mockGradients.sweet,
84
+ formatScore: mockFormatters.score,
85
+ itemLabels,
86
+ formatPrice,
87
+ },
88
+ };
89
+
90
+ /**
91
+ * A venue the review census has not reached: no digest at all. Every block that
92
+ * depends on one drops out and the card still reads as a venue.
93
+ */
94
+ export const WithoutDigest: Story = {
95
+ args: {
96
+ place: mockPlaces[3],
97
+ href: mockVenues[3].href,
98
+ labels: { ...labels, openUntil: undefined, reviewCount: "141 reviews" },
99
+ meta: "restaurant · Mărăști",
100
+ formatScore: mockFormatters.score,
101
+ itemLabels,
102
+ formatPrice,
103
+ },
104
+ };
105
+
106
+ /** Quotes wired to their evidence. Needs a client caller. */
107
+ export const CitableQuotes: Story = {
108
+ args: {
109
+ place: mockPlaces[0],
110
+ href: mockVenues[0].href,
111
+ labels,
112
+ meta: "pub · Centru",
113
+ signature: mockVenues[0].signature,
114
+ signatureGradient: mockGradients.meat,
115
+ formatScore: mockFormatters.score,
116
+ itemLabels,
117
+ formatPrice,
118
+ onCite: () => {},
119
+ },
120
+ };
121
+
122
+ export const Mobile: Story = {
123
+ args: {
124
+ place: mockPlaces[0],
125
+ href: mockVenues[0].href,
126
+ labels,
127
+ meta: "pub · Centru",
128
+ signature: mockVenues[0].signature,
129
+ signatureGradient: mockGradients.meat,
130
+ formatScore: mockFormatters.score,
131
+ itemLabels,
132
+ formatPrice,
133
+ },
134
+ decorators: [
135
+ (Story) => (
136
+ <div className="w-[390px] bg-background p-3">
137
+ <Story />
138
+ </div>
139
+ ),
140
+ ],
141
+ };
@@ -0,0 +1,236 @@
1
+ import { Camera, Star } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Separator } from "./separator";
4
+ import { VenueGradeDimensions, type VenueGradeDimensionKey } from "./venue-grade-dimensions";
5
+ import { VenueQuotes } from "./venue-quotes";
6
+ import { VenueSignatureBlock } from "./venue-signature-block";
7
+ import type { MenuItemLabels } from "./menu-item";
8
+ import type { MenuVenueSignature, MenuLinkProps, MenuLinkComponent } from "./menu-types";
9
+ import type { CitedText, PlacePreview } from "./place-types";
10
+
11
+ /**
12
+ * VenueSpotlight — one venue given real space: photograph, what it is known
13
+ * for, what reviewers say, how it grades, and when it fills up.
14
+ *
15
+ * The photograph IS the point of this surface. It is the one module on a menu
16
+ * hub that shows an image, and it can, because venue photography is real and
17
+ * licensed where item photography is generated. Absent a photo it degrades to
18
+ * the same diagonal texture the hero uses, never to a bare icon.
19
+ *
20
+ * ## Composed from exported parts
21
+ *
22
+ * `VenueSignatureBlock`, `VenueQuotes`, `VenueGradeDimensions` and
23
+ * `VenueBusynessStrip` are each exported on their own. This composite is the
24
+ * arrangement the hub wants; a consumer filming a single panel, or building a
25
+ * narrower variant, takes the parts instead. Everything here is layout — no
26
+ * part is reachable only through the composite.
27
+ *
28
+ * No directive: it renders on the server unless `onCite` is passed through to
29
+ * the quotes.
30
+ */
31
+
32
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
33
+ export type VenueSpotlightLinkProps = MenuLinkProps;
34
+
35
+ function DefaultLink({ href, ...props }: VenueSpotlightLinkProps) {
36
+ return <a href={href} {...props} />;
37
+ }
38
+
39
+ export type VenueSpotlightLabels = {
40
+ /** Overlay on the photograph, e.g. "venue photograph". */
41
+ photoCredit?: string;
42
+ /** Composed open state, e.g. "Kitchen open until 23:00". Absent when closed or unknown. */
43
+ openUntil?: string;
44
+ /** Kicker over the signature, e.g. "WHAT IT IS KNOWN FOR". */
45
+ signatureKicker: string;
46
+ /** Composed review count, e.g. "318 reviews". */
47
+ reviewCount?: string;
48
+ /** Dimension key → translated label. */
49
+ dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
50
+ /** Tag code → translated label. Missing codes render the raw code. */
51
+ tags?: Record<string, string>;
52
+ /** Composed value verdict, e.g. "Good value". */
53
+ valueVerdict?: string;
54
+ /** Composed busyness sentence. */
55
+ busyness?: string;
56
+ /** Accessible name for a quote's cite action. */
57
+ cite?: (reviewCount: number) => string;
58
+ };
59
+
60
+ export type VenueSpotlightProps = {
61
+ place: PlacePreview;
62
+ href: string;
63
+ labels: VenueSpotlightLabels;
64
+ /** Composed meta line, e.g. "pub · Centru". Clause order is locale-dependent. */
65
+ meta?: string;
66
+ signature?: MenuVenueSignature;
67
+ /** CSS gradient for the signature swatch. */
68
+ signatureGradient?: string;
69
+ /** Passed through to the signature `<MenuItem>`. */
70
+ itemLabels: MenuItemLabels;
71
+ formatPrice: (amount: number) => string;
72
+ formatCount?: (value: number) => string;
73
+ /** 0–100 → display numeral for the dimension bars. */
74
+ formatScore: (value: number) => string;
75
+ /** Opens the reviews behind a quote. */
76
+ onCite?: (quote: CitedText) => void;
77
+ /** The action in the footer — pass a rendered link or button. */
78
+ action?: React.ReactNode;
79
+ linkComponent?: MenuLinkComponent;
80
+ className?: string;
81
+ };
82
+
83
+ /** Quotes are capped at two: a third pushes the grade row below the fold on a phone. */
84
+ const QUOTE_LIMIT = 2;
85
+
86
+ export function VenueSpotlight({
87
+ place,
88
+ href,
89
+ labels,
90
+ meta,
91
+ signature,
92
+ signatureGradient,
93
+ itemLabels,
94
+ formatPrice,
95
+ formatCount,
96
+ formatScore,
97
+ onCite,
98
+ action,
99
+ linkComponent: LinkComponent = DefaultLink,
100
+ className,
101
+ }: VenueSpotlightProps) {
102
+ const digest = place.reviews_digest;
103
+ const tags = digest?.tags ?? [];
104
+
105
+ return (
106
+ <div
107
+ className={cn(
108
+ "grid overflow-hidden rounded-[18px] border border-border bg-card lg:grid-cols-[404px_1fr]",
109
+ className
110
+ )}
111
+ >
112
+ <div className="relative min-h-[220px] bg-[repeating-linear-gradient(135deg,oklch(0.76_0.02_55)_0_10px,oklch(0.83_0.013_58)_10px_20px)] lg:min-h-[340px]">
113
+ {place.thumbnail_url && (
114
+ // eslint-disable-next-line @next/next/no-img-element
115
+ <img
116
+ src={place.thumbnail_url}
117
+ alt={place.name}
118
+ className="absolute inset-0 h-full w-full object-cover"
119
+ />
120
+ )}
121
+
122
+ {labels.photoCredit && (
123
+ <span className="absolute left-3.5 top-3.5 inline-flex h-7 items-center gap-1.5 rounded-full bg-black/60 px-2.5 text-[11px] font-bold text-white">
124
+ <Camera className="h-2.5 w-2.5" aria-hidden />
125
+ {labels.photoCredit}
126
+ </span>
127
+ )}
128
+
129
+ {labels.openUntil && (
130
+ <span className="absolute bottom-3.5 left-3.5 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-background px-3 text-[11.5px] font-bold text-[var(--free,currentColor)]">
131
+ <i className="h-[7px] w-[7px] rounded-full bg-current" aria-hidden />
132
+ {labels.openUntil}
133
+ </span>
134
+ )}
135
+ </div>
136
+
137
+ <div className="flex flex-col gap-3.5 p-4 lg:p-6">
138
+ {/*
139
+ `items-baseline` and a hard `shrink-0` on the rating: the venue name
140
+ is a long serif line that wraps, and with `items-start` the numeral
141
+ floated beside its second line instead of sitting on the first. The
142
+ name column also needs `min-w-0` AND `basis-0` — without the basis a
143
+ long unbroken word (a venue called "Tortelli Pasta Bar") refuses to
144
+ wrap and pushes the rating off the card.
145
+ */}
146
+ <div className="flex items-baseline justify-between gap-3">
147
+ <h3 className="min-w-0 flex-1 basis-0 font-serif text-xl font-semibold leading-tight tracking-[-0.02em] lg:text-[28px]">
148
+ <LinkComponent
149
+ href={href}
150
+ className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
151
+ >
152
+ {place.name}
153
+ </LinkComponent>
154
+ </h3>
155
+
156
+ {place.rating && (
157
+ <div className="flex shrink-0 items-baseline gap-1.5">
158
+ <b className="text-xl font-extrabold tabular-nums lg:text-2xl">
159
+ {place.rating.rating}
160
+ </b>
161
+ <Star className="h-3 w-3 self-center text-muted-foreground" aria-hidden />
162
+ </div>
163
+ )}
164
+ </div>
165
+
166
+ {(meta || labels.reviewCount) && (
167
+ <div className="-mt-2 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12.5px] text-muted-foreground">
168
+ {meta && <span>{meta}</span>}
169
+ {meta && labels.reviewCount && <span aria-hidden>·</span>}
170
+ {labels.reviewCount && <span>{labels.reviewCount}</span>}
171
+ </div>
172
+ )}
173
+
174
+ {signature && (
175
+ <VenueSignatureBlock
176
+ item={signature.item}
177
+ labels={itemLabels}
178
+ formatPrice={formatPrice}
179
+ formatCount={formatCount}
180
+ kicker={labels.signatureKicker}
181
+ markGradient={signatureGradient}
182
+ subtitle={signature.why}
183
+ href={signature.href}
184
+ linkComponent={LinkComponent}
185
+ />
186
+ )}
187
+
188
+ {digest?.highlights && (
189
+ <VenueQuotes
190
+ quotes={digest.highlights}
191
+ limit={QUOTE_LIMIT}
192
+ onCite={onCite}
193
+ citeLabel={labels.cite}
194
+ />
195
+ )}
196
+
197
+ {digest?.grade?.dimensions && <Separator />}
198
+
199
+ {digest?.grade?.dimensions && (
200
+ <VenueGradeDimensions
201
+ dimensions={digest.grade.dimensions}
202
+ labels={labels.dimensions}
203
+ formatScore={formatScore}
204
+ className="mt-1"
205
+ />
206
+ )}
207
+
208
+ {(labels.valueVerdict || tags.length > 0) && (
209
+ <div className="flex flex-wrap items-center gap-2">
210
+ {labels.valueVerdict && (
211
+ <span className="inline-flex h-[26px] items-center gap-1.5 rounded-full bg-accent-subtle px-2.5 text-[11.5px] font-bold text-accent">
212
+ {labels.valueVerdict}
213
+ </span>
214
+ )}
215
+ {tags.map((tag) => (
216
+ <span
217
+ key={tag.value}
218
+ className="inline-flex h-[26px] items-center rounded-full border border-border px-2.5 text-[11.5px] text-muted-foreground"
219
+ >
220
+ {labels.tags?.[tag.value] ?? tag.value}
221
+ </span>
222
+ ))}
223
+ </div>
224
+ )}
225
+
226
+ {/* No busyness strip here. Twelve unlabelled bars under a grade row
227
+ read as a second, unexplained chart — nobody could tell what they
228
+ measured without the sentence beside them, and the sentence does not
229
+ fit on this card. `<VenueBusynessStrip>` still exists and belongs on
230
+ the venue's own page, where it has room to be captioned. */}
231
+
232
+ {action && <div className="mt-auto flex justify-end pt-1">{action}</div>}
233
+ </div>
234
+ </div>
235
+ );
236
+ }
@@ -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
+ };