@bearmenu/ui 0.8.4 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/chunk-637L7XQ3.js +85 -0
  2. package/dist/chunk-6XMYEZVR.js +26 -0
  3. package/dist/chunk-DNSXMHYX.js +8 -0
  4. package/dist/chunk-FDITZ2VM.js +16 -0
  5. package/dist/chunk-GPH6L4DX.js +55 -0
  6. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  7. package/dist/chunk-TT3WFS6S.js +379 -0
  8. package/dist/chunk-UXWAOISG.js +170 -0
  9. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  10. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  11. package/dist/components/card.d.ts +2 -2
  12. package/dist/components/card.js +4 -127
  13. package/dist/components/category-deck.d.ts +50 -0
  14. package/dist/components/category-deck.js +128 -0
  15. package/dist/components/concept-card.d.ts +43 -0
  16. package/dist/components/concept-card.js +81 -0
  17. package/dist/components/coverflow.d.ts +123 -0
  18. package/dist/components/coverflow.js +300 -0
  19. package/dist/components/day-rail-chip.d.ts +82 -0
  20. package/dist/components/day-rail-chip.js +105 -0
  21. package/dist/components/drift-rail.d.ts +28 -0
  22. package/dist/components/drift-rail.js +3 -0
  23. package/dist/components/event-card.d.ts +97 -0
  24. package/dist/components/event-card.js +164 -0
  25. package/dist/components/event-compact-row.d.ts +73 -0
  26. package/dist/components/event-compact-row.js +134 -0
  27. package/dist/components/event-day-header.d.ts +65 -0
  28. package/dist/components/event-day-header.js +95 -0
  29. package/dist/components/event-day-separator.d.ts +46 -0
  30. package/dist/components/event-day-separator.js +50 -0
  31. package/dist/components/event-detail-panel.d.ts +80 -0
  32. package/dist/components/event-detail-panel.js +251 -0
  33. package/dist/components/event-hub-hero.d.ts +34 -0
  34. package/dist/components/event-hub-hero.js +57 -0
  35. package/dist/components/event-index-row.d.ts +68 -0
  36. package/dist/components/event-index-row.js +84 -0
  37. package/dist/components/event-list-row-wide.d.ts +101 -0
  38. package/dist/components/event-list-row-wide.js +218 -0
  39. package/dist/components/event-list-row.d.ts +82 -0
  40. package/dist/components/event-list-row.js +173 -0
  41. package/dist/components/event-poster-fallback.d.ts +20 -0
  42. package/dist/components/event-poster-fallback.js +3 -0
  43. package/dist/components/event-types.d.ts +122 -0
  44. package/dist/components/event-types.js +2 -0
  45. package/dist/components/events-empty-state.d.ts +44 -0
  46. package/dist/components/events-empty-state.js +73 -0
  47. package/dist/components/facet-group.d.ts +93 -0
  48. package/dist/components/facet-group.js +222 -0
  49. package/dist/components/featured-rail-item.d.ts +42 -0
  50. package/dist/components/featured-rail-item.js +88 -0
  51. package/dist/components/hour-band.d.ts +68 -0
  52. package/dist/components/hour-band.js +53 -0
  53. package/dist/components/hour-group.d.ts +67 -0
  54. package/dist/components/hour-group.js +59 -0
  55. package/dist/components/motion-icon-controls.js +3 -379
  56. package/dist/components/multi-select-combobox.js +1 -1
  57. package/dist/components/place-about-tab.js +3 -3
  58. package/dist/components/place-details-mobile.js +3 -3
  59. package/dist/components/poster-stack.d.ts +30 -0
  60. package/dist/components/poster-stack.js +104 -0
  61. package/dist/components/poster-tape.d.ts +36 -0
  62. package/dist/components/poster-tape.js +64 -0
  63. package/dist/components/poster-wall.d.ts +25 -0
  64. package/dist/components/poster-wall.js +183 -0
  65. package/dist/components/quiet-stretch.d.ts +43 -0
  66. package/dist/components/quiet-stretch.js +54 -0
  67. package/dist/components/section-header.d.ts +53 -0
  68. package/dist/components/section-header.js +63 -0
  69. package/dist/components/segmented-nav.d.ts +62 -0
  70. package/dist/components/segmented-nav.js +54 -0
  71. package/dist/components/skeleton-card.js +1 -1
  72. package/dist/components/skeleton.d.ts +17 -0
  73. package/dist/components/skeleton.js +1 -1
  74. package/dist/components/tag-group.js +3 -26
  75. package/dist/components/tag.d.ts +2 -2
  76. package/dist/components/tag.js +1 -1
  77. package/dist/components/timeline-band.d.ts +46 -0
  78. package/dist/components/timeline-band.js +5 -0
  79. package/dist/components/venue-events-block.d.ts +143 -0
  80. package/dist/components/venue-events-block.js +222 -0
  81. package/dist/components/weekend-panel.d.ts +44 -0
  82. package/dist/components/weekend-panel.js +231 -0
  83. package/package.json +1 -1
  84. package/src/components/card.tsx +44 -1
  85. package/src/components/category-deck.stories.tsx +96 -0
  86. package/src/components/category-deck.tsx +227 -0
  87. package/src/components/concept-card.stories.tsx +80 -0
  88. package/src/components/concept-card.tsx +143 -0
  89. package/src/components/coverflow.stories.tsx +240 -0
  90. package/src/components/coverflow.test.tsx +193 -0
  91. package/src/components/coverflow.tsx +550 -0
  92. package/src/components/day-rail-chip.stories.tsx +145 -0
  93. package/src/components/day-rail-chip.tsx +201 -0
  94. package/src/components/drift-rail.stories.tsx +92 -0
  95. package/src/components/drift-rail.tsx +86 -0
  96. package/src/components/event-card.stories.tsx +248 -0
  97. package/src/components/event-card.test.tsx +188 -0
  98. package/src/components/event-card.tsx +329 -0
  99. package/src/components/event-compact-row.stories.tsx +221 -0
  100. package/src/components/event-compact-row.test.tsx +51 -0
  101. package/src/components/event-compact-row.tsx +270 -0
  102. package/src/components/event-day-header.stories.tsx +124 -0
  103. package/src/components/event-day-header.tsx +180 -0
  104. package/src/components/event-day-separator.stories.tsx +70 -0
  105. package/src/components/event-day-separator.tsx +95 -0
  106. package/src/components/event-detail-panel.stories.tsx +191 -0
  107. package/src/components/event-detail-panel.tsx +360 -0
  108. package/src/components/event-hub-hero.stories.tsx +66 -0
  109. package/src/components/event-hub-hero.tsx +110 -0
  110. package/src/components/event-index-row.stories.tsx +116 -0
  111. package/src/components/event-index-row.tsx +181 -0
  112. package/src/components/event-list-row-wide.stories.tsx +204 -0
  113. package/src/components/event-list-row-wide.tsx +386 -0
  114. package/src/components/event-list-row.stories.tsx +159 -0
  115. package/src/components/event-list-row.tsx +335 -0
  116. package/src/components/event-poster-fallback.stories.tsx +52 -0
  117. package/src/components/event-poster-fallback.tsx +31 -0
  118. package/src/components/event-types.ts +137 -0
  119. package/src/components/events-empty-state.stories.tsx +59 -0
  120. package/src/components/events-empty-state.tsx +131 -0
  121. package/src/components/facet-group.stories.tsx +247 -0
  122. package/src/components/facet-group.test.tsx +225 -0
  123. package/src/components/facet-group.tsx +352 -0
  124. package/src/components/featured-rail-item.stories.tsx +86 -0
  125. package/src/components/featured-rail-item.tsx +153 -0
  126. package/src/components/hour-band.stories.tsx +159 -0
  127. package/src/components/hour-band.tsx +126 -0
  128. package/src/components/hour-group.stories.tsx +100 -0
  129. package/src/components/hour-group.tsx +124 -0
  130. package/src/components/poster-stack.stories.tsx +68 -0
  131. package/src/components/poster-stack.tsx +155 -0
  132. package/src/components/poster-tape.stories.tsx +70 -0
  133. package/src/components/poster-tape.tsx +122 -0
  134. package/src/components/poster-wall.stories.tsx +95 -0
  135. package/src/components/poster-wall.tsx +220 -0
  136. package/src/components/quiet-stretch.stories.tsx +62 -0
  137. package/src/components/quiet-stretch.tsx +115 -0
  138. package/src/components/section-header.stories.tsx +70 -0
  139. package/src/components/section-header.tsx +134 -0
  140. package/src/components/segmented-nav.stories.tsx +118 -0
  141. package/src/components/segmented-nav.tsx +113 -0
  142. package/src/components/skeleton.tsx +18 -0
  143. package/src/components/tag.tsx +11 -1
  144. package/src/components/timeline-band.stories.tsx +206 -0
  145. package/src/components/timeline-band.tsx +207 -0
  146. package/src/components/venue-events-block.stories.tsx +216 -0
  147. package/src/components/venue-events-block.tsx +408 -0
  148. package/src/components/weekend-panel.stories.tsx +92 -0
  149. package/src/components/weekend-panel.tsx +322 -0
  150. package/src/globals.css +19 -0
