@bearmenu/ui 0.8.8 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/chunk-62ES6N32.js +27 -0
  2. package/dist/chunk-AXV5O2IZ.js +58 -0
  3. package/dist/chunk-Q6QH52LJ.js +38 -0
  4. package/dist/chunk-TH6W2PWG.js +213 -0
  5. package/dist/chunk-THOVPZNN.js +37 -0
  6. package/dist/chunk-YS6BHKZO.js +92 -0
  7. package/dist/components/alert.d.ts +2 -2
  8. package/dist/components/badge.d.ts +1 -1
  9. package/dist/components/button.d.ts +1 -1
  10. package/dist/components/command.d.ts +7 -7
  11. package/dist/components/concept-compare.d.ts +73 -0
  12. package/dist/components/concept-compare.js +162 -0
  13. package/dist/components/event-card.js +1 -1
  14. package/dist/components/event-compact-row.d.ts +1 -1
  15. package/dist/components/event-compact-row.js +1 -1
  16. package/dist/components/event-detail-panel.d.ts +171 -25
  17. package/dist/components/event-detail-panel.js +471 -174
  18. package/dist/components/event-list-row-wide.js +1 -1
  19. package/dist/components/event-list-row.js +1 -1
  20. package/dist/components/hub-surface.d.ts +27 -0
  21. package/dist/components/hub-surface.js +20 -0
  22. package/dist/components/menu-family-card.d.ts +59 -0
  23. package/dist/components/menu-family-card.js +121 -0
  24. package/dist/components/menu-field.d.ts +57 -0
  25. package/dist/components/menu-field.js +3 -0
  26. package/dist/components/menu-hub-hero.d.ts +65 -0
  27. package/dist/components/menu-hub-hero.js +103 -0
  28. package/dist/components/menu-intent-tiles.d.ts +31 -0
  29. package/dist/components/menu-intent-tiles.js +50 -0
  30. package/dist/components/menu-item.d.ts +127 -0
  31. package/dist/components/menu-item.js +5 -0
  32. package/dist/components/menu-mocks.d.ts +55 -0
  33. package/dist/components/menu-mocks.js +379 -0
  34. package/dist/components/menu-types.d.ts +245 -0
  35. package/dist/components/menu-types.js +1 -0
  36. package/dist/components/motion-icon-set.js +15 -3
  37. package/dist/components/open-now-list.d.ts +40 -0
  38. package/dist/components/open-now-list.js +69 -0
  39. package/dist/components/place-types.d.ts +112 -1
  40. package/dist/components/ranked-venue-list.d.ts +49 -0
  41. package/dist/components/ranked-venue-list.js +87 -0
  42. package/dist/components/signature-chip.d.ts +37 -0
  43. package/dist/components/signature-chip.js +4 -0
  44. package/dist/components/signature-line-list.d.ts +45 -0
  45. package/dist/components/signature-line-list.js +99 -0
  46. package/dist/components/snap-rail.d.ts +24 -4
  47. package/dist/components/snap-rail.js +66 -8
  48. package/dist/components/spinner.d.ts +2 -2
  49. package/dist/components/stat-banner.d.ts +47 -0
  50. package/dist/components/stat-banner.js +61 -0
  51. package/dist/components/tag.d.ts +2 -2
  52. package/dist/components/text.d.ts +3 -3
  53. package/dist/components/venue-busyness-strip.d.ts +35 -0
  54. package/dist/components/venue-busyness-strip.js +37 -0
  55. package/dist/components/venue-grade-dimensions.d.ts +36 -0
  56. package/dist/components/venue-grade-dimensions.js +3 -0
  57. package/dist/components/venue-quotes.d.ts +32 -0
  58. package/dist/components/venue-quotes.js +3 -0
  59. package/dist/components/venue-signature-block.d.ts +39 -0
  60. package/dist/components/venue-signature-block.js +6 -0
  61. package/dist/components/venue-spotlight.d.ts +74 -0
  62. package/dist/components/venue-spotlight.js +138 -0
  63. package/dist/components/venue-wall.d.ts +41 -0
  64. package/dist/components/venue-wall.js +118 -0
  65. package/dist/components/weekday-theme-banner.d.ts +70 -0
  66. package/dist/components/weekday-theme-banner.js +127 -0
  67. package/package.json +1 -1
  68. package/src/components/colour-dictionary.stories.tsx +227 -0
  69. package/src/components/concept-compare.stories.tsx +104 -0
  70. package/src/components/concept-compare.tsx +276 -0
  71. package/src/components/event-compact-row.tsx +1 -1
  72. package/src/components/event-detail-panel.stories.tsx +205 -97
  73. package/src/components/event-detail-panel.test.tsx +181 -0
  74. package/src/components/event-detail-panel.tsx +703 -185
  75. package/src/components/hub-surface.stories.tsx +68 -0
  76. package/src/components/hub-surface.tsx +40 -0
  77. package/src/components/menu-family-card.stories.tsx +112 -0
  78. package/src/components/menu-family-card.tsx +211 -0
  79. package/src/components/menu-field.stories.tsx +74 -0
  80. package/src/components/menu-field.tsx +199 -0
  81. package/src/components/menu-hub-hero.stories.tsx +92 -0
  82. package/src/components/menu-hub-hero.tsx +187 -0
  83. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  84. package/src/components/menu-intent-tiles.tsx +76 -0
  85. package/src/components/menu-item.tsx +416 -0
  86. package/src/components/menu-mocks.ts +417 -0
  87. package/src/components/menu-types.ts +259 -0
  88. package/src/components/motion-icon-set.tsx +30 -3
  89. package/src/components/open-now-list.stories.tsx +76 -0
  90. package/src/components/open-now-list.tsx +127 -0
  91. package/src/components/place-types.ts +112 -0
  92. package/src/components/ranked-venue-list.stories.tsx +80 -0
  93. package/src/components/ranked-venue-list.tsx +177 -0
  94. package/src/components/signature-chip.tsx +77 -0
  95. package/src/components/signature-line-list.stories.tsx +85 -0
  96. package/src/components/signature-line-list.tsx +136 -0
  97. package/src/components/snap-rail.tsx +90 -9
  98. package/src/components/stat-banner.stories.tsx +100 -0
  99. package/src/components/stat-banner.tsx +117 -0
  100. package/src/components/venue-busyness-strip.stories.tsx +66 -0
  101. package/src/components/venue-busyness-strip.tsx +64 -0
  102. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  103. package/src/components/venue-grade-dimensions.tsx +91 -0
  104. package/src/components/venue-quotes.stories.tsx +59 -0
  105. package/src/components/venue-quotes.tsx +64 -0
  106. package/src/components/venue-signature-block.stories.tsx +68 -0
  107. package/src/components/venue-signature-block.tsx +71 -0
  108. package/src/components/venue-spotlight.stories.tsx +141 -0
  109. package/src/components/venue-spotlight.tsx +236 -0
  110. package/src/components/venue-wall.stories.tsx +78 -0
  111. package/src/components/venue-wall.tsx +185 -0
  112. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  113. package/src/components/weekday-theme-banner.tsx +231 -0
