@bearmenu/ui 0.8.3 → 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-2WDJR6K7.js +76 -0
- 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/icon.d.ts +18 -0
- package/dist/components/icon.js +31 -0
- package/dist/components/item.d.ts +2 -2
- package/dist/components/motion-icon-controls.d.ts +26 -0
- package/dist/components/motion-icon-controls.js +4 -0
- package/dist/components/motion-icon-set.d.ts +27 -0
- package/dist/components/motion-icon-set.js +139 -0
- package/dist/components/motion-icon.d.ts +44 -0
- package/dist/components/motion-icon.js +3 -0
- 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/dist/lib/icon-registry.d.ts +58 -0
- package/dist/lib/icon-registry.js +39 -0
- package/package.json +2 -1
- package/src/components/accordion.stories.tsx +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/combobox.stories.tsx +1 -1
- 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/currency-input.stories.tsx +3 -3
- 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/empty-state.stories.tsx +3 -3
- 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/form.stories.tsx +1 -1
- 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/icon.tsx +57 -0
- package/src/components/input-otp.stories.tsx +1 -1
- package/src/components/markdown-renderer.stories.tsx +1 -1
- package/src/components/motion-icon-controls.test.tsx +78 -0
- package/src/components/motion-icon-controls.tsx +552 -0
- package/src/components/motion-icon-set.stories.tsx +185 -0
- package/src/components/motion-icon-set.tsx +219 -0
- package/src/components/motion-icon.test.tsx +159 -0
- package/src/components/motion-icon.tsx +163 -0
- package/src/components/multi-select-combobox.stories.tsx +1 -1
- package/src/components/phone-frame.stories.tsx +1 -1
- 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/rating.stories.tsx +3 -3
- package/src/components/searchable-select.stories.tsx +1 -1
- 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/selection-bar.stories.tsx +1 -1
- package/src/components/skeleton-card.stories.tsx +3 -3
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sliding-panels.stories.tsx +1 -1
- package/src/components/stepper.stories.tsx +1 -1
- package/src/components/sticky-container.stories.tsx +3 -3
- 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/toggle-group.stories.tsx +1 -1
- package/src/components/tooltip.stories.tsx +2 -2
- 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
- package/src/lib/icon-registry.ts +139 -0
- package/src/test/setup.ts +5 -1
|
@@ -0,0 +1,335 @@
|
|
|
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 { EventPosterFallback } from "./event-poster-fallback";
|
|
8
|
+
import { Separator } from "./separator";
|
|
9
|
+
import { Skeleton } from "./skeleton";
|
|
10
|
+
import { Tag } from "./tag";
|
|
11
|
+
import { TagGroup } from "./tag-group";
|
|
12
|
+
import {
|
|
13
|
+
resolveEventEntryFee,
|
|
14
|
+
type EventDateBadgeFormatters,
|
|
15
|
+
type EventListRowLabels,
|
|
16
|
+
type EventPreview,
|
|
17
|
+
} from "./event-types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* EventListRow — the dense row used by the list view at phone width.
|
|
21
|
+
*
|
|
22
|
+
* Roughly eight events per screen against the poster card's two, which is the
|
|
23
|
+
* whole reason the list view exists: fourteen days of inventory is punishing to
|
|
24
|
+
* scan in posters.
|
|
25
|
+
*
|
|
26
|
+
* NOTE ON THE ROW ACTION: this row used to carry a trailing "Reserve" CTA that
|
|
27
|
+
* `<EventCard>` deliberately does not, and an earlier version of this comment
|
|
28
|
+
* asked for the two surfaces to be reconciled rather than for the CTA to be
|
|
29
|
+
* quietly dropped. This is that reconciliation, and it lands differently per
|
|
30
|
+
* width. At phone width the CTA is gone: it was an inert `<span>` inside the
|
|
31
|
+
* row's own anchor, so it was drawn like a booking control (`text-primary`,
|
|
32
|
+
* bold) while doing nothing that a tap anywhere else on the row did not
|
|
33
|
+
* already do. `<EventListRowWide>` keeps a real action column, because desktop
|
|
34
|
+
* has the width for one and its row root is a container rather than a single
|
|
35
|
+
* anchor. If a CTA comes back here, it has to be a real one, not a span.
|
|
36
|
+
*
|
|
37
|
+
* NOTE ON WEIGHT: nothing in this row is heavier than 600, and only the title
|
|
38
|
+
* and the date badge reach it. Eight of these stack per screen, and at that
|
|
39
|
+
* density hierarchy comes from size and ink tier, not from weight — a field of
|
|
40
|
+
* bold text has no foreground. `Free` is the one exception (600 plus
|
|
41
|
+
* `text-success`), because it is an inducement rather than an attribute.
|
|
42
|
+
*
|
|
43
|
+
* Same contract as the rest: DS-owned view model, injected labels and
|
|
44
|
+
* formatters, plain `<img>`, anchor whenever `href` is given.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export type EventListRowLinkProps = {
|
|
48
|
+
href: string;
|
|
49
|
+
className?: string;
|
|
50
|
+
children: React.ReactNode;
|
|
51
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function DefaultLink({ href, ...props }: EventListRowLinkProps) {
|
|
55
|
+
return <a href={href} {...props} />;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type EventListRowProps = {
|
|
59
|
+
event: EventPreview;
|
|
60
|
+
labels: EventListRowLabels;
|
|
61
|
+
formatters: EventDateBadgeFormatters;
|
|
62
|
+
/** Currently running — fills the date badge and shows the on-air chip. */
|
|
63
|
+
isLive?: boolean;
|
|
64
|
+
/** Sold out or otherwise closed — dims the row and replaces the fee. */
|
|
65
|
+
isSoldOut?: boolean;
|
|
66
|
+
href?: string;
|
|
67
|
+
linkComponent?: React.ComponentType<EventListRowLinkProps>;
|
|
68
|
+
onClick?: (event: EventPreview, mouseEvent: React.MouseEvent) => void;
|
|
69
|
+
className?: string;
|
|
70
|
+
priority?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Caller-resolved tag display labels for the chip line, e.g.
|
|
73
|
+
* `["Rock", "Terrace"]`. Same contract as `EventCompactRowProps.tags`: the
|
|
74
|
+
* DS holds no taxonomy display names, so the caller resolves AND caps them
|
|
75
|
+
* — two is what fits beside the live chip at 375px. Anything that does not
|
|
76
|
+
* fit is clipped whole rather than counted; see the chip line below.
|
|
77
|
+
*/
|
|
78
|
+
tags?: string[];
|
|
79
|
+
/** Rendered instead of the thumbnail when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
|
|
80
|
+
fallback?: ReactNode;
|
|
81
|
+
/**
|
|
82
|
+
* Drops this row's top rule. Pass it on the FIRST row under a day heading:
|
|
83
|
+
* the heading already ends the block above it, so the row's own rule lands
|
|
84
|
+
* immediately beneath it and draws two dividers a few pixels apart. The gap
|
|
85
|
+
* is the separator there — whitespace does the same job without the second
|
|
86
|
+
* line. Same prop and same reason as `EventListRowWide` and `EventIndexRow`.
|
|
87
|
+
*/
|
|
88
|
+
hideRule?: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const EventListRow = memo(function EventListRow({
|
|
92
|
+
event,
|
|
93
|
+
labels,
|
|
94
|
+
formatters,
|
|
95
|
+
isLive,
|
|
96
|
+
isSoldOut,
|
|
97
|
+
href,
|
|
98
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
99
|
+
onClick,
|
|
100
|
+
className,
|
|
101
|
+
priority,
|
|
102
|
+
tags,
|
|
103
|
+
fallback = <EventPosterFallback />,
|
|
104
|
+
hideRule,
|
|
105
|
+
}: EventListRowProps) {
|
|
106
|
+
const [imgError, setImgError] = useState(false);
|
|
107
|
+
|
|
108
|
+
const fee = resolveEventEntryFee(event);
|
|
109
|
+
const showPoster = !!event.poster_url && !imgError;
|
|
110
|
+
const venue = event.place_name ?? event.location;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `undefined` when the fee is UNKNOWN — the row renders nothing rather than
|
|
114
|
+
* a "Price TBA" placeholder, for the reasons `<EventListRowWide>` spells out
|
|
115
|
+
* at length. Unknown is the commonest case in this feed, so the placeholder
|
|
116
|
+
* was a repeated announcement that we do not know something, and it taught
|
|
117
|
+
* the reader to skip the one slot where a real price appears.
|
|
118
|
+
*/
|
|
119
|
+
const priceText =
|
|
120
|
+
fee.kind === "free"
|
|
121
|
+
? labels.free
|
|
122
|
+
: fee.kind === "paid"
|
|
123
|
+
? formatters.price(fee.amountRon)
|
|
124
|
+
: undefined;
|
|
125
|
+
|
|
126
|
+
const body = (
|
|
127
|
+
<>
|
|
128
|
+
{!hideRule && <Separator className="absolute inset-x-0 top-0" />}
|
|
129
|
+
|
|
130
|
+
{/* No resting fill. Eight tinted chips per screen was the loudest
|
|
131
|
+
texture in the gutter and meant nothing; the fill now belongs to
|
|
132
|
+
`isLive` alone, so the only filled badge on screen is the live one.
|
|
133
|
+
The geometry is identical either way, so nothing shifts when the
|
|
134
|
+
tint appears. */}
|
|
135
|
+
<div
|
|
136
|
+
className={cn("w-[42px] shrink-0 rounded-[9px] py-1.5 text-center", isLive && "bg-success/10")}
|
|
137
|
+
>
|
|
138
|
+
<div
|
|
139
|
+
className={cn(
|
|
140
|
+
"text-sm font-semibold leading-none tabular-nums",
|
|
141
|
+
isLive ? "text-success" : "text-foreground"
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
{formatters.dayNumber(event.starts_at)}
|
|
145
|
+
</div>
|
|
146
|
+
<div
|
|
147
|
+
className={cn(
|
|
148
|
+
"mt-[3px] text-[10px] font-semibold tracking-[0.06em]",
|
|
149
|
+
isLive ? "text-success" : "text-muted-foreground"
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
{formatters.monthShort(event.starts_at)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="aspect-square w-[72px] shrink-0 overflow-hidden rounded-[9px] bg-muted">
|
|
157
|
+
{showPoster ? (
|
|
158
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
159
|
+
<img
|
|
160
|
+
src={event.poster_url}
|
|
161
|
+
alt=""
|
|
162
|
+
loading={priority ? "eager" : "lazy"}
|
|
163
|
+
decoding="async"
|
|
164
|
+
className="h-full w-full object-cover"
|
|
165
|
+
onError={() => setImgError(true)}
|
|
166
|
+
/>
|
|
167
|
+
) : (
|
|
168
|
+
fallback
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className="min-w-0 flex-1">
|
|
173
|
+
{/* Full title, never clamped. The row is `items-start`, so extra lines
|
|
174
|
+
grow the row downward past the 72px poster instead of squeezing
|
|
175
|
+
anything. */}
|
|
176
|
+
<div className="text-[15px] font-semibold leading-[1.3] tracking-[-0.01em]">{event.title}</div>
|
|
177
|
+
|
|
178
|
+
{/* WHEN and WHERE, with the fee pinned right. Time leads because a
|
|
179
|
+
time-sorted day group scans down that column. No `·` between time
|
|
180
|
+
and venue: the `MapPin` is the field marker, so a dot as well is
|
|
181
|
+
two separators doing one job — the glyph costs 6px net, not 16.
|
|
182
|
+
The pin is the same one the filter rail uses for VENUE. */}
|
|
183
|
+
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
184
|
+
<span className="shrink-0 tabular-nums text-foreground">
|
|
185
|
+
{formatters.time(event.starts_at)}
|
|
186
|
+
</span>
|
|
187
|
+
|
|
188
|
+
<span className="flex min-w-0 flex-1 items-center gap-1">
|
|
189
|
+
<MapPin className="h-3 w-3 shrink-0" aria-hidden />
|
|
190
|
+
<span className="truncate">{venue}</span>
|
|
191
|
+
</span>
|
|
192
|
+
|
|
193
|
+
{/* A sold-out event's price is not a fact anyone needs, so the chip
|
|
194
|
+
takes the fee's slot rather than sitting beside it. The fee stays
|
|
195
|
+
on THIS line rather than joining the chips: it is the row's only
|
|
196
|
+
inducement, and among decorative chips it would either become one
|
|
197
|
+
or duplicate the live chip's green with a second meaning. */}
|
|
198
|
+
{isSoldOut ? (
|
|
199
|
+
<Tag size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
|
|
200
|
+
{labels.soldOut}
|
|
201
|
+
</Tag>
|
|
202
|
+
) : (
|
|
203
|
+
priceText && (
|
|
204
|
+
<span
|
|
205
|
+
className={cn(
|
|
206
|
+
"shrink-0 tabular-nums",
|
|
207
|
+
fee.kind === "free" && "font-semibold text-success"
|
|
208
|
+
)}
|
|
209
|
+
>
|
|
210
|
+
{priceText}
|
|
211
|
+
</span>
|
|
212
|
+
)
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* WHAT. Status first, then attributes. Live is the only FILLED chip
|
|
217
|
+
on the surface, exactly as it is the only filled date badge —
|
|
218
|
+
under deuteranopia the green/grey hues converge, so the fill and
|
|
219
|
+
the `Radio` glyph are what actually separate it, not the hue.
|
|
220
|
+
|
|
221
|
+
`h-[22px] overflow-hidden` is the overflow mechanism: `TagGroup`
|
|
222
|
+
already wraps, so a chip that does not fit moves to a second line
|
|
223
|
+
that is clipped away, and it disappears WHOLE. No half-chips, no
|
|
224
|
+
ellipsis inside a pill, and it is width-safe for caller strings of
|
|
225
|
+
any length — which a fixed count is not. Deliberately no "+2"
|
|
226
|
+
counter: nothing in this row is tappable, so a counter would
|
|
227
|
+
promise a disclosure that does not exist, eight times per screen.
|
|
228
|
+
|
|
229
|
+
The line collapses when there is nothing to say. A "Tags" filler
|
|
230
|
+
would be the same mistake as "Price TBA" — the row's spare slot
|
|
231
|
+
announcing an absence. */}
|
|
232
|
+
{(isLive || (tags && tags.length > 0)) && (
|
|
233
|
+
<TagGroup className="mt-1.5 h-[22px] overflow-hidden">
|
|
234
|
+
{isLive && (
|
|
235
|
+
<Tag size="sm" variant="success" className="shrink-0 whitespace-nowrap">
|
|
236
|
+
<Radio className="h-2.5 w-2.5" aria-hidden />
|
|
237
|
+
{labels.liveNow}
|
|
238
|
+
</Tag>
|
|
239
|
+
)}
|
|
240
|
+
{tags?.map((tag) => (
|
|
241
|
+
<Tag key={tag} size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
|
|
242
|
+
{tag}
|
|
243
|
+
</Tag>
|
|
244
|
+
))}
|
|
245
|
+
</TagGroup>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
</>
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const rootClasses = cn(
|
|
252
|
+
// `py-3`, not `py-3.5`: the chip line adds a third line back, and 8px of
|
|
253
|
+
// padding pays for most of it. Rows with a one-line title and no chips are
|
|
254
|
+
// still poster-driven and end up SHORTER than before.
|
|
255
|
+
"relative flex w-full items-start gap-3 py-3 text-left",
|
|
256
|
+
isSoldOut && "opacity-60",
|
|
257
|
+
(href || onClick) &&
|
|
258
|
+
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
259
|
+
className
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (href) {
|
|
263
|
+
return (
|
|
264
|
+
<LinkComponent
|
|
265
|
+
href={href}
|
|
266
|
+
className={rootClasses}
|
|
267
|
+
onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
|
|
268
|
+
>
|
|
269
|
+
{body}
|
|
270
|
+
</LinkComponent>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (onClick) {
|
|
275
|
+
return (
|
|
276
|
+
<button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
|
|
277
|
+
{body}
|
|
278
|
+
</button>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return <div className={rootClasses}>{body}</div>;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
export type EventListRowSkeletonProps = {
|
|
286
|
+
/** Matches `EventListRowProps["hideRule"]` — drop the top rule on the first row of a day group. */
|
|
287
|
+
hideRule?: boolean;
|
|
288
|
+
/** Reserve the chip line. Matches the real row, where it collapses on events with no live status and no tags. */
|
|
289
|
+
showTags?: boolean;
|
|
290
|
+
className?: string;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/** Mirrors `<EventListRow>`'s geometry exactly so nothing shifts when data lands. */
|
|
294
|
+
export function EventListRowSkeleton({
|
|
295
|
+
hideRule,
|
|
296
|
+
showTags = true,
|
|
297
|
+
className,
|
|
298
|
+
}: EventListRowSkeletonProps) {
|
|
299
|
+
return (
|
|
300
|
+
<div
|
|
301
|
+
aria-hidden="true"
|
|
302
|
+
className={cn("relative flex w-full items-start gap-3 py-3 text-left", className)}
|
|
303
|
+
>
|
|
304
|
+
{!hideRule && <Separator className="absolute inset-x-0 top-0" />}
|
|
305
|
+
|
|
306
|
+
<div className="w-[42px] shrink-0 rounded-[9px] py-1.5 text-center">
|
|
307
|
+
<Skeleton className="mx-auto h-4 w-5" />
|
|
308
|
+
<Skeleton className="mx-auto mt-[3px] h-[10px] w-6" />
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<Skeleton className="aspect-square w-[72px] shrink-0 rounded-[9px]" />
|
|
312
|
+
|
|
313
|
+
<div className="min-w-0 flex-1">
|
|
314
|
+
<div className="space-y-1">
|
|
315
|
+
<Skeleton className="h-[15px] w-full" />
|
|
316
|
+
<Skeleton className="h-[15px] w-2/3" />
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div className="mt-1 flex items-center gap-2">
|
|
320
|
+
<Skeleton className="h-3 w-10" />
|
|
321
|
+
<Skeleton className="h-3 w-3 shrink-0 rounded-full" />
|
|
322
|
+
<Skeleton className="h-3 w-24" />
|
|
323
|
+
<Skeleton className="ml-auto h-3 w-12" />
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
{showTags && (
|
|
327
|
+
<div className="mt-1.5 flex items-center gap-1.5">
|
|
328
|
+
<Skeleton className="h-[22px] w-14 rounded-full" />
|
|
329
|
+
<Skeleton className="h-[22px] w-11 rounded-full" />
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Music, PartyPopper } from "lucide-react";
|
|
3
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
4
|
+
|
|
5
|
+
// Explicitly annotated rather than `satisfies`: the meta-level decorator makes
|
|
6
|
+
// the inferred type reference storybook's internal CSF module, which tsc cannot
|
|
7
|
+
// name portably (TS2742).
|
|
8
|
+
const meta: Meta<typeof EventPosterFallback> = {
|
|
9
|
+
title: "Events/EventPosterFallback",
|
|
10
|
+
component: EventPosterFallback,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
decorators: [
|
|
13
|
+
(Story) => (
|
|
14
|
+
<div className="aspect-[4/3] w-[220px] overflow-hidden rounded-xl">
|
|
15
|
+
<Story />
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
],
|
|
19
|
+
parameters: {
|
|
20
|
+
docs: {
|
|
21
|
+
description: {
|
|
22
|
+
component:
|
|
23
|
+
"What every events surface renders in place of a missing or failed poster — a warm `--gradient-primary` fill with a drop-shadowed icon, never a bare icon on a flat background. The consuming app passes a tag-derived icon; the default is a calendar glyph.",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
type Story = StoryObj<typeof EventPosterFallback>;
|
|
31
|
+
|
|
32
|
+
export const Default: Story = {};
|
|
33
|
+
|
|
34
|
+
/** The consuming app passes a category-derived icon (e.g. from its own `getEventFallbackIcon`). */
|
|
35
|
+
export const CustomIcon: Story = {
|
|
36
|
+
args: { icon: <PartyPopper className="h-10 w-10" aria-hidden /> },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const MusicCategory: Story = {
|
|
40
|
+
args: { icon: <Music className="h-10 w-10" aria-hidden /> },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Portrait poster shape — the same fallback in the hub's 4:5 editorial slots. */
|
|
44
|
+
export const PortraitPoster: Story = {
|
|
45
|
+
decorators: [
|
|
46
|
+
(Story) => (
|
|
47
|
+
<div className="aspect-[4/5] w-[186px] overflow-hidden rounded-xl">
|
|
48
|
+
<Story />
|
|
49
|
+
</div>
|
|
50
|
+
),
|
|
51
|
+
],
|
|
52
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { CalendarDays } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* EventPosterFallback — what every events surface renders in place of a
|
|
7
|
+
* missing (or failed) poster image.
|
|
8
|
+
*
|
|
9
|
+
* Follows the DS image-fallback rule: never a bare icon on a flat background.
|
|
10
|
+
* `--gradient-primary` is warm in both themes, so the drop-shadowed icon
|
|
11
|
+
* always sits on a tinted, on-brand surface rather than the cold, zero-chroma
|
|
12
|
+
* `bg-muted` block a poster box would otherwise show.
|
|
13
|
+
*/
|
|
14
|
+
export type EventPosterFallbackProps = {
|
|
15
|
+
/** Defaults to a calendar glyph. Pass a tag-derived icon (e.g. `getEventFallbackIcon`) for a closer match to the event's category. */
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function EventPosterFallback({ icon, className }: EventPosterFallbackProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className={cn("flex h-full w-full items-center justify-center", className)}
|
|
24
|
+
style={{ background: "var(--gradient-primary)" }}
|
|
25
|
+
>
|
|
26
|
+
<span className="text-primary-foreground/85 drop-shadow-[0_2px_6px_oklch(0_0_0/0.35)]">
|
|
27
|
+
{icon ?? <CalendarDays className="h-10 w-10" aria-hidden />}
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural mirror of the openapi `Event` schema, decoupled from any one app's
|
|
3
|
+
* generated types. Both FE and admin map their domain shapes into this type
|
|
4
|
+
* before passing data to `<EventCard>` / `<EventListRow>` / `<EventDaySection>`.
|
|
5
|
+
*
|
|
6
|
+
* Same contract as `place-types.ts`: no app schemas, no app hooks, no i18n.
|
|
7
|
+
* Every user-facing string arrives through a `*Labels` object, and every
|
|
8
|
+
* locale-sensitive format arrives as an injected formatter — the DS never owns
|
|
9
|
+
* a translation key or an `Intl` locale.
|
|
10
|
+
*/
|
|
11
|
+
export type EventPreview = {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
/** Short teaser (≤79 chars) for tight surfaces. Absent for events without one. */
|
|
16
|
+
short_description?: string;
|
|
17
|
+
/** Free-text venue/location line as returned by the API. */
|
|
18
|
+
location: string;
|
|
19
|
+
/** ISO 8601 date-time. */
|
|
20
|
+
starts_at: string;
|
|
21
|
+
/** ISO 8601 date-time. Absent for events with no published end. */
|
|
22
|
+
ends_at?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Entry fee in RON. `0` means explicitly free; `undefined` means unknown.
|
|
25
|
+
* The distinction is load-bearing — see `resolveEventEntryFee`.
|
|
26
|
+
*/
|
|
27
|
+
entry_fee?: number;
|
|
28
|
+
place_name?: string;
|
|
29
|
+
place_slug?: string;
|
|
30
|
+
/** 0–100 social-buzz score. `0` for events with no engagement signal. */
|
|
31
|
+
hype_score?: number;
|
|
32
|
+
performers?: EventPerformerRef[];
|
|
33
|
+
tags?: EventTagRef[];
|
|
34
|
+
event_concept_slug?: string;
|
|
35
|
+
event_concept_title?: string;
|
|
36
|
+
/** Booking URL, when the host takes bookings through a link. */
|
|
37
|
+
bookings?: string;
|
|
38
|
+
/** Ticketing URL, when the event is ticketed. */
|
|
39
|
+
tickets?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Pre-resolved poster URL. Callers compute this themselves — the API returns
|
|
42
|
+
* a `Photo.key`, and CDN base plus transform differ per app.
|
|
43
|
+
*/
|
|
44
|
+
poster_url?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** A taxonomy tag. `slug` is only unique within its `axis_slug`. */
|
|
48
|
+
export type EventTagRef = {
|
|
49
|
+
slug: string;
|
|
50
|
+
axis_slug: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type EventPerformerRef = {
|
|
54
|
+
name: string;
|
|
55
|
+
kind: "person" | "group";
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Entry-fee state. `unknown` is a real third case, not a missing `paid` — an
|
|
60
|
+
* event with no published fee must never render as free.
|
|
61
|
+
*/
|
|
62
|
+
export type EventEntryFee =
|
|
63
|
+
| { kind: "free" }
|
|
64
|
+
| { kind: "paid"; amountRon: number }
|
|
65
|
+
| { kind: "unknown" };
|
|
66
|
+
|
|
67
|
+
/** Structural, not editorial — safe to derive inside the DS. */
|
|
68
|
+
export function resolveEventEntryFee(event: EventPreview): EventEntryFee {
|
|
69
|
+
if (event.entry_fee === undefined) return { kind: "unknown" };
|
|
70
|
+
if (event.entry_fee === 0) return { kind: "free" };
|
|
71
|
+
return { kind: "paid", amountRon: event.entry_fee };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Locale-sensitive formatters, injected by the caller. The DS holds no `Intl`
|
|
76
|
+
* locale and no currency of its own.
|
|
77
|
+
*/
|
|
78
|
+
export type EventFormatters = {
|
|
79
|
+
/** e.g. `"20:00"` */
|
|
80
|
+
time: (isoDateTime: string) => string;
|
|
81
|
+
/** e.g. `"Sat, Sep 5"` — used when a card appears outside its own day section. */
|
|
82
|
+
day: (isoDateTime: string) => string;
|
|
83
|
+
/** e.g. `50 => "50 RON"` */
|
|
84
|
+
price: (amountRon: number) => string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The extra formatters a date badge needs, split out rather than added to
|
|
89
|
+
* `EventFormatters` so `<EventCard>` keeps asking for only what it uses.
|
|
90
|
+
* `<EventListRow>` and the timeline's day headers need the parts separately.
|
|
91
|
+
*/
|
|
92
|
+
export type EventDateBadgeFormatters = EventFormatters & {
|
|
93
|
+
/** Day of month as displayed, e.g. `"05"`. */
|
|
94
|
+
dayNumber: (isoDateTime: string) => string;
|
|
95
|
+
/** Short month, e.g. `"SEP"`. */
|
|
96
|
+
monthShort: (isoDateTime: string) => string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Labels passed to `<EventCard>`. All translation happens in the caller.
|
|
101
|
+
*
|
|
102
|
+
* There is no `priceUnknown` here on purpose. An unknown fee is the commonest
|
|
103
|
+
* case in this feed, and every surface that used to render a placeholder for
|
|
104
|
+
* it now renders nothing — see the long note in `event-list-row-wide.tsx`.
|
|
105
|
+
*/
|
|
106
|
+
export type EventCardLabels = {
|
|
107
|
+
free: string;
|
|
108
|
+
/** Badge shown while the event is currently running. */
|
|
109
|
+
liveNow: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Labels passed to `<EventListRow>`. Extends the card's with the sold-out
|
|
114
|
+
* chip; the row's ACTION labels live on `EventListRowWideLabels`, since the
|
|
115
|
+
* phone row has no action to label.
|
|
116
|
+
*/
|
|
117
|
+
export type EventListRowLabels = EventCardLabels & {
|
|
118
|
+
soldOut: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Labels passed to `<DayRailChip>`. */
|
|
122
|
+
export type DayRailChipLabels = {
|
|
123
|
+
/** Screen-reader text for a day with no events, rendered where the count sits. */
|
|
124
|
+
noEvents: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `<EventDayHeader>` takes no labels object. Its meta line is one caller-composed
|
|
129
|
+
* string, because the shapes the design uses do not decompose into counts:
|
|
130
|
+
* "September · 15 events · 3 live", "September · nothing listed yet",
|
|
131
|
+
* "September · 2 of 23 are free". Composing those from parts inside the DS would
|
|
132
|
+
* hardcode English clause order.
|
|
133
|
+
*/
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { CalendarPlus, MapPin } from "lucide-react";
|
|
3
|
+
import { EventsEmptyState } from "./events-empty-state";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof EventsEmptyState> = {
|
|
6
|
+
title: "Events/EventsEmptyState",
|
|
7
|
+
component: EventsEmptyState,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[420px]">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"\"Nothing on in this city\" — built fresh rather than composed from `empty-state.tsx`, since its actions have no `href`/link-component path and its button chrome doesn't produce this design's dark primary chip. The illustration follows the image-fallback rule: a photo when given, otherwise a tinted gradient circle with a drop-shadowed icon.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof EventsEmptyState>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
title: "Nothing on right now",
|
|
32
|
+
body: "There's no listed events in this city for the next two weeks — check back soon or explore what's on nearby.",
|
|
33
|
+
primaryAction: { label: "Browse nearby cities", icon: <MapPin className="h-4 w-4" aria-hidden />, href: "#" },
|
|
34
|
+
secondaryAction: { label: "Suggest an event", icon: <CalendarPlus className="h-4 w-4" aria-hidden />, onClick: () => {} },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const WithIllustration: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
...Default.args,
|
|
41
|
+
illustrationUrl:
|
|
42
|
+
"https://images.unsplash.com/photo-1514525253161-7a46d19cd819?w=240&h=240&fit=crop",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const OneActionOnly: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
title: "No matches for these filters",
|
|
49
|
+
body: "Try clearing a filter or two — this city usually has something on.",
|
|
50
|
+
primaryAction: { label: "Clear filters", onClick: () => {} },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const NoActions: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
title: "Nothing scheduled yet",
|
|
57
|
+
body: "This city hasn't had any events published. Check back once venues start listing.",
|
|
58
|
+
},
|
|
59
|
+
};
|