@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,245 @@
1
+ import { ComponentType, CSSProperties, ReactNode } from 'react';
2
+ import { MenuSizeAndPrice, PlacePreview } from './place-types.js';
3
+
4
+ /**
5
+ * Structural mirror of the openapi `Dish` / `Drink` schemas, decoupled from any
6
+ * one app's generated types. FE, admin and post-generator map their domain
7
+ * shapes into these before passing data to the menu-hub surfaces.
8
+ *
9
+ * Same contract as `place-types.ts` and `event-types.ts`: no app schemas, no
10
+ * app hooks, no i18n. Every user-facing string arrives through a `*Labels`
11
+ * object, every destination through an `href`, and every locale-sensitive
12
+ * format through an injected formatter — the DS never owns a translation key
13
+ * or an `Intl` locale.
14
+ *
15
+ * ## One set of components, two tabs
16
+ *
17
+ * Dishes and drinks are the same shape. `MenuItemPreview.kind` is the only
18
+ * discriminator, and nothing in this file branches on it — a drinks hub is a
19
+ * different view model and a different `*Labels` object, never a second set of
20
+ * components. Anything that would need a `if (kind === 'drink')` inside a
21
+ * component belongs in the caller's mapper instead.
22
+ */
23
+
24
+ /**
25
+ * The shape every menu surface's `linkComponent` must accept.
26
+ *
27
+ * One type rather than a clone per component: these surfaces nest each other —
28
+ * a family card renders a `<MenuItem>`, a spotlight renders a signature block —
29
+ * and a caller passes ONE `next/link` down through all of them. Subtly
30
+ * different clones made those hand-offs fail to typecheck for no design reason.
31
+ */
32
+ type MenuLinkProps = {
33
+ href: string;
34
+ className?: string;
35
+ 'aria-label'?: string;
36
+ 'aria-current'?: 'page';
37
+ /** Intent tiles paint their hue on the link element itself. */
38
+ style?: CSSProperties;
39
+ children?: ReactNode;
40
+ };
41
+ type MenuLinkComponent = ComponentType<MenuLinkProps>;
42
+ /**
43
+ * A dish or a drink as every hub surface consumes it.
44
+ *
45
+ * `mentions` is the census count of review mentions and is populated on every
46
+ * item in the catalogue — it is the ranking signal the hub is built on. Do not
47
+ * confuse it with `review_count`, which counts full review objects and is zero
48
+ * for most items; a surface that ranks on `review_count` renders an empty page.
49
+ */
50
+ type MenuItemPreview = {
51
+ id: string;
52
+ slug: string;
53
+ name: string;
54
+ kind: 'dish' | 'drink';
55
+ description?: string;
56
+ /** API category code (pizza, pasta, cocktail, non-alcoholic, …). Localised by the caller. */
57
+ category_slug?: string;
58
+ sizes_and_prices?: MenuSizeAndPrice[];
59
+ /** Censused review mentions. The hub's ranking signal. */
60
+ mentions?: number;
61
+ /** Full review objects citing this item. Sparse — not a ranking signal. */
62
+ review_count?: number;
63
+ /** 0–100 recommendation score. */
64
+ score?: number;
65
+ /** The venue serving it. Menu items are always venue-scoped. */
66
+ place?: MenuItemPlaceRef;
67
+ /**
68
+ * Pre-resolved photo URL. Menu-item photography is AI-generated and hidden in
69
+ * production, so hub surfaces draw a typographic `<MenuFieldTile>` instead
70
+ * and leave this unset. It exists for the surfaces that legitimately show one
71
+ * (concept detail) and for consumers outside the consumer app.
72
+ */
73
+ photo_url?: string;
74
+ };
75
+ /** The venue attribution carried on a menu item. */
76
+ type MenuItemPlaceRef = {
77
+ slug: string;
78
+ name: string;
79
+ /** Pre-resolved venue thumbnail. Venue photography is real; item photography is not. */
80
+ thumbnail_url?: string;
81
+ };
82
+ /**
83
+ * A dish or drink concept — one item across every kitchen that makes it, under
84
+ * whatever name each of them uses. The page's keystone, and the one thing the
85
+ * catalogue can say that a single menu cannot.
86
+ */
87
+ type MenuConceptPreview = {
88
+ slug: string;
89
+ /** Canonical concept name, e.g. "Margherita". */
90
+ title: string;
91
+ kind: 'dish' | 'drink';
92
+ /** Venues serving it. */
93
+ venue_count?: number;
94
+ price_low?: number;
95
+ price_high?: number;
96
+ /** Distinct names it appears under across those menus. */
97
+ alias_count?: number;
98
+ /** Summed censused mentions across every venue's version. */
99
+ mentions?: number;
100
+ rows?: MenuConceptRow[];
101
+ };
102
+ /** One venue's version of a concept, as drawn by `<ConceptCompare>`. */
103
+ type MenuConceptRow = {
104
+ place_slug: string;
105
+ place_name: string;
106
+ /** What this kitchen calls it on its own menu. */
107
+ alias: string;
108
+ price: number;
109
+ mentions?: number;
110
+ rating?: number;
111
+ /** Venue type line, e.g. "pub". Localised by the caller. */
112
+ place_type?: string;
113
+ /** Neighbourhood, e.g. "Centru". */
114
+ place_area?: string;
115
+ /**
116
+ * Pre-resolved VENUE photograph.
117
+ *
118
+ * The row is place-led: five restaurants that disagree about a name, not five
119
+ * bars of similar length. A price bar was drawn here first and it measured
120
+ * the one thing already written beside it in words, while the venue — the
121
+ * thing you are actually choosing between — had no image at all.
122
+ */
123
+ place_thumbnail_url?: string;
124
+ open_now?: boolean;
125
+ /** Marks the row the surface should lead with (cheapest, or most named). */
126
+ featured?: boolean;
127
+ /** Portion, already formatted with its unit, e.g. "260g". */
128
+ portion_label?: string;
129
+ };
130
+ /**
131
+ * A browsable grouping of venues — a cuisine on the dishes tab, a drink family
132
+ * on the drinks tab. Both are sets of VENUES, never sets of items: cuisine
133
+ * exists only on places, so a card counting dishes cannot be built.
134
+ */
135
+ type MenuFamilyPreview = {
136
+ slug: string;
137
+ /** Display name, already localised by the caller. */
138
+ name: string;
139
+ /** Venues in this family. */
140
+ venue_count: number;
141
+ /**
142
+ * Facet chips. Counts are VENUES, not items — they come from the places
143
+ * facet narrowed by this family. The drinks tab has no equivalent facet, so
144
+ * its chips ship without counts; `count` is optional for that reason.
145
+ */
146
+ chips?: MenuFamilyChip[];
147
+ /** Price band across the family's venues. */
148
+ price_low?: number;
149
+ price_high?: number;
150
+ price_median?: number;
151
+ /** Pre-resolved venue photos for the fanned deck. Venue photography only. */
152
+ venue_photo_urls?: string[];
153
+ /** The item this family is most named for. */
154
+ signature?: MenuFamilySignature;
155
+ };
156
+ type MenuFamilyChip = {
157
+ slug: string;
158
+ label: string;
159
+ /** Venue count. Absent where no facet backs the chip. */
160
+ count?: number;
161
+ };
162
+ /** A family card's featured item. Same shape as a venue's, for the same reason. */
163
+ type MenuFamilySignature = MenuVenueSignature;
164
+ /**
165
+ * A venue as the hub's venue-scoped surfaces consume it: the place itself plus
166
+ * the pre-resolved bits a hub row needs and `PlacePreview` does not carry —
167
+ * which signature to lead with, and where the row points.
168
+ */
169
+ type MenuVenuePreview = {
170
+ place: PlacePreview;
171
+ href: string;
172
+ /**
173
+ * The signature to draw. Callers pick it from `reviews_digest.must_try`
174
+ * rather than the surface re-deriving the rule on every row.
175
+ */
176
+ signature?: MenuVenueSignature;
177
+ /** Closing time as an already-formatted wall clock, e.g. "23:00". */
178
+ closes_at_label?: string;
179
+ };
180
+ /**
181
+ * The resolved signature on a venue row.
182
+ *
183
+ * It carries a whole `MenuItemPreview` rather than a flattened name and price,
184
+ * because every surface that draws one renders `<MenuItem>` — the same row a
185
+ * venue's own menu uses — and that component takes an item. Flattening it here
186
+ * was what let the hub's version drift into a different-looking row.
187
+ */
188
+ type MenuVenueSignature = {
189
+ item: MenuItemPreview;
190
+ /** The reviewers' reason, from the digest. Rendered in place of the description. */
191
+ why?: string;
192
+ href?: string;
193
+ /** The digest called this the venue's signature rather than merely frequent. */
194
+ isSignature?: boolean;
195
+ };
196
+ /**
197
+ * An intent tile — a family plus a constraint, resolving to a category page.
198
+ *
199
+ * Only about thirty-two of the thirty-eight cuisines have a page, so `href` is
200
+ * required: a tile that cannot resolve to a real destination is not rendered
201
+ * rather than linking nowhere.
202
+ */
203
+ type MenuIntentTile = {
204
+ slug: string;
205
+ label: string;
206
+ /** Supporting line, e.g. "247 kitchens". Already formatted by the caller. */
207
+ caption?: string;
208
+ href: string;
209
+ /**
210
+ * Finished CSS gradient for the tile's ground, e.g.
211
+ * `linear-gradient(150deg, …)`.
212
+ *
213
+ * A gradient rather than a token name, for the same reason `<MenuField>`
214
+ * takes one: the hue tables are app-owned, and a component referencing
215
+ * `var(--h-pizza)` renders an invisible tile in any consumer that does not
216
+ * happen to define that variable. A missing gradient falls back to `bg-muted`
217
+ * — visible and obviously unstyled, rather than white text on white.
218
+ */
219
+ gradient?: string;
220
+ /** Icon node, resolved by the caller — the DS does not pick icons for it. */
221
+ icon?: ReactNode;
222
+ };
223
+ /** Formatters injected into every menu surface. The DS owns no `Intl` locale. */
224
+ type MenuFormatters = {
225
+ /** 24 → "24 lei". */
226
+ price: (value: number) => string;
227
+ /** 1717 → "1,717". */
228
+ number: (value: number) => string;
229
+ };
230
+ /** Strings shared by every menu-hub surface. */
231
+ type MenuHubLabels = {
232
+ /** Mention-count badge. Pass a translator that handles ICU pluralization. */
233
+ mentions: (count: number) => string;
234
+ /** Venue-count line. Pass a translator that handles ICU pluralization. */
235
+ venues: (count: number) => string;
236
+ /** "See all" affordance on a section header. */
237
+ seeAll: string;
238
+ /** Badge on a must-try row the digest called `signature`. */
239
+ signature: string;
240
+ openNow: string;
241
+ /** Prefixes a closing time, e.g. "until". */
242
+ until: string;
243
+ };
244
+
245
+ export { type MenuConceptPreview, type MenuConceptRow, type MenuFamilyChip, type MenuFamilyPreview, type MenuFamilySignature, type MenuFormatters, type MenuHubLabels, type MenuIntentTile, type MenuItemPlaceRef, type MenuItemPreview, type MenuLinkComponent, type MenuLinkProps, MenuSizeAndPrice, type MenuVenuePreview, type MenuVenueSignature };
@@ -0,0 +1 @@
1
+
@@ -40,12 +40,14 @@ function MotionHouse(props) {
40
40
  }
