@bearmenu/ui 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-637L7XQ3.js +85 -0
- package/dist/chunk-6XMYEZVR.js +26 -0
- package/dist/chunk-DNSXMHYX.js +8 -0
- package/dist/chunk-FDITZ2VM.js +16 -0
- package/dist/chunk-GPH6L4DX.js +55 -0
- package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
- package/dist/chunk-TT3WFS6S.js +379 -0
- package/dist/chunk-UXWAOISG.js +170 -0
- package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
- package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/card.d.ts +2 -2
- package/dist/components/card.js +4 -127
- package/dist/components/category-deck.d.ts +50 -0
- package/dist/components/category-deck.js +128 -0
- package/dist/components/command.d.ts +15 -15
- package/dist/components/concept-card.d.ts +43 -0
- package/dist/components/concept-card.js +81 -0
- package/dist/components/coverflow.d.ts +123 -0
- package/dist/components/coverflow.js +300 -0
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/day-rail-chip.d.ts +82 -0
- package/dist/components/day-rail-chip.js +105 -0
- package/dist/components/drift-rail.d.ts +28 -0
- package/dist/components/drift-rail.js +3 -0
- package/dist/components/event-card.d.ts +97 -0
- package/dist/components/event-card.js +161 -0
- package/dist/components/event-compact-row.d.ts +72 -0
- package/dist/components/event-compact-row.js +133 -0
- package/dist/components/event-day-header.d.ts +65 -0
- package/dist/components/event-day-header.js +95 -0
- package/dist/components/event-day-separator.d.ts +46 -0
- package/dist/components/event-day-separator.js +50 -0
- package/dist/components/event-detail-panel.d.ts +80 -0
- package/dist/components/event-detail-panel.js +251 -0
- package/dist/components/event-hub-hero.d.ts +34 -0
- package/dist/components/event-hub-hero.js +57 -0
- package/dist/components/event-index-row.d.ts +68 -0
- package/dist/components/event-index-row.js +84 -0
- package/dist/components/event-list-row-wide.d.ts +101 -0
- package/dist/components/event-list-row-wide.js +218 -0
- package/dist/components/event-list-row.d.ts +82 -0
- package/dist/components/event-list-row.js +173 -0
- package/dist/components/event-poster-fallback.d.ts +20 -0
- package/dist/components/event-poster-fallback.js +3 -0
- package/dist/components/event-types.d.ts +118 -0
- package/dist/components/event-types.js +2 -0
- package/dist/components/events-empty-state.d.ts +44 -0
- package/dist/components/events-empty-state.js +73 -0
- package/dist/components/facet-group.d.ts +93 -0
- package/dist/components/facet-group.js +222 -0
- package/dist/components/featured-rail-item.d.ts +42 -0
- package/dist/components/featured-rail-item.js +88 -0
- package/dist/components/hour-band.d.ts +68 -0
- package/dist/components/hour-band.js +53 -0
- package/dist/components/hour-group.d.ts +67 -0
- package/dist/components/hour-group.js +59 -0
- package/dist/components/item.d.ts +2 -2
- package/dist/components/motion-icon-controls.js +3 -379
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-about-tab.js +3 -3
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/poster-stack.d.ts +30 -0
- package/dist/components/poster-stack.js +104 -0
- package/dist/components/poster-tape.d.ts +36 -0
- package/dist/components/poster-tape.js +64 -0
- package/dist/components/poster-wall.d.ts +25 -0
- package/dist/components/poster-wall.js +183 -0
- package/dist/components/quiet-stretch.d.ts +43 -0
- package/dist/components/quiet-stretch.js +54 -0
- package/dist/components/section-header.d.ts +53 -0
- package/dist/components/section-header.js +63 -0
- package/dist/components/segmented-nav.d.ts +62 -0
- package/dist/components/segmented-nav.js +54 -0
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.d.ts +17 -0
- package/dist/components/skeleton.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag-group.js +3 -26
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +1 -1
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +46 -0
- package/dist/components/timeline-band.js +5 -0
- package/dist/components/venue-events-block.d.ts +143 -0
- package/dist/components/venue-events-block.js +222 -0
- package/dist/components/weekend-panel.d.ts +44 -0
- package/dist/components/weekend-panel.js +231 -0
- package/package.json +1 -1
- package/src/components/card.tsx +44 -1
- package/src/components/category-deck.stories.tsx +96 -0
- package/src/components/category-deck.tsx +227 -0
- package/src/components/concept-card.stories.tsx +80 -0
- package/src/components/concept-card.tsx +143 -0
- package/src/components/coverflow.stories.tsx +240 -0
- package/src/components/coverflow.test.tsx +193 -0
- package/src/components/coverflow.tsx +550 -0
- package/src/components/day-rail-chip.stories.tsx +145 -0
- package/src/components/day-rail-chip.tsx +201 -0
- package/src/components/drift-rail.stories.tsx +92 -0
- package/src/components/drift-rail.tsx +86 -0
- package/src/components/event-card.stories.tsx +248 -0
- package/src/components/event-card.test.tsx +177 -0
- package/src/components/event-card.tsx +321 -0
- package/src/components/event-compact-row.stories.tsx +221 -0
- package/src/components/event-compact-row.tsx +268 -0
- package/src/components/event-day-header.stories.tsx +124 -0
- package/src/components/event-day-header.tsx +180 -0
- package/src/components/event-day-separator.stories.tsx +70 -0
- package/src/components/event-day-separator.tsx +95 -0
- package/src/components/event-detail-panel.stories.tsx +191 -0
- package/src/components/event-detail-panel.tsx +360 -0
- package/src/components/event-hub-hero.stories.tsx +66 -0
- package/src/components/event-hub-hero.tsx +110 -0
- package/src/components/event-index-row.stories.tsx +116 -0
- package/src/components/event-index-row.tsx +181 -0
- package/src/components/event-list-row-wide.stories.tsx +204 -0
- package/src/components/event-list-row-wide.tsx +386 -0
- package/src/components/event-list-row.stories.tsx +159 -0
- package/src/components/event-list-row.tsx +335 -0
- package/src/components/event-poster-fallback.stories.tsx +52 -0
- package/src/components/event-poster-fallback.tsx +31 -0
- package/src/components/event-types.ts +133 -0
- package/src/components/events-empty-state.stories.tsx +59 -0
- package/src/components/events-empty-state.tsx +131 -0
- package/src/components/facet-group.stories.tsx +247 -0
- package/src/components/facet-group.test.tsx +225 -0
- package/src/components/facet-group.tsx +352 -0
- package/src/components/featured-rail-item.stories.tsx +86 -0
- package/src/components/featured-rail-item.tsx +153 -0
- package/src/components/hour-band.stories.tsx +159 -0
- package/src/components/hour-band.tsx +126 -0
- package/src/components/hour-group.stories.tsx +100 -0
- package/src/components/hour-group.tsx +124 -0
- package/src/components/poster-stack.stories.tsx +68 -0
- package/src/components/poster-stack.tsx +155 -0
- package/src/components/poster-tape.stories.tsx +70 -0
- package/src/components/poster-tape.tsx +122 -0
- package/src/components/poster-wall.stories.tsx +95 -0
- package/src/components/poster-wall.tsx +220 -0
- package/src/components/quiet-stretch.stories.tsx +62 -0
- package/src/components/quiet-stretch.tsx +115 -0
- package/src/components/section-header.stories.tsx +70 -0
- package/src/components/section-header.tsx +134 -0
- package/src/components/segmented-nav.stories.tsx +118 -0
- package/src/components/segmented-nav.tsx +113 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/tag.tsx +11 -1
- package/src/components/timeline-band.stories.tsx +206 -0
- package/src/components/timeline-band.tsx +207 -0
- package/src/components/venue-events-block.stories.tsx +216 -0
- package/src/components/venue-events-block.tsx +408 -0
- package/src/components/weekend-panel.stories.tsx +92 -0
- package/src/components/weekend-panel.tsx +322 -0
- package/src/globals.css +19 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { memo, useState } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { MapPin, Radio } from "lucide-react";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { buttonVariants } from "./button";
|
|
8
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
9
|
+
import { Separator } from "./separator";
|
|
10
|
+
import { Skeleton } from "./skeleton";
|
|
11
|
+
import { Tag } from "./tag";
|
|
12
|
+
import {
|
|
13
|
+
resolveEventEntryFee,
|
|
14
|
+
type EventDateBadgeFormatters,
|
|
15
|
+
type EventListRowLabels,
|
|
16
|
+
type EventPreview,
|
|
17
|
+
} from "./event-types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* EventListRowWide — the desktop counterpart to `<EventListRow>`.
|
|
21
|
+
*
|
|
22
|
+
* Denser than `<EventCard>`, richer than the mobile `<EventListRow>`: desktop
|
|
23
|
+
* has the width to spare for a description and a dedicated action column, so
|
|
24
|
+
* this adds both rather than just scaling up the mobile row's type sizes.
|
|
25
|
+
*
|
|
26
|
+
* NESTED-LINK PATTERN, not the mobile row's single anchor: the row root is a
|
|
27
|
+
* plain, non-interactive container. The event TITLE is the primary link and
|
|
28
|
+
* gets a stretched overlay (`after:absolute after:inset-0`) so the whole row
|
|
29
|
+
* stays one big click target; "See the menu" (when `menuHref` is supplied) is
|
|
30
|
+
* a second, real link, `relative` with a higher `z-index` than the overlay so
|
|
31
|
+
* it wins the click in its own area — nesting it inside the title's anchor
|
|
32
|
+
* would be invalid HTML, which is why this uses two siblings instead. The
|
|
33
|
+
* title's `onClick` still fires for the caller (e.g. to `preventDefault()`
|
|
34
|
+
* into a detail sheet); modifier/middle-clicks still fall through to the
|
|
35
|
+
* anchor since nothing calls `preventDefault()` here.
|
|
36
|
+
*
|
|
37
|
+
* `event-list-row.tsx` (the mobile row) keeps the single-anchor shape and has
|
|
38
|
+
* no action column at all — the two were reconciled in favour of a real
|
|
39
|
+
* action here and none there, since a nested inert CTA inside a single-anchor
|
|
40
|
+
* row is a control that only looks like one. See that file's header.
|
|
41
|
+
*
|
|
42
|
+
* WEIGHT: the family tops out at 600, and after that pass the `Button` in this
|
|
43
|
+
* row's action column is the only 700-class element left in it — correct,
|
|
44
|
+
* since it is the only real action. Density sets weight, width sets size:
|
|
45
|
+
* both row titles are 600, and 15px vs 16.5px is what separates them.
|
|
46
|
+
*
|
|
47
|
+
* Same contract as the rest of the family: DS-owned view model, injected
|
|
48
|
+
* labels and formatters, plain `<img>`, anchor whenever `href` is given.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export type EventListRowWideLinkProps = {
|
|
52
|
+
href: string;
|
|
53
|
+
className?: string;
|
|
54
|
+
children: React.ReactNode;
|
|
55
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function DefaultLink({ href, ...props }: EventListRowWideLinkProps) {
|
|
59
|
+
return <a href={href} {...props} />;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extends the mobile row's labels with the strings only the wide row needs —
|
|
64
|
+
* the action column included, since this is the only row in the family that
|
|
65
|
+
* has one.
|
|
66
|
+
*/
|
|
67
|
+
export type EventListRowWideLabels = EventListRowLabels & {
|
|
68
|
+
/** Primary row action, e.g. "Reserve". */
|
|
69
|
+
reserve: string;
|
|
70
|
+
/** Row action for a sold-out event. */
|
|
71
|
+
notifyMe: string;
|
|
72
|
+
/** Secondary link in the action column, e.g. "See the menu". */
|
|
73
|
+
seeMenu: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type EventListRowWideProps = {
|
|
77
|
+
event: EventPreview;
|
|
78
|
+
labels: EventListRowWideLabels;
|
|
79
|
+
formatters: EventDateBadgeFormatters;
|
|
80
|
+
/** Currently running — tints the date badge and shows the on-air chip. */
|
|
81
|
+
isLive?: boolean;
|
|
82
|
+
/** Sold out or otherwise closed — dims the row and swaps the CTA. */
|
|
83
|
+
isSoldOut?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Caller-resolved tag display labels, rendered as chips after the venue.
|
|
86
|
+
* The DS holds no taxonomy display names, so resolving AND capping them is
|
|
87
|
+
* the caller's job — three is what the desktop meta line carries. Same
|
|
88
|
+
* contract as `EventListRowProps.tags`; this replaced a `tagsText` prose
|
|
89
|
+
* string, which was the only place in the family tags were not chips.
|
|
90
|
+
*/
|
|
91
|
+
tags?: string[];
|
|
92
|
+
href?: string;
|
|
93
|
+
/** Destination for the "See the menu" affordance. Omit to not render it at all. */
|
|
94
|
+
menuHref?: string;
|
|
95
|
+
linkComponent?: React.ComponentType<EventListRowWideLinkProps>;
|
|
96
|
+
onClick?: (event: EventPreview, mouseEvent: React.MouseEvent) => void;
|
|
97
|
+
className?: string;
|
|
98
|
+
priority?: boolean;
|
|
99
|
+
/** Rendered instead of the thumbnail when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
|
|
100
|
+
fallback?: ReactNode;
|
|
101
|
+
/**
|
|
102
|
+
* Drops this row's top rule. Pass it on the FIRST row under a day heading:
|
|
103
|
+
* the heading already ends the block above it, so the row's own rule lands
|
|
104
|
+
* immediately beneath it and draws two dividers a few pixels apart. The gap
|
|
105
|
+
* is the separator there — whitespace does the same job without the second
|
|
106
|
+
* line. Same prop and same reason as `EventIndexRow`.
|
|
107
|
+
*/
|
|
108
|
+
hideRule?: boolean;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const EventListRowWide = memo(function EventListRowWide({
|
|
112
|
+
event,
|
|
113
|
+
labels,
|
|
114
|
+
formatters,
|
|
115
|
+
isLive,
|
|
116
|
+
isSoldOut,
|
|
117
|
+
tags,
|
|
118
|
+
href,
|
|
119
|
+
menuHref,
|
|
120
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
121
|
+
onClick,
|
|
122
|
+
className,
|
|
123
|
+
priority,
|
|
124
|
+
fallback = <EventPosterFallback />,
|
|
125
|
+
hideRule,
|
|
126
|
+
}: EventListRowWideProps) {
|
|
127
|
+
const [imgError, setImgError] = useState(false);
|
|
128
|
+
|
|
129
|
+
const fee = resolveEventEntryFee(event);
|
|
130
|
+
const showPoster = !!event.poster_url && !imgError;
|
|
131
|
+
const venue = event.place_name ?? event.location;
|
|
132
|
+
const description = event.description ?? event.short_description;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `undefined` when the price is UNKNOWN — the row renders nothing rather
|
|
136
|
+
* than "Price TBA".
|
|
137
|
+
*
|
|
138
|
+
* An unknown fee is the commonest case in this feed (the ingest reads
|
|
139
|
+
* Instagram captions, which mostly do not state a door price), so the
|
|
140
|
+
* placeholder was the loudest, boldest element on most rows, repeated down
|
|
141
|
+
* the page, saying only that we do not know something. That is worse than
|
|
142
|
+
* silence: it spends the row's strongest typographic slot on an absence, and
|
|
143
|
+
* a reader scanning for prices learns to skip the one place a real price
|
|
144
|
+
* would appear. Silence reads as "not stated"; "Price TBA" reads as a
|
|
145
|
+
* promise that a number is coming.
|
|
146
|
+
*
|
|
147
|
+
* `EventListRow` reached the same conclusion, and `labels.priceUnknown` is
|
|
148
|
+
* gone from the contract entirely — no component in the package renders a
|
|
149
|
+
* placeholder for a fee any more.
|
|
150
|
+
*/
|
|
151
|
+
const priceText =
|
|
152
|
+
fee.kind === "free" ? labels.free : fee.kind === "paid" ? formatters.price(fee.amountRon) : undefined;
|
|
153
|
+
|
|
154
|
+
const body = (
|
|
155
|
+
<>
|
|
156
|
+
{!hideRule && <Separator className="absolute inset-x-0 top-0" />}
|
|
157
|
+
|
|
158
|
+
{/* Fill means LIVE, not decoration — same rule as the phone row, and the
|
|
159
|
+
one thing about the badge that must not diverge between widths. */}
|
|
160
|
+
<div
|
|
161
|
+
className={cn("w-[52px] shrink-0 rounded-[10px] py-[7px] text-center", isLive && "bg-success/10")}
|
|
162
|
+
>
|
|
163
|
+
<div
|
|
164
|
+
className={cn(
|
|
165
|
+
"text-[19px] font-semibold leading-none tracking-[-0.02em] tabular-nums",
|
|
166
|
+
isLive ? "text-success" : "text-foreground"
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
{formatters.dayNumber(event.starts_at)}
|
|
170
|
+
</div>
|
|
171
|
+
<div
|
|
172
|
+
className={cn(
|
|
173
|
+
"mt-[3px] text-[10.5px] font-semibold tracking-[0.06em]",
|
|
174
|
+
isLive ? "text-success" : "text-muted-foreground"
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{formatters.monthShort(event.starts_at)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div className="aspect-square w-[104px] shrink-0 overflow-hidden rounded-[10px] bg-muted">
|
|
182
|
+
{showPoster ? (
|
|
183
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
184
|
+
<img
|
|
185
|
+
src={event.poster_url}
|
|
186
|
+
alt=""
|
|
187
|
+
loading={priority ? "eager" : "lazy"}
|
|
188
|
+
decoding="async"
|
|
189
|
+
className="h-full w-full object-cover"
|
|
190
|
+
onError={() => setImgError(true)}
|
|
191
|
+
/>
|
|
192
|
+
) : (
|
|
193
|
+
fallback
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="min-w-0 flex-1">
|
|
198
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
199
|
+
{href ? (
|
|
200
|
+
<LinkComponent
|
|
201
|
+
href={href}
|
|
202
|
+
className="rounded-sm text-[16.5px] font-semibold tracking-[-0.01em] after:absolute after:inset-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
203
|
+
onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
|
|
204
|
+
>
|
|
205
|
+
{event.title}
|
|
206
|
+
</LinkComponent>
|
|
207
|
+
) : onClick ? (
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
className="relative cursor-pointer rounded-sm bg-transparent p-0 text-left text-[16.5px] font-semibold tracking-[-0.01em] after:absolute after:inset-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
211
|
+
onClick={(mouseEvent) => onClick(event, mouseEvent)}
|
|
212
|
+
>
|
|
213
|
+
{event.title}
|
|
214
|
+
</button>
|
|
215
|
+
) : (
|
|
216
|
+
<span className="text-[16.5px] font-semibold tracking-[-0.01em]">{event.title}</span>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{isLive && (
|
|
220
|
+
<Tag size="sm" variant="success" className="shrink-0 whitespace-nowrap">
|
|
221
|
+
<Radio className="h-2.5 w-2.5" aria-hidden />
|
|
222
|
+
{labels.liveNow}
|
|
223
|
+
</Tag>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{isSoldOut && (
|
|
227
|
+
<Tag size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
|
|
228
|
+
{labels.soldOut}
|
|
229
|
+
</Tag>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Time first, matching the phone row and the compact row: in a
|
|
234
|
+
time-sorted feed the clock is the axis, so it belongs at a fixed
|
|
235
|
+
left edge. Emphasis moved from weight to the ink channel — 400 in
|
|
236
|
+
`text-foreground` against the muted rest. The `MapPin` marks the
|
|
237
|
+
venue field and replaces the dot that used to; both widths carry
|
|
238
|
+
it, and it is the same glyph the filter rail uses for VENUE. */}
|
|
239
|
+
<div className="mt-[5px] flex items-center gap-2 text-[12.5px] text-muted-foreground">
|
|
240
|
+
<span className="shrink-0 tabular-nums text-foreground">
|
|
241
|
+
{formatters.time(event.starts_at)}
|
|
242
|
+
</span>
|
|
243
|
+
<span className="flex min-w-0 items-center gap-1">
|
|
244
|
+
<MapPin className="h-3 w-3 shrink-0" aria-hidden />
|
|
245
|
+
<span className="truncate">{venue}</span>
|
|
246
|
+
</span>
|
|
247
|
+
{tags?.map((tag) => (
|
|
248
|
+
<Tag key={tag} size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
|
|
249
|
+
{tag}
|
|
250
|
+
</Tag>
|
|
251
|
+
))}
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{description && (
|
|
255
|
+
<p className="mt-2 line-clamp-2 max-w-[620px] text-[13.5px] leading-[1.55] text-muted-foreground">
|
|
256
|
+
{description}
|
|
257
|
+
</p>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div className="flex w-[150px] shrink-0 flex-col items-end gap-[9px]">
|
|
262
|
+
{priceText && (
|
|
263
|
+
<span
|
|
264
|
+
className={cn("text-[15px] font-semibold tabular-nums", fee.kind === "free" && "text-success")}
|
|
265
|
+
>
|
|
266
|
+
{priceText}
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{/* The CTA carries the DS `Button`'s own styling via `buttonVariants`,
|
|
271
|
+
not a hand-rolled pill. It stays a `<span>` rather than becoming a
|
|
272
|
+
real `<Button>`: the whole row is already a link or a button, and a
|
|
273
|
+
nested interactive element inside one is invalid HTML and steals
|
|
274
|
+
the row's click target. So it takes the button's LOOK from the
|
|
275
|
+
single source that defines it, while remaining inert — which is
|
|
276
|
+
what it always was, just previously drawn by hand from `CHIP_BASE`
|
|
277
|
+
plus `bg-primary` and free to drift from every real button on the
|
|
278
|
+
page. */}
|
|
279
|
+
<span
|
|
280
|
+
className={buttonVariants({
|
|
281
|
+
variant: isSoldOut ? "outline" : "default",
|
|
282
|
+
size: "sm",
|
|
283
|
+
})}
|
|
284
|
+
>
|
|
285
|
+
{isSoldOut ? labels.notifyMe : labels.reserve}
|
|
286
|
+
</span>
|
|
287
|
+
|
|
288
|
+
{menuHref && (
|
|
289
|
+
<LinkComponent
|
|
290
|
+
href={menuHref}
|
|
291
|
+
className="relative z-10 cursor-pointer rounded-sm text-xs font-semibold text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
292
|
+
>
|
|
293
|
+
{labels.seeMenu}
|
|
294
|
+
</LinkComponent>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
</>
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const rootClasses = cn(
|
|
301
|
+
"relative flex w-full items-start gap-[18px] py-[18px] text-left",
|
|
302
|
+
// The row is a click target — its title carries an `after:inset-0` overlay
|
|
303
|
+
// that makes the WHOLE row the link — and it had no hover response at all,
|
|
304
|
+
// so a pointer user got no confirmation the thing under the cursor was
|
|
305
|
+
// interactive until they had already clicked it.
|
|
306
|
+
//
|
|
307
|
+
// The tint bleeds past the text edges (`-mx-3 px-3`) so the fill reads as
|
|
308
|
+
// one row rather than a box drawn around the copy, and the row keeps its
|
|
309
|
+
// full-bleed separator. Sold-out rows are excluded: they are dimmed and
|
|
310
|
+
// inert, and lighting one up on hover promises an action that is not there.
|
|
311
|
+
//
|
|
312
|
+
// TEXT-KEYED, not surface-keyed — the same reasoning as `EventCompactRow`.
|
|
313
|
+
// This shipped as `bg-muted/50` and the numbers are why it did not stay:
|
|
314
|
+
// composited, that lands 0.0155 L from `--background` in light mode, which
|
|
315
|
+
// is the case this row actually renders in, and 0.0075 from `--card` in
|
|
316
|
+
// dark, which is invisible. `--foreground` at 5% holds 0.038-0.041 L across
|
|
317
|
+
// all four surface/mode combinations, so the wash is the same strength
|
|
318
|
+
// wherever the row is dropped and cannot be silently cancelled by the
|
|
319
|
+
// surface beneath it.
|
|
320
|
+
!isSoldOut &&
|
|
321
|
+
"-mx-3 rounded-xl px-3 transition-colors duration-150 hover:bg-foreground/5 motion-reduce:transition-none",
|
|
322
|
+
isSoldOut && "opacity-60",
|
|
323
|
+
className
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
return <div className={rootClasses}>{body}</div>;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
export type EventListRowWideSkeletonProps = {
|
|
330
|
+
/** Matches `EventListRowWideProps` shape: show the description block by default, drop it for `NoDescription`-style rows. */
|
|
331
|
+
showDescription?: boolean;
|
|
332
|
+
/** Matches `EventListRowWideProps["hideRule"]` — drop the top rule on the first row of a day group. */
|
|
333
|
+
hideRule?: boolean;
|
|
334
|
+
className?: string;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** Mirrors `<EventListRowWide>`'s geometry exactly so nothing shifts when data lands. */
|
|
338
|
+
export function EventListRowWideSkeleton({
|
|
339
|
+
showDescription = true,
|
|
340
|
+
hideRule,
|
|
341
|
+
className,
|
|
342
|
+
}: EventListRowWideSkeletonProps) {
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
aria-hidden="true"
|
|
346
|
+
className={cn(
|
|
347
|
+
"relative flex w-full items-start gap-[18px] py-[18px] text-left",
|
|
348
|
+
className
|
|
349
|
+
)}
|
|
350
|
+
>
|
|
351
|
+
{!hideRule && <Separator className="absolute inset-x-0 top-0" />}
|
|
352
|
+
|
|
353
|
+
<div className="w-[52px] shrink-0 rounded-[10px] py-[7px] text-center">
|
|
354
|
+
<Skeleton className="mx-auto h-5 w-6" />
|
|
355
|
+
<Skeleton className="mx-auto mt-[3px] h-[10px] w-7" />
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<Skeleton className="aspect-square w-[104px] shrink-0 rounded-[10px]" />
|
|
359
|
+
|
|
360
|
+
<div className="min-w-0 flex-1">
|
|
361
|
+
<Skeleton className="h-[16.5px] w-3/5" />
|
|
362
|
+
|
|
363
|
+
<div className="mt-[5px] flex items-center gap-2">
|
|
364
|
+
<Skeleton className="h-3 w-10" />
|
|
365
|
+
<Skeleton className="h-3 w-3 shrink-0 rounded-full" />
|
|
366
|
+
<Skeleton className="h-3 w-24" />
|
|
367
|
+
<Skeleton className="h-[22px] w-16 rounded-full" />
|
|
368
|
+
<Skeleton className="h-[22px] w-12 rounded-full" />
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
{showDescription && (
|
|
372
|
+
<div className="mt-2 max-w-[620px] space-y-1.5">
|
|
373
|
+
<Skeleton className="h-[13.5px] w-full" />
|
|
374
|
+
<Skeleton className="h-[13.5px] w-2/3" />
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div className="flex w-[150px] shrink-0 flex-col items-end gap-[9px]">
|
|
380
|
+
<Skeleton className="h-[15px] w-16" />
|
|
381
|
+
<Skeleton className="h-6 w-20 rounded-full" />
|
|
382
|
+
<Skeleton className="h-3 w-20" />
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { EventListRow, EventListRowSkeleton } from "./event-list-row";
|
|
3
|
+
import type { EventDateBadgeFormatters, EventListRowLabels, EventPreview } from "./event-types";
|
|
4
|
+
import { Separator } from "./separator";
|
|
5
|
+
|
|
6
|
+
const labels: EventListRowLabels = {
|
|
7
|
+
free: "Free",
|
|
8
|
+
liveNow: "On now",
|
|
9
|
+
soldOut: "Sold out",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const formatters: EventDateBadgeFormatters = {
|
|
13
|
+
time: (iso) => new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
|
|
14
|
+
day: (iso) =>
|
|
15
|
+
new Date(iso).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
|
|
16
|
+
price: (amount) => `${amount} RON`,
|
|
17
|
+
dayNumber: (iso) => String(new Date(iso).getDate()).padStart(2, "0"),
|
|
18
|
+
monthShort: (iso) =>
|
|
19
|
+
new Date(iso).toLocaleDateString("en-GB", { month: "short" }).toUpperCase(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const event = (
|
|
23
|
+
id: string,
|
|
24
|
+
title: string,
|
|
25
|
+
place: string,
|
|
26
|
+
startsAt: string,
|
|
27
|
+
fee?: number
|
|
28
|
+
): EventPreview => ({ id, title, location: place, place_name: place, starts_at: startsAt, entry_fee: fee });
|
|
29
|
+
|
|
30
|
+
const meta: Meta<typeof EventListRow> = {
|
|
31
|
+
title: "Events/EventListRow",
|
|
32
|
+
component: EventListRow,
|
|
33
|
+
tags: ["autodocs"],
|
|
34
|
+
args: { labels, formatters },
|
|
35
|
+
decorators: [
|
|
36
|
+
(Story) => (
|
|
37
|
+
<div className="w-[358px]">
|
|
38
|
+
<Story />
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
],
|
|
42
|
+
parameters: {
|
|
43
|
+
docs: {
|
|
44
|
+
description: {
|
|
45
|
+
component:
|
|
46
|
+
"The dense row for the list view — roughly eight events per screen against the poster card's two. Note it carries a trailing CTA, which the poster card deliberately does not: cards elsewhere mean one thing, 'open me'. That divergence is intentional per the source design; reconcile both surfaces if it ever changes.",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default meta;
|
|
53
|
+
type Story = StoryObj<typeof EventListRow>;
|
|
54
|
+
|
|
55
|
+
export const Default: Story = {
|
|
56
|
+
args: { event: event("1", "Psihotrop Live", "Atelier Café", "2026-09-05T20:00:00", 50) },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const FreeEntry: Story = {
|
|
60
|
+
args: { event: event("2", "Vinyl Happy Hour", "Zorki Photo Cafe", "2026-09-04T18:00:00", 0) },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** No published fee is "TBC", never "Free". */
|
|
64
|
+
export const UnknownPrice: Story = {
|
|
65
|
+
args: { event: event("3", "Quiz Night", "Flying Circus", "2026-09-03T19:00:00") },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Live tints the date badge and adds the on-air chip. */
|
|
69
|
+
export const LiveNow: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
event: event("4", "Charity Concert & Afterparty", "Flying Circus", "2026-09-03T21:00:00", 30),
|
|
72
|
+
isLive: true,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Sold out dims the row and swaps the CTA to "Notify me". */
|
|
77
|
+
export const SoldOut: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
event: event("5", "Friday Night Techno", "Form Space", "2026-09-04T22:00:00", 50),
|
|
80
|
+
isSoldOut: true,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A long title wraps in full and the row grows to fit it. It used to clamp at
|
|
86
|
+
* two lines; that clamp was the reported defect, so this story now proves the
|
|
87
|
+
* opposite.
|
|
88
|
+
*/
|
|
89
|
+
export const LongTitle: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
event: event(
|
|
92
|
+
"6",
|
|
93
|
+
"Stand-up Comedy with Dobrotă, Cîrje & Florin — The Late Show, second edition",
|
|
94
|
+
"Klausen Pubhouse",
|
|
95
|
+
"2026-09-04T19:30:00",
|
|
96
|
+
45
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
|
|
102
|
+
export const Loading: Story = {
|
|
103
|
+
render: () => <EventListRowSkeleton />,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** A stack of loading rows, plus one loaded row for comparison. */
|
|
107
|
+
export const LoadingVsLoaded: Story = {
|
|
108
|
+
render: () => (
|
|
109
|
+
<div>
|
|
110
|
+
<EventListRowSkeleton />
|
|
111
|
+
<EventListRowSkeleton />
|
|
112
|
+
<EventListRow
|
|
113
|
+
event={event("1", "Psihotrop Live", "Atelier Café", "2026-09-05T20:00:00", 50)}
|
|
114
|
+
labels={labels}
|
|
115
|
+
formatters={formatters}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** A day's worth of rows, as the list view stacks them. */
|
|
122
|
+
export const ADay: Story = {
|
|
123
|
+
render: () => (
|
|
124
|
+
<div>
|
|
125
|
+
<div className="flex items-center gap-2.5 py-4">
|
|
126
|
+
<span className="text-sm font-extrabold">Today · Thursday 3</span>
|
|
127
|
+
<span className="text-[11.5px] text-muted-foreground">15 events</span>
|
|
128
|
+
<Separator className="flex-1" />
|
|
129
|
+
</div>
|
|
130
|
+
<EventListRow
|
|
131
|
+
event={event("a", "Psihotrop Live", "Atelier Café", "2026-09-03T20:00:00", 50)}
|
|
132
|
+
labels={labels}
|
|
133
|
+
formatters={formatters}
|
|
134
|
+
isLive
|
|
135
|
+
/>
|
|
136
|
+
<EventListRow
|
|
137
|
+
event={event("b", "Quiz Night", "Flying Circus", "2026-09-03T20:00:00")}
|
|
138
|
+
labels={labels}
|
|
139
|
+
formatters={formatters}
|
|
140
|
+
/>
|
|
141
|
+
<EventListRow
|
|
142
|
+
event={event("c", "Beer Battle", "Irish Music Pub", "2026-09-03T20:00:00", 0)}
|
|
143
|
+
labels={labels}
|
|
144
|
+
formatters={formatters}
|
|
145
|
+
/>
|
|
146
|
+
<EventListRow
|
|
147
|
+
event={event("d", "COJO", "MATTER", "2026-09-03T20:30:00", 40)}
|
|
148
|
+
labels={labels}
|
|
149
|
+
formatters={formatters}
|
|
150
|
+
/>
|
|
151
|
+
<EventListRow
|
|
152
|
+
event={event("e", "Schoolotheque Afterparty", "Flying Circus", "2026-09-03T22:00:00", 25)}
|
|
153
|
+
labels={labels}
|
|
154
|
+
formatters={formatters}
|
|
155
|
+
isSoldOut
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
),
|
|
159
|
+
};
|