@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.
- package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-AXV5O2IZ.js +58 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-THOVPZNN.js +37 -0
- package/dist/chunk-YS6BHKZO.js +92 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-compare.d.ts +73 -0
- package/dist/components/concept-compare.js +162 -0
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +64 -34
- package/dist/components/event-compact-row.d.ts +33 -6
- package/dist/components/event-compact-row.js +120 -39
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +471 -174
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +79 -35
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +20 -0
- package/dist/components/menu-family-card.d.ts +59 -0
- package/dist/components/menu-family-card.js +121 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +3 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- package/dist/components/menu-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- package/dist/components/menu-mocks.d.ts +55 -0
- package/dist/components/menu-mocks.js +379 -0
- package/dist/components/menu-types.d.ts +245 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-set.js +15 -3
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/ranked-venue-list.d.ts +49 -0
- package/dist/components/ranked-venue-list.js +87 -0
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/signature-line-list.d.ts +45 -0
- package/dist/components/signature-line-list.js +99 -0
- package/dist/components/snap-rail.d.ts +54 -0
- package/dist/components/snap-rail.js +109 -0
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/venue-busyness-strip.d.ts +35 -0
- package/dist/components/venue-busyness-strip.js +37 -0
- package/dist/components/venue-grade-dimensions.d.ts +36 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-signature-block.d.ts +39 -0
- package/dist/components/venue-signature-block.js +6 -0
- package/dist/components/venue-spotlight.d.ts +74 -0
- package/dist/components/venue-spotlight.js +138 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare.stories.tsx +104 -0
- package/src/components/concept-compare.tsx +276 -0
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +162 -38
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +40 -0
- package/src/components/menu-family-card.stories.tsx +112 -0
- package/src/components/menu-family-card.tsx +211 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- package/src/components/menu-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -0
- package/src/components/menu-mocks.ts +417 -0
- package/src/components/menu-types.ts +259 -0
- package/src/components/motion-icon-set.tsx +30 -3
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/ranked-venue-list.stories.tsx +80 -0
- package/src/components/ranked-venue-list.tsx +177 -0
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/signature-line-list.stories.tsx +85 -0
- package/src/components/signature-line-list.tsx +136 -0
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +148 -0
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/venue-busyness-strip.stories.tsx +66 -0
- package/src/components/venue-busyness-strip.tsx +64 -0
- package/src/components/venue-grade-dimensions.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-signature-block.stories.tsx +68 -0
- package/src/components/venue-signature-block.tsx +71 -0
- package/src/components/venue-spotlight.stories.tsx +141 -0
- package/src/components/venue-spotlight.tsx +236 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- 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 {
|
|
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
|
|
20
|
-
* and `Drawer`; owning sheet mechanics a third time here would fork
|
|
21
|
-
* consuming app mounts this inside whichever container the
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
45
|
-
|
|
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
|
-
* "
|
|
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
|
-
/**
|
|
136
|
+
/** Activity tags, after status, fee and hype. */
|
|
72
137
|
tags?: string[];
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
/**
|
|
146
|
+
/** The footer — usually 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
|
-
/**
|
|
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-
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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.
|
|
175
|
-
|
|
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=
|
|
183
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
{
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
{
|
|
296
|
+
{performers && performers.length > 0 && (
|
|
235
297
|
<div>
|
|
236
|
-
<div className="pb-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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-
|
|
362
|
+
<div className="h-1.5" aria-hidden />
|
|
250
363
|
</div>
|
|
251
364
|
</div>
|
|
252
365
|
|
|
253
|
-
{footer
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
|
574
|
+
/** `compact` is the phone drawer: the shorter default hero cap. */
|
|
273
575
|
size?: "compact" | "default";
|
|
274
|
-
|
|
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
|
|
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
|
|
286
|
-
|
|
596
|
+
const cap = heroHeight ?? (size === "compact" ? 318 : 300);
|
|
287
597
|
return (
|
|
288
598
|
<div
|
|
289
|
-
|
|
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
|
-
"
|
|
300
|
-
|
|
607
|
+
"relative shrink-0 bg-muted",
|
|
608
|
+
cap === "aspect" && "aspect-[4/5]",
|
|
301
609
|
)}
|
|
610
|
+
style={heroHeightStyle(cap)}
|
|
302
611
|
>
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
311
|
-
<Skeleton className="h-
|
|
312
|
-
<Skeleton className="h-
|
|
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-
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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-
|
|
641
|
+
<Skeleton className="mb-1.5 h-3.5 w-[120px]" />
|
|
334
642
|
<div className="flex flex-col gap-1.5">
|
|
335
|
-
<Skeleton className=
|
|
336
|
-
<Skeleton className=
|
|
337
|
-
<Skeleton className=
|
|
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
|
-
"
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
}
|