@bearmenu/ui 0.8.4 → 0.8.7

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 (150) 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/card.d.ts +2 -2
  12. package/dist/components/card.js +4 -127
  13. package/dist/components/category-deck.d.ts +50 -0
  14. package/dist/components/category-deck.js +128 -0
  15. package/dist/components/concept-card.d.ts +43 -0
  16. package/dist/components/concept-card.js +81 -0
  17. package/dist/components/coverflow.d.ts +123 -0
  18. package/dist/components/coverflow.js +300 -0
  19. package/dist/components/day-rail-chip.d.ts +82 -0
  20. package/dist/components/day-rail-chip.js +105 -0
  21. package/dist/components/drift-rail.d.ts +28 -0
  22. package/dist/components/drift-rail.js +3 -0
  23. package/dist/components/event-card.d.ts +97 -0
  24. package/dist/components/event-card.js +164 -0
  25. package/dist/components/event-compact-row.d.ts +73 -0
  26. package/dist/components/event-compact-row.js +134 -0
  27. package/dist/components/event-day-header.d.ts +65 -0
  28. package/dist/components/event-day-header.js +95 -0
  29. package/dist/components/event-day-separator.d.ts +46 -0
  30. package/dist/components/event-day-separator.js +50 -0
  31. package/dist/components/event-detail-panel.d.ts +80 -0
  32. package/dist/components/event-detail-panel.js +251 -0
  33. package/dist/components/event-hub-hero.d.ts +34 -0
  34. package/dist/components/event-hub-hero.js +57 -0
  35. package/dist/components/event-index-row.d.ts +68 -0
  36. package/dist/components/event-index-row.js +84 -0
  37. package/dist/components/event-list-row-wide.d.ts +101 -0
  38. package/dist/components/event-list-row-wide.js +218 -0
  39. package/dist/components/event-list-row.d.ts +82 -0
  40. package/dist/components/event-list-row.js +173 -0
  41. package/dist/components/event-poster-fallback.d.ts +20 -0
  42. package/dist/components/event-poster-fallback.js +3 -0
  43. package/dist/components/event-types.d.ts +122 -0
  44. package/dist/components/event-types.js +2 -0
  45. package/dist/components/events-empty-state.d.ts +44 -0
  46. package/dist/components/events-empty-state.js +73 -0
  47. package/dist/components/facet-group.d.ts +93 -0
  48. package/dist/components/facet-group.js +222 -0
  49. package/dist/components/featured-rail-item.d.ts +42 -0
  50. package/dist/components/featured-rail-item.js +88 -0
  51. package/dist/components/hour-band.d.ts +68 -0
  52. package/dist/components/hour-band.js +53 -0
  53. package/dist/components/hour-group.d.ts +67 -0
  54. package/dist/components/hour-group.js +59 -0
  55. package/dist/components/motion-icon-controls.js +3 -379
  56. package/dist/components/multi-select-combobox.js +1 -1
  57. package/dist/components/place-about-tab.js +3 -3
  58. package/dist/components/place-details-mobile.js +3 -3
  59. package/dist/components/poster-stack.d.ts +30 -0
  60. package/dist/components/poster-stack.js +104 -0
  61. package/dist/components/poster-tape.d.ts +36 -0
  62. package/dist/components/poster-tape.js +64 -0
  63. package/dist/components/poster-wall.d.ts +25 -0
  64. package/dist/components/poster-wall.js +183 -0
  65. package/dist/components/quiet-stretch.d.ts +43 -0
  66. package/dist/components/quiet-stretch.js +54 -0
  67. package/dist/components/section-header.d.ts +53 -0
  68. package/dist/components/section-header.js +63 -0
  69. package/dist/components/segmented-nav.d.ts +62 -0
  70. package/dist/components/segmented-nav.js +54 -0
  71. package/dist/components/skeleton-card.js +1 -1
  72. package/dist/components/skeleton.d.ts +17 -0
  73. package/dist/components/skeleton.js +1 -1
  74. package/dist/components/tag-group.js +3 -26
  75. package/dist/components/tag.d.ts +2 -2
  76. package/dist/components/tag.js +1 -1
  77. package/dist/components/timeline-band.d.ts +46 -0
  78. package/dist/components/timeline-band.js +5 -0
  79. package/dist/components/venue-events-block.d.ts +143 -0
  80. package/dist/components/venue-events-block.js +222 -0
  81. package/dist/components/weekend-panel.d.ts +44 -0
  82. package/dist/components/weekend-panel.js +231 -0
  83. package/package.json +1 -1
  84. package/src/components/card.tsx +44 -1
  85. package/src/components/category-deck.stories.tsx +96 -0
  86. package/src/components/category-deck.tsx +227 -0
  87. package/src/components/concept-card.stories.tsx +80 -0
  88. package/src/components/concept-card.tsx +143 -0
  89. package/src/components/coverflow.stories.tsx +240 -0
  90. package/src/components/coverflow.test.tsx +193 -0
  91. package/src/components/coverflow.tsx +550 -0
  92. package/src/components/day-rail-chip.stories.tsx +145 -0
  93. package/src/components/day-rail-chip.tsx +201 -0
  94. package/src/components/drift-rail.stories.tsx +92 -0
  95. package/src/components/drift-rail.tsx +86 -0
  96. package/src/components/event-card.stories.tsx +248 -0
  97. package/src/components/event-card.test.tsx +188 -0
  98. package/src/components/event-card.tsx +329 -0
  99. package/src/components/event-compact-row.stories.tsx +221 -0
  100. package/src/components/event-compact-row.test.tsx +51 -0
  101. package/src/components/event-compact-row.tsx +270 -0
  102. package/src/components/event-day-header.stories.tsx +124 -0
  103. package/src/components/event-day-header.tsx +180 -0
  104. package/src/components/event-day-separator.stories.tsx +70 -0
  105. package/src/components/event-day-separator.tsx +95 -0
  106. package/src/components/event-detail-panel.stories.tsx +191 -0
  107. package/src/components/event-detail-panel.tsx +360 -0
  108. package/src/components/event-hub-hero.stories.tsx +66 -0
  109. package/src/components/event-hub-hero.tsx +110 -0
  110. package/src/components/event-index-row.stories.tsx +116 -0
  111. package/src/components/event-index-row.tsx +181 -0
  112. package/src/components/event-list-row-wide.stories.tsx +204 -0
  113. package/src/components/event-list-row-wide.tsx +386 -0
  114. package/src/components/event-list-row.stories.tsx +159 -0
  115. package/src/components/event-list-row.tsx +335 -0
  116. package/src/components/event-poster-fallback.stories.tsx +52 -0
  117. package/src/components/event-poster-fallback.tsx +31 -0
  118. package/src/components/event-types.ts +137 -0
  119. package/src/components/events-empty-state.stories.tsx +59 -0
  120. package/src/components/events-empty-state.tsx +131 -0
  121. package/src/components/facet-group.stories.tsx +247 -0
  122. package/src/components/facet-group.test.tsx +225 -0
  123. package/src/components/facet-group.tsx +352 -0
  124. package/src/components/featured-rail-item.stories.tsx +86 -0
  125. package/src/components/featured-rail-item.tsx +153 -0
  126. package/src/components/hour-band.stories.tsx +159 -0
  127. package/src/components/hour-band.tsx +126 -0
  128. package/src/components/hour-group.stories.tsx +100 -0
  129. package/src/components/hour-group.tsx +124 -0
  130. package/src/components/poster-stack.stories.tsx +68 -0
  131. package/src/components/poster-stack.tsx +155 -0
  132. package/src/components/poster-tape.stories.tsx +70 -0
  133. package/src/components/poster-tape.tsx +122 -0
  134. package/src/components/poster-wall.stories.tsx +95 -0
  135. package/src/components/poster-wall.tsx +220 -0
  136. package/src/components/quiet-stretch.stories.tsx +62 -0
  137. package/src/components/quiet-stretch.tsx +115 -0
  138. package/src/components/section-header.stories.tsx +70 -0
  139. package/src/components/section-header.tsx +134 -0
  140. package/src/components/segmented-nav.stories.tsx +118 -0
  141. package/src/components/segmented-nav.tsx +113 -0
  142. package/src/components/skeleton.tsx +18 -0
  143. package/src/components/tag.tsx +11 -1
  144. package/src/components/timeline-band.stories.tsx +206 -0
  145. package/src/components/timeline-band.tsx +207 -0
  146. package/src/components/venue-events-block.stories.tsx +216 -0
  147. package/src/components/venue-events-block.tsx +408 -0
  148. package/src/components/weekend-panel.stories.tsx +92 -0
  149. package/src/components/weekend-panel.tsx +322 -0
  150. package/src/globals.css +19 -0
