@bearmenu/ui 0.8.3 → 0.8.6

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 (194) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/chunk-637L7XQ3.js +85 -0
  3. package/dist/chunk-6XMYEZVR.js +26 -0
  4. package/dist/chunk-DNSXMHYX.js +8 -0
  5. package/dist/chunk-FDITZ2VM.js +16 -0
  6. package/dist/chunk-GPH6L4DX.js +55 -0
  7. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  8. package/dist/chunk-TT3WFS6S.js +379 -0
  9. package/dist/chunk-UXWAOISG.js +170 -0
  10. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  11. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  12. package/dist/components/alert.d.ts +2 -2
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/card.d.ts +2 -2
  16. package/dist/components/card.js +4 -127
  17. package/dist/components/category-deck.d.ts +50 -0
  18. package/dist/components/category-deck.js +128 -0
  19. package/dist/components/command.d.ts +15 -15
  20. package/dist/components/concept-card.d.ts +43 -0
  21. package/dist/components/concept-card.js +81 -0
  22. package/dist/components/coverflow.d.ts +123 -0
  23. package/dist/components/coverflow.js +300 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/day-rail-chip.d.ts +82 -0
  26. package/dist/components/day-rail-chip.js +105 -0
  27. package/dist/components/drift-rail.d.ts +28 -0
  28. package/dist/components/drift-rail.js +3 -0
  29. package/dist/components/event-card.d.ts +97 -0
  30. package/dist/components/event-card.js +161 -0
  31. package/dist/components/event-compact-row.d.ts +72 -0
  32. package/dist/components/event-compact-row.js +133 -0
  33. package/dist/components/event-day-header.d.ts +65 -0
  34. package/dist/components/event-day-header.js +95 -0
  35. package/dist/components/event-day-separator.d.ts +46 -0
  36. package/dist/components/event-day-separator.js +50 -0
  37. package/dist/components/event-detail-panel.d.ts +80 -0
  38. package/dist/components/event-detail-panel.js +251 -0
  39. package/dist/components/event-hub-hero.d.ts +34 -0
  40. package/dist/components/event-hub-hero.js +57 -0
  41. package/dist/components/event-index-row.d.ts +68 -0
  42. package/dist/components/event-index-row.js +84 -0
  43. package/dist/components/event-list-row-wide.d.ts +101 -0
  44. package/dist/components/event-list-row-wide.js +218 -0
  45. package/dist/components/event-list-row.d.ts +82 -0
  46. package/dist/components/event-list-row.js +173 -0
  47. package/dist/components/event-poster-fallback.d.ts +20 -0
  48. package/dist/components/event-poster-fallback.js +3 -0
  49. package/dist/components/event-types.d.ts +118 -0
  50. package/dist/components/event-types.js +2 -0
  51. package/dist/components/events-empty-state.d.ts +44 -0
  52. package/dist/components/events-empty-state.js +73 -0
  53. package/dist/components/facet-group.d.ts +93 -0
  54. package/dist/components/facet-group.js +222 -0
  55. package/dist/components/featured-rail-item.d.ts +42 -0
  56. package/dist/components/featured-rail-item.js +88 -0
  57. package/dist/components/hour-band.d.ts +68 -0
  58. package/dist/components/hour-band.js +53 -0
  59. package/dist/components/hour-group.d.ts +67 -0
  60. package/dist/components/hour-group.js +59 -0
  61. package/dist/components/icon.d.ts +18 -0
  62. package/dist/components/icon.js +31 -0
  63. package/dist/components/item.d.ts +2 -2
  64. package/dist/components/motion-icon-controls.d.ts +26 -0
  65. package/dist/components/motion-icon-controls.js +4 -0
  66. package/dist/components/motion-icon-set.d.ts +27 -0
  67. package/dist/components/motion-icon-set.js +139 -0
  68. package/dist/components/motion-icon.d.ts +44 -0
  69. package/dist/components/motion-icon.js +3 -0
  70. package/dist/components/multi-select-combobox.js +1 -1
  71. package/dist/components/place-about-tab.js +3 -3
  72. package/dist/components/place-details-mobile.js +3 -3
  73. package/dist/components/poster-stack.d.ts +30 -0
  74. package/dist/components/poster-stack.js +104 -0
  75. package/dist/components/poster-tape.d.ts +36 -0
  76. package/dist/components/poster-tape.js +64 -0
  77. package/dist/components/poster-wall.d.ts +25 -0
  78. package/dist/components/poster-wall.js +183 -0
  79. package/dist/components/quiet-stretch.d.ts +43 -0
  80. package/dist/components/quiet-stretch.js +54 -0
  81. package/dist/components/section-header.d.ts +53 -0
  82. package/dist/components/section-header.js +63 -0
  83. package/dist/components/segmented-nav.d.ts +62 -0
  84. package/dist/components/segmented-nav.js +54 -0
  85. package/dist/components/skeleton-card.js +1 -1
  86. package/dist/components/skeleton.d.ts +17 -0
  87. package/dist/components/skeleton.js +1 -1
  88. package/dist/components/spinner.d.ts +2 -2
  89. package/dist/components/tag-group.js +3 -26
  90. package/dist/components/tag.d.ts +2 -2
  91. package/dist/components/tag.js +1 -1
  92. package/dist/components/text.d.ts +3 -3
  93. package/dist/components/timeline-band.d.ts +46 -0
  94. package/dist/components/timeline-band.js +5 -0
  95. package/dist/components/venue-events-block.d.ts +143 -0
  96. package/dist/components/venue-events-block.js +222 -0
  97. package/dist/components/weekend-panel.d.ts +44 -0
  98. package/dist/components/weekend-panel.js +231 -0
  99. package/dist/lib/icon-registry.d.ts +58 -0
  100. package/dist/lib/icon-registry.js +39 -0
  101. package/package.json +2 -1
  102. package/src/components/accordion.stories.tsx +1 -1
  103. package/src/components/card.tsx +44 -1
  104. package/src/components/category-deck.stories.tsx +96 -0
  105. package/src/components/category-deck.tsx +227 -0
  106. package/src/components/combobox.stories.tsx +1 -1
  107. package/src/components/concept-card.stories.tsx +80 -0
  108. package/src/components/concept-card.tsx +143 -0
  109. package/src/components/coverflow.stories.tsx +240 -0
  110. package/src/components/coverflow.test.tsx +193 -0
  111. package/src/components/coverflow.tsx +550 -0
  112. package/src/components/currency-input.stories.tsx +3 -3
  113. package/src/components/day-rail-chip.stories.tsx +145 -0
  114. package/src/components/day-rail-chip.tsx +201 -0
  115. package/src/components/drift-rail.stories.tsx +92 -0
  116. package/src/components/drift-rail.tsx +86 -0
  117. package/src/components/empty-state.stories.tsx +3 -3
  118. package/src/components/event-card.stories.tsx +248 -0
  119. package/src/components/event-card.test.tsx +177 -0
  120. package/src/components/event-card.tsx +321 -0
  121. package/src/components/event-compact-row.stories.tsx +221 -0
  122. package/src/components/event-compact-row.tsx +268 -0
  123. package/src/components/event-day-header.stories.tsx +124 -0
  124. package/src/components/event-day-header.tsx +180 -0
  125. package/src/components/event-day-separator.stories.tsx +70 -0
  126. package/src/components/event-day-separator.tsx +95 -0
  127. package/src/components/event-detail-panel.stories.tsx +191 -0
  128. package/src/components/event-detail-panel.tsx +360 -0
  129. package/src/components/event-hub-hero.stories.tsx +66 -0
  130. package/src/components/event-hub-hero.tsx +110 -0
  131. package/src/components/event-index-row.stories.tsx +116 -0
  132. package/src/components/event-index-row.tsx +181 -0
  133. package/src/components/event-list-row-wide.stories.tsx +204 -0
  134. package/src/components/event-list-row-wide.tsx +386 -0
  135. package/src/components/event-list-row.stories.tsx +159 -0
  136. package/src/components/event-list-row.tsx +335 -0
  137. package/src/components/event-poster-fallback.stories.tsx +52 -0
  138. package/src/components/event-poster-fallback.tsx +31 -0
  139. package/src/components/event-types.ts +133 -0
  140. package/src/components/events-empty-state.stories.tsx +59 -0
  141. package/src/components/events-empty-state.tsx +131 -0
  142. package/src/components/facet-group.stories.tsx +247 -0
  143. package/src/components/facet-group.test.tsx +225 -0
  144. package/src/components/facet-group.tsx +352 -0
  145. package/src/components/featured-rail-item.stories.tsx +86 -0
  146. package/src/components/featured-rail-item.tsx +153 -0
  147. package/src/components/form.stories.tsx +1 -1
  148. package/src/components/hour-band.stories.tsx +159 -0
  149. package/src/components/hour-band.tsx +126 -0
  150. package/src/components/hour-group.stories.tsx +100 -0
  151. package/src/components/hour-group.tsx +124 -0
  152. package/src/components/icon.tsx +57 -0
  153. package/src/components/input-otp.stories.tsx +1 -1
  154. package/src/components/markdown-renderer.stories.tsx +1 -1
  155. package/src/components/motion-icon-controls.test.tsx +78 -0
  156. package/src/components/motion-icon-controls.tsx +552 -0
  157. package/src/components/motion-icon-set.stories.tsx +185 -0
  158. package/src/components/motion-icon-set.tsx +219 -0
  159. package/src/components/motion-icon.test.tsx +159 -0
  160. package/src/components/motion-icon.tsx +163 -0
  161. package/src/components/multi-select-combobox.stories.tsx +1 -1
  162. package/src/components/phone-frame.stories.tsx +1 -1
  163. package/src/components/poster-stack.stories.tsx +68 -0
  164. package/src/components/poster-stack.tsx +155 -0
  165. package/src/components/poster-tape.stories.tsx +70 -0
  166. package/src/components/poster-tape.tsx +122 -0
  167. package/src/components/poster-wall.stories.tsx +95 -0
  168. package/src/components/poster-wall.tsx +220 -0
  169. package/src/components/quiet-stretch.stories.tsx +62 -0
  170. package/src/components/quiet-stretch.tsx +115 -0
  171. package/src/components/rating.stories.tsx +3 -3
  172. package/src/components/searchable-select.stories.tsx +1 -1
  173. package/src/components/section-header.stories.tsx +70 -0
  174. package/src/components/section-header.tsx +134 -0
  175. package/src/components/segmented-nav.stories.tsx +118 -0
  176. package/src/components/segmented-nav.tsx +113 -0
  177. package/src/components/selection-bar.stories.tsx +1 -1
  178. package/src/components/skeleton-card.stories.tsx +3 -3
  179. package/src/components/skeleton.tsx +18 -0
  180. package/src/components/sliding-panels.stories.tsx +1 -1
  181. package/src/components/stepper.stories.tsx +1 -1
  182. package/src/components/sticky-container.stories.tsx +3 -3
  183. package/src/components/tag.tsx +11 -1
  184. package/src/components/timeline-band.stories.tsx +206 -0
  185. package/src/components/timeline-band.tsx +207 -0
  186. package/src/components/toggle-group.stories.tsx +1 -1
  187. package/src/components/tooltip.stories.tsx +2 -2
  188. package/src/components/venue-events-block.stories.tsx +216 -0
  189. package/src/components/venue-events-block.tsx +408 -0
  190. package/src/components/weekend-panel.stories.tsx +92 -0
  191. package/src/components/weekend-panel.tsx +322 -0
  192. package/src/globals.css +19 -0
  193. package/src/lib/icon-registry.ts +139 -0
  194. package/src/test/setup.ts +5 -1
