@bearmenu/ui 0.8.7 → 0.8.9

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 (179) hide show
  1. package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
  2. package/dist/chunk-62ES6N32.js +27 -0
  3. package/dist/chunk-AHL45DD2.js +145 -0
  4. package/dist/chunk-AXV5O2IZ.js +58 -0
  5. package/dist/chunk-IYDH5IU6.js +66 -0
  6. package/dist/chunk-OPVKL4VS.js +18 -0
  7. package/dist/chunk-Q6QH52LJ.js +38 -0
  8. package/dist/chunk-TH6W2PWG.js +213 -0
  9. package/dist/chunk-THOVPZNN.js +37 -0
  10. package/dist/chunk-YS6BHKZO.js +92 -0
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/button.d.ts +1 -1
  13. package/dist/components/category-deck.js +10 -1
  14. package/dist/components/command.d.ts +7 -7
  15. package/dist/components/concept-compare.d.ts +73 -0
  16. package/dist/components/concept-compare.js +162 -0
  17. package/dist/components/coverflow.d.ts +11 -3
  18. package/dist/components/coverflow.js +34 -51
  19. package/dist/components/date-input.d.ts +1 -1
  20. package/dist/components/drift-rail.d.ts +24 -10
  21. package/dist/components/drift-rail.js +1 -1
  22. package/dist/components/event-card.d.ts +8 -0
  23. package/dist/components/event-card.js +64 -34
  24. package/dist/components/event-compact-row.d.ts +33 -6
  25. package/dist/components/event-compact-row.js +120 -39
  26. package/dist/components/event-day-header.js +3 -1
  27. package/dist/components/event-day-separator.d.ts +7 -1
  28. package/dist/components/event-day-separator.js +3 -2
  29. package/dist/components/event-detail-panel.d.ts +171 -25
  30. package/dist/components/event-detail-panel.js +471 -174
  31. package/dist/components/event-hub-hero.d.ts +25 -4
  32. package/dist/components/event-hub-hero.js +89 -19
  33. package/dist/components/event-list-row-wide.js +1 -1
  34. package/dist/components/event-list-row.d.ts +19 -6
  35. package/dist/components/event-list-row.js +79 -35
  36. package/dist/components/hour-band.d.ts +38 -40
  37. package/dist/components/hour-band.js +55 -27
  38. package/dist/components/hour-group.js +1 -1
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +20 -0
  41. package/dist/components/menu-family-card.d.ts +59 -0
  42. package/dist/components/menu-family-card.js +121 -0
  43. package/dist/components/menu-field.d.ts +57 -0
  44. package/dist/components/menu-field.js +3 -0
  45. package/dist/components/menu-hub-hero.d.ts +65 -0
  46. package/dist/components/menu-hub-hero.js +103 -0
  47. package/dist/components/menu-intent-tiles.d.ts +31 -0
  48. package/dist/components/menu-intent-tiles.js +50 -0
  49. package/dist/components/menu-item.d.ts +127 -0
  50. package/dist/components/menu-item.js +5 -0
  51. package/dist/components/menu-mocks.d.ts +55 -0
  52. package/dist/components/menu-mocks.js +379 -0
  53. package/dist/components/menu-types.d.ts +245 -0
  54. package/dist/components/menu-types.js +1 -0
  55. package/dist/components/motion-icon-set.js +15 -3
  56. package/dist/components/open-now-list.d.ts +40 -0
  57. package/dist/components/open-now-list.js +69 -0
  58. package/dist/components/place-types.d.ts +112 -1
  59. package/dist/components/poster-tape.js +1 -1
  60. package/dist/components/poster-wall.d.ts +3 -3
  61. package/dist/components/poster-wall.js +73 -17
  62. package/dist/components/ranked-venue-list.d.ts +49 -0
  63. package/dist/components/ranked-venue-list.js +87 -0
  64. package/dist/components/section-header.d.ts +16 -1
  65. package/dist/components/section-header.js +26 -7
  66. package/dist/components/signature-chip.d.ts +37 -0
  67. package/dist/components/signature-chip.js +4 -0
  68. package/dist/components/signature-line-list.d.ts +45 -0
  69. package/dist/components/signature-line-list.js +99 -0
  70. package/dist/components/snap-rail.d.ts +54 -0
  71. package/dist/components/snap-rail.js +109 -0
  72. package/dist/components/spinner.d.ts +2 -2
  73. package/dist/components/stat-banner.d.ts +47 -0
  74. package/dist/components/stat-banner.js +61 -0
  75. package/dist/components/text.d.ts +3 -3
  76. package/dist/components/timeline-band.d.ts +20 -2
  77. package/dist/components/timeline-band.js +1 -1
  78. package/dist/components/timeline-playhead.d.ts +26 -0
  79. package/dist/components/timeline-playhead.js +25 -0
  80. package/dist/components/venue-busyness-strip.d.ts +35 -0
  81. package/dist/components/venue-busyness-strip.js +37 -0
  82. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  83. package/dist/components/venue-grade-dimensions.js +3 -0
  84. package/dist/components/venue-quotes.d.ts +32 -0
  85. package/dist/components/venue-quotes.js +3 -0
  86. package/dist/components/venue-signature-block.d.ts +39 -0
  87. package/dist/components/venue-signature-block.js +6 -0
  88. package/dist/components/venue-spotlight.d.ts +74 -0
  89. package/dist/components/venue-spotlight.js +138 -0
  90. package/dist/components/venue-wall.d.ts +41 -0
  91. package/dist/components/venue-wall.js +118 -0
  92. package/dist/components/weekday-theme-banner.d.ts +70 -0
  93. package/dist/components/weekday-theme-banner.js +127 -0
  94. package/dist/components/weekend-panel.d.ts +5 -4
  95. package/dist/components/weekend-panel.js +85 -71
  96. package/dist/hooks/use-drag-swipe.d.ts +44 -0
  97. package/dist/hooks/use-drag-swipe.js +2 -0
  98. package/dist/hooks/use-media-query.d.ts +17 -0
  99. package/dist/hooks/use-media-query.js +2 -0
  100. package/package.json +1 -1
  101. package/src/components/category-deck.tsx +19 -8
  102. package/src/components/colour-dictionary.stories.tsx +227 -0
  103. package/src/components/concept-compare.stories.tsx +104 -0
  104. package/src/components/concept-compare.tsx +276 -0
  105. package/src/components/coverflow.test.tsx +28 -1
  106. package/src/components/coverflow.tsx +37 -43
  107. package/src/components/drift-rail.tsx +167 -42
  108. package/src/components/event-card.tsx +55 -10
  109. package/src/components/event-compact-row.stories.tsx +30 -0
  110. package/src/components/event-compact-row.test.tsx +49 -0
  111. package/src/components/event-compact-row.tsx +162 -38
  112. package/src/components/event-day-header.tsx +3 -1
  113. package/src/components/event-day-separator.tsx +8 -1
  114. package/src/components/event-detail-panel.stories.tsx +205 -97
  115. package/src/components/event-detail-panel.test.tsx +181 -0
  116. package/src/components/event-detail-panel.tsx +703 -185
  117. package/src/components/event-hub-hero.stories.tsx +30 -0
  118. package/src/components/event-hub-hero.tsx +102 -30
  119. package/src/components/event-list-row.stories.tsx +34 -0
  120. package/src/components/event-list-row.test.tsx +53 -0
  121. package/src/components/event-list-row.tsx +135 -87
  122. package/src/components/hour-band.stories.tsx +28 -5
  123. package/src/components/hour-band.tsx +90 -70
  124. package/src/components/hub-surface.stories.tsx +68 -0
  125. package/src/components/hub-surface.tsx +40 -0
  126. package/src/components/menu-family-card.stories.tsx +112 -0
  127. package/src/components/menu-family-card.tsx +211 -0
  128. package/src/components/menu-field.stories.tsx +74 -0
  129. package/src/components/menu-field.tsx +199 -0
  130. package/src/components/menu-hub-hero.stories.tsx +92 -0
  131. package/src/components/menu-hub-hero.tsx +187 -0
  132. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  133. package/src/components/menu-intent-tiles.tsx +76 -0
  134. package/src/components/menu-item.tsx +416 -0
  135. package/src/components/menu-mocks.ts +417 -0
  136. package/src/components/menu-types.ts +259 -0
  137. package/src/components/motion-icon-set.tsx +30 -3
  138. package/src/components/open-now-list.stories.tsx +76 -0
  139. package/src/components/open-now-list.tsx +127 -0
  140. package/src/components/place-types.ts +112 -0
  141. package/src/components/poster-wall.test.tsx +28 -0
  142. package/src/components/poster-wall.tsx +96 -23
  143. package/src/components/ranked-venue-list.stories.tsx +80 -0
  144. package/src/components/ranked-venue-list.tsx +177 -0
  145. package/src/components/section-header.stories.tsx +24 -0
  146. package/src/components/section-header.test.tsx +28 -0
  147. package/src/components/section-header.tsx +45 -7
  148. package/src/components/signature-chip.tsx +77 -0
  149. package/src/components/signature-line-list.stories.tsx +85 -0
  150. package/src/components/signature-line-list.tsx +136 -0
  151. package/src/components/snap-rail.stories.tsx +79 -0
  152. package/src/components/snap-rail.test.tsx +18 -0
  153. package/src/components/snap-rail.tsx +148 -0
  154. package/src/components/stat-banner.stories.tsx +100 -0
  155. package/src/components/stat-banner.tsx +117 -0
  156. package/src/components/timeline-band.tsx +52 -12
  157. package/src/components/timeline-playhead.stories.tsx +30 -0
  158. package/src/components/timeline-playhead.test.tsx +11 -0
  159. package/src/components/timeline-playhead.tsx +46 -0
  160. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  161. package/src/components/venue-busyness-strip.tsx +64 -0
  162. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  163. package/src/components/venue-grade-dimensions.tsx +91 -0
  164. package/src/components/venue-quotes.stories.tsx +59 -0
  165. package/src/components/venue-quotes.tsx +64 -0
  166. package/src/components/venue-signature-block.stories.tsx +68 -0
  167. package/src/components/venue-signature-block.tsx +71 -0
  168. package/src/components/venue-spotlight.stories.tsx +141 -0
  169. package/src/components/venue-spotlight.tsx +236 -0
  170. package/src/components/venue-wall.stories.tsx +78 -0
  171. package/src/components/venue-wall.tsx +185 -0
  172. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  173. package/src/components/weekday-theme-banner.tsx +231 -0
  174. package/src/components/weekend-panel.test.tsx +43 -0
  175. package/src/components/weekend-panel.tsx +142 -108
  176. package/src/hooks/use-drag-swipe.test.tsx +66 -0
  177. package/src/hooks/use-drag-swipe.ts +105 -0
  178. package/src/hooks/use-media-query.ts +31 -0
  179. package/dist/chunk-GPH6L4DX.js +0 -55