41
41
  var UTENSILS_TIMES = [0, 0.18, 0.72, 1];
42
42
  var SEPARATION = 5;
43
+ var FORK_LIFT = -1.5;
43
44
  var fork = {
44
- rest: { rotate: 0, x: 0, transition: returnTransition },
45
- active: { rotate: -44, x: -SEPARATION, transition: returnTransition },
45
+ rest: { rotate: 0, x: 0, y: 0, transition: returnTransition },
46
+ active: { rotate: -44, x: -SEPARATION, y: FORK_LIFT, transition: returnTransition },
46
47
  play: {
47
48
  rotate: [0, 6, -50, -44],
48
49
  x: [0, 0.8, -6, -SEPARATION],
50
+ y: [0, 0, FORK_LIFT - 0.3, FORK_LIFT],
49
51
  transition: beats(UTENSILS_TIMES, 0.6)
50
52
  }
51
53
  };
@@ -59,6 +61,16 @@ var knife = {
59
61
  }
60
62
  };
61
63
  var CROSSING = { originX: "12px", originY: "12px" };
64
+ var HANDLE_CROSSED = "M2.1 21.8 8.5 15.4";
65
+ var HANDLE_CLEAR = "M2.1 21.8 12 12";
66
+ var forkHandle = {
67
+ rest: { d: HANDLE_CROSSED, transition: returnTransition },
68
+ active: { d: HANDLE_CLEAR, transition: returnTransition },
69
+ play: {
70
+ d: [HANDLE_CROSSED, HANDLE_CROSSED, HANDLE_CLEAR, HANDLE_CLEAR],
71
+ transition: beats(UTENSILS_TIMES, 0.6)
72
+ }
73
+ };
62
74
  function MotionUtensils(props) {
63
75
  return /* @__PURE__ */ jsxs(MotionIcon, __spreadProps(__spreadValues({}, props), { children: [
64
76
  /* @__PURE__ */ jsx(
@@ -71,7 +83,7 @@ function MotionUtensils(props) {
71
83
  ),
72
84
  /* @__PURE__ */ jsxs(motion.g, { variants: fork, style: CROSSING, children: [
73
85
  /* @__PURE__ */ jsx("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" }),
74
- /* @__PURE__ */ jsx("path", { d: "m2.1 21.8 6.4-6.3" }),
86
+ /* @__PURE__ */ jsx(motion.path, { variants: forkHandle, d: HANDLE_CROSSED }),
75
87
  /* @__PURE__ */ jsx("path", { d: "m19 5-7 7" })
76
88
  ] })
77
89
  ] }));
@@ -0,0 +1,40 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { MenuVenuePreview, MenuLinkComponent, MenuLinkProps } from './menu-types.js';
3
+ import 'react';
4
+ import './place-types.js';
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
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
24
+ type OpenNowListLinkProps = MenuLinkProps;
25
+ type OpenNowListProps = {
26
+ venues: MenuVenuePreview[];
27
+ /** Prefix on the closing pill, e.g. "until". */
28
+ untilLabel: string;
29
+ /** Composed meta line per venue, keyed by place slug, e.g. "pub · Centru". */
30
+ metaLabels?: Record<string, string>;
31
+ /** What the kitchen is still sending out, keyed by place slug. */
32
+ summaryLabels?: Record<string, string>;
33
+ /** Composed price band per venue, keyed by place slug, e.g. "19—62 lei". */
34
+ priceBandLabels?: Record<string, string>;
35
+ linkComponent?: MenuLinkComponent;
36
+ className?: string;
37
+ };
38
+ declare function OpenNowList({ venues, untilLabel, metaLabels, summaryLabels, priceBandLabels, linkComponent: LinkComponent, className, }: OpenNowListProps): react_jsx_runtime.JSX.Element;
39
+
40
+ export { OpenNowList, type OpenNowListLinkProps, type OpenNowListProps };
@@ -0,0 +1,69 @@
1
+ import { Separator } from '../chunk-WSRWFA6K.js';
2
+ import { cn } from '../chunk-FEK5XGZW.js';
3
+ import { __objRest, __spreadValues } from '../chunk-3GWWZKX7.js';
4
+ import { Clock, ChevronRight } from 'lucide-react';
5
+ import { jsx, jsxs } from 'react/jsx-runtime';
6
+
7
+ function DefaultLink(_a) {
8
+ var _b = _a, { href } = _b, props = __objRest(_b, ["href"]);
9
+ return /* @__PURE__ */ jsx("a", __spreadValues({ href }, props));
10
+ }
11
+ function OpenNowList({
12
+ venues,
13
+ untilLabel,
14
+ metaLabels,
15
+ summaryLabels,
16
+ priceBandLabels,
17
+ linkComponent: LinkComponent = DefaultLink,
18
+ className
19
+ }) {
20
+ return /* @__PURE__ */ jsx("ul", { className: cn("flex flex-col", className), children: venues.map((venue, index) => {
21
+ const { place } = venue;
22
+ return /* @__PURE__ */ jsxs("li", { children: [
23
+ index > 0 && /* @__PURE__ */ jsx(Separator, {}),
24
+ /* @__PURE__ */ jsxs(
25
+ LinkComponent,
26
+ {
27
+ href: venue.href,
28
+ 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",
29
+ children: [
30
+ /* @__PURE__ */ jsxs("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)]", children: [
31
+ place.thumbnail_url && // eslint-disable-next-line @next/next/no-img-element
32
+ /* @__PURE__ */ jsx(
33
+ "img",
34
+ {
35
+ src: place.thumbnail_url,
36
+ alt: "",
37
+ className: "h-full w-full object-cover",
38
+ loading: "lazy"
39
+ }
40
+ ),
41
+ /* @__PURE__ */ jsx(
42
+ "i",
43
+ {
44
+ "aria-hidden": true,
45
+ className: "absolute right-1 top-1 h-[7px] w-[7px] rounded-full bg-[var(--free,currentColor)] ring-2 ring-white/80"
46
+ }
47
+ )
48
+ ] }),
49
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
50
+ /* @__PURE__ */ jsx("span", { className: "block text-sm font-bold leading-snug tracking-[-0.01em]", children: place.name }),
51
+ (metaLabels == null ? void 0 : metaLabels[place.slug]) && /* @__PURE__ */ jsx("span", { className: "mt-px block truncate text-[11.5px] text-muted-foreground", children: metaLabels[place.slug] })
52
+ ] }),
53
+ (summaryLabels == null ? void 0 : summaryLabels[place.slug]) && /* @__PURE__ */ jsx("span", { className: "order-last w-full truncate text-[12.5px] text-muted-foreground lg:order-none lg:w-auto lg:flex-1", children: summaryLabels[place.slug] }),
54
+ (venue.closes_at_label || (priceBandLabels == null ? void 0 : priceBandLabels[place.slug])) && /* @__PURE__ */ jsxs("span", { className: "flex shrink-0 flex-col items-end gap-1 lg:flex-row lg:items-center lg:gap-3", children: [
55
+ (priceBandLabels == null ? void 0 : priceBandLabels[place.slug]) && /* @__PURE__ */ jsx("span", { className: "text-[12.5px] tabular-nums text-muted-foreground", children: priceBandLabels[place.slug] }),
56
+ venue.closes_at_label && /* @__PURE__ */ jsxs("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", children: [
57
+ /* @__PURE__ */ jsx(Clock, { className: "h-[11px] w-[11px]", "aria-hidden": true }),
58
+ `${untilLabel} ${venue.closes_at_label}`
59
+ ] })
60
+ ] }),
61
+ /* @__PURE__ */ jsx(ChevronRight, { className: "h-4 w-4 shrink-0 text-muted-foreground", "aria-hidden": true })
62
+ ]
63
+ }
64
+ )
65
+ ] }, place.slug);
66
+ }) });
67
+ }
68
+
69
+ export { OpenNowList };
@@ -47,6 +47,13 @@ type PlacePreview = {
47
47
  hour: number;
48
48
  score: number;
49
49
  }>;
