@bearmenu/ui 0.8.4 → 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 (157) hide show
  1. package/dist/chunk-637L7XQ3.js +85 -0
  2. package/dist/chunk-6XMYEZVR.js +26 -0
  3. package/dist/chunk-DNSXMHYX.js +8 -0
  4. package/dist/chunk-FDITZ2VM.js +16 -0
  5. package/dist/chunk-GPH6L4DX.js +55 -0
  6. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  7. package/dist/chunk-TT3WFS6S.js +379 -0
  8. package/dist/chunk-UXWAOISG.js +170 -0
  9. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  10. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  11. package/dist/components/alert.d.ts +2 -2
  12. package/dist/components/badge.d.ts +1 -1
  13. package/dist/components/button.d.ts +1 -1
  14. package/dist/components/card.d.ts +2 -2
  15. package/dist/components/card.js +4 -127
  16. package/dist/components/category-deck.d.ts +50 -0
  17. package/dist/components/category-deck.js +128 -0
  18. package/dist/components/command.d.ts +15 -15
  19. package/dist/components/concept-card.d.ts +43 -0
  20. package/dist/components/concept-card.js +81 -0
  21. package/dist/components/coverflow.d.ts +123 -0
  22. package/dist/components/coverflow.js +300 -0
  23. package/dist/components/date-input.d.ts +1 -1
  24. package/dist/components/day-rail-chip.d.ts +82 -0
  25. package/dist/components/day-rail-chip.js +105 -0
  26. package/dist/components/drift-rail.d.ts +28 -0
  27. package/dist/components/drift-rail.js +3 -0
  28. package/dist/components/event-card.d.ts +97 -0
  29. package/dist/components/event-card.js +161 -0
  30. package/dist/components/event-compact-row.d.ts +72 -0
  31. package/dist/components/event-compact-row.js +133 -0
  32. package/dist/components/event-day-header.d.ts +65 -0
  33. package/dist/components/event-day-header.js +95 -0
  34. package/dist/components/event-day-separator.d.ts +46 -0
  35. package/dist/components/event-day-separator.js +50 -0
  36. package/dist/components/event-detail-panel.d.ts +80 -0
  37. package/dist/components/event-detail-panel.js +251 -0
  38. package/dist/components/event-hub-hero.d.ts +34 -0
  39. package/dist/components/event-hub-hero.js +57 -0
  40. package/dist/components/event-index-row.d.ts +68 -0
  41. package/dist/components/event-index-row.js +84 -0
  42. package/dist/components/event-list-row-wide.d.ts +101 -0
  43. package/dist/components/event-list-row-wide.js +218 -0
  44. package/dist/components/event-list-row.d.ts +82 -0
  45. package/dist/components/event-list-row.js +173 -0
  46. package/dist/components/event-poster-fallback.d.ts +20 -0
  47. package/dist/components/event-poster-fallback.js +3 -0
  48. package/dist/components/event-types.d.ts +118 -0
  49. package/dist/components/event-types.js +2 -0
  50. package/dist/components/events-empty-state.d.ts +44 -0
  51. package/dist/components/events-empty-state.js +73 -0
  52. package/dist/components/facet-group.d.ts +93 -0
  53. package/dist/components/facet-group.js +222 -0
  54. package/dist/components/featured-rail-item.d.ts +42 -0
  55. package/dist/components/featured-rail-item.js +88 -0
  56. package/dist/components/hour-band.d.ts +68 -0
  57. package/dist/components/hour-band.js +53 -0
  58. package/dist/components/hour-group.d.ts +67 -0
  59. package/dist/components/hour-group.js +59 -0
  60. package/dist/components/item.d.ts +2 -2
  61. package/dist/components/motion-icon-controls.js +3 -379
  62. package/dist/components/multi-select-combobox.js +1 -1
  63. package/dist/components/place-about-tab.js +3 -3
  64. package/dist/components/place-details-mobile.js +3 -3
  65. package/dist/components/poster-stack.d.ts +30 -0
  66. package/dist/components/poster-stack.js +104 -0
  67. package/dist/components/poster-tape.d.ts +36 -0
  68. package/dist/components/poster-tape.js +64 -0
  69. package/dist/components/poster-wall.d.ts +25 -0
  70. package/dist/components/poster-wall.js +183 -0
  71. package/dist/components/quiet-stretch.d.ts +43 -0
  72. package/dist/components/quiet-stretch.js +54 -0
  73. package/dist/components/section-header.d.ts +53 -0
  74. package/dist/components/section-header.js +63 -0
  75. package/dist/components/segmented-nav.d.ts +62 -0
  76. package/dist/components/segmented-nav.js +54 -0
  77. package/dist/components/skeleton-card.js +1 -1
  78. package/dist/components/skeleton.d.ts +17 -0
  79. package/dist/components/skeleton.js +1 -1
  80. package/dist/components/spinner.d.ts +2 -2
  81. package/dist/components/tag-group.js +3 -26
  82. package/dist/components/tag.d.ts +2 -2
  83. package/dist/components/tag.js +1 -1
  84. package/dist/components/text.d.ts +3 -3
  85. package/dist/components/timeline-band.d.ts +46 -0
  86. package/dist/components/timeline-band.js +5 -0
  87. package/dist/components/venue-events-block.d.ts +143 -0
  88. package/dist/components/venue-events-block.js +222 -0
  89. package/dist/components/weekend-panel.d.ts +44 -0
  90. package/dist/components/weekend-panel.js +231 -0
  91. package/package.json +1 -1
  92. package/src/components/card.tsx +44 -1
  93. package/src/components/category-deck.stories.tsx +96 -0
  94. package/src/components/category-deck.tsx +227 -0
  95. package/src/components/concept-card.stories.tsx +80 -0
  96. package/src/components/concept-card.tsx +143 -0
  97. package/src/components/coverflow.stories.tsx +240 -0
  98. package/src/components/coverflow.test.tsx +193 -0
  99. package/src/components/coverflow.tsx +550 -0
  100. package/src/components/day-rail-chip.stories.tsx +145 -0
  101. package/src/components/day-rail-chip.tsx +201 -0
  102. package/src/components/drift-rail.stories.tsx +92 -0
  103. package/src/components/drift-rail.tsx +86 -0
  104. package/src/components/event-card.stories.tsx +248 -0
  105. package/src/components/event-card.test.tsx +177 -0
  106. package/src/components/event-card.tsx +321 -0
  107. package/src/components/event-compact-row.stories.tsx +221 -0
  108. package/src/components/event-compact-row.tsx +268 -0
  109. package/src/components/event-day-header.stories.tsx +124 -0
  110. package/src/components/event-day-header.tsx +180 -0
  111. package/src/components/event-day-separator.stories.tsx +70 -0
  112. package/src/components/event-day-separator.tsx +95 -0
  113. package/src/components/event-detail-panel.stories.tsx +191 -0
  114. package/src/components/event-detail-panel.tsx +360 -0
  115. package/src/components/event-hub-hero.stories.tsx +66 -0
  116. package/src/components/event-hub-hero.tsx +110 -0
  117. package/src/components/event-index-row.stories.tsx +116 -0
  118. package/src/components/event-index-row.tsx +181 -0
  119. package/src/components/event-list-row-wide.stories.tsx +204 -0
  120. package/src/components/event-list-row-wide.tsx +386 -0
  121. package/src/components/event-list-row.stories.tsx +159 -0
  122. package/src/components/event-list-row.tsx +335 -0
  123. package/src/components/event-poster-fallback.stories.tsx +52 -0
  124. package/src/components/event-poster-fallback.tsx +31 -0
  125. package/src/components/event-types.ts +133 -0
  126. package/src/components/events-empty-state.stories.tsx +59 -0
  127. package/src/components/events-empty-state.tsx +131 -0
  128. package/src/components/facet-group.stories.tsx +247 -0
  129. package/src/components/facet-group.test.tsx +225 -0
  130. package/src/components/facet-group.tsx +352 -0
  131. package/src/components/featured-rail-item.stories.tsx +86 -0
  132. package/src/components/featured-rail-item.tsx +153 -0
  133. package/src/components/hour-band.stories.tsx +159 -0
  134. package/src/components/hour-band.tsx +126 -0
  135. package/src/components/hour-group.stories.tsx +100 -0
  136. package/src/components/hour-group.tsx +124 -0
  137. package/src/components/poster-stack.stories.tsx +68 -0
  138. package/src/components/poster-stack.tsx +155 -0
  139. package/src/components/poster-tape.stories.tsx +70 -0
  140. package/src/components/poster-tape.tsx +122 -0
  141. package/src/components/poster-wall.stories.tsx +95 -0
  142. package/src/components/poster-wall.tsx +220 -0
  143. package/src/components/quiet-stretch.stories.tsx +62 -0
  144. package/src/components/quiet-stretch.tsx +115 -0
  145. package/src/components/section-header.stories.tsx +70 -0
  146. package/src/components/section-header.tsx +134 -0
  147. package/src/components/segmented-nav.stories.tsx +118 -0
  148. package/src/components/segmented-nav.tsx +113 -0
  149. package/src/components/skeleton.tsx +18 -0
  150. package/src/components/tag.tsx +11 -1
  151. package/src/components/timeline-band.stories.tsx +206 -0
  152. package/src/components/timeline-band.tsx +207 -0
  153. package/src/components/venue-events-block.stories.tsx +216 -0
  154. package/src/components/venue-events-block.tsx +408 -0
  155. package/src/components/weekend-panel.stories.tsx +92 -0
  156. package/src/components/weekend-panel.tsx +322 -0
  157. package/src/globals.css +19 -0
