@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.
Files changed (194) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/chunk-637L7XQ3.js +85 -0
  3. package/dist/chunk-6XMYEZVR.js +26 -0
  4. package/dist/chunk-DNSXMHYX.js +8 -0
  5. package/dist/chunk-FDITZ2VM.js +16 -0
  6. package/dist/chunk-GPH6L4DX.js +55 -0
  7. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  8. package/dist/chunk-TT3WFS6S.js +379 -0
  9. package/dist/chunk-UXWAOISG.js +170 -0
  10. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  11. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  12. package/dist/components/alert.d.ts +2 -2
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/card.d.ts +2 -2
  16. package/dist/components/card.js +4 -127
  17. package/dist/components/category-deck.d.ts +50 -0
  18. package/dist/components/category-deck.js +128 -0
  19. package/dist/components/command.d.ts +15 -15
  20. package/dist/components/concept-card.d.ts +43 -0
  21. package/dist/components/concept-card.js +81 -0
  22. package/dist/components/coverflow.d.ts +123 -0
  23. package/dist/components/coverflow.js +300 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/day-rail-chip.d.ts +82 -0
  26. package/dist/components/day-rail-chip.js +105 -0
  27. package/dist/components/drift-rail.d.ts +28 -0
  28. package/dist/components/drift-rail.js +3 -0
  29. package/dist/components/event-card.d.ts +97 -0
  30. package/dist/components/event-card.js +161 -0
  31. package/dist/components/event-compact-row.d.ts +72 -0
  32. package/dist/components/event-compact-row.js +133 -0
  33. package/dist/components/event-day-header.d.ts +65 -0
  34. package/dist/components/event-day-header.js +95 -0
  35. package/dist/components/event-day-separator.d.ts +46 -0
  36. package/dist/components/event-day-separator.js +50 -0
  37. package/dist/components/event-detail-panel.d.ts +80 -0
  38. package/dist/components/event-detail-panel.js +251 -0
  39. package/dist/components/event-hub-hero.d.ts +34 -0
  40. package/dist/components/event-hub-hero.js +57 -0
  41. package/dist/components/event-index-row.d.ts +68 -0
  42. package/dist/components/event-index-row.js +84 -0
  43. package/dist/components/event-list-row-wide.d.ts +101 -0
  44. package/dist/components/event-list-row-wide.js +218 -0
  45. package/dist/components/event-list-row.d.ts +82 -0
  46. package/dist/components/event-list-row.js +173 -0
  47. package/dist/components/event-poster-fallback.d.ts +20 -0
  48. package/dist/components/event-poster-fallback.js +3 -0
  49. package/dist/components/event-types.d.ts +118 -0
  50. package/dist/components/event-types.js +2 -0
  51. package/dist/components/events-empty-state.d.ts +44 -0
  52. package/dist/components/events-empty-state.js +73 -0
  53. package/dist/components/facet-group.d.ts +93 -0
  54. package/dist/components/facet-group.js +222 -0
  55. package/dist/components/featured-rail-item.d.ts +42 -0
  56. package/dist/components/featured-rail-item.js +88 -0
  57. package/dist/components/hour-band.d.ts +68 -0
  58. package/dist/components/hour-band.js +53 -0
  59. package/dist/components/hour-group.d.ts +67 -0
  60. package/dist/components/hour-group.js +59 -0
  61. package/dist/components/icon.d.ts +18 -0
  62. package/dist/components/icon.js +31 -0
  63. package/dist/components/item.d.ts +2 -2
  64. package/dist/components/motion-icon-controls.d.ts +26 -0
  65. package/dist/components/motion-icon-controls.js +4 -0
  66. package/dist/components/motion-icon-set.d.ts +27 -0
  67. package/dist/components/motion-icon-set.js +139 -0
  68. package/dist/components/motion-icon.d.ts +44 -0
  69. package/dist/components/motion-icon.js +3 -0
  70. package/dist/components/multi-select-combobox.js +1 -1
  71. package/dist/components/place-about-tab.js +3 -3
  72. package/dist/components/place-details-mobile.js +3 -3
  73. package/dist/components/poster-stack.d.ts +30 -0
  74. package/dist/components/poster-stack.js +104 -0
  75. package/dist/components/poster-tape.d.ts +36 -0
  76. package/dist/components/poster-tape.js +64 -0
  77. package/dist/components/poster-wall.d.ts +25 -0
  78. package/dist/components/poster-wall.js +183 -0
  79. package/dist/components/quiet-stretch.d.ts +43 -0
  80. package/dist/components/quiet-stretch.js +54 -0
  81. package/dist/components/section-header.d.ts +53 -0
  82. package/dist/components/section-header.js +63 -0
  83. package/dist/components/segmented-nav.d.ts +62 -0
  84. package/dist/components/segmented-nav.js +54 -0
  85. package/dist/components/skeleton-card.js +1 -1
  86. package/dist/components/skeleton.d.ts +17 -0
  87. package/dist/components/skeleton.js +1 -1
  88. package/dist/components/spinner.d.ts +2 -2
  89. package/dist/components/tag-group.js +3 -26
  90. package/dist/components/tag.d.ts +2 -2
  91. package/dist/components/tag.js +1 -1
  92. package/dist/components/text.d.ts +3 -3
  93. package/dist/components/timeline-band.d.ts +46 -0
  94. package/dist/components/timeline-band.js +5 -0
  95. package/dist/components/venue-events-block.d.ts +143 -0
  96. package/dist/components/venue-events-block.js +222 -0
  97. package/dist/components/weekend-panel.d.ts +44 -0
  98. package/dist/components/weekend-panel.js +231 -0
  99. package/dist/lib/icon-registry.d.ts +58 -0
  100. package/dist/lib/icon-registry.js +39 -0
  101. package/package.json +2 -1
  102. package/src/components/accordion.stories.tsx +1 -1
  103. package/src/components/card.tsx +44 -1
  104. package/src/components/category-deck.stories.tsx +96 -0
  105. package/src/components/category-deck.tsx +227 -0
  106. package/src/components/combobox.stories.tsx +1 -1
  107. package/src/components/concept-card.stories.tsx +80 -0
  108. package/src/components/concept-card.tsx +143 -0
  109. package/src/components/coverflow.stories.tsx +240 -0
  110. package/src/components/coverflow.test.tsx +193 -0
  111. package/src/components/coverflow.tsx +550 -0
  112. package/src/components/currency-input.stories.tsx +3 -3
  113. package/src/components/day-rail-chip.stories.tsx +145 -0
  114. package/src/components/day-rail-chip.tsx +201 -0
  115. package/src/components/drift-rail.stories.tsx +92 -0
  116. package/src/components/drift-rail.tsx +86 -0
  117. package/src/components/empty-state.stories.tsx +3 -3
  118. package/src/components/event-card.stories.tsx +248 -0
  119. package/src/components/event-card.test.tsx +177 -0
  120. package/src/components/event-card.tsx +321 -0
  121. package/src/components/event-compact-row.stories.tsx +221 -0
  122. package/src/components/event-compact-row.tsx +268 -0
  123. package/src/components/event-day-header.stories.tsx +124 -0
  124. package/src/components/event-day-header.tsx +180 -0
  125. package/src/components/event-day-separator.stories.tsx +70 -0
  126. package/src/components/event-day-separator.tsx +95 -0
  127. package/src/components/event-detail-panel.stories.tsx +191 -0
  128. package/src/components/event-detail-panel.tsx +360 -0
  129. package/src/components/event-hub-hero.stories.tsx +66 -0
  130. package/src/components/event-hub-hero.tsx +110 -0
  131. package/src/components/event-index-row.stories.tsx +116 -0
  132. package/src/components/event-index-row.tsx +181 -0
  133. package/src/components/event-list-row-wide.stories.tsx +204 -0
  134. package/src/components/event-list-row-wide.tsx +386 -0
  135. package/src/components/event-list-row.stories.tsx +159 -0
  136. package/src/components/event-list-row.tsx +335 -0
  137. package/src/components/event-poster-fallback.stories.tsx +52 -0
  138. package/src/components/event-poster-fallback.tsx +31 -0
  139. package/src/components/event-types.ts +133 -0
  140. package/src/components/events-empty-state.stories.tsx +59 -0
  141. package/src/components/events-empty-state.tsx +131 -0
  142. package/src/components/facet-group.stories.tsx +247 -0
  143. package/src/components/facet-group.test.tsx +225 -0
  144. package/src/components/facet-group.tsx +352 -0
  145. package/src/components/featured-rail-item.stories.tsx +86 -0
  146. package/src/components/featured-rail-item.tsx +153 -0
  147. package/src/components/form.stories.tsx +1 -1
  148. package/src/components/hour-band.stories.tsx +159 -0
  149. package/src/components/hour-band.tsx +126 -0
  150. package/src/components/hour-group.stories.tsx +100 -0
  151. package/src/components/hour-group.tsx +124 -0
  152. package/src/components/icon.tsx +57 -0
  153. package/src/components/input-otp.stories.tsx +1 -1
  154. package/src/components/markdown-renderer.stories.tsx +1 -1
  155. package/src/components/motion-icon-controls.test.tsx +78 -0
  156. package/src/components/motion-icon-controls.tsx +552 -0
  157. package/src/components/motion-icon-set.stories.tsx +185 -0
  158. package/src/components/motion-icon-set.tsx +219 -0
  159. package/src/components/motion-icon.test.tsx +159 -0
  160. package/src/components/motion-icon.tsx +163 -0
  161. package/src/components/multi-select-combobox.stories.tsx +1 -1
  162. package/src/components/phone-frame.stories.tsx +1 -1
  163. package/src/components/poster-stack.stories.tsx +68 -0
  164. package/src/components/poster-stack.tsx +155 -0
  165. package/src/components/poster-tape.stories.tsx +70 -0
  166. package/src/components/poster-tape.tsx +122 -0
  167. package/src/components/poster-wall.stories.tsx +95 -0
  168. package/src/components/poster-wall.tsx +220 -0
  169. package/src/components/quiet-stretch.stories.tsx +62 -0
  170. package/src/components/quiet-stretch.tsx +115 -0
  171. package/src/components/rating.stories.tsx +3 -3
  172. package/src/components/searchable-select.stories.tsx +1 -1
  173. package/src/components/section-header.stories.tsx +70 -0
  174. package/src/components/section-header.tsx +134 -0
  175. package/src/components/segmented-nav.stories.tsx +118 -0
  176. package/src/components/segmented-nav.tsx +113 -0
  177. package/src/components/selection-bar.stories.tsx +1 -1
  178. package/src/components/skeleton-card.stories.tsx +3 -3
  179. package/src/components/skeleton.tsx +18 -0
  180. package/src/components/sliding-panels.stories.tsx +1 -1
  181. package/src/components/stepper.stories.tsx +1 -1
  182. package/src/components/sticky-container.stories.tsx +3 -3
  183. package/src/components/tag.tsx +11 -1
  184. package/src/components/timeline-band.stories.tsx +206 -0
  185. package/src/components/timeline-band.tsx +207 -0
  186. package/src/components/toggle-group.stories.tsx +1 -1
  187. package/src/components/tooltip.stories.tsx +2 -2
  188. package/src/components/venue-events-block.stories.tsx +216 -0
  189. package/src/components/venue-events-block.tsx +408 -0
  190. package/src/components/weekend-panel.stories.tsx +92 -0
  191. package/src/components/weekend-panel.tsx +322 -0
  192. package/src/globals.css +19 -0
  193. package/src/lib/icon-registry.ts +139 -0
  194. package/src/test/setup.ts +5 -1
