@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
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { MapPin, MessageSquareQuote, Star } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Separator } from "./separator";
|
|
4
|
+
import { Skeleton } from "./skeleton";
|
|
5
|
+
import { MenuItem, type MenuItemLabels } from "./menu-item";
|
|
6
|
+
import type { MenuItemPreview, MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
7
|
+
import type { TopRankEntry } from "./top-types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* TopRankingEntry — one rank, as an article entry.
|
|
11
|
+
*
|
|
12
|
+
* ## A numbered editorial list, not a grid of cards
|
|
13
|
+
*
|
|
14
|
+
* Cards imply a grid you filter, and they force every entry into the same
|
|
15
|
+
* height — wrong when the evidence varies, because one kitchen has a three-line
|
|
16
|
+
* quote and another has a sentence. A numbered list lets each entry be as long
|
|
17
|
+
* as its argument, which is what reads as an article, which is what ranks.
|
|
18
|
+
*
|
|
19
|
+
* ## Evidence leads, because a rank is a claim
|
|
20
|
+
*
|
|
21
|
+
* The cited review is the entry's first content, not a footnote. The dish it
|
|
22
|
+
* names sits under it with its price and its mention count. The old page had
|
|
23
|
+
* this the other way round — name, grade, photo, then evidence somewhere below
|
|
24
|
+
* the fold — which asked the reader to accept the position before showing them
|
|
25
|
+
* any reason for it.
|
|
26
|
+
*
|
|
27
|
+
* ## Two targets, the same rule as everywhere
|
|
28
|
+
*
|
|
29
|
+
* The entry goes to the venue; the signature dish opens the item drawer. Not
|
|
30
|
+
* implemented as a stretched overlay here, unlike `MenuKitchenList`: this entry
|
|
31
|
+
* is a paragraph of prose with several links inside it, so a full-bleed overlay
|
|
32
|
+
* would swallow them. The name and the cover are the venue's targets.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
function DefaultLink({ href, ...props }: MenuLinkProps) {
|
|
36
|
+
return <a href={href} {...props} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TopRankingEntryLabels = {
|
|
40
|
+
/** Heading over the menu rows when the venue's own group is unknown. */
|
|
41
|
+
signature: string;
|
|
42
|
+
/** Accessible name for the dish link. */
|
|
43
|
+
openDish: (name: string) => string;
|
|
44
|
+
/** Passed through to the production `<MenuItem>`. */
|
|
45
|
+
item: MenuItemLabels;
|
|
46
|
+
/**
|
|
47
|
+
* Opens an item's drawer. The DS owns no routing, so the caller composes it.
|
|
48
|
+
*
|
|
49
|
+
* Takes the ITEM, not its slug: a menu row is a dish or a drink and the two
|
|
50
|
+
* resolve through different endpoints, so the caller has to see `kind` to
|
|
51
|
+
* build the right link. `undefined` leaves the row inert, which is the
|
|
52
|
+
* text-only must-try mention with no live menu row behind it.
|
|
53
|
+
*/
|
|
54
|
+
itemHref: (item: MenuItemPreview) => string | undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type TopRankingEntryProps = {
|
|
58
|
+
entry: TopRankEntry;
|
|
59
|
+
labels: TopRankingEntryLabels;
|
|
60
|
+
formatPrice: (amount: number) => string;
|
|
61
|
+
formatCount?: (value: number) => string;
|
|
62
|
+
/** The category's hue, for the menu rows' colour mark. */
|
|
63
|
+
markGradient?: string;
|
|
64
|
+
linkComponent?: MenuLinkComponent;
|
|
65
|
+
className?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The scrim under the rank, name, district and grade on the phone.
|
|
70
|
+
*
|
|
71
|
+
* A literal, never `var(--scrim)`: that token is defined in the consumer app's
|
|
72
|
+
* stylesheet and not in this package's, so a DS component reading it renders an
|
|
73
|
+
* invisible scrim in Storybook, admin and the post-generator — and the failure
|
|
74
|
+
* only appears in the consumers nobody is looking at. Same base colour as
|
|
75
|
+
* `MenuFamilyCard` and `MenuFamilyBlock`, so the three are one family.
|
|
76
|
+
*
|
|
77
|
+
* The stops are in PIXELS from the block's top, not percentages of the cover.
|
|
78
|
+
* The cover is an aspect ratio, so its height runs 192px to 265px across the
|
|
79
|
+
* phone range and the name wraps to one line or two; a percentage ramp would
|
|
80
|
+
* put the first glyph anywhere between alpha 0.52 and 0.79 depending on the
|
|
81
|
+
* handset and the venue. Anchored to the fixed `pt-12` runway it is 0.72 every
|
|
82
|
+
* time — which is what makes the contrast floor a guarantee rather than an
|
|
83
|
+
* average. Against a pure-white photograph that is 7.3:1 on the name.
|
|
84
|
+
*
|
|
85
|
+
* If it ever bands on a flat bright sky, lengthen the runway (`pt-14`, 0.72
|
|
86
|
+
* stop to 56px). Never lower the terminal alpha — that is the guarantee.
|
|
87
|
+
*
|
|
88
|
+
* Applied unprefixed and cleared with `lg:bg-none`, NOT written as `max-lg:`.
|
|
89
|
+
* This project's Tailwind emits no `max-width` media queries at all, so every
|
|
90
|
+
* `max-lg:` class is silently dropped — no error, no style, and on this
|
|
91
|
+
* component that meant white type on white paper in the gap under the photo.
|
|
92
|
+
*/
|
|
93
|
+
const ENTRY_COVER_SCRIM =
|
|
94
|
+
"bg-[linear-gradient(180deg,oklch(0.16_0.012_55_/_0)_0px,oklch(0.16_0.012_55_/_0.06)_16px,oklch(0.16_0.012_55_/_0.3)_32px,oklch(0.16_0.012_55_/_0.72)_48px,oklch(0.16_0.012_55_/_0.95)_100%)]";
|
|
95
|
+
|
|
96
|
+
export function TopRankingEntry({
|
|
97
|
+
entry,
|
|
98
|
+
labels,
|
|
99
|
+
formatPrice,
|
|
100
|
+
formatCount,
|
|
101
|
+
markGradient,
|
|
102
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
103
|
+
className,
|
|
104
|
+
}: TopRankingEntryProps) {
|
|
105
|
+
const initial = (entry.name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<article
|
|
109
|
+
id={entry.slug}
|
|
110
|
+
/* A one-column GRID on the phone so the identity block can share the
|
|
111
|
+
cover's cell, and the same flex row it always was from `lg` — the
|
|
112
|
+
desktop declarations below compute to exactly today's box. */
|
|
113
|
+
className={cn("grid grid-cols-1 gap-0 py-6 lg:flex lg:flex-row lg:gap-7", className)}
|
|
114
|
+
>
|
|
115
|
+
{/*
|
|
116
|
+
The cover sits beside the entry rather than above it: the column has the
|
|
117
|
+
room, and a full-width photograph per entry turns twenty-five of them
|
|
118
|
+
into a scroll with no argument visible at any point.
|
|
119
|
+
|
|
120
|
+
## One box, the same on every entry
|
|
121
|
+
|
|
122
|
+
It was 300×225, and entries run roughly 300–370px tall — so the short
|
|
123
|
+
cover left a hole under itself on nearly all of them. Letting it stretch
|
|
124
|
+
to the row would close that, but then no two entries share a photo size,
|
|
125
|
+
and a ranking whose thumbnails all differ reads as broken rather than as
|
|
126
|
+
responsive. Fixed 4:5 instead: it sits at the common entry height, and
|
|
127
|
+
where it overshoots, the text simply ends — a much softer failure than an
|
|
128
|
+
image floating above a void.
|
|
129
|
+
|
|
130
|
+
`object-cover` absorbs the source ratios. Restaurant photography is
|
|
131
|
+
landscape, so a 4:5 centre-crop loses the sides and keeps the subject.
|
|
132
|
+
*/}
|
|
133
|
+
<LinkComponent
|
|
134
|
+
href={entry.href}
|
|
135
|
+
className="col-start-1 row-start-1 block shrink-0 overflow-hidden rounded-xl transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:opacity-90 motion-reduce:transition-none lg:sticky lg:top-4 lg:w-[300px] lg:self-start"
|
|
136
|
+
>
|
|
137
|
+
{/* 3:2 on the phone, 4:5 from `lg`.
|
|
138
|
+
4:5 is ~448px at 390 — an entry ran ~880px, so a reader met one
|
|
139
|
+
venue per two screens on a page whose unit of reading is the entry.
|
|
140
|
+
3:2 halves the cover and, with the identity line folded into it,
|
|
141
|
+
brings an entry to roughly one viewport. It is also the ratio the
|
|
142
|
+
source photography is actually shot at, so unlike 4:5 the crop
|
|
143
|
+
throws almost nothing away.
|
|
144
|
+
|
|
145
|
+
The warm ground is the phone's; desktop restores its stripes. Base
|
|
146
|
+
first and `lg:` to override, never `max-lg:` — this project's
|
|
147
|
+
Tailwind emits no `max-width` media queries at all, so a `max-lg:`
|
|
148
|
+
class is silently dropped and the style simply never exists. */}
|
|
149
|
+
<span className="relative block aspect-[3/2] w-full bg-[oklch(0.21_0.012_55)] lg:aspect-[4/5] lg:bg-[repeating-linear-gradient(135deg,oklch(0.82_0.014_55)_0_10px,oklch(0.87_0.01_58)_10px_20px)]">
|
|
150
|
+
{entry.coverUrl ? (
|
|
151
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
152
|
+
<img
|
|
153
|
+
src={entry.coverUrl}
|
|
154
|
+
alt=""
|
|
155
|
+
loading="lazy"
|
|
156
|
+
decoding="async"
|
|
157
|
+
className="h-full w-full object-cover"
|
|
158
|
+
/>
|
|
159
|
+
) : (
|
|
160
|
+
/* Never a grey box: the warm ground with the venue's initial, the
|
|
161
|
+
rule `MenuFamilyBlock` states. Phone only — desktop keeps its
|
|
162
|
+
stripes. The stripes are a LIGHT surface in both themes, so at
|
|
163
|
+
this size they would be a pale rectangle under white type.
|
|
164
|
+
The ground stays on the parent span, so a 404 on `coverUrl`
|
|
165
|
+
degrades to the same surface for free. */
|
|
166
|
+
<span
|
|
167
|
+
aria-hidden
|
|
168
|
+
className="absolute inset-0 flex items-center justify-center lg:hidden"
|
|
169
|
+
>
|
|
170
|
+
<span className="font-serif text-[64px] font-semibold leading-none text-white/25">
|
|
171
|
+
{initial}
|
|
172
|
+
</span>
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</span>
|
|
176
|
+
</LinkComponent>
|
|
177
|
+
|
|
178
|
+
{/* `contents` on the phone so the identity block below becomes a direct
|
|
179
|
+
grid child and can be placed in the cover's cell. One DOM copy, not a
|
|
180
|
+
`hidden`/`lg:hidden` twin: this page's whole value is search, and 25
|
|
181
|
+
duplicated headings and venue links is not a thing to ship to avoid a
|
|
182
|
+
`display: contents`. */}
|
|
183
|
+
<div className="contents lg:block lg:min-w-0 lg:flex-1">
|
|
184
|
+
{/* On the phone this sits IN the photograph, bottom-anchored, and
|
|
185
|
+
carries its own scrim. Grid paints items in document order, so the
|
|
186
|
+
block covers the cover anchor with no `z-index`.
|
|
187
|
+
|
|
188
|
+
`flex-wrap` + `basis-full` on the rank is what gives the phone its
|
|
189
|
+
two-line stack — rank on its own line, then the name column and the
|
|
190
|
+
grade pill sharing the next — while desktop keeps the single
|
|
191
|
+
no-wrap row with the numeral out to the left. One DOM order, two
|
|
192
|
+
compositions, no duplicated markup. */}
|
|
193
|
+
<div
|
|
194
|
+
className={cn(
|
|
195
|
+
/* `relative z-[1]`: without it this block's background is a
|
|
196
|
+
non-positioned box's background, which paints in an earlier phase
|
|
197
|
+
than the cover's replaced `<img>` — so the scrim went UNDER the
|
|
198
|
+
photograph and the white type landed on bare image. Positioning
|
|
199
|
+
it lifts the whole block, background included, above the image.
|
|
200
|
+
Harmless at `lg`, where the two no longer overlap. */
|
|
201
|
+
"pointer-events-none relative z-[1] col-start-1 row-start-1 flex flex-wrap items-end gap-x-3 gap-y-0 self-end",
|
|
202
|
+
"rounded-b-xl px-3.5 pb-3.5 pt-12 [text-shadow:0_1px_2px_oklch(0.16_0.012_55_/_0.45)]",
|
|
203
|
+
ENTRY_COVER_SCRIM,
|
|
204
|
+
"lg:pointer-events-auto lg:flex-nowrap lg:items-start lg:gap-3 lg:rounded-none lg:bg-none lg:p-0 lg:[text-shadow:none]"
|
|
205
|
+
)}
|
|
206
|
+
>
|
|
207
|
+
<span
|
|
208
|
+
aria-hidden
|
|
209
|
+
className="basis-full text-[11px] font-bold leading-none tabular-nums text-white/70 lg:basis-auto lg:font-serif lg:text-[28px] lg:font-semibold lg:text-muted-foreground"
|
|
210
|
+
>
|
|
211
|
+
{entry.rank}
|
|
212
|
+
</span>
|
|
213
|
+
|
|
214
|
+
<div className="min-w-0 flex-1">
|
|
215
|
+
<h3 className="line-clamp-2 text-lg font-bold leading-tight tracking-[-0.015em] text-white lg:line-clamp-none lg:text-foreground">
|
|
216
|
+
<LinkComponent
|
|
217
|
+
href={entry.href}
|
|
218
|
+
/* `lg:hover:` — sticky hover on touch would leave the name
|
|
219
|
+
terracotta on the scrim, which measures 1.28:1. The ring
|
|
220
|
+
inverts for the same reason: `--ring` is a dark brown drawn
|
|
221
|
+
on a dark scrim. */
|
|
222
|
+
className="pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white lg:focus-visible:ring-ring lg:hover:text-accent"
|
|
223
|
+
>
|
|
224
|
+
{entry.name}
|
|
225
|
+
</LinkComponent>
|
|
226
|
+
</h3>
|
|
227
|
+
{entry.district && (
|
|
228
|
+
<span className="mt-0.5 flex items-center gap-1 text-[12.5px] text-white/80 lg:text-muted-foreground">
|
|
229
|
+
<MapPin className="h-3 w-3 shrink-0" aria-hidden />
|
|
230
|
+
{entry.district}
|
|
231
|
+
</span>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
{/* The grade, with the band it falls in. A bare 8.7 is a number; "8.7
|
|
236
|
+
Excellent" is the claim the number is making. The star is the
|
|
237
|
+
third-party fallback for a venue the reviews have not graded.
|
|
238
|
+
|
|
239
|
+
Over the photograph it INVERTS rather than dissolving: a white
|
|
240
|
+
pill with dark ink, the same mode-invariant device the family
|
|
241
|
+
block's "Browse" pill uses. `bg-accent-subtle` could not stay —
|
|
242
|
+
it is near-white in one theme and near-black in the other, and
|
|
243
|
+
photography is not themed — and dropping the container instead
|
|
244
|
+
would have cost the band word, which is the half that says what
|
|
245
|
+
the number MEANS. */}
|
|
246
|
+
{entry.gradeLabel ? (
|
|
247
|
+
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-white/95 px-2.5 py-1 backdrop-blur-[2px] lg:gap-2 lg:rounded-lg lg:bg-accent-subtle lg:px-2.5 lg:py-1.5 lg:backdrop-blur-none">
|
|
248
|
+
<b className="text-[15px] font-extrabold tabular-nums text-[oklch(0.21_0.012_55)] lg:text-accent">
|
|
249
|
+
{entry.gradeLabel}
|
|
250
|
+
</b>
|
|
251
|
+
{entry.tierLabel && (
|
|
252
|
+
<span className="text-[11.5px] text-[oklch(0.21_0.012_55)]/70 lg:text-muted-foreground">
|
|
253
|
+
{entry.tierLabel}
|
|
254
|
+
</span>
|
|
255
|
+
)}
|
|
256
|
+
</span>
|
|
257
|
+
) : (
|
|
258
|
+
entry.rating !== undefined && (
|
|
259
|
+
/* The star fallback inverts the same way as the grade — a white
|
|
260
|
+
pill, deliberately quieter than a grade, because a third-party
|
|
261
|
+
rating is weaker evidence and should look it. Filled, since an
|
|
262
|
+
outlined star at this size disappears over photography. */
|
|
263
|
+
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-white/95 px-2.5 py-1 text-[13px] font-semibold tabular-nums text-[oklch(0.21_0.012_55)] backdrop-blur-[2px] lg:bg-transparent lg:px-0 lg:py-0 lg:text-foreground lg:backdrop-blur-none">
|
|
264
|
+
<Star
|
|
265
|
+
className="h-3.5 w-3.5 shrink-0 fill-[oklch(0.21_0.012_55)] lg:fill-none"
|
|
266
|
+
aria-hidden
|
|
267
|
+
/>
|
|
268
|
+
{entry.rating}
|
|
269
|
+
</span>
|
|
270
|
+
)
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
{/* The place caption: large enough to be the entry's argument, quiet
|
|
275
|
+
enough not to be its headline.
|
|
276
|
+
At 17px in full foreground it was the loudest block in the entry and
|
|
277
|
+
competed with the venue name — three lines of black serif italic
|
|
278
|
+
reads as a pull-quote, not as supporting prose. The size stays,
|
|
279
|
+
because it is what fills the column beside a 4:5 cover; the COLOUR
|
|
280
|
+
drops instead. Weight is the wrong lever here: the serif italic is
|
|
281
|
+
what makes the page read as an article. */}
|
|
282
|
+
{entry.editorial && (
|
|
283
|
+
/* The article's row gap is 0 on the phone now that the identity block
|
|
284
|
+
lives inside the photograph, so the paragraph owns the space under
|
|
285
|
+
it and gives it back at `lg`. */
|
|
286
|
+
<p className="mt-4 font-serif text-[17px] italic leading-[1.65] text-muted-foreground [text-wrap:pretty] lg:mt-3">
|
|
287
|
+
{entry.editorial}
|
|
288
|
+
</p>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{entry.menuItems && entry.menuItems.length > 0 && (
|
|
292
|
+
<div className="mt-4">
|
|
293
|
+
<span className="block pb-1.5 text-[15px] font-bold tracking-[-0.01em]">
|
|
294
|
+
{entry.menuGroupLabel ?? labels.signature}
|
|
295
|
+
</span>
|
|
296
|
+
<Separator />
|
|
297
|
+
{/* The production `<MenuItem>`, so a dish is visibly the same object
|
|
298
|
+
here as on the venue's own menu and on every hub surface. The
|
|
299
|
+
row owns its own padding and its colour mark is drawn at
|
|
300
|
+
`-ml-4`, so it is not wrapped in anything that would clip it. */}
|
|
301
|
+
{entry.menuItems.map((item) => (
|
|
302
|
+
<MenuItem
|
|
303
|
+
key={item.id}
|
|
304
|
+
item={item}
|
|
305
|
+
labels={labels.item}
|
|
306
|
+
formatPrice={formatPrice}
|
|
307
|
+
formatCount={formatCount}
|
|
308
|
+
markGradient={markGradient}
|
|
309
|
+
href={labels.itemHref(item)}
|
|
310
|
+
linkLabel={labels.openDish(item.name)}
|
|
311
|
+
linkComponent={LinkComponent}
|
|
312
|
+
/>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
{entry.evidence && (
|
|
318
|
+
<figure className="mt-4 border-l-[3px] border-border pl-4">
|
|
319
|
+
{entry.evidence.quote && (
|
|
320
|
+
<blockquote className="text-[13.5px] leading-relaxed text-muted-foreground [text-wrap:pretty]">
|
|
321
|
+
{`“${entry.evidence.quote}”`}
|
|
322
|
+
</blockquote>
|
|
323
|
+
)}
|
|
324
|
+
<figcaption
|
|
325
|
+
className={cn(
|
|
326
|
+
"flex items-center gap-1.5 text-[11.5px] text-muted-foreground",
|
|
327
|
+
entry.evidence.quote && "mt-1.5"
|
|
328
|
+
)}
|
|
329
|
+
>
|
|
330
|
+
<MessageSquareQuote className="h-3 w-3 shrink-0 opacity-60" aria-hidden />
|
|
331
|
+
{entry.evidence.count && (
|
|
332
|
+
<b className="font-bold tabular-nums text-foreground">{entry.evidence.count}</b>
|
|
333
|
+
)}
|
|
334
|
+
{entry.evidence.sourceLabel}
|
|
335
|
+
</figcaption>
|
|
336
|
+
|
|
337
|
+
{/* A `<details>`, never a drawer: its contents ship in the HTML, so
|
|
338
|
+
the remaining evidence is crawlable and readable with no
|
|
339
|
+
JavaScript. On a page whose argument IS the evidence, hiding
|
|
340
|
+
most of it behind a modal gives away the argument. */}
|
|
341
|
+
{entry.evidence.extraQuotes && entry.evidence.extraQuotes.length > 0 && (
|
|
342
|
+
<details className="mt-2">
|
|
343
|
+
<summary className="cursor-pointer text-[11.5px] font-semibold text-accent hover:underline">
|
|
344
|
+
{entry.evidence.extraLabel}
|
|
345
|
+
</summary>
|
|
346
|
+
<ul className="mt-2 flex flex-col gap-2">
|
|
347
|
+
{entry.evidence.extraQuotes.map((quote) => (
|
|
348
|
+
<li
|
|
349
|
+
key={quote}
|
|
350
|
+
className="text-[13px] leading-relaxed text-muted-foreground [text-wrap:pretty]"
|
|
351
|
+
>
|
|
352
|
+
{`“${quote}”`}
|
|
353
|
+
</li>
|
|
354
|
+
))}
|
|
355
|
+
</ul>
|
|
356
|
+
</details>
|
|
357
|
+
)}
|
|
358
|
+
</figure>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* No "see the full menu" button.
|
|
362
|
+
The entry already has three targets — the cover, the name, and every
|
|
363
|
+
dish row — and all of them lead into the venue. A fourth, sitting
|
|
364
|
+
under the rows as the heaviest element in the block, made the entry
|
|
365
|
+
read as a card with a call to action rather than as an item in an
|
|
366
|
+
article. The name is the way in. */}
|
|
367
|
+
</div>
|
|
368
|
+
</article>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Skeleton ───────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The entry's own loading state, at the entry's footprint.
|
|
376
|
+
*
|
|
377
|
+
* The cover keeps its 3:2 on the phone and its 4:5 at 300px wide on desktop, so
|
|
378
|
+
* the article does not jump when the photographs land — the one reflow a long,
|
|
379
|
+
* scrolled page cannot afford. (This said "4:3 … 300×225" for a while after the
|
|
380
|
+
* cover changed underneath it, which is the failure mode a skeleton in the same
|
|
381
|
+
* file as its component is supposed to prevent.)
|
|
382
|
+
*/
|
|
383
|
+
export function TopRankingEntrySkeleton({ className }: { className?: string }) {
|
|
384
|
+
return (
|
|
385
|
+
<div
|
|
386
|
+
className={cn("flex flex-col gap-0 py-6 lg:flex-row lg:gap-7", className)}
|
|
387
|
+
aria-hidden
|
|
388
|
+
>
|
|
389
|
+
<Skeleton className="aspect-[3/2] w-full shrink-0 rounded-xl lg:aspect-[4/5] lg:w-[300px]" />
|
|
390
|
+
<div className="min-w-0 flex-1">
|
|
391
|
+
{/* Hidden on the phone: the real entry draws the rank, name, district
|
|
392
|
+
and grade INSIDE the photograph, so a placeholder row here would
|
|
393
|
+
promise a band that never arrives and reflow on hydration. */}
|
|
394
|
+
<div className="hidden items-start gap-3 lg:flex">
|
|
395
|
+
<Skeleton className="h-7 w-6" />
|
|
396
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
397
|
+
<Skeleton className="h-5 w-[220px]" />
|
|
398
|
+
<Skeleton className="h-3 w-[100px]" />
|
|
399
|
+
</div>
|
|
400
|
+
<Skeleton className="h-8 w-[110px] rounded-lg" />
|
|
401
|
+
</div>
|
|
402
|
+
<div className="mt-4 flex flex-col gap-2 lg:mt-3">
|
|
403
|
+
<Skeleton className="h-4 w-full" />
|
|
404
|
+
<Skeleton className="h-4 w-[92%]" />
|
|
405
|
+
<Skeleton className="h-4 w-[64%]" />
|
|
406
|
+
</div>
|
|
407
|
+
<Skeleton className="mt-4 h-[62px] w-full rounded-lg" />
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A run of entries — the ranking's loading state. */
|
|
414
|
+
export function TopRankingListSkeleton({ rows = 6 }: { rows?: number }) {
|
|
415
|
+
return (
|
|
416
|
+
<div aria-hidden>
|
|
417
|
+
{Array.from({ length: rows }, (_, index) => (
|
|
418
|
+
<div key={index}>
|
|
419
|
+
{index > 0 && <Separator />}
|
|
420
|
+
<TopRankingEntrySkeleton />
|
|
421
|
+
</div>
|
|
422
|
+
))}
|
|
423
|
+
</div>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ChefHat, Clock, LayoutList, MapPin } from "lucide-react";
|
|
3
|
+
import { TopMethodNote, TopRankingHero } from "./top-ranking-hero";
|
|
4
|
+
import { mockGradients } from "./menu-mocks";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof TopRankingHero> = {
|
|
7
|
+
title: "Top/TopRankingHero",
|
|
8
|
+
component: TopRankingHero,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
args: {
|
|
11
|
+
kicker: "RANKED · CUISINE",
|
|
12
|
+
title: "The best Italian restaurants in Cluj-Napoca",
|
|
13
|
+
lede: "Two hundred and forty-seven kitchens in the city cook Italian. These are the ones the reviews keep returning to — ranked on BearMenu grades, with the dish each is named for and what it costs today.",
|
|
14
|
+
updatedLabel: "Updated 6 September 2026",
|
|
15
|
+
metaLabel: "247 kitchens considered · 4,318 reviews read",
|
|
16
|
+
exits: [
|
|
17
|
+
{ label: "Browse all 247 Italian kitchens", href: "/menu/cuisines/italian", icon: <LayoutList className="h-4 w-4" /> },
|
|
18
|
+
{ label: "58 open now", href: "/menu/cuisines/italian?open=now", icon: <Clock className="h-4 w-4" /> },
|
|
19
|
+
],
|
|
20
|
+
gradient: mockGradients.sweet,
|
|
21
|
+
icon: <ChefHat />,
|
|
22
|
+
},
|
|
23
|
+
decorators: [
|
|
24
|
+
(Story) => (
|
|
25
|
+
<div className="w-[1000px] bg-background p-4">
|
|
26
|
+
<Story />
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
],
|
|
30
|
+
parameters: {
|
|
31
|
+
docs: {
|
|
32
|
+
description: {
|
|
33
|
+
component:
|
|
34
|
+
"It says **when** and **how** before it says what. The trust contract is the point of `/top` — a visible Updated date, the size of the pool it chose from, a method line — and those sit where a reader looks for them, not in fine print at the foot. The two exits into the browsing tool are stated rather than implied: the ranking is the editorial pick, the intent page is everything else.",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default meta;
|
|
41
|
+
type Story = StoryObj<typeof TopRankingHero>;
|
|
42
|
+
|
|
43
|
+
export const Cuisine: Story = {};
|
|
44
|
+
|
|
45
|
+
/** A district takes cobblestone and a pin. Same component, different token. */
|
|
46
|
+
export const District: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
kicker: "RANKED · DISTRICT",
|
|
49
|
+
title: "The best restaurants in Centru, Cluj-Napoca",
|
|
50
|
+
lede: "Four hundred and eighty-one places eat and drink inside the old town. These are the ones the reviews rate highest — whatever they cook.",
|
|
51
|
+
metaLabel: "481 places considered · 9,204 reviews read",
|
|
52
|
+
icon: <MapPin />,
|
|
53
|
+
gradient: "linear-gradient(150deg, oklch(0.72 0.03 70), oklch(0.62 0.028 64))",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const Mobile: Story = {
|
|
58
|
+
decorators: [
|
|
59
|
+
(Story) => (
|
|
60
|
+
<div className="w-[390px] bg-background p-3">
|
|
61
|
+
<Story />
|
|
62
|
+
</div>
|
|
63
|
+
),
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const MethodNote: StoryObj<typeof TopMethodNote> = {
|
|
68
|
+
render: () => (
|
|
69
|
+
<TopMethodNote heading="How this is ranked.">
|
|
70
|
+
BearMenu grades, which weigh how many reviews a place has as well as how good they are, then
|
|
71
|
+
the review evidence for the dish each kitchen is named for. Prices are read from the venue's
|
|
72
|
+
current menu and dated. No sponsorship, no submissions.
|
|
73
|
+
</TopMethodNote>
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
|