@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
@@ -12,6 +12,29 @@ const cardVariants = cva(
12
12
  glass: "glass",
13
13
  outline: "bg-transparent border border-border",
14
14
  muted: "bg-muted border border-transparent",
15
+ // `surface` PLUS elevation. The border is NOT replaced, and that is the
16
+ // whole design of these two.
17
+ //
18
+ // In light mode `--card` is pure white on a 0.986 ground — 1.04:1, a
19
+ // five-in-255 step — so a plain surface is defined by its 1.32:1
20
+ // hairline alone. That is proportionate under a pill and invisible under
21
+ // a panel, which is what "this looks flat" means here: there is no z.
22
+ //
23
+ // But dropping the border to go shadow-only breaks both themes. Every
24
+ // elevation token in this system is downward-offset, so a white
25
+ // shadow-only box has no defined TOP edge on a near-white page and
26
+ // reads as half-drawn. And on the 0.155 dark canvas the shadow is
27
+ // nearly invisible, so `--border` is the only thing left describing the
28
+ // box. The border IS the dark-mode fallback, which is why neither
29
+ // variant needs a `dark:` branch.
30
+ elevated: "bg-card border border-border shadow-[var(--shadow-card)]",
31
+ // The same recipe at panel scale — see `--shadow-panel` for why a
32
+ // bigger box does not simply take the card shadow. Resting elevation
33
+ // only: deliberately absent from `compoundVariants` below, because a
34
+ // several-hundred-pixel slab that translates under the cursor is a
35
+ // wobble rather than a lift. A panel that size delegates interaction to
36
+ // its children.
37
+ elevatedPanel: "bg-card border border-border shadow-[var(--shadow-panel)]",
15
38
  },
16
39
  size: {
17
40
  sm: "[&_[data-slot=card-header]]:p-4 [&_[data-slot=card-content]]:p-4 [&_[data-slot=card-content]]:pt-0 [&_[data-slot=card-footer]]:p-4 [&_[data-slot=card-footer]]:pt-0",
@@ -21,11 +44,31 @@ const cardVariants = cva(
21
44
  },
22
45
  // Interactive variant follows v1.0 `press-response` archetype:
23
46
  // hover lift via shadow + translate (200ms), press scale-down (instant).
47
+ //
48
+ // The hover SHADOW is not here — it lives in `compoundVariants` below, so
49
+ // it can be expressed relative to the resting elevation. `box-shadow` is
50
+ // a single property, so a flat `hover:shadow-md` REPLACES a resting
51
+ // shadow rather than stepping up from it: an `elevated` card would
52
+ // translate upward while its shadow got tighter, which is physically
53
+ // backwards and reads as a flicker.
24
54
  interactive: {
25
- true: "cursor-pointer transition-[transform,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-standard)] hover:border-primary/40 hover:-translate-y-0.5 hover:shadow-md active:scale-[0.97] active:duration-[var(--duration-instant)] motion-reduce:hover:translate-y-0 motion-reduce:active:scale-100",
55
+ true: "cursor-pointer transition-[transform,border-color,box-shadow] duration-[var(--duration-quick)] ease-[var(--ease-standard)] hover:border-primary/40 hover:-translate-y-0.5 active:scale-[0.97] active:duration-[var(--duration-instant)] motion-reduce:hover:translate-y-0 motion-reduce:active:scale-100",
26
56
  false: "",
27
57
  },
28
58
  },
