@bearmenu/ui 0.8.7 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-62ES6N32.js +27 -0
  3. package/dist/chunk-AHL45DD2.js +145 -0
  4. package/dist/chunk-AXV5O2IZ.js +58 -0
  5. package/dist/chunk-IYDH5IU6.js +66 -0
  6. package/dist/chunk-OPVKL4VS.js +18 -0
  7. package/dist/chunk-Q6QH52LJ.js +38 -0
  8. package/dist/chunk-TH6W2PWG.js +213 -0
  9. package/dist/chunk-THOVPZNN.js +37 -0
  10. package/dist/chunk-YS6BHKZO.js +92 -0
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/button.d.ts +1 -1
  13. package/dist/components/category-deck.js +10 -1
  14. package/dist/components/command.d.ts +7 -7
  15. package/dist/components/concept-compare.d.ts +73 -0
  16. package/dist/components/concept-compare.js +162 -0
  17. package/dist/components/coverflow.d.ts +11 -3
  18. package/dist/components/coverflow.js +34 -51
  19. package/dist/components/date-input.d.ts +1 -1
  20. package/dist/components/drift-rail.d.ts +24 -10
  21. package/dist/components/drift-rail.js +1 -1
  22. package/dist/components/event-card.d.ts +8 -0
  23. package/dist/components/event-card.js +64 -34
  24. package/dist/components/event-compact-row.d.ts +33 -6
  25. package/dist/components/event-compact-row.js +120 -39
  26. package/dist/components/event-day-header.js +3 -1
  27. package/dist/components/event-day-separator.d.ts +7 -1
  28. package/dist/components/event-day-separator.js +3 -2
  29. package/dist/components/event-detail-panel.d.ts +171 -25
  30. package/dist/components/event-detail-panel.js +471 -174
  31. package/dist/components/event-hub-hero.d.ts +25 -4
  32. package/dist/components/event-hub-hero.js +89 -19
  33. package/dist/components/event-list-row-wide.js +1 -1
  34. package/dist/components/event-list-row.d.ts +19 -6
  35. package/dist/components/event-list-row.js +79 -35
  36. package/dist/components/hour-band.d.ts +38 -40
  37. package/dist/components/hour-band.js +55 -27
  38. package/dist/components/hour-group.js +1 -1
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +20 -0
  41. package/dist/components/menu-family-card.d.ts +59 -0
  42. package/dist/components/menu-family-card.js +121 -0
  43. package/dist/components/menu-field.d.ts +57 -0
  44. package/dist/components/menu-field.js +3 -0
  45. package/dist/components/menu-hub-hero.d.ts +65 -0
  46. package/dist/components/menu-hub-hero.js +103 -0
  47. package/dist/components/menu-intent-tiles.d.ts +31 -0
  48. package/dist/components/menu-intent-tiles.js +50 -0
  49. package/dist/components/menu-item.d.ts +127 -0
  50. package/dist/components/menu-item.js +5 -0
  51. package/dist/components/menu-mocks.d.ts +55 -0
  52. package/dist/components/menu-mocks.js +379 -0
  53. package/dist/components/menu-types.d.ts +245 -0
  54. package/dist/components/menu-types.js +1 -0
  55. package/dist/components/motion-icon-set.js +15 -3
  56. package/dist/components/open-now-list.d.ts +40 -0
  57. package/dist/components/open-now-list.js +69 -0
  58. package/dist/components/place-types.d.ts +112 -1
  59. package/dist/components/poster-tape.js +1 -1
  60. package/dist/components/poster-wall.d.ts +3 -3
  61. package/dist/components/poster-wall.js +73 -17
  62. package/dist/components/ranked-venue-list.d.ts +49 -0
  63. package/dist/components/ranked-venue-list.js +87 -0
  64. package/dist/components/section-header.d.ts +16 -1
  65. package/dist/components/section-header.js +26 -7
  66. package/dist/components/signature-chip.d.ts +37 -0
  67. package/dist/components/signature-chip.js +4 -0
  68. package/dist/components/signature-line-list.d.ts +45 -0
  69. package/dist/components/signature-line-list.js +99 -0
  70. package/dist/components/snap-rail.d.ts +54 -0
  71. package/dist/components/snap-rail.js +109 -0
  72. package/dist/components/spinner.d.ts +2 -2
  73. package/dist/components/stat-banner.d.ts +47 -0
  74. package/dist/components/stat-banner.js +61 -0
  75. package/dist/components/text.d.ts +3 -3
  76. package/dist/components/timeline-band.d.ts +20 -2
  77. package/dist/components/timeline-band.js +1 -1
  78. package/dist/components/timeline-playhead.d.ts +26 -0
  79. package/dist/components/timeline-playhead.js +25 -0
  80. package/dist/components/venue-busyness-strip.d.ts +35 -0
  81. package/dist/components/venue-busyness-strip.js +37 -0
  82. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  83. package/dist/components/venue-grade-dimensions.js +3 -0
  84. package/dist/components/venue-quotes.d.ts +32 -0
  85. package/dist/components/venue-quotes.js +3 -0
  86. package/dist/components/venue-signature-block.d.ts +39 -0
  87. package/dist/components/venue-signature-block.js +6 -0
  88. package/dist/components/venue-spotlight.d.ts +74 -0
  89. package/dist/components/venue-spotlight.js +138 -0
  90. package/dist/components/venue-wall.d.ts +41 -0
  91. package/dist/components/venue-wall.js +118 -0
  92. package/dist/components/weekday-theme-banner.d.ts +70 -0
  93. package/dist/components/weekday-theme-banner.js +127 -0
  94. package/dist/components/weekend-panel.d.ts +5 -4
  95. package/dist/components/weekend-panel.js +85 -71
  96. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  97. package/dist/hooks/use-drag-swipe.js +2 -0
  98. package/dist/hooks/use-media-query.d.ts +17 -0
  99. package/dist/hooks/use-media-query.js +2 -0
  100. package/package.json +1 -1
  101. package/src/components/category-deck.tsx +19 -8
  102. package/src/components/colour-dictionary.stories.tsx +227 -0
  103. package/src/components/concept-compare.stories.tsx +104 -0
  104. package/src/components/concept-compare.tsx +276 -0
  105. package/src/components/coverflow.test.tsx +28 -1
  106. package/src/components/coverflow.tsx +37 -43
  107. package/src/components/drift-rail.tsx +167 -42
  108. package/src/components/event-card.tsx +55 -10
  109. package/src/components/event-compact-row.stories.tsx +30 -0
  110. package/src/components/event-compact-row.test.tsx +49 -0
  111. package/src/components/event-compact-row.tsx +162 -38
  112. package/src/components/event-day-header.tsx +3 -1
  113. package/src/components/event-day-separator.tsx +8 -1
  114. package/src/components/event-detail-panel.stories.tsx +205 -97
  115. package/src/components/event-detail-panel.test.tsx +181 -0
  116. package/src/components/event-detail-panel.tsx +703 -185
  117. package/src/components/event-hub-hero.stories.tsx +30 -0
  118. package/src/components/event-hub-hero.tsx +102 -30
  119. package/src/components/event-list-row.stories.tsx +34 -0
  120. package/src/components/event-list-row.test.tsx +53 -0
  121. package/src/components/event-list-row.tsx +135 -87
  122. package/src/components/hour-band.stories.tsx +28 -5
  123. package/src/components/hour-band.tsx +90 -70
  124. package/src/components/hub-surface.stories.tsx +68 -0
  125. package/src/components/hub-surface.tsx +40 -0
  126. package/src/components/menu-family-card.stories.tsx +112 -0
  127. package/src/components/menu-family-card.tsx +211 -0
  128. package/src/components/menu-field.stories.tsx +74 -0
  129. package/src/components/menu-field.tsx +199 -0
  130. package/src/components/menu-hub-hero.stories.tsx +92 -0
  131. package/src/components/menu-hub-hero.tsx +187 -0
  132. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  133. package/src/components/menu-intent-tiles.tsx +76 -0
  134. package/src/components/menu-item.tsx +416 -0
  135. package/src/components/menu-mocks.ts +417 -0
  136. package/src/components/menu-types.ts +259 -0
  137. package/src/components/motion-icon-set.tsx +30 -3
  138. package/src/components/open-now-list.stories.tsx +76 -0
  139. package/src/components/open-now-list.tsx +127 -0
  140. package/src/components/place-types.ts +112 -0
  141. package/src/components/poster-wall.test.tsx +28 -0
  142. package/src/components/poster-wall.tsx +96 -23
  143. package/src/components/ranked-venue-list.stories.tsx +80 -0
  144. package/src/components/ranked-venue-list.tsx +177 -0
  145. package/src/components/section-header.stories.tsx +24 -0
  146. package/src/components/section-header.test.tsx +28 -0
  147. package/src/components/section-header.tsx +45 -7
  148. package/src/components/signature-chip.tsx +77 -0
  149. package/src/components/signature-line-list.stories.tsx +85 -0
  150. package/src/components/signature-line-list.tsx +136 -0
  151. package/src/components/snap-rail.stories.tsx +79 -0
  152. package/src/components/snap-rail.test.tsx +18 -0
  153. package/src/components/snap-rail.tsx +148 -0
  154. package/src/components/stat-banner.stories.tsx +100 -0
  155. package/src/components/stat-banner.tsx +117 -0
  156. package/src/components/timeline-band.tsx +52 -12
  157. package/src/components/timeline-playhead.stories.tsx +30 -0
  158. package/src/components/timeline-playhead.test.tsx +11 -0
  159. package/src/components/timeline-playhead.tsx +46 -0
  160. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  161. package/src/components/venue-busyness-strip.tsx +64 -0
  162. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  163. package/src/components/venue-grade-dimensions.tsx +91 -0
  164. package/src/components/venue-quotes.stories.tsx +59 -0
  165. package/src/components/venue-quotes.tsx +64 -0
  166. package/src/components/venue-signature-block.stories.tsx +68 -0
  167. package/src/components/venue-signature-block.tsx +71 -0
  168. package/src/components/venue-spotlight.stories.tsx +141 -0
  169. package/src/components/venue-spotlight.tsx +236 -0
  170. package/src/components/venue-wall.stories.tsx +78 -0
  171. package/src/components/venue-wall.tsx +185 -0
  172. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  173. package/src/components/weekday-theme-banner.tsx +231 -0
  174. package/src/components/weekend-panel.test.tsx +43 -0
  175. package/src/components/weekend-panel.tsx +142 -108
  176. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  177. package/src/hooks/use-drag-swipe.ts +105 -0
  178. package/src/hooks/use-media-query.ts +31 -0
  179. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -1,81 +1,127 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { ExternalLink, Navigation, Phone, Share2, X } from "lucide-react";
