@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.
- package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-AXV5O2IZ.js +58 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-THOVPZNN.js +37 -0
- package/dist/chunk-YS6BHKZO.js +92 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-compare.d.ts +73 -0
- package/dist/components/concept-compare.js +162 -0
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +64 -34
- package/dist/components/event-compact-row.d.ts +33 -6
- package/dist/components/event-compact-row.js +120 -39
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +471 -174
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +79 -35
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +20 -0
- package/dist/components/menu-family-card.d.ts +59 -0
- package/dist/components/menu-family-card.js +121 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +3 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- package/dist/components/menu-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- package/dist/components/menu-mocks.d.ts +55 -0
- package/dist/components/menu-mocks.js +379 -0
- package/dist/components/menu-types.d.ts +245 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-set.js +15 -3
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/ranked-venue-list.d.ts +49 -0
- package/dist/components/ranked-venue-list.js +87 -0
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/signature-line-list.d.ts +45 -0
- package/dist/components/signature-line-list.js +99 -0
- package/dist/components/snap-rail.d.ts +54 -0
- package/dist/components/snap-rail.js +109 -0
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/venue-busyness-strip.d.ts +35 -0
- package/dist/components/venue-busyness-strip.js +37 -0
- package/dist/components/venue-grade-dimensions.d.ts +36 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-signature-block.d.ts +39 -0
- package/dist/components/venue-signature-block.js +6 -0
- package/dist/components/venue-spotlight.d.ts +74 -0
- package/dist/components/venue-spotlight.js +138 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare.stories.tsx +104 -0
- package/src/components/concept-compare.tsx +276 -0
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +162 -38
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +40 -0
- package/src/components/menu-family-card.stories.tsx +112 -0
- package/src/components/menu-family-card.tsx +211 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- package/src/components/menu-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -0
- package/src/components/menu-mocks.ts +417 -0
- package/src/components/menu-types.ts +259 -0
- package/src/components/motion-icon-set.tsx +30 -3
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/ranked-venue-list.stories.tsx +80 -0
- package/src/components/ranked-venue-list.tsx +177 -0
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/signature-line-list.stories.tsx +85 -0
- package/src/components/signature-line-list.tsx +136 -0
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +148 -0
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/venue-busyness-strip.stories.tsx +66 -0
- package/src/components/venue-busyness-strip.tsx +64 -0
- package/src/components/venue-grade-dimensions.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-signature-block.stories.tsx +68 -0
- package/src/components/venue-signature-block.tsx +71 -0
- package/src/components/venue-spotlight.stories.tsx +141 -0
- package/src/components/venue-spotlight.tsx +236 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- package/dist/chunk-GPH6L4DX.js +0 -55
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import type { MenuLinkProps, MenuLinkComponent } from "./menu-types";
|
|
3
|
+
import type { PlacePreview } from "./place-types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* VenueWall — the catalogue's scale, stated as a row of the venues in it.
|
|
7
|
+
*
|
|
8
|
+
* A closing beat rather than a browse surface: the avatars are named and
|
|
9
|
+
* linked, but the module's job is to make "we read every menu in the city"
|
|
10
|
+
* land as something specific. It shows a handful and counts the rest, because
|
|
11
|
+
* a wall long enough to be exhaustive stops reading as places.
|
|
12
|
+
*
|
|
13
|
+
* Server-safe.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
17
|
+
export type VenueWallLinkProps = MenuLinkProps;
|
|
18
|
+
|
|
19
|
+
function DefaultLink({ href, ...props }: VenueWallLinkProps) {
|
|
20
|
+
return <a href={href} {...props} />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type VenueWallProps = {
|
|
24
|
+
places: PlacePreview[];
|
|
25
|
+
/** Destination per place, keyed by slug. Without one an avatar is inert. */
|
|
26
|
+
hrefs?: Record<string, string>;
|
|
27
|
+
kicker?: string;
|
|
28
|
+
kickerIcon?: React.ReactNode;
|
|
29
|
+
headline?: string;
|
|
30
|
+
body?: string;
|
|
31
|
+
/** Composed overflow chip, e.g. "+393 more". */
|
|
32
|
+
overflowLabel?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Tiles for the mosaic ground — the same licensed stills the hero uses.
|
|
35
|
+
* Without them the wall falls back to the flat diagonal texture, which is
|
|
36
|
+
* the degraded state rather than the design.
|
|
37
|
+
*/
|
|
38
|
+
tileUrls?: string[];
|
|
39
|
+
/** The action — pass a rendered link or button. */
|
|
40
|
+
action?: React.ReactNode;
|
|
41
|
+
className?: string;
|
|
42
|
+
linkComponent?: MenuLinkComponent;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function VenueWall({
|
|
46
|
+
places,
|
|
47
|
+
hrefs,
|
|
48
|
+
kicker,
|
|
49
|
+
kickerIcon,
|
|
50
|
+
headline,
|
|
51
|
+
body,
|
|
52
|
+
overflowLabel,
|
|
53
|
+
tileUrls,
|
|
54
|
+
action,
|
|
55
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
56
|
+
className,
|
|
57
|
+
}: VenueWallProps) {
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className={cn(
|
|
61
|
+
"relative flex flex-col gap-4 overflow-hidden rounded-[20px] bg-[oklch(0.21_0.012_55)] p-5 text-white lg:p-8",
|
|
62
|
+
className
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{tileUrls?.length ? (
|
|
66
|
+
<>
|
|
67
|
+
<div aria-hidden className="absolute inset-0 grid auto-rows-fr grid-cols-4 gap-0.5 lg:grid-cols-6">
|
|
68
|
+
{Array.from({ length: 18 }, (_, cell) => (
|
|
69
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
70
|
+
<img
|
|
71
|
+
key={cell}
|
|
72
|
+
src={tileUrls[cell % tileUrls.length]}
|
|
73
|
+
alt=""
|
|
74
|
+
loading="lazy"
|
|
75
|
+
decoding="async"
|
|
76
|
+
className="h-full w-full object-cover"
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
{/* Heavier than the hero's: this block's copy sits over the full
|
|
81
|
+
width rather than in a left column. */}
|
|
82
|
+
<div
|
|
83
|
+
aria-hidden
|
|
84
|
+
className="absolute inset-0 bg-[oklch(0.16_0.012_55_/_0.9)]"
|
|
85
|
+
/>
|
|
86
|
+
</>
|
|
87
|
+
) : (
|
|
88
|
+
<div
|
|
89
|
+
aria-hidden
|
|
90
|
+
className="absolute inset-0 bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.18]"
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
<div className="relative z-[1] flex flex-col gap-3">
|
|
95
|
+
{kicker && (
|
|
96
|
+
<span className="inline-flex w-max items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-[11px] font-extrabold tracking-[0.11em]">
|
|
97
|
+
{kickerIcon}
|
|
98
|
+
{kicker}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{headline && (
|
|
103
|
+
<h3 className="max-w-[640px] font-serif text-[28px] font-semibold leading-[1.1] tracking-[-0.025em] lg:text-[40px]">
|
|
104
|
+
{headline}
|
|
105
|
+
</h3>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{body && <p className="hidden max-w-[540px] text-sm leading-[1.55] opacity-85 lg:block">{body}</p>}
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Below `lg` the crowd is a stack: the first few faces overlapping, then
|
|
112
|
+
the count. Eight named chips wrapped over four lines were most of the
|
|
113
|
+
banner on a phone and said nothing the count does not. */}
|
|
114
|
+
<div aria-hidden className="relative z-[1] flex items-center gap-3 lg:hidden">
|
|
115
|
+
<div className="flex">
|
|
116
|
+
{places.slice(0, 5).map((place, index) => (
|
|
117
|
+
<span
|
|
118
|
+
key={place.slug}
|
|
119
|
+
className={cn(
|
|
120
|
+
"h-9 w-9 shrink-0 overflow-hidden rounded-full bg-white/20 ring-2 ring-[oklch(0.21_0.012_55)]",
|
|
121
|
+
index > 0 && "-ml-2.5"
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{place.thumbnail_url && (
|
|
125
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
126
|
+
<img src={place.thumbnail_url} alt="" className="h-full w-full object-cover" loading="lazy" />
|
|
127
|
+
)}
|
|
128
|
+
</span>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
{overflowLabel && <span className="text-[13px] font-medium opacity-85">{overflowLabel}</span>}
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<ul className="relative z-[1] hidden flex-wrap items-center gap-2 lg:flex">
|
|
135
|
+
{places.map((place) => {
|
|
136
|
+
const avatar = (
|
|
137
|
+
<>
|
|
138
|
+
<span className="h-7 w-7 shrink-0 overflow-hidden rounded-full bg-white/20">
|
|
139
|
+
{place.thumbnail_url && (
|
|
140
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
141
|
+
<img
|
|
142
|
+
src={place.thumbnail_url}
|
|
143
|
+
alt=""
|
|
144
|
+
className="h-full w-full object-cover"
|
|
145
|
+
loading="lazy"
|
|
146
|
+
/>
|
|
147
|
+
)}
|
|
148
|
+
</span>
|
|
149
|
+
<span className="truncate text-[12.5px] font-medium">{place.name}</span>
|
|
150
|
+
</>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const chipClasses =
|
|
154
|
+
"inline-flex max-w-[220px] items-center gap-2 rounded-full bg-white/10 py-1 pl-1 pr-3.5 backdrop-blur-[6px]";
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<li key={place.slug}>
|
|
158
|
+
{hrefs?.[place.slug] ? (
|
|
159
|
+
<LinkComponent
|
|
160
|
+
href={hrefs[place.slug]}
|
|
161
|
+
className={cn(
|
|
162
|
+
chipClasses,
|
|
163
|
+
"transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{avatar}
|
|
167
|
+
</LinkComponent>
|
|
168
|
+
) : (
|
|
169
|
+
<span className={chipClasses}>{avatar}</span>
|
|
170
|
+
)}
|
|
171
|
+
</li>
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
|
|
175
|
+
{overflowLabel && (
|
|
176
|
+
<li className="inline-flex h-9 items-center rounded-full bg-white/10 px-4 text-[12.5px] opacity-80">
|
|
177
|
+
{overflowLabel}
|
|
178
|
+
</li>
|
|
179
|
+
)}
|
|
180
|
+
</ul>
|
|
181
|
+
|
|
182
|
+
{action && <div className="relative z-[1] mt-1">{action}</div>}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Calendar, PanelRightOpen } from "lucide-react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { WeekdayThemeBanner, type WeekdayThemeDay } from "./weekday-theme-banner";
|
|
5
|
+
|
|
6
|
+
const days: WeekdayThemeDay[] = [
|
|
7
|
+
{ code: "MON", name: "Meatless Monday", shortName: "Meatless", count: 84 },
|
|
8
|
+
{ code: "TUE", name: "Taco Tuesday", shortName: "Taco", count: 26 },
|
|
9
|
+
{ code: "WED", name: "Salad Wednesday", shortName: "Salad", count: 132 },
|
|
10
|
+
{ code: "THU", name: "Burger Thursday", shortName: "Burger", count: 186 },
|
|
11
|
+
{ code: "FRI", name: "Pizza Friday", shortName: "Pizza", count: 214 },
|
|
12
|
+
{ code: "SAT", name: "Brunch Saturday", shortName: "Brunch", count: 96 },
|
|
13
|
+
{ code: "SUN", name: "Roast Sunday", shortName: "Roast", count: 41 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const drinkDays: WeekdayThemeDay[] = [
|
|
17
|
+
{ code: "MON", name: "Mulled Monday", shortName: "Mulled", count: 22 },
|
|
18
|
+
{ code: "TUE", name: "Tap Takeover", shortName: "Tap", count: 41 },
|
|
19
|
+
{ code: "WED", name: "Wine Wednesday", shortName: "Wine", count: 52 },
|
|
20
|
+
{ code: "THU", name: "Spritz Thursday", shortName: "Spritz", count: 64 },
|
|
21
|
+
{ code: "FRI", name: "Negroni Friday", shortName: "Negroni", count: 38 },
|
|
22
|
+
{ code: "SAT", name: "Sour Saturday", shortName: "Sour", count: 29 },
|
|
23
|
+
{ code: "SUN", name: "Slow Coffee Sunday", shortName: "Coffee", count: 34 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const meta: Meta<typeof WeekdayThemeBanner> = {
|
|
27
|
+
title: "Menu/WeekdayThemeBanner",
|
|
28
|
+
component: WeekdayThemeBanner,
|
|
29
|
+
tags: ["autodocs"],
|
|
30
|
+
decorators: [
|
|
31
|
+
(Story) => (
|
|
32
|
+
<div className="w-[1100px] bg-background p-4">
|
|
33
|
+
<Story />
|
|
34
|
+
</div>
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
parameters: {
|
|
38
|
+
docs: {
|
|
39
|
+
description: {
|
|
40
|
+
component:
|
|
41
|
+
"The day's editorial theme, with the whole week beside it. Showing all seven is the point: a banner that only says \"it is Burger Thursday\" is an assertion, while one showing Wednesday was salads and Friday is pizza reads as a standing rhythm worth coming back for — and the other six days are the only way to browse to one. `activeDay` is resolved by the CALLER from the reader's clock, because a server in UTC and a reader in Bucharest disagree about the date for two hours a day.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export default meta;
|
|
48
|
+
type Story = StoryObj<typeof WeekdayThemeBanner>;
|
|
49
|
+
|
|
50
|
+
export const Default: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
days,
|
|
53
|
+
activeDay: 3,
|
|
54
|
+
kicker: "TODAY IS THURSDAY",
|
|
55
|
+
kickerIcon: <Calendar className="h-3 w-3" />,
|
|
56
|
+
body: "186 of them across 11 kitchens, from 32 lei. Two do a vegetarian version and four keep the grill on past midnight.",
|
|
57
|
+
weekLabel: "THE WEEK",
|
|
58
|
+
action: (
|
|
59
|
+
<Button size="sm" variant="secondary">
|
|
60
|
+
<PanelRightOpen className="h-3.5 w-3.5" />
|
|
61
|
+
See today's picks
|
|
62
|
+
</Button>
|
|
63
|
+
),
|
|
64
|
+
actionNote: "Bruno picks nine, ranked on review mentions",
|
|
65
|
+
dayHref: (day) => `/menu?theme=${day.code.toLowerCase()}`,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The drinks tab, same component. */
|
|
70
|
+
export const DrinksTab: Story = {
|
|
71
|
+
args: {
|
|
72
|
+
days: drinkDays,
|
|
73
|
+
activeDay: 2,
|
|
74
|
+
kicker: "TODAY IS WEDNESDAY",
|
|
75
|
+
kickerIcon: <Calendar className="h-3 w-3" />,
|
|
76
|
+
body: "Fifty-two by the glass across nineteen bars, from 16 lei.",
|
|
77
|
+
weekLabel: "THE WEEK",
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** `onDayChange` instead of `dayHref` — the rail selects in place. Needs a client caller. */
|
|
82
|
+
export const Selectable: Story = {
|
|
83
|
+
args: { days, activeDay: 4, kicker: "TODAY IS FRIDAY", weekLabel: "THE WEEK", onDayChange: () => {} },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** No counts: themes without a number behind them still read. */
|
|
87
|
+
export const WithoutCounts: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
days: days.map(({ count: _count, ...day }) => day),
|
|
90
|
+
activeDay: 0,
|
|
91
|
+
kicker: "TODAY IS MONDAY",
|
|
92
|
+
weekLabel: "THE WEEK",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const Mobile: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
days,
|
|
99
|
+
activeDay: 3,
|
|
100
|
+
kicker: "TODAY IS THURSDAY",
|
|
101
|
+
kickerIcon: <Calendar className="h-3 w-3" />,
|
|
102
|
+
body: "186 of them across 11 kitchens, from 32 lei.",
|
|
103
|
+
weekLabel: "THE WEEK",
|
|
104
|
+
action: <Button size="sm" variant="secondary">See today's picks</Button>,
|
|
105
|
+
},
|
|
106
|
+
decorators: [
|
|
107
|
+
(Story) => (
|
|
108
|
+
<div className="w-[390px] bg-background p-3">
|
|
109
|
+
<Story />
|
|
110
|
+
</div>
|
|
111
|
+
),
|
|
112
|
+
],
|
|
113
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WeekdayThemeBanner — the day's editorial theme, with the whole week beside it.
|
|
5
|
+
*
|
|
6
|
+
* Showing all seven days is the point: a banner that only says "it is Burger
|
|
7
|
+
* Thursday" is an assertion, while one that shows Wednesday was salads and
|
|
8
|
+
* Friday is pizza reads as a standing rhythm the reader can come back for. The
|
|
9
|
+
* other six days are also the only affordance for browsing to one.
|
|
10
|
+
*
|
|
11
|
+
* ## The active day is the caller's, not the component's
|
|
12
|
+
*
|
|
13
|
+
* `activeDay` is an index into `days`, resolved by the caller from the READER's
|
|
14
|
+
* clock. Resolving it here would use the server's, and a server in UTC and a
|
|
15
|
+
* reader in Bucharest disagree about which day it is for two hours out of every
|
|
16
|
+
* twenty-four.
|
|
17
|
+
*
|
|
18
|
+
* No directive: with `dayHref` the week rail is links and renders on the
|
|
19
|
+
* server; with `onDayChange` it is buttons and needs a client caller.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type WeekdayThemeBannerLinkProps = {
|
|
23
|
+
href: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
children: React.ReactNode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function DefaultLink({ href, ...props }: WeekdayThemeBannerLinkProps) {
|
|
29
|
+
return <a href={href} {...props} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type WeekdayThemeDay = {
|
|
33
|
+
/** Short code, e.g. "THU". Already localised. */
|
|
34
|
+
code: string;
|
|
35
|
+
/** Theme name, e.g. "Burger Thursday". */
|
|
36
|
+
name: string;
|
|
37
|
+
/** Short form for the rail, e.g. "Burger". Falls back to `name`. */
|
|
38
|
+
shortName?: string;
|
|
39
|
+
/** Items under the theme. Omit where there is no count to show. */
|
|
40
|
+
count?: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type WeekdayThemeBannerProps = {
|
|
44
|
+
days: WeekdayThemeDay[];
|
|
45
|
+
/** Index into `days` for the reader's own today. */
|
|
46
|
+
activeDay: number;
|
|
47
|
+
/** Kicker over the title, e.g. "TODAY IS THURSDAY". Composed by the caller. */
|
|
48
|
+
kicker?: string;
|
|
49
|
+
kickerIcon?: React.ReactNode;
|
|
50
|
+
/** The banner's body copy for the active day. */
|
|
51
|
+
body?: string;
|
|
52
|
+
/** Heading over the week rail, e.g. "THE WEEK". */
|
|
53
|
+
weekLabel?: string;
|
|
54
|
+
/** The primary action — pass a rendered button or link. */
|
|
55
|
+
action?: React.ReactNode;
|
|
56
|
+
/** Note beside the action, e.g. "Bruno picks nine, ranked on mentions". */
|
|
57
|
+
actionNote?: string;
|
|
58
|
+
/** Destination per day index. Renders the rail as links. */
|
|
59
|
+
dayHref?: (day: WeekdayThemeDay, index: number) => string;
|
|
60
|
+
/** Selects a day in place. Renders the rail as buttons. */
|
|
61
|
+
onDayChange?: (index: number) => void;
|
|
62
|
+
/**
|
|
63
|
+
* The day's photograph.
|
|
64
|
+
*
|
|
65
|
+
* This is the one editorial slot on a menu hub that carries a plated dish,
|
|
66
|
+
* and it can because these are art-directed assets shipped with the app, not
|
|
67
|
+
* generated item photos pulled off a menu row. Without one the banner falls
|
|
68
|
+
* back to its flat ground and still reads — but the flat version is the
|
|
69
|
+
* degraded state, not the design.
|
|
70
|
+
*/
|
|
71
|
+
imageUrl?: string;
|
|
72
|
+
linkComponent?: React.ComponentType<WeekdayThemeBannerLinkProps>;
|
|
73
|
+
className?: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function WeekdayThemeBanner({
|
|
77
|
+
days,
|
|
78
|
+
activeDay,
|
|
79
|
+
kicker,
|
|
80
|
+
kickerIcon,
|
|
81
|
+
body,
|
|
82
|
+
weekLabel,
|
|
83
|
+
action,
|
|
84
|
+
actionNote,
|
|
85
|
+
dayHref,
|
|
86
|
+
onDayChange,
|
|
87
|
+
imageUrl,
|
|
88
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
89
|
+
className,
|
|
90
|
+
}: WeekdayThemeBannerProps) {
|
|
91
|
+
const active = days[activeDay];
|
|
92
|
+
if (!active) return null;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
className={cn(
|
|
97
|
+
"relative grid overflow-hidden rounded-[18px] bg-[linear-gradient(120deg,oklch(0.32_0.06_45),oklch(0.24_0.04_40))] text-white lg:grid-cols-[1fr_auto]",
|
|
98
|
+
className
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
{imageUrl && (
|
|
102
|
+
<>
|
|
103
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
104
|
+
<img
|
|
105
|
+
src={imageUrl}
|
|
106
|
+
alt=""
|
|
107
|
+
aria-hidden
|
|
108
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
109
|
+
/>
|
|
110
|
+
{/*
|
|
111
|
+
The scrim covers the WHOLE banner, not just the text side.
|
|
112
|
+
It used to fade to near-transparent on the right, which looked
|
|
113
|
+
better in isolation and made the week rail — seven lines of 12px
|
|
114
|
+
type — sit directly on whatever the plate happened to be. A photo
|
|
115
|
+
you can half-read under is worth less than seven labels you can
|
|
116
|
+
read. Still a gradient rather than a flat wash, so the image keeps
|
|
117
|
+
some depth on the right.
|
|
118
|
+
*/}
|
|
119
|
+
<div
|
|
120
|
+
aria-hidden
|
|
121
|
+
className="absolute inset-0 bg-[linear-gradient(100deg,oklch(0.18_0.03_45_/_0.94)_0%,oklch(0.18_0.03_45_/_0.82)_45%,oklch(0.18_0.03_45_/_0.66)_100%)]"
|
|
122
|
+
/>
|
|
123
|
+
</>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{!imageUrl && (
|
|
127
|
+
<span
|
|
128
|
+
aria-hidden
|
|
129
|
+
className="pointer-events-none absolute bottom-0 right-[28%] select-none font-serif text-[140px] font-bold leading-none text-white/[0.06]"
|
|
130
|
+
>
|
|
131
|
+
{active.code}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<div className="relative z-[1] flex flex-col gap-2.5 p-4 lg:gap-3 lg:p-6">
|
|
136
|
+
{kicker && (
|
|
137
|
+
<span className="inline-flex w-max items-center gap-1.5 rounded-full bg-white/15 px-2.5 py-1 text-[10.5px] font-bold tracking-[0.09em]">
|
|
138
|
+
{kickerIcon}
|
|
139
|
+
{kicker}
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<h3 className="font-serif text-[24px] font-semibold tracking-[-0.02em] lg:text-[36px]">
|
|
144
|
+
{active.name}
|
|
145
|
+
</h3>
|
|
146
|
+
|
|
147
|
+
{body && <p className="max-w-[520px] text-[13.5px] leading-[1.5] opacity-90">{body}</p>}
|
|
148
|
+
|
|
149
|
+
{(action || actionNote) && (
|
|
150
|
+
<div className="mt-1 flex flex-wrap items-center gap-3">
|
|
151
|
+
{action}
|
|
152
|
+
{actionNote && <span className="text-xs opacity-75">{actionNote}</span>}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div
|
|
158
|
+
className={cn(
|
|
159
|
+
"relative z-[1] hidden border-t border-white/15 p-4 lg:block lg:min-w-[240px] lg:border-l lg:border-t-0 lg:p-5",
|
|
160
|
+
// Over a photograph the rail needs its own ground, or seven lines of
|
|
161
|
+
// small type land on whatever the plate happens to be.
|
|
162
|
+
imageUrl && "bg-black/45 backdrop-blur-[2px]"
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{weekLabel && (
|
|
166
|
+
<div className="mb-2 text-[10px] font-bold tracking-[0.12em] opacity-70">
|
|
167
|
+
{weekLabel}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
<ul className="grid grid-cols-2 gap-x-4 sm:grid-cols-4 lg:grid-cols-1">
|
|
172
|
+
{days.map((day, index) => {
|
|
173
|
+
const isActive = index === activeDay;
|
|
174
|
+
const content = (
|
|
175
|
+
<>
|
|
176
|
+
<b className="w-8 shrink-0 font-mono text-[11px] font-medium opacity-70">
|
|
177
|
+
{day.code}
|
|
178
|
+
</b>
|
|
179
|
+
<span className="min-w-0 flex-1 truncate">{day.shortName ?? day.name}</span>
|
|
180
|
+
{day.count != null && (
|
|
181
|
+
<span className="shrink-0 tabular-nums opacity-70">{day.count}</span>
|
|
182
|
+
)}
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Today is set BIGGER, not just bolder. Weight alone was doing all
|
|
187
|
+
// the work at 12.5px against six neighbours, which is not enough
|
|
188
|
+
// separation to answer "which one is today" at a glance — the
|
|
189
|
+
// question the rail exists to answer.
|
|
190
|
+
const rowClasses = cn(
|
|
191
|
+
"flex w-full items-center gap-2 rounded transition-opacity",
|
|
192
|
+
isActive
|
|
193
|
+
? "-mx-1.5 bg-white/10 px-1.5 py-1.5 text-[14.5px] font-extrabold"
|
|
194
|
+
: "py-[5px] text-[12.5px] opacity-70 hover:opacity-100"
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<li key={day.code}>
|
|
199
|
+
{dayHref ? (
|
|
200
|
+
<LinkComponent
|
|
201
|
+
href={dayHref(day, index)}
|
|
202
|
+
className={cn(
|
|
203
|
+
rowClasses,
|
|
204
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
|
205
|
+
)}
|
|
206
|
+
>
|
|
207
|
+
{content}
|
|
208
|
+
</LinkComponent>
|
|
209
|
+
) : onDayChange ? (
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
aria-pressed={isActive}
|
|
213
|
+
onClick={() => onDayChange(index)}
|
|
214
|
+
className={cn(
|
|
215
|
+
rowClasses,
|
|
216
|
+
"text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
{content}
|
|
220
|
+
</button>
|
|
221
|
+
) : (
|
|
222
|
+
<span className={rowClasses}>{content}</span>
|
|
223
|
+
)}
|
|
224
|
+
</li>
|
|
225
|
+
);
|
|
226
|
+
})}
|
|
227
|
+
</ul>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { WeekendPanel } from "./weekend-panel";
|
|
4
|
+
|
|
5
|
+
const labels = { pause: "Pause", play: "Play" };
|
|
6
|
+
|
|
7
|
+
function renderPanel() {
|
|
8
|
+
return render(
|
|
9
|
+
<WeekendPanel dayLabel="Saturday" countLabel="4 events" actionLabel="See all" labels={labels}>
|
|
10
|
+
<a href="/a">A</a>
|
|
11
|
+
<a href="/b">B</a>
|
|
12
|
+
<a href="/c">C</a>
|
|
13
|
+
<a href="/d">D</a>
|
|
14
|
+
</WeekendPanel>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const track = () => screen.getByText("A").parentElement!.parentElement as HTMLElement;
|
|
19
|
+
|
|
20
|
+
describe("WeekendPanel", () => {
|
|
21
|
+
it("has no autoplay control below the desktop breakpoint", () => {
|
|
22
|
+
renderPanel();
|
|
23
|
+
expect(screen.queryByRole("button", { name: "Pause" })).not.toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("starts on the first page and turns on a leftward drag", () => {
|
|
27
|
+
renderPanel();
|
|
28
|
+
expect(track().style.transform).toContain("calc(0 *");
|
|
29
|
+
|
|
30
|
+
const stage = track().parentElement as HTMLElement;
|
|
31
|
+
fireEvent.pointerDown(stage, { clientX: 200, pointerId: 1, button: 0 });
|
|
32
|
+
fireEvent.pointerMove(stage, { clientX: 150, pointerId: 1 });
|
|
33
|
+
fireEvent.pointerMove(stage, { clientX: 100, pointerId: 1 });
|
|
34
|
+
fireEvent.pointerUp(stage, { clientX: 100, pointerId: 1 });
|
|
35
|
+
expect(track().style.transform).toContain("calc(-1 *");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("turns to the page of a card that receives focus", () => {
|
|
39
|
+
renderPanel();
|
|
40
|
+
fireEvent.focus(screen.getByText("D"));
|
|
41
|
+
expect(track().style.transform).toContain("calc(-1 *");
|
|
42
|
+
});
|
|
43
|
+
});
|