@@ -0,0 +1,95 @@
1
+ import type { ElementType } from "react";
2
+ import { cn } from "../lib/utils";
3
+ import { Separator } from "./separator";
4
+ import { Skeleton } from "./skeleton";
5
+
6
+ /**
7
+ * EventDaySeparator — the day divider in the list view (as opposed to
8
+ * `<EventDayHeader>`, the timeline's oversized day-number header — the two
9
+ * live on different surfaces with different densities, not a size variant of
10
+ * one component).
11
+ *
12
+ * Both `label` and `count` are caller-composed strings, same reasoning as
13
+ * `<EventDayHeader>`'s `meta`: "Today · Thursday 3 September" / "15 events"
14
+ * don't decompose into parts without baking in English clause order.
15
+ *
16
+ * Renders the label as a real heading (`as`, default `h2`) so a list of these
17
+ * gives the page a document outline instead of being purely decorative rules.
18
+ *
19
+ * NOTE ON WEIGHT: this heading is deliberately SMALLER and lighter than the
20
+ * row titles beneath it. The boundary is already the strongest structural
21
+ * event on the page — a rule to the right edge, 24px of air above it, a muted
22
+ * count beside it — so it needs no ink on top of that. And the day is chrome
23
+ * on a scan surface where the events are the content: when the chrome is the
24
+ * heaviest thing on screen, the eye lands on the same repeated word every
25
+ * eight rows and nothing in the actual inventory ever wins. If the boundary
26
+ * ever needs to read louder, the levers are the rule and the air (move the
27
+ * `<Separator>` above the label), not the weight.
28
+ */
29
+
30
+ export type EventDaySeparatorProps = {
31
+ label: string;
32
+ count?: string;
33
+ size?: "sm" | "default";
34
+ /** Heading level for `label`. Defaults to `h2`. */
35
+ as?: ElementType;
36
+ className?: string;
37
+ };
38
+
39
+ export function EventDaySeparator({
40
+ label,
41
+ count,
42
+ size = "default",
43
+ as: Tag = "h2",
44
+ className,
45
+ }: EventDaySeparatorProps) {
46
+ return (
47
+ <div
48
+ className={cn(
49
+ "flex items-center gap-[11px]",
50
+ size === "sm" ? "pt-[18px] pb-1" : "pt-6 pb-2",
51
+ className
52
+ )}
53
+ >
54
+ <Tag className={cn("font-semibold", size === "sm" ? "text-[13px]" : "text-sm")}>
55
+ {label}
56
+ </Tag>
57
+
58
+ {count && (
59
+ <span className={cn("text-muted-foreground", size === "sm" ? "text-[11.5px]" : "text-xs")}>
60
+ {count}
61
+ </span>
62
+ )}
63
+
64
+ <Separator className="flex-1" />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export type EventDaySeparatorSkeletonProps = {
70
+ /** Matches `EventDaySeparatorProps["size"]`. */
71
+ size?: "sm" | "default";
72
+ /** Shows the count placeholder — omit for label-only separators. */
73
+ showCount?: boolean;
74
+ className?: string;
75
+ };
76
+
77
+ /** Mirrors `<EventDaySeparator>`'s geometry exactly so nothing shifts when data lands. */
78
+ export function EventDaySeparatorSkeleton({
79
+ size = "default",
80
+ showCount = true,
81
+ className,
82
+ }: EventDaySeparatorSkeletonProps) {
83
+ const isSm = size === "sm";
84
+
85
+ return (
86
+ <div
87
+ aria-hidden="true"
88
+ className={cn("flex items-center gap-[11px]", isSm ? "pt-[18px] pb-1" : "pt-6 pb-2", className)}
89
+ >
90
+ <Skeleton className={cn(isSm ? "h-3" : "h-3.5", "w-40")} />
91
+ {showCount && <Skeleton className="h-3 w-16" />}
92
+ <Separator className="flex-1" />
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,191 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ExternalLink, Navigation, Phone, Share2, X } from "lucide-react";
3
+ import {
4
+ EventDetailPanel,
5
+ EventDetailPanelSkeleton,
6
+ type EventDetailPanelLabels,
7
+ } from "./event-detail-panel";
8
+ import type { EventFormatters, EventPreview } from "./event-types";
9
+
10
+ const labels: EventDetailPanelLabels = {
11
+ free: "Free",
12
+ liveNow: "Live now",
13
+ venueHeading: "Event venue",
14
+ aboutHeading: "About this event",
15
+ };
16
+
17
+ const formatters: EventFormatters = {
18
+ time: () => "20:00",
19
+ day: () => "Sat, Sep 5",
20
+ price: (amount) => `${amount} RON`,
21
+ };
22
+
23
+ const event: EventPreview = {
24
+ id: "e1",
25
+ title: "Psihotrop Live",
26
+ description:
27
+ "A full live set spanning the band's whole catalogue, with support from Mara Ionescu. Doors at 19:30.",
28
+ location: "Atelier Café",
29
+ place_name: "Atelier Café",
30
+ starts_at: "2026-09-05T20:00:00",
31
+ entry_fee: 50,
32
+ };
33
+
34
+ const venue = {
35
+ name: "Atelier Café",
36
+ meta: "Str. Iuliu Maniu 5 · open until 01:00",
37
+ ratingLabel: "4.7",
38
+ href: "/places/atelier-cafe",
39
+ };
40
+
41
+ const roundBtn =
42
+ "flex h-9 w-9 items-center justify-center rounded-full bg-white/90 text-foreground";
43
+
44
+ const headerActions = (
45
+ <>
46
+ <button type="button" aria-label="Share" className={`absolute left-3.5 top-3.5 ${roundBtn}`}>
47
+ <Share2 className="h-4 w-4" aria-hidden />
48
+ </button>
49
+ <button type="button" aria-label="Close" className={`absolute right-3.5 top-3.5 ${roundBtn}`}>
50
+ <X className="h-[17px] w-[17px]" aria-hidden />
51
+ </button>
52
+ </>
53
+ );
54
+
55
+ const footer = (
56
+ <>
57
+ <button
58
+ type="button"
59
+ className="flex h-[46px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary text-sm font-bold text-primary-foreground"
60
+ >
61
+ <Phone className="h-4 w-4" aria-hidden />
62
+ Reserve a spot
63
+ </button>
64
+ <button
65
+ type="button"
66
+ className="flex h-[46px] items-center gap-2 rounded-xl border border-border px-4 text-sm font-bold"
67
+ >
68
+ <Navigation className="h-4 w-4" aria-hidden />
69
+ Directions
70
+ </button>
71
+ </>
72
+ );
73
+
74
+ const externalLink = (
75
+ <a href="#" className="flex items-center gap-1.5 text-[13.5px] font-bold text-primary">
76
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
77
+ View on Instagram
78
+ </a>
79
+ );
80
+
81
+ const meta: Meta<typeof EventDetailPanel> = {
82
+ title: "Events/EventDetailPanel",
83
+ component: EventDetailPanel,
84
+ tags: ["autodocs"],
85
+ args: {
86
+ event,
87
+ labels,
88
+ formatters,
89
+ dateLine: "Saturday, September 5 · Starts at 20:00",
90
+ venue,
91
+ tags: ["Live music"],
92
+ headerActions,
93
+ footer,
94
+ externalLink,
95
+ },
96
+ parameters: {
97
+ docs: {
98
+ description: {
99
+ component:
100
+ "The BODY of the detail sheet, not a sheet itself — the DS already ships `Sheet` and `Drawer`, and the app mounts this inside the side sheet on desktop and the bottom sheet on mobile. This is the terminal surface for acting on an event, which is why the cards that open it carry no actions of their own.",
101
+ },
102
+ },
103
+ },
104
+ };
105
+
106
+ export default meta;
107
+ type Story = StoryObj<typeof EventDetailPanel>;
108
+
109
+ /** Desktop side sheet — 460px wide, 4:5 poster. */
110
+ export const SideSheet: Story = {
111
+ decorators: [
112
+ (Story) => (
113
+ <div className="h-[820px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
114
+ <Story />
115
+ </div>
116
+ ),
117
+ ],
118
+ };
119
+
120
+ /** Mobile bottom sheet — compact size, same 4:5 poster, tighter type. The hero scrolls away with the body; only the footer stays put. */
121
+ export const BottomSheet: Story = {
122
+ args: { size: "compact" },
123
+ decorators: [
124
+ (Story) => (
125
+ <div className="h-[720px] w-full max-w-[390px] overflow-hidden rounded-t-[20px] shadow-2xl">
126
+ <div className="mx-auto mt-2.5 mb-1 h-1 w-[38px] rounded-full bg-border" />
127
+ <Story />
128
+ </div>
129
+ ),
130
+ ],
131
+ };
132
+
133
+ /** Live events get the on-air chip beside the category and price. */
134
+ export const LiveNow: Story = {
135
+ args: { isLive: true },
136
+ decorators: [
137
+ (Story) => (
138
+ <div className="h-[820px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
139
+ <Story />
140
+ </div>
141
+ ),
142
+ ],
143
+ };
144
+
145
+ /** A free event: `entry_fee: 0` renders the free chip instead of a price. */
146
+ export const FreeEntry: Story = {
147
+ args: { event: { ...event, entry_fee: 0 } },
148
+ decorators: [
149
+ (Story) => (
150
+ <div className="h-[820px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
151
+ <Story />
152
+ </div>
153
+ ),
154
+ ],
155
+ };
156
+
157
+ /** No description and no venue — the sections drop out rather than render empty headings. */
158
+ export const Sparse: Story = {
159
+ args: {
160
+ event: { ...event, description: undefined, entry_fee: undefined },
161
+ venue: undefined,
162
+ tags: undefined,
163
+ externalLink: undefined,
164
+ },
165
+ decorators: [
166
+ (Story) => (
167
+ <div className="h-[560px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
168
+ <Story />
169
+ </div>
170
+ ),
171
+ ],
172
+ };
173
+
174
+ /** Desktop side sheet loading state. */
175
+ export const Skeleton: Story = {
176
+ render: () => (
177
+ <div className="h-[820px] w-[460px] overflow-hidden rounded-[18px] shadow-2xl">
178
+ <EventDetailPanelSkeleton />
179
+ </div>
180
+ ),
181
+ };
182
+
183
+ /** Mobile bottom sheet loading state — compact size, same 4:5 poster. */
184
+ export const SkeletonCompact: Story = {
185
+ render: () => (
186
+ <div className="h-[720px] w-full max-w-[390px] overflow-hidden rounded-t-[20px] shadow-2xl">
187
+ <div className="mx-auto mt-2.5 mb-1 h-1 w-[38px] rounded-full bg-border" />
188
+ <EventDetailPanelSkeleton size="compact" />
189
+ </div>
190
+ ),
191
+ };
@@ -0,0 +1,360 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Calendar, ChevronRight, Radio, Star } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { EventPosterFallback } from "./event-poster-fallback";
7
+ import { Separator } from "./separator";
8
+ import { Skeleton } from "./skeleton";
9
+ import {
10
+ resolveEventEntryFee,
11
+ type EventFormatters,
12
+ type EventPreview,
13
+ } from "./event-types";
14
+
15
+ /**
16
+ * EventDetailPanel — the BODY of the event detail sheet.
17
+ *
18
+ * Deliberately not a sheet. The design opens this as a side sheet over a dimmed
19
+ * page on desktop and a bottom sheet on mobile, and the DS already ships `Sheet`
20
+ * and `Drawer`; owning sheet mechanics a third time here would fork them. The
21
+ * consuming app mounts this inside whichever container the breakpoint calls for.
22
+ *
23
+ * This is the terminal surface for acting on an event, which is why the cards
24
+ * that open it carry no actions of their own — the footer is where booking and
25
+ * directions live.
26
+ *
27
+ * Everything is injected: strings via `labels`, times via `formatters`, and the
28
+ * close/share affordances as `headerActions` so the sheet container owns
29
+ * dismissal (bearmenu sheets close by drag handle, not by back navigation).
30
+ */
31
+
32
+ export type EventDetailPanelLinkProps = {
33
+ href: string;
34
+ className?: string;
35
+ children: React.ReactNode;
36
+ };
37
+
38
+ function DefaultLink({ href, ...props }: EventDetailPanelLinkProps) {
39
+ return <a href={href} {...props} />;
40
+ }
41
+
42
+ export type EventDetailVenue = {
43
+ name: string;
44
+ /** Address plus opening line, composed by the caller. */
45
+ meta?: string;
46
+ ratingLabel?: string;
47
+ thumbnailUrl?: string;
48
+ href?: string;
49
+ };
50
+
51
+ export type EventDetailPanelLabels = {
52
+ free: string;
53
+ liveNow: string;
54
+ /** Section heading above the venue card. */
55
+ venueHeading: string;
56
+ /** Section heading above the description. */
57
+ aboutHeading: string;
58
+ };
59
+
60
+ export type EventDetailPanelProps = {
61
+ event: EventPreview;
62
+ labels: EventDetailPanelLabels;
63
+ formatters: EventFormatters;
64
+ /**
65
+ * The date line, composed by the caller — the design reads
66
+ * "Saturday, September 5 · Starts at 20:00", whose clause order and
67
+ * preposition are locale-dependent.
68
+ */
69
+ dateLine: string;
70
+ venue?: EventDetailVenue;
71
+ /** Category chips beside the live/price chips. */
72
+ tags?: string[];
73
+ isLive?: boolean;
74
+ /** Close and share controls, positioned over the poster by this component. */
75
+ headerActions?: React.ReactNode;
76
+ /** Footer actions — the booking CTA and directions. Rendered in a sticky footer. */
77
+ footer?: React.ReactNode;
78
+ /** An external link row, e.g. "View on Instagram". */
79
+ externalLink?: React.ReactNode;
80
+ /** `compact` is the mobile bottom sheet: tighter type and padding, same 4:5 poster. */
81
+ size?: "compact" | "default";
82
+ linkComponent?: React.ComponentType<EventDetailPanelLinkProps>;
83
+ className?: string;
84
+ /** Rendered instead of the poster when it's absent. Defaults to `<EventPosterFallback />`. */
85
+ fallback?: React.ReactNode;
86
+ };
87
+
88
+ const CHIP =
89
+ "inline-flex items-center gap-1 rounded-full border border-border px-2.5 text-xs";
90
+
91
+ export function EventDetailPanel({
92
+ event,
93
+ labels,
94
+ formatters,
95
+ dateLine,
96
+ venue,
97
+ tags,
98
+ isLive,
99
+ headerActions,
100
+ footer,
101
+ externalLink,
102
+ size = "default",
103
+ linkComponent: LinkComponent = DefaultLink,
104
+ className,
105
+ fallback = <EventPosterFallback />,
106
+ }: EventDetailPanelProps) {
107
+ const compact = size === "compact";
108
+ const fee = resolveEventEntryFee(event);
109
+
110
+ const venueCard = venue && (
111
+ <div className="flex items-center gap-3 rounded-[13px] border border-border p-2.5">
112
+ <div
113
+ className={cn(
114
+ "shrink-0 overflow-hidden rounded-[10px] bg-muted",
115
+ compact ? "h-12 w-12" : "h-[52px] w-[52px]"
116
+ )}
117
+ >
118
+ {venue.thumbnailUrl && (
119
+ // eslint-disable-next-line @next/next/no-img-element
120
+ <img
121
+ src={venue.thumbnailUrl}
122
+ alt=""
123
+ className="h-full w-full object-cover"
124
+ loading="lazy"
125
+ decoding="async"
126
+ />
127
+ )}
128
+ </div>
129
+ <div className="min-w-0 flex-1">
130
+ <div className={cn("font-bold", compact ? "text-[13.5px]" : "text-sm")}>{venue.name}</div>
131
+ {venue.meta && (
132
+ <div className={cn("text-muted-foreground", compact ? "text-[11.5px]" : "text-xs")}>
133
+ {venue.meta}
134
+ </div>
135
+ )}
136
+ {venue.ratingLabel && (
137
+ <div className="mt-0.5 flex items-center gap-1 text-xs font-bold">
138
+ <Star className="h-3 w-3" aria-hidden />
139
+ {venue.ratingLabel}
140
+ </div>
141
+ )}
142
+ </div>
143
+ <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
144
+ </div>
145
+ );
146
+
147
+ return (
148
+ <div className={cn("flex h-full flex-col overflow-hidden bg-card", className)}>
149
+ <div className="flex-1 overflow-y-auto">
150
+ <div className="relative aspect-[4/5] shrink-0 overflow-hidden bg-muted">
151
+ {event.poster_url ? (
152
+ // eslint-disable-next-line @next/next/no-img-element
153
+ <img
154
+ src={event.poster_url}
155
+ alt={event.title}
156
+ className="h-full w-full object-cover"
157
+ loading="eager"
158
+ decoding="async"
159
+ />
160
+ ) : (
161
+ fallback
162
+ )}
163
+ {headerActions}
164
+ </div>
165
+
166
+ <div
167
+ className={cn(
168
+ "flex flex-col gap-3.5",
169
+ compact ? "px-4 pt-4" : "px-[22px] pt-5"
170
+ )}
171
+ >
172
+ <h2
173
+ className={cn(
174
+ "font-extrabold leading-[1.25] tracking-[-0.02em]",
175
+ compact ? "text-xl" : "text-[21px]"
176
+ )}
177
+ >
178
+ {event.title}
179
+ </h2>
180
+
181
+ <div
182
+ className={cn(
183
+ "flex items-center gap-1.5 text-muted-foreground",
184
+ compact ? "text-[12.5px]" : "text-[13px]"
185
+ )}
186
+ >
187
+ <Calendar className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} aria-hidden />
188
+ {dateLine}
189
+ </div>
190
+
191
+ {/* The live chip is green, not the brand accent: "on now" reads as one
192
+ colour across every events surface, and it is the same green the app
193
+ already uses for "open now". */}
194
+ <div className="flex flex-wrap gap-1.5">
195
+ {isLive && (
196
+ <span
197
+ className={cn(
198
+ CHIP,
199
+ "h-7 border-success/30 bg-success/10 font-semibold text-success"
200
+ )}
201
+ >
202
+ <Radio className="h-3 w-3" aria-hidden />
203
+ {labels.liveNow}
204
+ </span>
205
+ )}
206
+ {tags?.map((tag) => (
207
+ <span key={tag} className={cn(CHIP, "h-7")}>
208
+ {tag}
209
+ </span>
210
+ ))}
211
+ {fee.kind === "free" && (
212
+ <span className={cn(CHIP, "h-7 border-success/40 font-semibold text-success")}>
213
+ {labels.free}
214
+ </span>
215
+ )}
216
+ {fee.kind === "paid" && (
217
+ <span className={cn(CHIP, "h-7")}>{formatters.price(fee.amountRon)}</span>
218
+ )}
219
+ </div>
220
+
221
+ {venue && (
222
+ <div>
223
+ <div className="pb-2 text-[13px] font-extrabold">{labels.venueHeading}</div>
224
+ {venue.href ? (
225
+ <LinkComponent href={venue.href} className="block">
226
+ {venueCard}
227
+ </LinkComponent>
228
+ ) : (
229
+ venueCard
230
+ )}
231
+ </div>
232
+ )}
233
+
234
+ {event.description && (
235
+ <div>
236
+ <div className="pb-1.5 text-[13px] font-extrabold">{labels.aboutHeading}</div>
237
+ <p
238
+ className={cn(
239
+ "leading-[1.6] text-muted-foreground",
240
+ compact ? "text-[13px]" : "text-[13.5px]"
241
+ )}
242
+ >
243
+ {event.description}
244
+ </p>
245
+ </div>
246
+ )}
247
+
248
+ {externalLink}
249
+ <div className="h-2" aria-hidden />
250
+ </div>
251
+ </div>
252
+
253
+ {footer && (
254
+ <div
255
+ className={cn(
256
+ "sticky bottom-0 flex shrink-0 gap-2.5 bg-card",
257
+ compact
258
+ ? "px-4 pt-3 pb-[max(1.375rem,env(safe-area-inset-bottom))]"
259
+ : "px-[22px] pt-4 pb-[max(1.25rem,env(safe-area-inset-bottom))]"
260
+ )}
261
+ >
262
+ <Separator className="absolute inset-x-0 top-0" />
263
+
264
+ {footer}
265
+ </div>
266
+ )}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ export type EventDetailPanelSkeletonProps = {
272
+ /** `compact` is the mobile bottom sheet: tighter type and padding, same 4:5 poster. */
273
+ size?: "compact" | "default";
274
+ /** Close and share controls, positioned over the poster — same slot as the real component. */
275
+ headerActions?: React.ReactNode;
276
+ className?: string;
277
+ };
278
+
279
+ /** Mirrors `<EventDetailPanel>`'s geometry exactly: the hero scrolls away with the body, the footer is sticky. */
280
+ export function EventDetailPanelSkeleton({
281
+ size = "default",
282
+ headerActions,
283
+ className,
284
+ }: EventDetailPanelSkeletonProps) {
285
+ const compact = size === "compact";
286
+
287
+ return (
288
+ <div
289
+ aria-hidden="true"
290
+ className={cn("flex h-full flex-col overflow-hidden bg-card", className)}
291
+ >
292
+ <div className="flex-1 overflow-y-auto">
293
+ <div className="relative aspect-[4/5] shrink-0 overflow-hidden bg-muted">
294
+ {headerActions}
295
+ </div>
296
+
297
+ <div
298
+ className={cn(
299
+ "flex flex-col gap-3.5",
300
+ compact ? "px-4 pt-4" : "px-[22px] pt-5"
301
+ )}
302
+ >
303
+ <Skeleton className={cn("w-3/4", compact ? "h-5" : "h-[21px]")} />
304
+
305
+ <div className="flex items-center gap-1.5">
306
+ <Skeleton className={cn("shrink-0 rounded-full", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
307
+ <Skeleton className={cn("w-48", compact ? "h-[12.5px]" : "h-[13px]")} />
308
+ </div>
309
+
310
+ <div className="flex flex-wrap gap-1.5">
311
+ <Skeleton className="h-7 w-20 rounded-full" />
312
+ <Skeleton className="h-7 w-16 rounded-full" />
313
+ </div>
314
+
315
+ <div>
316
+ <Skeleton className="mb-2 h-[13px] w-28" />
317
+ <div className="flex items-center gap-3 rounded-[13px] border border-border p-2.5">
318
+ <Skeleton
319
+ className={cn(
320
+ "shrink-0 rounded-[10px]",
321
+ compact ? "h-12 w-12" : "h-[52px] w-[52px]"
322
+ )}
323
+ />
324
+ <div className="min-w-0 flex-1">
325
+ <Skeleton className={cn("w-1/2", compact ? "h-[13.5px]" : "h-3.5")} />
326
+ <Skeleton className={cn("mt-1 w-3/4", compact ? "h-[11.5px]" : "h-3")} />
327
+ </div>
328
+ <Skeleton className="h-4 w-4 shrink-0 rounded-full" />
329
+ </div>
330
+ </div>
331
+
332
+ <div>
333
+ <Skeleton className="mb-1.5 h-[13px] w-32" />
334
+ <div className="flex flex-col gap-1.5">
335
+ <Skeleton className={cn("w-full", compact ? "h-[13px]" : "h-[13.5px]")} />
336
+ <Skeleton className={cn("w-full", compact ? "h-[13px]" : "h-[13.5px]")} />
337
+ <Skeleton className={cn("w-2/3", compact ? "h-[13px]" : "h-[13.5px]")} />
338
+ </div>
339
+ </div>
340
+
341
+ <div className="h-2" aria-hidden />
342
+ </div>
343
+ </div>
344
+
345
+ <div
346
+ className={cn(
347
+ "sticky bottom-0 flex shrink-0 gap-2.5 bg-card",
348
+ compact
349
+ ? "px-4 pt-3 pb-[max(1.375rem,env(safe-area-inset-bottom))]"
350
+ : "px-[22px] pt-4 pb-[max(1.25rem,env(safe-area-inset-bottom))]"
351
+ )}
352
+ >
353
+ <Separator className="absolute inset-x-0 top-0" />
354
+
355
+ <Skeleton className="h-[46px] flex-1 rounded-xl" />
356
+ <Skeleton className="h-[46px] w-24 rounded-xl" />
357
+ </div>
358
+ </div>
359
+ );
360
+ }