@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,177 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { EventCard } from "./event-card";
|
|
5
|
+
import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
|
|
6
|
+
|
|
7
|
+
const labels: EventCardLabels = {
|
|
8
|
+
free: "Free",
|
|
9
|
+
liveNow: "Live now",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const formatters: EventFormatters = {
|
|
13
|
+
time: () => "20:00",
|
|
14
|
+
day: () => "Sat, Sep 5",
|
|
15
|
+
price: (amount) => `${amount} RON`,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const baseEvent: EventPreview = {
|
|
19
|
+
id: "e1",
|
|
20
|
+
title: "Psihotrop Live",
|
|
21
|
+
location: "Atelier Cafe",
|
|
22
|
+
starts_at: "2026-09-05T20:00:00Z",
|
|
23
|
+
place_name: "Atelier Cafe",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const renderCard = (props: Partial<React.ComponentProps<typeof EventCard>> = {}) =>
|
|
27
|
+
render(<EventCard event={baseEvent} labels={labels} formatters={formatters} {...props} />);
|
|
28
|
+
|
|
29
|
+
describe("EventCard", () => {
|
|
30
|
+
describe("entry fee", () => {
|
|
31
|
+
// The tri-state is the whole point: an absent fee is unknown, NOT free.
|
|
32
|
+
// Rendering "Free" for an unpublished price would be a factual claim the
|
|
33
|
+
// API never made.
|
|
34
|
+
it("shows the free pill only when entry_fee is explicitly 0", () => {
|
|
35
|
+
renderCard({ event: { ...baseEvent, entry_fee: 0 } });
|
|
36
|
+
expect(screen.getByText("Free")).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not show the free pill when entry_fee is absent", () => {
|
|
40
|
+
renderCard();
|
|
41
|
+
expect(screen.queryByText("Free")).not.toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not show the free pill when entry_fee is a real price", () => {
|
|
45
|
+
renderCard({ event: { ...baseEvent, entry_fee: 50 } });
|
|
46
|
+
expect(screen.queryByText("Free")).not.toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("appends the formatted price to the meta line only when asked", () => {
|
|
50
|
+
const { rerender } = renderCard({ event: { ...baseEvent, entry_fee: 50 } });
|
|
51
|
+
expect(screen.queryByText("50 RON")).not.toBeInTheDocument();
|
|
52
|
+
|
|
53
|
+
rerender(
|
|
54
|
+
<EventCard
|
|
55
|
+
event={{ ...baseEvent, entry_fee: 50 }}
|
|
56
|
+
labels={labels}
|
|
57
|
+
formatters={formatters}
|
|
58
|
+
showPrice
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
expect(screen.getByText("50 RON")).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("root element", () => {
|
|
66
|
+
// Regression guard for the measured SEO defect: hydrated event cards used
|
|
67
|
+
// to render as <button onClick>, so the rendered DOM carried no links into
|
|
68
|
+
// the events cluster at all.
|
|
69
|
+
it("renders an anchor when href is given", () => {
|
|
70
|
+
renderCard({ href: "/en/cluj-napoca/events/e/psihotrop-live-e1" });
|
|
71
|
+
expect(screen.getByRole("link", { name: /Psihotrop Live/ })).toHaveAttribute(
|
|
72
|
+
"href",
|
|
73
|
+
"/en/cluj-napoca/events/e/psihotrop-live-e1"
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("still fires onClick from the anchor, so the sheet can intercept it", async () => {
|
|
78
|
+
const onClick = vi.fn();
|
|
79
|
+
renderCard({ href: "/events/e/e1", onClick });
|
|
80
|
+
|
|
81
|
+
await userEvent.click(screen.getByRole("link", { name: /Psihotrop Live/ }));
|
|
82
|
+
expect(onClick).toHaveBeenCalledWith(baseEvent, expect.anything());
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("falls back to a button when only onClick is given", () => {
|
|
86
|
+
renderCard({ onClick: vi.fn() });
|
|
87
|
+
expect(screen.getByRole("button", { name: /Psihotrop Live/ })).toBeInTheDocument();
|
|
88
|
+
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders a non-interactive element when neither href nor onClick is given", () => {
|
|
92
|
+
renderCard();
|
|
93
|
+
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
94
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("meta line", () => {
|
|
99
|
+
it("uses the injected formatters rather than formatting dates itself", () => {
|
|
100
|
+
renderCard();
|
|
101
|
+
expect(screen.getByText("Sat, Sep 5")).toBeInTheDocument();
|
|
102
|
+
expect(screen.getByText("20:00")).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("prefers an explicit dayLabel over the formatted start date", () => {
|
|
106
|
+
renderCard({ dayLabel: "Tomorrow" });
|
|
107
|
+
expect(screen.getByText("Tomorrow")).toBeInTheDocument();
|
|
108
|
+
expect(screen.queryByText("Sat, Sep 5")).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("hides the whole meta line when hideWhen is set", () => {
|
|
112
|
+
renderCard({ hideWhen: true });
|
|
113
|
+
expect(screen.queryByText("20:00")).not.toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("falls back to location when the event carries no place_name", () => {
|
|
117
|
+
renderCard({ event: { ...baseEvent, place_name: undefined, location: "Piata Unirii" } });
|
|
118
|
+
expect(screen.getByText("Piata Unirii")).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("poster", () => {
|
|
123
|
+
it("renders no img when the event has no resolved poster", () => {
|
|
124
|
+
const { container } = renderCard();
|
|
125
|
+
expect(container.querySelector("img")).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("renders the poster the caller resolved", () => {
|
|
129
|
+
renderCard({ event: { ...baseEvent, poster_url: "https://cdn.example/p.avif" } });
|
|
130
|
+
expect(screen.getByAltText("Psihotrop Live")).toHaveAttribute(
|
|
131
|
+
"src",
|
|
132
|
+
"https://cdn.example/p.avif"
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Truncation is CSS-only, so jsdom cannot see the visual result — the class
|
|
138
|
+
// list is the only observable. Asserting on it is worth the coupling here:
|
|
139
|
+
// the reported bug ("Movie Night: Futni m…") was invisible to every other
|
|
140
|
+
// kind of test, and a clamp added back in any variant is exactly what this
|
|
141
|
+
// needs to catch.
|
|
142
|
+
describe("title", () => {
|
|
143
|
+
const longTitle =
|
|
144
|
+
"Movie Night: Futni må ikke være så vanskelig — an outdoor screening with live score";
|
|
145
|
+
|
|
146
|
+
it("renders the whole title, never a shortened copy of it", () => {
|
|
147
|
+
renderCard({ event: { ...baseEvent, title: longTitle } });
|
|
148
|
+
expect(screen.getByRole("heading", { name: longTitle })).toBeInTheDocument();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("is not clamped in the base variant", () => {
|
|
152
|
+
renderCard({ event: { ...baseEvent, title: longTitle } });
|
|
153
|
+
expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
|
|
154
|
+
/line-clamp|truncate/
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("is not clamped in the emphasis variant either", () => {
|
|
159
|
+
renderCard({ event: { ...baseEvent, title: longTitle }, emphasis: true });
|
|
160
|
+
expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
|
|
161
|
+
/line-clamp|truncate/
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it.each(["4/3", "3/4", "4/5"] as const)("is not clamped at posterAspect %s", (posterAspect) => {
|
|
166
|
+
renderCard({ event: { ...baseEvent, title: longTitle }, posterAspect });
|
|
167
|
+
expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
|
|
168
|
+
/line-clamp|truncate/
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("renders the live line the caller composed, not one of its own", () => {
|
|
174
|
+
renderCard({ liveStatus: "Live now · ends around 23:00" });
|
|
175
|
+
expect(screen.getByText("Live now · ends around 23:00")).toBeInTheDocument();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { memo, useState } from "react";
|
|
4
|
+
import { Gift, MapPin, Radio } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { EventPosterFallback } from "./event-poster-fallback";
|
|
7
|
+
import { Skeleton } from "./skeleton";
|
|
8
|
+
import { Tag } from "./tag";
|
|
9
|
+
import { TagGroup } from "./tag-group";
|
|
10
|
+
import {
|
|
11
|
+
resolveEventEntryFee,
|
|
12
|
+
type EventCardLabels,
|
|
13
|
+
type EventFormatters,
|
|
14
|
+
type EventPreview,
|
|
15
|
+
} from "./event-types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* EventCard — the poster card every events surface reuses.
|
|
19
|
+
*
|
|
20
|
+
* RENDERS AN ANCHOR WHENEVER `href` IS GIVEN, and that is not incidental. The
|
|
21
|
+
* consumer app's events hub was measured serving 2,751 words and 50 links in
|
|
22
|
+
* its raw HTML but only 12 links — none of them to an event — in the rendered
|
|
23
|
+
* DOM, because the hydrated cards were `<button onClick>` rather than links.
|
|
24
|
+
* Pass `href` AND `onClick`: the anchor is real for crawlers, middle-click and
|
|
25
|
+
* "open in new tab", while `onClick` opens the detail sheet (call
|
|
26
|
+
* `preventDefault()` there). Falls back to a `<button>` when only `onClick` is
|
|
27
|
+
* given, and a plain `<div>` when neither is.
|
|
28
|
+
*
|
|
29
|
+
* Like `place-card.tsx`, this takes a DS-owned view model, injected labels and
|
|
30
|
+
* injected formatters — no app schema, no app hooks, no i18n, and a plain
|
|
31
|
+
* `<img>` so it works outside Next.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type EventCardLinkProps = {
|
|
35
|
+
href: string;
|
|
36
|
+
className?: string;
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function DefaultLink({ href, ...props }: EventCardLinkProps) {
|
|
42
|
+
return <a href={href} {...props} />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type EventCardProps = {
|
|
46
|
+
event: EventPreview;
|
|
47
|
+
labels: EventCardLabels;
|
|
48
|
+
formatters: EventFormatters;
|
|
49
|
+
/**
|
|
50
|
+
* Poster ratio. `4/3` is the house shape shared with the dish/drink/place
|
|
51
|
+
* cards — the default, and what every card sitting in a column should use.
|
|
52
|
+
* `3/4` is the hub's original portrait deck/rail/tape ratio. `4/5` matches
|
|
53
|
+
* the Instagram source most real posters ship at (1080x1350) and is used by
|
|
54
|
+
* the hub's editorial poster surfaces (`CategoryDeck`, `PosterTape`,
|
|
55
|
+
* `Coverflow`) — it crops less than `3/4` and is not the same as it.
|
|
56
|
+
*/
|
|
57
|
+
posterAspect?: "4/3" | "3/4" | "4/5";
|
|
58
|
+
/** Larger title — the featured/highlight slot. Never a clamp: the title wraps in full either way. */
|
|
59
|
+
emphasis?: boolean;
|
|
60
|
+
/** Top-left overlay pill (the design's "Busy" hype marker). Include its icon. */
|
|
61
|
+
badge?: React.ReactNode;
|
|
62
|
+
/** Bottom-left overlay pill naming the category. Include its icon. */
|
|
63
|
+
tagLabel?: React.ReactNode;
|
|
64
|
+
/**
|
|
65
|
+
* Caller-supplied display labels, rendered as small chips in the text area
|
|
66
|
+
* beneath the venue/meta line. `EventPreview.tags` is taxonomy coordinates
|
|
67
|
+
* (`slug`/`axis_slug`), not display text, so the DS cannot render them
|
|
68
|
+
* itself. Distinct from `tagLabel`: that is a single overlay pill on the
|
|
69
|
+
* poster (the design's category affordance for posters); this is an
|
|
70
|
+
* in-body list for the rest of an event's tags. Cap the count at the call
|
|
71
|
+
* site — this card does not.
|
|
72
|
+
*/
|
|
73
|
+
tags?: string[];
|
|
74
|
+
/**
|
|
75
|
+
* The live line, fully composed by the caller (e.g. "Live now · ends around
|
|
76
|
+
* 23:00"). Composed outside the DS deliberately: the clause order differs by
|
|
77
|
+
* locale, and "is this live" depends on the app's clock, not the DS's.
|
|
78
|
+
*/
|
|
79
|
+
liveStatus?: string;
|
|
80
|
+
/** Hide the day/time line — the timeline supplies its own hour heading. */
|
|
81
|
+
hideWhen?: boolean;
|
|
82
|
+
/** Overrides the day label when the card appears outside its own day section. */
|
|
83
|
+
dayLabel?: string;
|
|
84
|
+
/** Append the entry fee to the meta line. Off by default, matching the design's base card. */
|
|
85
|
+
showPrice?: boolean;
|
|
86
|
+
href?: string;
|
|
87
|
+
/** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
|
|
88
|
+
linkComponent?: React.ComponentType<EventCardLinkProps>;
|
|
89
|
+
onClick?: (event: EventPreview, mouseEvent: React.MouseEvent) => void;
|
|
90
|
+
className?: string;
|
|
91
|
+
/** Forwarded to the poster's `loading` attribute (`"eager"` when true). */
|
|
92
|
+
priority?: boolean;
|
|
93
|
+
/** Rendered instead of the poster when it's absent or failed to load. Defaults to `<EventPosterFallback />`. */
|
|
94
|
+
fallback?: React.ReactNode;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const PILL_BASE =
|
|
98
|
+
"inline-flex items-center gap-1 h-6 max-w-[70%] px-2 rounded-full text-[11px] font-semibold";
|
|
99
|
+
|
|
100
|
+
export const EventCard = memo(function EventCard({
|
|
101
|
+
event,
|
|
102
|
+
labels,
|
|
103
|
+
formatters,
|
|
104
|
+
posterAspect = "4/3",
|
|
105
|
+
emphasis,
|
|
106
|
+
badge,
|
|
107
|
+
tagLabel,
|
|
108
|
+
tags,
|
|
109
|
+
liveStatus,
|
|
110
|
+
hideWhen,
|
|
111
|
+
dayLabel,
|
|
112
|
+
showPrice,
|
|
113
|
+
href,
|
|
114
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
115
|
+
onClick,
|
|
116
|
+
className,
|
|
117
|
+
priority,
|
|
118
|
+
fallback = <EventPosterFallback />,
|
|
119
|
+
}: EventCardProps) {
|
|
120
|
+
const [imgError, setImgError] = useState(false);
|
|
121
|
+
|
|
122
|
+
const fee = resolveEventEntryFee(event);
|
|
123
|
+
const showPoster = !!event.poster_url && !imgError;
|
|
124
|
+
const venue = event.place_name ?? event.location;
|
|
125
|
+
|
|
126
|
+
const body = (
|
|
127
|
+
<>
|
|
128
|
+
<div
|
|
129
|
+
className={cn(
|
|
130
|
+
"relative overflow-hidden rounded-xl bg-muted",
|
|
131
|
+
posterAspect === "3/4"
|
|
132
|
+
? "aspect-[3/4]"
|
|
133
|
+
: posterAspect === "4/5"
|
|
134
|
+
? "aspect-[4/5]"
|
|
135
|
+
: "aspect-[4/3]"
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{showPoster ? (
|
|
139
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
140
|
+
<img
|
|
141
|
+
src={event.poster_url}
|
|
142
|
+
alt={event.title}
|
|
143
|
+
title={event.title}
|
|
144
|
+
loading={priority ? "eager" : "lazy"}
|
|
145
|
+
decoding="async"
|
|
146
|
+
className="h-full w-full object-cover"
|
|
147
|
+
onError={() => setImgError(true)}
|
|
148
|
+
/>
|
|
149
|
+
) : (
|
|
150
|
+
fallback
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Top overlay row — hype badge left, entry fee right. The empty span
|
|
154
|
+
keeps the fee pill right-aligned when there is no badge. */}
|
|
155
|
+
<div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
|
|
156
|
+
{badge ? <span className={cn(PILL_BASE, "bg-black/55 text-white")}>{badge}</span> : <span />}
|
|
157
|
+
{fee.kind === "free" && (
|
|
158
|
+
<span className={cn(PILL_BASE, "bg-success font-bold text-success-foreground")}>
|
|
159
|
+
<Gift className="h-3 w-3" aria-hidden />
|
|
160
|
+
{labels.free}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{tagLabel && (
|
|
166
|
+
<div className="absolute inset-x-2 bottom-2 flex gap-1">
|
|
167
|
+
<span className={cn(PILL_BASE, "bg-black/55 text-white")}>{tagLabel}</span>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* NEVER CLAMPED, in any variant. The title is the one thing a card
|
|
173
|
+
exists to say, and "Movie Night: Futni m…" says none of it — the
|
|
174
|
+
truncated form was the reported defect. It wraps to as many lines as
|
|
175
|
+
the event needs; the card is `flex-col` and grows downward, so a
|
|
176
|
+
three-line title lengthens the card rather than pushing anything out
|
|
177
|
+
of it. In a grid, give the row `items-start` so the extra lines hang
|
|
178
|
+
below instead of stretching every sibling. */}
|
|
179
|
+
<h3
|
|
180
|
+
className={cn(
|
|
181
|
+
"mt-2.5 leading-[1.35]",
|
|
182
|
+
emphasis ? "text-lg font-bold tracking-[-0.02em]" : "text-sm font-semibold"
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
{event.title}
|
|
186
|
+
</h3>
|
|
187
|
+
|
|
188
|
+
<div className="mt-0.5 flex min-w-0 items-center gap-1 text-xs font-medium text-foreground/85">
|
|
189
|
+
<MapPin className="h-3 w-3 shrink-0" aria-hidden />
|
|
190
|
+
<span className="truncate">{venue}</span>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{!hideWhen && (
|
|
194
|
+
<div className="mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
195
|
+
<span>{dayLabel ?? formatters.day(event.starts_at)}</span>
|
|
196
|
+
<span className="text-border" aria-hidden>
|
|
197
|
+
·
|
|
198
|
+
</span>
|
|
199
|
+
<span>{formatters.time(event.starts_at)}</span>
|
|
200
|
+
{showPrice && fee.kind === "paid" && (
|
|
201
|
+
<>
|
|
202
|
+
<span className="text-border" aria-hidden>
|
|
203
|
+
·
|
|
204
|
+
</span>
|
|
205
|
+
<span>{formatters.price(fee.amountRon)}</span>
|
|
206
|
+
</>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{tags && tags.length > 0 && (
|
|
212
|
+
<TagGroup className="mt-1.5">
|
|
213
|
+
{tags.map((tag) => (
|
|
214
|
+
<Tag key={tag} size="sm">
|
|
215
|
+
{tag}
|
|
216
|
+
</Tag>
|
|
217
|
+
))}
|
|
218
|
+
</TagGroup>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{liveStatus && (
|
|
222
|
+
<div className="mt-1 flex items-center gap-1.5 text-xs font-bold text-success">
|
|
223
|
+
<Radio className="h-3 w-3 shrink-0" aria-hidden />
|
|
224
|
+
{liveStatus}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const rootClasses = cn(
|
|
231
|
+
"flex min-w-0 flex-col text-left",
|
|
232
|
+
(href || onClick) &&
|
|
233
|
+
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
|
|
234
|
+
className
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (href) {
|
|
238
|
+
return (
|
|
239
|
+
<LinkComponent
|
|
240
|
+
href={href}
|
|
241
|
+
className={rootClasses}
|
|
242
|
+
onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
|
|
243
|
+
>
|
|
244
|
+
{body}
|
|
245
|
+
</LinkComponent>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (onClick) {
|
|
250
|
+
return (
|
|
251
|
+
<button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
|
|
252
|
+
{body}
|
|
253
|
+
</button>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return <div className={rootClasses}>{body}</div>;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
export type EventCardSkeletonProps = {
|
|
261
|
+
/** Matches `EventCardProps["posterAspect"]` — keep the poster box the same shape. */
|
|
262
|
+
posterAspect?: "4/3" | "3/4" | "4/5";
|
|
263
|
+
/** Matches `EventCardProps["emphasis"]` — the featured slot's taller title. */
|
|
264
|
+
emphasis?: boolean;
|
|
265
|
+
/** Matches `EventCardProps["hideWhen"]` — drops the day/time line placeholder too. */
|
|
266
|
+
hideWhen?: boolean;
|
|
267
|
+
className?: string;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Mirrors `<EventCard>`'s geometry so nothing shifts when data lands — with
|
|
272
|
+
* one honest caveat since the title stopped being clamped: the placeholder
|
|
273
|
+
* reserves two lines (three under `emphasis`), and a real title longer than
|
|
274
|
+
* that will grow the card past the skeleton it replaces. Reserving more would
|
|
275
|
+
* only move the shift to the far commoner short-title case.
|
|
276
|
+
*/
|
|
277
|
+
export function EventCardSkeleton({
|
|
278
|
+
posterAspect = "4/3",
|
|
279
|
+
emphasis,
|
|
280
|
+
hideWhen,
|
|
281
|
+
className,
|
|
282
|
+
}: EventCardSkeletonProps) {
|
|
283
|
+
return (
|
|
284
|
+
<div aria-hidden="true" className={cn("flex min-w-0 flex-col text-left", className)}>
|
|
285
|
+
<Skeleton
|
|
286
|
+
className={cn(
|
|
287
|
+
posterAspect === "3/4"
|
|
288
|
+
? "aspect-[3/4]"
|
|
289
|
+
: posterAspect === "4/5"
|
|
290
|
+
? "aspect-[4/5]"
|
|
291
|
+
: "aspect-[4/3]"
|
|
292
|
+
)}
|
|
293
|
+
/>
|
|
294
|
+
|
|
295
|
+
{emphasis ? (
|
|
296
|
+
<div className="mt-2.5 space-y-2">
|
|
297
|
+
<Skeleton className="h-5 w-full" />
|
|
298
|
+
<Skeleton className="h-5 w-full" />
|
|
299
|
+
<Skeleton className="h-5 w-1/2" />
|
|
300
|
+
</div>
|
|
301
|
+
) : (
|
|
302
|
+
<div className="mt-2.5 space-y-1.5">
|
|
303
|
+
<Skeleton className="h-3.5 w-full" />
|
|
304
|
+
<Skeleton className="h-3.5 w-2/3" />
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
<div className="mt-0.5 flex items-center gap-1">
|
|
309
|
+
<Skeleton className="h-3 w-3 shrink-0 rounded-full" />
|
|
310
|
+
<Skeleton className="h-3 w-24" />
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{!hideWhen && (
|
|
314
|
+
<div className="mt-[3px] flex items-center gap-1.5">
|
|
315
|
+
<Skeleton className="h-3 w-16" />
|
|
316
|
+
<Skeleton className="h-3 w-10" />
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|