3
2
  import {
3
+ ExternalLink,
4
+ Navigation,
5
+ Phone,
6
+ Share2,
7
+ X,
8
+ CalendarClock,
9
+ } from "lucide-react";
10
+ import { Button } from "./button";
11
+ import {
12
+ EventDetailFooter,
13
+ EventDetailGhostAction,
4
14
  EventDetailPanel,
15
+ EventDetailPanelError,
5
16
  EventDetailPanelSkeleton,
6
17
  type EventDetailPanelLabels,
7
18
  } from "./event-detail-panel";
8
19
  import type { EventFormatters, EventPreview } from "./event-types";
9
20
 
10
21
  const labels: EventDetailPanelLabels = {
11
- free: "Free",
12
- liveNow: "Live now",
22
+ free: "Free entry",
23
+ liveNow: "Now",
24
+ ended: "Ended",
13
25
  venueHeading: "Event venue",
26
+ venueNoPage: "name only — no venue page yet",
27
+ lineupHeading: "Line-up",
14
28
  aboutHeading: "About this event",
29
+ noDescription: "Event details coming soon.",
30
+ readMore: "Read more",
31
+ readLess: "Read less",
15
32
  };
16
33
 
17
34
  const formatters: EventFormatters = {
18
- time: () => "20:00",
19
- day: () => "Sat, Sep 5",
35
+ time: () => "17:30",
36
+ day: () => "Tue, Sep 8",
20
37
  price: (amount) => `${amount} RON`,
21
38
  };
