@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
@@ -76,12 +76,20 @@ const UTENSILS_TIMES = [0, 0.18, 0.72, 1];
76
76
  */
77
77
  const SEPARATION = 5;
78
78
 
79
+ /**
80
+ * The fork is not centred on the crossing: its handle reaches ~13.9 units
81
+ * below (12,12) and its tines ~10.8 above, so upright it hung ~1.5 units lower
82
+ * than the knife. `FORK_LIFT` centres it in the uncrossed pose.
83
+ */
84
+ const FORK_LIFT = -1.5;
85
+
79
86
  const fork: Variants = {
80
- rest: { rotate: 0, x: 0, transition: returnTransition },
81
- active: { rotate: -44, x: -SEPARATION, transition: returnTransition },
87
+ rest: { rotate: 0, x: 0, y: 0, transition: returnTransition },
88
+ active: { rotate: -44, x: -SEPARATION, y: FORK_LIFT, transition: returnTransition },
82
89
  play: {
83
90
  rotate: [0, 6, -50, -44],
84
91
  x: [0, 0.8, -6, -SEPARATION],
92
+ y: [0, 0, FORK_LIFT - 0.3, FORK_LIFT],
85
93
  transition: beats(UTENSILS_TIMES, 0.6),
86
94
  },
87
95
  };
