@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,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { HubSurface } from "./hub-surface";
|
|
3
|
+
import { SectionHeader } from "./section-header";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof HubSurface> = {
|
|
6
|
+
title: "Menu/HubSurface",
|
|
7
|
+
component: HubSurface,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[390px] bg-[var(--surface-2,oklch(0.97_0.004_60))] p-3">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"The surface a hub section sits on below `lg`. On a phone a hub is an app screen, not a page: each section is a white card on the tinted ground, 18px radius, one shadow, no border. From `lg` up the card dissolves entirely and sections sit flat the way the desktop design draws them — one element, two looks, identical markup inside. Hairlines live inside a card, never across the page.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof HubSurface>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
render: () => (
|
|
31
|
+
<HubSurface>
|
|
32
|
+
<SectionHeader title="Open right now" />
|
|
33
|
+
<p className="text-sm text-muted-foreground">
|
|
34
|
+
Section content, inset by the surface.
|
|
35
|
+
</p>
|
|
36
|
+
</HubSurface>
|
|
37
|
+
),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** `flush` drops the inset so a rail or a photograph can bleed to the edge. */
|
|
41
|
+
export const Flush: Story = {
|
|
42
|
+
render: () => (
|
|
43
|
+
<HubSurface flush>
|
|
44
|
+
<div className="px-3.5 pt-4">
|
|
45
|
+
<SectionHeader title="Browse by cuisine" />
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex gap-3 overflow-x-auto px-3.5 pb-4">
|
|
48
|
+
{[0, 1, 2].map((card) => (
|
|
49
|
+
<div key={card} className="h-24 w-40 shrink-0 rounded-xl bg-muted" />
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
</HubSurface>
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Stacked, at the 12px rhythm the hub uses between sections. */
|
|
57
|
+
export const Stacked: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<div className="flex flex-col gap-3">
|
|
60
|
+
{["Open right now", "One kitchen worth knowing", "Most named"].map((title) => (
|
|
61
|
+
<HubSurface key={title}>
|
|
62
|
+
<SectionHeader title={title} />
|
|
63
|
+
<div className="h-12 rounded-lg bg-muted" />
|
|
64
|
+
</HubSurface>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
),
|
|
68
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HubSurface — the surface a hub section sits on BELOW `lg`.
|
|
5
|
+
*
|
|
6
|
+
* On a phone a hub is an app screen, not a page: each section is a white card
|
|
7
|
+
* on the tinted ground, 18px radius, one card shadow and no border, with 14px
|
|
8
|
+
* of inset. Hairlines live inside a card, never across the page. From `lg` up
|
|
9
|
+
* the card dissolves — no fill, no radius, no inset — and the sections sit flat
|
|
10
|
+
* the way the desktop design draws them. One element, two looks; the section
|
|
11
|
+
* markup inside is identical at every width.
|
|
12
|
+
*
|
|
13
|
+
* `flush` drops the inset so a section whose content bleeds to the card edge —
|
|
14
|
+
* a snap rail, a full-width photograph — can pad its own header and let the
|
|
15
|
+
* rest run.
|
|
16
|
+
*
|
|
17
|
+
* Generalised out of the events hub's `EventsHubCard`, which had the same job
|
|
18
|
+
* and lived in the app. Nothing here is menu- or event-shaped.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type HubSurfaceProps = {
|
|
22
|
+
flush?: boolean;
|
|
23
|
+
className?: string;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function HubSurface({ flush, className, children }: HubSurfaceProps) {
|
|
28
|
+
return (
|
|
29
|
+
<section
|
|
30
|
+
className={cn(
|
|
31
|
+
"rounded-[18px] border border-border bg-card shadow-none",
|
|
32
|
+
flush ? "overflow-hidden" : "px-3.5 py-4",
|
|
33
|
+
"lg:overflow-visible lg:rounded-none lg:bg-transparent lg:p-0 lg:shadow-none",
|
|
34
|
+
className
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
{children}
|
|
38
|
+
</section>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuFamilyCard } from "./menu-family-card";
|
|
3
|
+
import { mockDrinkFamily, mockFamily } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const itemLabels = {
|
|
6
|
+
currency: "lei",
|
|
7
|
+
mentions: (count: number) => `${count} mentions`,
|
|
8
|
+
signature: "SIGNATURE",
|
|
9
|
+
mentioned: "MENTIONED",
|
|
10
|
+
};
|
|
11
|
+
const formatPrice = (amount: number) => String(amount);
|
|
12
|
+
|
|
13
|
+
const labels = {
|
|
14
|
+
venueUnit: "KITCHENS",
|
|
15
|
+
mostNamed: "Most named",
|
|
16
|
+
priceBand: "kitchen price band",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const meta: Meta<typeof MenuFamilyCard> = {
|
|
20
|
+
title: "Menu/MenuFamilyCard",
|
|
21
|
+
component: MenuFamilyCard,
|
|
22
|
+
tags: ["autodocs"],
|
|
23
|
+
decorators: [
|
|
24
|
+
(Story) => (
|
|
25
|
+
<div className="w-[300px] bg-background p-4">
|
|
26
|
+
<Story />
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
parameters: {
|
|
31
|
+
docs: {
|
|
32
|
+
description: {
|
|
33
|
+
component:
|
|
34
|
+
"A browsable grouping of venues — a cuisine on the dishes tab, a drink family on the drinks tab. Two halves with two jobs: the dark panel is for scanning a row of four (one number, one name, the groups the family consists of, over a mosaic of its venues), and the white half underneath answers what you would actually order, as the production `MenuItem`. Both halves count VENUES: cuisine exists only on places, so a card counting dishes cannot be built.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default meta;
|
|
41
|
+
type Story = StoryObj<typeof MenuFamilyCard>;
|
|
42
|
+
|
|
43
|
+
export const Default: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
family: mockFamily,
|
|
46
|
+
labels,
|
|
47
|
+
itemLabels,
|
|
48
|
+
formatPrice,
|
|
49
|
+
href: "/top/italian",
|
|
50
|
+
hue: "oklch(0.55 0.15 40)",
|
|
51
|
+
priceBandLabel: "32–84 lei",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The drinks tab. Same card, different unit and vocabulary. */
|
|
56
|
+
export const DrinkFamily: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
family: mockDrinkFamily,
|
|
59
|
+
labels: { venueUnit: "BARS", mostNamed: "Most named", priceBand: "bar price band" },
|
|
60
|
+
itemLabels,
|
|
61
|
+
formatPrice,
|
|
62
|
+
href: "/top/baruri",
|
|
63
|
+
hue: "oklch(0.5 0.13 240)",
|
|
64
|
+
priceBandLabel: "18–54 lei",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A one-venue cuisine: the count is honest, and the mosaic repeats one photo. */
|
|
69
|
+
export const Sparse: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
family: {
|
|
72
|
+
slug: "portuguese",
|
|
73
|
+
name: "Portuguese",
|
|
74
|
+
venue_count: 1,
|
|
75
|
+
chips: [{ slug: "seafood-dish", label: "seafood" }],
|
|
76
|
+
signature: mockFamily.signature,
|
|
77
|
+
},
|
|
78
|
+
labels,
|
|
79
|
+
itemLabels,
|
|
80
|
+
formatPrice,
|
|
81
|
+
href: "/top/restaurante",
|
|
82
|
+
hue: "oklch(0.52 0.13 340)",
|
|
83
|
+
priceBandLabel: "42–88 lei",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The row of four the card is designed to be scanned in. */
|
|
88
|
+
export const Row: Story = {
|
|
89
|
+
render: () => (
|
|
90
|
+
<div className="grid grid-cols-4 gap-5">
|
|
91
|
+
{[mockFamily, mockDrinkFamily, mockFamily, mockDrinkFamily].map((family, index) => (
|
|
92
|
+
<MenuFamilyCard
|
|
93
|
+
key={index}
|
|
94
|
+
family={family}
|
|
95
|
+
labels={labels}
|
|
96
|
+
itemLabels={itemLabels}
|
|
97
|
+
formatPrice={formatPrice}
|
|
98
|
+
href={`/top/${family.slug}`}
|
|
99
|
+
hue="oklch(0.55 0.15 40)"
|
|
100
|
+
priceBandLabel="32–84 lei"
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
),
|
|
105
|
+
decorators: [
|
|
106
|
+
(Story) => (
|
|
107
|
+
<div className="w-[1200px] bg-background p-4">
|
|
108
|
+
<Story />
|
|
109
|
+
</div>
|
|
110
|
+
),
|
|
111
|
+
],
|
|
112
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Wallet } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { MenuItem, type MenuItemLabels } from "./menu-item";
|
|
4
|
+
import type { MenuFamilyPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MenuFamilyCard — a browsable grouping of venues: a cuisine on the dishes tab,
|
|
8
|
+
* a drink family on the drinks tab.
|
|
9
|
+
*
|
|
10
|
+
* ## The count does the scanning, the item answers the question
|
|
11
|
+
*
|
|
12
|
+
* The card is two halves with two jobs. The dark panel is for scanning a row of
|
|
13
|
+
* four: one number, one name, and the groups the family consists of, over a
|
|
14
|
+
* mosaic of its venues. The white half underneath answers what you would
|
|
15
|
+
* actually order — the production `<MenuItem>`, with its price, portion and
|
|
16
|
+
* mention count, so the recommendation is the review census rather than an
|
|
17
|
+
* editorial pick.
|
|
18
|
+
*
|
|
19
|
+
* ## Both halves count VENUES
|
|
20
|
+
*
|
|
21
|
+
* Cuisine exists on places and nowhere else — no listing endpoint will tell you
|
|
22
|
+
* how many dishes are Italian — so a card counting dishes cannot be built and
|
|
23
|
+
* this one does not try. The groups line is a plain list without counts: at
|
|
24
|
+
* four cards across, four chips each carrying a number turned the panel into a
|
|
25
|
+
* table, and the numbers were kitchens, which nobody reads a chip as.
|
|
26
|
+
*
|
|
27
|
+
* The mosaic holds VENUE photographs, which are real. It dims them hard — this
|
|
28
|
+
* is a ground for a number, not a gallery, and at this size a legible photo
|
|
29
|
+
* competes with the count for the same glance.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
33
|
+
export type MenuFamilyCardLinkProps = MenuLinkProps;
|
|
34
|
+
|
|
35
|
+
function DefaultLink({ href, ...props }: MenuFamilyCardLinkProps) {
|
|
36
|
+
return <a href={href} {...props} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type MenuFamilyCardLabels = {
|
|
40
|
+
/** Unit under the count, e.g. "KITCHENS". */
|
|
41
|
+
venueUnit: string;
|
|
42
|
+
/** Kicker over the featured item, e.g. "MOST NAMED". */
|
|
43
|
+
mostNamed: string;
|
|
44
|
+
/** Footer caption, e.g. "kitchen price band". */
|
|
45
|
+
priceBand: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type MenuFamilyCardProps = {
|
|
49
|
+
family: MenuFamilyPreview;
|
|
50
|
+
labels: MenuFamilyCardLabels;
|
|
51
|
+
/** Passed through to the featured `<MenuItem>`. */
|
|
52
|
+
itemLabels: MenuItemLabels;
|
|
53
|
+
formatPrice: (amount: number) => string;
|
|
54
|
+
formatCount?: (value: number) => string;
|
|
55
|
+
href: string;
|
|
56
|
+
/** Finished CSS colour for the featured item's category mark. */
|
|
57
|
+
hue?: string;
|
|
58
|
+
/** Composed price band, e.g. "32–84 lei". */
|
|
59
|
+
priceBandLabel?: string;
|
|
60
|
+
linkComponent?: MenuLinkComponent;
|
|
61
|
+
className?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A 2×2, not a 2×4.
|
|
66
|
+
*
|
|
67
|
+
* Four cells at card width give each photograph enough room to read as a room
|
|
68
|
+
* rather than as a swatch, and — the reason it changed — four cells match the
|
|
69
|
+
* four venues a caller actually has, so no photo repeats. At eight cells the
|
|
70
|
+
* list wrapped and put the SAME image directly above itself in a column, which
|
|
71
|
+
* is the one repeat a mosaic cannot hide.
|
|
72
|
+
*/
|
|
73
|
+
const MOSAIC_CELLS = 4;
|
|
74
|
+
|
|
75
|
+
export function MenuFamilyCard({
|
|
76
|
+
family,
|
|
77
|
+
labels,
|
|
78
|
+
itemLabels,
|
|
79
|
+
formatPrice,
|
|
80
|
+
formatCount,
|
|
81
|
+
href,
|
|
82
|
+
hue,
|
|
83
|
+
priceBandLabel,
|
|
84
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
85
|
+
className,
|
|
86
|
+
}: MenuFamilyCardProps) {
|
|
87
|
+
const photos = family.venue_photo_urls ?? [];
|
|
88
|
+
const groups = (family.chips ?? []).map((chip) => chip.label);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
className={cn(
|
|
93
|
+
"flex h-full flex-col overflow-hidden rounded-[14px] border border-border bg-card",
|
|
94
|
+
className
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
<LinkComponent
|
|
98
|
+
href={href}
|
|
99
|
+
className="relative block overflow-hidden bg-[oklch(0.21_0.012_55)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
100
|
+
>
|
|
101
|
+
<span aria-hidden className="absolute inset-0 grid grid-cols-2 grid-rows-2 gap-px">
|
|
102
|
+
{Array.from({ length: MOSAIC_CELLS }, (_, cell) =>
|
|
103
|
+
photos.length ? (
|
|
104
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
105
|
+
<img
|
|
106
|
+
key={cell}
|
|
107
|
+
src={photos[cell % photos.length]}
|
|
108
|
+
alt=""
|
|
109
|
+
loading="lazy"
|
|
110
|
+
decoding="async"
|
|
111
|
+
className="h-full w-full object-cover"
|
|
112
|
+
/>
|
|
113
|
+
) : (
|
|
114
|
+
<span
|
|
115
|
+
key={cell}
|
|
116
|
+
className="bg-[repeating-linear-gradient(135deg,oklch(0.6_0.02_55)_0_9px,oklch(0.68_0.016_58)_9px_18px)] opacity-[0.18]"
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
119
|
+
)}
|
|
120
|
+
</span>
|
|
121
|
+
|
|
122
|
+
{/*
|
|
123
|
+
The family's colour over the photography, when the caller passes a
|
|
124
|
+
`hue`. `mix-blend-mode: color` keeps every photo's luminance and
|
|
125
|
+
replaces its chroma with the family's, so four cards of four
|
|
126
|
+
different rooms read as one cocktail-blue, one wine-red, one
|
|
127
|
+
beer-gold, one coffee-brown — the same dictionary the chips and the
|
|
128
|
+
price band already use (`markGradient`). The scrim below still owns
|
|
129
|
+
the contrast; this layer only says which family the room belongs to.
|
|
130
|
+
*/}
|
|
131
|
+
{hue && (
|
|
132
|
+
<span
|
|
133
|
+
aria-hidden
|
|
134
|
+
className="absolute inset-0 opacity-90 mix-blend-color"
|
|
135
|
+
style={{ backgroundColor: hue }}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{/*
|
|
140
|
+
The hero's scrim, not a light tint.
|
|
141
|
+
The count and the name sit at opposite ends of this panel, so both
|
|
142
|
+
ends need to be dark — a gradient that clears in the middle looked
|
|
143
|
+
better empty and made the type unreadable the moment there were real
|
|
144
|
+
photographs behind it. Dimming the images themselves was tried first
|
|
145
|
+
and is worse: it greys the photography without ever guaranteeing
|
|
146
|
+
contrast, because the guarantee has to come from a fixed layer.
|
|
147
|
+
*/}
|
|
148
|
+
<span
|
|
149
|
+
aria-hidden
|
|
150
|
+
className="absolute inset-0 bg-[linear-gradient(180deg,oklch(0.16_0.012_55_/_0.9)_0%,oklch(0.16_0.012_55_/_0.62)_45%,oklch(0.16_0.012_55_/_0.94)_100%)]"
|
|
151
|
+
/>
|
|
152
|
+
|
|
153
|
+
{/* Taller than the half below it: the photography is what makes a row
|
|
154
|
+
of four scannable, and it was losing the card to a text block. */}
|
|
155
|
+
<span className="relative flex min-h-[260px] flex-col justify-between p-3.5 text-white">
|
|
156
|
+
<span className="block">
|
|
157
|
+
<b className="block text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums">
|
|
158
|
+
{family.venue_count}
|
|
159
|
+
</b>
|
|
160
|
+
<span className="mt-1 block text-[9.5px] font-bold tracking-[0.12em] opacity-80">
|
|
161
|
+
{labels.venueUnit}
|
|
162
|
+
</span>
|
|
163
|
+
</span>
|
|
164
|
+
|
|
165
|
+
<span className="block">
|
|
166
|
+
<span className="block text-[17px] font-bold tracking-[-0.01em]">{family.name}</span>
|
|
167
|
+
{groups.length > 0 && (
|
|
168
|
+
<span className="mt-0.5 block truncate text-[11.5px] opacity-75">
|
|
169
|
+
{groups.join(" · ")}
|
|
170
|
+
</span>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{/* The band belongs up here with the other facts ABOUT the family.
|
|
174
|
+
Below, it was a third block competing with the one thing that
|
|
175
|
+
half is for — the item you might actually order. */}
|
|
176
|
+
{priceBandLabel && (
|
|
177
|
+
<span className="mt-2 inline-flex items-center gap-1.5 rounded-full bg-white/15 px-2.5 py-1 text-[11px] backdrop-blur-[2px]">
|
|
178
|
+
<Wallet className="h-3 w-3 shrink-0" aria-hidden />
|
|
179
|
+
<span className="opacity-80">{labels.priceBand}</span>
|
|
180
|
+
<b className="font-semibold tabular-nums">{priceBandLabel}</b>
|
|
181
|
+
</span>
|
|
182
|
+
)}
|
|
183
|
+
</span>
|
|
184
|
+
</span>
|
|
185
|
+
</LinkComponent>
|
|
186
|
+
|
|
187
|
+
{family.signature && (
|
|
188
|
+
<div className="flex flex-1 flex-col px-2 pb-2 pt-3">
|
|
189
|
+
<span className="px-2 text-[9.5px] font-bold uppercase tracking-[0.09em] text-muted-foreground">
|
|
190
|
+
{labels.mostNamed}
|
|
191
|
+
</span>
|
|
192
|
+
|
|
193
|
+
{/* MenuItem's own `px-4`: its colour mark is drawn at `-ml-4` and
|
|
194
|
+
needs that gutter — a tighter padding clipped it to a grey stub. */}
|
|
195
|
+
<MenuItem
|
|
196
|
+
className="min-h-0"
|
|
197
|
+
item={family.signature.item}
|
|
198
|
+
labels={itemLabels}
|
|
199
|
+
formatPrice={formatPrice}
|
|
200
|
+
formatCount={formatCount}
|
|
201
|
+
markGradient={hue}
|
|
202
|
+
subtitle={family.signature.why}
|
|
203
|
+
href={family.signature.href ?? href}
|
|
204
|
+
linkComponent={LinkComponent}
|
|
205
|
+
/>
|
|
206
|
+
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuField } from "./menu-field";
|
|
3
|
+
import { mockGradients } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MenuField> = {
|
|
6
|
+
title: "Menu/MenuField",
|
|
7
|
+
component: MenuField,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The photoless cover for a dish or drink: the item's own name repeating across a shallow diagonal over a gradient. Menu-item photography is AI-generated and never shown, so the type IS the cover. Rows fade symmetrically out from one strong centre line, which is what keeps them reading as texture in the colour rather than as a watermark stamped on top of it. The gradient is injected because resolving a category to a hue needs app-owned enums.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof MenuField>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
args: {
|
|
24
|
+
name: "Truffle Tagliatelle",
|
|
25
|
+
gradient: mockGradients.pasta,
|
|
26
|
+
className: "h-[200px] w-[260px] rounded-xl",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A long name repeats fewer times per line, so the texture opens up. */
|
|
31
|
+
export const LongName: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
name: "Burrata & heirloom tomato with basil oil",
|
|
34
|
+
gradient: mockGradients.greens,
|
|
35
|
+
className: "h-[200px] w-[260px] rounded-xl",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Short names tile densely — the falloff is what stops that reading as noise. */
|
|
40
|
+
export const ShortName: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
name: "Mici",
|
|
43
|
+
gradient: mockGradients.meat,
|
|
44
|
+
className: "h-[200px] w-[260px] rounded-xl",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `seedKey` fixes the horizontal stagger. Two cards for the same concept at
|
|
50
|
+
* different venues break identically; pass the item slug and they diverge.
|
|
51
|
+
*/
|
|
52
|
+
export const EveryHue: Story = {
|
|
53
|
+
render: () => (
|
|
54
|
+
<div className="flex flex-wrap gap-3">
|
|
55
|
+
{Object.entries(mockGradients).map(([name, gradient]) => (
|
|
56
|
+
<MenuField
|
|
57
|
+
key={name}
|
|
58
|
+
name={name}
|
|
59
|
+
gradient={gradient}
|
|
60
|
+
className="h-[120px] w-[150px] rounded-xl"
|
|
61
|
+
/>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** At tile scale the type has to survive being small. This is the real size. */
|
|
68
|
+
export const AtTileScale: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
name: "Chicken wings, 6 pc",
|
|
71
|
+
gradient: mockGradients.meat,
|
|
72
|
+
className: "aspect-[5/4] w-[180px] rounded-xl",
|
|
73
|
+
},
|
|
74
|
+
};
|