@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
|
@@ -76,12 +76,20 @@ const UTENSILS_TIMES = [0, 0.18, 0.72, 1];
|
|
|
76
76
|
*/
|
|
77
77
|
const SEPARATION = 5;
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The fork is not centred on the crossing: its handle reaches ~13.9 units
|
|
81
|
+
* below (12,12) and its tines ~10.8 above, so upright it hung ~1.5 units lower
|
|
82
|
+
* than the knife. `FORK_LIFT` centres it in the uncrossed pose.
|
|
83
|
+
*/
|
|
84
|
+
const FORK_LIFT = -1.5;
|
|
85
|
+
|
|
79
86
|
const fork: Variants = {
|
|
80
|
-
rest: { rotate: 0, x: 0, transition: returnTransition },
|
|
81
|
-
active: { rotate: -44, x: -SEPARATION, transition: returnTransition },
|
|
87
|
+
rest: { rotate: 0, x: 0, y: 0, transition: returnTransition },
|
|
88
|
+
active: { rotate: -44, x: -SEPARATION, y: FORK_LIFT, transition: returnTransition },
|
|
82
89
|
play: {
|
|
83
90
|
rotate: [0, 6, -50, -44],
|
|
84
91
|
x: [0, 0.8, -6, -SEPARATION],
|
|
92
|
+
y: [0, 0, FORK_LIFT - 0.3, FORK_LIFT],
|
|
85
93
|
transition: beats(UTENSILS_TIMES, 0.6),
|
|
86
94
|
},
|
|
87
95
|
};
|
|
@@ -98,6 +106,25 @@ const knife: Variants = {
|
|
|
98
106
|
|
|
99
107
|
const CROSSING = { originX: "12px", originY: "12px" } as const;
|
|
100
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The fork's handle. Lucide leaves a gap between the handle and the neck where
|
|
111
|
+
* the knife crosses, so the two strokes never form a blob at the crossing. Once
|
|
112
|
+
* the fork swings clear the gap is just a broken fork, so the handle lengthens
|
|
113
|
+
* to meet the neck at (12,12) as it uncrosses and shortens again on the way
|
|
114
|
+
* back. Same command shape both ways, so the `d` tweens.
|
|
115
|
+
*/
|
|
116
|
+
const HANDLE_CROSSED = "M2.1 21.8 8.5 15.4";
|
|
117
|
+
const HANDLE_CLEAR = "M2.1 21.8 12 12";
|
|
118
|
+
|
|
119
|
+
const forkHandle: Variants = {
|
|
120
|
+
rest: { d: HANDLE_CROSSED, transition: returnTransition },
|
|
121
|
+
active: { d: HANDLE_CLEAR, transition: returnTransition },
|
|
122
|
+
play: {
|
|
123
|
+
d: [HANDLE_CROSSED, HANDLE_CROSSED, HANDLE_CLEAR, HANDLE_CLEAR],
|
|
124
|
+
transition: beats(UTENSILS_TIMES, 0.6),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
101
128
|
/**
|
|
102
129
|
* The one gesture that HOLDS: uncrossed is the active pose. ~89° of relative
|
|
103
130
|
* separation, so the outline changes completely and the state is readable from
|
|
@@ -113,7 +140,7 @@ export function MotionUtensils(props: MotionIconProps) {
|
|
|
113
140
|
/>
|
|
114
141
|
<motion.g variants={fork} style={CROSSING}>
|
|
115
142
|
<path d="m16 2-2.3 2.3a3 3 0 0 0 0 4.2l1.8 1.8a3 3 0 0 0 4.2 0L22 8" />
|
|
116
|
-
<path d=
|
|
143
|
+
<motion.path variants={forkHandle} d={HANDLE_CROSSED} />
|
|
117
144
|
<path d="m19 5-7 7" />
|
|
118
145
|
</motion.g>
|
|
119
146
|
</MotionIcon>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { mockVenues } from "./menu-mocks";
|
|
3
|
+
import { OpenNowList } from "./open-now-list";
|
|
4
|
+
|
|
5
|
+
const metaLabels = {
|
|
6
|
+
"the-copper-yard": "pub · Centru",
|
|
7
|
+
"salt-and-stone": "restaurant · Centru",
|
|
8
|
+
"the-quiet-hours": "coffee · Centru",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const summaryLabels = {
|
|
12
|
+
"the-copper-yard": "Wings, truffle fries, ribs",
|
|
13
|
+
"salt-and-stone": "Pasta, antipasti, tiramisu",
|
|
14
|
+
"the-quiet-hours": "Filter, flat white, pastries",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const priceBandLabels = {
|
|
18
|
+
"the-copper-yard": "19—62 lei",
|
|
19
|
+
"salt-and-stone": "38—84 lei",
|
|
20
|
+
"the-quiet-hours": "8—26 lei",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const meta: Meta<typeof OpenNowList> = {
|
|
24
|
+
title: "Menu/OpenNowList",
|
|
25
|
+
component: OpenNowList,
|
|
26
|
+
tags: ["autodocs"],
|
|
27
|
+
decorators: [
|
|
28
|
+
(Story) => (
|
|
29
|
+
<div className="w-[620px] bg-background p-4">
|
|
30
|
+
<Story />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
parameters: {
|
|
35
|
+
docs: {
|
|
36
|
+
description: {
|
|
37
|
+
component:
|
|
38
|
+
"Who is still serving, and until when. At half past ten this beats every other sort, which is why it is its own surface rather than a filter chip: the question is not \"what is good\" but \"what is possible\", and every other module answers the first. The closing time arrives pre-formatted because a trading day crossing midnight needs the venue's timezone and the reader's clock. The live dot is never the only signal — the pill carries the same fact in words.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default meta;
|
|
45
|
+
type Story = StoryObj<typeof OpenNowList>;
|
|
46
|
+
|
|
47
|
+
export const Default: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
venues: mockVenues.slice(0, 3),
|
|
50
|
+
untilLabel: "until",
|
|
51
|
+
metaLabels,
|
|
52
|
+
summaryLabels,
|
|
53
|
+
priceBandLabels,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Without the summary line — a tighter row for a narrow column. */
|
|
58
|
+
export const Compact: Story = {
|
|
59
|
+
args: { venues: mockVenues.slice(0, 3), untilLabel: "until", metaLabels, priceBandLabels },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A venue past closing has no pill; the row still lists. */
|
|
63
|
+
export const MixedHours: Story = {
|
|
64
|
+
args: { venues: mockVenues, untilLabel: "until", metaLabels, priceBandLabels },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const Mobile: Story = {
|
|
68
|
+
args: { venues: mockVenues.slice(0, 3), untilLabel: "until", metaLabels, summaryLabels },
|
|
69
|
+
decorators: [
|
|
70
|
+
(Story) => (
|
|
71
|
+
<div className="w-[390px] bg-background p-3">
|
|
72
|
+
<Story />
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ChevronRight, Clock } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Separator } from "./separator";
|
|
4
|
+
import type { MenuVenuePreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* OpenNowList — who is still serving, and until when.
|
|
8
|
+
*
|
|
9
|
+
* At half past ten this beats every other sort, which is why it is its own
|
|
10
|
+
* surface rather than a filter chip: the question is not "what is good" but
|
|
11
|
+
* "what is possible", and every other module on a hub answers the first one.
|
|
12
|
+
*
|
|
13
|
+
* The closing time arrives pre-formatted on the view model (`closes_at_label`)
|
|
14
|
+
* because the venue's own trading day can cross midnight — a bar closing at
|
|
15
|
+
* 04:00 closes on the following date — and resolving that needs the venue's
|
|
16
|
+
* timezone and the reader's clock, neither of which the DS has.
|
|
17
|
+
*
|
|
18
|
+
* The live dot is never the only signal that a venue is open: the closing pill
|
|
19
|
+
* carries the same fact in words, so the row reads without colour.
|
|
20
|
+
*
|
|
21
|
+
* Server-safe.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
25
|
+
export type OpenNowListLinkProps = MenuLinkProps;
|
|
26
|
+
|
|
27
|
+
function DefaultLink({ href, ...props }: OpenNowListLinkProps) {
|
|
28
|
+
return <a href={href} {...props} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type OpenNowListProps = {
|
|
32
|
+
venues: MenuVenuePreview[];
|
|
33
|
+
/** Prefix on the closing pill, e.g. "until". */
|
|
34
|
+
untilLabel: string;
|
|
35
|
+
/** Composed meta line per venue, keyed by place slug, e.g. "pub · Centru". */
|
|
36
|
+
metaLabels?: Record<string, string>;
|
|
37
|
+
/** What the kitchen is still sending out, keyed by place slug. */
|
|
38
|
+
summaryLabels?: Record<string, string>;
|
|
39
|
+
/** Composed price band per venue, keyed by place slug, e.g. "19—62 lei". */
|
|
40
|
+
priceBandLabels?: Record<string, string>;
|
|
41
|
+
linkComponent?: MenuLinkComponent;
|
|
42
|
+
className?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function OpenNowList({
|
|
46
|
+
venues,
|
|
47
|
+
untilLabel,
|
|
48
|
+
metaLabels,
|
|
49
|
+
summaryLabels,
|
|
50
|
+
priceBandLabels,
|
|
51
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
52
|
+
className,
|
|
53
|
+
}: OpenNowListProps) {
|
|
54
|
+
return (
|
|
55
|
+
<ul className={cn("flex flex-col", className)}>
|
|
56
|
+
{venues.map((venue, index) => {
|
|
57
|
+
const { place } = venue;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<li key={place.slug}>
|
|
61
|
+
{index > 0 && <Separator />}
|
|
62
|
+
<LinkComponent
|
|
63
|
+
href={venue.href}
|
|
64
|
+
className="flex flex-wrap items-center gap-x-3 gap-y-1.5 py-3 transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
65
|
+
>
|
|
66
|
+
{/* 56px, not 40. At 40 the venue photograph was too small to be
|
|
67
|
+
anything — a smudge of colour rather than a room you might
|
|
68
|
+
recognise — which is the only reason it is on the row. */}
|
|
69
|
+
<span className="relative h-14 w-14 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)]">
|
|
70
|
+
{place.thumbnail_url && (
|
|
71
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
72
|
+
<img
|
|
73
|
+
src={place.thumbnail_url}
|
|
74
|
+
alt=""
|
|
75
|
+
className="h-full w-full object-cover"
|
|
76
|
+
loading="lazy"
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
<i
|
|
80
|
+
aria-hidden
|
|
81
|
+
className="absolute right-1 top-1 h-[7px] w-[7px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
|
|
82
|
+
/>
|
|
83
|
+
</span>
|
|
84
|
+
|
|
85
|
+
<span className="min-w-0 flex-1">
|
|
86
|
+
<span className="block text-sm font-bold leading-snug tracking-[-0.01em]">
|
|
87
|
+
{place.name}
|
|
88
|
+
</span>
|
|
89
|
+
{metaLabels?.[place.slug] && (
|
|
90
|
+
<span className="mt-px block truncate text-[11.5px] text-muted-foreground">
|
|
91
|
+
{metaLabels[place.slug]}
|
|
92
|
+
</span>
|
|
93
|
+
)}
|
|
94
|
+
</span>
|
|
95
|
+
|
|
96
|
+
{summaryLabels?.[place.slug] && (
|
|
97
|
+
<span className="order-last w-full truncate text-[12.5px] text-muted-foreground lg:order-none lg:w-auto lg:flex-1">
|
|
98
|
+
{summaryLabels[place.slug]}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{/* Price over hours on the phone — side by side they squeezed a
|
|
103
|
+
three-line venue name; one row again from lg. */}
|
|
104
|
+
{(venue.closes_at_label || priceBandLabels?.[place.slug]) && (
|
|
105
|
+
<span className="flex shrink-0 flex-col items-end gap-1 lg:flex-row lg:items-center lg:gap-3">
|
|
106
|
+
{priceBandLabels?.[place.slug] && (
|
|
107
|
+
<span className="text-[12.5px] tabular-nums text-muted-foreground">
|
|
108
|
+
{priceBandLabels[place.slug]}
|
|
109
|
+
</span>
|
|
110
|
+
)}
|
|
111
|
+
{venue.closes_at_label && (
|
|
112
|
+
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-[var(--free-subtle,var(--accent-subtle))] px-2.5 text-[11.5px] font-semibold tabular-nums">
|
|
113
|
+
<Clock className="h-[11px] w-[11px]" aria-hidden />
|
|
114
|
+
{`${untilLabel} ${venue.closes_at_label}`}
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
121
|
+
</LinkComponent>
|
|
122
|
+
</li>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</ul>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -31,6 +31,13 @@ export type PlacePreview = {
|
|
|
31
31
|
landmarks?: Array<{ name: string; distance_meters?: number }>
|
|
32
32
|
areas?: Array<{ name: string }>
|
|
33
33
|
busyness?: Array<{ hour: number; score: number }>
|
|
34
|
+
/**
|
|
35
|
+
* The review distillation. Drives every menu-hub surface that speaks for a
|
|
36
|
+
* venue — `<VenueSpotlight>`, `<RankedVenueList>`, `<SignatureLineList>` —
|
|
37
|
+
* because the signature dish, the pull-quotes and the graded dimensions all
|
|
38
|
+
* live here rather than on the menu itself.
|
|
39
|
+
*/
|
|
40
|
+
reviews_digest?: PlaceReviewsDigest
|
|
34
41
|
type?: string
|
|
35
42
|
business_status?: 'operational' | 'closed_temporarily' | 'closed_permanently'
|
|
36
43
|
phone?: string
|
|
@@ -60,6 +67,111 @@ export type PlaceSchedule = Record<WeekdayKey, PlaceHourBlock[]>
|
|
|
60
67
|
export type PlacePriceRange = {
|
|
61
68
|
price_range_low?: number
|
|
62
69
|
price_range_high?: number
|
|
70
|
+
/** Median price across the place's menu, in the local currency. */
|
|
71
|
+
median_price?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Menu items are priced per size; a single-size item still ships one entry.
|
|
76
|
+
*
|
|
77
|
+
* Defined here rather than in `menu-types.ts` because `PlaceMustTry` carries
|
|
78
|
+
* it, and `menu-types.ts` already imports from this file — keeping the
|
|
79
|
+
* definition here leaves that import one-directional. `menu-types.ts`
|
|
80
|
+
* re-exports it, so menu code never reaches into place types for it.
|
|
81
|
+
*/
|
|
82
|
+
export type MenuSizeAndPrice = {
|
|
83
|
+
/** Grams or millilitres. */
|
|
84
|
+
size: number
|
|
85
|
+
price: number
|
|
86
|
+
discounted_price?: number
|
|
87
|
+
discount_percentage?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A digest string with the reviews that back it. `review_ids.length` is the mention count. */
|
|
91
|
+
export type CitedText = { text: string; review_ids?: string[] }
|
|
92
|
+
|
|
93
|
+
/** A digest enum value with the reviews that back it. The value is a language-neutral code. */
|
|
94
|
+
export type CitedValue = { value: string; review_ids?: string[] }
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One dish or drink the reviews single out.
|
|
98
|
+
*
|
|
99
|
+
* `mentions` and `score` come from the item census and are only served by the
|
|
100
|
+
* place DETAIL endpoint — a listing carries the item without them. Surfaces
|
|
101
|
+
* that need a count from a list fall back to `review_ids.length`, which is
|
|
102
|
+
* present in both. `strength`, `why` and `review_ids` are absent for census
|
|
103
|
+
* items the prose digest never mentioned, so none of them can be assumed.
|
|
104
|
+
*/
|
|
105
|
+
export type PlaceMustTry = {
|
|
106
|
+
name: string
|
|
107
|
+
why?: string
|
|
108
|
+
strength?: 'signature' | 'frequent' | 'occasional'
|
|
109
|
+
/** Deep-link slug. Present only when the item matched a live menu row. */
|
|
110
|
+
slug?: string
|
|
111
|
+
sizes_and_prices?: MenuSizeAndPrice[]
|
|
112
|
+
review_ids?: string[]
|
|
113
|
+
/** 0–100 census sentiment. Detail endpoint only; 3+ mentions required. */
|
|
114
|
+
score?: number
|
|
115
|
+
/** Censused review mentions of this item. Detail endpoint only. */
|
|
116
|
+
mentions?: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Per-dimension 0–100 review scores.
|
|
121
|
+
*
|
|
122
|
+
* A dimension the reviews never mention is ABSENT, not zero — so a surface
|
|
123
|
+
* drawing these must render what it is given rather than a fixed row of seven.
|
|
124
|
+
* Exactly one of `food` / `coffee_quality` / `drinks_quality` is scored per
|
|
125
|
+
* place: a coffee bar has no food score and a restaurant has no coffee one.
|
|
126
|
+
*/
|
|
127
|
+
export type PlaceGradeDimensions = {
|
|
128
|
+
food?: number
|
|
129
|
+
coffee_quality?: number
|
|
130
|
+
drinks_quality?: number
|
|
131
|
+
value?: number
|
|
132
|
+
service?: number
|
|
133
|
+
cleanliness?: number
|
|
134
|
+
ambiance?: number
|
|
135
|
+
portion?: number
|
|
136
|
+
consistency?: number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type PlaceGrade = {
|
|
140
|
+
/** 1.0–10.0. */
|
|
141
|
+
overall?: number
|
|
142
|
+
/**
|
|
143
|
+
* Backend recommendation to show the number at all. When false the caller
|
|
144
|
+
* should draw a "building confidence" state instead — the DS renders what it
|
|
145
|
+
* is told, it does not apply the rule.
|
|
146
|
+
*/
|
|
147
|
+
show_number?: boolean
|
|
148
|
+
/** Locale-resolved band label, e.g. "Foarte bun". Already translated upstream. */
|
|
149
|
+
descriptor?: string
|
|
150
|
+
confidence?: 'low' | 'medium' | 'high'
|
|
151
|
+
capped?: boolean
|
|
152
|
+
dimensions?: PlaceGradeDimensions
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type PlaceReviewsDigest = {
|
|
156
|
+
grade?: PlaceGrade
|
|
157
|
+
grade_note?: string
|
|
158
|
+
tagline?: CitedText
|
|
159
|
+
verdict?: CitedText
|
|
160
|
+
caveat?: CitedText
|
|
161
|
+
/** 1–3 standout positives. The pull-quotes on `<VenueSpotlight>`. */
|
|
162
|
+
highlights?: CitedText[]
|
|
163
|
+
must_try?: PlaceMustTry[]
|
|
164
|
+
/** Vibe codes (cozy, quiet, family_friendly, …). Localised by the caller. */
|
|
165
|
+
tags?: CitedValue[]
|
|
166
|
+
dietary?: CitedValue[]
|
|
167
|
+
/** small | moderate | generous */
|
|
168
|
+
portion_size?: CitedValue
|
|
169
|
+
/** cheap | fair | mid_range | splurge */
|
|
170
|
+
value?: CitedValue
|
|
171
|
+
/** quiet | moderate | loud */
|
|
172
|
+
noise_level?: CitedValue
|
|
173
|
+
/** Text reviews behind the digest. */
|
|
174
|
+
review_count?: number
|
|
63
175
|
}
|
|
64
176
|
|
|
65
177
|
/** Labels passed to `<PlaceCard>`. All translation happens in the caller. */
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { PosterWall } from "./poster-wall";
|
|
4
|
+
|
|
5
|
+
const grid = (container: HTMLElement) =>
|
|
6
|
+
container.querySelector<HTMLElement>("[aria-hidden].grid")!;
|
|
7
|
+
|
|
8
|
+
describe("PosterWall grid", () => {
|
|
9
|
+
it("sizes the grid to the tiles and completes the last row by cycling them", () => {
|
|
10
|
+
const tiles = Array.from({ length: 5 }, (_, i) => ({ src: `p${i}.jpg` }));
|
|
11
|
+
const { container } = render(
|
|
12
|
+
<PosterWall tiles={tiles} variant="compact" />,
|
|
13
|
+
);
|
|
14
|
+
const g = grid(container);
|
|
15
|
+
// compact: width 390, height 330 → cols = round(√(5·1.18)) = 2, rows = 3 → 6 slots
|
|
16
|
+
expect(g.style.gridTemplateColumns).toBe("repeat(2, minmax(0, 1fr))");
|
|
17
|
+
expect(g.querySelectorAll("img")).toHaveLength(6);
|
|
18
|
+
expect(g.querySelectorAll("img")[5].getAttribute("src")).toBe("p0.jpg");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps the placeholder grid when there are no pictures", () => {
|
|
22
|
+
const { container } = render(<PosterWall tiles={[]} variant="compact" />);
|
|
23
|
+
const g = grid(container);
|
|
24
|
+
expect(g.style.gridTemplateColumns).toBe("repeat(6, minmax(0, 1fr))");
|
|
25
|
+
expect(g.children).toHaveLength(36);
|
|
26
|
+
expect(g.querySelectorAll("img")).toHaveLength(0);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -17,6 +17,14 @@ import { Skeleton } from "./skeleton";
|
|
|
17
17
|
* the container's size, alignment and padding for the given `variant`. All
|
|
18
18
|
* overlay typography belongs to the caller.
|
|
19
19
|
*
|
|
20
|
+
* THE GRID FOLLOWS THE TILES. It used to be a fixed 12×4 (48) or 6×6 (36)
|
|
21
|
+
* grid, and a city with 28 posters left twenty empty cells — a dark hole down
|
|
22
|
+
* one side of the masthead. Now the column count comes from the tile count
|
|
23
|
+
* and the wall's measured aspect ratio (`cols ≈ √(n · w/h)`, so tiles stay
|
|
24
|
+
* roughly square), rows follow, and the last row is completed by cycling the
|
|
25
|
+
* tiles. Fewer pictures means fewer, bigger tiles; never a gap. The default
|
|
26
|
+
* grid is kept for the server render and for an empty tile list.
|
|
27
|
+
*
|
|
20
28
|
* The sweep is a genuine `@keyframes` animation (not Framer Motion) because
|
|
21
29
|
* its exact timing is part of the design spec and it needs to keep running
|
|
22
30
|
* indefinitely at zero React cost — the keyframes are scoped to this file via
|
|
@@ -43,16 +51,22 @@ function usePrefersReducedMotion(): boolean {
|
|
|
43
51
|
return reduced;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
function useInViewport<T extends Element>(): [
|
|
54
|
+
function useInViewport<T extends Element>(): [
|
|
55
|
+
React.RefObject<T | null>,
|
|
56
|
+
boolean,
|
|
57
|
+
] {
|
|
47
58
|
const ref = React.useRef<T | null>(null);
|
|
48
59
|
const [inView, setInView] = React.useState(true);
|
|
49
60
|
|
|
50
61
|
React.useEffect(() => {
|
|
51
62
|
const node = ref.current;
|
|
52
63
|
if (!node || typeof IntersectionObserver === "undefined") return;
|
|
53
|
-
const observer = new IntersectionObserver(
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
const observer = new IntersectionObserver(
|
|
65
|
+
([entry]) => setInView(entry.isIntersecting),
|
|
66
|
+
{
|
|
67
|
+
threshold: 0,
|
|
68
|
+
},
|
|
69
|
+
);
|
|
56
70
|
observer.observe(node);
|
|
57
71
|
return () => observer.disconnect();
|
|
58
72
|
}, []);
|
|
@@ -65,7 +79,7 @@ export type PosterWallTile = {
|
|
|
65
79
|
};
|
|
66
80
|
|
|
67
81
|
export type PosterWallProps = {
|
|
68
|
-
/** Background tiles.
|
|
82
|
+
/** Background tiles. The grid sizes itself to how many there are (see above); with none, a placeholder grid renders. */
|
|
69
83
|
tiles?: PosterWallTile[];
|
|
70
84
|
/** `compact` is the bottom-aligned mobile treatment (fewer, larger tiles, a top-fading veil). */
|
|
71
85
|
variant?: "default" | "compact";
|
|
@@ -74,22 +88,64 @@ export type PosterWallProps = {
|
|
|
74
88
|
className?: string;
|
|
75
89
|
};
|
|
76
90
|
|
|
77
|
-
export function PosterWall({
|
|
91
|
+
export function PosterWall({
|
|
92
|
+
tiles,
|
|
93
|
+
variant = "default",
|
|
94
|
+
children,
|
|
95
|
+
className,
|
|
96
|
+
}: PosterWallProps) {
|
|
78
97
|
const isCompact = variant === "compact";
|
|
79
|
-
const
|
|
80
|
-
const
|
|
98
|
+
const maxCols = isCompact ? 6 : 12;
|
|
99
|
+
const maxSlots = isCompact
|
|
100
|
+
? POSTER_WALL_COMPACT_TILE_COUNT
|
|
101
|
+
: POSTER_WALL_DESKTOP_TILE_COUNT;
|
|
102
|
+
const heightPx = isCompact ? 330 : 420;
|
|
103
|
+
const pictures = React.useMemo(
|
|
104
|
+
() => (tiles ?? []).filter((tile) => !!tile.src),
|
|
105
|
+
[tiles],
|
|
106
|
+
);
|
|
81
107
|
|
|
82
108
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
83
109
|
const [wallRef, inView] = useInViewport<HTMLDivElement>();
|
|
84
110
|
const isSweepPaused = prefersReducedMotion || !inView;
|
|
85
111
|
|
|
112
|
+
// Measured on the client; the server assumes a phone or a wide desktop
|
|
113
|
+
// column, which is what those two variants are for.
|
|
114
|
+
const [width, setWidth] = React.useState(isCompact ? 390 : 1200);
|
|
115
|
+
React.useEffect(() => {
|
|
116
|
+
const node = wallRef.current;
|
|
117
|
+
if (!node || typeof ResizeObserver === "undefined") return;
|
|
118
|
+
const observer = new ResizeObserver(([entry]) =>
|
|
119
|
+
setWidth(entry.contentRect.width),
|
|
120
|
+
);
|
|
121
|
+
observer.observe(node);
|
|
122
|
+
return () => observer.disconnect();
|
|
123
|
+
}, [wallRef]);
|
|
124
|
+
|
|
125
|
+
let cols = maxCols;
|
|
126
|
+
let slots: PosterWallTile[];
|
|
127
|
+
if (pictures.length === 0) {
|
|
128
|
+
slots = Array.from({ length: maxSlots }, (): PosterWallTile => ({}));
|
|
129
|
+
} else {
|
|
130
|
+
const shown = Math.min(pictures.length, maxSlots);
|
|
131
|
+
cols = Math.min(
|
|
132
|
+
maxCols,
|
|
133
|
+
Math.max(2, Math.round(Math.sqrt((shown * width) / heightPx))),
|
|
134
|
+
);
|
|
135
|
+
const rows = Math.max(1, Math.ceil(shown / cols));
|
|
136
|
+
slots = Array.from(
|
|
137
|
+
{ length: cols * rows },
|
|
138
|
+
(_, index) => pictures[index % pictures.length],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
86
142
|
return (
|
|
87
143
|
<div
|
|
88
144
|
ref={wallRef}
|
|
89
145
|
className={cn(
|
|
90
146
|
"relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
|
|
91
147
|
isCompact ? "h-[330px]" : "h-[420px]",
|
|
92
|
-
className
|
|
148
|
+
className,
|
|
93
149
|
)}
|
|
94
150
|
>
|
|
95
151
|
<style>{`
|
|
@@ -100,10 +156,8 @@ export function PosterWall({ tiles, variant = "default", children, className }:
|
|
|
100
156
|
`}</style>
|
|
101
157
|
|
|
102
158
|
<div
|
|
103
|
-
className=
|
|
104
|
-
|
|
105
|
-
isCompact ? "grid-cols-6" : "grid-cols-12"
|
|
106
|
-
)}
|
|
159
|
+
className="absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50"
|
|
160
|
+
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
|
107
161
|
aria-hidden
|
|
108
162
|
>
|
|
109
163
|
{slots.map((tile, index) =>
|
|
@@ -122,7 +176,7 @@ export function PosterWall({ tiles, variant = "default", children, className }:
|
|
|
122
176
|
key={index}
|
|
123
177
|
className="h-full w-full bg-[repeating-linear-gradient(45deg,color-mix(in_oklch,white_10%,transparent)_0px,color-mix(in_oklch,white_10%,transparent)_2px,transparent_2px,transparent_8px)]"
|
|
124
178
|
/>
|
|
125
|
-
)
|
|
179
|
+
),
|
|
126
180
|
)}
|
|
127
181
|
</div>
|
|
128
182
|
|
|
@@ -157,7 +211,9 @@ export function PosterWall({ tiles, variant = "default", children, className }:
|
|
|
157
211
|
<div
|
|
158
212
|
className={cn(
|
|
159
213
|
"relative flex h-full min-w-0 flex-col text-white",
|
|
160
|
-
isCompact
|
|
214
|
+
isCompact
|
|
215
|
+
? "justify-end gap-[11px] p-5"
|
|
216
|
+
: "justify-center gap-[15px] px-12",
|
|
161
217
|
)}
|
|
162
218
|
>
|
|
163
219
|
{children}
|
|
@@ -173,9 +229,14 @@ export type PosterWallSkeletonProps = {
|
|
|
173
229
|
};
|
|
174
230
|
|
|
175
231
|
/** The tile grid at rest — the sweeping light never renders in the loading state. */
|
|
176
|
-
export function PosterWallSkeleton({
|
|
232
|
+
export function PosterWallSkeleton({
|
|
233
|
+
variant = "default",
|
|
234
|
+
className,
|
|
235
|
+
}: PosterWallSkeletonProps) {
|
|
177
236
|
const isCompact = variant === "compact";
|
|
178
|
-
const tileCount = isCompact
|
|
237
|
+
const tileCount = isCompact
|
|
238
|
+
? POSTER_WALL_COMPACT_TILE_COUNT
|
|
239
|
+
: POSTER_WALL_DESKTOP_TILE_COUNT;
|
|
179
240
|
|
|
180
241
|
return (
|
|
181
242
|
<div
|
|
@@ -183,17 +244,20 @@ export function PosterWallSkeleton({ variant = "default", className }: PosterWal
|
|
|
183
244
|
className={cn(
|
|
184
245
|
"relative overflow-hidden rounded-[18px] bg-[oklch(0.22_0.012_60)]",
|
|
185
246
|
isCompact ? "h-[330px]" : "h-[420px]",
|
|
186
|
-
className
|
|
247
|
+
className,
|
|
187
248
|
)}
|
|
188
249
|
>
|
|
189
250
|
<div
|
|
190
251
|
className={cn(
|
|
191
252
|
"absolute inset-0 grid auto-rows-fr gap-[2px] opacity-50",
|
|
192
|
-
isCompact ? "grid-cols-6" : "grid-cols-12"
|
|
253
|
+
isCompact ? "grid-cols-6" : "grid-cols-12",
|
|
193
254
|
)}
|
|
194
255
|
>
|
|
195
256
|
{Array.from({ length: tileCount }, (_, index) => (
|
|
196
|
-
<Skeleton
|
|
257
|
+
<Skeleton
|
|
258
|
+
key={index}
|
|
259
|
+
className="h-full w-full rounded-none bg-white/10"
|
|
260
|
+
/>
|
|
197
261
|
))}
|
|
198
262
|
</div>
|
|
199
263
|
|
|
@@ -209,11 +273,20 @@ export function PosterWallSkeleton({ variant = "default", className }: PosterWal
|
|
|
209
273
|
<div
|
|
210
274
|
className={cn(
|
|
211
275
|
"relative flex h-full min-w-0 flex-col",
|
|
212
|
-
isCompact
|
|
276
|
+
isCompact
|
|
277
|
+
? "justify-end gap-[11px] p-5"
|
|
278
|
+
: "justify-center gap-[15px] px-12",
|
|
213
279
|
)}
|
|
214
280
|
>
|
|
215
|
-
<Skeleton
|
|
216
|
-
|
|
281
|
+
<Skeleton
|
|
282
|
+
className={cn(
|
|
283
|
+
"bg-white/15",
|
|
284
|
+
isCompact ? "h-[11px] w-40" : "h-3 w-52",
|
|
285
|
+
)}
|
|
286
|
+
/>
|
|
287
|
+
<Skeleton
|
|
288
|
+
className={cn("bg-white/20", isCompact ? "h-7 w-64" : "h-10 w-96")}
|
|
289
|
+
/>
|
|
217
290
|
</div>
|
|
218
291
|
</div>
|
|
219
292
|
);
|