@bearmenu/ui 0.8.8 → 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-62ES6N32.js +27 -0
- package/dist/chunk-AXV5O2IZ.js +58 -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/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -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/event-card.js +1 -1
- package/dist/components/event-compact-row.d.ts +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +471 -174
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.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/ranked-venue-list.d.ts +49 -0
- package/dist/components/ranked-venue-list.js +87 -0
- 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 +24 -4
- package/dist/components/snap-rail.js +66 -8
- 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/tag.d.ts +2 -2
- package/dist/components/text.d.ts +3 -3
- 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/package.json +1 -1
- 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/event-compact-row.tsx +1 -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/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/ranked-venue-list.stories.tsx +80 -0
- package/src/components/ranked-venue-list.tsx +177 -0
- 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.tsx +90 -9
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Camera, Star } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Separator } from "./separator";
|
|
4
|
+
import { VenueGradeDimensions, type VenueGradeDimensionKey } from "./venue-grade-dimensions";
|
|
5
|
+
import { VenueQuotes } from "./venue-quotes";
|
|
6
|
+
import { VenueSignatureBlock } from "./venue-signature-block";
|
|
7
|
+
import type { MenuItemLabels } from "./menu-item";
|
|
8
|
+
import type { MenuVenueSignature, MenuLinkProps, MenuLinkComponent } from "./menu-types";
|
|
9
|
+
import type { CitedText, PlacePreview } from "./place-types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* VenueSpotlight — one venue given real space: photograph, what it is known
|
|
13
|
+
* for, what reviewers say, how it grades, and when it fills up.
|
|
14
|
+
*
|
|
15
|
+
* The photograph IS the point of this surface. It is the one module on a menu
|
|
16
|
+
* hub that shows an image, and it can, because venue photography is real and
|
|
17
|
+
* licensed where item photography is generated. Absent a photo it degrades to
|
|
18
|
+
* the same diagonal texture the hero uses, never to a bare icon.
|
|
19
|
+
*
|
|
20
|
+
* ## Composed from exported parts
|
|
21
|
+
*
|
|
22
|
+
* `VenueSignatureBlock`, `VenueQuotes`, `VenueGradeDimensions` and
|
|
23
|
+
* `VenueBusynessStrip` are each exported on their own. This composite is the
|
|
24
|
+
* arrangement the hub wants; a consumer filming a single panel, or building a
|
|
25
|
+
* narrower variant, takes the parts instead. Everything here is layout — no
|
|
26
|
+
* part is reachable only through the composite.
|
|
27
|
+
*
|
|
28
|
+
* No directive: it renders on the server unless `onCite` is passed through to
|
|
29
|
+
* the quotes.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
33
|
+
export type VenueSpotlightLinkProps = MenuLinkProps;
|
|
34
|
+
|
|
35
|
+
function DefaultLink({ href, ...props }: VenueSpotlightLinkProps) {
|
|
36
|
+
return <a href={href} {...props} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type VenueSpotlightLabels = {
|
|
40
|
+
/** Overlay on the photograph, e.g. "venue photograph". */
|
|
41
|
+
photoCredit?: string;
|
|
42
|
+
/** Composed open state, e.g. "Kitchen open until 23:00". Absent when closed or unknown. */
|
|
43
|
+
openUntil?: string;
|
|
44
|
+
/** Kicker over the signature, e.g. "WHAT IT IS KNOWN FOR". */
|
|
45
|
+
signatureKicker: string;
|
|
46
|
+
/** Composed review count, e.g. "318 reviews". */
|
|
47
|
+
reviewCount?: string;
|
|
48
|
+
/** Dimension key → translated label. */
|
|
49
|
+
dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
|
|
50
|
+
/** Tag code → translated label. Missing codes render the raw code. */
|
|
51
|
+
tags?: Record<string, string>;
|
|
52
|
+
/** Composed value verdict, e.g. "Good value". */
|
|
53
|
+
valueVerdict?: string;
|
|
54
|
+
/** Composed busyness sentence. */
|
|
55
|
+
busyness?: string;
|
|
56
|
+
/** Accessible name for a quote's cite action. */
|
|
57
|
+
cite?: (reviewCount: number) => string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type VenueSpotlightProps = {
|
|
61
|
+
place: PlacePreview;
|
|
62
|
+
href: string;
|
|
63
|
+
labels: VenueSpotlightLabels;
|
|
64
|
+
/** Composed meta line, e.g. "pub · Centru". Clause order is locale-dependent. */
|
|
65
|
+
meta?: string;
|
|
66
|
+
signature?: MenuVenueSignature;
|
|
67
|
+
/** CSS gradient for the signature swatch. */
|
|
68
|
+
signatureGradient?: string;
|
|
69
|
+
/** Passed through to the signature `<MenuItem>`. */
|
|
70
|
+
itemLabels: MenuItemLabels;
|
|
71
|
+
formatPrice: (amount: number) => string;
|
|
72
|
+
formatCount?: (value: number) => string;
|
|
73
|
+
/** 0–100 → display numeral for the dimension bars. */
|
|
74
|
+
formatScore: (value: number) => string;
|
|
75
|
+
/** Opens the reviews behind a quote. */
|
|
76
|
+
onCite?: (quote: CitedText) => void;
|
|
77
|
+
/** The action in the footer — pass a rendered link or button. */
|
|
78
|
+
action?: React.ReactNode;
|
|
79
|
+
linkComponent?: MenuLinkComponent;
|
|
80
|
+
className?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Quotes are capped at two: a third pushes the grade row below the fold on a phone. */
|
|
84
|
+
const QUOTE_LIMIT = 2;
|
|
85
|
+
|
|
86
|
+
export function VenueSpotlight({
|
|
87
|
+
place,
|
|
88
|
+
href,
|
|
89
|
+
labels,
|
|
90
|
+
meta,
|
|
91
|
+
signature,
|
|
92
|
+
signatureGradient,
|
|
93
|
+
itemLabels,
|
|
94
|
+
formatPrice,
|
|
95
|
+
formatCount,
|
|
96
|
+
formatScore,
|
|
97
|
+
onCite,
|
|
98
|
+
action,
|
|
99
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
100
|
+
className,
|
|
101
|
+
}: VenueSpotlightProps) {
|
|
102
|
+
const digest = place.reviews_digest;
|
|
103
|
+
const tags = digest?.tags ?? [];
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
className={cn(
|
|
108
|
+
"grid overflow-hidden rounded-[18px] border border-border bg-card lg:grid-cols-[404px_1fr]",
|
|
109
|
+
className
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
<div className="relative min-h-[220px] bg-[repeating-linear-gradient(135deg,oklch(0.76_0.02_55)_0_10px,oklch(0.83_0.013_58)_10px_20px)] lg:min-h-[340px]">
|
|
113
|
+
{place.thumbnail_url && (
|
|
114
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
115
|
+
<img
|
|
116
|
+
src={place.thumbnail_url}
|
|
117
|
+
alt={place.name}
|
|
118
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{labels.photoCredit && (
|
|
123
|
+
<span className="absolute left-3.5 top-3.5 inline-flex h-7 items-center gap-1.5 rounded-full bg-black/60 px-2.5 text-[11px] font-bold text-white">
|
|
124
|
+
<Camera className="h-2.5 w-2.5" aria-hidden />
|
|
125
|
+
{labels.photoCredit}
|
|
126
|
+
</span>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{labels.openUntil && (
|
|
130
|
+
<span className="absolute bottom-3.5 left-3.5 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-background px-3 text-[11.5px] font-bold text-[var(--free,currentColor)]">
|
|
131
|
+
<i className="h-[7px] w-[7px] rounded-full bg-current" aria-hidden />
|
|
132
|
+
{labels.openUntil}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="flex flex-col gap-3.5 p-4 lg:p-6">
|
|
138
|
+
{/*
|
|
139
|
+
`items-baseline` and a hard `shrink-0` on the rating: the venue name
|
|
140
|
+
is a long serif line that wraps, and with `items-start` the numeral
|
|
141
|
+
floated beside its second line instead of sitting on the first. The
|
|
142
|
+
name column also needs `min-w-0` AND `basis-0` — without the basis a
|
|
143
|
+
long unbroken word (a venue called "Tortelli Pasta Bar") refuses to
|
|
144
|
+
wrap and pushes the rating off the card.
|
|
145
|
+
*/}
|
|
146
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
147
|
+
<h3 className="min-w-0 flex-1 basis-0 font-serif text-xl font-semibold leading-tight tracking-[-0.02em] lg:text-[28px]">
|
|
148
|
+
<LinkComponent
|
|
149
|
+
href={href}
|
|
150
|
+
className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
151
|
+
>
|
|
152
|
+
{place.name}
|
|
153
|
+
</LinkComponent>
|
|
154
|
+
</h3>
|
|
155
|
+
|
|
156
|
+
{place.rating && (
|
|
157
|
+
<div className="flex shrink-0 items-baseline gap-1.5">
|
|
158
|
+
<b className="text-xl font-extrabold tabular-nums lg:text-2xl">
|
|
159
|
+
{place.rating.rating}
|
|
160
|
+
</b>
|
|
161
|
+
<Star className="h-3 w-3 self-center text-muted-foreground" aria-hidden />
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{(meta || labels.reviewCount) && (
|
|
167
|
+
<div className="-mt-2 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12.5px] text-muted-foreground">
|
|
168
|
+
{meta && <span>{meta}</span>}
|
|
169
|
+
{meta && labels.reviewCount && <span aria-hidden>·</span>}
|
|
170
|
+
{labels.reviewCount && <span>{labels.reviewCount}</span>}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{signature && (
|
|
175
|
+
<VenueSignatureBlock
|
|
176
|
+
item={signature.item}
|
|
177
|
+
labels={itemLabels}
|
|
178
|
+
formatPrice={formatPrice}
|
|
179
|
+
formatCount={formatCount}
|
|
180
|
+
kicker={labels.signatureKicker}
|
|
181
|
+
markGradient={signatureGradient}
|
|
182
|
+
subtitle={signature.why}
|
|
183
|
+
href={signature.href}
|
|
184
|
+
linkComponent={LinkComponent}
|
|
185
|
+
/>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{digest?.highlights && (
|
|
189
|
+
<VenueQuotes
|
|
190
|
+
quotes={digest.highlights}
|
|
191
|
+
limit={QUOTE_LIMIT}
|
|
192
|
+
onCite={onCite}
|
|
193
|
+
citeLabel={labels.cite}
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{digest?.grade?.dimensions && <Separator />}
|
|
198
|
+
|
|
199
|
+
{digest?.grade?.dimensions && (
|
|
200
|
+
<VenueGradeDimensions
|
|
201
|
+
dimensions={digest.grade.dimensions}
|
|
202
|
+
labels={labels.dimensions}
|
|
203
|
+
formatScore={formatScore}
|
|
204
|
+
className="mt-1"
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{(labels.valueVerdict || tags.length > 0) && (
|
|
209
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
210
|
+
{labels.valueVerdict && (
|
|
211
|
+
<span className="inline-flex h-[26px] items-center gap-1.5 rounded-full bg-accent-subtle px-2.5 text-[11.5px] font-bold text-accent">
|
|
212
|
+
{labels.valueVerdict}
|
|
213
|
+
</span>
|
|
214
|
+
)}
|
|
215
|
+
{tags.map((tag) => (
|
|
216
|
+
<span
|
|
217
|
+
key={tag.value}
|
|
218
|
+
className="inline-flex h-[26px] items-center rounded-full border border-border px-2.5 text-[11.5px] text-muted-foreground"
|
|
219
|
+
>
|
|
220
|
+
{labels.tags?.[tag.value] ?? tag.value}
|
|
221
|
+
</span>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{/* No busyness strip here. Twelve unlabelled bars under a grade row
|
|
227
|
+
read as a second, unexplained chart — nobody could tell what they
|
|
228
|
+
measured without the sentence beside them, and the sentence does not
|
|
229
|
+
fit on this card. `<VenueBusynessStrip>` still exists and belongs on
|
|
230
|
+
the venue's own page, where it has room to be captioned. */}
|
|
231
|
+
|
|
232
|
+
{action && <div className="mt-auto flex justify-end pt-1">{action}</div>}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { UtensilsCrossed } from "lucide-react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { mockPlaces } from "./menu-mocks";
|
|
5
|
+
import { VenueWall } from "./venue-wall";
|
|
6
|
+
|
|
7
|
+
const hrefs = Object.fromEntries(
|
|
8
|
+
mockPlaces.map((place) => [place.slug, `/places/${place.slug}`])
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof VenueWall> = {
|
|
12
|
+
title: "Menu/VenueWall",
|
|
13
|
+
component: VenueWall,
|
|
14
|
+
tags: ["autodocs"],
|
|
15
|
+
decorators: [
|
|
16
|
+
(Story) => (
|
|
17
|
+
<div className="w-[1100px] bg-background p-4">
|
|
18
|
+
<Story />
|
|
19
|
+
</div>
|
|
20
|
+
),
|
|
21
|
+
],
|
|
22
|
+
parameters: {
|
|
23
|
+
docs: {
|
|
24
|
+
description: {
|
|
25
|
+
component:
|
|
26
|
+
"The catalogue's scale, stated as a row of the venues in it. A closing beat rather than a browse surface: the avatars are named and linked, but the module's job is to make \"we read every menu in the city\" land as something specific. It shows a handful and counts the rest, because a wall long enough to be exhaustive stops reading as places.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
type Story = StoryObj<typeof VenueWall>;
|
|
34
|
+
|
|
35
|
+
export const Default: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
places: mockPlaces,
|
|
38
|
+
hrefs,
|
|
39
|
+
kicker: "397 MENUS IN CLUJ-NAPOCA, READ IN FULL",
|
|
40
|
+
kickerIcon: <UtensilsCrossed className="h-3 w-3" />,
|
|
41
|
+
headline: "Every menu in the city, priced to the portion",
|
|
42
|
+
body: "45,140 dishes and drinks with what they cost, how big they are, and a mention count on every one of them.",
|
|
43
|
+
overflowLabel: "+393 more",
|
|
44
|
+
action: <Button size="sm" variant="secondary">Ask Bruno</Button>,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Avatars only — the wall inside a section that already has a header. */
|
|
49
|
+
export const Bare: Story = {
|
|
50
|
+
args: { places: mockPlaces, hrefs, overflowLabel: "+393 more" },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Inert avatars, for a render with nowhere to navigate. */
|
|
54
|
+
export const Static: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
places: mockPlaces,
|
|
57
|
+
kicker: "397 MENUS READ IN FULL",
|
|
58
|
+
headline: "Every menu in the city, priced to the portion",
|
|
59
|
+
overflowLabel: "+393 more",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const Mobile: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
places: mockPlaces.slice(0, 3),
|
|
66
|
+
hrefs,
|
|
67
|
+
kicker: "397 MENUS READ IN FULL",
|
|
68
|
+
headline: "Every menu in the city, priced to the portion",
|
|
69
|
+
overflowLabel: "+394 more",
|
|
70
|
+
},
|
|
71
|
+
decorators: [
|
|
72
|
+
(Story) => (
|
|
73
|
+
<div className="w-[390px] bg-background p-3">
|
|
74
|
+
<Story />
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
],
|
|
78
|
+
};
|
|
@@ -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
|
+
};
|