@@ -98,6 +106,25 @@ const knife: Variants = {
98
106
 
99
107
  const CROSSING = { originX: "12px", originY: "12px" } as const;
100
108
 
109
+ /**
110
+ * The fork's handle. Lucide leaves a gap between the handle and the neck where
111
+ * the knife crosses, so the two strokes never form a blob at the crossing. Once
112
+ * the fork swings clear the gap is just a broken fork, so the handle lengthens
113
+ * to meet the neck at (12,12) as it uncrosses and shortens again on the way
114
+ * back. Same command shape both ways, so the `d` tweens.
115
+ */
116
+ const HANDLE_CROSSED = "M2.1 21.8 8.5 15.4";
117
+ const HANDLE_CLEAR = "M2.1 21.8 12 12";
118
+
119
+ const forkHandle: Variants = {
120
+ rest: { d: HANDLE_CROSSED, transition: returnTransition },
121
+ active: { d: HANDLE_CLEAR, transition: returnTransition },
122
+ play: {
123
+ d: [HANDLE_CROSSED, HANDLE_CROSSED, HANDLE_CLEAR, HANDLE_CLEAR],
124
+ transition: beats(UTENSILS_TIMES, 0.6),
125
+ },
126
+ };
127
+
101
128
  /**
102
129
  * The one gesture that HOLDS: uncrossed is the active pose. ~89° of relative
103
130
  * separation, so the outline changes completely and the state is readable from
@@ -113,7 +140,7 @@ export function MotionUtensils(props: MotionIconProps) {
113
140
  />
114
141
  <motion.g variants={fork} style={CROSSING}>
115
142
  <path d="m16 2-2.3 2.3a3 3 0 0 0 0 4.2l1.8 1.8a3 3 0 0 0 4.2 0L22 8" />
116
- <path d="m2.1 21.8 6.4-6.3" />
143
+ <motion.path variants={forkHandle} d={HANDLE_CROSSED} />
117
144
  <path d="m19 5-7 7" />
118
145
  </motion.g>
119
146
  </MotionIcon>
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockVenues } from "./menu-mocks";
3
+ import { OpenNowList } from "./open-now-list";
4
+
5
+ const metaLabels = {
6
+ "the-copper-yard": "pub · Centru",
7
+ "salt-and-stone": "restaurant · Centru",
8
+ "the-quiet-hours": "coffee · Centru",
9
+ };
10
+
11
+ const summaryLabels = {
12
+ "the-copper-yard": "Wings, truffle fries, ribs",
13
+ "salt-and-stone": "Pasta, antipasti, tiramisu",
14
+ "the-quiet-hours": "Filter, flat white, pastries",
15
+ };
16
+
17
+ const priceBandLabels = {
18
+ "the-copper-yard": "19—62 lei",
19
+ "salt-and-stone": "38—84 lei",
20
+ "the-quiet-hours": "8—26 lei",
21
+ };
22
+
23
+ const meta: Meta<typeof OpenNowList> = {
24
+ title: "Menu/OpenNowList",
25
+ component: OpenNowList,
26
+ tags: ["autodocs"],
27
+ decorators: [
28
+ (Story) => (
29
+ <div className="w-[620px] bg-background p-4">
30
+ <Story />
31
+ </div>
32
+ ),
33
+ ],
34
+ parameters: {
35
+ docs: {
36
+ description: {
37
+ component:
38
+ "Who is still serving, and until when. At half past ten this beats every other sort, which is why it is its own surface rather than a filter chip: the question is not \"what is good\" but \"what is possible\", and every other module answers the first. The closing time arrives pre-formatted because a trading day crossing midnight needs the venue's timezone and the reader's clock. The live dot is never the only signal — the pill carries the same fact in words.",
39
+ },
40
+ },
41
+ },
42
+ };
43
+
44
+ export default meta;
45
+ type Story = StoryObj<typeof OpenNowList>;
46
+
47
+ export const Default: Story = {
48
+ args: {
49
+ venues: mockVenues.slice(0, 3),
50
+ untilLabel: "until",
51
+ metaLabels,
52
+ summaryLabels,
53
+ priceBandLabels,
54
+ },
55
+ };
56
+
57
+ /** Without the summary line — a tighter row for a narrow column. */
58
+ export const Compact: Story = {
59
+ args: { venues: mockVenues.slice(0, 3), untilLabel: "until", metaLabels, priceBandLabels },
60
+ };
61
+
62
+ /** A venue past closing has no pill; the row still lists. */
63
+ export const MixedHours: Story = {
64
+ args: { venues: mockVenues, untilLabel: "until", metaLabels, priceBandLabels },
65
+ };
66
+
67
+ export const Mobile: Story = {
68
+ args: { venues: mockVenues.slice(0, 3), untilLabel: "until", metaLabels, summaryLabels },
69
+ decorators: [
70
+ (Story) => (
71
+ <div className="w-[390px] bg-background p-3">
72
+ <Story />
73
+ </div>
74
+ ),
75
+ ],
76
+ };
@@ -0,0 +1,127 @@
1
+ import { ChevronRight, Clock } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Separator } from "./separator";
4
+ import type { MenuVenuePreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
5
+
6
+ /**
7
+ * OpenNowList — who is still serving, and until when.
8
+ *
9
+ * At half past ten this beats every other sort, which is why it is its own
10
+ * surface rather than a filter chip: the question is not "what is good" but
11
+ * "what is possible", and every other module on a hub answers the first one.
12
+ *
13
+ * The closing time arrives pre-formatted on the view model (`closes_at_label`)
14
+ * because the venue's own trading day can cross midnight — a bar closing at
15
+ * 04:00 closes on the following date — and resolving that needs the venue's
16
+ * timezone and the reader's clock, neither of which the DS has.
17
+ *
18
+ * The live dot is never the only signal that a venue is open: the closing pill
19
+ * carries the same fact in words, so the row reads without colour.
20
+ *
21
+ * Server-safe.
22
+ */
23
+
24
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
25
+ export type OpenNowListLinkProps = MenuLinkProps;
26
+
27
+ function DefaultLink({ href, ...props }: OpenNowListLinkProps) {
28
+ return <a href={href} {...props} />;
29
+ }
30
+
31
+ export type OpenNowListProps = {
32
+ venues: MenuVenuePreview[];
33
+ /** Prefix on the closing pill, e.g. "until". */
34
+ untilLabel: string;
35
+ /** Composed meta line per venue, keyed by place slug, e.g. "pub · Centru". */
36
+ metaLabels?: Record<string, string>;
37
+ /** What the kitchen is still sending out, keyed by place slug. */
38
+ summaryLabels?: Record<string, string>;
39
+ /** Composed price band per venue, keyed by place slug, e.g. "19—62 lei". */
40
+ priceBandLabels?: Record<string, string>;
41
+ linkComponent?: MenuLinkComponent;
42
+ className?: string;
43
+ };
44
+
45
+ export function OpenNowList({
46
+ venues,
47
+ untilLabel,
48
+ metaLabels,
49
+ summaryLabels,
50
+ priceBandLabels,
51
+ linkComponent: LinkComponent = DefaultLink,
52
+ className,
53
+ }: OpenNowListProps) {
54
+ return (
55
+ <ul className={cn("flex flex-col", className)}>
56
+ {venues.map((venue, index) => {
57
+ const { place } = venue;
58
+
59
+ return (
60
+ <li key={place.slug}>
61
+ {index > 0 && <Separator />}
62
+ <LinkComponent
63
+ href={venue.href}
64
+ className="flex flex-wrap items-center gap-x-3 gap-y-1.5 py-3 transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
65
+ >
66
+ {/* 56px, not 40. At 40 the venue photograph was too small to be
67
+ anything — a smudge of colour rather than a room you might
68
+ recognise — which is the only reason it is on the row. */}
69
+ <span className="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)]">
70
+ {place.thumbnail_url && (
71
+ // eslint-disable-next-line @next/next/no-img-element
72
+ <img
73
+ src={place.thumbnail_url}
74
+ alt=""
75
+ className="h-full w-full object-cover"
76
+ loading="lazy"
77
+ />
78
+ )}
79
+ <i
80
+ aria-hidden
81
+ className="absolute right-1 top-1 h-[7px] w-[7px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
82
+ />
83
+ </span>
84
+
85
+ <span className="min-w-0 flex-1">
86
+ <span className="block text-sm font-bold leading-snug tracking-[-0.01em]">
87
+ {place.name}
88
+ </span>
89
+ {metaLabels?.[place.slug] && (
90
+ <span className="mt-px block truncate text-[11.5px] text-muted-foreground">
91
+ {metaLabels[place.slug]}
92
+ </span>
93
+ )}
94
+ </span>
95
+
96
+ {summaryLabels?.[place.slug] && (
97
+ <span className="order-last w-full truncate text-[12.5px] text-muted-foreground lg:order-none lg:w-auto lg:flex-1">
98
+ {summaryLabels[place.slug]}
99
+ </span>
100
+ )}
101
+
102
+ {/* Price over hours on the phone — side by side they squeezed a
103
+ three-line venue name; one row again from lg. */}
104
+ {(venue.closes_at_label || priceBandLabels?.[place.slug]) && (
105
+ <span className="flex shrink-0 flex-col items-end gap-1 lg:flex-row lg:items-center lg:gap-3">
106
+ {priceBandLabels?.[place.slug] && (
107
+ <span className="text-[12.5px] tabular-nums text-muted-foreground">
108
+ {priceBandLabels[place.slug]}
109
+ </span>
110
+ )}
111
+ {venue.closes_at_label && (
112
+ <span className="inline-flex h-6 items-center gap-1 rounded-full bg-[var(--free-subtle,var(--accent-subtle))] px-2.5 text-[11.5px] font-semibold tabular-nums">
113
+ <Clock className="h-[11px] w-[11px]" aria-hidden />
114
+ {`${untilLabel} ${venue.closes_at_label}`}
115
+ </span>
116
+ )}
117
+ </span>
118
+ )}
119
+
120
+ <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
121
+ </LinkComponent>
122
+ </li>
123
+ );
124
+ })}
125
+ </ul>
126
+ );
127
+ }
@@ -31,6 +31,13 @@ export type PlacePreview = {
31
31
  landmarks?: Array<{ name: string; distance_meters?: number }>
32
32
  areas?: Array<{ name: string }>
33
33
  busyness?: Array<{ hour: number; score: number }>
34
+ /**
35
+ * The review distillation. Drives every menu-hub surface that speaks for a
36
+ * venue — `<VenueSpotlight>`, `<RankedVenueList>`, `<SignatureLineList>` —
37
+ * because the signature dish, the pull-quotes and the graded dimensions all
38
+ * live here rather than on the menu itself.
39
+ */
40
+ reviews_digest?: PlaceReviewsDigest
34
41
  type?: string
35
42
  business_status?: 'operational' | 'closed_temporarily' | 'closed_permanently'
36
43
  phone?: string
@@ -60,6 +67,111 @@ export type PlaceSchedule = Record<WeekdayKey, PlaceHourBlock[]>
60
67
  export type PlacePriceRange = {
61
68
  price_range_low?: number
62
69
  price_range_high?: number
70
+ /** Median price across the place's menu, in the local currency. */
71
+ median_price?: number
72
+ }
73
+
74
+ /**
75
+ * Menu items are priced per size; a single-size item still ships one entry.
76
+ *
77
+ * Defined here rather than in `menu-types.ts` because `PlaceMustTry` carries
78
+ * it, and `menu-types.ts` already imports from this file — keeping the
79
+ * definition here leaves that import one-directional. `menu-types.ts`
80
+ * re-exports it, so menu code never reaches into place types for it.
81
+ */
82
+ export type MenuSizeAndPrice = {
83
+ /** Grams or millilitres. */
84
+ size: number
85
+ price: number
86
+ discounted_price?: number
87
+ discount_percentage?: number
88
+ }
89
+
90
+ /** A digest string with the reviews that back it. `review_ids.length` is the mention count. */
91
+ export type CitedText = { text: string; review_ids?: string[] }
92
+
93
+ /** A digest enum value with the reviews that back it. The value is a language-neutral code. */
94
+ export type CitedValue = { value: string; review_ids?: string[] }
95
+
96
+ /**
97
+ * One dish or drink the reviews single out.
98
+ *
99
+ * `mentions` and `score` come from the item census and are only served by the
100
+ * place DETAIL endpoint — a listing carries the item without them. Surfaces
101
+ * that need a count from a list fall back to `review_ids.length`, which is
102
+ * present in both. `strength`, `why` and `review_ids` are absent for census
103
+ * items the prose digest never mentioned, so none of them can be assumed.
104
+ */
105
+ export type PlaceMustTry = {
106
+ name: string
107
+ why?: string
108
+ strength?: 'signature' | 'frequent' | 'occasional'
109
+ /** Deep-link slug. Present only when the item matched a live menu row. */
110
+ slug?: string
111
+ sizes_and_prices?: MenuSizeAndPrice[]
112
+ review_ids?: string[]
113
+ /** 0–100 census sentiment. Detail endpoint only; 3+ mentions required. */
114
+ score?: number
115
+ /** Censused review mentions of this item. Detail endpoint only. */
116
+ mentions?: number
117
+ }
118
+
119
+ /**
120
+ * Per-dimension 0–100 review scores.
121
+ *
122
+ * A dimension the reviews never mention is ABSENT, not zero — so a surface
123
+ * drawing these must render what it is given rather than a fixed row of seven.
124
+ * Exactly one of `food` / `coffee_quality` / `drinks_quality` is scored per
125
+ * place: a coffee bar has no food score and a restaurant has no coffee one.
126
+ */
127
+ export type PlaceGradeDimensions = {
128
+ food?: number
129
+ coffee_quality?: number
130
+ drinks_quality?: number
131
+ value?: number
132
+ service?: number
133
+ cleanliness?: number
134
+ ambiance?: number
135
+ portion?: number
136
+ consistency?: number
137
+ }
138
+
139
+ export type PlaceGrade = {
140
+ /** 1.0–10.0. */
141
+ overall?: number
142
+ /**
143
+ * Backend recommendation to show the number at all. When false the caller
144
+ * should draw a "building confidence" state instead — the DS renders what it
145
+ * is told, it does not apply the rule.
146
+ */
147
+ show_number?: boolean
148
+ /** Locale-resolved band label, e.g. "Foarte bun". Already translated upstream. */
149
+ descriptor?: string
150
+ confidence?: 'low' | 'medium' | 'high'
151
+ capped?: boolean
152
+ dimensions?: PlaceGradeDimensions
153
+ }
154
+
155
+ export type PlaceReviewsDigest = {
156
+ grade?: PlaceGrade
157
+ grade_note?: string
158
+ tagline?: CitedText
159
+ verdict?: CitedText
160
+ caveat?: CitedText
161
+ /** 1–3 standout positives. The pull-quotes on `<VenueSpotlight>`. */
162
+ highlights?: CitedText[]
163
+ must_try?: PlaceMustTry[]
164
+ /** Vibe codes (cozy, quiet, family_friendly, …). Localised by the caller. */
165
+ tags?: CitedValue[]
166
+ dietary?: CitedValue[]
167
+ /** small | moderate | generous */
168
+ portion_size?: CitedValue
169
+ /** cheap | fair | mid_range | splurge */
170
+ value?: CitedValue
171
+ /** quiet | moderate | loud */
172
+ noise_level?: CitedValue
173
+ /** Text reviews behind the digest. */
174
+ review_count?: number
63
175
  }
64
176
 
65
177
  /** Labels passed to `<PlaceCard>`. All translation happens in the caller. */
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { mockVenues } from "./menu-mocks";
3
+ import { RankedVenueList } from "./ranked-venue-list";
4
+
5
+ const priceBandLabels = {
6
+ "the-copper-yard": "19—62",
7
+ "salt-and-stone": "38—84",
8
+ "the-quiet-hours": "8—26",
9
+ "green-room-canteen": "22—51",
10
+ };
11
+
12
+ const meta: Meta<typeof RankedVenueList> = {
13
+ title: "Menu/RankedVenueList",
14
+ component: RankedVenueList,
15
+ tags: ["autodocs"],
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="w-[620px] bg-background p-4">
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ parameters: {
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "Venues ordered by how often reviews name their dishes — the answer to \"best Italian in Cluj\". What makes the order defensible is the mention count on every row: it is evidence, not opinion. A caller that cannot supply mentions should use a different surface rather than this one with the column blank, because an unexplained ranking is worse than none. The tab strip re-ranks in place and never navigates.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+ type Story = StoryObj<typeof RankedVenueList>;
35
+
36
+ export const Default: Story = {
37
+ args: {
38
+ venues: mockVenues.slice(0, 3),
39
+ heading: "The kitchens Cluj rates for Italian",
40
+ caption: "by how often reviews name their dishes",
41
+ priceBandLabels,
42
+ },
43
+ };
44
+
45
+ /** With tabs. `activeTab` and `onTabChange` are the caller's state. */
46
+ export const WithTabs: Story = {
47
+ args: {
48
+ venues: mockVenues.slice(0, 3),
49
+ heading: "The kitchens Cluj rates",
50
+ caption: "from review mentions, not opinion",
51
+ tabs: [
52
+ { slug: "italian", label: "Italian" },
53
+ { slug: "romanesc", label: "Romanian" },
54
+ { slug: "japonez", label: "Japanese" },
55
+ { slug: "burgeri", label: "Burgers" },
56
+ ],
57
+ activeTab: "italian",
58
+ onTabChange: () => {},
59
+ priceBandLabels,
60
+ },
61
+ };
62
+
63
+ /** No header at all — the list inside a section that already has one. */
64
+ export const Bare: Story = { args: { venues: mockVenues.slice(0, 3), priceBandLabels } };
65
+
66
+ /** A venue with no signature still ranks; its quoted dish line is simply absent. */
67
+ export const MixedEvidence: Story = {
68
+ args: { venues: mockVenues, heading: "The kitchens Cluj rates", priceBandLabels },
69
+ };
70
+
71
+ export const Mobile: Story = {
72
+ args: { venues: mockVenues.slice(0, 3), heading: "The kitchens Cluj rates" },
73
+ decorators: [
74
+ (Story) => (
75
+ <div className="w-[390px] bg-background p-3">
76
+ <Story />
77
+ </div>
78
+ ),
79
+ ],
80
+ };
@@ -0,0 +1,177 @@
1
+ import { ChevronRight, MessageSquare, Star } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { Separator } from "./separator";
4
+ import type { MenuVenuePreview, MenuLinkProps, MenuLinkComponent } from "./menu-types";
5
+
6
+ /**
7
+ * RankedVenueList — venues ordered by how often reviews name their dishes.
8
+ *
9
+ * The answer to "best Italian in Cluj", and the reason it is defensible is the
10
+ * mention count sitting on every row: the order is evidence, not opinion. A
11
+ * caller that cannot supply mentions should use a different surface rather than
12
+ * this one with the column blank — an unexplained ranking is worse than none.
13
+ *
14
+ * The tab strip re-ranks in place and never navigates, so `activeTab` and
15
+ * `onTabChange` are controlled by the caller. Omit `tabs` entirely and the
16
+ * header is just a heading.
17
+ *
18
+ * No directive: without `tabs` the list renders on the server, which is the
19
+ * common case on a hub.
20
+ */
21
+
22
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
23
+ export type RankedVenueListLinkProps = MenuLinkProps;
24
+
25
+ function DefaultLink({ href, ...props }: RankedVenueListLinkProps) {
26
+ return <a href={href} {...props} />;
27
+ }
28
+
29
+ export type RankedVenueListTab = {
30
+ slug: string;
31
+ label: string;
32
+ };
33
+
34
+ export type RankedVenueListProps = {
35
+ venues: MenuVenuePreview[];
36
+ /** Section heading, e.g. "The kitchens Cluj rates for Italian". */
37
+ heading?: string;
38
+ /** The basis of the order, e.g. "by how often reviews name their dishes". */
39
+ caption?: string;
40
+ tabs?: RankedVenueListTab[];
41
+ activeTab?: string;
42
+ onTabChange?: (slug: string) => void;
43
+ /** Composed price band per venue, keyed by place slug, e.g. "38—84". */
44
+ priceBandLabels?: Record<string, string>;
45
+ /**
46
+ * The app's own grade per venue slug, already formatted ("8.7"). Shown in
47
+ * place of the star rating when present — the star is the third-party
48
+ * fallback for a venue the reviews have not graded yet.
49
+ */
50
+ gradeLabels?: Record<string, string>;
51
+ linkComponent?: MenuLinkComponent;
52
+ className?: string;
53
+ };
54
+
55
+ export function RankedVenueList({
56
+ venues,
57
+ heading,
58
+ caption,
59
+ tabs,
60
+ activeTab,
61
+ onTabChange,
62
+ priceBandLabels,
63
+ gradeLabels,
64
+ linkComponent: LinkComponent = DefaultLink,
65
+ className,
66
+ }: RankedVenueListProps) {
67
+ return (
68
+ <div className={cn("overflow-hidden rounded-[14px] border border-border bg-card", className)}>
69
+ {(heading || tabs) && (
70
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border p-3.5">
71
+ {heading && (
72
+ <h3 className="text-[15px] font-bold leading-tight tracking-[-0.01em]">{heading}</h3>
73
+ )}
74
+ {caption && (
75
+ <span className="text-[12.5px] text-muted-foreground">{caption}</span>
76
+ )}
77
+ {tabs && tabs.length > 0 && (
78
+ <div className="ml-auto flex flex-wrap gap-1.5">
79
+ {tabs.map((tab) => (
80
+ <button
81
+ key={tab.slug}
82
+ type="button"
83
+ aria-pressed={tab.slug === activeTab}
84
+ onClick={() => onTabChange?.(tab.slug)}
85
+ className={cn(
86
+ "inline-flex h-7 items-center rounded-full border px-2.5 text-xs font-semibold transition-colors",
87
+ tab.slug === activeTab
88
+ ? "border-accent bg-accent-subtle text-accent"
89
+ : "border-border text-muted-foreground hover:bg-muted/50"
90
+ )}
91
+ >
92
+ {tab.label}
93
+ </button>
94
+ ))}
95
+ </div>
96
+ )}
97
+ </div>
98
+ )}
99
+
100
+ <ol>
101
+ {venues.map((venue, index) => {
102
+ const { place, signature } = venue;
103
+ const mentions = signature?.item.mentions ?? 0;
104
+
105
+ return (
106
+ <li key={place.slug}>
107
+ {index > 0 && <Separator />}
108
+ <LinkComponent
109
+ href={venue.href}
110
+ className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
111
+ >
112
+ <span className="w-4 shrink-0 text-center text-[13px] font-extrabold tabular-nums text-accent">
113
+ {index + 1}
114
+ </span>
115
+
116
+ {/* 48px. Same reason as `OpenNowList`: below ~48 the venue
117
+ photograph stops being a photograph. */}
118
+ <span className="h-12 w-12 shrink-0 overflow-hidden rounded-xl bg-[repeating-linear-gradient(135deg,oklch(0.78_0.018_55)_0_8px,oklch(0.84_0.012_58)_8px_16px)]">
119
+ {place.thumbnail_url && (
120
+ // eslint-disable-next-line @next/next/no-img-element
121
+ <img
122
+ src={place.thumbnail_url}
123
+ alt=""
124
+ className="h-full w-full object-cover"
125
+ loading="lazy"
126
+ />
127
+ )}
128
+ </span>
129
+
130
+ <span className="min-w-0 flex-1">
131
+ {/* The whole name: a ranked list is a list of names, and a
132
+ name cut to "PASQUALE | brunch & din…" is not one. */}
133
+ <span className="block text-sm font-bold leading-snug tracking-[-0.01em]">
134
+ {place.name}
135
+ </span>
136
+ {signature?.item.name && (
137
+ <span className="mt-px block truncate font-serif text-xs italic text-muted-foreground">
138
+ {`“${signature.item.name}”`}
139
+ </span>
140
+ )}
141
+ </span>
142
+
143
+ {mentions > 0 && (
144
+ <span className="hidden shrink-0 items-center gap-1 text-[11.5px] tabular-nums text-muted-foreground sm:inline-flex">
145
+ <MessageSquare className="h-[11px] w-[11px]" aria-hidden />
146
+ {mentions}
147
+ </span>
148
+ )}
149
+
150
+ {gradeLabels?.[place.slug] ? (
151
+ <span className="inline-flex h-6 shrink-0 items-center rounded-md bg-foreground px-1.5 text-[12px] font-extrabold tabular-nums text-background">
152
+ {gradeLabels[place.slug]}
153
+ </span>
154
+ ) : (
155
+ place.rating && (
156
+ <span className="inline-flex shrink-0 items-center gap-1 text-[12.5px] font-semibold tabular-nums">
157
+ <Star className="h-3 w-3" aria-hidden />
158
+ {place.rating.rating}
159
+ </span>
160
+ )
161
+ )}
162
+
163
+ {priceBandLabels?.[place.slug] && (
164
+ <span className="hidden shrink-0 text-[12.5px] tabular-nums text-muted-foreground sm:block">
165
+ {priceBandLabels[place.slug]}
166
+ </span>
167
+ )}
168
+
169
+ <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
170
+ </LinkComponent>
171
+ </li>
172
+ );
173
+ })}
174
+ </ol>
175
+ </div>
176
+ );
177
+ }