@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,8 +1,21 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { Calendar, ChevronRight, Radio, Star } from "lucide-react";
4
+ import {
5
+ Calendar,
6
+ CalendarX,
7
+ ChevronRight,
8
+ Gift,
9
+ MapPin,
10
+ RefreshCw,
11
+ Star,
12
+ Ticket,
13
+ Timer,
14
+ Unplug,
15
+ User,
16
+ } from "lucide-react";
5
17
  import { cn } from "../lib/utils";
18
+ import { Button } from "./button";
6
19
  import { EventPosterFallback } from "./event-poster-fallback";
7
20
  import { Separator } from "./separator";
8
21
  import { Skeleton } from "./skeleton";
@@ -13,48 +26,93 @@ import {
13
26
  } from "./event-types";
14
27
 
15
28
  /**
16
- * EventDetailPanel — the BODY of the event detail sheet.
29
+ * EventDetailPanel — the BODY of the event detail sheet, and the body the
30
+ * event PAGE is a superset of.
17
31
  *
18
32
  * Deliberately not a sheet. The design opens this as a side sheet over a dimmed
19
- * page on desktop and a bottom sheet on mobile, and the DS already ships `Sheet`
20
- * and `Drawer`; owning sheet mechanics a third time here would fork them. The
21
- * consuming app mounts this inside whichever container the breakpoint calls for.
33
+ * page on desktop and a bottom drawer on mobile, and the DS already ships
34
+ * `Sheet` and `Drawer`; owning sheet mechanics a third time here would fork
35
+ * them. The consuming app mounts this inside whichever container the
36
+ * breakpoint calls for, and passes the container's own controls (close, back,
37
+ * share) as `headerActions`, positioned over the poster by this component.
38
+ *
39
+ * ONE BODY, TWO HOSTS. The same regions in the same order everywhere: poster,
40
+ * title, when-line, one chip row, venue, line-up, about, external link, then a
41
+ * footer of one primary action over a row of ghost actions. The page renders
42
+ * these regions itself with more room; nothing here is invented twice.
22
43
  *
23
- * This is the terminal surface for acting on an event, which is why the cards
24
- * that open it carry no actions of their own the footer is where booking and
25
- * directions live.
44
+ * THE HERO IS CAPPED, NOT A RATIO. 91% of posters are 4:5 flyers, and at 4:5 a
45
+ * hero took 68% of a side sheet and cut the venue card mid-row before any
46
+ * scrolling — the one thing this surface exists to answer. So `heroHeight`
47
+ * caps it: 300px on the 480px side sheet, 318px on the phone drawer, chosen so
48
+ * the venue card clears the fold even when status, fee, hype and two tags wrap
49
+ * the chip row onto a second line. It crops more than a 4:3 would, and that is
50
+ * the trade; the full flyer is one tap away on the page.
26
51
  *
27
- * Everything is injected: strings via `labels`, times via `formatters`, and the
28
- * close/share affordances as `headerActions` so the sheet container owns
29
- * dismissal (bearmenu sheets close by drag handle, not by back navigation).
52
+ * STATUS IS A CHIP, IN THE INFO BLOCK. Cards put pills on the poster because a
53
+ * card has nowhere else; a detail surface does, and a portrait flyer is busy
54
+ * artwork already. `live` and the free fee are `--success`; `soon` is the
55
+ * accent — success is reserved for live and free, and a third meaning on the
56
+ * same colour would flatten both. `ended` desaturates and dims the POSTER
57
+ * (never the whole surface, which drops text below AA), mutes the title and
58
+ * adds an explicit chip; the host replaces the primary action rather than
59
+ * disabling it.
60
+ *
61
+ * Everything is injected: strings via `labels`, times via `formatters`, the
62
+ * date line and the soon label composed by the caller (clause order is
63
+ * locale-dependent and "is this live" depends on the app's clock, not the
64
+ * DS's).
30
65
  */
31
66
 
32
67
  export type EventDetailPanelLinkProps = {
33
68
  href: string;
34
69
  className?: string;
35
70
  children: React.ReactNode;
71
+ onClick?: (mouseEvent: React.MouseEvent) => void;
36
72
  };
37
73
 
38
74
  function DefaultLink({ href, ...props }: EventDetailPanelLinkProps) {
39
75
  return <a href={href} {...props} />;
40
76
  }
41
77
 
78
+ export type EventDetailStatus = "upcoming" | "soon" | "live" | "ended";
79
+
42
80
  export type EventDetailVenue = {
43
81
  name: string;
44
- /** Address plus opening line, composed by the caller. */
45
- meta?: string;
82
+ /** Address line, e.g. "Str. Iuliu Maniu 5". */
83
+ address?: string;
84
+ /** e.g. "4.7" — rendered with a star. */
46
85
  ratingLabel?: string;
86
+ /** e.g. "open until 20:00" — appended to the meta row when given. */
87
+ openLabel?: string;
88
+ /** Lights the green dot on the thumbnail. */
89
+ isOpenNow?: boolean;
47
90
  thumbnailUrl?: string;
91
+ /**
92
+ * The venue's own page. Without it the card is not a link and shows
93
+ * `labels.venueNoPage` beneath the name — a venue the ingest knows by name
94
+ * only, which is common and must not read as a broken link.
95
+ */
48
96
  href?: string;
49
97
  };
50
98
 