@@ -0,0 +1,360 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Calendar, ChevronRight, Radio, Star } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { EventPosterFallback } from "./event-poster-fallback";
7
+ import { Separator } from "./separator";
8
+ import { Skeleton } from "./skeleton";
9
+ import {
10
+ resolveEventEntryFee,
11
+ type EventFormatters,
12
+ type EventPreview,
13
+ } from "./event-types";
14
+
15
+ /**
16
+ * EventDetailPanel — the BODY of the event detail sheet.
17
+ *
18
+ * Deliberately not a sheet. The design opens this as a side sheet over a dimmed
19
+ * page on desktop and a bottom sheet on mobile, and the DS already ships `Sheet`
20
+ * and `Drawer`; owning sheet mechanics a third time here would fork them. The
21
+ * consuming app mounts this inside whichever container the breakpoint calls for.
22
+ *
23
+ * This is the terminal surface for acting on an event, which is why the cards
24
+ * that open it carry no actions of their own — the footer is where booking and
25
+ * directions live.
26
+ *
27
+ * Everything is injected: strings via `labels`, times via `formatters`, and the
28
+ * close/share affordances as `headerActions` so the sheet container owns
29
+ * dismissal (bearmenu sheets close by drag handle, not by back navigation).
30
+ */
31
+
32
+ export type EventDetailPanelLinkProps = {
33
+ href: string;
34
+ className?: string;
35
+ children: React.ReactNode;
36
+ };
37
+
38
+ function DefaultLink({ href, ...props }: EventDetailPanelLinkProps) {
39
+ return <a href={href} {...props} />;
40
+ }
41
+
42
+ export type EventDetailVenue = {
43
+ name: string;
44
+ /** Address plus opening line, composed by the caller. */
45
+ meta?: string;
46
+ ratingLabel?: string;
47
+ thumbnailUrl?: string;
48
+ href?: string;
49
+ };
50
+
51
+ export type EventDetailPanelLabels = {
52
+ free: string;
53
+ liveNow: string;
54
+ /** Section heading above the venue card. */
55
+ venueHeading: string;
56
+ /** Section heading above the description. */
57
+ aboutHeading: string;
58
+ };
59
+
60
+ export type EventDetailPanelProps = {
61
+ event: EventPreview;
62
+ labels: EventDetailPanelLabels;
63
+ formatters: EventFormatters;
64
+ /**
65
+ * The date line, composed by the caller — the design reads
66
+ * "Saturday, September 5 · Starts at 20:00", whose clause order and
67
+ * preposition are locale-dependent.
68
+ */
69
+ dateLine: string;
70
+ venue?: EventDetailVenue;
71
+ /** Category chips beside the live/price chips. */
72
+ tags?: string[];
73
+ isLive?: boolean;
74
+ /** Close and share controls, positioned over the poster by this component. */
75
+ headerActions?: React.ReactNode;
76
+ /** Footer actions — the booking CTA and directions. Rendered in a sticky footer. */
77
+ footer?: React.ReactNode;
78
+ /** An external link row, e.g. "View on Instagram". */
79
+ externalLink?: React.ReactNode;
80
+ /** `compact` is the mobile bottom sheet: tighter type and padding, same 4:5 poster. */
81
+ size?: "compact" | "default";
82
+ linkComponent?: React.ComponentType<EventDetailPanelLinkProps>;
83
+ className?: string;
84
+ /** Rendered instead of the poster when it's absent. Defaults to `<EventPosterFallback />`. */
85
+ fallback?: React.ReactNode;
86
+ };
87
+
88
+ const CHIP =
89
+ "inline-flex items-center gap-1 rounded-full border border-border px-2.5 text-xs";
90
+
91
+ export function EventDetailPanel({
92
+ event,
93
+ labels,
94
+ formatters,
95
+ dateLine,
96
+ venue,
97
+ tags,
98
+ isLive,
99
+ headerActions,
100
+ footer,
101
+ externalLink,
102
+ size = "default",
103
+ linkComponent: LinkComponent = DefaultLink,
104
+ className,
105
+ fallback = <EventPosterFallback />,
106
+ }: EventDetailPanelProps) {
107
+ const compact = size === "compact";
108
+ const fee = resolveEventEntryFee(event);
109
+
110
+ const venueCard = venue && (
111
+ <div className="flex items-center gap-3 rounded-[13px] border border-border p-2.5">
112
+ <div
113
+ className={cn(
114
+ "shrink-0 overflow-hidden rounded-[10px] bg-muted",
115
+ compact ? "h-12 w-12" : "h-[52px] w-[52px]"
116
+ )}
117
+ >
118
+ {venue.thumbnailUrl && (
119
+ // eslint-disable-next-line @next/next/no-img-element
120
+ <img
121
+ src={venue.thumbnailUrl}
122
+ alt=""
123
+ className="h-full w-full object-cover"
124
+ loading="lazy"
125
+ decoding="async"
126
+ />
127
+ )}
128
+ </div>
129
+ <div className="min-w-0 flex-1">
130
+ <div className={cn("font-bold", compact ? "text-[13.5px]" : "text-sm")}>{venue.name}</div>
131
+ {venue.meta && (
132
+ <div className={cn("text-muted-foreground", compact ? "text-[11.5px]" : "text-xs")}>
133
+ {venue.meta}
134
+ </div>
135
+ )}
136
+ {venue.ratingLabel && (
137
+ <div className="mt-0.5 flex items-center gap-1 text-xs font-bold">
138
+ <Star className="h-3 w-3" aria-hidden />
139
+ {venue.ratingLabel}
140
+ </div>
141
+ )}
142
+ </div>
143
+ <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
144
+ </div>
145
+ );
146
+
147
+ return (
148
+ <div className={cn("flex h-full flex-col overflow-hidden bg-card", className)}>
149
+ <div className="flex-1 overflow-y-auto">
150
+ <div className="relative aspect-[4/5] shrink-0 overflow-hidden bg-muted">
151
+ {event.poster_url ? (
152
+ // eslint-disable-next-line @next/next/no-img-element
153
+ <img
154
+ src={event.poster_url}
155
+ alt={event.title}
156
+ className="h-full w-full object-cover"
157
+ loading="eager"
158
+ decoding="async"
159
+ />
160
+ ) : (
161
+ fallback
162
+ )}
163
+ {headerActions}
164
+ </div>
165
+
166
+ <div
167
+ className={cn(
168
+ "flex flex-col gap-3.5",
169
+ compact ? "px-4 pt-4" : "px-[22px] pt-5"
170
+ )}
171
+ >
172
+ <h2
173
+ className={cn(
174
+ "font-extrabold leading-[1.25] tracking-[-0.02em]",
175
+ compact ? "text-xl" : "text-[21px]"
176
+ )}
177
+ >
178
+ {event.title}
179
+ </h2>
180
+
181
+ <div
182
+ className={cn(
183
+ "flex items-center gap-1.5 text-muted-foreground",
184
+ compact ? "text-[12.5px]" : "text-[13px]"
185
+ )}
186
+ >
187
+ <Calendar className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} aria-hidden />
188
+ {dateLine}
189
+ </div>
190
+
191
+ {/* The live chip is green, not the brand accent: "on now" reads as one
192
+ colour across every events surface, and it is the same green the app
193
+ already uses for "open now". */}
194
+ <div className="flex flex-wrap gap-1.5">
195
+ {isLive && (
196
+ <span
197
+ className={cn(
198
+ CHIP,
199
+ "h-7 border-success/30 bg-success/10 font-semibold text-success"
200
+ )}
201
+ >
202
+ <Radio className="h-3 w-3" aria-hidden />
203
+ {labels.liveNow}
204
+ </span>
205
+ )}
206
+ {tags?.map((tag) => (
207
+ <span key={tag} className={cn(CHIP, "h-7")}>
208
+ {tag}
209
+ </span>
210
+ ))}
211
+ {fee.kind === "free" && (
212
+ <span className={cn(CHIP, "h-7 border-success/40 font-semibold text-success")}>
213
+ {labels.free}
214
+ </span>
215
+ )}
216
+ {fee.kind === "paid" && (
217
+ <span className={cn(CHIP, "h-7")}>{formatters.price(fee.amountRon)}</span>
218
+ )}
219
+ </div>
220
+
221
+ {venue && (
222
+ <div>
223
+ <div className="pb-2 text-[13px] font-extrabold">{labels.venueHeading}</div>
224
+ {venue.href ? (
225
+ <LinkComponent href={venue.href} className="block">
226
+ {venueCard}
227
+ </LinkComponent>
228
+ ) : (
229
+ venueCard
230
+ )}
231
+ </div>
232
+ )}
233
+
234
+ {event.description && (
235
+ <div>
236
+ <div className="pb-1.5 text-[13px] font-extrabold">{labels.aboutHeading}</div>
237
+ <p
238
+ className={cn(
239
+ "leading-[1.6] text-muted-foreground",
240
+ compact ? "text-[13px]" : "text-[13.5px]"
241
+ )}
242
+ >
243
+ {event.description}
244
+ </p>
245
+ </div>
246
+ )}
247
+
248
+ {externalLink}
249
+ <div className="h-2" aria-hidden />
250
+ </div>
251
+ </div>
252
+
253
+ {footer && (
254
+ <div
255
+ className={cn(
256
+ "sticky bottom-0 flex shrink-0 gap-2.5 bg-card",
257
+ compact
258
+ ? "px-4 pt-3 pb-[max(1.375rem,env(safe-area-inset-bottom))]"
259
+ : "px-[22px] pt-4 pb-[max(1.25rem,env(safe-area-inset-bottom))]"
260
+ )}
261
+ >
262
+ <Separator className="absolute inset-x-0 top-0" />
263
+
264
+ {footer}
265
+ </div>
266
+ )}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ export type EventDetailPanelSkeletonProps = {
272
+ /** `compact` is the mobile bottom sheet: tighter type and padding, same 4:5 poster. */
273
+ size?: "compact" | "default";
274
+ /** Close and share controls, positioned over the poster — same slot as the real component. */
275
+ headerActions?: React.ReactNode;
276
+ className?: string;
277
+ };
278
+
279
+ /** Mirrors `<EventDetailPanel>`'s geometry exactly: the hero scrolls away with the body, the footer is sticky. */
280
+ export function EventDetailPanelSkeleton({
281
+ size = "default",
282
+ headerActions,
283
+ className,
284
+ }: EventDetailPanelSkeletonProps) {
285
+ const compact = size === "compact";
286
+
287
+ return (
288
+ <div
289
+ aria-hidden="true"
290
+ className={cn("flex h-full flex-col overflow-hidden bg-card", className)}
291
+ >
292
+ <div className="flex-1 overflow-y-auto">
293
+ <div className="relative aspect-[4/5] shrink-0 overflow-hidden bg-muted">
294
+ {headerActions}
295
+ </div>
296
+
297
+ <div
298
+ className={cn(
299
+ "flex flex-col gap-3.5",
300
+ compact ? "px-4 pt-4" : "px-[22px] pt-5"
301
+ )}
302
+ >
303
+ <Skeleton className={cn("w-3/4", compact ? "h-5" : "h-[21px]")} />
304
+
305
+ <div className="flex items-center gap-1.5">
306
+ <Skeleton className={cn("shrink-0 rounded-full", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
307
+ <Skeleton className={cn("w-48", compact ? "h-[12.5px]" : "h-[13px]")} />
308
+ </div>
309
+
310
+ <div className="flex flex-wrap gap-1.5">
311
+ <Skeleton className="h-7 w-20 rounded-full" />
312
+ <Skeleton className="h-7 w-16 rounded-full" />
313
+ </div>
314
+
315
+ <div>
316
+ <Skeleton className="mb-2 h-[13px] w-28" />
317
+ <div className="flex items-center gap-3 rounded-[13px] border border-border p-2.5">
318
+ <Skeleton
319
+ className={cn(
320
+ "shrink-0 rounded-[10px]",
321
+ compact ? "h-12 w-12" : "h-[52px] w-[52px]"
322
+ )}
323
+ />
324
+ <div className="min-w-0 flex-1">
325
+ <Skeleton className={cn("w-1/2", compact ? "h-[13.5px]" : "h-3.5")} />
326
+ <Skeleton className={cn("mt-1 w-3/4", compact ? "h-[11.5px]" : "h-3")} />
327
+ </div>
328
+ <Skeleton className="h-4 w-4 shrink-0 rounded-full" />
329
+ </div>
330
+ </div>
331
+
332
+ <div>
333
+ <Skeleton className="mb-1.5 h-[13px] w-32" />
334
+ <div className="flex flex-col gap-1.5">
335
+ <Skeleton className={cn("w-full", compact ? "h-[13px]" : "h-[13.5px]")} />
336
+ <Skeleton className={cn("w-full", compact ? "h-[13px]" : "h-[13.5px]")} />
337
+ <Skeleton className={cn("w-2/3", compact ? "h-[13px]" : "h-[13.5px]")} />
338
+ </div>
339
+ </div>
340
+
341
+ <div className="h-2" aria-hidden />
342
+ </div>
343
+ </div>
344
+
345
+ <div
346
+ className={cn(
347
+ "sticky bottom-0 flex shrink-0 gap-2.5 bg-card",
348
+ compact
349
+ ? "px-4 pt-3 pb-[max(1.375rem,env(safe-area-inset-bottom))]"
350
+ : "px-[22px] pt-4 pb-[max(1.25rem,env(safe-area-inset-bottom))]"
351
+ )}
352
+ >
353
+ <Separator className="absolute inset-x-0 top-0" />
354
+
355
+ <Skeleton className="h-[46px] flex-1 rounded-xl" />
356
+ <Skeleton className="h-[46px] w-24 rounded-xl" />
357
+ </div>
358
+ </div>
359
+ );
360
+ }
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { EventHubHero, EventHubHeroSkeleton } from "./event-hub-hero";
3
+ import { PosterStack } from "./poster-stack";
4
+
5
+ function posterDataUri(label: string, fill: string) {
6
+ return (
7
+ "data:image/svg+xml;utf8," +
8
+ encodeURIComponent(
9
+ `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="${fill}"/><text x="50%" y="50%" font-family="monospace" font-size="20" fill="#fff" text-anchor="middle">${label}</text></svg>`
10
+ )
11
+ );
12
+ }
13
+
14
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
15
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
16
+ // name portably (TS2742).
17
+ const meta: Meta<typeof EventHubHero> = {
18
+ title: "Events/EventHubHero",
19
+ component: EventHubHero,
20
+ tags: ["autodocs"],
21
+ args: {
22
+ eyebrow: "Updated today",
23
+ title: "Everything happening in Bucharest",
24
+ intro:
25
+ "Concerts, parties, exhibitions and everything in between — hand-curated and updated every morning.",
26
+ stats: [
27
+ { value: "312", label: "events this month" },
28
+ { value: "48", label: "venues" },
29
+ { value: "19", label: "free tonight" },
30
+ ],
31
+ },
32
+ decorators: [
33
+ (Story) => (
34
+ <div className="p-6">
35
+ <Story />
36
+ </div>
37
+ ),
38
+ ],
39
+ };
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof EventHubHero>;
43
+
44
+ export const Default: Story = {};
45
+
46
+ export const WithPosterStackAside: Story = {
47
+ args: {
48
+ aside: (
49
+ <PosterStack
50
+ posters={[
51
+ { src: posterDataUri("1", "#7a5c46"), alt: "Psihotrop poster" },
52
+ { src: posterDataUri("2", "#4a6a5c"), alt: "Warehouse rave poster" },
53
+ { src: posterDataUri("3", "#6a5c8a"), alt: "Jazz night poster" },
54
+ ]}
55
+ />
56
+ ),
57
+ },
58
+ };
59
+
60
+ export const NoAside: Story = {
61
+ args: { aside: undefined },
62
+ };
63
+
64
+ export const Skeleton: Story = {
65
+ render: () => <EventHubHeroSkeleton aside={<div className="aspect-[3/2] rounded-xl bg-muted" />} />,
66
+ };
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { Skeleton } from "./skeleton";
6
+
7
+ /**
8
+ * EventHubHero — the hub's opening block: an eyebrow, a big headline, an
9
+ * intro line and a stats row on the left; an arbitrary slot (the poster
10
+ * stack, in the source design) on the right. Stacks on mobile.
11
+ *
12
+ * All copy and stat values arrive pre-formatted — the DS holds no `Intl`
13
+ * locale and does not format numbers.
14
+ */
15
+
16
+ export type EventHubHeroStat = {
17
+ value: string;
18
+ label: string;
19
+ };
20
+
21
+ export type EventHubHeroProps = {
22
+ /** Small caps-style label above the title, e.g. "Updated today". */
23
+ eyebrow: string;
24
+ title: string;
25
+ intro: string;
26
+ stats: EventHubHeroStat[];
27
+ /** The right-column slot — the poster stack in the source design. */
28
+ aside?: React.ReactNode;
29
+ className?: string;
30
+ };
31
+
32
+ export function EventHubHero({ eyebrow, title, intro, stats, aside, className }: EventHubHeroProps) {
33
+ return (
34
+ <div
35
+ className={cn(
36
+ "grid grid-cols-1 gap-8 lg:grid-cols-[1fr_620px] lg:items-center lg:gap-10",
37
+ className
38
+ )}
39
+ >
40
+ <div className="flex flex-col gap-4">
41
+ <p className="text-[11.5px] font-extrabold tracking-[0.12em] text-muted-foreground">
42
+ {eyebrow}
43
+ </p>
44
+
45
+ <h1 className="text-[31px] font-black leading-[1.05] tracking-[-0.035em] text-foreground md:text-[42px] md:tracking-[-0.04em]">
46
+ {title}
47
+ </h1>
48
+
49
+ <p className="max-w-[520px] text-[14px] leading-[1.6] text-muted-foreground md:text-[15px] md:leading-[1.65]">
50
+ {intro}
51
+ </p>
52
+
53
+ <div className="flex gap-[22px] pt-1.5 md:gap-[30px]">
54
+ {stats.map((stat) => (
55
+ <div key={stat.label} className="flex flex-col">
56
+ <span className="text-[24px] font-black leading-none tracking-[-0.03em] tabular-nums text-foreground md:text-[30px]">
57
+ {stat.value}
58
+ </span>
59
+ <span className="mt-1 text-[11px] text-muted-foreground md:mt-[5px] md:text-[12.5px]">
60
+ {stat.label}
61
+ </span>
62
+ </div>
63
+ ))}
64
+ </div>
65
+ </div>
66
+
67
+ {aside && <div className="min-w-0">{aside}</div>}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export type EventHubHeroSkeletonProps = {
73
+ /** The right-column slot, e.g. a `PosterStack`-shaped placeholder. Mirrors `aside` on the real component. */
74
+ aside?: React.ReactNode;
75
+ className?: string;
76
+ };
77
+
78
+ export function EventHubHeroSkeleton({ aside, className }: EventHubHeroSkeletonProps) {
79
+ return (
80
+ <div
81
+ aria-hidden="true"
82
+ className={cn(
83
+ "grid grid-cols-1 gap-8 lg:grid-cols-[1fr_620px] lg:items-center lg:gap-10",
84
+ className
85
+ )}
86
+ >
87
+ <div className="flex flex-col gap-4">
88
+ <Skeleton className="h-[11.5px] w-28" />
89
+
90
+ <Skeleton className="h-[31px] w-full max-w-[420px] md:h-[42px]" />
91
+
92
+ <div className="flex max-w-[520px] flex-col gap-1.5">
93
+ <Skeleton className="h-[14px] w-full md:h-[15px]" />
94
+ <Skeleton className="h-[14px] w-4/5 md:h-[15px]" />
95
+ </div>
96
+
97
+ <div className="flex gap-[22px] pt-1.5 md:gap-[30px]">
98
+ {Array.from({ length: 3 }, (_, index) => (
99
+ <div key={index} className="flex flex-col">
100
+ <Skeleton className="h-[24px] w-10 md:h-[30px]" />
101
+ <Skeleton className="mt-1 h-[11px] w-16 md:mt-[5px] md:h-[12.5px]" />
102
+ </div>
103
+ ))}
104
+ </div>
105
+ </div>
106
+
107
+ {aside && <div className="min-w-0">{aside}</div>}
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,116 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Music, Wine } from "lucide-react";
3
+ import { EventIndexRow } from "./event-index-row";
4
+ import { EventDaySeparator } from "./event-day-separator";
5
+ import { EventPosterFallback } from "./event-poster-fallback";
6
+
7
+ const POSTER = "https://images.unsplash.com/photo-1514525253161-7a46d19cd819?w=256&h=256&fit=crop";
8
+
9
+ const DESCRIPTION =
10
+ "Trupa cântă un set de două ore din repertoriul propriu plus reinterpretări de standarde, cu pauză de treizeci de minute la mijloc. Barul rămâne deschis până la ora două, iar bucătăria ia ultima comandă la unsprezece.";
11
+
12
+ const meta: Meta<typeof EventIndexRow> = {
13
+ title: "Events/EventIndexRow",
14
+ component: EventIndexRow,
15
+ tags: ["autodocs"],
16
+ args: {
17
+ title: "Afterwork Karaoke",
18
+ href: "#event",
19
+ when: "26 iulie, 20:00–23:00",
20
+ venue: "Cafe Solas",
21
+ venueHref: "#place",
22
+ fee: "intrare liberă",
23
+ tags: ["Karaoke", "Live music"],
24
+ description: DESCRIPTION,
25
+ posterUrl: POSTER,
26
+ },
27
+ // The row sits on a tinted band on its real surface; the decorator mirrors
28
+ // that so the `outline` tags and the `bg-card/60` hover are judged against
29
+ // the ground they actually land on, not against plain `--background`.
30
+ decorators: [
31
+ (Story) => (
32
+ <div className="w-full max-w-[900px] bg-muted/50 p-6">
33
+ <Story />
34
+ </div>
35
+ ),
36
+ ],
37
+ };
38
+
39
+ export default meta;
40
+ type Story = StoryObj<typeof EventIndexRow>;
41
+
42
+ export const Default: Story = {};
43
+
44
+ /** The shape the section actually renders: a heading that ends in its own rule,
45
+ * then rows, the first of which suppresses its rule to avoid doubling. */
46
+ export const InAGroup: Story = {
47
+ render: (args) => (
48
+ <div>
49
+ <EventDaySeparator as="h2" label="Evenimente azi în Cluj-Napoca" count="3 evenimente" />
50
+ <EventIndexRow {...args} hideRule />
51
+ <EventIndexRow
52
+ {...args}
53
+ title="Degustare de vinuri naturale"
54
+ when="26 iulie, 19:00"
55
+ venue="Enoteca Veche"
56
+ fee="bilet 80 lei"
57
+ tags={["Wine tasting"]}
58
+ posterUrl={undefined}
59
+ fallback={<EventPosterFallback icon={<Wine className="h-5 w-5 md:h-7 md:w-7" aria-hidden />} />}
60
+ />
61
+ <EventIndexRow
62
+ {...args}
63
+ title="Peggy Gou b2b special guest"
64
+ when="26 iulie, 23:00–05:00"
65
+ venue="Form Space"
66
+ fee="bilet 140 lei"
67
+ tags={["Techno", "Party"]}
68
+ fallback={<EventPosterFallback icon={<Music className="h-5 w-5 md:h-7 md:w-7" aria-hidden />} />}
69
+ />
70
+ </div>
71
+ ),
72
+ };
73
+
74
+ /** No poster: the gradient fallback is already layered behind the image slot,
75
+ * so this is what a missing OR a failed image renders — with no JS. */
76
+ export const NoPoster: Story = {
77
+ args: { posterUrl: undefined },
78
+ };
79
+
80
+ export const TagDerivedFallback: Story = {
81
+ args: {
82
+ posterUrl: undefined,
83
+ fallback: <EventPosterFallback icon={<Wine className="h-5 w-5 md:h-7 md:w-7" aria-hidden />} />,
84
+ },
85
+ };
86
+
87
+ /** An event nobody wrote a description for. The row closes up rather than
88
+ * reserving empty space, so a group of mixed rows still scans. */
89
+ export const NoDescription: Story = {
90
+ args: { description: undefined },
91
+ };
92
+
93
+ /** A venue with no place page — the name stays plain text rather than becoming
94
+ * a link to nowhere. */
95
+ export const NoVenueLink: Story = {
96
+ args: { venueHref: undefined },
97
+ };
98
+
99
+ export const NoTags: Story = {
100
+ args: { tags: undefined },
101
+ };
102
+
103
+ /** Long titles WRAP. They are document-outline headings, so truncating one
104
+ * would hide text from the outline the section exists to build. */
105
+ export const LongTitle: Story = {
106
+ args: {
107
+ title:
108
+ "Concert aniversar: douăzeci de ani de la primul album, cu invitați speciali și orchestră de cameră",
109
+ },
110
+ };
111
+
112
+ /** `hideRule` on its own, for the first row directly under any heading that
113
+ * already draws a rule. */
114
+ export const FirstInGroup: Story = {
115
+ args: { hideRule: true },
116
+ };