@bearmenu/ui 0.8.9 → 0.8.10
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-24RFI4RQ.js +167 -0
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
- package/dist/chunk-CLLY6XB3.js +41 -0
- package/dist/chunk-DTXNDLSE.js +125 -0
- package/dist/chunk-F6FMZJDG.js +113 -0
- package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
- package/dist/chunk-MZL7CGCL.js +109 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- 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-rail.d.ts +50 -0
- package/dist/components/concept-compare-rail.js +5 -0
- package/dist/components/concept-compare.d.ts +75 -38
- package/dist/components/concept-compare.js +209 -133
- package/dist/components/concept-convergence.d.ts +41 -0
- package/dist/components/concept-convergence.js +53 -0
- package/dist/components/convergence.d.ts +25 -0
- package/dist/components/convergence.js +4 -0
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/explainer-steps.d.ts +34 -0
- package/dist/components/explainer-steps.js +36 -0
- package/dist/components/glass-ground.d.ts +66 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/hub-surface.js +11 -1
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +187 -0
- package/dist/components/menu-dead-end.d.ts +56 -0
- package/dist/components/menu-dead-end.js +49 -0
- package/dist/components/menu-family-block.d.ts +99 -0
- package/dist/components/menu-family-block.js +155 -0
- package/dist/components/menu-family-card.d.ts +33 -27
- package/dist/components/menu-family-card.js +43 -85
- package/dist/components/menu-field.js +92 -2
- package/dist/components/menu-filter-chips.d.ts +86 -0
- package/dist/components/menu-filter-chips.js +122 -0
- package/dist/components/menu-group-rows.d.ts +53 -0
- package/dist/components/menu-group-rows.js +67 -0
- package/dist/components/menu-index-header.d.ts +37 -0
- package/dist/components/menu-index-header.js +68 -0
- package/dist/components/menu-index-list.d.ts +48 -0
- package/dist/components/menu-index-list.js +96 -0
- package/dist/components/menu-intent-hero.d.ts +69 -0
- package/dist/components/menu-intent-hero.js +87 -0
- package/dist/components/menu-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +37 -2
- package/dist/components/menu-mocks.js +152 -27
- package/dist/components/menu-types.d.ts +182 -6
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +6 -6
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/photo-ground.d.ts +64 -0
- package/dist/components/photo-ground.js +3 -0
- package/dist/components/place-about-tab.js +2 -2
- package/dist/components/place-details-mobile.js +2 -2
- package/dist/components/place-types.d.ts +2 -2
- package/dist/components/qa-block.d.ts +36 -0
- package/dist/components/qa-block.js +24 -0
- package/dist/components/scroll-fade.js +3 -121
- package/dist/components/snap-rail.d.ts +24 -1
- package/dist/components/snap-rail.js +3 -109
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/top-comparison.d.ts +28 -0
- package/dist/components/top-comparison.js +76 -0
- package/dist/components/top-cross-link.d.ts +34 -0
- package/dist/components/top-cross-link.js +101 -0
- package/dist/components/top-ranking-entry.d.ts +53 -0
- package/dist/components/top-ranking-entry.js +217 -0
- package/dist/components/top-ranking-hero.d.ts +61 -0
- package/dist/components/top-ranking-hero.js +99 -0
- package/dist/components/top-ranking-legend.d.ts +69 -0
- package/dist/components/top-ranking-legend.js +124 -0
- package/dist/components/top-types.d.ts +109 -0
- package/dist/components/top-types.js +1 -0
- package/dist/components/venue-grade-dimensions.d.ts +3 -1
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +122 -54
- package/src/components/concept-compare.tsx +363 -196
- package/src/components/concept-convergence.stories.tsx +53 -0
- package/src/components/concept-convergence.tsx +90 -0
- package/src/components/convergence.stories.tsx +54 -0
- package/src/components/convergence.tsx +177 -0
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +129 -0
- package/src/components/hub-surface.tsx +11 -1
- package/src/components/hue-chip.stories.tsx +76 -0
- package/src/components/hue-chip.test.tsx +62 -0
- package/src/components/hue-chip.tsx +99 -0
- package/src/components/kitchen-dossier.stories.tsx +108 -0
- package/src/components/kitchen-dossier.tsx +322 -0
- package/src/components/menu-dead-end.stories.tsx +67 -0
- package/src/components/menu-dead-end.tsx +127 -0
- package/src/components/menu-family-block.stories.tsx +91 -0
- package/src/components/menu-family-block.tsx +314 -0
- package/src/components/menu-family-card.stories.tsx +43 -35
- package/src/components/menu-family-card.tsx +122 -142
- package/src/components/menu-filter-chips.stories.tsx +107 -0
- package/src/components/menu-filter-chips.tsx +238 -0
- package/src/components/menu-group-rows.stories.tsx +54 -0
- package/src/components/menu-group-rows.tsx +133 -0
- package/src/components/menu-index-header.stories.tsx +72 -0
- package/src/components/menu-index-header.tsx +125 -0
- package/src/components/menu-index-list.stories.tsx +50 -0
- package/src/components/menu-index-list.tsx +149 -0
- package/src/components/menu-intent-hero.stories.tsx +67 -0
- package/src/components/menu-intent-hero.tsx +189 -0
- package/src/components/menu-kitchen-list.stories.tsx +67 -0
- package/src/components/menu-kitchen-list.test.tsx +78 -0
- package/src/components/menu-kitchen-list.tsx +284 -0
- package/src/components/menu-mocks.ts +198 -23
- package/src/components/menu-types.ts +189 -6
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +6 -5
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +2 -2
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/snap-rail.tsx +58 -15
- package/src/components/story.tsx +225 -0
- package/src/components/top-comparison.stories.tsx +78 -0
- package/src/components/top-comparison.tsx +151 -0
- package/src/components/top-cross-link.stories.tsx +84 -0
- package/src/components/top-cross-link.tsx +171 -0
- package/src/components/top-ranking-entry.stories.tsx +171 -0
- package/src/components/top-ranking-entry.test.tsx +105 -0
- package/src/components/top-ranking-entry.tsx +425 -0
- package/src/components/top-ranking-hero.stories.tsx +76 -0
- package/src/components/top-ranking-hero.tsx +218 -0
- package/src/components/top-ranking-legend.stories.tsx +78 -0
- package/src/components/top-ranking-legend.tsx +239 -0
- package/src/components/top-types.ts +110 -0
- package/src/components/venue-grade-dimensions.tsx +2 -2
- package/dist/chunk-THOVPZNN.js +0 -37
- package/dist/chunk-YS6BHKZO.js +0 -92
- package/dist/components/ranked-venue-list.d.ts +0 -49
- package/dist/components/ranked-venue-list.js +0 -87
- package/dist/components/signature-line-list.d.ts +0 -45
- package/dist/components/signature-line-list.js +0 -99
- package/dist/components/venue-busyness-strip.d.ts +0 -35
- package/dist/components/venue-busyness-strip.js +0 -37
- package/dist/components/venue-signature-block.d.ts +0 -39
- package/dist/components/venue-signature-block.js +0 -6
- package/dist/components/venue-spotlight.d.ts +0 -74
- package/dist/components/venue-spotlight.js +0 -138
- package/src/components/ranked-venue-list.stories.tsx +0 -80
- package/src/components/ranked-venue-list.tsx +0 -177
- package/src/components/signature-line-list.stories.tsx +0 -85
- package/src/components/signature-line-list.tsx +0 -136
- package/src/components/venue-busyness-strip.stories.tsx +0 -66
- package/src/components/venue-busyness-strip.tsx +0 -64
- package/src/components/venue-signature-block.stories.tsx +0 -68
- package/src/components/venue-signature-block.tsx +0 -71
- package/src/components/venue-spotlight.stories.tsx +0 -141
- package/src/components/venue-spotlight.tsx +0 -236
- package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -1,34 +1,64 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ArrowDown, MapPin, Star } from "lucide-react";
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
4
|
+
import { Separator } from "./separator";
|
|
5
|
+
import { Skeleton } from "./skeleton";
|
|
6
|
+
import { ConceptCompareRail } from "./concept-compare-rail";
|
|
5
7
|
import type { MenuConceptPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
* ConceptCompare — one dish or drink across
|
|
9
|
-
* whatever name each of them uses.
|
|
10
|
+
* ConceptCompare — one dish or drink across the kitchens that make it, under
|
|
11
|
+
* whatever name each of them uses. No surfaces at all.
|
|
10
12
|
*
|
|
11
13
|
* The keystone module: a single menu can list papanași, only a catalogue can
|
|
12
|
-
* say that
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* showing each kitchen's own wording is the evidence for that.
|
|
14
|
+
* say that forty-five kitchens make them under eight names between 29 and 52
|
|
15
|
+
* lei. Each kitchen's own wording is the evidence that these are matched rows
|
|
16
|
+
* rather than one row repeated.
|
|
16
17
|
*
|
|
17
|
-
* ## The
|
|
18
|
+
* ## The card IS the rooms
|
|
18
19
|
*
|
|
19
|
-
* Each
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* length is not.
|
|
20
|
+
* Every panel is gone. Each room is a VENUE photograph — real and licensed
|
|
21
|
+
* where an item's would be generated — with the kitchen's line floating over
|
|
22
|
+
* it, and the three readable things sit on glass: the identity in a band
|
|
23
|
+
* across the top, each kitchen's line over its own photograph, the foot along
|
|
24
|
+
* the bottom.
|
|
25
25
|
*
|
|
26
|
-
* ##
|
|
26
|
+
* ## Read across on desktop, one at a time on the phone
|
|
27
27
|
*
|
|
28
|
-
* From `lg` the
|
|
29
|
-
* the
|
|
30
|
-
*
|
|
31
|
-
* the
|
|
28
|
+
* From `lg` the rooms stand side by side — four kitchens, each with its own
|
|
29
|
+
* name for the dish and its own price, so comparing is a horizontal glance —
|
|
30
|
+
* and the head and foot are ABSOLUTE glass bands over them, so the photographs
|
|
31
|
+
* run the full height of the card beneath both. The head's glass fades out
|
|
32
|
+
* downward on the same curve as its mask; the foot's fades out upward the same
|
|
33
|
+
* way. It was the ink stopping short of the mask that drew a line where a band
|
|
34
|
+
* ended.
|
|
35
|
+
*
|
|
36
|
+
* Below `lg` four rooms in a 2×2 was too much happening in one card, so the
|
|
37
|
+
* rooms become a `SnapRail`: one room per swipe, page dots under it, the head
|
|
38
|
+
* and foot static on the card's ground above and below. The dots are the
|
|
39
|
+
* rail's own — the same object the hub's other rails use.
|
|
40
|
+
*
|
|
41
|
+
* ## The dark overlay, and the hover that lifts it
|
|
42
|
+
*
|
|
43
|
+
* Four busy interiors are four competing subjects, and type over them had
|
|
44
|
+
* nothing to sit on but its own local scrim — the eye could not settle. Every
|
|
45
|
+
* photograph carries a uniform dark overlay so the words lead and the picture
|
|
46
|
+
* supports; hovering a room lifts the overlay off THAT room only, so the reader
|
|
47
|
+
* can look at the place they are about to open.
|
|
48
|
+
*
|
|
49
|
+
* ## Light and dark
|
|
50
|
+
*
|
|
51
|
+
* There is nothing in it to theme. Photographs and their scrims are identical
|
|
52
|
+
* in both, because text over an image needs the same treatment either way —
|
|
53
|
+
* the content is `.dark`-scoped for exactly that. What adapts is the card's
|
|
54
|
+
* EDGE: a soft shadow and hairline on light, a deeper shadow and a light
|
|
55
|
+
* hairline on dark. The edge lives on the outer wrapper, outside the scope.
|
|
56
|
+
*
|
|
57
|
+
* ## No room here is a room that does not serve it
|
|
58
|
+
*
|
|
59
|
+
* The earlier card led with an AI-generated picture of the dish, and a
|
|
60
|
+
* generated image at the head of a block whose whole argument is "we counted
|
|
61
|
+
* real menus" undercut the argument. The rooms are real.
|
|
32
62
|
*/
|
|
33
63
|
|
|
34
64
|
/** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
|
|
@@ -39,52 +69,104 @@ function DefaultLink({ href, ...props }: ConceptCompareLinkProps) {
|
|
|
39
69
|
}
|
|
40
70
|
|
|
41
71
|
export type ConceptCompareFigure = {
|
|
42
|
-
/** Pre-formatted, e.g. "44". */
|
|
72
|
+
/** Pre-formatted, e.g. "44" or "29–52". */
|
|
43
73
|
value: string;
|
|
44
74
|
label: string;
|
|
75
|
+
/** Draws the value in amber — the spread, which is the number the card is about. */
|
|
76
|
+
accent?: boolean;
|
|
45
77
|
};
|
|
46
78
|
|
|
47
79
|
export type ConceptCompareLabels = {
|
|
48
80
|
/** Small caps kicker, e.g. "DISH CONCEPT". */
|
|
49
81
|
kind: string;
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
|
|
82
|
+
/** The badge on the lowest-priced room, e.g. "CHEAPEST". */
|
|
83
|
+
cheapest: string;
|
|
84
|
+
/** The badge on every other room, e.g. "#2". Defaults to `#${rank}`. */
|
|
85
|
+
rank?: (rank: number) => string;
|
|
86
|
+
/** Beside each price, e.g. "12 mentions". Pluralised by the caller. */
|
|
87
|
+
mentions: (count: number) => string;
|
|
88
|
+
/** Accessible name for a room's link, e.g. "Papanași at Casa Maramureșeană". */
|
|
89
|
+
openRoom: (alias: string, venue: string) => string;
|
|
56
90
|
};
|
|
57
91
|
|
|
58
92
|
export type ConceptCompareProps = {
|
|
59
93
|
concept: MenuConceptPreview;
|
|
60
94
|
labels: ConceptCompareLabels;
|
|
61
|
-
/**
|
|
62
|
-
|
|
95
|
+
/** One line under the title, e.g. "45 kitchens make it, and they do not agree on the name". */
|
|
96
|
+
lede?: string;
|
|
63
97
|
formatPrice: (amount: number) => string;
|
|
64
|
-
formatCount?: (value: number) => string;
|
|
65
|
-
/** CSS gradient for the header swatch, when there is no photograph. */
|
|
66
|
-
gradient: string;
|
|
67
|
-
/** A real photograph of the concept, when the matched menu row carries one. */
|
|
68
|
-
imageUrl?: string;
|
|
69
98
|
figures?: ConceptCompareFigure[];
|
|
70
|
-
/** Destination per
|
|
99
|
+
/** Destination per room, keyed by place slug. A room without one is inert. */
|
|
71
100
|
hrefs?: Record<string, string>;
|
|
72
|
-
/** The provenance note in the
|
|
101
|
+
/** The provenance note in the foot. */
|
|
73
102
|
footnote?: string;
|
|
74
|
-
/**
|
|
103
|
+
/** The action in the foot — pass a rendered link or button. */
|
|
75
104
|
actions?: React.ReactNode;
|
|
76
105
|
linkComponent?: MenuLinkComponent;
|
|
77
106
|
className?: string;
|
|
78
107
|
};
|
|
79
108
|
|
|
109
|
+
/** Four rooms across on desktop; the caller decides how many it sends. */
|
|
110
|
+
const MAX_ROOMS = 4;
|
|
111
|
+
|
|
112
|
+
/** The venue's first letter, for a room with no photograph. */
|
|
113
|
+
const initialOf = (name: string) => (name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
|
|
114
|
+
|
|
115
|
+
/** The card's ground is `PhotoGround`'s — the phone's surface for head and foot too. */
|
|
116
|
+
const GROUND = PHOTO_GROUND;
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
* The layers, bottom to top: the photograph, a DARK OVERLAY on it, the room's
|
|
120
|
+
* own scrim under its type, and the two glass bands. Written out in full rather
|
|
121
|
+
* than composed from a shared colour string: Tailwind emits only the classes it
|
|
122
|
+
* can read verbatim at build time, and a template literal is invisible to it —
|
|
123
|
+
* the class would be in the DOM and the style would not exist.
|
|
124
|
+
*
|
|
125
|
+
* Every layer shares one literal, `oklch(0.16 0.012 55)`, so they read as one
|
|
126
|
+
* shadow. Not `var(--scrim)`: that token is the app's, not this package's.
|
|
127
|
+
*/
|
|
128
|
+
|
|
129
|
+
/** `PhotoGround`'s overlay, plus the hover that lifts it off THIS room only. */
|
|
130
|
+
const PHOTO_OVERLAY_HOVER =
|
|
131
|
+
"transition-opacity duration-300 group-hover/room:opacity-0 motion-reduce:transition-none";
|
|
132
|
+
|
|
133
|
+
/** Each room's own scrim, rising from its foot under the type. */
|
|
134
|
+
const ROOM_SCRIM =
|
|
135
|
+
"absolute inset-0 bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0)_0%,oklch(0.16_0.012_55_/_0)_30%,oklch(0.16_0.012_55_/_0.55)_58%,oklch(0.16_0.012_55_/_0.9)_100%)]";
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The head band's glass. Mask and ink both run the band's full height and go
|
|
139
|
+
* to ZERO at its underside on the same curve — a soft edge, not a line.
|
|
140
|
+
*/
|
|
141
|
+
const HEAD_GLASS =
|
|
142
|
+
"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-[''] before:backdrop-blur-lg before:[mask-image:linear-gradient(to_bottom,black_35%,transparent_100%)] before:bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.8)_0%,oklch(0.16_0.012_55_/_0.6)_35%,oklch(0.16_0.012_55_/_0.22)_75%,oklch(0.16_0.012_55_/_0)_100%)]";
|
|
143
|
+
|
|
144
|
+
/* The foot band's glass lives in `concept-compare-rail.tsx`, with the foot. */
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Desktop column templates by room count — literal, for the scanner. The phone
|
|
148
|
+
* ignores these: there the rail lays the rooms out.
|
|
149
|
+
*/
|
|
150
|
+
const COLUMNS: Record<number, string> = {
|
|
151
|
+
1: "lg:grid-cols-1",
|
|
152
|
+
2: "lg:grid-cols-2",
|
|
153
|
+
3: "lg:grid-cols-3",
|
|
154
|
+
4: "lg:grid-cols-4",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* How far a room's text sits above the card's foot on desktop, where the foot
|
|
159
|
+
* is an absolute band over the rooms. The band is one line of prose and a
|
|
160
|
+
* button, ~80px; the padding clears it with room to spare at `lg`, where the
|
|
161
|
+
* prose can wrap to two lines.
|
|
162
|
+
*/
|
|
163
|
+
const DESKTOP_FOOT_CLEARANCE = "lg:pb-[104px]";
|
|
164
|
+
|
|
80
165
|
export function ConceptCompare({
|
|
81
166
|
concept,
|
|
82
167
|
labels,
|
|
83
|
-
|
|
168
|
+
lede,
|
|
84
169
|
formatPrice,
|
|
85
|
-
formatCount,
|
|
86
|
-
gradient,
|
|
87
|
-
imageUrl,
|
|
88
170
|
figures,
|
|
89
171
|
hrefs,
|
|
90
172
|
footnote,
|
|
@@ -92,185 +174,270 @@ export function ConceptCompare({
|
|
|
92
174
|
linkComponent: LinkComponent = DefaultLink,
|
|
93
175
|
className,
|
|
94
176
|
}: ConceptCompareProps) {
|
|
95
|
-
const
|
|
96
|
-
|
|
177
|
+
const rooms = (concept.rows ?? []).slice(0, MAX_ROOMS);
|
|
178
|
+
const count = rooms.length;
|
|
179
|
+
if (count === 0) return null;
|
|
180
|
+
|
|
181
|
+
// Derived, not passed: the cheapest room is a fact about THIS list, and a
|
|
97
182
|
// caller computing it separately could disagree with what is rendered.
|
|
98
|
-
const cheapest =
|
|
99
|
-
(lowest,
|
|
183
|
+
const cheapest = rooms.reduce<number | undefined>(
|
|
184
|
+
(lowest, room) => (lowest === undefined || room.price < lowest ? room.price : lowest),
|
|
100
185
|
undefined
|
|
101
186
|
);
|
|
187
|
+
const rankOf = labels.rank ?? ((rank: number) => `#${rank}`);
|
|
188
|
+
const hasFoot = Boolean(footnote || actions);
|
|
102
189
|
|
|
103
190
|
return (
|
|
104
|
-
<div
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
{figures.map((figure) => (
|
|
134
|
-
<div key={figure.label} className="text-left lg:text-right">
|
|
135
|
-
<div className="text-xl font-extrabold leading-none tracking-[-0.03em] tabular-nums lg:text-2xl">
|
|
136
|
-
{figure.value}
|
|
137
|
-
</div>
|
|
138
|
-
<div className="mt-1 text-[10.5px] text-muted-foreground">{figure.label}</div>
|
|
139
|
-
</div>
|
|
140
|
-
))}
|
|
191
|
+
<div
|
|
192
|
+
className={cn(
|
|
193
|
+
/* The edge, OUTSIDE the dark scope — the one thing about this card
|
|
194
|
+
that belongs to the page it sits on. */
|
|
195
|
+
"overflow-hidden rounded-[18px] border border-border shadow-[var(--shadow-card)]",
|
|
196
|
+
className
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
<div className={cn("dark relative isolate flex flex-col text-foreground", GROUND)}>
|
|
200
|
+
{/* ── The identity ─────────────────────────────────────────────── */}
|
|
201
|
+
{/* A `div`, not `<header>`. Outside `<article>`/`<section>` a `<header>`
|
|
202
|
+
is the `banner` landmark and a `<footer>` is `contentinfo`, so each
|
|
203
|
+
card would have announced itself as the page's own header and
|
|
204
|
+
footer to a screen reader. */}
|
|
205
|
+
<div
|
|
206
|
+
className={cn(
|
|
207
|
+
"relative isolate flex flex-col gap-3 px-4 pb-5 pt-4",
|
|
208
|
+
"lg:absolute lg:inset-x-0 lg:top-0 lg:z-10 lg:flex-row lg:items-start lg:justify-between lg:gap-6 lg:px-5 lg:pb-12 lg:pt-5",
|
|
209
|
+
HEAD_GLASS
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
<div className="min-w-0">
|
|
213
|
+
<span className="block text-[10.5px] font-extrabold uppercase tracking-[0.13em] text-white/70">
|
|
214
|
+
{labels.kind}
|
|
215
|
+
</span>
|
|
216
|
+
<h3 className="mt-1 font-serif text-[28px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[32px]">
|
|
217
|
+
{concept.title}
|
|
218
|
+
</h3>
|
|
219
|
+
{lede && <p className="mt-1 text-[13px] leading-snug text-white/75">{lede}</p>}
|
|
141
220
|
</div>
|
|
142
|
-
)}
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
{/* ── Below `lg`: the production item row ─────────────────────────── */}
|
|
146
|
-
<ul className="border-t border-border lg:hidden">
|
|
147
|
-
{rows.map((row) => (
|
|
148
|
-
<li key={row.place_slug} className="border-b border-border last:border-b-0">
|
|
149
|
-
<MenuItem
|
|
150
|
-
item={{
|
|
151
|
-
id: row.place_slug,
|
|
152
|
-
slug: row.place_slug,
|
|
153
|
-
// The kitchen's OWN wording leads the row — that IS the point
|
|
154
|
-
// of the module.
|
|
155
|
-
name: row.alias,
|
|
156
|
-
kind: concept.kind,
|
|
157
|
-
mentions: row.mentions,
|
|
158
|
-
sizes_and_prices: [{ size: 0, price: row.price }],
|
|
159
|
-
}}
|
|
160
|
-
labels={itemLabels}
|
|
161
|
-
formatPrice={formatPrice}
|
|
162
|
-
formatCount={formatCount}
|
|
163
|
-
venueName={row.place_name}
|
|
164
|
-
href={hrefs?.[row.place_slug]}
|
|
165
|
-
linkComponent={LinkComponent}
|
|
166
|
-
className={cn(row.price === cheapest && "bg-accent-subtle/40")}
|
|
167
|
-
/>
|
|
168
|
-
</li>
|
|
169
|
-
))}
|
|
170
|
-
</ul>
|
|
171
221
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
222
|
+
{figures && figures.length > 0 && (
|
|
223
|
+
<dl className="flex shrink-0 flex-wrap gap-x-5 gap-y-2 lg:gap-x-6">
|
|
224
|
+
{figures.map((figure) => (
|
|
225
|
+
<div key={figure.label} className="flex flex-col items-start lg:items-center">
|
|
226
|
+
<dd
|
|
227
|
+
className={cn(
|
|
228
|
+
"text-[22px] font-bold leading-none tabular-nums tracking-[-0.02em]",
|
|
229
|
+
figure.accent ? "text-[oklch(0.82_0.15_85)]" : "text-white"
|
|
230
|
+
)}
|
|
231
|
+
>
|
|
232
|
+
{figure.value}
|
|
233
|
+
</dd>
|
|
234
|
+
<dt className="mt-1 text-[11px] text-white/65">{figure.label}</dt>
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
</dl>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
182
240
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
241
|
+
{/* ── The rooms: a rail on the phone, a row from `lg` ──────────── */}
|
|
242
|
+
{/* The rail also owns the phone's backdrop — the card's ground is the
|
|
243
|
+
ACTIVE room's photograph, cross-fading on swipe. That is the one
|
|
244
|
+
piece of state in this card, so it is the one client boundary, and
|
|
245
|
+
it takes only what can cross one: URLs, initials, rendered rooms. */}
|
|
246
|
+
<ConceptCompareRail
|
|
247
|
+
backdrops={rooms.map((room) => ({
|
|
248
|
+
src: room.place_thumbnail_url,
|
|
249
|
+
initial: initialOf(room.place_name),
|
|
250
|
+
}))}
|
|
251
|
+
railClassName={cn(
|
|
252
|
+
"lg:grid lg:gap-px lg:overflow-visible lg:[&>*]:w-auto",
|
|
253
|
+
COLUMNS[count] ?? COLUMNS[4]
|
|
254
|
+
)}
|
|
255
|
+
/* The foot's CONTENT; the rail draws the band, because on the phone
|
|
256
|
+
the page dots belong inside its glass and the dots are the rail's
|
|
257
|
+
state. */
|
|
258
|
+
foot={
|
|
259
|
+
hasFoot ? (
|
|
260
|
+
<>
|
|
261
|
+
{footnote && (
|
|
262
|
+
<p className="min-w-0 flex-1 text-[12px] leading-relaxed text-white/70">
|
|
263
|
+
{footnote}
|
|
264
|
+
</p>
|
|
192
265
|
)}
|
|
193
|
-
{
|
|
194
|
-
<
|
|
195
|
-
aria-hidden
|
|
196
|
-
className="absolute right-0.5 top-0.5 h-[6px] w-[6px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
|
|
197
|
-
/>
|
|
266
|
+
{actions && (
|
|
267
|
+
<div className="flex shrink-0 [&>*]:w-full lg:[&>*]:w-auto">{actions}</div>
|
|
198
268
|
)}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
269
|
+
</>
|
|
270
|
+
) : undefined
|
|
271
|
+
}
|
|
272
|
+
>
|
|
273
|
+
{rooms.map((room, index) => {
|
|
274
|
+
const href = hrefs?.[room.place_slug];
|
|
275
|
+
const isCheapest = room.price === cheapest;
|
|
276
|
+
const roomClass = cn(
|
|
277
|
+
/* Transparent on the phone — the card behind it is the room's
|
|
278
|
+
photograph — with only its scrim and its line. A photograph of
|
|
279
|
+
its own from `lg`, where four stand side by side. */
|
|
280
|
+
"group/room relative isolate flex aspect-[4/3] flex-col justify-end overflow-hidden p-4",
|
|
281
|
+
"lg:aspect-auto lg:min-h-[440px] lg:p-4",
|
|
282
|
+
DESKTOP_FOOT_CLEARANCE
|
|
283
|
+
);
|
|
284
|
+
const body = (
|
|
285
|
+
<>
|
|
286
|
+
{/* Behind the type: the room's own photograph and its overlay
|
|
287
|
+
(desktop only — on the phone the card's ground is the
|
|
288
|
+
photograph), and the scrim (both). */}
|
|
289
|
+
<span aria-hidden className="absolute inset-0 -z-10">
|
|
290
|
+
<PhotoGround
|
|
291
|
+
className="hidden lg:block"
|
|
292
|
+
photos={[room.place_thumbnail_url]}
|
|
293
|
+
initials={initialOf(room.place_name)}
|
|
294
|
+
mosaicClassName="grid-cols-1"
|
|
295
|
+
overlay
|
|
296
|
+
overlayClassName={PHOTO_OVERLAY_HOVER}
|
|
297
|
+
/>
|
|
298
|
+
<span className={ROOM_SCRIM} />
|
|
204
299
|
</span>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
300
|
+
|
|
301
|
+
<span
|
|
302
|
+
className={cn(
|
|
303
|
+
"inline-flex h-[22px] w-max items-center gap-1 rounded-full px-2 text-[10px] font-extrabold uppercase tracking-[0.08em]",
|
|
304
|
+
isCheapest
|
|
305
|
+
? "bg-success text-white"
|
|
306
|
+
: "bg-white/15 text-white/85 backdrop-blur-[2px]"
|
|
211
307
|
)}
|
|
212
|
-
|
|
308
|
+
>
|
|
309
|
+
{isCheapest && <ArrowDown className="h-3 w-3 shrink-0" aria-hidden />}
|
|
310
|
+
{isCheapest ? labels.cheapest : rankOf(index + 1)}
|
|
213
311
|
</span>
|
|
214
|
-
</span>
|
|
215
312
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
313
|
+
{/* Name and rating on one line: the pin leads, the name takes
|
|
314
|
+
what is left, the star sits at the end. */}
|
|
315
|
+
<span className="mt-2.5 flex items-center gap-1.5 text-white">
|
|
316
|
+
<MapPin className="h-3.5 w-3.5 shrink-0 text-white/70" aria-hidden />
|
|
317
|
+
<b className="min-w-0 flex-1 truncate text-[15px] font-bold leading-tight">
|
|
318
|
+
{room.place_name}
|
|
319
|
+
</b>
|
|
320
|
+
{room.rating !== undefined && (
|
|
321
|
+
<span className="flex shrink-0 items-center gap-1 text-[12px] text-white/70">
|
|
322
|
+
<Star className="h-3 w-3 shrink-0" aria-hidden />
|
|
323
|
+
<span className="tabular-nums">{room.rating}</span>
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
219
326
|
</span>
|
|
220
|
-
|
|
221
|
-
|
|
327
|
+
{room.place_area && (
|
|
328
|
+
<span className="mt-0.5 block text-[12px] text-white/70">{room.place_area}</span>
|
|
329
|
+
)}
|
|
222
330
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
{
|
|
331
|
+
{/* The kitchen's own wording — the evidence that these are
|
|
332
|
+
matched rows rather than one row repeated. */}
|
|
333
|
+
<span className="mt-2 block truncate font-serif text-[14px] italic text-white/90">
|
|
334
|
+
{room.alias}
|
|
227
335
|
</span>
|
|
228
|
-
)}
|
|
229
336
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
337
|
+
{/* The DS rule. `via-white/20` because the dark theme's `--border`
|
|
338
|
+
is a 0.32-lightness grey that vanishes on inked photography. */}
|
|
339
|
+
<Separator className="my-2.5 via-white/20" />
|
|
340
|
+
|
|
341
|
+
<span className="flex items-baseline gap-2">
|
|
342
|
+
<b
|
|
343
|
+
className={cn(
|
|
344
|
+
"text-[19px] font-bold leading-none tabular-nums tracking-[-0.02em]",
|
|
345
|
+
isCheapest ? "text-success" : "text-white"
|
|
346
|
+
)}
|
|
347
|
+
>
|
|
348
|
+
{formatPrice(room.price)}
|
|
349
|
+
</b>
|
|
350
|
+
{room.mentions !== undefined && room.mentions > 0 && (
|
|
351
|
+
<span className="text-[11.5px] text-white/65">
|
|
352
|
+
{labels.mentions(room.mentions)}
|
|
353
|
+
</span>
|
|
235
354
|
)}
|
|
236
|
-
>
|
|
237
|
-
{formatPrice(row.price)}
|
|
238
|
-
</b>
|
|
239
|
-
<span className="block text-[10px] text-muted-foreground">
|
|
240
|
-
{isCheapest && labels.cheapest ? labels.cheapest : labels.perPortion}
|
|
241
355
|
</span>
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
356
|
+
</>
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
return href ? (
|
|
360
|
+
<LinkComponent
|
|
361
|
+
key={room.place_slug}
|
|
362
|
+
href={href}
|
|
363
|
+
aria-label={labels.openRoom(room.alias, room.place_name)}
|
|
364
|
+
className={cn(
|
|
365
|
+
roomClass,
|
|
366
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
|
|
367
|
+
)}
|
|
368
|
+
>
|
|
369
|
+
{body}
|
|
370
|
+
</LinkComponent>
|
|
371
|
+
) : (
|
|
372
|
+
<div key={room.place_slug} className={roomClass}>
|
|
373
|
+
{body}
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
})}
|
|
377
|
+
</ConceptCompareRail>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
245
382
|
|
|
246
|
-
|
|
383
|
+
// ─── Skeleton ───────────────────────────────────────────────────────────────
|
|
247
384
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
385
|
+
/**
|
|
386
|
+
* The card's own loading state, at the card's footprint.
|
|
387
|
+
*
|
|
388
|
+
* The ground is the card's own dark surface, so the placeholder is already
|
|
389
|
+
* what the photographs will land on and nothing flashes from paper to ink.
|
|
390
|
+
* The phone shape is one room tall with the head above and the foot below;
|
|
391
|
+
* from `lg` it is the four-room row with the head and foot laid over it.
|
|
392
|
+
*/
|
|
393
|
+
export function ConceptCompareSkeleton({ className }: { className?: string }) {
|
|
394
|
+
return (
|
|
395
|
+
<div
|
|
396
|
+
className={cn(
|
|
397
|
+
"overflow-hidden rounded-[18px] border border-border shadow-[var(--shadow-card)]",
|
|
398
|
+
className
|
|
399
|
+
)}
|
|
400
|
+
aria-hidden
|
|
401
|
+
>
|
|
402
|
+
<div className={cn("dark relative flex flex-col text-foreground", GROUND)}>
|
|
403
|
+
<div className="flex flex-col gap-3 px-4 pb-5 pt-4 lg:absolute lg:inset-x-0 lg:top-0 lg:z-10 lg:flex-row lg:justify-between lg:px-5 lg:pt-5">
|
|
404
|
+
<div>
|
|
405
|
+
<Skeleton className="h-2.5 w-[90px] bg-white/15" />
|
|
406
|
+
<Skeleton className="mt-2 h-8 w-[160px] bg-white/15" />
|
|
407
|
+
<Skeleton className="mt-2 h-3.5 w-[240px] bg-white/15" />
|
|
408
|
+
</div>
|
|
409
|
+
<div className="flex gap-5">
|
|
410
|
+
{Array.from({ length: 4 }, (_, index) => (
|
|
411
|
+
<div key={index} className="flex flex-col items-start gap-1.5 lg:items-center">
|
|
412
|
+
<Skeleton className="h-6 w-8 bg-white/15" />
|
|
413
|
+
<Skeleton className="h-2.5 w-12 bg-white/15" />
|
|
414
|
+
</div>
|
|
415
|
+
))}
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
<div className="grid grid-cols-1 lg:grid-cols-4 lg:gap-px">
|
|
420
|
+
{Array.from({ length: 4 }, (_, index) => (
|
|
421
|
+
<div
|
|
422
|
+
key={index}
|
|
423
|
+
className={cn(
|
|
424
|
+
"flex aspect-[4/3] flex-col justify-end p-4 lg:aspect-auto lg:min-h-[440px] lg:pb-[104px]",
|
|
425
|
+
index > 0 && "hidden lg:flex"
|
|
262
426
|
)}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
427
|
+
>
|
|
428
|
+
<Skeleton className="h-[22px] w-[72px] rounded-full bg-white/15" />
|
|
429
|
+
<Skeleton className="mt-2.5 h-4 w-[60%] bg-white/15" />
|
|
430
|
+
<Skeleton className="mt-2 h-3.5 w-[50%] bg-white/15" />
|
|
431
|
+
<Skeleton className="mt-4 h-5 w-[80px] bg-white/15" />
|
|
432
|
+
</div>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
267
435
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
|
|
436
|
+
<div className="flex flex-col gap-3 px-4 pb-4 pt-3 lg:absolute lg:inset-x-0 lg:bottom-0 lg:flex-row lg:items-center lg:gap-5 lg:px-5 lg:pb-4 lg:pt-6">
|
|
437
|
+
<Skeleton className="h-3 w-full bg-white/15 lg:flex-1" />
|
|
438
|
+
<Skeleton className="h-10 w-full rounded-full bg-white/15 lg:w-[120px]" />
|
|
272
439
|
</div>
|
|
273
|
-
|
|
440
|
+
</div>
|
|
274
441
|
</div>
|
|
275
442
|
);
|
|
276
443
|
}
|