22
39
 
40
+ const description =
41
+ "An event for future brides featuring the new Rosa Clara 2027 collection and wedding planning advice from industry specialists. Doors at 17:00. The first look runs about an hour, then the atelier stays open for fittings until half seven. Bubbles throughout, and the kitchen puts out canapés at six.";
42
+
23
43
  const event: EventPreview = {
24
44
  id: "e1",
25
- title: "Psihotrop Live",
26
- description:
27
- "A full live set spanning the band's whole catalogue, with support from Mara Ionescu. Doors at 19:30.",
28
- location: "Atelier Café",
29
- place_name: "Atelier Café",
30
- starts_at: "2026-09-05T20:00:00",
45
+ title: "Brides & Bubbles at Sophie Wedding House",
46
+ description,
47
+ location: "Sophie Wedding House",
48
+ place_name: "Sophie Wedding House",
49
+ place_slug: "sophie-wedding-house",
50
+ starts_at: "2026-09-08T17:30:00",
31
51
  entry_fee: 50,
52
+ hype_score: 72,
32
53
  };
33
54
 
55
+ const performers = [
56
+ "Rosa Clara",
57
+ "Sophie Atelier",
58
+ "Mara Ionescu",
59
+ "DJ Vlad",
60
+ "Trio Boema",
61
+ "Ana Pop",
62
+ "Mihai Ionescu",
63
+ "Quartetto",
64
+ ];
65
+
34
66
  const venue = {
35
- name: "Atelier Café",
36
- meta: "Str. Iuliu Maniu 5 · open until 01:00",
67
+ name: "Sophie Wedding House",
68
+ address: "Str. Iuliu Maniu 5",
37
69
  ratingLabel: "4.7",
38
- href: "/places/atelier-cafe",
70
+ isOpenNow: true,
71
+ href: "/places/sophie-wedding-house",
39
72
  };
