@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,77 @@
|
|
|
1
|
+
import { Badge } from "./badge";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "Signature" / "Mentioned" — the marker for a menu item the review digest
|
|
6
|
+
* backs, shared by `<MenuItem>` and the dish/drink cards so the same claim
|
|
7
|
+
* never renders two different ways.
|
|
8
|
+
*
|
|
9
|
+
* When `onEvidence` is supplied the chip becomes a button that opens the
|
|
10
|
+
* reviews behind it, and takes a dashed outline to read as interactive. That is
|
|
11
|
+
* the whole differentiator: every competitor badges items ("Nou", "NEW"), none
|
|
12
|
+
* can show you why. Without evidence it degrades to a plain label rather than
|
|
13
|
+
* offering a tap that would reveal nothing.
|
|
14
|
+
*
|
|
15
|
+
* `stopPropagation` matters on every host: the row is a `role="button"` and the
|
|
16
|
+
* cards carry a stretched crawlable `<a>` overlay, so an un-stopped click would
|
|
17
|
+
* also open the item drawer. On the cards it must additionally out-rank that
|
|
18
|
+
* overlay's `z-[1]`, hence `relative z-[2]` from the caller.
|
|
19
|
+
*
|
|
20
|
+
* `surface="image"` is for card covers, where the chip sits over the photo
|
|
21
|
+
* rather than in the caption block. The accent-on-canvas palette has no
|
|
22
|
+
* contrast guarantee over an arbitrary photo, so it swaps to the same frosted
|
|
23
|
+
* fill the place badge uses there and keeps the dashed edge — which is what
|
|
24
|
+
* says "there is evidence behind this" — in the inverted colour.
|
|
25
|
+
*
|
|
26
|
+
* The label is passed in: the DS owns no translation keys.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type SignatureChipProps = {
|
|
30
|
+
/** Already translated. `strong` → "Signature", `weak` → "Mentioned". */
|
|
31
|
+
label: string;
|
|
32
|
+
onEvidence?: () => void;
|
|
33
|
+
/** `image` = over a card cover; `canvas` = in a caption or row. */
|
|
34
|
+
surface?: "canvas" | "image";
|
|
35
|
+
className?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function SignatureChip({
|
|
39
|
+
label,
|
|
40
|
+
onEvidence,
|
|
41
|
+
surface = "canvas",
|
|
42
|
+
className,
|
|
43
|
+
}: SignatureChipProps) {
|
|
44
|
+
const shared = "h-4 shrink-0 px-1.5 text-[10px] font-medium uppercase tracking-wider";
|
|
45
|
+
const onImage = surface === "image";
|
|
46
|
+
|
|
47
|
+
if (!onEvidence) {
|
|
48
|
+
return (
|
|
49
|
+
<Badge variant={onImage ? "overlay" : "secondary"} className={cn(shared, className)}>
|
|
50
|
+
{label}
|
|
51
|
+
</Badge>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={(event) => {
|
|
59
|
+
event.stopPropagation();
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
onEvidence();
|
|
62
|
+
}}
|
|
63
|
+
className={cn(
|
|
64
|
+
shared,
|
|
65
|
+
"inline-flex items-center rounded-full border border-dashed",
|
|
66
|
+
onImage
|
|
67
|
+
? "border-background/70 bg-foreground/70 text-background backdrop-blur-sm hover:bg-foreground/85"
|
|
68
|
+
: "border-accent/40 text-accent hover:bg-accent/10",
|
|
69
|
+
"focus-visible:outline-none focus-visible:ring-2",
|
|
70
|
+
onImage ? "focus-visible:ring-background/70" : "focus-visible:ring-accent/40",
|
|
71
|
+
className
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{label}
|
|
75
|
+
</button>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { mockHubLabels, mockVenues } from "./menu-mocks";
|
|
3
|
+
import { SignatureLineList } from "./signature-line-list";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const itemLabels = {
|
|
7
|
+
currency: "lei",
|
|
8
|
+
mentions: (count: number) => `${count} mentions`,
|
|
9
|
+
signature: "SIGNATURE",
|
|
10
|
+
mentioned: "MENTIONED",
|
|
11
|
+
};
|
|
12
|
+
const formatPrice = (amount: number) => String(amount);
|
|
13
|
+
|
|
14
|
+
const labels = itemLabels;
|
|
15
|
+
|
|
16
|
+
const metaLabels = {
|
|
17
|
+
"the-copper-yard": "pub · Centru",
|
|
18
|
+
"salt-and-stone": "italian · Centru",
|
|
19
|
+
"the-quiet-hours": "coffee · Centru",
|
|
20
|
+
"green-room-canteen": "vegetarian · Mărăști",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const hues = {
|
|
24
|
+
"the-copper-yard": "oklch(0.52 0.15 25)",
|
|
25
|
+
"salt-and-stone": "oklch(0.62 0.14 55)",
|
|
26
|
+
"the-quiet-hours": "oklch(0.58 0.09 70)",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
const meta: Meta<typeof SignatureLineList> = {
|
|
31
|
+
title: "Menu/SignatureLineList",
|
|
32
|
+
component: SignatureLineList,
|
|
33
|
+
tags: ["autodocs"],
|
|
34
|
+
decorators: [
|
|
35
|
+
(Story) => (
|
|
36
|
+
<div className="w-[980px] bg-background p-4">
|
|
37
|
+
<Story />
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
],
|
|
41
|
+
parameters: {
|
|
42
|
+
docs: {
|
|
43
|
+
description: {
|
|
44
|
+
component:
|
|
45
|
+
"Family, venue, dish, price and reputation on one line — the densest surface on the hub, and the one that most nearly justifies the whole catalogue. Two targets per row on purpose: the venue line goes to the venue, the item row goes to that item across every kitchen that makes it. Collapsing them into one tap loses the comparison that makes the item worth showing. They are separate anchors rather than a button nested in a link, which would be invalid.",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default meta;
|
|
52
|
+
type Story = StoryObj<typeof SignatureLineList>;
|
|
53
|
+
|
|
54
|
+
export const Default: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
venues: mockVenues.slice(0, 3),
|
|
57
|
+
labels,
|
|
58
|
+
formatPrice,
|
|
59
|
+
metaLabels,
|
|
60
|
+
hues,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** The last venue has no signature — the row is a venue line and nothing else. */
|
|
65
|
+
export const MixedEvidence: Story = {
|
|
66
|
+
args: { venues: mockVenues, labels, formatPrice, metaLabels, hues },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Below `lg` the row stacks: venue line, then the full item row underneath. */
|
|
70
|
+
export const Mobile: Story = {
|
|
71
|
+
args: {
|
|
72
|
+
venues: mockVenues.slice(0, 3),
|
|
73
|
+
labels,
|
|
74
|
+
formatPrice,
|
|
75
|
+
metaLabels,
|
|
76
|
+
hues,
|
|
77
|
+
},
|
|
78
|
+
decorators: [
|
|
79
|
+
(Story) => (
|
|
80
|
+
<div className="w-[390px] bg-background p-3">
|
|
81
|
+
<Story />
|
|
82
|
+
</div>
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { ChevronRight, Star } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Separator } from "./separator";
|
|
4
|
+
import { MenuItem, type MenuItemLabels } from "./menu-item";
|
|
5
|
+
import type { MenuVenuePreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SignatureLineList — family, venue, dish, price and reputation on one line.
|
|
9
|
+
*
|
|
10
|
+
* The densest surface on the hub, and the one that most nearly justifies the
|
|
11
|
+
* whole catalogue: every row ties a kitchen to the single thing its reviewers
|
|
12
|
+
* keep naming, with the price and the count that back it.
|
|
13
|
+
*
|
|
14
|
+
* ## Two targets per row, deliberately
|
|
15
|
+
*
|
|
16
|
+
* The venue line goes to the venue; the item row goes to the item across every
|
|
17
|
+
* kitchen that makes it. Those are genuinely different destinations, and
|
|
18
|
+
* collapsing them into one tap loses the comparison that makes the item worth
|
|
19
|
+
* showing. They are separate anchors rather than one anchor with a nested
|
|
20
|
+
* button, which would be invalid markup.
|
|
21
|
+
*
|
|
22
|
+
* Below `lg` the row stacks: venue line first, then the full `<MenuItem>`
|
|
23
|
+
* underneath. The desktop layout puts them side by side. Same markup, one
|
|
24
|
+
* breakpoint — no second component.
|
|
25
|
+
*
|
|
26
|
+
* Server-safe.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
30
|
+
export type SignatureLineListLinkProps = MenuLinkProps;
|
|
31
|
+
|
|
32
|
+
function DefaultLink({ href, ...props }: SignatureLineListLinkProps) {
|
|
33
|
+
return <a href={href} {...props} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SignatureLineListProps = {
|
|
37
|
+
venues: MenuVenuePreview[];
|
|
38
|
+
/** Passed through to each `<MenuItem>`. */
|
|
39
|
+
labels: MenuItemLabels;
|
|
40
|
+
formatPrice: (amount: number) => string;
|
|
41
|
+
formatCount?: (value: number) => string;
|
|
42
|
+
/** Composed meta line per venue, keyed by place slug, e.g. "romanian · Centru". */
|
|
43
|
+
metaLabels?: Record<string, string>;
|
|
44
|
+
/** The row's category mark per venue, keyed by place slug. */
|
|
45
|
+
hues?: Record<string, string>;
|
|
46
|
+
linkComponent?: MenuLinkComponent;
|
|
47
|
+
className?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function SignatureLineList({
|
|
51
|
+
venues,
|
|
52
|
+
labels,
|
|
53
|
+
formatPrice,
|
|
54
|
+
formatCount,
|
|
55
|
+
metaLabels,
|
|
56
|
+
hues,
|
|
57
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
58
|
+
className,
|
|
59
|
+
}: SignatureLineListProps) {
|
|
60
|
+
return (
|
|
61
|
+
<ul className={cn("flex flex-col", className)}>
|
|
62
|
+
{venues.map((venue, index) => {
|
|
63
|
+
const { place, signature } = venue;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<li
|
|
67
|
+
key={place.slug}
|
|
68
|
+
className="grid gap-x-4 py-3.5 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)] lg:items-center"
|
|
69
|
+
>
|
|
70
|
+
{/* The DS rule between rows, spanning both grid columns. */}
|
|
71
|
+
{index > 0 && <Separator className="mb-3.5 lg:col-span-2" />}
|
|
72
|
+
<LinkComponent
|
|
73
|
+
href={venue.href}
|
|
74
|
+
className="flex items-center gap-3 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
75
|
+
>
|
|
76
|
+
<span className="relative h-12 w-12 shrink-0 overflow-hidden rounded-xl bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)] lg:h-16 lg:w-16">
|
|
77
|
+
{place.thumbnail_url && (
|
|
78
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
79
|
+
<img
|
|
80
|
+
src={place.thumbnail_url}
|
|
81
|
+
alt=""
|
|
82
|
+
className="h-full w-full object-cover"
|
|
83
|
+
loading="lazy"
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
{place.open_now && (
|
|
87
|
+
<i
|
|
88
|
+
aria-hidden
|
|
89
|
+
className="absolute right-1 top-1 h-[7px] w-[7px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</span>
|
|
93
|
+
|
|
94
|
+
<span className="min-w-0 flex-1">
|
|
95
|
+
<span className="block truncate text-[15px] font-bold tracking-[-0.01em]">
|
|
96
|
+
{place.name}
|
|
97
|
+
</span>
|
|
98
|
+
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
99
|
+
{place.rating && (
|
|
100
|
+
<>
|
|
101
|
+
<Star className="h-[11px] w-[11px]" aria-hidden />
|
|
102
|
+
<span className="tabular-nums">{place.rating.rating}</span>
|
|
103
|
+
</>
|
|
104
|
+
)}
|
|
105
|
+
{metaLabels?.[place.slug] && (
|
|
106
|
+
<span className="truncate">{metaLabels[place.slug]}</span>
|
|
107
|
+
)}
|
|
108
|
+
</span>
|
|
109
|
+
</span>
|
|
110
|
+
|
|
111
|
+
<ChevronRight
|
|
112
|
+
className="h-4 w-4 shrink-0 text-muted-foreground lg:hidden"
|
|
113
|
+
aria-hidden
|
|
114
|
+
/>
|
|
115
|
+
</LinkComponent>
|
|
116
|
+
|
|
117
|
+
{signature && (
|
|
118
|
+
<MenuItem
|
|
119
|
+
item={signature.item}
|
|
120
|
+
labels={labels}
|
|
121
|
+
formatPrice={formatPrice}
|
|
122
|
+
formatCount={formatCount}
|
|
123
|
+
markGradient={hues?.[place.slug]}
|
|
124
|
+
subtitle={signature.why}
|
|
125
|
+
showSignatureBadge={signature.isSignature}
|
|
126
|
+
href={signature.href}
|
|
127
|
+
linkComponent={LinkComponent}
|
|
128
|
+
className="min-w-0"
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</ul>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -32,6 +32,8 @@ const snapRailVariants = cva(
|
|
|
32
32
|
sm: "[&>*]:w-[46%]",
|
|
33
33
|
md: "[&>*]:w-[60%]",
|
|
34
34
|
lg: "[&>*]:w-[80%]",
|
|
35
|
+
/** One card per view — pair with `dots` so the reader knows there is more. */
|
|
36
|
+
full: "[&>*]:w-full",
|
|
35
37
|
},
|
|
36
38
|
gap: {
|
|
37
39
|
sm: "gap-2",
|
|
@@ -52,16 +54,95 @@ const snapRailVariants = cva(
|
|
|
52
54
|
);
|
|
53
55
|
|
|
54
56
|
export type SnapRailProps = React.HTMLAttributes<HTMLDivElement> &
|
|
55
|
-
VariantProps<typeof snapRailVariants
|
|
57
|
+
VariantProps<typeof snapRailVariants> & {
|
|
58
|
+
/**
|
|
59
|
+
* Page dots under the rail, one per child, the current one filled. For
|
|
60
|
+
* `itemWidth="full"`, where no next card peeks in to say there is more.
|
|
61
|
+
* Decorative: the rail itself is the control, and every card is reachable
|
|
62
|
+
* by scrolling, so the dots are `aria-hidden`.
|
|
63
|
+
*/
|
|
64
|
+
dots?: boolean;
|
|
65
|
+
/** Classes for the dots row, e.g. `lg:hidden` where the rail becomes a grid. */
|
|
66
|
+
dotsClassName?: string;
|
|
67
|
+
};
|
|
56
68
|
|
|
57
69
|
export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
|
|
58
|
-
({ className, itemWidth, gap, bleed, ...props }, ref) =>
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
({ className, itemWidth, gap, bleed, dots, dotsClassName, children, ...props }, ref) => {
|
|
71
|
+
const railRef = React.useRef<HTMLDivElement | null>(null);
|
|
72
|
+
const [active, setActive] = React.useState(0);
|
|
73
|
+
const count = React.Children.count(children);
|
|
74
|
+
|
|
75
|
+
const setRefs = React.useCallback(
|
|
76
|
+
(node: HTMLDivElement | null) => {
|
|
77
|
+
railRef.current = node;
|
|
78
|
+
if (typeof ref === "function") ref(node);
|
|
79
|
+
else if (ref) ref.current = node;
|
|
80
|
+
},
|
|
81
|
+
[ref]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// The child whose left edge is nearest the rail's scroll-padding edge is
|
|
85
|
+
// the current page. Read on scroll, rAF-throttled; passive so it never
|
|
86
|
+
// blocks the swipe it watches.
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
if (!dots) return;
|
|
89
|
+
const rail = railRef.current;
|
|
90
|
+
if (!rail) return;
|
|
91
|
+
let frame = 0;
|
|
92
|
+
const sync = () => {
|
|
93
|
+
frame = 0;
|
|
94
|
+
const origin = rail.scrollLeft + parseFloat(getComputedStyle(rail).scrollPaddingLeft || "0");
|
|
95
|
+
let best = 0;
|
|
96
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
97
|
+
Array.from(rail.children).forEach((child, index) => {
|
|
98
|
+
const distance = Math.abs((child as HTMLElement).offsetLeft - origin);
|
|
99
|
+
if (distance < bestDistance) {
|
|
100
|
+
bestDistance = distance;
|
|
101
|
+
best = index;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
setActive(best);
|
|
105
|
+
};
|
|
106
|
+
const onScroll = () => {
|
|
107
|
+
if (!frame) frame = requestAnimationFrame(sync);
|
|
108
|
+
};
|
|
109
|
+
sync();
|
|
110
|
+
rail.addEventListener("scroll", onScroll, { passive: true });
|
|
111
|
+
return () => {
|
|
112
|
+
if (frame) cancelAnimationFrame(frame);
|
|
113
|
+
rail.removeEventListener("scroll", onScroll);
|
|
114
|
+
};
|
|
115
|
+
}, [dots, count]);
|
|
116
|
+
|
|
117
|
+
const rail = (
|
|
118
|
+
<div
|
|
119
|
+
ref={setRefs}
|
|
120
|
+
data-slot="snap-rail"
|
|
121
|
+
className={cn(snapRailVariants({ itemWidth, gap, bleed }), className)}
|
|
122
|
+
{...props}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (!dots) return rail;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className="flex flex-col gap-3">
|
|
132
|
+
{rail}
|
|
133
|
+
<div aria-hidden className={cn("flex justify-center gap-1.5", dotsClassName)}>
|
|
134
|
+
{Array.from({ length: count }, (_, index) => (
|
|
135
|
+
<span
|
|
136
|
+
key={index}
|
|
137
|
+
className={cn(
|
|
138
|
+
"h-1.5 rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none",
|
|
139
|
+
index === active ? "w-4 bg-foreground" : "w-1.5 bg-foreground/25"
|
|
140
|
+
)}
|
|
141
|
+
/>
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
66
147
|
);
|
|
67
148
|
SnapRail.displayName = "SnapRail";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ArrowRight } from "lucide-react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { StatBanner } from "./stat-banner";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof StatBanner> = {
|
|
7
|
+
title: "Menu/StatBanner",
|
|
8
|
+
component: StatBanner,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="w-[520px] bg-background p-4">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
parameters: {
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"One claim about the catalogue, carried by a single number. These sit between the modules that list things and give the page somewhere to STATE what it knows rather than only showing it. Two side by side is the usual arrangement, which is a caller's grid rather than a `StatBannerPair` component — pairing is layout, and a consumer wanting one, three, or a different order should not have to fight a wrapper. Pass `scale` only when all three values come from the same real measurement.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof StatBanner>;
|
|
29
|
+
|
|
30
|
+
export const Dark: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
tone: "dark",
|
|
33
|
+
kicker: "ONE DISH, A DOZEN NAMES",
|
|
34
|
+
headline: "“Margherita” appears on eighty-seven menus and never twice the same way.",
|
|
35
|
+
body: "We match them anyway, so the price comparison is real.",
|
|
36
|
+
watermark: "12",
|
|
37
|
+
action: (
|
|
38
|
+
<Button size="sm" variant="secondary">
|
|
39
|
+
How concepts work
|
|
40
|
+
</Button>
|
|
41
|
+
),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Warm: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
tone: "warm",
|
|
48
|
+
kicker: "THE REVIEW CENSUS",
|
|
49
|
+
figure: "100",
|
|
50
|
+
figureUnit: "%",
|
|
51
|
+
body: "Every one of the 45,140 items carries a mention count read from the reviews. The typical dish is named twelve times; the most-named, sixty-eight.",
|
|
52
|
+
scale: ["1", "12", "68"],
|
|
53
|
+
action: (
|
|
54
|
+
<Button size="sm" variant="ghost">
|
|
55
|
+
How the census works
|
|
56
|
+
<ArrowRight className="h-3.5 w-3.5" />
|
|
57
|
+
</Button>
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The pair, as the hub arranges it — a caller's grid, not a component. */
|
|
63
|
+
export const AsAPair: Story = {
|
|
64
|
+
render: () => (
|
|
65
|
+
<div className="grid gap-5 lg:grid-cols-2">
|
|
66
|
+
<StatBanner
|
|
67
|
+
tone="dark"
|
|
68
|
+
kicker="ONE DISH, A DOZEN NAMES"
|
|
69
|
+
headline="Eighty-seven menus, a dozen names."
|
|
70
|
+
watermark="12"
|
|
71
|
+
/>
|
|
72
|
+
<StatBanner
|
|
73
|
+
tone="warm"
|
|
74
|
+
kicker="THE REVIEW CENSUS"
|
|
75
|
+
figure="100"
|
|
76
|
+
figureUnit="%"
|
|
77
|
+
body="A mention count on every item in the catalogue."
|
|
78
|
+
scale={["1", "12", "68"]}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
),
|
|
82
|
+
decorators: [
|
|
83
|
+
(Story) => (
|
|
84
|
+
<div className="w-[1000px] bg-background p-4">
|
|
85
|
+
<Story />
|
|
86
|
+
</div>
|
|
87
|
+
),
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** No scale — because nothing counted the distribution. */
|
|
92
|
+
export const WithoutScale: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
tone: "warm",
|
|
95
|
+
kicker: "THE WIDEST SPREAD WE FOUND",
|
|
96
|
+
figure: "4.99",
|
|
97
|
+
figureUnit: "— 30 lei",
|
|
98
|
+
body: "The same espresso, across the ninety-eight bars and cafés that pour one.",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* StatBanner — one claim about the catalogue, carried by a single number.
|
|
6
|
+
*
|
|
7
|
+
* These sit between the modules that list things and give the page somewhere to
|
|
8
|
+
* state what it knows rather than only showing it. Two side by side is the
|
|
9
|
+
* usual arrangement, which is a caller's grid rather than a `StatBannerPair`
|
|
10
|
+
* component — pairing is layout, and a consumer wanting one, or three, or a
|
|
11
|
+
* different order should not have to fight a wrapper for it.
|
|
12
|
+
*
|
|
13
|
+
* `scale` draws the distribution the figure sits in — low, the figure, high.
|
|
14
|
+
* Pass it only when all three are real numbers from the same measurement; a
|
|
15
|
+
* scale invented around a figure is worse than no scale, because it implies a
|
|
16
|
+
* spread nobody counted.
|
|
17
|
+
*
|
|
18
|
+
* Server-safe.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const bannerVariants = cva(
|
|
22
|
+
"relative flex flex-col gap-2 overflow-hidden rounded-[18px] p-5",
|
|
23
|
+
{
|
|
24
|
+
variants: {
|
|
25
|
+
tone: {
|
|
26
|
+
/** Dark ground, for the claim that needs the most weight. */
|
|
27
|
+
dark: "bg-[oklch(0.21_0.012_55)] text-white",
|
|
28
|
+
/** Tinted ground, for the supporting figure beside it. */
|
|
29
|
+
warm: "bg-accent-subtle text-foreground",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: { tone: "dark" },
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export type StatBannerProps = VariantProps<typeof bannerVariants> & {
|
|
37
|
+
/** Small caps kicker, e.g. "THE REVIEW CENSUS". */
|
|
38
|
+
kicker?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The headline claim. On the dark tone this is the whole point; on the warm
|
|
41
|
+
* one it usually sits under `figure`.
|
|
42
|
+
*/
|
|
43
|
+
headline?: string;
|
|
44
|
+
body?: string;
|
|
45
|
+
/** The number itself, pre-formatted, e.g. "100". */
|
|
46
|
+
figure?: string;
|
|
47
|
+
/** Unit set small beside the figure, e.g. "%" or "lei". */
|
|
48
|
+
figureUnit?: string;
|
|
49
|
+
/** Oversized watermark numeral behind the content. */
|
|
50
|
+
watermark?: string;
|
|
51
|
+
/** Low, middle and high of the distribution. All three, or none. */
|
|
52
|
+
scale?: [string, string, string];
|
|
53
|
+
/** The action — pass a rendered link or button. */
|
|
54
|
+
action?: React.ReactNode;
|
|
55
|
+
className?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function StatBanner({
|
|
59
|
+
tone,
|
|
60
|
+
kicker,
|
|
61
|
+
headline,
|
|
62
|
+
body,
|
|
63
|
+
figure,
|
|
64
|
+
figureUnit,
|
|
65
|
+
watermark,
|
|
66
|
+
scale,
|
|
67
|
+
action,
|
|
68
|
+
className,
|
|
69
|
+
}: StatBannerProps) {
|
|
70
|
+
return (
|
|
71
|
+
<div className={cn(bannerVariants({ tone }), className)}>
|
|
72
|
+
{watermark && (
|
|
73
|
+
<span
|
|
74
|
+
aria-hidden
|
|
75
|
+
className="pointer-events-none absolute -bottom-8 right-2 select-none font-serif text-[150px] font-bold leading-none opacity-[0.08]"
|
|
76
|
+
>
|
|
77
|
+
{watermark}
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div className="relative z-[1] flex flex-1 flex-col gap-2">
|
|
82
|
+
{kicker && (
|
|
83
|
+
<span className="text-[10px] font-bold tracking-[0.11em] opacity-80">{kicker}</span>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{figure && (
|
|
87
|
+
<div className="text-[40px] font-extrabold leading-none tracking-[-0.03em] tabular-nums">
|
|
88
|
+
{figure}
|
|
89
|
+
{figureUnit && (
|
|
90
|
+
<span className="ml-1 text-lg font-extrabold">{figureUnit}</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{headline && (
|
|
96
|
+
<h3 className="font-serif text-xl font-semibold leading-[1.2] tracking-[-0.02em] lg:text-2xl">
|
|
97
|
+
{headline}
|
|
98
|
+
</h3>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{body && <p className="text-[13px] leading-[1.5] opacity-85">{body}</p>}
|
|
102
|
+
|
|
103
|
+
{scale && (
|
|
104
|
+
<div className="mt-1 flex items-center gap-2 text-[11px] tabular-nums opacity-70">
|
|
105
|
+
<span>{scale[0]}</span>
|
|
106
|
+
<span aria-hidden className="h-px flex-1 bg-current opacity-30" />
|
|
107
|
+
<span>{scale[1]}</span>
|
|
108
|
+
<span aria-hidden className="h-px flex-1 bg-current opacity-30" />
|
|
109
|
+
<span>{scale[2]}</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{action && <div className="mt-auto flex items-center pt-3">{action}</div>}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { mockPlaces } from "./menu-mocks";
|
|
3
|
+
import { VenueBusynessStrip } from "./venue-busyness-strip";
|
|
4
|
+
|
|
5
|
+
const busyness = mockPlaces[0].busyness!;
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof VenueBusynessStrip> = {
|
|
8
|
+
title: "Menu/VenueBusynessStrip",
|
|
9
|
+
component: VenueBusynessStrip,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
decorators: [
|
|
12
|
+
(Story) => (
|
|
13
|
+
<div className="w-[560px] bg-background p-4">
|
|
14
|
+
<Story />
|
|
15
|
+
</div>
|
|
16
|
+
),
|
|
17
|
+
],
|
|
18
|
+
parameters: {
|
|
19
|
+
docs: {
|
|
20
|
+
description: {
|
|
21
|
+
component:
|
|
22
|
+
"When a venue actually fills up, as a bar per hour. The API serves one score per hour of the venue's OWN trading day, so the strip has no fixed length — a bakery closing at three and a club open until four draw different numbers of bars, and padding either to 24 would put empty columns under hours the venue is shut. The rush is derived relative to that venue's own peak, because a fixed score threshold would mark every hour at a busy venue and none at a quiet one.",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
type Story = StoryObj<typeof VenueBusynessStrip>;
|
|
30
|
+
|
|
31
|
+
export const Default: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
busyness,
|
|
34
|
+
caption: "Usually not busy until 18:00 — the rush is seven to eight.",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** No caption: the bars alone, for a tight row. */
|
|
39
|
+
export const BarsOnly: Story = { args: { busyness } };
|
|
40
|
+
|
|
41
|
+
/** A short trading day draws a short strip rather than padding to 24. */
|
|
42
|
+
export const ShortDay: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
busyness: [
|
|
45
|
+
{ hour: 8, score: 40 },
|
|
46
|
+
{ hour: 9, score: 72 },
|
|
47
|
+
{ hour: 10, score: 88 },
|
|
48
|
+
{ hour: 11, score: 60 },
|
|
49
|
+
{ hour: 12, score: 34 },
|
|
50
|
+
],
|
|
51
|
+
caption: "Busiest first thing — quiet by noon.",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** A flat day: `peakRatio` still finds the top of it, because it is relative. */
|
|
56
|
+
export const Flat: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
busyness: Array.from({ length: 10 }, (_, index) => ({ hour: 12 + index, score: 30 })),
|
|
59
|
+
caption: "Steady all day.",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** All zeros — no reading. The strip renders nothing rather than a flat line. */
|
|
64
|
+
export const NoReading: Story = {
|
|
65
|
+
args: { busyness: Array.from({ length: 8 }, (_, index) => ({ hour: index, score: 0 })) },
|
|
66
|
+
};
|