@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,321 @@
1
+ "use client";
2
+
3
+ import { memo, useState } from "react";
4
+ import { Gift, MapPin, Radio } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { EventPosterFallback } from "./event-poster-fallback";
7
+ import { Skeleton } from "./skeleton";
8
+ import { Tag } from "./tag";
9
+ import { TagGroup } from "./tag-group";
10
+ import {
11
+ resolveEventEntryFee,
12
+ type EventCardLabels,
13
+ type EventFormatters,
14
+ type EventPreview,
15
+ } from "./event-types";
16
+
17
+ /**
18
+ * EventCard — the poster card every events surface reuses.
19
+ *
20
+ * RENDERS AN ANCHOR WHENEVER `href` IS GIVEN, and that is not incidental. The
21
+ * consumer app's events hub was measured serving 2,751 words and 50 links in
22
+ * its raw HTML but only 12 links — none of them to an event — in the rendered
23
+ * DOM, because the hydrated cards were `<button onClick>` rather than links.
24
+ * Pass `href` AND `onClick`: the anchor is real for crawlers, middle-click and
25
+ * "open in new tab", while `onClick` opens the detail sheet (call
26
+ * `preventDefault()` there). Falls back to a `<button>` when only `onClick` is
27
+ * given, and a plain `<div>` when neither is.
28
+ *
29
+ * Like `place-card.tsx`, this takes a DS-owned view model, injected labels and
30
+ * injected formatters — no app schema, no app hooks, no i18n, and a plain
31
+ * `<img>` so it works outside Next.
32
+ */
33
+
34
+ export type EventCardLinkProps = {
35
+ href: string;
36
+ className?: string;
37
+ children: React.ReactNode;
38
+ onClick?: (mouseEvent: React.MouseEvent) => void;
39
+ };
40
+
41
+ function DefaultLink({ href, ...props }: EventCardLinkProps) {
42
+ return <a href={href} {...props} />;
43
+ }
44
+
45
+ export type EventCardProps = {
46
+ event: EventPreview;
47
+ labels: EventCardLabels;
48
+ formatters: EventFormatters;
49
+ /**
50
+ * Poster ratio. `4/3` is the house shape shared with the dish/drink/place
51
+ * cards — the default, and what every card sitting in a column should use.
52
+ * `3/4` is the hub's original portrait deck/rail/tape ratio. `4/5` matches
53
+ * the Instagram source most real posters ship at (1080x1350) and is used by
54
+ * the hub's editorial poster surfaces (`CategoryDeck`, `PosterTape`,
55
+ * `Coverflow`) — it crops less than `3/4` and is not the same as it.
56
+ */
57
+ posterAspect?: "4/3" | "3/4" | "4/5";
58
+ /** Larger title — the featured/highlight slot. Never a clamp: the title wraps in full either way. */
59
+ emphasis?: boolean;
60
+ /** Top-left overlay pill (the design's "Busy" hype marker). Include its icon. */
61
+ badge?: React.ReactNode;
62
+ /** Bottom-left overlay pill naming the category. Include its icon. */
63
+ tagLabel?: React.ReactNode;
64
+ /**
65
+ * Caller-supplied display labels, rendered as small chips in the text area
66
+ * beneath the venue/meta line. `EventPreview.tags` is taxonomy coordinates
67
+ * (`slug`/`axis_slug`), not display text, so the DS cannot render them
68
+ * itself. Distinct from `tagLabel`: that is a single overlay pill on the
69
+ * poster (the design's category affordance for posters); this is an
70
+ * in-body list for the rest of an event's tags. Cap the count at the call
71
+ * site — this card does not.
72
+ */
73
+ tags?: string[];
74
+ /**
75
+ * The live line, fully composed by the caller (e.g. "Live now · ends around
76
+ * 23:00"). Composed outside the DS deliberately: the clause order differs by
77
+ * locale, and "is this live" depends on the app's clock, not the DS's.
78
+ */
79
+ liveStatus?: string;
80
+ /** Hide the day/time line — the timeline supplies its own hour heading. */
81
+ hideWhen?: boolean;
82
+ /** Overrides the day label when the card appears outside its own day section. */
83
+ dayLabel?: string;
84
+ /** Append the entry fee to the meta line. Off by default, matching the design's base card. */
85
+ showPrice?: boolean;
86
+ href?: string;
87
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
88
+ linkComponent?: React.ComponentType<EventCardLinkProps>;
89
+ onClick?: (event: EventPreview, mouseEvent: React.MouseEvent) => void;
90
+ className?: string;
91
+ /** Forwarded to the poster's `loading` attribute (`"eager"` when true). */
92
+ priority?: boolean;
93
+ /** Rendered instead of the poster when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
94
+ fallback?: React.ReactNode;
95
+ };
96
+
97
+ const PILL_BASE =
98
+ "inline-flex items-center gap-1 h-6 max-w-[70%] px-2 rounded-full text-[11px] font-semibold";
99
+
100
+ export const EventCard = memo(function EventCard({
101
+ event,
102
+ labels,
103
+ formatters,
104
+ posterAspect = "4/3",
105
+ emphasis,
106
+ badge,
107
+ tagLabel,
108
+ tags,
109
+ liveStatus,
110
+ hideWhen,
111
+ dayLabel,
112
+ showPrice,
113
+ href,
114
+ linkComponent: LinkComponent = DefaultLink,
115
+ onClick,
116
+ className,
117
+ priority,
118
+ fallback = <EventPosterFallback />,
119
+ }: EventCardProps) {
120
+ const [imgError, setImgError] = useState(false);
121
+
122
+ const fee = resolveEventEntryFee(event);
123
+ const showPoster = !!event.poster_url && !imgError;
124
+ const venue = event.place_name ?? event.location;
125
+
126
+ const body = (
127
+ <>
128
+ <div
129
+ className={cn(
130
+ "relative overflow-hidden rounded-xl bg-muted",
131
+ posterAspect === "3/4"
132
+ ? "aspect-[3/4]"
133
+ : posterAspect === "4/5"
134
+ ? "aspect-[4/5]"
135
+ : "aspect-[4/3]"
136
+ )}
137
+ >
138
+ {showPoster ? (
139
+ // eslint-disable-next-line @next/next/no-img-element
140
+ <img
141
+ src={event.poster_url}
142
+ alt={event.title}
143
+ title={event.title}
144
+ loading={priority ? "eager" : "lazy"}
145
+ decoding="async"
146
+ className="h-full w-full object-cover"
147
+ onError={() => setImgError(true)}
148
+ />
149
+ ) : (
150
+ fallback
151
+ )}
152
+
153
+ {/* Top overlay row — hype badge left, entry fee right. The empty span
154
+ keeps the fee pill right-aligned when there is no badge. */}
155
+ <div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
156
+ {badge ? <span className={cn(PILL_BASE, "bg-black/55 text-white")}>{badge}</span> : <span />}
157
+ {fee.kind === "free" && (
158
+ <span className={cn(PILL_BASE, "bg-success font-bold text-success-foreground")}>
159
+ <Gift className="h-3 w-3" aria-hidden />
160
+ {labels.free}
161
+ </span>
162
+ )}
163
+ </div>
164
+
165
+ {tagLabel && (
166
+ <div className="absolute inset-x-2 bottom-2 flex gap-1">
167
+ <span className={cn(PILL_BASE, "bg-black/55 text-white")}>{tagLabel}</span>
168
+ </div>
169
+ )}
170
+ </div>
171
+
172
+ {/* NEVER CLAMPED, in any variant. The title is the one thing a card
173
+ exists to say, and "Movie Night: Futni m…" says none of it — the
174
+ truncated form was the reported defect. It wraps to as many lines as
175
+ the event needs; the card is `flex-col` and grows downward, so a
176
+ three-line title lengthens the card rather than pushing anything out
177
+ of it. In a grid, give the row `items-start` so the extra lines hang
178
+ below instead of stretching every sibling. */}
179
+ <h3
180
+ className={cn(
181
+ "mt-2.5 leading-[1.35]",
182
+ emphasis ? "text-lg font-bold tracking-[-0.02em]" : "text-sm font-semibold"
183
+ )}
184
+ >
185
+ {event.title}
186
+ </h3>
187
+
188
+ <div className="mt-0.5 flex min-w-0 items-center gap-1 text-xs font-medium text-foreground/85">
189
+ <MapPin className="h-3 w-3 shrink-0" aria-hidden />
190
+ <span className="truncate">{venue}</span>
191
+ </div>
192
+
193
+ {!hideWhen && (
194
+ <div className="mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground">
195
+ <span>{dayLabel ?? formatters.day(event.starts_at)}</span>
196
+ <span className="text-border" aria-hidden>
197
+ ·
198
+ </span>
199
+ <span>{formatters.time(event.starts_at)}</span>
200
+ {showPrice && fee.kind === "paid" && (
201
+ <>
202
+ <span className="text-border" aria-hidden>
203
+ ·
204
+ </span>
205
+ <span>{formatters.price(fee.amountRon)}</span>
206
+ </>
207
+ )}
208
+ </div>
209
+ )}
210
+
211
+ {tags && tags.length > 0 && (
212
+ <TagGroup className="mt-1.5">
213
+ {tags.map((tag) => (
214
+ <Tag key={tag} size="sm">
215
+ {tag}
216
+ </Tag>
217
+ ))}
218
+ </TagGroup>
219
+ )}
220
+
221
+ {liveStatus && (
222
+ <div className="mt-1 flex items-center gap-1.5 text-xs font-bold text-success">
223
+ <Radio className="h-3 w-3 shrink-0" aria-hidden />
224
+ {liveStatus}
225
+ </div>
226
+ )}
227
+ </>
228
+ );
229
+
230
+ const rootClasses = cn(
231
+ "flex min-w-0 flex-col text-left",
232
+ (href || onClick) &&
233
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
234
+ className
235
+ );
236
+
237
+ if (href) {
238
+ return (
239
+ <LinkComponent
240
+ href={href}
241
+ className={rootClasses}
242
+ onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
243
+ >
244
+ {body}
245
+ </LinkComponent>
246
+ );
247
+ }
248
+
249
+ if (onClick) {
250
+ return (
251
+ <button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
252
+ {body}
253
+ </button>
254
+ );
255
+ }
256
+
257
+ return <div className={rootClasses}>{body}</div>;
258
+ });
259
+
260
+ export type EventCardSkeletonProps = {
261
+ /** Matches `EventCardProps["posterAspect"]` — keep the poster box the same shape. */
262
+ posterAspect?: "4/3" | "3/4" | "4/5";
263
+ /** Matches `EventCardProps["emphasis"]` — the featured slot's taller title. */
264
+ emphasis?: boolean;
265
+ /** Matches `EventCardProps["hideWhen"]` — drops the day/time line placeholder too. */
266
+ hideWhen?: boolean;
267
+ className?: string;
268
+ };
269
+
270
+ /**
271
+ * Mirrors `<EventCard>`'s geometry so nothing shifts when data lands — with
272
+ * one honest caveat since the title stopped being clamped: the placeholder
273
+ * reserves two lines (three under `emphasis`), and a real title longer than
274
+ * that will grow the card past the skeleton it replaces. Reserving more would
275
+ * only move the shift to the far commoner short-title case.
276
+ */
277
+ export function EventCardSkeleton({
278
+ posterAspect = "4/3",
279
+ emphasis,
280
+ hideWhen,
281
+ className,
282
+ }: EventCardSkeletonProps) {
283
+ return (
284
+ <div aria-hidden="true" className={cn("flex min-w-0 flex-col text-left", className)}>
285
+ <Skeleton
286
+ className={cn(
287
+ posterAspect === "3/4"
288
+ ? "aspect-[3/4]"
289
+ : posterAspect === "4/5"
290
+ ? "aspect-[4/5]"
291
+ : "aspect-[4/3]"
292
+ )}
293
+ />
294
+
295
+ {emphasis ? (
296
+ <div className="mt-2.5 space-y-2">
297
+ <Skeleton className="h-5 w-full" />
298
+ <Skeleton className="h-5 w-full" />
299
+ <Skeleton className="h-5 w-1/2" />
300
+ </div>
301
+ ) : (
302
+ <div className="mt-2.5 space-y-1.5">
303
+ <Skeleton className="h-3.5 w-full" />
304
+ <Skeleton className="h-3.5 w-2/3" />
305
+ </div>
306
+ )}
307
+
308
+ <div className="mt-0.5 flex items-center gap-1">
309
+ <Skeleton className="h-3 w-3 shrink-0 rounded-full" />
310
+ <Skeleton className="h-3 w-24" />
311
+ </div>
312
+
313
+ {!hideWhen && (
314
+ <div className="mt-[3px] flex items-center gap-1.5">
315
+ <Skeleton className="h-3 w-16" />
316
+ <Skeleton className="h-3 w-10" />
317
+ </div>
318
+ )}
319
+ </div>
320
+ );
321
+ }
@@ -0,0 +1,221 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { EventCompactRow, EventCompactRowSkeleton } from "./event-compact-row";
3
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
4
+
5
+ const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
6
+
7
+ const formatters: EventFormatters = {
8
+ time: (iso) => new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
9
+ day: (iso) =>
10
+ new Date(iso).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
11
+ price: (amount) => `${amount} RON`,
12
+ };
13
+
14
+ const event = (id: string, title: string, place: string, startsAt: string, fee?: number): EventPreview => ({
15
+ id,
16
+ title,
17
+ location: place,
18
+ place_name: place,
19
+ starts_at: startsAt,
20
+ entry_fee: fee,
21
+ });
22
+
23
+ const meta: Meta<typeof EventCompactRow> = {
24
+ title: "Events/EventCompactRow",
25
+ component: EventCompactRow,
26
+ tags: ["autodocs"],
27
+ args: { labels, formatters },
28
+ decorators: [
29
+ (Story) => (
30
+ <div className="w-[358px]">
31
+ <Story />
32
+ </div>
33
+ ),
34
+ ],
35
+ parameters: {
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "The dense row used inside `<HourGroup>` once the night thins out. Unlike `<EventListRow>`, it carries neither a date badge nor a trailing CTA — the timeline's hour heading already names the hour, and every row here means 'open me'.",
40
+ },
41
+ },
42
+ },
43
+ };
44
+
45
+ export default meta;
46
+ type Story = StoryObj<typeof EventCompactRow>;
47
+
48
+ export const Default: Story = {
49
+ args: { event: event("1", "Psihotrop Live", "Atelier Café", "2026-09-05T20:00:00", 50) },
50
+ };
51
+
52
+ export const FreeEntry: Story = {
53
+ args: { event: event("2", "Vinyl Happy Hour", "Zorki Photo Cafe", "2026-09-04T18:00:00", 0) },
54
+ };
55
+
56
+ /**
57
+ * No poster — the gradient fallback, which this row did not have until the
58
+ * thumbnail grew. A blank `bg-muted` box passed for "no image" at 34px; at
59
+ * 64x80 beside real posters it reads as broken.
60
+ */
61
+ export const NoPoster: Story = {
62
+ args: {
63
+ event: {
64
+ ...event("np", "Loops & Books at The Square One", "The Square One", "2026-09-04T18:30:00"),
65
+ poster_url: undefined,
66
+ },
67
+ },
68
+ };
69
+
70
+ /**
71
+ * The hover wash, which cannot be asserted in a test and has to be looked at.
72
+ * Point at a row: the whole row washes and the title takes `--primary`.
73
+ *
74
+ * Both surfaces are shown because the treatment had to work on each. The row
75
+ * renders straight on `--background` in the timeline and inside an elevated
76
+ * `Card` in the hub's venue block — a wash keyed to a background token is a
77
+ * no-op on one of the two, which is why this one is keyed to the text colour.
78
+ */
79
+ export const HoverOnBothSurfaces: Story = {
80
+ render: (args) => (
81
+ <div className="space-y-4">
82
+ <div className="rounded-xl bg-background p-3">
83
+ <p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
84
+ on --background
85
+ </p>
86
+ <EventCompactRow {...args} href="#a" />
87
+ <EventCompactRow {...args} event={event("h2", "Frat House Party with DJ M4RA", "Flying Circus", "2026-09-04T23:00:00")} href="#b" />
88
+ </div>
89
+ <div className="rounded-xl border border-border bg-card p-3">
90
+ <p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
91
+ on --card
92
+ </p>
93
+ <EventCompactRow {...args} href="#c" />
94
+ <EventCompactRow {...args} event={event("h4", "Horticulture Workshop at DOT", "DOT", "2026-09-04T19:00:00")} href="#d" />
95
+ </div>
96
+ </div>
97
+ ),
98
+ args: { event: event("h1", "Last Open Air with Dominik Muélar", "Iconic Garden", "2026-09-04T18:00:00") },
99
+ };
100
+
101
+ /** `hideTime` drops the leading time — for when the enclosing `HourGroup` already names it. */
102
+ export const HiddenTime: Story = {
103
+ args: {
104
+ event: event("3", "Quiz Night", "Flying Circus", "2026-09-03T19:00:00"),
105
+ hideTime: true,
106
+ },
107
+ };
108
+
109
+ /**
110
+ * A long title WRAPS — the row grows taller rather than cutting the event's
111
+ * name off. This story used to demonstrate the opposite; the single truncated
112
+ * line was the reported defect, on this row and every sibling.
113
+ */
114
+ export const LongTitle: Story = {
115
+ args: {
116
+ event: event(
117
+ "4",
118
+ "Stand-up Comedy with Dobrotă, Cîrje & Florin — The Late Show, second edition",
119
+ "Klausen Pubhouse",
120
+ "2026-09-04T19:30:00",
121
+ 45
122
+ ),
123
+ },
124
+ };
125
+
126
+ /** The mobile variant — a slightly larger poster and chip. */
127
+ export const Small: Story = {
128
+ args: {
129
+ size: "sm",
130
+ event: event("5", "Beer Battle", "Irish Music Pub", "2026-09-03T20:00:00", 0),
131
+ },
132
+ };
133
+
134
+ /**
135
+ * `subtitle` replaces the default "time · venue" sub-line entirely — the hub's
136
+ * venue block case, where the block's header already names the venue and the
137
+ * event's description is what actually distinguishes rows.
138
+ */
139
+ export const WithSubtitle: Story = {
140
+ args: {
141
+ event: event("6", "Karaoke Thursdays", "Yolka", "2026-09-03T21:00:00"),
142
+ subtitle: "Sing your heart out with the house band backing every song.",
143
+ },
144
+ };
145
+
146
+ /** Default sub-line beside the `subtitle` override, so the difference is visible. */
147
+ export const DefaultVsSubtitle: Story = {
148
+ render: () => (
149
+ <div>
150
+ <EventCompactRow
151
+ event={event("6a", "Karaoke Thursdays", "Yolka", "2026-09-03T21:00:00")}
152
+ labels={labels}
153
+ formatters={formatters}
154
+ />
155
+ <EventCompactRow
156
+ event={event("6b", "Karaoke Thursdays", "Yolka", "2026-09-03T21:00:00")}
157
+ labels={labels}
158
+ formatters={formatters}
159
+ subtitle="Sing your heart out with the house band backing every song."
160
+ />
161
+ </div>
162
+ ),
163
+ };
164
+
165
+ /** Tag chips below the sub-line — labels the caller derives from `event.tags`. */
166
+ export const WithTags: Story = {
167
+ args: {
168
+ event: event("7", "Psihotrop Live", "Atelier Café", "2026-09-05T20:00:00", 50),
169
+ tags: ["Live Music", "Rock"],
170
+ },
171
+ };
172
+
173
+ /** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
174
+ export const Loading: Story = {
175
+ render: () => <EventCompactRowSkeleton />,
176
+ };
177
+
178
+ /** The `size="sm"` loading geometry. */
179
+ export const LoadingSmall: Story = {
180
+ render: () => <EventCompactRowSkeleton size="sm" />,
181
+ };
182
+
183
+ /** A loading row above a loaded one, so any geometry drift is visible at a glance. */
184
+ export const LoadingVsLoaded: Story = {
185
+ render: () => (
186
+ <div>
187
+ <EventCompactRowSkeleton />
188
+ <EventCompactRow
189
+ event={event("1", "Psihotrop Live", "Atelier Café", "2026-09-05T20:00:00", 50)}
190
+ labels={labels}
191
+ formatters={formatters}
192
+ />
193
+ </div>
194
+ ),
195
+ };
196
+
197
+ /** A run of rows as they stack inside `HourGroup`. */
198
+ export const AStretch: Story = {
199
+ render: () => (
200
+ <div>
201
+ <EventCompactRow
202
+ event={event("a", "Psihotrop Live", "Atelier Café", "2026-09-03T20:00:00", 50)}
203
+ labels={labels}
204
+ formatters={formatters}
205
+ hideTime
206
+ />
207
+ <EventCompactRow
208
+ event={event("b", "Quiz Night", "Flying Circus", "2026-09-03T20:00:00")}
209
+ labels={labels}
210
+ formatters={formatters}
211
+ hideTime
212
+ />
213
+ <EventCompactRow
214
+ event={event("c", "Beer Battle", "Irish Music Pub", "2026-09-03T20:00:00", 0)}
215
+ labels={labels}
216
+ formatters={formatters}
217
+ hideTime
218
+ />
219
+ </div>
220
+ ),
221
+ };