@@ -1,26 +1,41 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useId, useRef } from "react";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import type { PointerEvent as ReactPointerEvent } from "react";
4
5
  import { cn } from "../lib/utils";
5
6
 
6
7
  /**
7
8
  * DriftRail — an infinite horizontal marquee for logos, tag chips or small
8
- * poster tiles. The caller passes one set of children; the component renders
9
- * a second, `aria-hidden` copy right after it so a `translateX(-50%)` loop is
10
- * seamless. Because the loop depends on the duplicate being pixel-identical
11
- * to the first pass, keep `children` a fixed, ordered list — no per-render
12
- * randomisation.
9
+ * poster tiles that the reader can take hold of. The caller passes one set of
10
+ * children; the component renders a second, `aria-hidden` copy right after it
11
+ * so the loop is seamless. Because the loop depends on the duplicate being
12
+ * pixel-identical to the first pass, keep `children` a fixed, ordered list —
13
+ * no per-render randomisation.
13
14
  *
14
- * Motion is scoped to this instance via a generated id (no shared stylesheet,
15
- * no class collisions between multiple rails on one page) and is paused two
16
- * ways, per the source design: an `IntersectionObserver` stops the loop the
17
- * moment the rail scrolls off-screen, and `prefers-reduced-motion: reduce`
18
- * removes the animation outright rather than merely slowing it.
15
+ * IT IS A SCROLLER THAT DRIFTS, NOT AN ANIMATION. It used to be a CSS
16
+ * `translateX` keyframe, which is cheap but cannot be grabbed: a finger on it
17
+ * did nothing, and a marquee that ignores the hand on it reads as a broken
18
+ * scroller. Now the rail is a real horizontal scroll container with the
19
+ * scrollbar hidden, and a `requestAnimationFrame` loop advances `scrollLeft`
20
+ * at `width / durationSeconds` per second. When the scroll position passes
21
+ * the first copy it is wrapped back by exactly one copy's width — the two
22
+ * copies are identical, so the jump is invisible — and the same wrap holds
23
+ * while the reader scrolls, in either direction, so there is no end to hit.
24
+ *
25
+ * THE READER WINS. A touch scrolls natively (momentum and all); a mouse drag
26
+ * scrolls the rail by the drag distance; a wheel works because it is a
27
+ * scroller. Any of those stops the drift, and it resumes a few seconds after
28
+ * the last interaction. Hover pauses it too (`pauseOnHover`), and an
29
+ * `IntersectionObserver` stops the loop off-screen so the page is not doing
30
+ * work nobody can see. `prefers-reduced-motion` removes the drift entirely;
31
+ * the rail stays scrollable by hand, which is more than the static strip it
32
+ * used to collapse to.
19
33
  */