51
99
  export type EventDetailPanelLabels = {
52
100
  free: string;
53
101
  liveNow: string;
102
+ /** The ended chip, e.g. "Ended". */
103
+ ended: string;
54
104
  /** Section heading above the venue card. */
55
105
  venueHeading: string;
106
+ /** Under the venue name when the venue has no page, e.g. "name only — no venue page yet". */
107
+ venueNoPage: string;
108
+ /** Section heading above the performers. */
109
+ lineupHeading: string;
56
110
  /** Section heading above the description. */
57
111
  aboutHeading: string;
112
+ /** Italic placeholder when there is no description, e.g. "Event details coming soon." */
113
+ noDescription: string;
114
+ readMore: string;
115
+ readLess: string;
58
116
  };
59
117
 
60
118
  export type EventDetailPanelProps = {
@@ -63,21 +121,38 @@ export type EventDetailPanelProps = {
63
121
  formatters: EventFormatters;
64
122
  /**
65
123
  * The date line, composed by the caller — the design reads
66
- * "Saturday, September 5 · Starts at 20:00", whose clause order and
67
- * preposition are locale-dependent.
124
+ * "Tuesday, September 8 · Starts at 17:30", or time to be announced" when
125
+ * the hour is unknown. Clause order and preposition are locale-dependent.
68
126
  */
69
127
  dateLine: string;
128
+ /** The start as an ISO string; when given the date line is a `<time dateTime>`. */
129
+ dateTime?: string;
130
+ status?: EventDetailStatus;
131
+ /** The soon chip's text, composed by the caller — "Starts in 2h 10m". Required to render it. */
132
+ soonLabel?: string;
133
+ /** The hype tier, rendered as given (the app's own badge) in the chip row. */
134
+ hype?: React.ReactNode;
70
135
  venue?: EventDetailVenue;
71
- /** Category chips beside the live/price chips. */
136
+ /** Activity tags, after status, fee and hype. */
72
137
  tags?: string[];
73
- isLive?: boolean;
74
- /** Close and share controls, positioned over the poster by this component. */
138
+ /** Performer names, in the order the API gave them. */
139
+ performers?: string[];
140
+ /** How many performers to show before the "show all" chip. Default 5. */
141
+ performersPreview?: number;
142
+ /** The "show all" chip's text, e.g. "Show all 8 performers". Required to render it. */
143
+ showAllPerformersLabel?: string;
144
+ /** Close, back and share controls, positioned over the poster by this component. */
75
145
  headerActions?: React.ReactNode;
76
- /** Footer actionsthe booking CTA and directions. Rendered in a sticky footer. */
146
+ /** The footerusually an `EventDetailFooter`. Rendered below the scroll region. */
77
147
  footer?: React.ReactNode;
78
148
  /** An external link row, e.g. "View on Instagram". */
79
149
  externalLink?: React.ReactNode;
80
- /** `compact` is the mobile bottom sheet: tighter type and padding, same 4:5 poster. */
150
+ /**
151
+ * Poster height cap in px, or `"aspect"` for the poster's own 4:5. Defaults
152
+ * to 318 for `compact` (the phone drawer) and 300 otherwise (the side sheet).
153
+ */
154
+ heroHeight?: number | "aspect";
155
+ /** `compact` is the phone drawer: the shorter default hero cap. */
81
156
  size?: "compact" | "default";
82
157
  linkComponent?: React.ComponentType<EventDetailPanelLinkProps>;
83
158
  className?: string;
@@ -85,69 +160,76 @@ export type EventDetailPanelProps = {
85
160
  fallback?: React.ReactNode;
86
161
  };
87
162
 
163
+ /** Description length past which the panel clamps to four lines with Read more. */
164
+ const DESCRIPTION_CLAMP_CHARS = 220;
165
+
88
166
  const CHIP =
89
- "inline-flex items-center gap-1 rounded-full border border-border px-2.5 text-xs";
167
+ "inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-full border border-border bg-card px-2.5 text-[11.5px] font-semibold text-muted-foreground";
168
+
169
+ const SECTION_HEADING =
170
+ "text-[13px] font-extrabold tracking-[-0.01em] text-foreground";
171
+
172
+ function heroHeightStyle(
173
+ heroHeight: number | "aspect",
174
+ ): React.CSSProperties | undefined {
175
+ return heroHeight === "aspect" ? undefined : { height: heroHeight };
176
+ }
90
177
 
91
178
  export function EventDetailPanel({
92
179
  event,
93
180
  labels,
94
181
  formatters,
95
182
  dateLine,
183
+ dateTime,
184
+ status = "upcoming",
185
+ soonLabel,
186
+ hype,
96
187
  venue,
97
188
  tags,
98
- isLive,
189
+ performers,
190
+ performersPreview = 5,
191
+ showAllPerformersLabel,
99
192
  headerActions,
100
193
  footer,
101
194
  externalLink,
195
+ heroHeight,
102
196
  size = "default",
103
197
  linkComponent: LinkComponent = DefaultLink,
104
198
  className,
105
199
  fallback = <EventPosterFallback />,
106
200
  }: EventDetailPanelProps) {
107
201
  const compact = size === "compact";
202
+ const cap = heroHeight ?? (compact ? 318 : 300);
108
203
  const fee = resolveEventEntryFee(event);
204
+ const isEnded = status === "ended";
205
+ const [descriptionExpanded, setDescriptionExpanded] = React.useState(false);
206
+ const [performersExpanded, setPerformersExpanded] = React.useState(false);
109
207
 
110
- const venueCard = venue && (
111
- <div className="flex items-center gap-3 rounded-[13px] border border-border p-2.5">
112
- <div
113
- className={cn(
114
- "shrink-0 overflow-hidden rounded-[10px] bg-muted",
115
- compact ? "h-12 w-12" : "h-[52px] w-[52px]"
116
- )}
117
- >
118
- {venue.thumbnailUrl && (
119
- // eslint-disable-next-line @next/next/no-img-element
120
- <img
121
- src={venue.thumbnailUrl}
122
- alt=""
123
- className="h-full w-full object-cover"
124
- loading="lazy"
125
- decoding="async"
126
- />
127
- )}
128
- </div>
129
- <div className="min-w-0 flex-1">
130
- <div className={cn("font-bold", compact ? "text-[13.5px]" : "text-sm")}>{venue.name}</div>
131
- {venue.meta && (
132
- <div className={cn("text-muted-foreground", compact ? "text-[11.5px]" : "text-xs")}>
133
- {venue.meta}
134
- </div>
135
- )}
136
- {venue.ratingLabel && (
137
- <div className="mt-0.5 flex items-center gap-1 text-xs font-bold">
138
- <Star className="h-3 w-3" aria-hidden />
139
- {venue.ratingLabel}
140
- </div>
141
- )}
142
- </div>
143
- <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
144
- </div>
145
- );
208
+ const description = event.description?.trim() ?? "";
209
+ const clampDescription =
210
+ description.length > DESCRIPTION_CLAMP_CHARS && !descriptionExpanded;
211
+
212
+ const shownPerformers =
213
+ performers && !performersExpanded
214
+ ? performers.slice(0, performersPreview)
215
+ : (performers ?? []);
216
+ const hiddenPerformers = (performers?.length ?? 0) - shownPerformers.length;
146
217
 
147
218
  return (
148
- <div className={cn("flex h-full flex-col overflow-hidden bg-card", className)}>
219
+ <div
220
+ className={cn("flex h-full flex-col overflow-hidden bg-card", className)}
221
+ >
149
222
  <div className="flex-1 overflow-y-auto">
150
- <div className="relative aspect-[4/5] shrink-0 overflow-hidden bg-muted">
223
+ {/* The poster. Ended dims and desaturates it — the photo, not the text. */}
224
+ <div
225
+ className={cn(
226
+ "relative shrink-0 overflow-hidden bg-muted",
227
+ cap === "aspect" && "aspect-[4/5]",
228
+ !event.poster_url && "bg-[image:var(--gradient-primary)]",
229
+ isEnded && "saturate-[.35] brightness-[.82]",
230
+ )}
231
+ style={heroHeightStyle(cap)}
232
+ >
151
233
  {event.poster_url ? (
152
234
  // eslint-disable-next-line @next/next/no-img-element
153
235
  <img
@@ -160,201 +242,637 @@ export function EventDetailPanel({
160
242
  ) : (
161
243
  fallback
162
244
  )}
163
- {headerActions}
245
+ {/* Scrim into the card so the title below reads as continuous with the poster. */}
246
+ <div
247
+ aria-hidden
248
+ className="pointer-events-none absolute inset-x-0 bottom-0 h-[34%] bg-gradient-to-t from-card via-card/50 to-transparent"
249
+ />
250
+ {headerActions && (
251
+ <div className="absolute inset-x-3 top-3 z-[2] flex items-center gap-2">
252
+ {headerActions}
253
+ </div>
254
+ )}
164
255
  </div>
165
256
 
166
- <div
167
- className={cn(
168
- "flex flex-col gap-3.5",
169
- compact ? "px-4 pt-4" : "px-[22px] pt-5"
170
- )}
171
- >
257
+ <div className="flex flex-col gap-[13px] px-[18px] pt-3.5">
258
+ {/* Two lines at most: the title is what the card already said. */}
172
259
  <h2
173
260
  className={cn(
174
- "font-extrabold leading-[1.25] tracking-[-0.02em]",
175
- compact ? "text-xl" : "text-[21px]"
261
+ "line-clamp-2 text-[21px] font-extrabold leading-[1.25] tracking-[-0.025em]",
262
+ isEnded ? "text-muted-foreground" : "text-foreground",
176
263
  )}
177
264
  >
178
265
  {event.title}
179
266
  </h2>
180
267
 
181
- <div
182
- className={cn(
183
- "flex items-center gap-1.5 text-muted-foreground",
184
- compact ? "text-[12.5px]" : "text-[13px]"
185
- )}
186
- >
187
- <Calendar className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} aria-hidden />
188
- {dateLine}
268
+ <div className="flex flex-wrap items-center gap-1.5 text-[13px] text-muted-foreground">
269
+ <Calendar className="h-3.5 w-3.5 shrink-0" aria-hidden />
270
+ {dateTime ? <time dateTime={dateTime}>{dateLine}</time> : dateLine}
189
271
  </div>
190
272
 
191
- {/* The live chip is green, not the brand accent: "on now" reads as one
192
- colour across every events surface, and it is the same green the app
193
- already uses for "open now". */}
194
- <div className="flex flex-wrap gap-1.5">
195
- {isLive && (
196
- <span
197
- className={cn(
198
- CHIP,
199
- "h-7 border-success/30 bg-success/10 font-semibold text-success"
200
- )}
201
- >
202
- <Radio className="h-3 w-3" aria-hidden />
203
- {labels.liveNow}
204
- </span>
205
- )}
206
- {tags?.map((tag) => (
207
- <span key={tag} className={cn(CHIP, "h-7")}>
208
- {tag}
209
- </span>
210
- ))}
211
- {fee.kind === "free" && (
212
- <span className={cn(CHIP, "h-7 border-success/40 font-semibold text-success")}>
213
- {labels.free}
214
- </span>
215
- )}
216
- {fee.kind === "paid" && (
217
- <span className={cn(CHIP, "h-7")}>{formatters.price(fee.amountRon)}</span>
218
- )}
219
- </div>
273
+ <EventDetailChipRow
274
+ event={event}
275
+ labels={labels}
276
+ formatters={formatters}
277
+ status={status}
278
+ soonLabel={soonLabel}
279
+ hype={hype}
280
+ tags={tags}
281
+ />
220
282
 
221
283
  {venue && (
222
284
  <div>
223
- <div className="pb-2 text-[13px] font-extrabold">{labels.venueHeading}</div>
224
- {venue.href ? (
225
- <LinkComponent href={venue.href} className="block">
226
- {venueCard}
227
- </LinkComponent>
228
- ) : (
229
- venueCard
230
- )}
285
+ <div className={cn(SECTION_HEADING, "pb-2")}>
286
+ {labels.venueHeading}
287
+ </div>
288
+ <EventDetailVenueCard
289
+ venue={venue}
290
+ labels={labels}
291
+ linkComponent={LinkComponent}
292
+ />
231
293
  </div>
232
294
  )}
233
295
 
234
- {event.description && (
296
+ {performers && performers.length > 0 && (
235
297
  <div>
236
- <div className="pb-1.5 text-[13px] font-extrabold">{labels.aboutHeading}</div>
237
- <p
238
- className={cn(
239
- "leading-[1.6] text-muted-foreground",
240
- compact ? "text-[13px]" : "text-[13.5px]"
298
+ <div className={cn(SECTION_HEADING, "pb-2")}>
299
+ {labels.lineupHeading}
300
+ </div>
301
+ <div className="flex flex-wrap gap-[7px]">
302
+ {shownPerformers.map((name) => (
303
+ <span
304
+ key={name}
305
+ className="inline-flex h-[26px] items-center gap-1.5 rounded-full bg-muted px-2.5 text-xs font-semibold text-foreground"
306
+ >
307
+ <User
308
+ className="h-3 w-3 text-muted-foreground"
309
+ aria-hidden
310
+ />
311
+ {name}
312
+ </span>
313
+ ))}
314
+ {hiddenPerformers > 0 && showAllPerformersLabel && (
315
+ <button
316
+ type="button"
317
+ onClick={() => setPerformersExpanded(true)}
318
+ className="inline-flex h-[26px] cursor-pointer items-center rounded-full border border-dashed border-border px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
319
+ >
320
+ {showAllPerformersLabel}
321
+ </button>
241
322
  )}
242
- >
243
- {event.description}
244
- </p>
323
+ </div>
245
324
  </div>
246
325
  )}
247
326
 
327
+ <div>
328
+ <div className={cn(SECTION_HEADING, "pb-1.5")}>
329
+ {labels.aboutHeading}
330
+ </div>
331
+ {description ? (
332
+ <>
333
+ <p
334
+ className={cn(
335
+ "whitespace-pre-line text-[13.5px] leading-[1.6] text-foreground/85",
336
+ clampDescription && "line-clamp-4",
337
+ )}
338
+ >
339
+ {description}
340
+ </p>
341
+ {description.length > DESCRIPTION_CLAMP_CHARS && (
342
+ <button
343
+ type="button"
344
+ onClick={() =>
345
+ setDescriptionExpanded((current) => !current)
346
+ }
347
+ aria-expanded={descriptionExpanded}
348
+ className="mt-1.5 cursor-pointer text-[13px] font-bold text-accent hover:underline focus-visible:outline-none focus-visible:underline"
349
+ >
350
+ {descriptionExpanded ? labels.readLess : labels.readMore}
351
+ </button>
352
+ )}
353
+ </>
354
+ ) : (
355
+ <p className="text-[13.5px] italic leading-[1.6] text-muted-foreground">
356
+ {labels.noDescription}
357
+ </p>
358
+ )}
359
+ </div>
360
+
248
361
  {externalLink}
249
- <div className="h-2" aria-hidden />
362
+ <div className="h-1.5" aria-hidden />
250
363
  </div>
251
364
  </div>
252
365
 
253
- {footer && (
254
- <div
255
- className={cn(
256
- "sticky bottom-0 flex shrink-0 gap-2.5 bg-card",
257
- compact
258
- ? "px-4 pt-3 pb-[max(1.375rem,env(safe-area-inset-bottom))]"
259
- : "px-[22px] pt-4 pb-[max(1.25rem,env(safe-area-inset-bottom))]"
260
- )}
261
- >
262
- <Separator className="absolute inset-x-0 top-0" />
366
+ {footer}
367
+ </div>
368
+ );
369
+ }
370
+
371
+ // ─── Footer ─────────────────────────────────────────────────────────────────
263
372
 
264
- {footer}
373
+ export type EventDetailFooterProps = {
374
+ /**
375
+ * The one primary action — a `Button` (reserve, tickets, directions when
376
+ * there is no booking, or "see what's on tonight" for an ended event). The
377
+ * host decides which; this only lays it out.
378
+ */
379
+ primary: React.ReactNode;
380
+ /**
381
+ * Zero to two ghost actions — `EventDetailGhostAction`s — drawn as one row
382
+ * above the primary with a hairline between them. Ghosts, not bordered
383
+ * boxes: three bordered buttons stacked read as three equal choices, and
384
+ * the reserve action is not one of three.
385
+ */
386
+ ghosts?: React.ReactNode[];
387
+ className?: string;
388
+ };
389
+
390
+ /** The sticky footer under the panel's scroll region: one primary over a ghost row. */
391
+ export function EventDetailFooter({
392
+ primary,
393
+ ghosts = [],
394
+ className,
395
+ }: EventDetailFooterProps) {
396
+ const visible = ghosts.filter(Boolean);
397
+ return (
398
+ <div
399
+ className={cn(
400
+ "relative flex shrink-0 flex-col gap-0.5 bg-card/95 px-3 pt-3 pb-[max(0.875rem,env(safe-area-inset-bottom))] backdrop-blur-sm",
401
+ className,
402
+ )}
403
+ >
404
+ <Separator className="absolute inset-x-0 top-0" />
405
+ {visible.length > 0 && (
406
+ <div className="flex items-stretch">
407
+ {visible.map((ghost, index) => (
408
+ <React.Fragment key={index}>
409
+ {index > 0 && (
410
+ <Separator orientation="vertical" className="my-2 h-auto" />
411
+ )}
412
+ {ghost}
413
+ </React.Fragment>
414
+ ))}
265
415
  </div>
266
416
  )}
417
+ {primary}
418
+ </div>
419
+ );
420
+ }
421
+
422
+ export type EventDetailGhostActionProps = {
423
+ icon?: React.ReactNode;
424
+ children: React.ReactNode;
425
+ href?: string;
426
+ linkComponent?: React.ComponentType<EventDetailPanelLinkProps>;
427
+ onClick?: (mouseEvent: React.MouseEvent) => void;
428
+ /** `_blank` for an outward link (a maps app). */
429
+ target?: string;
430
+ rel?: string;
431
+ className?: string;
432
+ };
433
+
434
+ const GHOST_CLASS =
435
+ "flex h-10 min-w-0 flex-1 cursor-pointer items-center justify-center gap-[7px] rounded-[10px] text-[13px] font-semibold text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none";
436
+
437
+ /** A quiet action in the footer's ghost row. A link when `href` is given, a button otherwise. */
438
+ export function EventDetailGhostAction({
439
+ icon,
440
+ children,
441
+ href,
442
+ linkComponent: LinkComponent = DefaultLink,
443
+ onClick,
444
+ target,
445
+ rel,
446
+ className,
447
+ }: EventDetailGhostActionProps) {
448
+ const body = (
449
+ <>
450
+ {icon}
451
+ <span className="truncate">{children}</span>
452
+ </>
453
+ );
454
+ if (href) {
455
+ if (target) {
456
+ return (
457
+ <a
458
+ href={href}
459
+ target={target}
460
+ rel={rel}
461
+ onClick={onClick}
462
+ className={cn(GHOST_CLASS, className)}
463
+ >
464
+ {body}
465
+ </a>
466
+ );
467
+ }
468
+ return (
469
+ <LinkComponent
470
+ href={href}
471
+ onClick={onClick}
472
+ className={cn(GHOST_CLASS, className)}
473
+ >
474
+ {body}
475
+ </LinkComponent>
476
+ );
477
+ }
478
+ return (
479
+ <button
480
+ type="button"
481
+ onClick={onClick}
482
+ className={cn(GHOST_CLASS, className)}
483
+ >
484
+ {body}
485
+ </button>
486
+ );
487
+ }
488
+
489
+ // ─── Error ──────────────────────────────────────────────────────────────────
490
+
491
+ export type EventDetailPanelErrorProps = {
492
+ title: string;
493
+ description: string;
494
+ retryLabel: string;
495
+ onRetry: () => void;
496
+ /** The way onward, e.g. "See what's on tonight". */
497
+ secondaryLabel?: string;
498
+ secondaryHref?: string;
499
+ onSecondary?: () => void;
500
+ linkComponent?: React.ComponentType<EventDetailPanelLinkProps>;
501
+ /** Close/share controls over the (empty) hero — same slot as the panel. */
502
+ headerActions?: React.ReactNode;
503
+ className?: string;
504
+ };
505
+
506
+ /**
507
+ * The panel when the event could not be loaded: an empty gradient hero so the
508
+ * sheet keeps its shape and its controls, then the message and a way onward —
509
+ * not only an apology.
510
+ */
511
+ export function EventDetailPanelError({
512
+ title,
513
+ description,
514
+ retryLabel,
515
+ onRetry,
516
+ secondaryLabel,
517
+ secondaryHref,
518
+ onSecondary,
519
+ linkComponent: LinkComponent = DefaultLink,
520
+ headerActions,
521
+ className,
522
+ }: EventDetailPanelErrorProps) {
523
+ return (
524
+ <div
525
+ className={cn("flex h-full flex-col overflow-hidden bg-card", className)}
526
+ >
527
+ <div className="relative flex aspect-video shrink-0 items-center justify-center bg-[image:var(--gradient-primary)] text-primary-foreground/90">
528
+ <CalendarX className="h-12 w-12" aria-hidden />
529
+ {headerActions && (
530
+ <div className="absolute inset-x-3 top-3 z-[2] flex items-center gap-2">
531
+ {headerActions}
532
+ </div>
533
+ )}
534
+ </div>
535
+ <div
536
+ role="alert"
537
+ className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-8 text-center"
538
+ >
539
+ <span className="flex h-[62px] w-[62px] items-center justify-center rounded-full bg-muted text-muted-foreground">
540
+ <Unplug className="h-[26px] w-[26px]" aria-hidden />
541
+ </span>
542
+ <h2 className="text-[17px] font-extrabold tracking-[-0.02em]">
543
+ {title}
544
+ </h2>
545
+ <p className="text-[13.5px] leading-[1.6] text-muted-foreground">
546
+ {description}
547
+ </p>
548
+ <div className="flex w-full flex-col gap-[9px] pt-1">
549
+ <Button onClick={onRetry} fullWidth>
550
+ <RefreshCw aria-hidden />
551
+ {retryLabel}
552
+ </Button>
553
+ {secondaryLabel &&
554
+ (secondaryHref ? (
555
+ <Button asChild variant="outline" fullWidth>
556
+ <LinkComponent href={secondaryHref}>
557
+ {secondaryLabel}
558
+ </LinkComponent>
559
+ </Button>
560
+ ) : (
561
+ <Button variant="outline" fullWidth onClick={onSecondary}>
562
+ {secondaryLabel}
563
+ </Button>
564
+ ))}
565
+ </div>
566
+ </div>
267
567
  </div>
268
568
  );
269
569
  }
270
570
 
571
+ // ─── Skeleton ───────────────────────────────────────────────────────────────
572
+
271
573
  export type EventDetailPanelSkeletonProps = {
272
- /** `compact` is the mobile bottom sheet: tighter type and padding, same 4:5 poster. */
574
+ /** `compact` is the phone drawer: the shorter default hero cap. */
273
575
  size?: "compact" | "default";
274
- /** Close and share controls, positioned over the poster — same slot as the real component. */
576
+ heroHeight?: number | "aspect";
577
+ /** Close and share controls over the poster — same slot as the real component. */
275
578
  headerActions?: React.ReactNode;
579
+ /**
580
+ * Announces the skeleton as a busy status region with this text, e.g.
581
+ * "Loading the event". Without it the skeleton is `aria-hidden` and the host
582
+ * is expected to announce loading itself.
583
+ */
584
+ label?: string;
276
585
  className?: string;
277
586
  };
278
587
 
279
- /** Mirrors `<EventDetailPanel>`'s geometry exactly: the hero scrolls away with the body, the footer is sticky. */
588
+ /** Mirrors `<EventDetailPanel>`'s geometry: capped hero, two title lines, when-line, chips, venue card, line-up, about, ghost row and primary. */
280
589
  export function EventDetailPanelSkeleton({
281
590
  size = "default",
591
+ heroHeight,
282
592
  headerActions,
593
+ label,
283
594
  className,
284
595
  }: EventDetailPanelSkeletonProps) {
285
- const compact = size === "compact";
286
-
596
+ const cap = heroHeight ?? (size === "compact" ? 318 : 300);
287
597
  return (
288
598
  <div
289
- aria-hidden="true"
599
+ {...(label
600
+ ? { role: "status", "aria-busy": true, "aria-label": label }
601
+ : { "aria-hidden": true })}
290
602
  className={cn("flex h-full flex-col overflow-hidden bg-card", className)}
291
603
  >
292
604
  <div className="flex-1 overflow-y-auto">
293
- <div className="relative aspect-[4/5] shrink-0 overflow-hidden bg-muted">
294
- {headerActions}
295
- </div>
296
-
297
605
  <div
298
606
  className={cn(
299
- "flex flex-col gap-3.5",
300
- compact ? "px-4 pt-4" : "px-[22px] pt-5"
607
+ "relative shrink-0 bg-muted",
608
+ cap === "aspect" && "aspect-[4/5]",
301
609
  )}
610
+ style={heroHeightStyle(cap)}
302
611
  >
303
- <Skeleton className={cn("w-3/4", compact ? "h-5" : "h-[21px]")} />
304
-
305
- <div className="flex items-center gap-1.5">
306
- <Skeleton className={cn("shrink-0 rounded-full", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
307
- <Skeleton className={cn("w-48", compact ? "h-[12.5px]" : "h-[13px]")} />
612
+ {headerActions && (
613
+ <div className="absolute inset-x-3 top-3 z-[2] flex items-center gap-2">
614
+ {headerActions}
615
+ </div>
616
+ )}
617
+ </div>
618
+ <div className="flex flex-col gap-[13px] px-[18px] pt-3.5">
619
+ <div className="flex flex-col gap-1.5">
620
+ <Skeleton className="h-[22px] w-[86%]" />
621
+ <Skeleton className="h-[22px] w-[58%]" />
308
622
  </div>
309
-
310
- <div className="flex flex-wrap gap-1.5">
311
- <Skeleton className="h-7 w-20 rounded-full" />
312
- <Skeleton className="h-7 w-16 rounded-full" />
623
+ <Skeleton className="h-3.5 w-[54%]" />
624
+ <div className="flex gap-[7px]">
625
+ <Skeleton className="h-6 w-[74px] rounded-full" />
626
+ <Skeleton className="h-6 w-[88px] rounded-full" />
627
+ <Skeleton className="h-6 w-[66px] rounded-full" />
313
628
  </div>
314
-
315
629
  <div>
316
- <Skeleton className="mb-2 h-[13px] w-28" />
317
- <div className="flex items-center gap-3 rounded-[13px] border border-border p-2.5">
318
- <Skeleton
319
- className={cn(
320
- "shrink-0 rounded-[10px]",
321
- compact ? "h-12 w-12" : "h-[52px] w-[52px]"
322
- )}
323
- />
324
- <div className="min-w-0 flex-1">
325
- <Skeleton className={cn("w-1/2", compact ? "h-[13.5px]" : "h-3.5")} />
326
- <Skeleton className={cn("mt-1 w-3/4", compact ? "h-[11.5px]" : "h-3")} />
327
- </div>
328
- <Skeleton className="h-4 w-4 shrink-0 rounded-full" />
630
+ <Skeleton className="mb-2 h-3.5 w-24" />
631
+ <Skeleton className="h-[74px] w-full rounded-[13px]" />
632
+ </div>
633
+ <div>
634
+ <Skeleton className="mb-2 h-3.5 w-[70px]" />
635
+ <div className="flex gap-[7px]">
636
+ <Skeleton className="h-[26px] w-[92px] rounded-full" />
637
+ <Skeleton className="h-[26px] w-[104px] rounded-full" />
329
638
  </div>
330
639
  </div>
331
-
332
640
  <div>
333
- <Skeleton className="mb-1.5 h-[13px] w-32" />
641
+ <Skeleton className="mb-1.5 h-3.5 w-[120px]" />
334
642
  <div className="flex flex-col gap-1.5">
335
- <Skeleton className={cn("w-full", compact ? "h-[13px]" : "h-[13.5px]")} />
336
- <Skeleton className={cn("w-full", compact ? "h-[13px]" : "h-[13.5px]")} />
337
- <Skeleton className={cn("w-2/3", compact ? "h-[13px]" : "h-[13.5px]")} />
643
+ <Skeleton className="h-[13px] w-full" />
644
+ <Skeleton className="h-[13px] w-full" />
645
+ <Skeleton className="h-[13px] w-[72%]" />
338
646
  </div>
339
647
  </div>
340
-
341
- <div className="h-2" aria-hidden />
648
+ <div className="h-1.5" aria-hidden />
342
649
  </div>
343
650
  </div>
651
+ <div className="relative flex shrink-0 flex-col gap-0.5 bg-card/95 px-3 pt-3 pb-[max(0.875rem,env(safe-area-inset-bottom))]">
652
+ <Separator className="absolute inset-x-0 top-0" />
653
+ <div className="flex items-stretch gap-2 py-0.5">
654
+ <Skeleton className="h-9 flex-1 rounded-[10px]" />
655
+ <Skeleton className="h-9 flex-1 rounded-[10px]" />
656
+ </div>
657
+ <Skeleton className="h-11 w-full rounded-xl" />
658
+ </div>
659
+ </div>
660
+ );
661
+ }
662
+
663
+ // ─── Chip row ───────────────────────────────────────────────────────────────
664
+
665
+ export type EventDetailChipRowProps = Pick<
666
+ EventDetailPanelProps,
667
+ "event" | "formatters" | "status" | "soonLabel" | "hype" | "tags"
668
+ > & {
669
+ /** Only the chip strings — a page rendering the row alone need not name the section headings. */
670
+ labels: Pick<EventDetailPanelLabels, "free" | "liveNow" | "ended">;
671
+ className?: string;
672
+ };
673
+
674
+ /**
675
+ * One wrapping row, all 24px: status, fee, hype, then activity tags. Exported
676
+ * so the page renders the same row above its title — it is the panel's row,
677
+ * not a second one.
678
+ */
679
+ export function EventDetailChipRow({
680
+ event,
681
+ labels,
682
+ formatters,
683
+ status = "upcoming",
684
+ soonLabel,
685
+ hype,
686
+ tags,
687
+ className,
688
+ }: EventDetailChipRowProps) {
689
+ const fee = resolveEventEntryFee(event);
690
+ return (
691
+ <div className={cn("flex flex-wrap gap-[7px]", className)}>
692
+ {status === "live" && (
693
+ <span
694
+ className={cn(
695
+ CHIP,
696
+ "border-success/30 bg-success/10 font-bold text-success",
697
+ )}
698
+ >
699
+ <span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
700
+ {labels.liveNow}
701
+ </span>
702
+ )}
703
+ {status === "soon" && soonLabel && (
704
+ <span
705
+ className={cn(
706
+ CHIP,
707
+ "border-accent/30 bg-accent-subtle font-bold text-accent",
708
+ )}
709
+ >
710
+ <Timer className="h-3 w-3" aria-hidden />
711
+ {soonLabel}
712
+ </span>
713
+ )}
714
+ {status === "ended" && (
715
+ <span
716
+ className={cn(
717
+ CHIP,
718
+ "border-transparent bg-muted font-bold text-muted-foreground",
719
+ )}
720
+ >
721
+ {labels.ended}
722
+ </span>
723
+ )}
724
+ {fee.kind === "free" && (
725
+ <span
726
+ className={cn(
727
+ CHIP,
728
+ "border-success/30 bg-success/10 font-bold text-success",
729
+ )}
730
+ >
731
+ <Gift className="h-3 w-3" aria-hidden />
732
+ {labels.free}
733
+ </span>
734
+ )}
735
+ {fee.kind === "paid" && (
736
+ <span className={cn(CHIP, "text-foreground")}>
737
+ <Ticket className="h-3 w-3" aria-hidden />
738
+ {formatters.price(fee.amountRon)}
739
+ </span>
740
+ )}
741
+ {hype}
742
+ {tags?.map((tag) => (
743
+ <span key={tag} className={CHIP}>
744
+ {tag}
745
+ </span>
746
+ ))}
747
+ </div>
748
+ );
749
+ }
750
+
751
+ // ─── Venue card ─────────────────────────────────────────────────────────────
752
+
753
+ export type EventDetailVenueCardProps = {
754
+ venue: EventDetailVenue;
755
+ labels: Pick<EventDetailPanelLabels, "venueNoPage">;
756
+ linkComponent?: React.ComponentType<EventDetailPanelLinkProps>;
757
+ /** Thumbnail size in px: 52 in the sheet, 64 on the page. */
758
+ thumbnailSize?: 52 | 64;
759
+ /** An inline action beside the chevron — the page's "Open in Maps". */
760
+ action?: React.ReactNode;
761
+ className?: string;
762
+ };
344
763
 
764
+ /**
765
+ * The venue row: cover with an open-now dot, name, address · rating · open
766
+ * label, a chevron when it links. Without `venue.href` it is not a link and
767
+ * says so — a venue the ingest knows by name only must not read as broken.
768
+ */
769
+ export function EventDetailVenueCard({
770
+ venue,
771
+ labels,
772
+ linkComponent: LinkComponent = DefaultLink,
773
+ thumbnailSize = 52,
774
+ action,
775
+ className,
776
+ }: EventDetailVenueCardProps) {
777
+ const meta =
778
+ venue.href && (venue.address || venue.ratingLabel || venue.openLabel);
779
+ // The main area (thumb, name, meta, chevron) is the link; `action` sits
780
+ // BESIDE it, never inside — an anchor cannot contain another control, and a
781
+ // page's "Open in Maps" must not navigate to the venue instead.
782
+ const main = (
783
+ <>
345
784
  <div
346
785
  className={cn(
347
- "sticky bottom-0 flex shrink-0 gap-2.5 bg-card",
348
- compact
349
- ? "px-4 pt-3 pb-[max(1.375rem,env(safe-area-inset-bottom))]"
350
- : "px-[22px] pt-4 pb-[max(1.25rem,env(safe-area-inset-bottom))]"
786
+ "relative shrink-0 overflow-hidden rounded-[11px] bg-muted",
787
+ thumbnailSize === 64 ? "h-16 w-16" : "h-[52px] w-[52px]",
788
+ !venue.thumbnailUrl &&
789
+ "flex items-center justify-center text-muted-foreground",
351
790
  )}
352
791
  >
353
- <Separator className="absolute inset-x-0 top-0" />
354
-
355
- <Skeleton className="h-[46px] flex-1 rounded-xl" />
356
- <Skeleton className="h-[46px] w-24 rounded-xl" />
792
+ {venue.thumbnailUrl ? (
793
+ // eslint-disable-next-line @next/next/no-img-element
794
+ <img
795
+ src={venue.thumbnailUrl}
796
+ alt=""
797
+ className="h-full w-full object-cover"
798
+ loading="lazy"
799
+ decoding="async"
800
+ />
801
+ ) : (
802
+ <MapPin className="h-5 w-5" aria-hidden />
803
+ )}
804
+ {venue.isOpenNow && (
805
+ <span
806
+ aria-hidden
807
+ className="absolute right-1 top-1 h-[7px] w-[7px] rounded-full bg-success ring-2 ring-card"
808
+ />
809
+ )}
810
+ </div>
811
+ <div className="min-w-0 flex-1">
812
+ <div className="truncate text-sm font-bold">{venue.name}</div>
813
+ {venue.href ? (
814
+ meta && (
815
+ <div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
816
+ {venue.address && (
817
+ <span className="truncate">{venue.address}</span>
818
+ )}
819
+ {venue.address && venue.ratingLabel && (
820
+ <span aria-hidden className="text-border">
821
+ ·
822
+ </span>
823
+ )}
824
+ {venue.ratingLabel && (
825
+ <span className="inline-flex shrink-0 items-center gap-1">
826
+ <Star className="h-[11px] w-[11px]" aria-hidden />
827
+ {venue.ratingLabel}
828
+ </span>
829
+ )}
830
+ {venue.openLabel && (
831
+ <>
832
+ <span aria-hidden className="text-border">
833
+ ·
834
+ </span>
835
+ <span className="shrink-0">{venue.openLabel}</span>
836
+ </>
837
+ )}
838
+ </div>
839
+ )
840
+ ) : (
841
+ <div className="mt-0.5 text-xs text-muted-foreground">
842
+ {labels.venueNoPage}
843
+ </div>
844
+ )}
357
845
  </div>
846
+ {venue.href && (
847
+ <ChevronRight
848
+ className="h-[17px] w-[17px] shrink-0 text-muted-foreground"
849
+ aria-hidden
850
+ />
851
+ )}
852
+ </>
853
+ );
854
+ const MAIN_CLASS = "flex min-w-0 flex-1 items-center gap-3";
855
+ return (
856
+ <div
857
+ className={cn(
858
+ "flex items-center gap-3 rounded-[13px] border border-border bg-card p-2.5",
859
+ className,
860
+ )}
861
+ >
862
+ {venue.href ? (
863
+ <LinkComponent
864
+ href={venue.href}
865
+ className={cn(
866
+ MAIN_CLASS,
867
+ "-m-1 rounded-[11px] p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
868
+ )}
869
+ >
870
+ {main}
871
+ </LinkComponent>
872
+ ) : (
873
+ <div className={MAIN_CLASS}>{main}</div>
874
+ )}
875
+ {action}
358
876
  </div>
359
877
  );
360
878
  }