59
+ // The first four reproduce the previous `hover:shadow-md` exactly; only
60
+ // `elevated` is new. `elevatedPanel` is absent on purpose — see its note.
61
+ compoundVariants: [
62
+ { variant: "surface", interactive: true, class: "hover:shadow-md" },
63
+ { variant: "glass", interactive: true, class: "hover:shadow-md" },
64
+ { variant: "outline", interactive: true, class: "hover:shadow-md" },
65
+ { variant: "muted", interactive: true, class: "hover:shadow-md" },
66
+ {
67
+ variant: "elevated",
68
+ interactive: true,
69
+ class: "hover:shadow-[var(--shadow-card-hover)]",
70
+ },
71
+ ],
29
72
  defaultVariants: {
30
73
  variant: "surface",
31
74
  size: "default",
@@ -0,0 +1,96 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { CategoryDeck, CategoryDeckSkeleton } from "./category-deck";
3
+
4
+ function poster(color: string, label: string) {
5
+ return {
6
+ src:
7
+ "data:image/svg+xml;utf8," +
8
+ encodeURIComponent(
9
+ `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="320"><rect width="240" height="320" fill="${color}"/><text x="50%" y="50%" font-family="monospace" font-size="14" fill="#fff" text-anchor="middle">${label}</text></svg>`
10
+ ),
11
+ };
12
+ }
13
+
14
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
15
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
16
+ // name portably (TS2742).
17
+ const meta: Meta<typeof CategoryDeck> = {
18
+ title: "Events/CategoryDeck",
19
+ component: CategoryDeck,
20
+ tags: ["autodocs"],
21
+ args: {
22
+ name: "Petreceri",
23
+ meta: "32 events · /petreceri",
24
+ href: "/petreceri",
25
+ posters: [
26
+ poster("#7c5a3a", "1"),
27
+ poster("#5a4a3a", "2"),
28
+ poster("#8a6a4a", "3"),
29
+ poster("#3a2a1a", "4"),
30
+ ],
31
+ },
32
+ decorators: [
33
+ (Story) => (
34
+ <div className="w-[220px] p-8">
35
+ <Story />
36
+ </div>
37
+ ),
38
+ ],
39
+ parameters: {
40
+ docs: {
41
+ description: {
42
+ component:
43
+ "The hub's category doorway — one link, four decorative posters fanned back to front. The fan opens on hover AND keyboard focus (`group-focus-visible`), never on hover alone.",
44
+ },
45
+ },
46
+ },
47
+ };
48
+
49
+ export default meta;
50
+ type Story = StoryObj<typeof CategoryDeck>;
51
+
52
+ export const Default: Story = {};
53
+
54
+ /** Fewer than four posters supplied — the remaining slots render as placeholders. */
55
+ export const PartialPosters: Story = {
56
+ args: {
57
+ posters: [poster("#7c5a3a", "1"), poster("#5a4a3a", "2")],
58
+ },
59
+ };
60
+
61
+ /** No posters at all — every slot is a placeholder block. */
62
+ export const NoPosters: Story = {
63
+ args: { posters: [] },
64
+ };
65
+
66
+ /**
67
+ * `content` replaces the `<img>` entirely — for callers whose cards are not
68
+ * photographs. The menu hub fans dish categories this way, drawing a tinted
69
+ * colour field per card instead of a photo it is not allowed to show.
70
+ */
71
+ export const CustomContent: Story = {
72
+ args: {
73
+ name: "Pizza",
74
+ meta: "214 dishes · from 18 lei",
75
+ posters: ["#8d5524", "#a3663a", "#6f4520", "#b07d4e"].map((background, index) => ({
76
+ content: (
77
+ <div
78
+ className="flex h-full w-full items-end p-2 text-[10px] font-bold text-white/80"
79
+ style={{ background }}
80
+ >
81
+ Pizza {index + 1}
82
+ </div>
83
+ ),
84
+ })),
85
+ },
86
+ };
87
+
88
+ /** Rendered as a `<button>` when only `onClick` is given. */
89
+ export const AsButton: Story = {
90
+ args: { href: undefined, onClick: () => alert("Category selected") },
91
+ };
92
+
93
+ /** The fan at rest — never spread, since the loading state has nothing to hover. */
94
+ export const Skeleton: Story = {
95
+ render: () => <CategoryDeckSkeleton />,
96
+ };
@@ -0,0 +1,227 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronRight } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Skeleton } from "./skeleton";
7
+
8
+ /**
9
+ * CategoryDeck — the hub's category doorway: a fanned stack of four posters
10
+ * that spreads open on hover or focus.
11
+ *
12
+ * The whole deck is ONE link (or button, or static div — same href/onClick
13
+ * contract as `EventCard`). The four posters are decorative dressing for that
14
+ * single destination, not four separate targets, so they're `aria-hidden` and
15
+ * the link's accessible name comes from the visible `name`/`meta` text next
16
+ * to it.
17
+ *
18
+ * The fan must open for keyboard users too, not just mouse hover — the spread
19
+ * transform is driven by `group-hover` AND `group-focus-visible` on the same
20
+ * link. Every poster's `transform` is set as a single arbitrary CSS property
21
+ * rather than composed Tailwind `translate-x`/`rotate` utilities, because
22
+ * those utilities share one `--tw-translate-x` variable and would silently
23
+ * overwrite each other.
24
+ *
25
+ * Poster offsets are the same at every breakpoint — the design only specifies
26
+ * a smaller card size and deck height for mobile, not a rescaled fan.
27
+ */
28
+
29
+ const DECK_POSTER_COUNT = 4;
30
+
31
+ const DECK_TRANSITION =
32
+ "transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none";
33
+
34
+ const DECK_CARD_TRANSFORMS: ReadonlyArray<{ rest: string; spread: string; z: string }> = [
35
+ {
36
+ rest: "[transform:translateX(-50%)_rotate(-9deg)_translateX(-26px)]",
37
+ spread:
38
+ "group-hover:[transform:translateX(-50%)_rotate(-14deg)_translateX(-64px)_translateY(-6px)] group-focus-visible:[transform:translateX(-50%)_rotate(-14deg)_translateX(-64px)_translateY(-6px)]",
39
+ z: "z-0",
40
+ },
41
+ {
42
+ rest: "[transform:translateX(-50%)_rotate(-3deg)_translateX(-9px)]",
43
+ spread:
44
+ "group-hover:[transform:translateX(-50%)_rotate(-5deg)_translateX(-22px)_translateY(-10px)] group-focus-visible:[transform:translateX(-50%)_rotate(-5deg)_translateX(-22px)_translateY(-10px)]",
45
+ z: "z-[1]",
46
+ },
47
+ {
48
+ rest: "[transform:translateX(-50%)_rotate(3deg)_translateX(9px)]",
49
+ spread:
50
+ "group-hover:[transform:translateX(-50%)_rotate(5deg)_translateX(22px)_translateY(-10px)] group-focus-visible:[transform:translateX(-50%)_rotate(5deg)_translateX(22px)_translateY(-10px)]",
51
+ z: "z-[1]",
52
+ },
53
+ {
54
+ rest: "[transform:translateX(-50%)_rotate(9deg)_translateX(26px)]",
55
+ spread:
56
+ "group-hover:[transform:translateX(-50%)_rotate(14deg)_translateX(64px)_translateY(-6px)] group-focus-visible:[transform:translateX(-50%)_rotate(14deg)_translateX(64px)_translateY(-6px)]",
57
+ z: "z-[2]",
58
+ },
59
+ ];
60
+
61
+ export type CategoryDeckPoster = {
62
+ src?: string;
63
+ alt?: string;
64
+ /**
65
+ * Rendered in the card instead of an `<img>`. Wins over `src`.
66
+ *
67
+ * The deck was built for event posters, where every card is a photograph.
68
+ * The menu hub fans the same deck over dish categories, and that app renders
69
+ * no menu-item photography on card surfaces — it draws a tinted colour field
70
+ * carrying the item's name. Without a slot, those decks fanned four empty
71
+ * `bg-muted` blocks.
72
+ *
73
+ * Same escape hatch, and the same name-and-precedence rule, as
74
+ * `FeaturedRailItemProps.fallback` and `PosterTapeItem.fallback` — except
75
+ * those two are fallbacks shown when a poster is ABSENT, while this one is a
76
+ * deliberate replacement. Hence `content`, not `fallback`: a caller passing
77
+ * it is choosing what the card is, not what it degrades to.
78
+ */
79
+ content?: React.ReactNode;
80
+ };
81
+
82
+ export type CategoryDeckLinkProps = {
83
+ href: string;
84
+ className?: string;
85
+ children: React.ReactNode;
86
+ onClick?: (mouseEvent: React.MouseEvent) => void;
87
+ };
88
+
89
+ function DefaultLink({ href, ...props }: CategoryDeckLinkProps) {
90
+ return <a href={href} {...props} />;
91
+ }
92
+
93
+ export type CategoryDeckProps = {
94
+ name: string;
95
+ /** Caller-composed, e.g. "32 events · /petreceri". */
96
+ meta: string;
97
+ /** Up to four posters, fanned back to front. A poster with neither `content`
98
+ * nor `src` renders a placeholder block. */
99
+ posters: CategoryDeckPoster[];
100
+ href?: string;
101
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
102
+ linkComponent?: React.ComponentType<CategoryDeckLinkProps>;
103
+ onClick?: (mouseEvent: React.MouseEvent) => void;
104
+ className?: string;
105
+ };
106
+
107
+ export function CategoryDeck({
108
+ name,
109
+ meta,
110
+ posters,
111
+ href,
112
+ linkComponent: LinkComponent = DefaultLink,
113
+ onClick,
114
+ className,
115
+ }: CategoryDeckProps) {
116
+ const slots = Array.from(
117
+ { length: DECK_POSTER_COUNT },
118
+ (_, index): CategoryDeckPoster => posters[index] ?? {}
119
+ );
120
+
121
+ const body = (
122
+ <>
123
+ <div className="relative h-[150px] sm:h-[186px]" aria-hidden>
124
+
125
+ {slots.map((poster, index) => {
126
+ const transform = DECK_CARD_TRANSFORMS[index];
127
+ return (
128
+ <div
129
+ key={index}
130
+ className={cn(
131
+ "absolute left-1/2 top-0 aspect-[4/5] w-24 overflow-hidden rounded-[10px] bg-muted",
132
+ "shadow-[0_10px_26px_color-mix(in_oklch,var(--foreground)_18%,transparent)]",
133
+ "sm:w-[120px]",
134
+ transform.rest,
135
+ transform.spread,
136
+ transform.z,
137
+ DECK_TRANSITION
138
+ )}
139
+ >
140
+ {poster.content ? (
141
+ poster.content
142
+ ) : poster.src ? (
143
+ // eslint-disable-next-line @next/next/no-img-element
144
+ <img
145
+ src={poster.src}
146
+ alt={poster.alt ?? ""}
147
+ loading="lazy"
148
+ decoding="async"
149
+ className="h-full w-full object-cover"
150
+ />
151
+ ) : (
152
+ <div className="h-full w-full bg-muted" />
153
+ )}
154
+ </div>
155
+ );
156
+ })}
157
+ </div>
158
+
159
+ <div className="flex items-center gap-[9px] pt-3.5">
160
+ <div className="min-w-0">
161
+ <div className="truncate text-[14.5px] font-extrabold tracking-[-0.015em] sm:text-base">
162
+ {name}
163
+ </div>
164
+ <div className="truncate font-mono text-[11.5px] text-muted-foreground">{meta}</div>
165
+ </div>
166
+ <ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
167
+ </div>
168
+ </>
169
+ );
170
+
171
+ const rootClasses = cn(
172
+ "group flex min-w-0 flex-col text-left",
173
+ (href || onClick) &&
174
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
175
+ className
176
+ );
177
+
178
+ if (href) {
179
+ return (
180
+ <LinkComponent href={href} className={rootClasses} onClick={onClick}>
181
+ {body}
182
+ </LinkComponent>
183
+ );
184
+ }
185
+
186
+ if (onClick) {
187
+ return (
188
+ <button type="button" className={rootClasses} onClick={onClick}>
189
+ {body}
190
+ </button>
191
+ );
192
+ }
193
+
194
+ return <div className={rootClasses}>{body}</div>;
195
+ }
196
+
197
+ export type CategoryDeckSkeletonProps = {
198
+ className?: string;
199
+ };
200
+
201
+ /** The deck's resting layout — the fan sits at its rest transform, never spread. */
202
+ export function CategoryDeckSkeleton({ className }: CategoryDeckSkeletonProps) {
203
+ return (
204
+ <div aria-hidden="true" className={cn("flex min-w-0 flex-col", className)}>
205
+ <div className="relative h-[150px] sm:h-[186px]">
206
+ {DECK_CARD_TRANSFORMS.map((transform, index) => (
207
+ <Skeleton
208
+ key={index}
209
+ className={cn(
210
+ "absolute left-1/2 top-0 aspect-[4/5] w-24 rounded-[10px] sm:w-[120px]",
211
+ transform.rest,
212
+ transform.z
213
+ )}
214
+ />
215
+ ))}
216
+ </div>
217
+
218
+ <div className="flex items-center gap-[9px] pt-3.5">
219
+ <div className="min-w-0 flex-1">
220
+ <Skeleton className="h-[14.5px] w-24 sm:h-4 sm:w-28" />
221
+ <Skeleton className="mt-1.5 h-[11.5px] w-32" />
222
+ </div>
223
+ <Skeleton className="ml-auto h-4 w-4 shrink-0 rounded-full" />
224
+ </div>
225
+ </div>
226
+ );
227
+ }
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { PartyPopper, Music } from "lucide-react";
3
+ import { ConceptCard, ConceptCardSkeleton } from "./concept-card";
4
+
5
+ const kind = (
6
+ <>
7
+ <PartyPopper className="h-3 w-3" aria-hidden />
8
+ Party
9
+ </>
10
+ );
11
+
12
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
13
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
14
+ // name portably (TS2742).
15
+ const meta: Meta<typeof ConceptCard> = {
16
+ title: "Events/ConceptCard",
17
+ component: ConceptCard,
18
+ tags: ["autodocs"],
19
+ args: {
20
+ title: "Flying Circus",
21
+ meta: "Every Saturday · Flying Circus · 4 dates",
22
+ kind,
23
+ },
24
+ decorators: [
25
+ (Story) => (
26
+ <div className="w-[320px]">
27
+ <Story />
28
+ </div>
29
+ ),
30
+ ],
31
+ parameters: {
32
+ docs: {
33
+ description: {
34
+ component:
35
+ "`meta` is composed by the caller — the design's variants (\"Every Saturday · Flying Circus · 4 dates\") don't decompose into parts without hardcoding clause order.",
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof ConceptCard>;
43
+
44
+ export const Default: Story = {};
45
+
46
+ /** Absent `coverUrl` falls back to a flat muted block, same as `EventCard`. */
47
+ export const NoCover: Story = {
48
+ args: { coverUrl: undefined },
49
+ };
50
+
51
+ export const WithCover: Story = {
52
+ args: {
53
+ coverUrl:
54
+ "data:image/svg+xml;utf8," +
55
+ encodeURIComponent(
56
+ `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="320"><rect width="640" height="320" fill="#3a2c46"/><text x="50%" y="50%" font-family="monospace" font-size="18" fill="#fff" text-anchor="middle">cover</text></svg>`
57
+ ),
58
+ },
59
+ };
60
+
61
+ export const LiveMusicKind: Story = {
62
+ args: {
63
+ title: "Jazz at the Basement",
64
+ meta: "Every Thursday · The Basement · 12 dates",
65
+ kind: (
66
+ <>
67
+ <Music className="h-3 w-3" aria-hidden />
68
+ Live music
69
+ </>
70
+ ),
71
+ },
72
+ };
73
+
74
+ export const Clickable: Story = {
75
+ args: { href: "/events/flying-circus" },
76
+ };
77
+
78
+ export const Skeleton: Story = {
79
+ render: () => <ConceptCardSkeleton />,
80
+ };
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { memo, useState } from "react";
4
+ import { Calendar } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Skeleton } from "./skeleton";
7
+
8
+ /**
9
+ * ConceptCard — the recurring-series cover card (e.g. a weekly party
10
+ * concept), a wide 2:1 image with a bottom scrim carrying a kind pill,
11
+ * a composed date/venue/count line and the title.
12
+ *
13
+ * Same contract as `EventCard`: DS-owned props, plain `<img>`, anchor
14
+ * whenever `href` is given. Absent `coverUrl` falls back to a flat
15
+ * `bg-muted` block, same treatment `EventCard` gives a missing poster.
16
+ */
17
+
18
+ export type ConceptCardLinkProps = {
19
+ href: string;
20
+ className?: string;
21
+ children: React.ReactNode;
22
+ onClick?: (mouseEvent: React.MouseEvent) => void;
23
+ };
24
+
25
+ function DefaultLink({ href, ...props }: ConceptCardLinkProps) {
26
+ return <a href={href} {...props} />;
27
+ }
28
+
29
+ export type ConceptCardProps = {
30
+ title: string;
31
+ /**
32
+ * The whole date/venue/count line, composed by the caller, e.g. "Every
33
+ * Saturday · Flying Circus · 4 dates" — clause order is locale-dependent.
34
+ */
35
+ meta: string;
36
+ /** Label plus its icon, e.g. `<><PartyPopper .../> Party</>`. */
37
+ kind: React.ReactNode;
38
+ coverUrl?: string;
39
+ href?: string;
40
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
41
+ linkComponent?: React.ComponentType<ConceptCardLinkProps>;
42
+ onClick?: (mouseEvent: React.MouseEvent) => void;
43
+ className?: string;
44
+ /** Forwarded to the cover's `loading` attribute (`"eager"` when true). */
45
+ priority?: boolean;
46
+ };
47
+
48
+ export const ConceptCard = memo(function ConceptCard({
49
+ title,
50
+ meta,
51
+ kind,
52
+ coverUrl,
53
+ href,
54
+ linkComponent: LinkComponent = DefaultLink,
55
+ onClick,
56
+ className,
57
+ priority,
58
+ }: ConceptCardProps) {
59
+ const [imgError, setImgError] = useState(false);
60
+ const showCover = !!coverUrl && !imgError;
61
+
62
+ const body = (
63
+ <>
64
+ <div className="absolute inset-0 bg-muted">
65
+ {showCover && (
66
+ // eslint-disable-next-line @next/next/no-img-element
67
+ <img
68
+ src={coverUrl}
69
+ alt={title}
70
+ title={title}
71
+ loading={priority ? "eager" : "lazy"}
72
+ decoding="async"
73
+ className="h-full w-full object-cover"
74
+ onError={() => setImgError(true)}
75
+ />
76
+ )}
77
+ </div>
78
+
79
+ <div className="absolute inset-x-0 bottom-0 z-[1] h-[60%] bg-gradient-to-t from-black/90 via-black/60 to-transparent" />
80
+
81
+ <span className="absolute left-2 top-2 z-[2] inline-flex items-center gap-1 rounded-md bg-black/50 px-2 py-1 text-[11px] font-semibold text-white">
82
+ {kind}
83
+ </span>
84
+
85
+ <div className="relative z-[2] mt-auto flex w-full flex-col gap-0.5 p-2.5 text-white">
86
+ <div className="flex items-center gap-1 text-[11px] font-medium text-white/85">
87
+ <Calendar className="h-3 w-3 shrink-0" aria-hidden />
88
+ <span className="truncate">{meta}</span>
89
+ </div>
90
+ <div className="line-clamp-1 text-[14px] font-semibold leading-[1.25]">{title}</div>
91
+ </div>
92
+ </>
93
+ );
94
+
95
+ const rootClasses = cn(
96
+ "relative flex aspect-[2/1] flex-col overflow-hidden rounded-xl text-left",
97
+ (href || onClick) &&
98
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
99
+ className
100
+ );
101
+
102
+ if (href) {
103
+ return (
104
+ <LinkComponent href={href} className={rootClasses} onClick={onClick}>
105
+ {body}
106
+ </LinkComponent>
107
+ );
108
+ }
109
+
110
+ if (onClick) {
111
+ return (
112
+ <button type="button" className={rootClasses} onClick={onClick}>
113
+ {body}
114
+ </button>
115
+ );
116
+ }
117
+
118
+ return <div className={rootClasses}>{body}</div>;
119
+ });
120
+
121
+ export type ConceptCardSkeletonProps = {
122
+ className?: string;
123
+ };
124
+
125
+ export function ConceptCardSkeleton({ className }: ConceptCardSkeletonProps) {
126
+ return (
127
+ <div
128
+ aria-hidden="true"
129
+ className={cn("relative flex aspect-[2/1] flex-col overflow-hidden rounded-xl", className)}
130
+ >
131
+ <Skeleton className="absolute inset-0 rounded-none" />
132
+
133
+ <div className="absolute inset-x-0 bottom-0 z-[1] h-[60%] bg-gradient-to-t from-black/90 via-black/60 to-transparent" />
134
+
135
+ <Skeleton className="absolute left-2 top-2 z-[2] h-[26px] w-16 rounded-md bg-white/20" />
136
+
137
+ <div className="relative z-[2] mt-auto flex w-full flex-col gap-1 p-2.5">
138
+ <Skeleton className="h-[11px] w-24 bg-white/15" />
139
+ <Skeleton className="h-[14px] w-2/3 bg-white/20" />
140
+ </div>
141
+ </div>
142
+ );
143
+ }