@@ -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
+ };
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { EventCompactRow } from "./event-compact-row";
4
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
5
+
6
+ const labels: EventCardLabels = { free: "Free", liveNow: "Live now" };
7
+
8
+ const formatters: EventFormatters = {
9
+ time: () => "20:00",
10
+ day: () => "Sat, Sep 5",
11
+ price: (amount) => `${amount} RON`,
12
+ };
13
+
14
+ const event: EventPreview = {
15
+ id: "e1",
16
+ title: "Psihotrop Live",
17
+ location: "Atelier Cafe",
18
+ starts_at: "2026-09-05T20:00:00Z",
19
+ place_name: "Atelier Cafe",
20
+ };
21
+
22
+ describe("EventCompactRow sub line", () => {
23
+ it("leads with the time and the venue by default", () => {
24
+ const { container } = render(
25
+ <EventCompactRow event={event} labels={labels} formatters={formatters} />
26
+ );
27
+ expect(screen.getByText("20:00")).toBeInTheDocument();
28
+ expect(container.textContent).toContain("·");
29
+ expect(screen.getByText("Atelier Cafe")).toBeInTheDocument();
30
+ });
31
+
32
+ it("prints the venue alone when the time formatter returns an empty string (hour unknown)", () => {
33
+ const { container } = render(
34
+ <EventCompactRow
35
+ event={event}
36
+ labels={labels}
37
+ formatters={{ ...formatters, time: () => "" }}
38
+ />
39
+ );
40
+ expect(container.textContent).not.toContain("·");
41
+ expect(screen.getByText("Atelier Cafe")).toBeInTheDocument();
42
+ });
43
+
44
+ it("still honours hideTime for callers that know the hour is stated elsewhere", () => {
45
+ const { container } = render(
46
+ <EventCompactRow event={event} labels={labels} formatters={formatters} hideTime />
47
+ );
48
+ expect(container.textContent).not.toContain("20:00");
49
+ expect(container.textContent).not.toContain("·");
50
+ });
51
+ });
@@ -0,0 +1,270 @@
1
+ "use client";
2
+
3
+ import { memo, useState } from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { EventPosterFallback } from "./event-poster-fallback";
6
+ import { Skeleton } from "./skeleton";
7
+ import { Tag } from "./tag";
8
+ import { TagGroup } from "./tag-group";
9
+ import {
10
+ resolveEventEntryFee,
11
+ type EventCardLabels,
12
+ type EventFormatters,
13
+ type EventPreview,
14
+ } from "./event-types";
15
+
16
+ /**
17
+ * EventCompactRow — the dense row used inside `<HourGroup>` once the night
18
+ * thins out and a run of poster cards would spend more space than it earns.
19
+ *
20
+ * NOT `<EventListRow>`: that one carries a date badge and a trailing CTA for
21
+ * the list view. This row has neither — the timeline's `<HourGroup>` already
22
+ * names the hour, and every row here means "open me", same as the poster
23
+ * card. `hideTime` drops the leading time from the sub line too, for the
24
+ * common case where the surrounding heading already names it to the minute.
25
+ *
26
+ * THIS ROW DOES NOT DRAW DIVIDERS; ITS CONTAINER DOES. `<HourGroup>` already
27
+ * draws the rule under the hour heading, so a row-level top separator here
28
+ * doubled it directly beneath every hour. `<EventListRow>` and
29
+ * `<EventListRowWide>` are different: they stack in a plain list with no
30
+ * other rule around them, so their own top separator is the only one there —
31
+ * do not carry this rule over to them.
32
+ *
33
+ * Same contract as the rest: DS-owned view model, injected labels/formatters,
34
+ * plain `<img>`, anchor whenever `href` is given.
35
+ */
36
+
37
+ export type EventCompactRowLinkProps = {
38
+ href: string;
39
+ className?: string;
40
+ children: React.ReactNode;
41
+ onClick?: (mouseEvent: React.MouseEvent) => void;
42
+ };
43
+
44
+ function DefaultLink({ href, ...props }: EventCompactRowLinkProps) {
45
+ return <a href={href} {...props} />;
46
+ }
47
+
48
+ export type EventCompactRowProps = {
49
+ event: EventPreview;
50
+ labels: EventCardLabels;
51
+ formatters: EventFormatters;
52
+ /** Drops the leading time from the sub line. An empty `formatters.time`
53
+ * result (hour unknown) drops it the same way, without the prop. */
54
+ hideTime?: boolean;
55
+ /**
56
+ * Replaces the default "time · venue" sub-line entirely. For surfaces where
57
+ * the venue is already named above every row (the hub's venue block) and
58
+ * repeating it on each one is noise — the caller has something more
59
+ * differentiating to say instead (the event's own description). The
60
+ * timeline keeps the default: there the venue IS what tells rows apart
61
+ * across the night. Mirrors `menu-item-row.tsx`'s prop of the same name and
62
+ * intent.
63
+ */
64
+ subtitle?: string;
65
+ /** Caller-supplied display labels — `EventPreview.tags` is taxonomy
66
+ * coordinates (`slug`/`axis_slug`), not display text, so the DS cannot
67
+ * render them itself. Rendered as small chips below the sub-line; wrap
68
+ * rather than truncate, so a chip's own text is never cut mid-word. Cap
69
+ * the count at the call site — this row does not. */
70
+ tags?: string[];
71
+ size?: "sm" | "default";
72
+ href?: string;
73
+ linkComponent?: React.ComponentType<EventCompactRowLinkProps>;
74
+ onClick?: (event: EventPreview, mouseEvent: React.MouseEvent) => void;
75
+ className?: string;
76
+ /** Forwarded to the poster's `loading` attribute (`"eager"` when true). */
77
+ priority?: boolean;
78
+ /** Rendered in place of the poster when it is absent or fails to load. Defaults to `<EventPosterFallback />`. */
79
+ fallback?: React.ReactNode;
80
+ };
81
+
82
+ export const EventCompactRow = memo(function EventCompactRow({
83
+ event,
84
+ labels,
85
+ formatters,
86
+ hideTime,
87
+ subtitle,
88
+ tags,
89
+ size = "default",
90
+ href,
91
+ linkComponent: LinkComponent = DefaultLink,
92
+ onClick,
93
+ className,
94
+ priority,
95
+ fallback = <EventPosterFallback />,
96
+ }: EventCompactRowProps) {
97
+ const [imgError, setImgError] = useState(false);
98
+ const isSm = size === "sm";
99
+
100
+ const fee = resolveEventEntryFee(event);
101
+ const showPoster = !!event.poster_url && !imgError;
102
+ const venue = event.place_name ?? event.location;
103
+ const time = formatters.time(event.starts_at);
104
+
105
+ const body = (
106
+ <>
107
+ {/* 64x80 AND PORTRAIT, not a 34px square, and both parts matter.
108
+ 91% of the posters this renders are portrait and ~57% are Instagram's
109
+ 4:5 — so a square crop kept the middle band and threw away the top and
110
+ bottom, which on a flyer is exactly where the act, the venue and the
111
+ date sit. The complaint was that the thumbnail was unreadable, and it
112
+ was unreadable because of what the crop removed as much as the size.
113
+ `4/5` is the least-cropping ratio this DS has, and `event-card.tsx`
114
+ already reserves it for surfaces whose job is to sell the poster.
115
+
116
+ One size for both `sm` and `default`: no consumer passes `sm` today,
117
+ and the failure is identical on a phone, so there is no second number
118
+ to justify. `size` still drives type scale and pill height. */}
119
+ <div className="relative aspect-[4/5] w-16 shrink-0 overflow-hidden rounded-[9px] bg-muted">
120
+ {showPoster ? (
121
+ // eslint-disable-next-line @next/next/no-img-element
122
+ <img
123
+ src={event.poster_url}
124
+ alt=""
125
+ loading={priority ? "eager" : "lazy"}
126
+ decoding="async"
127
+ className="h-full w-full object-cover"
128
+ onError={() => setImgError(true)}
129
+ />
130
+ ) : (
131
+ // A bare `bg-muted` rectangle was survivable at 34px. At 64x80 beside
132
+ // real posters it reads as a broken image rather than an absent one —
133
+ // so this row finally joins every sibling in the family in having a
134
+ // designed fallback.
135
+ fallback
136
+ )}
137
+ </div>
138
+
139
+ <div className="min-w-0 flex-1">
140
+ {/* The title wraps in full — never truncated, in this row or any of
141
+ its siblings. The sub-line below it keeps its `truncate`: that one
142
+ is "20:00 · Venue", where the leading fact survives the cut and the
143
+ row's own height is what the density buys. */}
144
+ {/* `group-hover`, not `hover`: the whole row is one anchor, so the
145
+ pointer is over the parent and a plain `hover:` here would never
146
+ fire. */}
147
+ <div
148
+ className={cn(
149
+ "font-semibold leading-[1.35]",
150
+ "transition-colors duration-150 motion-reduce:transition-none",
151
+ isSm ? "text-[13.5px]" : "text-[13px]",
152
+ (href || onClick) && "group-hover:text-primary"
153
+ )}
154
+ >
155
+ {event.title}
156
+ </div>
157
+ <div className="truncate text-[11.5px] text-muted-foreground">
158
+ {subtitle ? (
159
+ subtitle
160
+ ) : (
161
+ <>
162
+ {!hideTime && time && (
163
+ <>
164
+ <span className="font-mono tabular-nums">{time}</span>
165
+ <span aria-hidden> · </span>
166
+ </>
167
+ )}
168
+ {venue}
169
+ </>
170
+ )}
171
+ </div>
172
+ {tags && tags.length > 0 && (
173
+ <TagGroup className="mt-1">
174
+ {tags.map((tag) => (
175
+ <Tag key={tag} size="sm">
176
+ {tag}
177
+ </Tag>
178
+ ))}
179
+ </TagGroup>
180
+ )}
181
+ </div>
182
+
183
+ {fee.kind === "free" && (
184
+ <span
185
+ className={cn(
186
+ "inline-flex shrink-0 items-center rounded-full border border-success/40 px-2 font-semibold text-success",
187
+ isSm ? "h-[22px] text-[10.5px]" : "h-6 text-[11px]"
188
+ )}
189
+ >
190
+ {labels.free}
191
+ </span>
192
+ )}
193
+ </>
194
+ );
195
+
196
+ // `items-start`, not `items-center`: an 80px poster is taller than a one-line
197
+ // title over its sub-line, so centring floated the text in empty space.
198
+ // `EventListRow` already made this call at its own 72px poster.
199
+ //
200
+ // THE HOVER WASH IS `bg-foreground/5`, AND THE TOKEN CHOICE IS LOAD-BEARING.
201
+ // This row renders on two different planes: the timeline puts it straight on
202
+ // `--background`, the hub's venue block puts it inside an elevated `Card`, so
203
+ // on `--card`. `hover:bg-card/60` — the obvious thing to copy from the
204
+ // nearest sibling — is a no-op on the second one, card blended onto card. And
205
+ // `bg-muted/50` is nearly invisible on `--card` in dark mode (an L delta of
206
+ // 0.015). A wash relative to the TEXT colour is surface-agnostic by
207
+ // construction, and lands at ~0.04 L on all four surface/mode combinations.
208
+ //
209
+ // The negative margin cancels the padding, so the fill bleeds past the text
210
+ // column and takes a radius without changing the row's content width — the
211
+ // same shape `EventIndexRow` uses. No transform: this is a zero-gap stacked
212
+ // list, so a lift would overlap its neighbours rather than read as elevation.
213
+ const rootClasses = cn(
214
+ "group relative -mx-2.5 flex w-full items-start gap-[11px] rounded-lg px-2.5 py-2 text-left",
215
+ "transition-colors duration-150 motion-reduce:transition-none",
216
+ (href || onClick) &&
217
+ "cursor-pointer hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
218
+ className
219
+ );
220
+
221
+ if (href) {
222
+ return (
223
+ <LinkComponent
224
+ href={href}
225
+ className={rootClasses}
226
+ onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
227
+ >
228
+ {body}
229
+ </LinkComponent>
230
+ );
231
+ }
232
+
233
+ if (onClick) {
234
+ return (
235
+ <button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
236
+ {body}
237
+ </button>
238
+ );
239
+ }
240
+
241
+ return <div className={rootClasses}>{body}</div>;
242
+ });
243
+
244
+ export type EventCompactRowSkeletonProps = {
245
+ /** Matches `EventCompactRowProps["size"]` — keep the poster and title sizes in step. */
246
+ size?: "sm" | "default";
247
+ className?: string;
248
+ };
249
+
250
+ /** Mirrors `<EventCompactRow>`'s geometry exactly so nothing shifts when data lands. */
251
+ export function EventCompactRowSkeleton({ size = "default", className }: EventCompactRowSkeletonProps) {
252
+ const isSm = size === "sm";
253
+
254
+ return (
255
+ <div
256
+ aria-hidden="true"
257
+ className={cn(
258
+ "relative -mx-2.5 flex w-full items-start gap-[11px] rounded-lg px-2.5 py-2 text-left",
259
+ className
260
+ )}
261
+ >
262
+ <Skeleton className="aspect-[4/5] w-16 shrink-0 rounded-[9px]" />
263
+
264
+ <div className="min-w-0 flex-1">
265
+ <Skeleton className={cn(isSm ? "h-[13.5px]" : "h-[13px]", "w-2/5")} />
266
+ <Skeleton className="mt-0.5 h-[11.5px] w-1/3" />
267
+ </div>
268
+ </div>
269
+ );
270
+ }
@@ -0,0 +1,124 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ArrowRight, PanelRightOpen } from "lucide-react";
3
+ import { EventDayHeader, EventDayHeaderSkeleton } from "./event-day-header";
4
+
5
+ const meta: Meta<typeof EventDayHeader> = {
6
+ title: "Events/EventDayHeader",
7
+ component: EventDayHeader,
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[358px]">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "The day divider in the timeline. Its meta line is one caller-composed string — the design's variants don't decompose into counts, and assembling them inside the DS would bake in English clause order.",
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof EventDayHeader>;
28
+
29
+ export const Today: Story = {
30
+ args: { dayNumber: "03", weekday: "Thursday", meta: "September · 15 events · 3 live" },
31
+ };
32
+
33
+ export const BusyDay: Story = {
34
+ args: { dayNumber: "05", weekday: "Saturday", meta: "September · 23 events" },
35
+ };
36
+
37
+ export const OneEvent: Story = {
38
+ args: { dayNumber: "08", weekday: "Tuesday", meta: "September · 1 event" },
39
+ };
40
+
41
+ /** A day with nothing on still gets a header — the empty state goes under it. */
42
+ export const NothingListed: Story = {
43
+ args: { dayNumber: "11", weekday: "Friday", meta: "September · nothing listed yet" },
44
+ };
45
+
46
+ /** Under an active filter the meta line reports the filtered count against the whole. */
47
+ export const Filtered: Story = {
48
+ args: { dayNumber: "05", weekday: "Saturday", meta: "September · 2 of 23 are free" },
49
+ };
50
+
51
+ /** Tappable days get a chevron; static ones don't. */
52
+ export const Tappable: Story = {
53
+ args: {
54
+ dayNumber: "06",
55
+ weekday: "Sunday",
56
+ meta: "September · 10 events",
57
+ onClick: () => {},
58
+ },
59
+ };
60
+
61
+ /** Romanian, to check the meta line and weekday don't assume English width. */
62
+ export const Romanian: Story = {
63
+ args: { dayNumber: "05", weekday: "Sâmbătă", meta: "Septembrie · 23 de evenimente · 3 live" },
64
+ };
65
+
66
+ /** Loading state — identical geometry to `Today`, so nothing shifts on data arrival. */
67
+ export const Loading: Story = {
68
+ render: () => <EventDayHeaderSkeleton />,
69
+ };
70
+
71
+ /** Reserves the chevron's space for a header that will end up tappable. */
72
+ export const LoadingInteractive: Story = {
73
+ render: () => <EventDayHeaderSkeleton interactive />,
74
+ };
75
+
76
+ /** Loading above loaded, so any geometry drift is visible at a glance. */
77
+ export const LoadingVsLoaded: Story = {
78
+ render: () => (
79
+ <div>
80
+ <EventDayHeaderSkeleton />
81
+ <EventDayHeader dayNumber="03" weekday="Thursday" meta="September · 15 events · 3 live" />
82
+ </div>
83
+ ),
84
+ };
85
+
86
+ /**
87
+ * Desktop scale: 58px day number, 18px weekday, and the "See the whole day"
88
+ * action chip the design puts in the trailing slot. The chip REPLACES the
89
+ * chevron — two affordances for one destination is the duplication this design
90
+ * set out to remove — and it renders outside the header's own link, so it is
91
+ * never an interactive element nested inside an anchor.
92
+ */
93
+ export const DesktopWithAction: Story = {
94
+ render: () => (
95
+ <div className="w-[860px]">
96
+ <EventDayHeader
97
+ size="default"
98
+ dayNumber="03"
99
+ weekday="Thursday"
100
+ meta="September · 15 events · 3 live right now"
101
+ href="/events/schedule?day=2026-09-03"
102
+ action={
103
+ <button
104
+ type="button"
105
+ className="inline-flex h-9 items-center gap-1.5 rounded-full border border-border bg-card px-4 text-sm font-semibold"
106
+ >
107
+ <PanelRightOpen className="h-3.5 w-3.5" aria-hidden />
108
+ See the whole day
109
+ <ArrowRight className="h-3.5 w-3.5" aria-hidden />
110
+ </button>
111
+ }
112
+ />
113
+ </div>
114
+ ),
115
+ };
116
+
117
+ /** Desktop scale with no action — a quiet day the reader cannot drill into. */
118
+ export const DesktopPlain: Story = {
119
+ render: () => (
120
+ <div className="w-[860px]">
121
+ <EventDayHeader size="default" dayNumber="11" weekday="Friday" meta="September · nothing listed yet" />
122
+ </div>
123
+ ),
124
+ };