20
34
  export type DriftRailProps = {
21
35
  children: React.ReactNode;
22
36
  /** Runs the loop right-to-left instead of left-to-right. */
23
37
  reverse?: boolean;
38
+ /** Seconds for one full pass of the children. */
24
39
  durationSeconds?: number;
25
40
  /** Pauses the loop while the pointer hovers the rail. */
26
41
  pauseOnHover?: boolean;
@@ -28,6 +43,8 @@ export type DriftRailProps = {
28
43
  };
29
44
 
30
45
  const DRIFT_RAIL_DEFAULT_DURATION_SECONDS = 42;
46
+ /** How long after the reader lets go before the drift resumes. */
47
+ const DRIFT_RAIL_RESUME_DELAY_MS = 3000;
31
48
 
32
49
  export function DriftRail({
33
50
  children,
@@ -36,46 +53,154 @@ export function DriftRail({
36
53
  pauseOnHover = true,
37
54
  className,
38
55
  }: DriftRailProps) {
39
- const scopeId = useId().replace(/[^a-zA-Z0-9]/g, "");
40
- const scopeClass = `drift-rail-${scopeId}`;
41
- const containerRef = useRef<HTMLDivElement>(null);
56
+ const scrollerRef = useRef<HTMLDivElement>(null);
57
+ const trackRef = useRef<HTMLDivElement>(null);
58
+ const [isHovered, setIsHovered] = useState(false);
59
+ const [inView, setInView] = useState(true);
60
+ const [reducedMotion, setReducedMotion] = useState(false);
61
+ // `heldUntil` is a timestamp rather than a boolean so a native touch scroll
62
+ // that keeps going after the finger lifts is not fought by the drift.
63
+ const heldUntil = useRef(0);
64
+ const mouseDrag = useRef<{ startX: number; startScroll: number; pointerId: number } | null>(null);
65
+ // The drift's own position, kept with its fraction. `scrollLeft` is rounded
66
+ // to whole pixels by the browser, and at ~60px/s the per-frame step is under
67
+ // a pixel — read back and added to, it rounded to nothing and the rail never
68
+ // moved. So the loop adds to THIS and writes it out; while the reader holds
69
+ // the rail it follows `scrollLeft` instead, so it never fights the finger.
70
+ const position = useRef(0);
42
71
 
43
72
  useEffect(() => {
44
- const node = containerRef.current;
45
- if (!node || typeof IntersectionObserver === "undefined") return undefined;
73
+ if (typeof window === "undefined" || !window.matchMedia) return;
74
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
75
+ setReducedMotion(query.matches);
76
+ const onChange = () => setReducedMotion(query.matches);
77
+ query.addEventListener("change", onChange);
78
+ return () => query.removeEventListener("change", onChange);
79
+ }, []);
46
80
 
47
- const observer = new IntersectionObserver(
48
- ([entry]) => {
49
- node.dataset.offscreen = entry.isIntersecting ? "false" : "true";
50
- },
51
- { threshold: 0 }
52
- );
81
+ useEffect(() => {
82
+ const node = scrollerRef.current;
83
+ if (!node || typeof IntersectionObserver === "undefined") return undefined;
84
+ const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
85
+ threshold: 0,
86
+ });
53
87
  observer.observe(node);
54
88
  return () => observer.disconnect();
55
89
  }, []);
56
90
 
57
- return (
58
- <div ref={containerRef} className={cn(scopeClass, "overflow-hidden py-1.5", className)}>
59
- <style>{`
60
- .${scopeClass} .drift-rail-run {
61
- animation: ${scopeClass}-run ${durationSeconds}s linear infinite;
62
- animation-direction: ${reverse ? "reverse" : "normal"};
63
- }
64
- .${scopeClass}[data-offscreen="true"] .drift-rail-run {
65
- animation-play-state: paused;
66
- }
67
- @keyframes ${scopeClass}-run {
68
- from { transform: translateX(0); }
69
- to { transform: translateX(-50%); }
91
+ // The drift, and the wrap. Runs while mounted; the wrap has to run even
92
+ // while the reader holds the rail, so the loop never pauses — it only stops
93
+ // ADDING distance when something says it should.
94
+ useEffect(() => {
95
+ const scroller = scrollerRef.current;
96
+ const track = trackRef.current;
97
+ if (!scroller || !track) return undefined;
98
+
99
+ let frame = 0;
100
+ let last = performance.now();
101
+ const drifting = !reducedMotion && inView && !(pauseOnHover && isHovered);
102
+
103
+ // Start the reverse loop inside the second copy so it has room to travel.
104
+ if (reverse && scroller.scrollLeft === 0) scroller.scrollLeft = track.scrollWidth / 2;
105
+ position.current = scroller.scrollLeft;
106
+
107
+ const tick = (now: number) => {
108
+ const half = track.scrollWidth / 2;
109
+ if (half > 0) {
110
+ const driftingNow = drifting && now >= heldUntil.current;
111
+ if (driftingNow) {
112
+ const perSecond = half / durationSeconds;
113
+ const delta = (perSecond * (now - last)) / 1000;
114
+ position.current += reverse ? -delta : delta;
115
+ } else {
116
+ position.current = scroller.scrollLeft;
70
117
  }
71
- @media (prefers-reduced-motion: reduce) {
72
- .${scopeClass} .drift-rail-run {
73
- animation: none;
74
- }
118
+ // The wrap applies in both modes: the two copies are identical, so
119
+ // jumping by exactly one copy's width is invisible even mid-swipe.
120
+ let wrapped = false;
121
+ if (position.current >= half) {
122
+ position.current -= half;
123
+ wrapped = true;
124
+ } else if (position.current < 0) {
125
+ position.current += half;
126
+ wrapped = true;
75
127
  }
76
- ${pauseOnHover ? `.${scopeClass}:hover .drift-rail-run { animation-play-state: paused; }` : ""}
77
- `}</style>
78
- <div className="drift-rail-run flex w-max items-stretch gap-3.5 motion-reduce:[animation:none]">
128
+ if (driftingNow || wrapped) scroller.scrollLeft = position.current;
129
+ }
130
+ last = now;
131
+ frame = requestAnimationFrame(tick);
132
+ };
133
+ frame = requestAnimationFrame(tick);
134
+ return () => cancelAnimationFrame(frame);
135
+ }, [durationSeconds, inView, isHovered, pauseOnHover, reducedMotion, reverse]);
136
+
137
+ const hold = () => {
138
+ heldUntil.current = Number.POSITIVE_INFINITY;
139
+ };
140
+ const release = () => {
141
+ heldUntil.current = performance.now() + DRIFT_RAIL_RESUME_DELAY_MS;
142
+ };
143
+
144
+ // Touch and pen scroll natively; only a mouse needs help to drag.
145
+ const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
146
+ hold();
147
+ if (event.pointerType !== "mouse" || event.button !== 0 || !scrollerRef.current) return;
148
+ mouseDrag.current = {
149
+ startX: event.clientX,
150
+ startScroll: scrollerRef.current.scrollLeft,
151
+ pointerId: event.pointerId,
152
+ };
153
+ };
154
+ const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
155
+ const drag = mouseDrag.current;
156
+ if (!drag || event.pointerId !== drag.pointerId || !scrollerRef.current) return;
157
+ const dx = event.clientX - drag.startX;
158
+ if (Math.abs(dx) > 4 && !event.currentTarget.hasPointerCapture?.(event.pointerId)) {
159
+ event.currentTarget.setPointerCapture?.(event.pointerId);
160
+ event.currentTarget.dataset.dragging = "true";
161
+ }
162
+ scrollerRef.current.scrollLeft = drag.startScroll - dx;
163
+ };
164
+ const onPointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
165
+ release();
166
+ const drag = mouseDrag.current;
167
+ mouseDrag.current = null;
168
+ if (drag && event.currentTarget.hasPointerCapture?.(event.pointerId)) {
169
+ event.currentTarget.releasePointerCapture(event.pointerId);
170
+ }
171
+ delete event.currentTarget.dataset.dragging;
172
+ };
173
+ // A mouse drag must not end as a click on the item under the cursor.
174
+ const onClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
175
+ if (event.currentTarget.dataset.dragging === "true") {
176
+ event.preventDefault();
177
+ event.stopPropagation();
178
+ }
179
+ };
180
+
181
+ return (
182
+ <div
183
+ ref={scrollerRef}
184
+ onMouseEnter={() => setIsHovered(true)}
185
+ onMouseLeave={() => setIsHovered(false)}
186
+ onPointerDown={onPointerDown}
187
+ onPointerMove={onPointerMove}
188
+ onPointerUp={onPointerEnd}
189
+ onPointerCancel={onPointerEnd}
190
+ onWheel={() => {
191
+ release();
192
+ }}
193
+ // The items are links wrapping images; a mouse drag on one would start a
194
+ // native HTML5 drag and cancel the pointer stream mid-gesture.
195
+ onDragStart={(event) => event.preventDefault()}
196
+ onClickCapture={onClickCapture}
197
+ className={cn(
198
+ "overflow-x-auto overflow-y-hidden py-1.5 scrollbar-hide [touch-action:pan-x]",
199
+ "cursor-grab select-none data-[dragging=true]:cursor-grabbing",
200
+ className
201
+ )}
202
+ >
203
+ <div ref={trackRef} className="flex w-max items-stretch gap-3.5">
79
204
  {children}
80
205
  <div className="contents" aria-hidden="true">
81
206
  {children}
@@ -79,6 +79,14 @@ export type EventCardProps = {
79
79
  liveStatus?: string;
80
80
  /** Hide the day/time line — the timeline supplies its own hour heading. */
81
81
  hideWhen?: boolean;
82
+ /**
83
+ * Already over — dims the poster, mutes the title and shows the `endedLabel`
84
+ * chip. Never row-level opacity, which composites the text below AA. Same
85
+ * contract as `EventCompactRow`.
86
+ */
87
+ isEnded?: boolean;
88
+ /** Chip text for an ended event, e.g. "Ended". Required to render the chip. */
89
+ endedLabel?: string;
82
90
  /** Overrides the day label when the card appears outside its own day section. */
83
91
  dayLabel?: string;
84
92
  /** Append the entry fee to the meta line. Off by default, matching the design's base card. */
@@ -108,6 +116,8 @@ export const EventCard = memo(function EventCard({
108
116
  tags,
109
117
  liveStatus,
110
118
  hideWhen,
119
+ isEnded,
120
+ endedLabel,
111
121
  dayLabel,
112
122
  showPrice,
113
123
  href,
@@ -118,6 +128,7 @@ export const EventCard = memo(function EventCard({
118
128
  fallback = <EventPosterFallback />,
119
129
  }: EventCardProps) {
120
130
  const [imgError, setImgError] = useState(false);
131
+ const showEnded = isEnded && !!endedLabel;
121
132
  const time = hideWhen ? "" : formatters.time(event.starts_at);
122
133
 
123
134
  const fee = resolveEventEntryFee(event);
@@ -129,11 +140,12 @@ export const EventCard = memo(function EventCard({
129
140
  <div
130
141
  className={cn(
131
142
  "relative overflow-hidden rounded-xl bg-muted",
143
+ isEnded && "opacity-45",
132
144
  posterAspect === "3/4"
133
145
  ? "aspect-[3/4]"
134
146
  : posterAspect === "4/5"
135
147
  ? "aspect-[4/5]"
136
- : "aspect-[4/3]"
148
+ : "aspect-[4/3]",
137
149
  )}
138
150
  >
139
151
  {showPoster ? (
@@ -154,9 +166,20 @@ export const EventCard = memo(function EventCard({
154
166
  {/* Top overlay row — hype badge left, entry fee right. The empty span
155
167
  keeps the fee pill right-aligned when there is no badge. */}
156
168
  <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 />}
169
+ {badge ? (
170
+ <span className={cn(PILL_BASE, "bg-black/55 text-white")}>
171
+ {badge}
172
+ </span>
173
+ ) : (
174
+ <span />
175
+ )}
158
176
  {fee.kind === "free" && (
159
- <span className={cn(PILL_BASE, "bg-success font-bold text-success-foreground")}>
177
+ <span
178
+ className={cn(
179
+ PILL_BASE,
180
+ "bg-success font-bold text-success-foreground",
181
+ )}
182
+ >
160
183
  <Gift className="h-3 w-3" aria-hidden />
161
184
  {labels.free}
162
185
  </span>
@@ -165,7 +188,9 @@ export const EventCard = memo(function EventCard({
165
188
 
166
189
  {tagLabel && (
167
190
  <div className="absolute inset-x-2 bottom-2 flex gap-1">
168
- <span className={cn(PILL_BASE, "bg-black/55 text-white")}>{tagLabel}</span>
191
+ <span className={cn(PILL_BASE, "bg-black/55 text-white")}>
192
+ {tagLabel}
193
+ </span>
169
194
  </div>
170
195
  )}
171
196
  </div>
@@ -180,7 +205,10 @@ export const EventCard = memo(function EventCard({
180
205
  <h3
181
206
  className={cn(
182
207
  "mt-2.5 leading-[1.35]",
183
- emphasis ? "text-lg font-bold tracking-[-0.02em]" : "text-sm font-semibold"
208
+ emphasis
209
+ ? "text-lg font-bold tracking-[-0.02em]"
210
+ : "text-sm font-semibold",
211
+ isEnded && "text-muted-foreground",
184
212
  )}
185
213
  >
186
214
  {event.title}
@@ -232,6 +260,14 @@ export const EventCard = memo(function EventCard({
232
260
  {liveStatus}
233
261
  </div>
234
262
  )}
263
+
264
+ {showEnded && (
265
+ <div className="mt-1.5">
266
+ <Tag size="sm" variant="default" className="font-extrabold">
267
+ {endedLabel}
268
+ </Tag>
269
+ </div>
270
+ )}
235
271
  </>
236
272
  );
237
273
 
@@ -239,7 +275,7 @@ export const EventCard = memo(function EventCard({
239
275
  "flex min-w-0 flex-col text-left",
240
276
  (href || onClick) &&
241
277
  "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
242
- className
278
+ className,
243
279
  );
244
280
 
245
281
  if (href) {
@@ -247,7 +283,9 @@ export const EventCard = memo(function EventCard({
247
283
  <LinkComponent
248
284
  href={href}
249
285
  className={rootClasses}
250
- onClick={onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined}
286
+ onClick={
287
+ onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined
288
+ }
251
289
  >
252
290
  {body}
253
291
  </LinkComponent>
@@ -256,7 +294,11 @@ export const EventCard = memo(function EventCard({
256
294
 
257
295
  if (onClick) {
258
296
  return (
259
- <button type="button" className={rootClasses} onClick={(e) => onClick(event, e)}>
297
+ <button
298
+ type="button"
299
+ className={rootClasses}
300
+ onClick={(e) => onClick(event, e)}
301
+ >
260
302
  {body}
261
303
  </button>
262
304
  );
@@ -289,14 +331,17 @@ export function EventCardSkeleton({
289
331
  className,
290
332
  }: EventCardSkeletonProps) {
291
333
  return (
292
- <div aria-hidden="true" className={cn("flex min-w-0 flex-col text-left", className)}>
334
+ <div
335
+ aria-hidden="true"
336
+ className={cn("flex min-w-0 flex-col text-left", className)}
337
+ >
293
338
  <Skeleton
294
339
  className={cn(
295
340
  posterAspect === "3/4"
296
341
  ? "aspect-[3/4]"
297
342
  : posterAspect === "4/5"
298
343
  ? "aspect-[4/5]"
299
- : "aspect-[4/3]"
344
+ : "aspect-[4/3]",
300
345
  )}
301
346
  />
302
347
 
@@ -53,6 +53,36 @@ export const FreeEntry: Story = {
53
53
  args: { event: event("2", "Vinyl Happy Hour", "Zorki Photo Cafe", "2026-09-04T18:00:00", 0) },
54
54
  };
55
55
 
56
+ /** The timeline row: fee on the meta line, tags below. */
57
+ export const TimelineRow: Story = {
58
+ args: {
59
+ event: event("t1", "Klausen Quiz by GOtrivia", "Klausen Pubhouse", "2026-09-07T20:00:00", 20),
60
+ showPrice: true,
61
+ tags: ["Drinks", "Games"],
62
+ },
63
+ };
64
+
65
+ /** Running now: the on-air chip joins the meta line. */
66
+ export const Live: Story = {
67
+ args: {
68
+ event: event("l1", "International Quiz at Yolka", "Yolka", "2026-09-07T19:00:00", 0),
69
+ isLive: true,
70
+ showPrice: true,
71
+ tags: ["Drinks", "Games"],
72
+ },
73
+ };
74
+
75
+ /** Over: poster dimmed, title muted, an explicit chip. Never row-level opacity. */
76
+ export const Ended: Story = {
77
+ args: {
78
+ event: event("e1", "Hat Design Workshop with Matira Hats", "Del Mars", "2026-09-07T17:00:00", 795),
79
+ isEnded: true,
80
+ endedLabel: "Ended",
81
+ showPrice: true,
82
+ tags: ["Workshop"],
83
+ },
84
+ };
85
+
56
86
  /**
57
87
  * No poster — the gradient fallback, which this row did not have until the
58
88
  * thumbnail grew. A blank `bg-muted` box passed for "no image" at 34px; at
@@ -49,3 +49,52 @@ describe("EventCompactRow sub line", () => {
49
49
  expect(container.textContent).not.toContain("·");
50
50
  });
51
51
  });
52
+
53
+ describe("EventCompactRow state and price", () => {
54
+ it("adds the live chip to the meta line when isLive", () => {
55
+ render(<EventCompactRow event={event} labels={labels} formatters={formatters} isLive />);
56
+ expect(screen.getByText("Live now")).toBeInTheDocument();
57
+ });
58
+
59
+ it("renders the ended chip only when both isEnded and endedLabel are given", () => {
60
+ const { rerender } = render(
61
+ <EventCompactRow event={event} labels={labels} formatters={formatters} isEnded />
62
+ );
63
+ expect(screen.queryByText("Ended")).not.toBeInTheDocument();
64
+
65
+ rerender(
66
+ <EventCompactRow event={event} labels={labels} formatters={formatters} isEnded endedLabel="Ended" />
67
+ );
68
+ expect(screen.getByText("Ended")).toBeInTheDocument();
69
+ });
70
+
71
+ it("prints a paid fee on the meta line with showPrice, and nothing without it", () => {
72
+ const paid = { ...event, entry_fee: 20 };
73
+ const { container, rerender } = render(
74
+ <EventCompactRow event={paid} labels={labels} formatters={formatters} />
75
+ );
76
+ expect(container.textContent).not.toContain("20 RON");
77
+
78
+ rerender(<EventCompactRow event={paid} labels={labels} formatters={formatters} showPrice />);
79
+ expect(screen.getByText("20 RON")).toBeInTheDocument();
80
+ });
81
+
82
+ it("prints the free label once — on the meta line with showPrice, as the trailing pill without", () => {
83
+ const free = { ...event, entry_fee: 0 };
84
+ const { rerender } = render(
85
+ <EventCompactRow event={free} labels={labels} formatters={formatters} />
86
+ );
87
+ expect(screen.getAllByText("Free")).toHaveLength(1);
88
+
89
+ rerender(<EventCompactRow event={free} labels={labels} formatters={formatters} showPrice />);
90
+ expect(screen.getAllByText("Free")).toHaveLength(1);
91
+ });
92
+
93
+ it("prints nothing for an unknown fee even with showPrice", () => {
94
+ const { container } = render(
95
+ <EventCompactRow event={event} labels={labels} formatters={formatters} showPrice />
96
+ );
97
+ expect(container.textContent).not.toContain("RON");
98
+ expect(container.textContent).not.toContain("Free");
99
+ });
100
+ });