50
+ /**
51
+ * The review distillation. Drives every menu-hub surface that speaks for a
52
+ * venue — `<VenueSpotlight>`, `<RankedVenueList>`, `<SignatureLineList>` —
53
+ * because the signature dish, the pull-quotes and the graded dimensions all
54
+ * live here rather than on the menu itself.
55
+ */
56
+ reviews_digest?: PlaceReviewsDigest;
50
57
  type?: string;
51
58
  business_status?: 'operational' | 'closed_temporarily' | 'closed_permanently';
52
59
  phone?: string;
@@ -71,6 +78,110 @@ type PlaceSchedule = Record<WeekdayKey, PlaceHourBlock[]>;
71
78
  type PlacePriceRange = {
72
79
  price_range_low?: number;
73
80
  price_range_high?: number;
81
+ /** Median price across the place's menu, in the local currency. */
82
+ median_price?: number;
83
+ };
84
+ /**
85
+ * Menu items are priced per size; a single-size item still ships one entry.
86
+ *
87
+ * Defined here rather than in `menu-types.ts` because `PlaceMustTry` carries
88
+ * it, and `menu-types.ts` already imports from this file — keeping the
89
+ * definition here leaves that import one-directional. `menu-types.ts`
90
+ * re-exports it, so menu code never reaches into place types for it.
91
+ */
92
+ type MenuSizeAndPrice = {
93
+ /** Grams or millilitres. */
94
+ size: number;
95
+ price: number;
96
+ discounted_price?: number;
97
+ discount_percentage?: number;
98
+ };
99
+ /** A digest string with the reviews that back it. `review_ids.length` is the mention count. */
100
+ type CitedText = {
101
+ text: string;
102
+ review_ids?: string[];
103
+ };
104
+ /** A digest enum value with the reviews that back it. The value is a language-neutral code. */
105
+ type CitedValue = {
106
+ value: string;
107
+ review_ids?: string[];
108
+ };
109
+ /**
110
+ * One dish or drink the reviews single out.
111
+ *
112
+ * `mentions` and `score` come from the item census and are only served by the
113
+ * place DETAIL endpoint — a listing carries the item without them. Surfaces
114
+ * that need a count from a list fall back to `review_ids.length`, which is
115
+ * present in both. `strength`, `why` and `review_ids` are absent for census
116
+ * items the prose digest never mentioned, so none of them can be assumed.
117
+ */
118
+ type PlaceMustTry = {
119
+ name: string;
120
+ why?: string;
121
+ strength?: 'signature' | 'frequent' | 'occasional';
122
+ /** Deep-link slug. Present only when the item matched a live menu row. */
123
+ slug?: string;
124
+ sizes_and_prices?: MenuSizeAndPrice[];
125
+ review_ids?: string[];
126
+ /** 0–100 census sentiment. Detail endpoint only; 3+ mentions required. */
127
+ score?: number;
128
+ /** Censused review mentions of this item. Detail endpoint only. */
129
+ mentions?: number;
130
+ };
131
+ /**
132
+ * Per-dimension 0–100 review scores.
133
+ *
134
+ * A dimension the reviews never mention is ABSENT, not zero — so a surface
135
+ * drawing these must render what it is given rather than a fixed row of seven.
136
+ * Exactly one of `food` / `coffee_quality` / `drinks_quality` is scored per
137
+ * place: a coffee bar has no food score and a restaurant has no coffee one.
138
+ */
139
+ type PlaceGradeDimensions = {
140
+ food?: number;
141
+ coffee_quality?: number;
142
+ drinks_quality?: number;
143
+ value?: number;
144
+ service?: number;
145
+ cleanliness?: number;
146
+ ambiance?: number;
147
+ portion?: number;
148
+ consistency?: number;
149
+ };
150
+ type PlaceGrade = {
151
+ /** 1.0–10.0. */
152
+ overall?: number;
153
+ /**
154
+ * Backend recommendation to show the number at all. When false the caller
155
+ * should draw a "building confidence" state instead — the DS renders what it
156
+ * is told, it does not apply the rule.
157
+ */
158
+ show_number?: boolean;
159
+ /** Locale-resolved band label, e.g. "Foarte bun". Already translated upstream. */
160
+ descriptor?: string;
161
+ confidence?: 'low' | 'medium' | 'high';
162
+ capped?: boolean;
163
+ dimensions?: PlaceGradeDimensions;
164
+ };
165
+ type PlaceReviewsDigest = {
166
+ grade?: PlaceGrade;
167
+ grade_note?: string;
168
+ tagline?: CitedText;
169
+ verdict?: CitedText;
170
+ caveat?: CitedText;
171
+ /** 1–3 standout positives. The pull-quotes on `<VenueSpotlight>`. */
172
+ highlights?: CitedText[];
173
+ must_try?: PlaceMustTry[];
174
+ /** Vibe codes (cozy, quiet, family_friendly, …). Localised by the caller. */
175
+ tags?: CitedValue[];
176
+ dietary?: CitedValue[];
177
+ /** small | moderate | generous */
178
+ portion_size?: CitedValue;
179
+ /** cheap | fair | mid_range | splurge */
180
+ value?: CitedValue;
181
+ /** quiet | moderate | loud */
182
+ noise_level?: CitedValue;
183
+ /** Text reviews behind the digest. */
184
+ review_count?: number;
74
185
  };