@@ -0,0 +1,64 @@
1
+ import { cn } from "../lib/utils";
2
+
3
+ /**
4
+ * VenueBusynessStrip — when a venue actually fills up, as a bar per hour.
5
+ *
6
+ * The API serves one score per hour of the venue's own trading day, so the
7
+ * strip has no fixed length: a bakery closing at three and a club open until
8
+ * four draw different numbers of bars, and padding either to 24 would put empty
9
+ * columns under hours the venue is not open.
10
+ *
11
+ * The rush is derived rather than passed: bars at or above `peakRatio` of the
12
+ * day's own maximum take the accent. A fixed score threshold would mark every
13
+ * hour at a busy venue and none at a quiet one — busyness is only meaningful
14
+ * relative to that venue's own day.
15
+ *
16
+ * `caption` is composed by the caller ("Usually not busy until 18:00 — the rush
17
+ * is nine to eleven"), because the sentence needs the venue's clock, its locale
18
+ * and a plural rule the DS does not own.
19
+ *
20
+ * Server-safe.
21
+ */
22
+
23
+ export type VenueBusynessStripProps = {
24
+ busyness: Array<{ hour: number; score: number }>;
25
+ /** Composed sentence beside the bars. */
26
+ caption?: string;
27
+ /** Share of the day's own peak at which an hour counts as the rush. */
28
+ peakRatio?: number;
29
+ className?: string;
30
+ };
31
+
32
+ const BAR_MAX_HEIGHT = 22;
33
+
34
+ export function VenueBusynessStrip({
35
+ busyness,
36
+ caption,
37
+ peakRatio = 0.8,
38
+ className,
39
+ }: VenueBusynessStripProps) {
40
+ if (busyness.length === 0) return null;
41
+
42
+ const peak = Math.max(...busyness.map((entry) => entry.score));
43
+ if (peak <= 0) return null;
44
+
45
+ return (
46
+ <div
47
+ className={cn("flex items-center gap-2.5 text-[12.5px] text-muted-foreground", className)}
48
+ >
49
+ <span aria-hidden className="flex h-[22px] items-end gap-[3px]">
50
+ {busyness.map(({ hour, score }) => (
51
+ <i
52
+ key={hour}
53
+ className={cn(
54
+ "w-[7px] rounded-t-sm bg-[oklch(0.88_0.02_45)]",
55
+ score >= peak * peakRatio && "bg-[var(--accent,currentColor)]"
56
+ )}
57
+ style={{ height: Math.max(2, Math.round((score / peak) * BAR_MAX_HEIGHT)) }}
58
+ />
59
+ ))}
60
+ </span>
61
+ {caption}
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,83 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockFormatters, mockPlaces } from "./menu-mocks";
3
+ import { VenueGradeDimensions } from "./venue-grade-dimensions";
4
+
5
+ const labels = {
6
+ food: "Food",
7
+ coffee_quality: "Coffee",
8
+ drinks_quality: "Drinks",
9
+ service: "Service",
10
+ value: "Value",
11
+ ambiance: "Ambiance",
12
+ portion: "Portion",
13
+ consistency: "Consistency",
14
+ cleanliness: "Cleanliness",
15
+ };
16
+
17
+ const meta: Meta<typeof VenueGradeDimensions> = {
18
+ title: "Menu/VenueGradeDimensions",
19
+ component: VenueGradeDimensions,
20
+ tags: ["autodocs"],
21
+ decorators: [
22
+ (Story) => (
23
+ <div className="w-[720px] bg-background p-4">
24
+ <Story />
25
+ </div>
26
+ ),
27
+ ],
28
+ parameters: {
29
+ docs: {
30
+ description: {
31
+ component:
32
+ "Per-dimension review scores as a row of bars. A dimension the reviews never mention is ABSENT from the payload, not zero, and exactly one of food / coffee_quality / drinks_quality is scored per place — so a component drawing a fixed row of seven would invent readings for a third of the catalogue. It iterates the canonical order, skips what is missing, and lays out however many survive. Scores arrive 0–100 and display over ten.",
33
+ },
34
+ },
35
+ },
36
+ };
37
+
38
+ export default meta;
39
+ type Story = StoryObj<typeof VenueGradeDimensions>;
40
+
41
+ export const Default: Story = {
42
+ args: {
43
+ dimensions: mockPlaces[0].reviews_digest!.grade!.dimensions!,
44
+ labels,
45
+ formatScore: mockFormatters.score,
46
+ },
47
+ };
48
+
49
+ /**
50
+ * A coffee bar. It is graded on `coffee_quality` and has no food score at all —
51
+ * the row is four columns, not seven with a gap.
52
+ */
53
+ export const CoffeeBar: Story = {
54
+ args: {
55
+ dimensions: mockPlaces[2].reviews_digest!.grade!.dimensions!,
56
+ labels,
57
+ formatScore: mockFormatters.score,
58
+ },
59
+ };
60
+
61
+ /** Thin evidence: only two dimensions were mentioned often enough to score. */
62
+ export const Sparse: Story = {
63
+ args: {
64
+ dimensions: { food: 78, value: 64 },
65
+ labels,
66
+ formatScore: mockFormatters.score,
67
+ },
68
+ };
69
+
70
+ /** `order` narrows as well as reorders — here, quality and value only. */
71
+ export const Narrowed: Story = {
72
+ args: {
73
+ dimensions: mockPlaces[0].reviews_digest!.grade!.dimensions!,
74
+ labels,
75
+ order: ["food", "value"],
76
+ formatScore: mockFormatters.score,
77
+ },
78
+ };
79
+
80
+ /** Nothing scored: the component renders nothing rather than an empty row. */
81
+ export const Empty: Story = {
82
+ args: { dimensions: {}, labels, formatScore: mockFormatters.score },
83
+ };
@@ -0,0 +1,91 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { PlaceGradeDimensions } from "./place-types";
3
+
4
+ /**
5
+ * VenueGradeDimensions — the per-dimension review scores as a row of bars.
6
+ *
7
+ * ## Why this renders what it is given rather than a fixed row
8
+ *
9
+ * A dimension the reviews never mention is ABSENT from the payload, not zero,
10
+ * and exactly one of `food` / `coffee_quality` / `drinks_quality` is scored per
11
+ * place — a coffee bar has no food score and a restaurant has no coffee one.
12
+ * So a component drawing seven fixed columns would invent readings for a third
13
+ * of the catalogue. It iterates `order`, skips what is missing, and lays out
14
+ * however many survive.
15
+ *
16
+ * Scores arrive 0–100 and display on the 0–10 scale the rest of the product
17
+ * grades on, so the bar width is the raw value and the numeral is that value
18
+ * over ten. `formatScore` is injected because a decimal separator is
19
+ * locale-dependent.
20
+ *
21
+ * Server-safe.
22
+ */
23
+
24
+ export type VenueGradeDimensionKey = keyof PlaceGradeDimensions;
25
+
26
+ /** Canonical order. Quality leads, and the three quality keys are mutually exclusive. */
27
+ const DEFAULT_ORDER: VenueGradeDimensionKey[] = [
28
+ "food",
29
+ "coffee_quality",
30
+ "drinks_quality",
31
+ "service",
32
+ "value",
33
+ "ambiance",
34
+ "portion",
35
+ "consistency",
36
+ "cleanliness",
37
+ ];
38
+
39
+ export type VenueGradeDimensionsProps = {
40
+ dimensions: PlaceGradeDimensions;
41
+ /** Dimension key → translated label. Keys with no label fall back to the key. */
42
+ labels: Partial<Record<VenueGradeDimensionKey, string>>;
43
+ /** Override the canonical order, or narrow which dimensions show at all. */
44
+ order?: VenueGradeDimensionKey[];
45
+ /** 0–100 → display numeral, e.g. `(v) => (v / 10).toFixed(1)`. */
46
+ formatScore: (value: number) => string;
47
+ className?: string;
48
+ };
49
+
50
+ export function VenueGradeDimensions({
51
+ dimensions,
52
+ labels,
53
+ order = DEFAULT_ORDER,
54
+ formatScore,
55
+ className,
56
+ }: VenueGradeDimensionsProps) {
57
+ const present = order
58
+ .map((key) => ({ key, value: dimensions[key] }))
59
+ .filter((entry): entry is { key: VenueGradeDimensionKey; value: number } =>
60
+ typeof entry.value === "number"
61
+ );
62
+
63
+ if (present.length === 0) return null;
64
+
65
+ return (
66
+ <dl
67
+ className={cn(
68
+ "grid grid-cols-3 gap-2.5 sm:grid-cols-4 lg:flex lg:gap-2.5",
69
+ className
70
+ )}
71
+ >
72
+ {present.map(({ key, value }) => (
73
+ <div key={key} className="flex flex-col gap-1.5 lg:flex-1">
74
+ <dd className="text-[15px] font-extrabold tracking-[-0.02em] tabular-nums">
75
+ {formatScore(value)}
76
+ </dd>
77
+ <div
78
+ aria-hidden
79
+ className="relative block h-[3px] overflow-hidden rounded-full bg-muted"
80
+ >
81
+ <span
82
+ className="absolute inset-y-0 left-0 rounded-full bg-[var(--gradient-primary,currentColor)]"
83
+ style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
84
+ />
85
+ </div>
86
+ <dt className="text-[10.5px] text-muted-foreground">{labels[key] ?? key}</dt>
87
+ </div>
88
+ ))}
89
+ </dl>
90
+ );
91
+ }
@@ -0,0 +1,59 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockPlaces } from "./menu-mocks";
3
+ import { VenueQuotes } from "./venue-quotes";
4
+
5
+ const highlights = mockPlaces[0].reviews_digest!.highlights!;
6
+
7
+ const meta: Meta<typeof VenueQuotes> = {
8
+ title: "Menu/VenueQuotes",
9
+ component: VenueQuotes,
10
+ tags: ["autodocs"],
11
+ decorators: [
12
+ (Story) => (
13
+ <div className="w-[720px] bg-background p-4">
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ parameters: {
19
+ docs: {
20
+ description: {
21
+ component:
22
+ "The digest's standout lines as pull-quotes — one to three short positives, already resolved into the request locale. They are the only place on a hub where a reviewer speaks in their own register, so they are set in serif italic against the sans everything else uses. `onCite` turns a quote into a button opening the reviews behind it: `review_ids` is what makes the claim checkable, and a quote nobody can trace is just copy.",
23
+ },
24
+ },
25
+ },
26
+ };
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof VenueQuotes>;
30
+
31
+ /** Inert prose. Correct on a surface with nowhere to open. */
32
+ export const Default: Story = { args: { quotes: highlights } };
33
+
34
+ /** With `onCite` each quote becomes a button onto its own evidence. */
35
+ export const Citable: Story = {
36
+ args: {
37
+ quotes: highlights,
38
+ onCite: () => {},
39
+ citeLabel: (count) => `Read ${count} reviews`,
40
+ },
41
+ };
42
+
43
+ /** A quote with no `review_ids` stays inert even when `onCite` is passed. */
44
+ export const MixedEvidence: Story = {
45
+ args: {
46
+ quotes: [highlights[0], { text: "Worth the walk from the centre." }],
47
+ onCite: () => {},
48
+ citeLabel: (count) => `Read ${count} reviews`,
49
+ },
50
+ };
51
+
52
+ export const Single: Story = { args: { quotes: [highlights[0]] } };
53
+
54
+ export const Limited: Story = {
55
+ args: {
56
+ quotes: [...highlights, { text: "A third line the surface has no room for." }],
57
+ limit: 2,
58
+ },
59
+ };
@@ -0,0 +1,64 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { CitedText } from "./place-types";
3
+
4
+ /**
5
+ * VenueQuotes — the digest's standout lines, as pull-quotes.
6
+ *
7
+ * These are `reviews_digest.highlights`: one to three short positives already
8
+ * resolved into the request locale. They are the only place on a hub where a
9
+ * reviewer speaks in their own register, so they are set in serif italic
10
+ * against the sans everything else uses.
11
+ *
12
+ * `onCite` turns a quote into a button that opens the reviews behind it —
13
+ * `review_ids` is what makes the claim checkable, and a quote nobody can trace
14
+ * is just copy. Without the handler the quote renders as inert prose, which is
15
+ * correct on surfaces that have nowhere to open.
16
+ *
17
+ * No directive: inert quotes render on the server; passing `onCite` needs a
18
+ * client caller.
19
+ */
20
+
21
+ export type VenueQuotesProps = {
22
+ quotes: CitedText[];
23
+ /** Opens the reviews behind one quote. Omit to render inert prose. */
24
+ onCite?: (quote: CitedText) => void;
25
+ /** Accessible name for the cite action, e.g. `(n) => "Read 5 reviews"`. */
26
+ citeLabel?: (reviewCount: number) => string;
27
+ /** Cap on how many render. The digest ships at most three. */
28
+ limit?: number;
29
+ className?: string;
30
+ };
31
+
32
+ export function VenueQuotes({ quotes, onCite, citeLabel, limit, className }: VenueQuotesProps) {
33
+ const shown = typeof limit === "number" ? quotes.slice(0, limit) : quotes;
34
+ if (shown.length === 0) return null;
35
+
36
+ return (
37
+ <div className={cn("grid gap-3 lg:grid-cols-2", className)}>
38
+ {shown.map((quote, index) => {
39
+ const citations = quote.review_ids?.length ?? 0;
40
+ const canCite = !!onCite && citations > 0;
41
+
42
+ const text = (
43
+ <blockquote className="border-l-2 border-border pl-3 font-serif text-[13px] italic leading-[1.45] text-muted-foreground">
44
+ {`“${quote.text}”`}
45
+ </blockquote>
46
+ );
47
+
48
+ if (!canCite) return <div key={index}>{text}</div>;
49
+
50
+ return (
51
+ <button
52
+ key={index}
53
+ type="button"
54
+ onClick={() => onCite(quote)}
55
+ aria-label={citeLabel?.(citations)}
56
+ className="rounded-md text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
57
+ >
58
+ {text}
59
+ </button>
60
+ );
61
+ })}
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockGradients, mockVenues } from "./menu-mocks";
3
+ import { VenueSignatureBlock } from "./venue-signature-block";
4
+
5
+ const labels = {
6
+ currency: "lei",
7
+ mentions: (count: number) => `${count} mentions`,
8
+ signature: "SIGNATURE",
9
+ mentioned: "MENTIONED",
10
+ };
11
+
12
+ const meta: Meta<typeof VenueSignatureBlock> = {
13
+ title: "Menu/VenueSignatureBlock",
14
+ component: VenueSignatureBlock,
15
+ tags: ["autodocs"],
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="w-[520px] bg-background p-4">
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "\"What it is known for\": the one dish or drink a venue's reviews single out, on a tinted band. It is a `MenuItem` with a kicker — the same row the venue's own menu renders, so the item does not look like a different object here. The kicker sits above the row rather than inside it; inline at 9.5px it competed with the dish name for the same line.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof VenueSignatureBlock>;
35
+
36
+ export const Default: Story = {
37
+ args: {
38
+ item: mockVenues[0].signature!.item,
39
+ labels,
40
+ formatPrice: (amount: number) => String(amount),
41
+ kicker: "WHAT IT IS KNOWN FOR",
42
+ markGradient: mockGradients.meat,
43
+ subtitle: mockVenues[0].signature!.why,
44
+ href: "/menu/dishes/chicken-wings",
45
+ },
46
+ };
47
+
48
+ /** A drink signature — same block, different scope. */
49
+ export const Drink: Story = {
50
+ args: {
51
+ item: mockVenues[2].signature!.item,
52
+ labels,
53
+ formatPrice: (amount: number) => String(amount),
54
+ kicker: "WHAT THEY POUR",
55
+ markGradient: mockGradients.cocktail,
56
+ },
57
+ };
58
+
59
+ /** Inert: no `href`. Used inside a surface that is itself a link. */
60
+ export const Static: Story = {
61
+ args: {
62
+ item: mockVenues[1].signature!.item,
63
+ labels,
64
+ formatPrice: (amount: number) => String(amount),
65
+ kicker: "WHAT IT IS KNOWN FOR",
66
+ markGradient: mockGradients.pasta,
67
+ },
68
+ };
@@ -0,0 +1,71 @@
1
+ import { cn } from "../lib/utils";
2
+ import { MenuItem, type MenuItemLabels, type MenuItemLinkProps } from "./menu-item";
3
+ import type { MenuItemPreview } from "./menu-types";
4
+
5
+ /**
6
+ * VenueSignatureBlock — "what it is known for": the one dish or drink a venue's
7
+ * reviews single out, on a tinted band.
8
+ *
9
+ * ## It is a `<MenuItem>` with a kicker
10
+ *
11
+ * It used to be its own layout — swatch, label, name, price, all hand-placed —
12
+ * and it drifted from the row every other surface renders. It is now the
13
+ * production row on an accent ground, so a dish looks like itself here, in the
14
+ * "known for" list, and on the venue's own menu.
15
+ *
16
+ * The kicker sits ABOVE the row rather than inside it. Inline at 9.5px it
17
+ * competed with the dish name for the same line, which is how it ended up
18
+ * stacked one word per line.
19
+ */
20
+
21
+ export type VenueSignatureBlockProps = {
22
+ item: MenuItemPreview;
23
+ labels: MenuItemLabels;
24
+ formatPrice: (amount: number) => string;
25
+ formatCount?: (value: number) => string;
26
+ /** Small caps kicker, e.g. "WHAT IT IS KNOWN FOR". */
27
+ kicker: string;
28
+ /** The row's category mark, as a CSS `background-image` value. */
29
+ markGradient?: string;
30
+ /** Reviewers' reason, in place of the item description. */
31
+ subtitle?: string;
32
+ href?: string;
33
+ linkComponent?: React.ComponentType<MenuItemLinkProps>;
34
+ className?: string;
35
+ };
36
+
37
+ export function VenueSignatureBlock({
38
+ item,
39
+ labels,
40
+ formatPrice,
41
+ formatCount,
42
+ kicker,
43
+ markGradient,
44
+ subtitle,
45
+ href,
46
+ linkComponent,
47
+ className,
48
+ }: VenueSignatureBlockProps) {
49
+ return (
50
+ <div className={cn("overflow-hidden rounded-xl bg-accent-subtle py-1", className)}>
51
+ <span className="block px-4 pt-2 text-[9.5px] font-bold uppercase tracking-[0.09em] text-muted-foreground">
52
+ {kicker}
53
+ </span>
54
+
55
+ <MenuItem
56
+ item={item}
57
+ labels={labels}
58
+ formatPrice={formatPrice}
59
+ formatCount={formatCount}
60
+ markGradient={markGradient}
61
+ subtitle={subtitle}
62
+ showSignatureBadge={false}
63
+ href={href}
64
+ linkComponent={linkComponent}
65
+ // The band is already the emphasis; the row must not also take a hover
66
+ // fill inside it.
67
+ className="min-h-0 md:rounded-none md:hover:bg-transparent md:hover:shadow-none"
68
+ />
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,141 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "./button";
3
+ import { mockFormatters, mockGradients, mockPlaces, mockVenues } from "./menu-mocks";
4
+ import { VenueSpotlight } from "./venue-spotlight";
5
+
6
+
7
+ const itemLabels = {
8
+ currency: "lei",
9
+ mentions: (count: number) => `${count} mentions`,
10
+ signature: "SIGNATURE",
11
+ mentioned: "MENTIONED",
12
+ };
13
+ const formatPrice = (amount: number) => String(amount);
14
+
15
+ const labels = {
16
+ photoCredit: "venue photograph",
17
+ openUntil: "Kitchen open until 23:00",
18
+ signatureKicker: "WHAT IT IS KNOWN FOR",
19
+ reviewCount: "318 reviews",
20
+ dimensions: {
21
+ food: "Food",
22
+ coffee_quality: "Coffee",
23
+ drinks_quality: "Drinks",
24
+ service: "Service",
25
+ value: "Value",
26
+ ambiance: "Ambiance",
27
+ portion: "Portion",
28
+ consistency: "Consistency",
29
+ },
30
+ tags: { casual: "casual", cozy: "cozy", family_friendly: "family friendly" },
31
+ valueVerdict: "Good value",
32
+ busyness: "Usually not busy until 18:00 — the rush is seven to eight.",
33
+ cite: (count: number) => `Read ${count} reviews`,
34
+ };
35
+
36
+ const meta: Meta<typeof VenueSpotlight> = {
37
+ title: "Menu/VenueSpotlight",
38
+ component: VenueSpotlight,
39
+ tags: ["autodocs"],
40
+ decorators: [
41
+ (Story) => (
42
+ <div className="w-[1100px] bg-background p-4">
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ parameters: {
48
+ docs: {
49
+ description: {
50
+ component:
51
+ "One venue given real space: photograph, what it is known for, what reviewers say, how it grades, and when it fills up. The photograph is the point — this is the one module on a menu hub that shows an image, and it can, because venue photography is real and licensed where item photography is generated. Composed from `VenueSignatureBlock`, `VenueQuotes`, `VenueGradeDimensions` and `VenueBusynessStrip`, each exported on its own for consumers building a narrower variant.",
52
+ },
53
+ },
54
+ },
55
+ };
56
+
57
+ export default meta;
58
+ type Story = StoryObj<typeof VenueSpotlight>;
59
+
60
+ export const Default: Story = {
61
+ args: {
62
+ place: mockPlaces[0],
63
+ href: mockVenues[0].href,
64
+ labels,
65
+ meta: "pub · Centru · 318 reviews read",
66
+ signature: mockVenues[0].signature,
67
+ signatureGradient: mockGradients.meat,
68
+ formatScore: mockFormatters.score,
69
+ itemLabels,
70
+ formatPrice,
71
+ action: <Button size="sm">Open the menu</Button>,
72
+ },
73
+ };
74
+
75
+ /** A coffee bar: no food dimension, and a `frequent` signature rather than a signature one. */
76
+ export const CoffeeBar: Story = {
77
+ args: {
78
+ place: mockPlaces[2],
79
+ href: mockVenues[2].href,
80
+ labels: { ...labels, openUntil: "Open until 20:00", reviewCount: "96 reviews" },
81
+ meta: "coffee · Centru",
82
+ signature: mockVenues[2].signature,
83
+ signatureGradient: mockGradients.sweet,
84
+ formatScore: mockFormatters.score,
85
+ itemLabels,
86
+ formatPrice,
87
+ },
88
+ };
89
+
90
+ /**
91
+ * A venue the review census has not reached: no digest at all. Every block that
92
+ * depends on one drops out and the card still reads as a venue.
93
+ */
94
+ export const WithoutDigest: Story = {
95
+ args: {
96
+ place: mockPlaces[3],
97
+ href: mockVenues[3].href,
98
+ labels: { ...labels, openUntil: undefined, reviewCount: "141 reviews" },
99
+ meta: "restaurant · Mărăști",
100
+ formatScore: mockFormatters.score,
101
+ itemLabels,
102
+ formatPrice,
103
+ },
104
+ };
105
+
106
+ /** Quotes wired to their evidence. Needs a client caller. */
107
+ export const CitableQuotes: Story = {
108
+ args: {
109
+ place: mockPlaces[0],
110
+ href: mockVenues[0].href,
111
+ labels,
112
+ meta: "pub · Centru",
113
+ signature: mockVenues[0].signature,
114
+ signatureGradient: mockGradients.meat,
115
+ formatScore: mockFormatters.score,
116
+ itemLabels,
117
+ formatPrice,
118
+ onCite: () => {},
119
+ },
120
+ };
121
+
122
+ export const Mobile: Story = {
123
+ args: {
124
+ place: mockPlaces[0],
125
+ href: mockVenues[0].href,
126
+ labels,
127
+ meta: "pub · Centru",
128
+ signature: mockVenues[0].signature,
129
+ signatureGradient: mockGradients.meat,
130
+ formatScore: mockFormatters.score,
131
+ itemLabels,
132
+ formatPrice,
133
+ },
134
+ decorators: [
135
+ (Story) => (
136
+ <div className="w-[390px] bg-background p-3">
137
+ <Story />
138
+ </div>
139
+ ),
140
+ ],
141
+ };