@bearmenu/ui 0.8.7 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-637L7XQ3.js → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-AXV5O2IZ.js +58 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-THOVPZNN.js +37 -0
- package/dist/chunk-YS6BHKZO.js +92 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-compare.d.ts +73 -0
- package/dist/components/concept-compare.js +162 -0
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +64 -34
- package/dist/components/event-compact-row.d.ts +33 -6
- package/dist/components/event-compact-row.js +120 -39
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +471 -174
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +79 -35
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +20 -0
- package/dist/components/menu-family-card.d.ts +59 -0
- package/dist/components/menu-family-card.js +121 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +3 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- package/dist/components/menu-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- package/dist/components/menu-mocks.d.ts +55 -0
- package/dist/components/menu-mocks.js +379 -0
- package/dist/components/menu-types.d.ts +245 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-set.js +15 -3
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/ranked-venue-list.d.ts +49 -0
- package/dist/components/ranked-venue-list.js +87 -0
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/signature-line-list.d.ts +45 -0
- package/dist/components/signature-line-list.js +99 -0
- package/dist/components/snap-rail.d.ts +54 -0
- package/dist/components/snap-rail.js +109 -0
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/venue-busyness-strip.d.ts +35 -0
- package/dist/components/venue-busyness-strip.js +37 -0
- package/dist/components/venue-grade-dimensions.d.ts +36 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-signature-block.d.ts +39 -0
- package/dist/components/venue-signature-block.js +6 -0
- package/dist/components/venue-spotlight.d.ts +74 -0
- package/dist/components/venue-spotlight.js +138 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare.stories.tsx +104 -0
- package/src/components/concept-compare.tsx +276 -0
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +162 -38
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +40 -0
- package/src/components/menu-family-card.stories.tsx +112 -0
- package/src/components/menu-family-card.tsx +211 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- package/src/components/menu-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -0
- package/src/components/menu-mocks.ts +417 -0
- package/src/components/menu-types.ts +259 -0
- package/src/components/motion-icon-set.tsx +30 -3
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/ranked-venue-list.stories.tsx +80 -0
- package/src/components/ranked-venue-list.tsx +177 -0
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/signature-line-list.stories.tsx +85 -0
- package/src/components/signature-line-list.tsx +136 -0
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +148 -0
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/venue-busyness-strip.stories.tsx +66 -0
- package/src/components/venue-busyness-strip.tsx +64 -0
- package/src/components/venue-grade-dimensions.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-signature-block.stories.tsx +68 -0
- package/src/components/venue-signature-block.tsx +71 -0
- package/src/components/venue-spotlight.stories.tsx +141 -0
- package/src/components/venue-spotlight.tsx +236 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- package/dist/chunk-GPH6L4DX.js +0 -55
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { Radio } from "lucide-react";
|
|
4
5
|
import { cn } from "../lib/utils";
|
|
5
6
|
import { Separator } from "./separator";
|
|
6
7
|
import { Skeleton } from "./skeleton";
|
|
@@ -45,14 +46,23 @@ import { Skeleton } from "./skeleton";
|
|
|
45
46
|
*/
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
* Ring pulse on the live puck — inherited from
|
|
49
|
+
* Ring pulse on the live puck — inherited from an earlier `TimelinePlayhead`,
|
|
49
50
|
* which carried this surface's only motion. Kept at its 2.4s `ease-in-out` and
|
|
50
|
-
* its reduced-motion kill switch, but BOX-SHADOW ONLY:
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* its reduced-motion kill switch, but BOX-SHADOW ONLY: a `transform: scale`
|
|
52
|
+
* would make the hairline visibly breathe here, because this puck sits ON the
|
|
53
|
+
* rail. (Today's `timeline-playhead.tsx` is static and sits between bands.)
|
|
53
54
|
*/
|
|
54
55
|
const LIVE_PUCK_PULSE_CLASS = "bm-timeline-live-puck-pulse";
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The mobile axis gutter, shared by `HourBand` and `TimelinePlayhead` so the
|
|
59
|
+
* hour chips and the current-time label sit in one column. 58px is the width
|
|
60
|
+
* of a five-glyph 24-hour label ("20:00") in the chip's mono type plus its
|
|
61
|
+
* padding; a 12-hour locale's "09:00 AM" overflows it, which is one of the
|
|
62
|
+
* reasons the events surfaces format 24-hour everywhere.
|
|
63
|
+
*/
|
|
64
|
+
export const TIMELINE_MOBILE_GUTTER_CLASS = "w-[58px]";
|
|
65
|
+
|
|
56
66
|
export type TimelineHourPuckProps = {
|
|
57
67
|
/** The hour, already formatted and localised — or an em dash for a quiet day. */
|
|
58
68
|
time: string;
|
|
@@ -60,6 +70,16 @@ export type TimelineHourPuckProps = {
|
|
|
60
70
|
isLive?: boolean;
|
|
61
71
|
/** Shown under the puck when live, e.g. "NOW ON". Required to render it. */
|
|
62
72
|
liveLabel?: string;
|
|
73
|
+
/**
|
|
74
|
+
* `station` (default): the bordered puck that sits ON the hairline, for the
|
|
75
|
+
* desktop spine. `chip`: a filled, fixed-width lozenge with the hairline
|
|
76
|
+
* starting BELOW it, for the mobile gutter — an app's time label, not a
|
|
77
|
+
* station on a rail. In `chip` the live label is visually hidden: the tinted
|
|
78
|
+
* chip plus a small on-air glyph are the visible signal (the glyph so the
|
|
79
|
+
* state never rests on colour alone once the pulse is reduced away) and the
|
|
80
|
+
* label stays for screen readers only.
|
|
81
|
+
*/
|
|
82
|
+
variant?: "station" | "chip";
|
|
63
83
|
className?: string;
|
|
64
84
|
};
|
|
65
85
|
|
|
@@ -78,7 +98,15 @@ export type TimelineHourPuckProps = {
|
|
|
78
98
|
* centre this puck with `-translate-y-1/2`, and a two-item column would centre
|
|
79
99
|
* the STACK, lifting the puck off the line it exists to sit on.
|
|
80
100
|
*/
|
|
81
|
-
export function TimelineHourPuck({
|
|
101
|
+
export function TimelineHourPuck({
|
|
102
|
+
time,
|
|
103
|
+
isLive,
|
|
104
|
+
liveLabel,
|
|
105
|
+
variant = "station",
|
|
106
|
+
className,
|
|
107
|
+
}: TimelineHourPuckProps) {
|
|
108
|
+
const isChip = variant === "chip";
|
|
109
|
+
|
|
82
110
|
return (
|
|
83
111
|
<span className={cn("relative flex justify-center", className)}>
|
|
84
112
|
{isLive && (
|
|
@@ -97,20 +125,32 @@ export function TimelineHourPuck({ time, isLive, liveLabel, className }: Timelin
|
|
|
97
125
|
)}
|
|
98
126
|
<span
|
|
99
127
|
className={cn(
|
|
100
|
-
"whitespace-nowrap
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
128
|
+
"whitespace-nowrap font-mono tabular-nums",
|
|
129
|
+
isChip
|
|
130
|
+
? cn(
|
|
131
|
+
"inline-flex h-6 min-w-[58px] items-center justify-center gap-1 rounded-[7px] px-1 text-[12px] font-medium",
|
|
132
|
+
isLive ? cn("bg-success/10 text-success", LIVE_PUCK_PULSE_CLASS) : "bg-muted text-foreground"
|
|
133
|
+
)
|
|
134
|
+
: cn(
|
|
135
|
+
"rounded-[7px] border bg-background px-[7px] py-[3px] text-[12.5px] font-medium",
|
|
136
|
+
isLive
|
|
137
|
+
? cn("border-success/40 text-success", LIVE_PUCK_PULSE_CLASS)
|
|
138
|
+
: "border-border text-foreground"
|
|
139
|
+
)
|
|
105
140
|
)}
|
|
106
141
|
>
|
|
142
|
+
{isChip && isLive && <Radio className="h-2.5 w-2.5 shrink-0" aria-hidden />}
|
|
107
143
|
{time}
|
|
108
144
|
</span>
|
|
109
145
|
{isLive && !!liveLabel && (
|
|
110
146
|
<span
|
|
111
147
|
className={cn(
|
|
112
|
-
|
|
113
|
-
|
|
148
|
+
isChip
|
|
149
|
+
? "sr-only"
|
|
150
|
+
: cn(
|
|
151
|
+
"absolute top-[calc(100%+6px)] whitespace-nowrap bg-background px-1",
|
|
152
|
+
"text-[9.5px] font-extrabold tracking-[0.08em] text-success"
|
|
153
|
+
)
|
|
114
154
|
)}
|
|
115
155
|
>
|
|
116
156
|
{liveLabel}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { TimelinePlayhead } from "./timeline-playhead";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof TimelinePlayhead> = {
|
|
5
|
+
title: "Events/TimelinePlayhead",
|
|
6
|
+
component: TimelinePlayhead,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
decorators: [
|
|
9
|
+
(Story) => (
|
|
10
|
+
<div className="w-[390px] bg-background px-4">
|
|
11
|
+
<Story />
|
|
12
|
+
</div>
|
|
13
|
+
),
|
|
14
|
+
],
|
|
15
|
+
parameters: {
|
|
16
|
+
docs: {
|
|
17
|
+
description: {
|
|
18
|
+
component:
|
|
19
|
+
"The current-time mark on the mobile timeline. Rendered between hour bands where the wall clock falls; its time label shares the gutter with `HourBand` so it aligns under the hour chips.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof TimelinePlayhead>;
|
|
27
|
+
|
|
28
|
+
export const Default: Story = {
|
|
29
|
+
args: { time: "21:38", label: "NOW" },
|
|
30
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { TimelinePlayhead } from "./timeline-playhead";
|
|
4
|
+
|
|
5
|
+
describe("TimelinePlayhead", () => {
|
|
6
|
+
it("prints the time and the label, both as given", () => {
|
|
7
|
+
render(<TimelinePlayhead time="21:38" label="NOW" />);
|
|
8
|
+
expect(screen.getByText("21:38")).toBeInTheDocument();
|
|
9
|
+
expect(screen.getByText("NOW")).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { TIMELINE_MOBILE_GUTTER_CLASS } from "./timeline-band";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TimelinePlayhead — the "you are here" mark on the mobile timeline: the
|
|
9
|
+
* current time in the axis gutter, a dashed rule across the content column,
|
|
10
|
+
* and a filled NOW pill at the far edge.
|
|
11
|
+
*
|
|
12
|
+
* It sits BETWEEN hour bands, at the point where the wall clock falls, so the
|
|
13
|
+
* reader can see at a glance which hours are behind them and which are ahead.
|
|
14
|
+
* The live hour band's tinted chip says "this hour is running"; the playhead
|
|
15
|
+
* says exactly how far into it we are. Both are needed: a band spans an hour
|
|
16
|
+
* and the hour is mostly not "now".
|
|
17
|
+
*
|
|
18
|
+
* The time label shares the gutter width with `HourBand` so it lines up under
|
|
19
|
+
* the hour chips. Both strings arrive formatted — the DS holds no clock.
|
|
20
|
+
*/
|
|
21
|
+
export type TimelinePlayheadProps = {
|
|
22
|
+
/** The current time, already formatted — "21:38". */
|
|
23
|
+
time: string;
|
|
24
|
+
/** The pill text, e.g. "NOW". */
|
|
25
|
+
label: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function TimelinePlayhead({ time, label, className }: TimelinePlayheadProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div className={cn("flex items-center gap-2.5 pt-3", className)}>
|
|
32
|
+
<span
|
|
33
|
+
className={cn(
|
|
34
|
+
TIMELINE_MOBILE_GUTTER_CLASS,
|
|
35
|
+
"shrink-0 text-center font-mono text-[11px] font-bold tabular-nums text-success"
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{time}
|
|
39
|
+
</span>
|
|
40
|
+
<span aria-hidden className="h-0 flex-1 border-t-[1.5px] border-dashed border-success/50" />
|
|
41
|
+
<span className="rounded-full bg-success px-2 py-0.5 text-[9.5px] font-extrabold tracking-[0.1em] text-success-foreground">
|
|
42
|
+
{label}
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* VenueBusynessStrip — when a venue actually fills up, as a bar per hour.
|
|
5
|
+
*
|
|
6
|
+
* The API serves one score per hour of the venue's own trading day, so the
|
|
7
|
+
* strip has no fixed length: a bakery closing at three and a club open until
|
|
8
|
+
* four draw different numbers of bars, and padding either to 24 would put empty
|
|
9
|
+
* columns under hours the venue is not open.
|
|
10
|
+
*
|
|
11
|
+
* The rush is derived rather than passed: bars at or above `peakRatio` of the
|
|
12
|
+
* day's own maximum take the accent. A fixed score threshold would mark every
|
|
13
|
+
* hour at a busy venue and none at a quiet one — busyness is only meaningful
|
|
14
|
+
* relative to that venue's own day.
|
|
15
|
+
*
|
|
16
|
+
* `caption` is composed by the caller ("Usually not busy until 18:00 — the rush
|
|
17
|
+
* is nine to eleven"), because the sentence needs the venue's clock, its locale
|
|
18
|
+
* and a plural rule the DS does not own.
|
|
19
|
+
*
|
|
20
|
+
* Server-safe.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type VenueBusynessStripProps = {
|
|
24
|
+
busyness: Array<{ hour: number; score: number }>;
|
|
25
|
+
/** Composed sentence beside the bars. */
|
|
26
|
+
caption?: string;
|
|
27
|
+
/** Share of the day's own peak at which an hour counts as the rush. */
|
|
28
|
+
peakRatio?: number;
|
|
29
|
+
className?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const BAR_MAX_HEIGHT = 22;
|
|
33
|
+
|
|
34
|
+
export function VenueBusynessStrip({
|
|
35
|
+
busyness,
|
|
36
|
+
caption,
|
|
37
|
+
peakRatio = 0.8,
|
|
38
|
+
className,
|
|
39
|
+
}: VenueBusynessStripProps) {
|
|
40
|
+
if (busyness.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
const peak = Math.max(...busyness.map((entry) => entry.score));
|
|
43
|
+
if (peak <= 0) return null;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className={cn("flex items-center gap-2.5 text-[12.5px] text-muted-foreground", className)}
|
|
48
|
+
>
|
|
49
|
+
<span aria-hidden className="flex h-[22px] items-end gap-[3px]">
|
|
50
|
+
{busyness.map(({ hour, score }) => (
|
|
51
|
+
<i
|
|
52
|
+
key={hour}
|
|
53
|
+
className={cn(
|
|
54
|
+
"w-[7px] rounded-t-sm bg-[oklch(0.88_0.02_45)]",
|
|
55
|
+
score >= peak * peakRatio && "bg-[var(--accent,currentColor)]"
|
|
56
|
+
)}
|
|
57
|
+
style={{ height: Math.max(2, Math.round((score / peak) * BAR_MAX_HEIGHT)) }}
|
|
58
|
+
/>
|
|
59
|
+
))}
|
|
60
|
+
</span>
|
|
61
|
+
{caption}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { mockFormatters, mockPlaces } from "./menu-mocks";
|
|
3
|
+
import { VenueGradeDimensions } from "./venue-grade-dimensions";
|
|
4
|
+
|
|
5
|
+
const labels = {
|
|
6
|
+
food: "Food",
|
|
7
|
+
coffee_quality: "Coffee",
|
|
8
|
+
drinks_quality: "Drinks",
|
|
9
|
+
service: "Service",
|
|
10
|
+
value: "Value",
|
|
11
|
+
ambiance: "Ambiance",
|
|
12
|
+
portion: "Portion",
|
|
13
|
+
consistency: "Consistency",
|
|
14
|
+
cleanliness: "Cleanliness",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const meta: Meta<typeof VenueGradeDimensions> = {
|
|
18
|
+
title: "Menu/VenueGradeDimensions",
|
|
19
|
+
component: VenueGradeDimensions,
|
|
20
|
+
tags: ["autodocs"],
|
|
21
|
+
decorators: [
|
|
22
|
+
(Story) => (
|
|
23
|
+
<div className="w-[720px] bg-background p-4">
|
|
24
|
+
<Story />
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
parameters: {
|
|
29
|
+
docs: {
|
|
30
|
+
description: {
|
|
31
|
+
component:
|
|
32
|
+
"Per-dimension review scores as a row of bars. A dimension the reviews never mention is ABSENT from the payload, not zero, and exactly one of food / coffee_quality / drinks_quality is scored per place — so a component drawing a fixed row of seven would invent readings for a third of the catalogue. It iterates the canonical order, skips what is missing, and lays out however many survive. Scores arrive 0–100 and display over ten.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default meta;
|
|
39
|
+
type Story = StoryObj<typeof VenueGradeDimensions>;
|
|
40
|
+
|
|
41
|
+
export const Default: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
dimensions: mockPlaces[0].reviews_digest!.grade!.dimensions!,
|
|
44
|
+
labels,
|
|
45
|
+
formatScore: mockFormatters.score,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A coffee bar. It is graded on `coffee_quality` and has no food score at all —
|
|
51
|
+
* the row is four columns, not seven with a gap.
|
|
52
|
+
*/
|
|
53
|
+
export const CoffeeBar: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
dimensions: mockPlaces[2].reviews_digest!.grade!.dimensions!,
|
|
56
|
+
labels,
|
|
57
|
+
formatScore: mockFormatters.score,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Thin evidence: only two dimensions were mentioned often enough to score. */
|
|
62
|
+
export const Sparse: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
dimensions: { food: 78, value: 64 },
|
|
65
|
+
labels,
|
|
66
|
+
formatScore: mockFormatters.score,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** `order` narrows as well as reorders — here, quality and value only. */
|
|
71
|
+
export const Narrowed: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
dimensions: mockPlaces[0].reviews_digest!.grade!.dimensions!,
|
|
74
|
+
labels,
|
|
75
|
+
order: ["food", "value"],
|
|
76
|
+
formatScore: mockFormatters.score,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Nothing scored: the component renders nothing rather than an empty row. */
|
|
81
|
+
export const Empty: Story = {
|
|
82
|
+
args: { dimensions: {}, labels, formatScore: mockFormatters.score },
|
|
83
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import type { PlaceGradeDimensions } from "./place-types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* VenueGradeDimensions — the per-dimension review scores as a row of bars.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this renders what it is given rather than a fixed row
|
|
8
|
+
*
|
|
9
|
+
* A dimension the reviews never mention is ABSENT from the payload, not zero,
|
|
10
|
+
* and exactly one of `food` / `coffee_quality` / `drinks_quality` is scored per
|
|
11
|
+
* place — a coffee bar has no food score and a restaurant has no coffee one.
|
|
12
|
+
* So a component drawing seven fixed columns would invent readings for a third
|
|
13
|
+
* of the catalogue. It iterates `order`, skips what is missing, and lays out
|
|
14
|
+
* however many survive.
|
|
15
|
+
*
|
|
16
|
+
* Scores arrive 0–100 and display on the 0–10 scale the rest of the product
|
|
17
|
+
* grades on, so the bar width is the raw value and the numeral is that value
|
|
18
|
+
* over ten. `formatScore` is injected because a decimal separator is
|
|
19
|
+
* locale-dependent.
|
|
20
|
+
*
|
|
21
|
+
* Server-safe.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type VenueGradeDimensionKey = keyof PlaceGradeDimensions;
|
|
25
|
+
|
|
26
|
+
/** Canonical order. Quality leads, and the three quality keys are mutually exclusive. */
|
|
27
|
+
const DEFAULT_ORDER: VenueGradeDimensionKey[] = [
|
|
28
|
+
"food",
|
|
29
|
+
"coffee_quality",
|
|
30
|
+
"drinks_quality",
|
|
31
|
+
"service",
|
|
32
|
+
"value",
|
|
33
|
+
"ambiance",
|
|
34
|
+
"portion",
|
|
35
|
+
"consistency",
|
|
36
|
+
"cleanliness",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export type VenueGradeDimensionsProps = {
|
|
40
|
+
dimensions: PlaceGradeDimensions;
|
|
41
|
+
/** Dimension key → translated label. Keys with no label fall back to the key. */
|
|
42
|
+
labels: Partial<Record<VenueGradeDimensionKey, string>>;
|
|
43
|
+
/** Override the canonical order, or narrow which dimensions show at all. */
|
|
44
|
+
order?: VenueGradeDimensionKey[];
|
|
45
|
+
/** 0–100 → display numeral, e.g. `(v) => (v / 10).toFixed(1)`. */
|
|
46
|
+
formatScore: (value: number) => string;
|
|
47
|
+
className?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function VenueGradeDimensions({
|
|
51
|
+
dimensions,
|
|
52
|
+
labels,
|
|
53
|
+
order = DEFAULT_ORDER,
|
|
54
|
+
formatScore,
|
|
55
|
+
className,
|
|
56
|
+
}: VenueGradeDimensionsProps) {
|
|
57
|
+
const present = order
|
|
58
|
+
.map((key) => ({ key, value: dimensions[key] }))
|
|
59
|
+
.filter((entry): entry is { key: VenueGradeDimensionKey; value: number } =>
|
|
60
|
+
typeof entry.value === "number"
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (present.length === 0) return null;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<dl
|
|
67
|
+
className={cn(
|
|
68
|
+
"grid grid-cols-3 gap-2.5 sm:grid-cols-4 lg:flex lg:gap-2.5",
|
|
69
|
+
className
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{present.map(({ key, value }) => (
|
|
73
|
+
<div key={key} className="flex flex-col gap-1.5 lg:flex-1">
|
|
74
|
+
<dd className="text-[15px] font-extrabold tracking-[-0.02em] tabular-nums">
|
|
75
|
+
{formatScore(value)}
|
|
76
|
+
</dd>
|
|
77
|
+
<div
|
|
78
|
+
aria-hidden
|
|
79
|
+
className="relative block h-[3px] overflow-hidden rounded-full bg-muted"
|
|
80
|
+
>
|
|
81
|
+
<span
|
|
82
|
+
className="absolute inset-y-0 left-0 rounded-full bg-[var(--gradient-primary,currentColor)]"
|
|
83
|
+
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
|
84
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
<dt className="text-[10.5px] text-muted-foreground">{labels[key] ?? key}</dt>
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</dl>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { mockPlaces } from "./menu-mocks";
|
|
3
|
+
import { VenueQuotes } from "./venue-quotes";
|
|
4
|
+
|
|
5
|
+
const highlights = mockPlaces[0].reviews_digest!.highlights!;
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof VenueQuotes> = {
|
|
8
|
+
title: "Menu/VenueQuotes",
|
|
9
|
+
component: VenueQuotes,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
decorators: [
|
|
12
|
+
(Story) => (
|
|
13
|
+
<div className="w-[720px] bg-background p-4">
|
|
14
|
+
<Story />
|
|
15
|
+
</div>
|
|
16
|
+
),
|
|
17
|
+
],
|
|
18
|
+
parameters: {
|
|
19
|
+
docs: {
|
|
20
|
+
description: {
|
|
21
|
+
component:
|
|
22
|
+
"The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in serif italic against the sans everything else uses. `onCite` turns a quote into a button opening the reviews behind it: `review_ids` is what makes the claim checkable, and a quote nobody can trace is just copy.",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
type Story = StoryObj<typeof VenueQuotes>;
|
|
30
|
+
|
|
31
|
+
/** Inert prose. Correct on a surface with nowhere to open. */
|
|
32
|
+
export const Default: Story = { args: { quotes: highlights } };
|
|
33
|
+
|
|
34
|
+
/** With `onCite` each quote becomes a button onto its own evidence. */
|
|
35
|
+
export const Citable: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
quotes: highlights,
|
|
38
|
+
onCite: () => {},
|
|
39
|
+
citeLabel: (count) => `Read ${count} reviews`,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A quote with no `review_ids` stays inert even when `onCite` is passed. */
|
|
44
|
+
export const MixedEvidence: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
quotes: [highlights[0], { text: "Worth the walk from the centre." }],
|
|
47
|
+
onCite: () => {},
|
|
48
|
+
citeLabel: (count) => `Read ${count} reviews`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const Single: Story = { args: { quotes: [highlights[0]] } };
|
|
53
|
+
|
|
54
|
+
export const Limited: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
quotes: [...highlights, { text: "A third line the surface has no room for." }],
|
|
57
|
+
limit: 2,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import type { CitedText } from "./place-types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* VenueQuotes — the digest's standout lines, as pull-quotes.
|
|
6
|
+
*
|
|
7
|
+
* These are `reviews_digest.highlights`: one to three short positives already
|
|
8
|
+
* resolved into the request locale. They are the only place on a hub where a
|
|
9
|
+
* reviewer speaks in their own register, so they are set in serif italic
|
|
10
|
+
* against the sans everything else uses.
|
|
11
|
+
*
|
|
12
|
+
* `onCite` turns a quote into a button that opens the reviews behind it —
|
|
13
|
+
* `review_ids` is what makes the claim checkable, and a quote nobody can trace
|
|
14
|
+
* is just copy. Without the handler the quote renders as inert prose, which is
|
|
15
|
+
* correct on surfaces that have nowhere to open.
|
|
16
|
+
*
|
|
17
|
+
* No directive: inert quotes render on the server; passing `onCite` needs a
|
|
18
|
+
* client caller.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type VenueQuotesProps = {
|
|
22
|
+
quotes: CitedText[];
|
|
23
|
+
/** Opens the reviews behind one quote. Omit to render inert prose. */
|
|
24
|
+
onCite?: (quote: CitedText) => void;
|
|
25
|
+
/** Accessible name for the cite action, e.g. `(n) => "Read 5 reviews"`. */
|
|
26
|
+
citeLabel?: (reviewCount: number) => string;
|
|
27
|
+
/** Cap on how many render. The digest ships at most three. */
|
|
28
|
+
limit?: number;
|
|
29
|
+
className?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function VenueQuotes({ quotes, onCite, citeLabel, limit, className }: VenueQuotesProps) {
|
|
33
|
+
const shown = typeof limit === "number" ? quotes.slice(0, limit) : quotes;
|
|
34
|
+
if (shown.length === 0) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className={cn("grid gap-3 lg:grid-cols-2", className)}>
|
|
38
|
+
{shown.map((quote, index) => {
|
|
39
|
+
const citations = quote.review_ids?.length ?? 0;
|
|
40
|
+
const canCite = !!onCite && citations > 0;
|
|
41
|
+
|
|
42
|
+
const text = (
|
|
43
|
+
<blockquote className="border-l-2 border-border pl-3 font-serif text-[13px] italic leading-[1.45] text-muted-foreground">
|
|
44
|
+
{`“${quote.text}”`}
|
|
45
|
+
</blockquote>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (!canCite) return <div key={index}>{text}</div>;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
key={index}
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => onCite(quote)}
|
|
55
|
+
aria-label={citeLabel?.(citations)}
|
|
56
|
+
className="rounded-md text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
57
|
+
>
|
|
58
|
+
{text}
|
|
59
|
+
</button>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|