75
186
  /** Labels passed to `<PlaceCard>`. All translation happens in the caller. */
76
187
  type PlaceCardLabels = {
@@ -151,4 +262,4 @@ type PlaceDetailsMobileLabels = PlaceAboutTabLabels & {
151
262
  noImageAvailable: string;
152
263
  };
153
264
 
154
- export type { PlaceAboutTabLabels, PlaceCardLabels, PlaceDetailsMobileLabels, PlaceHourBlock, PlaceLinkKind, PlacePreview, PlacePriceRange, PlaceSchedule, PlaceScheduleWeekLabels, WeekdayKey };
265
+ export type { CitedText, CitedValue, MenuSizeAndPrice, PlaceAboutTabLabels, PlaceCardLabels, PlaceDetailsMobileLabels, PlaceGrade, PlaceGradeDimensions, PlaceHourBlock, PlaceLinkKind, PlaceMustTry, PlacePreview, PlacePriceRange, PlaceReviewsDigest, PlaceSchedule, PlaceScheduleWeekLabels, WeekdayKey };
@@ -0,0 +1,49 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { MenuVenuePreview, MenuLinkComponent, MenuLinkProps } from './menu-types.js';
3
+ import 'react';
4
+ import './place-types.js';
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
+ /** @deprecated Alias of `MenuLinkProps`; kept so existing imports keep working. */
22
+ type RankedVenueListLinkProps = MenuLinkProps;
23
+ type RankedVenueListTab = {
24
+ slug: string;
25
+ label: string;
26
+ };
27
+ type RankedVenueListProps = {
28
+ venues: MenuVenuePreview[];
29
+ /** Section heading, e.g. "The kitchens Cluj rates for Italian". */
30
+ heading?: string;
31
+ /** The basis of the order, e.g. "by how often reviews name their dishes". */
32
+ caption?: string;
33
+ tabs?: RankedVenueListTab[];
34
+ activeTab?: string;
35
+ onTabChange?: (slug: string) => void;
36
+ /** Composed price band per venue, keyed by place slug, e.g. "38—84". */
37
+ priceBandLabels?: Record<string, string>;
38
+ /**
39
+ * The app's own grade per venue slug, already formatted ("8.7"). Shown in
40
+ * place of the star rating when present — the star is the third-party
41
+ * fallback for a venue the reviews have not graded yet.
42
+ */
43
+ gradeLabels?: Record<string, string>;
44
+ linkComponent?: MenuLinkComponent;
45
+ className?: string;
46
+ };
47
+ declare function RankedVenueList({ venues, heading, caption, tabs, activeTab, onTabChange, priceBandLabels, gradeLabels, linkComponent: LinkComponent, className, }: RankedVenueListProps): react_jsx_runtime.JSX.Element;
48
+
49
+ export { RankedVenueList, type RankedVenueListLinkProps, type RankedVenueListProps, type RankedVenueListTab };
@@ -0,0 +1,87 @@
1
+ import { Separator } from '../chunk-WSRWFA6K.js';
2
+ import { cn } from '../chunk-FEK5XGZW.js';
3
+ import { __objRest, __spreadValues } from '../chunk-3GWWZKX7.js';
4
+ import { MessageSquare, Star, ChevronRight } from 'lucide-react';
5
+ import { jsxs, jsx } from 'react/jsx-runtime';
6
+
7
+ function DefaultLink(_a) {
8
+ var _b = _a, { href } = _b, props = __objRest(_b, ["href"]);
9
+ return /* @__PURE__ */ jsx("a", __spreadValues({ href }, props));
10
+ }
11
+ function RankedVenueList({
12
+ venues,
13
+ heading,
14
+ caption,
15
+ tabs,
16
+ activeTab,
17
+ onTabChange,
18
+ priceBandLabels,
19
+ gradeLabels,
20
+ linkComponent: LinkComponent = DefaultLink,
21
+ className
22
+ }) {
23
+ return /* @__PURE__ */ jsxs("div", { className: cn("overflow-hidden rounded-[14px] border border-border bg-card", className), children: [
24
+ (heading || tabs) && /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border p-3.5", children: [
25
+ heading && /* @__PURE__ */ jsx("h3", { className: "text-[15px] font-bold leading-tight tracking-[-0.01em]", children: heading }),
26
+ caption && /* @__PURE__ */ jsx("span", { className: "text-[12.5px] text-muted-foreground", children: caption }),
27
+ tabs && tabs.length > 0 && /* @__PURE__ */ jsx("div", { className: "ml-auto flex flex-wrap gap-1.5", children: tabs.map((tab) => /* @__PURE__ */ jsx(
28
+ "button",
29
+ {
30
+ type: "button",
31
+ "aria-pressed": tab.slug === activeTab,
32
+ onClick: () => onTabChange == null ? void 0 : onTabChange(tab.slug),
33
+ className: cn(
34
+ "inline-flex h-7 items-center rounded-full border px-2.5 text-xs font-semibold transition-colors",
35
+ tab.slug === activeTab ? "border-accent bg-accent-subtle text-accent" : "border-border text-muted-foreground hover:bg-muted/50"
36
+ ),
37
+ children: tab.label
38
+ },
39
+ tab.slug
40
+ )) })
41
+ ] }),
42
+ /* @__PURE__ */ jsx("ol", { children: venues.map((venue, index) => {
43
+ var _a;
44
+ const { place, signature } = venue;
45
+ const mentions = (_a = signature == null ? void 0 : signature.item.mentions) != null ? _a : 0;
46
+ return /* @__PURE__ */ jsxs("li", { children: [
47
+ index > 0 && /* @__PURE__ */ jsx(Separator, {}),
48
+ /* @__PURE__ */ jsxs(
49
+ LinkComponent,
50
+ {
51
+ href: venue.href,
52
+ 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",
53
+ children: [
54
+ /* @__PURE__ */ jsx("span", { className: "w-4 shrink-0 text-center text-[13px] font-extrabold tabular-nums text-accent", children: index + 1 }),
55
+ /* @__PURE__ */ jsx("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)]", children: place.thumbnail_url && // eslint-disable-next-line @next/next/no-img-element
56
+ /* @__PURE__ */ jsx(
57
+ "img",
58
+ {
59
+ src: place.thumbnail_url,
60
+ alt: "",
61
+ className: "h-full w-full object-cover",
62
+ loading: "lazy"
63
+ }
64
+ ) }),
65
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
66
+ /* @__PURE__ */ jsx("span", { className: "block text-sm font-bold leading-snug tracking-[-0.01em]", children: place.name }),
67
+ (signature == null ? void 0 : signature.item.name) && /* @__PURE__ */ jsx("span", { className: "mt-px block truncate font-serif text-xs italic text-muted-foreground", children: `\u201C${signature.item.name}\u201D` })
68
+ ] }),
69
+ mentions > 0 && /* @__PURE__ */ jsxs("span", { className: "hidden shrink-0 items-center gap-1 text-[11.5px] tabular-nums text-muted-foreground sm:inline-flex", children: [
70
+ /* @__PURE__ */ jsx(MessageSquare, { className: "h-[11px] w-[11px]", "aria-hidden": true }),
71
+ mentions
72
+ ] }),
73
+ (gradeLabels == null ? void 0 : gradeLabels[place.slug]) ? /* @__PURE__ */ jsx("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", children: gradeLabels[place.slug] }) : place.rating && /* @__PURE__ */ jsxs("span", { className: "inline-flex shrink-0 items-center gap-1 text-[12.5px] font-semibold tabular-nums", children: [
74
+ /* @__PURE__ */ jsx(Star, { className: "h-3 w-3", "aria-hidden": true }),
75
+ place.rating.rating
76
+ ] }),
77
+ (priceBandLabels == null ? void 0 : priceBandLabels[place.slug]) && /* @__PURE__ */ jsx("span", { className: "hidden shrink-0 text-[12.5px] tabular-nums text-muted-foreground sm:block", children: priceBandLabels[place.slug] }),
78
+ /* @__PURE__ */ jsx(ChevronRight, { className: "h-4 w-4 shrink-0 text-muted-foreground", "aria-hidden": true })
79
+ ]
80
+ }
81
+ )
82
+ ] }, place.slug);
83
+ }) })
84
+ ] });
85
+ }
86
+
87
+ export { RankedVenueList };