@@ -0,0 +1,386 @@
1
+ "use client";
2
+
3
+ import { memo, useState } from "react";
4
+ import type { ReactNode } from "react";
5
+ import { MapPin, Radio } from "lucide-react";
6
+ import { cn } from "../lib/utils";
7
+ import { buttonVariants } from "./button";
8
+ import { EventPosterFallback } from "./event-poster-fallback";
9
+ import { Separator } from "./separator";
10
+ import { Skeleton } from "./skeleton";
11
+ import { Tag } from "./tag";
12
+ import {
13
+ resolveEventEntryFee,
14
+ type EventDateBadgeFormatters,
15
+ type EventListRowLabels,
16
+ type EventPreview,
17
+ } from "./event-types";
18
+
19
+ /**
20
+ * EventListRowWide — the desktop counterpart to `<EventListRow>`.
21
+ *
22
+ * Denser than `<EventCard>`, richer than the mobile `<EventListRow>`: desktop
23
+ * has the width to spare for a description and a dedicated action column, so
24
+ * this adds both rather than just scaling up the mobile row's type sizes.
25
+ *
26
+ * NESTED-LINK PATTERN, not the mobile row's single anchor: the row root is a
27
+ * plain, non-interactive container. The event TITLE is the primary link and
28
+ * gets a stretched overlay (`after:absolute after:inset-0`) so the whole row
29
+ * stays one big click target; "See the menu" (when `menuHref` is supplied) is
30
+ * a second, real link, `relative` with a higher `z-index` than the overlay so
31
+ * it wins the click in its own area — nesting it inside the title's anchor
32
+ * would be invalid HTML, which is why this uses two siblings instead. The
33
+ * title's `onClick` still fires for the caller (e.g. to `preventDefault()`
34
+ * into a detail sheet); modifier/middle-clicks still fall through to the
35
+ * anchor since nothing calls `preventDefault()` here.
36
+ *
37
+ * `event-list-row.tsx` (the mobile row) keeps the single-anchor shape and has
38
+ * no action column at all — the two were reconciled in favour of a real
39
+ * action here and none there, since a nested inert CTA inside a single-anchor
40
+ * row is a control that only looks like one. See that file's header.
41
+ *
42
+ * WEIGHT: the family tops out at 600, and after that pass the `Button` in this
43
+ * row's action column is the only 700-class element left in it — correct,
44
+ * since it is the only real action. Density sets weight, width sets size:
45
+ * both row titles are 600, and 15px vs 16.5px is what separates them.
46
+ *
47
+ * Same contract as the rest of the family: DS-owned view model, injected
48
+ * labels and formatters, plain `<img>`, anchor whenever `href` is given.
49
+ */
50
+
51
+ export type EventListRowWideLinkProps = {
52
+ href: string;
53
+ className?: string;
54
+ children: React.ReactNode;
55
+ onClick?: (mouseEvent: React.MouseEvent) => void;
56
+ };
57
+
58
+ function DefaultLink({ href, ...props }: EventListRowWideLinkProps) {
59
+ return <a href={href} {...props} />;
60
+ }
61
+
62
+ /**
63
+ * Extends the mobile row's labels with the strings only the wide row needs —
64
+ * the action column included, since this is the only row in the family that
65
+ * has one.
66
+ */
67
+ export type EventListRowWideLabels = EventListRowLabels & {
68
+ /** Primary row action, e.g. "Reserve". */
69
+ reserve: string;
70
+ /** Row action for a sold-out event. */
71
+ notifyMe: string;
72
+ /** Secondary link in the action column, e.g. "See the menu". */
73
+ seeMenu: string;
74
+ };
75
+
76
+ export type EventListRowWideProps = {
77
+ event: EventPreview;
78
+ labels: EventListRowWideLabels;
79
+ formatters: EventDateBadgeFormatters;
80
+ /** Currently running — tints the date badge and shows the on-air chip. */
81
+ isLive?: boolean;
82
+ /** Sold out or otherwise closed — dims the row and swaps the CTA. */
83
+ isSoldOut?: boolean;
84
+ /**
85
+ * Caller-resolved tag display labels, rendered as chips after the venue.
86
+ * The DS holds no taxonomy display names, so resolving AND capping them is
87
+ * the caller's job — three is what the desktop meta line carries. Same
88
+ * contract as `EventListRowProps.tags`; this replaced a `tagsText` prose
89
+ * string, which was the only place in the family tags were not chips.
90
+ */
91
+ tags?: string[];
92
+ href?: string;
93
+ /** Destination for the "See the menu" affordance. Omit to not render it at all. */
94
+ menuHref?: string;
95
+ linkComponent?: React.ComponentType<EventListRowWideLinkProps>;
96
+ onClick?: (event: EventPreview, mouseEvent: React.MouseEvent) => void;
97
+ className?: string;
98
+ priority?: boolean;
99
+ /** Rendered instead of the thumbnail when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
100
+ fallback?: ReactNode;
101
+ /**
102
+ * Drops this row's top rule. Pass it on the FIRST row under a day heading:
103
+ * the heading already ends the block above it, so the row's own rule lands
104
+ * immediately beneath it and draws two dividers a few pixels apart. The gap
105
+ * is the separator there — whitespace does the same job without the second
106
+ * line. Same prop and same reason as `EventIndexRow`.
107
+ */
108
+ hideRule?: boolean;
109
+ };
110
+
111
+ export const EventListRowWide = memo(function EventListRowWide({
112
+ event,
113
+ labels,
114
+ formatters,
115
+ isLive,
116
+ isSoldOut,
117
+ tags,
118
+ href,
119
+ menuHref,
120
+ linkComponent: LinkComponent = DefaultLink,
121
+ onClick,
122
+ className,
123
+ priority,
124
+ fallback = <EventPosterFallback />,
125
+ hideRule,
126
+ }: EventListRowWideProps) {
127
+ const [imgError, setImgError] = useState(false);
128
+
129
+ const fee = resolveEventEntryFee(event);
130
+ const showPoster = !!event.poster_url && !imgError;
131
+ const venue = event.place_name ?? event.location;
132
+ const description = event.description ?? event.short_description;
133
+
134
+ /**
135
+ * `undefined` when the price is UNKNOWN — the row renders nothing rather
136
+ * than "Price TBA".
137
+ *
138
+ * An unknown fee is the commonest case in this feed (the ingest reads
139
+ * Instagram captions, which mostly do not state a door price), so the
140
+ * placeholder was the loudest, boldest element on most rows, repeated down
141
+ * the page, saying only that we do not know something. That is worse than
142
+ * silence: it spends the row's strongest typographic slot on an absence, and
143
+ * a reader scanning for prices learns to skip the one place a real price
144
+ * would appear. Silence reads as "not stated"; "Price TBA" reads as a
145
+ * promise that a number is coming.
146
+ *
147
+ * `EventListRow` reached the same conclusion, and `labels.priceUnknown` is
148
+ * gone from the contract entirely — no component in the package renders a
149
+ * placeholder for a fee any more.
150
+ */
151
+ const priceText =
152
+ fee.kind === "free" ? labels.free : fee.kind === "paid" ? formatters.price(fee.amountRon) : undefined;
153
+
154
+ const body = (
155
+ <>
156
+ {!hideRule && <Separator className="absolute inset-x-0 top-0" />}
157
+
158
+ {/* Fill means LIVE, not decoration — same rule as the phone row, and the
159
+ one thing about the badge that must not diverge between widths. */}
160
+ <div
161
+ className={cn("w-[52px] shrink-0 rounded-[10px] py-[7px] text-center", isLive && "bg-success/10")}
162
+ >
163
+ <div
164
+ className={cn(
165
+ "text-[19px] font-semibold leading-none tracking-[-0.02em] tabular-nums",
166
+ isLive ? "text-success" : "text-foreground"
167
+ )}
168
+ >
169
+ {formatters.dayNumber(event.starts_at)}
170
+ </div>
171
+ <div
172
+ className={cn(
173
+ "mt-[3px] text-[10.5px] font-semibold tracking-[0.06em]",
174
+ isLive ? "text-success" : "text-muted-foreground"
175
+ )}
176
+ >
177
+ {formatters.monthShort(event.starts_at)}
178
+ </div>
179
+ </div>
180
+
181
+ <div className="aspect-square w-[104px] shrink-0 overflow-hidden rounded-[10px] bg-muted">
182
+ {showPoster ? (
183
+ // eslint-disable-next-line @next/next/no-img-element
184
+ <img
185
+ src={event.poster_url}
186
+ alt=""
187
+ loading={priority ? "eager" : "lazy"}
188
+ decoding="async"
189
+ className="h-full w-full object-cover"
190
+ onError={() => setImgError(true)}
191
+ />
192
+ ) : (
193
+ fallback
194
+ )}
195
+ </div>
196
+
197
+ <div className="min-w-0 flex-1">
198
+ <div className="flex flex-wrap items-center gap-2">
199
+ {href ? (
200
+ <LinkComponent
201
+ href={href}
202
+ className="rounded-sm text-[16.5px] font-semibold tracking-[-0.01em] after:absolute after:inset-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
203
+ onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
204
+ >
205
+ {event.title}
206
+ </LinkComponent>
207
+ ) : onClick ? (
208
+ <button
209
+ type="button"
210
+ className="relative cursor-pointer rounded-sm bg-transparent p-0 text-left text-[16.5px] font-semibold tracking-[-0.01em] after:absolute after:inset-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
211
+ onClick={(mouseEvent) => onClick(event, mouseEvent)}
212
+ >
213
+ {event.title}
214
+ </button>
215
+ ) : (
216
+ <span className="text-[16.5px] font-semibold tracking-[-0.01em]">{event.title}</span>
217
+ )}
218
+
219
+ {isLive && (
220
+ <Tag size="sm" variant="success" className="shrink-0 whitespace-nowrap">
221
+ <Radio className="h-2.5 w-2.5" aria-hidden />
222
+ {labels.liveNow}
223
+ </Tag>
224
+ )}
225
+
226
+ {isSoldOut && (
227
+ <Tag size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
228
+ {labels.soldOut}
229
+ </Tag>
230
+ )}
231
+ </div>
232
+
233
+ {/* Time first, matching the phone row and the compact row: in a
234
+ time-sorted feed the clock is the axis, so it belongs at a fixed
235
+ left edge. Emphasis moved from weight to the ink channel — 400 in
236
+ `text-foreground` against the muted rest. The `MapPin` marks the
237
+ venue field and replaces the dot that used to; both widths carry
238
+ it, and it is the same glyph the filter rail uses for VENUE. */}
239
+ <div className="mt-[5px] flex items-center gap-2 text-[12.5px] text-muted-foreground">
240
+ <span className="shrink-0 tabular-nums text-foreground">
241
+ {formatters.time(event.starts_at)}
242
+ </span>
243
+ <span className="flex min-w-0 items-center gap-1">
244
+ <MapPin className="h-3 w-3 shrink-0" aria-hidden />
245
+ <span className="truncate">{venue}</span>
246
+ </span>
247
+ {tags?.map((tag) => (
248
+ <Tag key={tag} size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
249
+ {tag}
250
+ </Tag>
251
+ ))}
252
+ </div>
253
+
254
+ {description && (
255
+ <p className="mt-2 line-clamp-2 max-w-[620px] text-[13.5px] leading-[1.55] text-muted-foreground">
256
+ {description}
257
+ </p>
258
+ )}
259
+ </div>
260
+
261
+ <div className="flex w-[150px] shrink-0 flex-col items-end gap-[9px]">
262
+ {priceText && (
263
+ <span
264
+ className={cn("text-[15px] font-semibold tabular-nums", fee.kind === "free" && "text-success")}
265
+ >
266
+ {priceText}
267
+ </span>
268
+ )}
269
+
270
+ {/* The CTA carries the DS `Button`'s own styling via `buttonVariants`,
271
+ not a hand-rolled pill. It stays a `<span>` rather than becoming a
272
+ real `<Button>`: the whole row is already a link or a button, and a
273
+ nested interactive element inside one is invalid HTML and steals
274
+ the row's click target. So it takes the button's LOOK from the
275
+ single source that defines it, while remaining inert — which is
276
+ what it always was, just previously drawn by hand from `CHIP_BASE`
277
+ plus `bg-primary` and free to drift from every real button on the
278
+ page. */}
279
+ <span
280
+ className={buttonVariants({
281
+ variant: isSoldOut ? "outline" : "default",
282
+ size: "sm",
283
+ })}
284
+ >
285
+ {isSoldOut ? labels.notifyMe : labels.reserve}
286
+ </span>
287
+
288
+ {menuHref && (
289
+ <LinkComponent
290
+ href={menuHref}
291
+ className="relative z-10 cursor-pointer rounded-sm text-xs font-semibold text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
292
+ >
293
+ {labels.seeMenu}
294
+ </LinkComponent>
295
+ )}
296
+ </div>
297
+ </>
298
+ );
299
+
300
+ const rootClasses = cn(
301
+ "relative flex w-full items-start gap-[18px] py-[18px] text-left",
302
+ // The row is a click target — its title carries an `after:inset-0` overlay
303
+ // that makes the WHOLE row the link — and it had no hover response at all,
304
+ // so a pointer user got no confirmation the thing under the cursor was
305
+ // interactive until they had already clicked it.
306
+ //
307
+ // The tint bleeds past the text edges (`-mx-3 px-3`) so the fill reads as
308
+ // one row rather than a box drawn around the copy, and the row keeps its
309
+ // full-bleed separator. Sold-out rows are excluded: they are dimmed and
310
+ // inert, and lighting one up on hover promises an action that is not there.
311
+ //
312
+ // TEXT-KEYED, not surface-keyed — the same reasoning as `EventCompactRow`.
313
+ // This shipped as `bg-muted/50` and the numbers are why it did not stay:
314
+ // composited, that lands 0.0155 L from `--background` in light mode, which
315
+ // is the case this row actually renders in, and 0.0075 from `--card` in
316
+ // dark, which is invisible. `--foreground` at 5% holds 0.038-0.041 L across
317
+ // all four surface/mode combinations, so the wash is the same strength
318
+ // wherever the row is dropped and cannot be silently cancelled by the
319
+ // surface beneath it.
320
+ !isSoldOut &&
321
+ "-mx-3 rounded-xl px-3 transition-colors duration-150 hover:bg-foreground/5 motion-reduce:transition-none",
322
+ isSoldOut && "opacity-60",
323
+ className
324
+ );
325
+
326
+ return <div className={rootClasses}>{body}</div>;
327
+ });
328
+
329
+ export type EventListRowWideSkeletonProps = {
330
+ /** Matches `EventListRowWideProps` shape: show the description block by default, drop it for `NoDescription`-style rows. */
331
+ showDescription?: boolean;
332
+ /** Matches `EventListRowWideProps["hideRule"]` — drop the top rule on the first row of a day group. */
333
+ hideRule?: boolean;
334
+ className?: string;
335
+ };
336
+
337
+ /** Mirrors `<EventListRowWide>`'s geometry exactly so nothing shifts when data lands. */
338
+ export function EventListRowWideSkeleton({
339
+ showDescription = true,
340
+ hideRule,
341
+ className,
342
+ }: EventListRowWideSkeletonProps) {
343
+ return (
344
+ <div
345
+ aria-hidden="true"
346
+ className={cn(
347
+ "relative flex w-full items-start gap-[18px] py-[18px] text-left",
348
+ className
349
+ )}
350
+ >
351
+ {!hideRule && <Separator className="absolute inset-x-0 top-0" />}
352
+
353
+ <div className="w-[52px] shrink-0 rounded-[10px] py-[7px] text-center">
354
+ <Skeleton className="mx-auto h-5 w-6" />
355
+ <Skeleton className="mx-auto mt-[3px] h-[10px] w-7" />
356
+ </div>
357
+
358
+ <Skeleton className="aspect-square w-[104px] shrink-0 rounded-[10px]" />
359
+
360
+ <div className="min-w-0 flex-1">
361
+ <Skeleton className="h-[16.5px] w-3/5" />
362
+
363
+ <div className="mt-[5px] flex items-center gap-2">
364
+ <Skeleton className="h-3 w-10" />
365
+ <Skeleton className="h-3 w-3 shrink-0 rounded-full" />
366
+ <Skeleton className="h-3 w-24" />
367
+ <Skeleton className="h-[22px] w-16 rounded-full" />
368
+ <Skeleton className="h-[22px] w-12 rounded-full" />
369
+ </div>
370
+
371
+ {showDescription && (
372
+ <div className="mt-2 max-w-[620px] space-y-1.5">
373
+ <Skeleton className="h-[13.5px] w-full" />
374
+ <Skeleton className="h-[13.5px] w-2/3" />
375
+ </div>
376
+ )}
377
+ </div>
378
+
379
+ <div className="flex w-[150px] shrink-0 flex-col items-end gap-[9px]">
380
+ <Skeleton className="h-[15px] w-16" />
381
+ <Skeleton className="h-6 w-20 rounded-full" />
382
+ <Skeleton className="h-3 w-20" />
383
+ </div>
384
+ </div>
385
+ );
386
+ }
@@ -0,0 +1,159 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { EventListRow, EventListRowSkeleton } from "./event-list-row";
3
+ import type { EventDateBadgeFormatters, EventListRowLabels, EventPreview } from "./event-types";
4
+ import { Separator } from "./separator";
5
+
6
+ const labels: EventListRowLabels = {
7
+ free: "Free",
8
+ liveNow: "On now",
9
+ soldOut: "Sold out",
10
+ };
11
+
12
+ const formatters: EventDateBadgeFormatters = {
13
+ time: (iso) => new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
14
+ day: (iso) =>
15
+ new Date(iso).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
16
+ price: (amount) => `${amount} RON`,
17
+ dayNumber: (iso) => String(new Date(iso).getDate()).padStart(2, "0"),
18
+ monthShort: (iso) =>
19
+ new Date(iso).toLocaleDateString("en-GB", { month: "short" }).toUpperCase(),
20
+ };
21
+
22
+ const event = (
23
+ id: string,
24
+ title: string,
25
+ place: string,
26
+ startsAt: string,
27
+ fee?: number
28
+ ): EventPreview => ({ id, title, location: place, place_name: place, starts_at: startsAt, entry_fee: fee });
29
+
30
+ const meta: Meta<typeof EventListRow> = {
31
+ title: "Events/EventListRow",
32
+ component: EventListRow,
33
+ tags: ["autodocs"],
34
+ args: { labels, formatters },
35
+ decorators: [
36
+ (Story) => (
37
+ <div className="w-[358px]">
38
+ <Story />
39
+ </div>
40
+ ),
41
+ ],
42
+ parameters: {
43
+ docs: {
44
+ description: {
45
+ component:
46
+ "The dense row for the list view — roughly eight events per screen against the poster card's two. Note it carries a trailing CTA, which the poster card deliberately does not: cards elsewhere mean one thing, 'open me'. That divergence is intentional per the source design; reconcile both surfaces if it ever changes.",
47
+ },
48
+ },
49
+ },
50
+ };
51
+
52
+ export default meta;
53
+ type Story = StoryObj<typeof EventListRow>;
54
+
55
+ export const Default: Story = {
56
+ args: { event: event("1", "Psihotrop Live", "Atelier Café", "2026-09-05T20:00:00", 50) },
57
+ };
58
+
59
+ export const FreeEntry: Story = {
60
+ args: { event: event("2", "Vinyl Happy Hour", "Zorki Photo Cafe", "2026-09-04T18:00:00", 0) },
61
+ };
62
+
63
+ /** No published fee is "TBC", never "Free". */
64
+ export const UnknownPrice: Story = {
65
+ args: { event: event("3", "Quiz Night", "Flying Circus", "2026-09-03T19:00:00") },
66
+ };
67
+
68
+ /** Live tints the date badge and adds the on-air chip. */
69
+ export const LiveNow: Story = {
70
+ args: {
71
+ event: event("4", "Charity Concert & Afterparty", "Flying Circus", "2026-09-03T21:00:00", 30),
72
+ isLive: true,
73
+ },
74
+ };
75
+
76
+ /** Sold out dims the row and swaps the CTA to "Notify me". */
77
+ export const SoldOut: Story = {
78
+ args: {
79
+ event: event("5", "Friday Night Techno", "Form Space", "2026-09-04T22:00:00", 50),
80
+ isSoldOut: true,
81
+ },
82
+ };
83
+
84
+ /**
85
+ * A long title wraps in full and the row grows to fit it. It used to clamp at
86
+ * two lines; that clamp was the reported defect, so this story now proves the
87
+ * opposite.
88
+ */
89
+ export const LongTitle: Story = {
90
+ args: {
91
+ event: event(
92
+ "6",
93
+ "Stand-up Comedy with Dobrotă, Cîrje & Florin — The Late Show, second edition",
94
+ "Klausen Pubhouse",
95
+ "2026-09-04T19:30:00",
96
+ 45
97
+ ),
98
+ },
99
+ };
100
+
101
+ /** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
102
+ export const Loading: Story = {
103
+ render: () => <EventListRowSkeleton />,
104
+ };
105
+
106
+ /** A stack of loading rows, plus one loaded row for comparison. */
107
+ export const LoadingVsLoaded: Story = {
108
+ render: () => (
109
+ <div>
110
+ <EventListRowSkeleton />
111
+ <EventListRowSkeleton />
112
+ <EventListRow
113
+ event={event("1", "Psihotrop Live", "Atelier Café", "2026-09-05T20:00:00", 50)}
114
+ labels={labels}
115
+ formatters={formatters}
116
+ />
117
+ </div>
118
+ ),
119
+ };
120
+
121
+ /** A day's worth of rows, as the list view stacks them. */
122
+ export const ADay: Story = {
123
+ render: () => (
124
+ <div>
125
+ <div className="flex items-center gap-2.5 py-4">
126
+ <span className="text-sm font-extrabold">Today · Thursday 3</span>
127
+ <span className="text-[11.5px] text-muted-foreground">15 events</span>
128
+ <Separator className="flex-1" />
129
+ </div>
130
+ <EventListRow
131
+ event={event("a", "Psihotrop Live", "Atelier Café", "2026-09-03T20:00:00", 50)}
132
+ labels={labels}
133
+ formatters={formatters}
134
+ isLive
135
+ />
136
+ <EventListRow
137
+ event={event("b", "Quiz Night", "Flying Circus", "2026-09-03T20:00:00")}
138
+ labels={labels}
139
+ formatters={formatters}
140
+ />
141
+ <EventListRow
142
+ event={event("c", "Beer Battle", "Irish Music Pub", "2026-09-03T20:00:00", 0)}
143
+ labels={labels}
144
+ formatters={formatters}
145
+ />
146
+ <EventListRow
147
+ event={event("d", "COJO", "MATTER", "2026-09-03T20:30:00", 40)}
148
+ labels={labels}
149
+ formatters={formatters}
150
+ />
151
+ <EventListRow
152
+ event={event("e", "Schoolotheque Afterparty", "Flying Circus", "2026-09-03T22:00:00", 25)}
153
+ labels={labels}
154
+ formatters={formatters}
155
+ isSoldOut
156
+ />
157
+ </div>
158
+ ),
159
+ };