@bearmenu/ui 0.8.8 → 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-62ES6N32.js +27 -0
- package/dist/chunk-AKQ57NXY.js +58 -0
- 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-Q6QH52LJ.js +38 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- 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 +110 -0
- package/dist/components/concept-compare.js +238 -0
- 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.d.ts +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +472 -175
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- 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.d.ts +27 -0
- package/dist/components/hub-surface.js +30 -0
- 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 +65 -0
- package/dist/components/menu-family-card.js +79 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +93 -0
- 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-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -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-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-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +90 -0
- package/dist/components/menu-mocks.js +504 -0
- package/dist/components/menu-types.d.ts +421 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +21 -9
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- 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 +112 -1
- 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/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/snap-rail.d.ts +48 -5
- package/dist/components/snap-rail.js +3 -51
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- 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 +38 -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-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.js +2 -2
- package/package.json +1 -1
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +172 -0
- package/src/components/concept-compare.tsx +443 -0
- 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/event-compact-row.tsx +1 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/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.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +50 -0
- 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 +120 -0
- package/src/components/menu-family-card.tsx +191 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- 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-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -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-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-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 +592 -0
- package/src/components/menu-types.ts +442 -0
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +36 -8
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/snap-rail.tsx +133 -9
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- 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.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-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/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { ArrowRight } from "lucide-react";
|
|
3
|
+
import { KitchenDossier } from "./kitchen-dossier";
|
|
4
|
+
import { mockVenues } from "./menu-mocks";
|
|
5
|
+
|
|
6
|
+
function photo(hue: number) {
|
|
7
|
+
return (
|
|
8
|
+
"data:image/svg+xml;utf8," +
|
|
9
|
+
encodeURIComponent(
|
|
10
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="960" height="640"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 30% 62%)"/><stop offset="1" stop-color="hsl(${hue} 20% 28%)"/></linearGradient></defs><rect width="960" height="640" fill="url(#g)"/><circle cx="240" cy="180" r="120" fill="hsl(${hue} 40% 80%)" opacity=".6"/><rect x="520" y="360" width="360" height="220" fill="hsl(${hue} 15% 20%)" opacity=".7"/></svg>`
|
|
11
|
+
)
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const venue = mockVenues[0];
|
|
16
|
+
|
|
17
|
+
const labels = {
|
|
18
|
+
knownFor: "Known for",
|
|
19
|
+
whatPeopleSay: "What people say",
|
|
20
|
+
theGrade: "The grade",
|
|
21
|
+
currency: "lei",
|
|
22
|
+
mentions: (count: number) => `${count} name it`,
|
|
23
|
+
reviewCount: "427 reviews",
|
|
24
|
+
openUntil: "Open until 22:00",
|
|
25
|
+
part: (index: number, count: number) => `Part ${index + 1} of ${count}`,
|
|
26
|
+
next: "Next",
|
|
27
|
+
dimensions: {
|
|
28
|
+
food: "Food",
|
|
29
|
+
service: "Service",
|
|
30
|
+
value: "Value",
|
|
31
|
+
ambiance: "Ambiance",
|
|
32
|
+
portion: "Portion",
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const action = (
|
|
37
|
+
<a
|
|
38
|
+
href={venue.href}
|
|
39
|
+
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-white px-5 text-[13.5px] font-bold text-[oklch(0.21_0.012_55)]"
|
|
40
|
+
>
|
|
41
|
+
Open the menu
|
|
42
|
+
<ArrowRight className="h-4 w-4" aria-hidden />
|
|
43
|
+
</a>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const meta: Meta<typeof KitchenDossier> = {
|
|
47
|
+
title: "Menu/KitchenDossier",
|
|
48
|
+
component: KitchenDossier,
|
|
49
|
+
tags: ["autodocs"],
|
|
50
|
+
decorators: [
|
|
51
|
+
(Story) => (
|
|
52
|
+
<div className="w-[390px] bg-background p-4">
|
|
53
|
+
<Story />
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
],
|
|
57
|
+
args: {
|
|
58
|
+
place: { ...venue.place, thumbnail_url: photo(30) },
|
|
59
|
+
href: venue.href,
|
|
60
|
+
meta: "Restaurant · Centru",
|
|
61
|
+
signature: venue.signature,
|
|
62
|
+
quotes: [
|
|
63
|
+
{ text: "Complimentary still or sparkling water with every meal, plus free coffee with brunch dishes." },
|
|
64
|
+
{ text: "Incredibly attentive and friendly service that creates a truly relaxing experience." },
|
|
65
|
+
],
|
|
66
|
+
grade: {
|
|
67
|
+
overall: "9.4",
|
|
68
|
+
dimensions: { food: 91, service: 90, value: 91, ambiance: 87, portion: 85 },
|
|
69
|
+
},
|
|
70
|
+
labels,
|
|
71
|
+
formatPrice: (amount: number) => String(amount),
|
|
72
|
+
formatScore: (value: number) => (value / 10).toFixed(1),
|
|
73
|
+
action,
|
|
74
|
+
},
|
|
75
|
+
parameters: {
|
|
76
|
+
docs: {
|
|
77
|
+
description: {
|
|
78
|
+
component:
|
|
79
|
+
"One kitchen, told as a story on its own photograph. The room is clear at the top; a frosted caption rises from the foot (`GlassGround`) with the identity, and under it a panel says the three things the reviews established one at a time — the dish it is known for, what people say, the grade — with segments at the top edge. The story runs while the card is in view and pauses while pressed; a tap advances; reduced motion stops the timer and keeps the tap.",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default meta;
|
|
86
|
+
type Story = StoryObj<typeof KitchenDossier>;
|
|
87
|
+
|
|
88
|
+
export const Phone: Story = {};
|
|
89
|
+
|
|
90
|
+
export const Desktop: Story = {
|
|
91
|
+
decorators: [
|
|
92
|
+
(Story) => (
|
|
93
|
+
<div className="w-[1100px] bg-background p-4">
|
|
94
|
+
<Story />
|
|
95
|
+
</div>
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** One part only: no segments, no tap-to-advance, the panel just is. */
|
|
101
|
+
export const OnePart: Story = {
|
|
102
|
+
args: { quotes: undefined, grade: undefined },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** No photograph: the ground with the initial. */
|
|
106
|
+
export const NoPhoto: Story = {
|
|
107
|
+
args: { place: venue.place },
|
|
108
|
+
};
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { MessageSquareQuote, Star } from "lucide-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { GlassGround } from "./glass-ground";
|
|
5
|
+
import type { MenuLinkComponent, MenuLinkProps, MenuVenueSignature } from "./menu-types";
|
|
6
|
+
import { PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
7
|
+
import type { CitedText, PlaceGradeDimensions, PlacePreview } from "./place-types";
|
|
8
|
+
import { Separator } from "./separator";
|
|
9
|
+
import { StoryPanel, StoryProvider, StorySegments } from "./story";
|
|
10
|
+
import {
|
|
11
|
+
DIMENSION_ORDER,
|
|
12
|
+
VenueGradeDimensions,
|
|
13
|
+
type VenueGradeDimensionKey,
|
|
14
|
+
} from "./venue-grade-dimensions";
|
|
15
|
+
import { VenueQuotes } from "./venue-quotes";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* KitchenDossier — one kitchen, told as a story on its own photograph.
|
|
19
|
+
*
|
|
20
|
+
* The hub used to say this three times: a spotlight with everything the
|
|
21
|
+
* digest knows, a ranked row with a grade, a card with the signature dish —
|
|
22
|
+
* and the same kitchen led all three. This is the one card: the venue's
|
|
23
|
+
* photograph clear at the top, a frosted caption rising from the foot with
|
|
24
|
+
* the identity (rating, name, type · area · reviews), and under it a PANEL
|
|
25
|
+
* that says the three or four things the reviews established one at a time —
|
|
26
|
+
* the dish it is known for, what people say, the grade — with segments at the
|
|
27
|
+
* top edge showing which is up. The story runs on its own while the card is
|
|
28
|
+
* in view; a tap advances.
|
|
29
|
+
*
|
|
30
|
+
* ## A photograph with a caption, not a dark card
|
|
31
|
+
*
|
|
32
|
+
* `ConceptCompare` is a dark card with a photograph showing through two glass
|
|
33
|
+
* bands. This is the reverse — the room is the subject and the facts are its
|
|
34
|
+
* caption — which is `GlassGround` at its lightest and smallest: anchored to
|
|
35
|
+
* the CAPTION, not the card, so it covers the identity, the panel and the
|
|
36
|
+
* action plus a short lead above them and nothing else; a `sm` blur that
|
|
37
|
+
* only takes the edge off the detail; ink that never passes 0.45; a text
|
|
38
|
+
* shadow under the identity doing the rest. Everything above the caption is
|
|
39
|
+
* the photograph, untouched. The panel is its own glass, so its rule reads
|
|
40
|
+
* as a panel and not a hole. The same layout at every width; the desktop
|
|
41
|
+
* card is wider, not turned on its side.
|
|
42
|
+
*
|
|
43
|
+
* ## Two targets, and everything else is "next"
|
|
44
|
+
*
|
|
45
|
+
* The name and the action open the venue; the dish name in the first part
|
|
46
|
+
* opens the item. A tap anywhere else on the card advances the story.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** @deprecated Alias of `MenuLinkProps`; kept for symmetry with the other menu surfaces. */
|
|
50
|
+
export type KitchenDossierLinkProps = MenuLinkProps;
|
|
51
|
+
|
|
52
|
+
function DefaultLink({ href, ...props }: KitchenDossierLinkProps) {
|
|
53
|
+
return <a href={href} {...props} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type KitchenDossierLabels = {
|
|
57
|
+
/** Kickers over the three parts. */
|
|
58
|
+
knownFor: string;
|
|
59
|
+
whatPeopleSay: string;
|
|
60
|
+
theGrade: string;
|
|
61
|
+
/** Currency beside the price, e.g. "lei". */
|
|
62
|
+
currency: string;
|
|
63
|
+
/** Beside the mention count, e.g. "4 name it". */
|
|
64
|
+
mentions: (count: number) => string;
|
|
65
|
+
/** "427 reviews", composed by the caller. */
|
|
66
|
+
reviewCount?: string;
|
|
67
|
+
/** "Open until 22:00", composed by the caller. Omit when closed or unknown. */
|
|
68
|
+
openUntil?: string;
|
|
69
|
+
/** Accessible name per segment, e.g. "Part 2 of 3". */
|
|
70
|
+
part: (index: number, count: number) => string;
|
|
71
|
+
/** Accessible name of the tap-to-advance. */
|
|
72
|
+
next: string;
|
|
73
|
+
/** Dimension key → translated label. */
|
|
74
|
+
dimensions: Partial<Record<VenueGradeDimensionKey, string>>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type KitchenDossierProps = {
|
|
78
|
+
place: PlacePreview;
|
|
79
|
+
/** The venue's destination. */
|
|
80
|
+
href: string;
|
|
81
|
+
/** "Restaurant · Centru", composed by the caller. */
|
|
82
|
+
meta?: string;
|
|
83
|
+
/** Part one. */
|
|
84
|
+
signature?: MenuVenueSignature;
|
|
85
|
+
/** Part two — the digest's highlights; two are shown. */
|
|
86
|
+
quotes?: CitedText[];
|
|
87
|
+
/** Part three — the grade, with the overall already formatted. */
|
|
88
|
+
grade?: { overall?: string; dimensions?: PlaceGradeDimensions };
|
|
89
|
+
labels: KitchenDossierLabels;
|
|
90
|
+
formatPrice: (amount: number) => string;
|
|
91
|
+
/** 0–100 → display numeral. */
|
|
92
|
+
formatScore: (value: number) => string;
|
|
93
|
+
/** How long each part shows. */
|
|
94
|
+
partDurationMs?: number;
|
|
95
|
+
/** The foot's action — pass a rendered link or button. */
|
|
96
|
+
action?: ReactNode;
|
|
97
|
+
linkComponent?: MenuLinkComponent;
|
|
98
|
+
className?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* How far above the caption the glass begins — the ramp from clear photograph
|
|
103
|
+
* to frost, ending exactly where the identity starts. Long, so the edge is a
|
|
104
|
+
* gradient and not a line. The glass wrapper's `-top-32` is this same 128px;
|
|
105
|
+
* the two must agree, or the identity sits on half-frost.
|
|
106
|
+
*/
|
|
107
|
+
const GLASS_LEAD = "128px";
|
|
108
|
+
|
|
109
|
+
/** How many dimensions the grade part shows: the three strongest. */
|
|
110
|
+
const DIMENSION_LIMIT = 3;
|
|
111
|
+
|
|
112
|
+
/** How many quotes the second part shows. */
|
|
113
|
+
const QUOTE_LIMIT = 2;
|
|
114
|
+
|
|
115
|
+
const initialOf = (name: string) => (name.match(/\p{L}/u)?.[0] ?? "").toLocaleUpperCase();
|
|
116
|
+
|
|
117
|
+
export function KitchenDossier({
|
|
118
|
+
place,
|
|
119
|
+
href,
|
|
120
|
+
meta,
|
|
121
|
+
signature,
|
|
122
|
+
quotes,
|
|
123
|
+
grade,
|
|
124
|
+
labels,
|
|
125
|
+
formatPrice,
|
|
126
|
+
formatScore,
|
|
127
|
+
partDurationMs,
|
|
128
|
+
action,
|
|
129
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
130
|
+
className,
|
|
131
|
+
}: KitchenDossierProps) {
|
|
132
|
+
const rating = place.rating?.rating;
|
|
133
|
+
const parts: ReactNode[] = [];
|
|
134
|
+
|
|
135
|
+
if (signature) {
|
|
136
|
+
const item = signature.item;
|
|
137
|
+
const firstEntry = item.sizes_and_prices?.[0];
|
|
138
|
+
const mentions = item.mentions ?? item.review_count ?? 0;
|
|
139
|
+
parts.push(
|
|
140
|
+
<Part key="signature" kicker={labels.knownFor}>
|
|
141
|
+
{signature.href ? (
|
|
142
|
+
<LinkComponent
|
|
143
|
+
href={signature.href}
|
|
144
|
+
className="w-max max-w-full text-[16px] font-bold leading-tight tracking-[-0.01em] text-white hover:underline focus-visible:outline-none focus-visible:underline"
|
|
145
|
+
>
|
|
146
|
+
{item.name}
|
|
147
|
+
</LinkComponent>
|
|
148
|
+
) : (
|
|
149
|
+
<span className="text-[16px] font-bold leading-tight tracking-[-0.01em] text-white">{item.name}</span>
|
|
150
|
+
)}
|
|
151
|
+
{signature.why && (
|
|
152
|
+
<p className="mt-1 line-clamp-2 font-serif text-[13px] italic leading-snug text-white/75">
|
|
153
|
+
{signature.why}
|
|
154
|
+
</p>
|
|
155
|
+
)}
|
|
156
|
+
{(firstEntry || mentions > 0) && (
|
|
157
|
+
<>
|
|
158
|
+
<Separator className="my-2.5 via-white/20" />
|
|
159
|
+
<div className="flex items-end justify-between gap-3">
|
|
160
|
+
<span className="flex items-baseline gap-1.5">
|
|
161
|
+
{firstEntry && (
|
|
162
|
+
<>
|
|
163
|
+
<span className="text-[18px] font-extrabold leading-none tabular-nums text-white">
|
|
164
|
+
{formatPrice(firstEntry.price)}
|
|
165
|
+
<span className="ml-1 text-[12px] font-bold">{labels.currency}</span>
|
|
166
|
+
</span>
|
|
167
|
+
{firstEntry.size > 0 && (
|
|
168
|
+
<span className="text-[11.5px] tabular-nums text-white/60">
|
|
169
|
+
{firstEntry.size}
|
|
170
|
+
{item.kind === "dish" ? "g" : "ml"}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</>
|
|
174
|
+
)}
|
|
175
|
+
</span>
|
|
176
|
+
{mentions > 0 && (
|
|
177
|
+
<span className="inline-flex items-center gap-1 text-[12px] font-semibold tabular-nums text-white/85">
|
|
178
|
+
<MessageSquareQuote className="h-3.5 w-3.5" aria-hidden />
|
|
179
|
+
{labels.mentions(mentions)}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
</Part>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (quotes && quotes.length > 0) {
|
|
190
|
+
parts.push(
|
|
191
|
+
<Part key="quotes" kicker={labels.whatPeopleSay}>
|
|
192
|
+
<VenueQuotes quotes={quotes} limit={QUOTE_LIMIT} className="lg:grid-cols-1" />
|
|
193
|
+
</Part>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* The three strongest, ties broken by the canonical order so that food
|
|
198
|
+
beats cleanliness at an equal score — the reader asked about a kitchen. */
|
|
199
|
+
const dimensions = grade?.dimensions;
|
|
200
|
+
const strongest = dimensions
|
|
201
|
+
? DIMENSION_ORDER.filter((key) => typeof dimensions[key] === "number")
|
|
202
|
+
.sort((a, b) => (dimensions[b] ?? 0) - (dimensions[a] ?? 0))
|
|
203
|
+
.slice(0, DIMENSION_LIMIT)
|
|
204
|
+
: [];
|
|
205
|
+
if (grade && (grade.overall || strongest.length > 0)) {
|
|
206
|
+
parts.push(
|
|
207
|
+
<Part key="grade" kicker={labels.theGrade}>
|
|
208
|
+
<div className="flex items-end gap-4">
|
|
209
|
+
{grade.overall && (
|
|
210
|
+
<span className="text-[34px] font-extrabold leading-none tracking-[-0.03em] tabular-nums text-[oklch(0.82_0.15_85)]">
|
|
211
|
+
{grade.overall}
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
{dimensions && strongest.length > 0 && (
|
|
215
|
+
<VenueGradeDimensions
|
|
216
|
+
className="min-w-0 flex-1"
|
|
217
|
+
dimensions={dimensions}
|
|
218
|
+
labels={labels.dimensions}
|
|
219
|
+
order={strongest}
|
|
220
|
+
formatScore={formatScore}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</Part>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<article
|
|
230
|
+
className={cn(
|
|
231
|
+
/* `flex flex-col` so the story fills the card's height: in a rail
|
|
232
|
+
the cards are stretched to the tallest, and a story that stopped
|
|
233
|
+
at its own min-height left a band of bare photograph under the
|
|
234
|
+
action on every shorter card. */
|
|
235
|
+
"dark relative isolate flex flex-col overflow-hidden rounded-[18px] border border-border text-foreground shadow-[var(--shadow-card)]",
|
|
236
|
+
PHOTO_GROUND,
|
|
237
|
+
className
|
|
238
|
+
)}
|
|
239
|
+
>
|
|
240
|
+
<PhotoGround className="-z-20" photos={[place.thumbnail_url]} initials={initialOf(place.name)} loading="eager" />
|
|
241
|
+
|
|
242
|
+
<StoryProvider
|
|
243
|
+
count={parts.length}
|
|
244
|
+
durationMs={partDurationMs}
|
|
245
|
+
nextLabel={labels.next}
|
|
246
|
+
className="relative flex min-h-[560px] flex-1 flex-col p-4 lg:min-h-[440px] lg:p-5"
|
|
247
|
+
>
|
|
248
|
+
<StorySegments
|
|
249
|
+
labels={parts.map((_, index) => labels.part(index, parts.length))}
|
|
250
|
+
className="-mt-1"
|
|
251
|
+
/>
|
|
252
|
+
|
|
253
|
+
{/* The clear photograph, with the one fact that belongs on it. */}
|
|
254
|
+
<div className="relative min-h-[184px] flex-1">
|
|
255
|
+
{labels.openUntil && (
|
|
256
|
+
<span className="absolute left-0 top-3 inline-flex h-[30px] items-center gap-[7px] rounded-full bg-[oklch(0.16_0.012_55_/_0.72)] px-3 text-[11.5px] font-bold text-white backdrop-blur-[2px]">
|
|
257
|
+
<i className="h-[7px] w-[7px] rounded-full bg-success" aria-hidden />
|
|
258
|
+
{labels.openUntil}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
{/* The caption: identity, the story's panel, the action — and the
|
|
264
|
+
glass, anchored to the caption rather than to the card, so it
|
|
265
|
+
covers the caption and the `GLASS_LEAD` above it and nothing more.
|
|
266
|
+
Bled to the card's edges past the story's padding. */}
|
|
267
|
+
<div className="relative flex flex-col gap-3.5">
|
|
268
|
+
<div
|
|
269
|
+
aria-hidden
|
|
270
|
+
className="pointer-events-none absolute -inset-x-4 -bottom-4 -top-32 -z-10 lg:-inset-x-5 lg:-bottom-5"
|
|
271
|
+
>
|
|
272
|
+
<GlassGround
|
|
273
|
+
stops={{ direction: "to bottom", clear: "0px", full: GLASS_LEAD }}
|
|
274
|
+
peak={0.4}
|
|
275
|
+
blur="sm"
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="[text-shadow:0_1px_12px_oklch(0_0_0/0.55)]">
|
|
279
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
280
|
+
<h3 className="min-w-0 font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white lg:text-[32px]">
|
|
281
|
+
<LinkComponent href={href} className="focus-visible:outline-none focus-visible:underline">
|
|
282
|
+
{place.name}
|
|
283
|
+
</LinkComponent>
|
|
284
|
+
</h3>
|
|
285
|
+
{rating !== undefined && (
|
|
286
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-[18px] font-extrabold leading-none tabular-nums text-white lg:text-[22px]">
|
|
287
|
+
{rating.toFixed(1)}
|
|
288
|
+
<Star className="h-3.5 w-3.5 lg:h-4 lg:w-4" aria-hidden />
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
{(meta || labels.reviewCount) && (
|
|
293
|
+
<p className="mt-1 truncate text-[12.5px] text-white/70">
|
|
294
|
+
{[meta, labels.reviewCount].filter(Boolean).join(" · ")}
|
|
295
|
+
</p>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{parts.length > 0 && (
|
|
300
|
+
<StoryPanel
|
|
301
|
+
parts={parts}
|
|
302
|
+
className="rounded-[14px] border border-white/10 bg-[oklch(0.16_0.012_55_/_0.35)] p-4 backdrop-blur-md"
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{action && <div className="flex [&>*]:w-full lg:[&>*]:w-auto">{action}</div>}
|
|
307
|
+
</div>
|
|
308
|
+
</StoryProvider>
|
|
309
|
+
</article>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function Part({ kicker, children }: { kicker: string; children: ReactNode }) {
|
|
314
|
+
return (
|
|
315
|
+
<div className="flex flex-col">
|
|
316
|
+
<span className="mb-1.5 block text-[9.5px] font-bold uppercase tracking-[0.12em] text-white/60">
|
|
317
|
+
{kicker}
|
|
318
|
+
</span>
|
|
319
|
+
{children}
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { MenuDeadEnd, MenuLoosenList } from "./menu-dead-end";
|
|
3
|
+
import { mockLoosenOptions } from "./menu-mocks";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MenuDeadEnd> = {
|
|
6
|
+
title: "Menu/MenuDeadEnd",
|
|
7
|
+
component: MenuDeadEnd,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
decorators: [
|
|
10
|
+
(Story) => (
|
|
11
|
+
<div className="w-[900px] bg-background p-4">
|
|
12
|
+
<Story />
|
|
13
|
+
</div>
|
|
14
|
+
),
|
|
15
|
+
],
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"A filter combination that returns nothing is the intent page's worst state, and an empty-state illustration is the worst answer to it: the reader already knows the list is empty — what they do not know is which of their four filters did it. So this names what dropped out, why, and what one specific widening would restore, with the count. It is also drawn on a non-empty list whenever a filter has cost the reader something notable.",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof MenuDeadEnd>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
children: (
|
|
32
|
+
<>
|
|
33
|
+
<b>Salt & Stone</b> and six others dropped out — their cheapest vegetarian pasta is 58
|
|
34
|
+
lei. Raising the ceiling to 60 brings back <b>five kitchens</b>, including the highest-rated
|
|
35
|
+
Italian room in the city.
|
|
36
|
+
</>
|
|
37
|
+
),
|
|
38
|
+
actionLabel: "Raise to 60 lei · 16 kitchens",
|
|
39
|
+
onAction: () => {},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** No single best widening — the panel explains, the list below prices each one. */
|
|
44
|
+
export const WithoutAction: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
children: (
|
|
47
|
+
<>
|
|
48
|
+
Nothing matches all four filters. <b>Open now</b> is the most expensive of them.
|
|
49
|
+
</>
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const LoosenList: StoryObj<typeof MenuLoosenList> = {
|
|
55
|
+
render: () => <MenuLoosenList options={mockLoosenOptions} />,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Mobile: Story = {
|
|
59
|
+
args: Default.args,
|
|
60
|
+
decorators: [
|
|
61
|
+
(Story) => (
|
|
62
|
+
<div className="w-[390px] bg-background p-3">
|
|
63
|
+
<Story />
|
|
64
|
+
</div>
|
|
65
|
+
),
|
|
66
|
+
],
|
|
67
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Info } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import type { MenuLinkComponent, MenuLinkProps, MenuLoosenOption } from "./menu-types";
|
|
4
|
+
|
|
5
|
+
function DefaultLink({ href, ...props }: MenuLinkProps) {
|
|
6
|
+
return <a href={href} {...props} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MenuDeadEnd — what dropped out, and the one press that brings it back.
|
|
11
|
+
*
|
|
12
|
+
* A filter combination that returns nothing is the intent page's worst state,
|
|
13
|
+
* and an empty-state illustration is the worst answer to it: the reader knows
|
|
14
|
+
* the list is empty, what they do not know is WHICH of their four filters did
|
|
15
|
+
* it. So this says what dropped out, why, and what widening one specific filter
|
|
16
|
+
* would restore — with the count, because "+5 kitchens, including the
|
|
17
|
+
* highest-rated Italian room in the city" is an answer and "try removing a
|
|
18
|
+
* filter" is a shrug.
|
|
19
|
+
*
|
|
20
|
+
* It is also drawn on a NON-empty list, whenever a filter has cost the reader
|
|
21
|
+
* something notable. The board's case is 34 kitchens still standing and Casa
|
|
22
|
+
* Boema — the one they came for — missing.
|
|
23
|
+
*
|
|
24
|
+
* Interactive: the consumer marks its own module `"use client"`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type MenuDeadEndProps = {
|
|
28
|
+
/**
|
|
29
|
+
* The explanation, composed by the caller.
|
|
30
|
+
*
|
|
31
|
+
* A node rather than a string because it emphasises real names ("**Casa
|
|
32
|
+
* Boema** and six others dropped out"), and assembling that from fragments
|
|
33
|
+
* here would produce translated word-salad in one of the two locales.
|
|
34
|
+
*/
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
/** The widening this offers, e.g. "Raise to 60 lei · 16 kitchens". */
|
|
37
|
+
actionLabel?: string;
|
|
38
|
+
/** Where the widening leads. Preferred over `onAction` — see `MenuLoosenOption.href`. */
|
|
39
|
+
actionHref?: string;
|
|
40
|
+
onAction?: () => void;
|
|
41
|
+
linkComponent?: MenuLinkComponent;
|
|
42
|
+
className?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function MenuDeadEnd({
|
|
46
|
+
children,
|
|
47
|
+
actionLabel,
|
|
48
|
+
actionHref,
|
|
49
|
+
onAction,
|
|
50
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
51
|
+
className,
|
|
52
|
+
}: MenuDeadEndProps) {
|
|
53
|
+
const actionClass =
|
|
54
|
+
"inline-flex h-7 shrink-0 items-center rounded-full bg-foreground px-3 text-[12.5px] font-bold text-background transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none lg:ml-auto";
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={cn(
|
|
59
|
+
"mt-4 flex flex-wrap items-center gap-3 rounded-[14px] bg-muted p-3.5 lg:flex-nowrap lg:gap-4 lg:px-[18px] lg:py-4",
|
|
60
|
+
className
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
<Info className="h-[18px] w-[18px] shrink-0 text-muted-foreground" aria-hidden />
|
|
64
|
+
<p className="max-w-[560px] text-[13px] leading-[1.55] text-muted-foreground [&_b]:font-bold [&_b]:text-foreground">
|
|
65
|
+
{children}
|
|
66
|
+
</p>
|
|
67
|
+
{actionLabel &&
|
|
68
|
+
(actionHref ? (
|
|
69
|
+
<LinkComponent href={actionHref} className={actionClass}>
|
|
70
|
+
{actionLabel}
|
|
71
|
+
</LinkComponent>
|
|
72
|
+
) : (
|
|
73
|
+
onAction && (
|
|
74
|
+
<button type="button" onClick={onAction} className={actionClass}>
|
|
75
|
+
{actionLabel}
|
|
76
|
+
</button>
|
|
77
|
+
)
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* MenuLoosenList — one row per active filter, each priced in kitchens.
|
|
85
|
+
*
|
|
86
|
+
* The companion to the panel above: the panel names the single best widening,
|
|
87
|
+
* this lists every one of them so the reader can choose which constraint they
|
|
88
|
+
* care least about. Every row states what it would return, because a filter the
|
|
89
|
+
* reader cannot price is a filter they will not drop.
|
|
90
|
+
*/
|
|
91
|
+
export type MenuLoosenListProps = {
|
|
92
|
+
options: MenuLoosenOption[];
|
|
93
|
+
linkComponent?: MenuLinkComponent;
|
|
94
|
+
className?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function MenuLoosenList({
|
|
98
|
+
options,
|
|
99
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
100
|
+
className,
|
|
101
|
+
}: MenuLoosenListProps) {
|
|
102
|
+
const rowClass =
|
|
103
|
+
"inline-flex h-9 items-center justify-between gap-3 rounded-full border border-border bg-card px-4 text-sm font-semibold transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none";
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className={cn("flex flex-col gap-2.5", className)}>
|
|
107
|
+
{options.map((option) => {
|
|
108
|
+
const body = (
|
|
109
|
+
<>
|
|
110
|
+
<span className="min-w-0 truncate">{option.label}</span>
|
|
111
|
+
<span className="shrink-0 font-bold tabular-nums text-accent">{option.gainLabel}</span>
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return option.href ? (
|
|
116
|
+
<LinkComponent key={option.key} href={option.href} className={rowClass}>
|
|
117
|
+
{body}
|
|
118
|
+
</LinkComponent>
|
|
119
|
+
) : (
|
|
120
|
+
<button key={option.key} type="button" onClick={option.onSelect} className={rowClass}>
|
|
121
|
+
{body}
|
|
122
|
+
</button>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|