@@ -0,0 +1,188 @@
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("prints the day alone, with no separator, when the time formatter returns an empty string", () => {
112
+ // "" is the formatter's way of saying the hour is unknown; the card must
113
+ // not print a dangling "·" or invent a placeholder for it.
114
+ const { container } = renderCard({
115
+ formatters: { ...formatters, time: () => "" },
116
+ });
117
+ expect(screen.getByText("Sat, Sep 5")).toBeInTheDocument();
118
+ expect(container.textContent).not.toContain("·");
119
+ expect(container.textContent).not.toContain("20:00");
120
+ });
121
+
122
+ it("hides the whole meta line when hideWhen is set", () => {
123
+ renderCard({ hideWhen: true });
124
+ expect(screen.queryByText("20:00")).not.toBeInTheDocument();
125
+ });
126
+
127
+ it("falls back to location when the event carries no place_name", () => {
128
+ renderCard({ event: { ...baseEvent, place_name: undefined, location: "Piata Unirii" } });
129
+ expect(screen.getByText("Piata Unirii")).toBeInTheDocument();
130
+ });
131
+ });
132
+
133
+ describe("poster", () => {
134
+ it("renders no img when the event has no resolved poster", () => {
135
+ const { container } = renderCard();
136
+ expect(container.querySelector("img")).toBeNull();
137
+ });
138
+
139
+ it("renders the poster the caller resolved", () => {
140
+ renderCard({ event: { ...baseEvent, poster_url: "https://cdn.example/p.avif" } });
141
+ expect(screen.getByAltText("Psihotrop Live")).toHaveAttribute(
142
+ "src",
143
+ "https://cdn.example/p.avif"
144
+ );
145
+ });
146
+ });
147
+
148
+ // Truncation is CSS-only, so jsdom cannot see the visual result — the class
149
+ // list is the only observable. Asserting on it is worth the coupling here:
150
+ // the reported bug ("Movie Night: Futni m…") was invisible to every other
151
+ // kind of test, and a clamp added back in any variant is exactly what this
152
+ // needs to catch.
153
+ describe("title", () => {
154
+ const longTitle =
155
+ "Movie Night: Futni må ikke være så vanskelig — an outdoor screening with live score";
156
+
157
+ it("renders the whole title, never a shortened copy of it", () => {
158
+ renderCard({ event: { ...baseEvent, title: longTitle } });
159
+ expect(screen.getByRole("heading", { name: longTitle })).toBeInTheDocument();
160
+ });
161
+
162
+ it("is not clamped in the base variant", () => {
163
+ renderCard({ event: { ...baseEvent, title: longTitle } });
164
+ expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
165
+ /line-clamp|truncate/
166
+ );
167
+ });
168
+
169
+ it("is not clamped in the emphasis variant either", () => {
170
+ renderCard({ event: { ...baseEvent, title: longTitle }, emphasis: true });
171
+ expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
172
+ /line-clamp|truncate/
173
+ );
174
+ });
175
+
176
+ it.each(["4/3", "3/4", "4/5"] as const)("is not clamped at posterAspect %s", (posterAspect) => {
177
+ renderCard({ event: { ...baseEvent, title: longTitle }, posterAspect });
178
+ expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
179
+ /line-clamp|truncate/
180
+ );
181
+ });
182
+ });
183
+
184
+ it("renders the live line the caller composed, not one of its own", () => {
185
+ renderCard({ liveStatus: "Live now · ends around 23:00" });
186
+ expect(screen.getByText("Live now · ends around 23:00")).toBeInTheDocument();
187
+ });
188
+ });
@@ -0,0 +1,329 @@
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
+ const time = hideWhen ? "" : formatters.time(event.starts_at);
122
+
123
+ const fee = resolveEventEntryFee(event);
124
+ const showPoster = !!event.poster_url && !imgError;
125
+ const venue = event.place_name ?? event.location;
126
+
127
+ const body = (
128
+ <>
129
+ <div
130
+ className={cn(
131
+ "relative overflow-hidden rounded-xl bg-muted",
132
+ posterAspect === "3/4"
133
+ ? "aspect-[3/4]"
134
+ : posterAspect === "4/5"
135
+ ? "aspect-[4/5]"
136
+ : "aspect-[4/3]"
137
+ )}
138
+ >
139
+ {showPoster ? (
140
+ // eslint-disable-next-line @next/next/no-img-element
141
+ <img
142
+ src={event.poster_url}
143
+ alt={event.title}
144
+ title={event.title}
145
+ loading={priority ? "eager" : "lazy"}
146
+ decoding="async"
147
+ className="h-full w-full object-cover"
148
+ onError={() => setImgError(true)}
149
+ />
150
+ ) : (
151
+ fallback
152
+ )}
153
+
154
+ {/* Top overlay row — hype badge left, entry fee right. The empty span
155
+ keeps the fee pill right-aligned when there is no badge. */}
156
+ <div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
157
+ {badge ? <span className={cn(PILL_BASE, "bg-black/55 text-white")}>{badge}</span> : <span />}
158
+ {fee.kind === "free" && (
159
+ <span className={cn(PILL_BASE, "bg-success font-bold text-success-foreground")}>
160
+ <Gift className="h-3 w-3" aria-hidden />
161
+ {labels.free}
162
+ </span>
163
+ )}
164
+ </div>
165
+
166
+ {tagLabel && (
167
+ <div className="absolute inset-x-2 bottom-2 flex gap-1">
168
+ <span className={cn(PILL_BASE, "bg-black/55 text-white")}>{tagLabel}</span>
169
+ </div>
170
+ )}
171
+ </div>
172
+
173
+ {/* NEVER CLAMPED, in any variant. The title is the one thing a card
174
+ exists to say, and "Movie Night: Futni m…" says none of it — the
175
+ truncated form was the reported defect. It wraps to as many lines as
176
+ the event needs; the card is `flex-col` and grows downward, so a
177
+ three-line title lengthens the card rather than pushing anything out
178
+ of it. In a grid, give the row `items-start` so the extra lines hang
179
+ below instead of stretching every sibling. */}
180
+ <h3
181
+ className={cn(
182
+ "mt-2.5 leading-[1.35]",
183
+ emphasis ? "text-lg font-bold tracking-[-0.02em]" : "text-sm font-semibold"
184
+ )}
185
+ >
186
+ {event.title}
187
+ </h3>
188
+
189
+ <div className="mt-0.5 flex min-w-0 items-center gap-1 text-xs font-medium text-foreground/85">
190
+ <MapPin className="h-3 w-3 shrink-0" aria-hidden />
191
+ <span className="truncate">{venue}</span>
192
+ </div>
193
+
194
+ {!hideWhen && (
195
+ <div className="mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground">
196
+ <span>{dayLabel ?? formatters.day(event.starts_at)}</span>
197
+ {/* An empty time is "hour unknown" (see `EventFormatters.time`): the
198
+ day stands alone, no dangling separator. Consumers used to have
199
+ to `hideWhen` these cards and lose the day with the time. */}
200
+ {time && (
201
+ <>
202
+ <span className="text-border" aria-hidden>
203
+ ·
204
+ </span>
205
+ <span>{time}</span>
206
+ </>
207
+ )}
208
+ {showPrice && fee.kind === "paid" && (
209
+ <>
210
+ <span className="text-border" aria-hidden>
211
+ ·
212
+ </span>
213
+ <span>{formatters.price(fee.amountRon)}</span>
214
+ </>
215
+ )}
216
+ </div>
217
+ )}
218
+
219
+ {tags && tags.length > 0 && (
220
+ <TagGroup className="mt-1.5">
221
+ {tags.map((tag) => (
222
+ <Tag key={tag} size="sm">
223
+ {tag}
224
+ </Tag>
225
+ ))}
226
+ </TagGroup>
227
+ )}
228
+
229
+ {liveStatus && (
230
+ <div className="mt-1 flex items-center gap-1.5 text-xs font-bold text-success">
231
+ <Radio className="h-3 w-3 shrink-0" aria-hidden />
232
+ {liveStatus}
233
+ </div>
234
+ )}
235
+ </>
236
+ );
237
+
238
+ const rootClasses = cn(
239
+ "flex min-w-0 flex-col text-left",
240
+ (href || onClick) &&
241
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
242
+ className
243
+ );
244
+
245
+ if (href) {
246
+ return (
247
+ <LinkComponent
248
+ href={href}
249
+ className={rootClasses}
250
+ onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
251
+ >
252
+ {body}
253
+ </LinkComponent>
254
+ );
255
+ }
256
+
257
+ if (onClick) {
258
+ return (
259
+ <button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
260
+ {body}
261
+ </button>
262
+ );
263
+ }
264
+
265
+ return <div className={rootClasses}>{body}</div>;
266
+ });
267
+
268
+ export type EventCardSkeletonProps = {
269
+ /** Matches `EventCardProps["posterAspect"]` — keep the poster box the same shape. */
270
+ posterAspect?: "4/3" | "3/4" | "4/5";
271
+ /** Matches `EventCardProps["emphasis"]` — the featured slot's taller title. */
272
+ emphasis?: boolean;
273
+ /** Matches `EventCardProps["hideWhen"]` — drops the day/time line placeholder too. */
274
+ hideWhen?: boolean;
275
+ className?: string;
276
+ };
277
+
278
+ /**
279
+ * Mirrors `<EventCard>`'s geometry so nothing shifts when data lands — with
280
+ * one honest caveat since the title stopped being clamped: the placeholder
281
+ * reserves two lines (three under `emphasis`), and a real title longer than
282
+ * that will grow the card past the skeleton it replaces. Reserving more would
283
+ * only move the shift to the far commoner short-title case.
284
+ */
285
+ export function EventCardSkeleton({
286
+ posterAspect = "4/3",
287
+ emphasis,
288
+ hideWhen,
289
+ className,
290
+ }: EventCardSkeletonProps) {
291
+ return (
292
+ <div aria-hidden="true" className={cn("flex min-w-0 flex-col text-left", className)}>
293
+ <Skeleton
294
+ className={cn(
295
+ posterAspect === "3/4"
296
+ ? "aspect-[3/4]"
297
+ : posterAspect === "4/5"
298
+ ? "aspect-[4/5]"
299
+ : "aspect-[4/3]"
300
+ )}
301
+ />
302
+
303
+ {emphasis ? (
304
+ <div className="mt-2.5 space-y-2">
305
+ <Skeleton className="h-5 w-full" />
306
+ <Skeleton className="h-5 w-full" />
307
+ <Skeleton className="h-5 w-1/2" />
308
+ </div>
309
+ ) : (
310
+ <div className="mt-2.5 space-y-1.5">
311
+ <Skeleton className="h-3.5 w-full" />
312
+ <Skeleton className="h-3.5 w-2/3" />
313
+ </div>
314
+ )}
315
+
316
+ <div className="mt-0.5 flex items-center gap-1">
317
+ <Skeleton className="h-3 w-3 shrink-0 rounded-full" />
318
+ <Skeleton className="h-3 w-24" />
319
+ </div>
320
+
321
+ {!hideWhen && (
322
+ <div className="mt-[3px] flex items-center gap-1.5">
323
+ <Skeleton className="h-3 w-16" />
324
+ <Skeleton className="h-3 w-10" />
325
+ </div>
326
+ )}
327
+ </div>
328
+ );
329
+ }