@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,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
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
  import { Coverflow, type CoverflowItem } from "./coverflow";
5
5
 
@@ -175,6 +175,33 @@ describe("Coverflow", () => {
175
175
  });
176
176
  });
177
177
 
178
+ describe("the drag", () => {
179
+ it("steps forward on a leftward drag and back on a rightward one", () => {
180
+ const onActiveIndexChange = vi.fn();
181
+ renderCoverflow({ activeIndex: 2, onActiveIndexChange });
182
+ const stage = screen.getByRole("button", { name: "Event 2" }).parentElement!
183
+ .parentElement as HTMLElement;
184
+
185
+ fireEvent.pointerDown(stage, { clientX: 200, pointerId: 1, button: 0 });
186
+ fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
187
+ fireEvent.pointerMove(stage, { clientX: 100, pointerId: 1 });
188
+ fireEvent.pointerUp(stage, { clientX: 100, pointerId: 1 });
189
+ expect(onActiveIndexChange).toHaveBeenLastCalledWith(3);
190
+
191
+ fireEvent.pointerDown(stage, { clientX: 100, pointerId: 1, button: 0 });
192
+ fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
193
+ fireEvent.pointerMove(stage, { clientX: 200, pointerId: 1 });
194
+ fireEvent.pointerUp(stage, { clientX: 200, pointerId: 1 });
195
+ expect(onActiveIndexChange).toHaveBeenLastCalledWith(1);
196
+ });
197
+
198
+ it("keeps the arrows for desktop only", () => {
199
+ renderCoverflow();
200
+ expect(screen.getByRole("button", { name: "Next" }).className).toContain("lg:flex");
201
+ expect(screen.getByRole("button", { name: "Next" }).className).toContain("hidden");
202
+ });
203
+ });
204
+
178
205
  describe("caption and position", () => {
179
206
  it("renders the position on its own, with no separator dot, when there is no caption", () => {
180
207
  renderCoverflow({ positionLabel: "1 of 7" });
@@ -1,16 +1,25 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useId, useRef, useState } from "react";
3
+ import { useId, useState } from "react";
4
4
  import { ChevronLeft, ChevronRight, Gift } from "lucide-react";
5
5
  import { cn } from "../lib/utils";
6
+ import { useDragSwipe } from "../hooks/use-drag-swipe";
6
7
  import { EventPosterFallback } from "./event-poster-fallback";
7
8
 
8
9
  /**
9
10
  * Coverflow — the busiest-events browser: a 3D perspective lane with a large
10
11
  * lead poster flanked by up to three shrinking, desaturating posters on each
11
- * side. The lane itself drifts a few pixels back and forth
12
- * (`prefers-reduced-motion` stops this outright; an `IntersectionObserver`
13
- * pauses it off-screen).
12
+ * side.
13
+ *
14
+ * NOTHING MOVES ON ITS OWN. The lane used to drift ±40px on a loop; it was
15
+ * removed at product request — a stage that sways is read as a carousel that
16
+ * is about to advance, and the reader waits for it instead of touching it.
17
+ * The stage is driven by the reader: a horizontal drag (touch, pen or mouse)
18
+ * steps the ring one card in the drag's direction and the lane follows the
19
+ * finger a little on the way, so the gesture has weight. The prev/next arrows
20
+ * stay for desktop and keyboard; below `lg` they are hidden so nothing sits
21
+ * over the lead card — the swipe is the control there, and every card is
22
+ * still a focusable, named button for keyboard and screen-reader users.
14
23
  *
15
24
  * IT IS A RING, NOT A LANE. A card's slot comes from `ringOffset` — the
16
25
  * SIGNED SHORTEST WAY ROUND the list, not the raw `index - activeIndex`. With
@@ -178,15 +187,12 @@ const COVERFLOW_LEAD_SHADOW_CLASS = "shadow-[0_24px_56px_oklch(0.28_0.03_45/0.32
178
187
  const COVERFLOW_PILL_CLASS =
179
188
  "inline-flex h-6 max-w-[85%] items-center gap-1 rounded-full px-2 text-[11px] font-semibold";
180
189
 
181
- const COVERFLOW_DRIFT_DURATION_SECONDS = 26;
182
- const COVERFLOW_DRIFT_PIXELS = 40;
183
-
184
190
  /** Below the lead's z-index the arrows (z-20) and edge fades (z-10) always
185
191
  * win, no matter how many cards are mid-transition and briefly promoted. */
186
192
  const COVERFLOW_LEAD_Z_INDEX = 6;
187
193
 
188
194
  const COVERFLOW_ARROW_CLASS =
189
- "absolute top-1/2 z-20 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-background/85 text-foreground shadow-[var(--shadow-overlay)] backdrop-blur-sm transition-colors duration-150 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none";
195
+ "absolute top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 lg:flex cursor-pointer items-center justify-center rounded-full bg-background/85 text-foreground shadow-[var(--shadow-overlay)] backdrop-blur-sm transition-colors duration-150 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none";
190
196
 
191
197
  function wrapIndex(index: number, length: number): number {
192
198
  return ((index % length) + length) % length;
@@ -347,7 +353,6 @@ export function Coverflow({
347
353
  }: CoverflowProps) {
348
354
  const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
349
355
  const scopeClass = `coverflow-${scopeId}`;
350
- const containerRef = useRef<HTMLDivElement>(null);
351
356
 
352
357
  const length = items.length;
353
358
  const active = length > 0 ? wrapIndex(activeIndex, length) : 0;
@@ -372,26 +377,21 @@ export function Coverflow({
372
377
  );
373
378
  }
374
379
 
375
- useEffect(() => {
376
- const node = containerRef.current;
377
- if (!node || typeof IntersectionObserver === "undefined") return undefined;
380
+ // No clamp at the ends: prev from 0 reports `length - 1` and next from
381
+ // `length - 1` reports 0, so the index the parent stores matches the ring.
382
+ const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
378
383
 
379
- const observer = new IntersectionObserver(
380
- ([entry]) => {
381
- node.dataset.offscreen = entry.isIntersecting ? "false" : "true";
382
- },
383
- { threshold: 0 }
384
- );
385
- observer.observe(node);
386
- return () => observer.disconnect();
387
- }, []);
384
+ // A drag to the left means "next" — the ring turns the way the finger went.
385
+ // The lane follows at a third of the drag distance while the finger is down,
386
+ // then snaps back as the step lands.
387
+ const { dragX, isDragging, handlers } = useDragSwipe({
388
+ onSwipe: (direction) => goTo(active + direction),
389
+ disabled: length < 2,
390
+ });
388
391
 
389
392
  if (length === 0) return null;
390
393
 
391
394
  const activeItem = items[active];
392
- // No clamp at the ends: prev from 0 reports `length - 1` and next from
393
- // `length - 1` reports 0, so the index the parent stores matches the ring.
394
- const goTo = (index: number) => onActiveIndexChange?.(wrapIndex(index, length));
395
395
 
396
396
  /** How far the lead itself moved, the short way round — 0 on a settled render. */
397
397
  const activeDelta = ringOffset(step.to - step.from, length);
@@ -446,26 +446,10 @@ export function Coverflow({
446
446
  --cf-tier-base: 0.5714;
447
447
  }
448
448
  }
449
- .${scopeClass} .coverflow-lane {
450
- animation: ${scopeClass}-drift ${COVERFLOW_DRIFT_DURATION_SECONDS}s ease-in-out infinite alternate;
451
- }
452
- .${scopeClass}[data-offscreen="true"] .coverflow-lane {
453
- animation-play-state: paused;
454
- }
455
- @keyframes ${scopeClass}-drift {
456
- from { transform: translateX(-${COVERFLOW_DRIFT_PIXELS}px); }
457
- to { transform: translateX(${COVERFLOW_DRIFT_PIXELS}px); }
458
- }
459
- @media (prefers-reduced-motion: reduce) {
460
- .${scopeClass} .coverflow-lane {
461
- animation: none;
462
- transform: none;
463
- }
464
- }
465
449
  `}</style>
466
450
 
467
451
  <div
468
- ref={containerRef}
452
+ {...handlers}
469
453
  // CLIPS HORIZONTALLY ONLY. The clip is what hides a card teleporting
470
454
  // round the back of the ring, and that only ever happens off the left
471
455
  // and right edges — but `overflow-hidden` clips both axes, so it was
@@ -477,7 +461,11 @@ export function Coverflow({
477
461
  // container), so `overflow-x-hidden overflow-y-visible` is not a thing
478
462
  // the cascade will give you. `clip` carries no such rule, so the pair
479
463
  // below means what it says.
480
- className="relative flex h-[300px] items-center justify-center overflow-x-clip overflow-y-visible [perspective:900px] sm:h-[380px] sm:[perspective:1400px]"
464
+ //
465
+ // `touch-action: pan-y` hands vertical gestures to the browser and
466
+ // horizontal ones to the drag handlers; `select-none` stops a mouse
467
+ // drag from selecting the captions underneath.
468
+ className="relative flex h-[300px] select-none items-center justify-center overflow-x-clip overflow-y-visible [touch-action:pan-y] [perspective:900px] sm:h-[380px] sm:[perspective:1400px]"
481
469
  >
482
470
  <div
483
471
  aria-hidden="true"
@@ -511,7 +499,13 @@ export function Coverflow({
511
499
  </>
512
500
  )}
513
501
 
514
- <div className="coverflow-lane relative h-full w-full [transform-style:preserve-3d] motion-reduce:[animation:none]">
502
+ <div
503
+ className={cn(
504
+ "coverflow-lane relative h-full w-full [transform-style:preserve-3d]",
505
+ !isDragging && "transition-transform duration-300 ease-out motion-reduce:transition-none"
506
+ )}
507
+ style={{ transform: `translateX(${dragX / 3}px)` }}
508
+ >
515
509
  {visibleCards.map(({ item, index, offset, suppressTransition }) => (
516
510
  <CoverflowCard
517
511
  key={item.id}