40
73
 
41
- const roundBtn =
42
- "flex h-9 w-9 items-center justify-center rounded-full bg-white/90 text-foreground";
74
+ const overlayButton =
75
+ "flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm";
43
76
 
44
- const headerActions = (
77
+ const sheetActions = (
45
78
  <>
46
- <button type="button" aria-label="Share" className={`absolute left-3.5 top-3.5 ${roundBtn}`}>
79
+ <button type="button" aria-label="Share" className={overlayButton}>
47
80
  <Share2 className="h-4 w-4" aria-hidden />
48
81
  </button>
49
- <button type="button" aria-label="Close" className={`absolute right-3.5 top-3.5 ${roundBtn}`}>
50
- <X className="h-[17px] w-[17px]" aria-hidden />
51
- </button>
52
- </>
53
- );
54
-
55
- const footer = (
56
- <>
57
- <button
58
- type="button"
59
- className="flex h-[46px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary text-sm font-bold text-primary-foreground"
60
- >
61
- <Phone className="h-4 w-4" aria-hidden />
62
- Reserve a spot
63
- </button>
64
82
  <button
65
83
  type="button"
66
- className="flex h-[46px] items-center gap-2 rounded-xl border border-border px-4 text-sm font-bold"
84
+ aria-label="Close"
85
+ className={`ml-auto ${overlayButton}`}
67
86
  >
68
- <Navigation className="h-4 w-4" aria-hidden />
69
- Directions
87
+ <X className="h-[17px] w-[17px]" aria-hidden />
70
88
  </button>
71
89
  </>
72
90
  );
73
91
 
74
- const externalLink = (
75
- <a href="#" className="flex items-center gap-1.5 text-[13.5px] font-bold text-primary">
76
- <ExternalLink className="h-3.5 w-3.5" aria-hidden />
77
- View on Instagram
78
- </a>
92
+ const hypePill = (
93
+ <span className="inline-flex h-6 items-center gap-1 rounded-full bg-[image:var(--gradient-primary)] px-2.5 text-[11.5px] font-bold text-primary-foreground">
94
+ Popular
95
+ </span>
96
+ );
97
+
98
+ const footer = (
99
+ <EventDetailFooter
100
+ ghosts={[
101
+ <EventDetailGhostAction
102
+ key="dir"
103
+ icon={<Navigation className="h-3.5 w-3.5" aria-hidden />}
104
+ href="https://maps.google.com"
105
+ target="_blank"
106
+ rel="noopener noreferrer"
107
+ >
108
+ Directions
109
+ </EventDetailGhostAction>,
110
+ <EventDetailGhostAction
111
+ key="page"
112
+ icon={<ExternalLink className="h-3.5 w-3.5" aria-hidden />}
113
+ href="/events/e/brides-and-bubbles"
114
+ >
115
+ Event page
116
+ </EventDetailGhostAction>,
117
+ ]}
118
+ primary={
119
+ <Button fullWidth>
120
+ <Phone aria-hidden />
121
+ Get tickets · 50 RON
122
+ </Button>
123
+ }
124
+ />
79
125
  );
80
126
 
81
127
  const meta: Meta<typeof EventDetailPanel> = {
@@ -86,18 +132,20 @@ const meta: Meta<typeof EventDetailPanel> = {
86
132
  event,
87
133
  labels,
88
134
  formatters,
89
- dateLine: "Saturday, September 5 · Starts at 20:00",
135
+ dateLine: "Tuesday, September 8 · Starts at 17:30",
90
136
  venue,
91
- tags: ["Live music"],
92
- headerActions,
137
+ tags: ["Workshop", "Tasting"],
138
+ performers,
139
+ showAllPerformersLabel: "Show all 8 performers",
140
+ hype: hypePill,
141
+ headerActions: sheetActions,
93
142
  footer,
94
- externalLink,
95
143
  },
96
144
  parameters: {
97
145
  docs: {
98
146
  description: {
99
147
  component:
100
- "The BODY of the detail sheet, not a sheet itself the DS already ships `Sheet` and `Drawer`, and the app mounts this inside the side sheet on desktop and the bottom sheet on mobile. This is the terminal surface for acting on an event, which is why the cards that open it carry no actions of their own.",
148
+ "The body of the event detail sheet and the base the event page extends. Not a sheet: mount it in `Sheet` (desktop) or `Drawer` (mobile). The hero is capped per host so the venue card clears the fold; status, fee, hype and tags share one chip row; the footer is one primary over a ghost row.",
101
149
  },
102
150
  },
103
151
  },
@@ -106,86 +154,146 @@ const meta: Meta<typeof EventDetailPanel> = {
106
154
  export default meta;
107
155
  type Story = StoryObj<typeof EventDetailPanel>;
108
156
 
109
- /** Desktop side sheet 460px wide, 4:5 poster. */
157
+ /** The desktop side sheet: 480px wide, hero capped at 300px. */
110
158
  export const SideSheet: Story = {
111
159
  decorators: [
112
160
  (Story) => (
113
- <div className="h-[820px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
161
+ <div className="h-[820px] w-[480px] overflow-hidden rounded-2xl shadow-[var(--shadow-overlay)]">
114
162
  <Story />
115
163
  </div>
116
164
  ),
117
165
  ],
118
166
  };
119
167
 
120
- /** Mobile bottom sheet compact size, same 4:5 poster, tighter type. The hero scrolls away with the body; only the footer stays put. */
121
- export const BottomSheet: Story = {
122
- args: { size: "compact" },
168
+ /** The phone drawer: `compact` caps the hero at 318px. The host draws the handle and a Back pill. */
169
+ export const PhoneDrawer: Story = {
170
+ args: {
171
+ size: "compact",
172
+ headerActions: (
173
+ <>
174
+ <span className="inline-flex h-9 items-center gap-1.5 rounded-full bg-black/40 pl-2.5 pr-3 text-[13px] font-semibold text-white backdrop-blur-sm">
175
+ Back
176
+ </span>
177
+ <button
178
+ type="button"
179
+ aria-label="Share"
180
+ className={`ml-auto ${overlayButton}`}
181
+ >
182
+ <Share2 className="h-4 w-4" aria-hidden />
183
+ </button>
184
+ </>
185
+ ),
186
+ },
123
187
  decorators: [
124
188
  (Story) => (
125
- <div className="h-[720px] w-full max-w-[390px] overflow-hidden rounded-t-[20px] shadow-2xl">
126
- <div className="mx-auto mt-2.5 mb-1 h-1 w-[38px] rounded-full bg-border" />
189
+ <div className="h-[720px] w-[390px] overflow-hidden rounded-t-[20px] shadow-[var(--shadow-overlay)]">
127
190
  <Story />
128
191
  </div>
129
192
  ),
130
193
  ],
131
194
  };
132
195
 
133
- /** Live events get the on-air chip beside the category and price. */
134
- export const LiveNow: Story = {
135
- args: { isLive: true },
136
- decorators: [
137
- (Story) => (
138
- <div className="h-[820px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
139
- <Story />
140
- </div>
141
- ),
142
- ],
196
+ /** Live, free, no poster: the gradient fallback, the Now chip and the free chip. */
197
+ export const LiveFreeNoPoster: Story = {
198
+ args: {
199
+ event: { ...event, poster_url: undefined, entry_fee: 0 },
200
+ status: "live",
201
+ },
202
+ decorators: SideSheet.decorators,
143
203
  };
144
204
 
145
- /** A free event: `entry_fee: 0` renders the free chip instead of a price. */
146
- export const FreeEntry: Story = {
147
- args: { event: { ...event, entry_fee: 0 } },
148
- decorators: [
149
- (Story) => (
150
- <div className="h-[820px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
151
- <Story />
152
- </div>
153
- ),
154
- ],
205
+ /** Starting soon: the accent countdown chip. */
206
+ export const Soon: Story = {
207
+ args: { status: "soon", soonLabel: "Starts in 2h 10m" },
208
+ decorators: SideSheet.decorators,
155
209
  };
156
210
 
157
- /** No description and no venue — the sections drop out rather than render empty headings. */
211
+ /**
212
+ * Unknown hour, no booking, a venue with no page, no line-up, no description.
213
+ * Every optional region drops out; the venue card says why it is not a link.
214
+ */
158
215
  export const Sparse: Story = {
159
216
  args: {
160
- event: { ...event, description: undefined, entry_fee: undefined },
161
- venue: undefined,
162
- tags: undefined,
163
- externalLink: undefined,
217
+ event: { ...event, description: "", hype_score: 0 },
218
+ dateLine: "Tuesday, September 8 · time to be announced",
219
+ venue: { name: "Sophie Wedding House" },
220
+ performers: undefined,
221
+ hype: undefined,
222
+ footer: (
223
+ <EventDetailFooter
224
+ ghosts={[
225
+ <EventDetailGhostAction
226
+ key="page"
227
+ icon={<ExternalLink className="h-3.5 w-3.5" aria-hidden />}
228
+ href="/events/e/x"
229
+ >
230
+ See the event page
231
+ </EventDetailGhostAction>,
232
+ ]}
233
+ primary={
234
+ <Button fullWidth>
235
+ <Navigation aria-hidden />
236
+ Directions
237
+ </Button>
238
+ }
239
+ />
240
+ ),
164
241
  },
165
- decorators: [
166
- (Story) => (
167
- <div className="h-[560px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
168
- <Story />
169
- </div>
242
+ decorators: SideSheet.decorators,
243
+ };
244
+
245
+ /** Ended: poster desaturated, title muted, explicit chip, and the primary replaced rather than disabled. */
246
+ export const Ended: Story = {
247
+ args: {
248
+ status: "ended",
249
+ footer: (
250
+ <EventDetailFooter
251
+ ghosts={[
252
+ <EventDetailGhostAction
253
+ key="dir"
254
+ icon={<Navigation className="h-3.5 w-3.5" aria-hidden />}
255
+ href="https://maps.google.com"
256
+ target="_blank"
257
+ rel="noopener noreferrer"
258
+ >
259
+ Directions
260
+ </EventDetailGhostAction>,
261
+ <EventDetailGhostAction
262
+ key="page"
263
+ icon={<ExternalLink className="h-3.5 w-3.5" aria-hidden />}
264
+ href="/events/e/x"
265
+ >
266
+ Event page
267
+ </EventDetailGhostAction>,
268
+ ]}
269
+ primary={
270
+ <Button fullWidth>
271
+ <CalendarClock aria-hidden />
272
+ See what's on tonight
273
+ </Button>
274
+ }
275
+ />
170
276
  ),
171
- ],
277
+ },
278
+ decorators: SideSheet.decorators,
172
279
  };
173
280
 
174
- /** Desktop side sheet loading state. */
175
- export const Skeleton: Story = {
176
- render: () => (
177
- <div className="h-[820px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
178
- <EventDetailPanelSkeleton />
179
- </div>
180
- ),
281
+ export const Loading: Story = {
282
+ render: () => <EventDetailPanelSkeleton headerActions={sheetActions} />,
283
+ decorators: SideSheet.decorators,
181
284
  };
182
285
 
183
- /** Mobile bottom sheet loading state — compact size, same 4:5 poster. */
184
- export const SkeletonCompact: Story = {
286
+ export const Error: Story = {
185
287
  render: () => (
186
- <div className="h-[720px] w-full max-w-[390px] overflow-hidden rounded-t-[20px] shadow-2xl">
187
- <div className="mx-auto mt-2.5 mb-1 h-1 w-[38px] rounded-full bg-border" />
188
- <EventDetailPanelSkeleton size="compact" />
189
- </div>
288
+ <EventDetailPanelError
289
+ title="We could not load this event"
290
+ description="It may have been taken down, or the connection dropped on the way. The rest of tonight is still there."
291
+ retryLabel="Try again"
292
+ onRetry={() => {}}
293
+ secondaryLabel="See what's on tonight"
294
+ secondaryHref="/events/schedule"
295
+ headerActions={sheetActions}
296
+ />
190
297
  ),
298
+ decorators: SideSheet.decorators,
191
299
  };
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import {
4
+ EventDetailFooter,
5
+ EventDetailGhostAction,
6
+ EventDetailPanel,
7
+ EventDetailPanelError,
8
+ type EventDetailPanelLabels,
9
+ } from "./event-detail-panel";
10
+ import type { EventFormatters, EventPreview } from "./event-types";
11
+
12
+ const labels: EventDetailPanelLabels = {
13
+ free: "Free entry",
14
+ liveNow: "Now",
15
+ ended: "Ended",
16
+ venueHeading: "Event venue",
17
+ venueNoPage: "name only — no venue page yet",
18
+ lineupHeading: "Line-up",
19
+ aboutHeading: "About this event",
20
+ noDescription: "Event details coming soon.",
21
+ readMore: "Read more",
22
+ readLess: "Read less",
23
+ };
24
+ const formatters: EventFormatters = {
25
+ time: () => "17:30",
26
+ day: () => "Tue",
27
+ price: (n) => `${n} RON`,
28
+ };
29
+ const event: EventPreview = {
30
+ id: "e1",
31
+ title: "Brides & Bubbles",
32
+ description: "",
33
+ location: "Sophie Wedding House",
34
+ starts_at: "2026-09-08T17:30:00",
35
+ entry_fee: 50,
36
+ };
37
+ const base = { event, labels, formatters, dateLine: "Tuesday · 17:30" };
38
+
39
+ describe("EventDetailPanel", () => {
40
+ it("shows the status chip for live, soon and ended, and none when upcoming", () => {
41
+ const { rerender } = render(<EventDetailPanel {...base} />);
42
+ expect(screen.queryByText("Now")).not.toBeInTheDocument();
43
+ rerender(<EventDetailPanel {...base} status="live" />);
44
+ expect(screen.getByText("Now")).toBeInTheDocument();
45
+ rerender(
46
+ <EventDetailPanel {...base} status="soon" soonLabel="Starts in 2h" />,
47
+ );
48
+ expect(screen.getByText("Starts in 2h")).toBeInTheDocument();
49
+ rerender(<EventDetailPanel {...base} status="ended" />);
50
+ expect(screen.getByText("Ended")).toBeInTheDocument();
51
+ });
52
+
53
+ it("renders the fee as a chip: paid amount, free label, nothing when unknown", () => {
54
+ const { rerender } = render(<EventDetailPanel {...base} />);
55
+ expect(screen.getByText("50 RON")).toBeInTheDocument();
56
+ rerender(<EventDetailPanel {...base} event={{ ...event, entry_fee: 0 }} />);
57
+ expect(screen.getByText("Free entry")).toBeInTheDocument();
58
+ rerender(
59
+ <EventDetailPanel {...base} event={{ ...event, entry_fee: undefined }} />,
60
+ );
61
+ expect(screen.queryByText(/RON|Free entry/)).not.toBeInTheDocument();
62
+ });
63
+
64
+ it("links the venue card only when the venue has a page, and says so otherwise", () => {
65
+ const { rerender } = render(
66
+ <EventDetailPanel
67
+ {...base}
68
+ venue={{
69
+ name: "Sophie",
70
+ address: "Str. 5",
71
+ ratingLabel: "4.7",
72
+ href: "/places/sophie",
73
+ }}
74
+ />,
75
+ );
76
+ expect(screen.getByRole("link", { name: /Sophie/ })).toHaveAttribute(
77
+ "href",
78
+ "/places/sophie",
79
+ );
80
+ expect(screen.queryByText(labels.venueNoPage)).not.toBeInTheDocument();
81
+
82
+ rerender(<EventDetailPanel {...base} venue={{ name: "Sophie" }} />);
83
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
84
+ expect(screen.getByText(labels.venueNoPage)).toBeInTheDocument();
85
+ });
86
+
87
+ it("previews five performers and reveals the rest on request", () => {
88
+ const performers = ["A", "B", "C", "D", "E", "F", "G"];
89
+ render(
90
+ <EventDetailPanel
91
+ {...base}
92
+ performers={performers}
93
+ showAllPerformersLabel="Show all 7"
94
+ />,
95
+ );
96
+ expect(screen.queryByText("F")).not.toBeInTheDocument();
97
+ fireEvent.click(screen.getByRole("button", { name: "Show all 7" }));
98
+ expect(screen.getByText("F")).toBeInTheDocument();
99
+ expect(
100
+ screen.queryByRole("button", { name: "Show all 7" }),
101
+ ).not.toBeInTheDocument();
102
+ });
103
+
104
+ it("clamps a long description behind Read more and prints the placeholder for none", () => {
105
+ const long = "x".repeat(300);
106
+ const { rerender } = render(
107
+ <EventDetailPanel {...base} event={{ ...event, description: long }} />,
108
+ );
109
+ expect(screen.getByText(long).className).toContain("line-clamp-4");
110
+ fireEvent.click(screen.getByRole("button", { name: "Read more" }));
111
+ expect(screen.getByText(long).className).not.toContain("line-clamp-4");
112
+ expect(
113
+ screen.getByRole("button", { name: "Read less" }),
114
+ ).toBeInTheDocument();
115
+
116
+ rerender(<EventDetailPanel {...base} />);
117
+ expect(screen.getByText("Event details coming soon.")).toBeInTheDocument();
118
+ });
119
+
120
+ it("dims the poster and mutes the title when ended, not the whole panel", () => {
121
+ render(
122
+ <EventDetailPanel
123
+ {...base}
124
+ status="ended"
125
+ event={{ ...event, poster_url: "p.jpg" }}
126
+ />,
127
+ );
128
+ expect(screen.getByRole("img").parentElement?.className).toContain(
129
+ "saturate-",
130
+ );
131
+ expect(
132
+ screen.getByRole("heading", { name: "Brides & Bubbles" }).className,
133
+ ).toContain("text-muted-foreground");
134
+ });
135
+ });
136
+
137
+ describe("EventDetailFooter", () => {
138
+ it("lays out the ghosts in one row above the primary", () => {
139
+ render(
140
+ <EventDetailFooter
141
+ ghosts={[
142
+ <EventDetailGhostAction key="a" href="/a">
143
+ Directions
144
+ </EventDetailGhostAction>,
145
+ <EventDetailGhostAction key="b" href="/b">
146
+ Event page
147
+ </EventDetailGhostAction>,
148
+ ]}
149
+ primary={<button type="button">Reserve</button>}
150
+ />,
151
+ );
152
+ expect(
153
+ screen.getByRole("link", { name: "Directions" }),
154
+ ).toBeInTheDocument();
155
+ expect(
156
+ screen.getByRole("link", { name: "Event page" }),
157
+ ).toBeInTheDocument();
158
+ expect(screen.getByRole("button", { name: "Reserve" })).toBeInTheDocument();
159
+ });
160
+ });
161
+
162
+ describe("EventDetailPanelError", () => {
163
+ it("offers a retry and a way onward", () => {
164
+ const onRetry = vi.fn();
165
+ render(
166
+ <EventDetailPanelError
167
+ title="We could not load this event"
168
+ description="Try again."
169
+ retryLabel="Try again"
170
+ onRetry={onRetry}
171
+ secondaryLabel="See what's on tonight"
172
+ secondaryHref="/events/schedule"
173
+ />,
174
+ );
175
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
176
+ expect(onRetry).toHaveBeenCalled();
177
+ expect(
178
+ screen.getByRole("link", { name: "See what's on tonight" }),
179
+ ).toHaveAttribute("href", "/events/schedule");
180
+ });
181
+ });