@mohasinac/appkit 4.53.1 → 4.53.2

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 (53) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/theme/ThemeProvider.js +15 -1
  3. package/dist/_internal/server/features/categories/price-guidance-resolve.d.ts +66 -0
  4. package/dist/_internal/server/features/categories/price-guidance-resolve.js +138 -0
  5. package/dist/_internal/server/features/price-index/market.d.ts +57 -0
  6. package/dist/_internal/server/features/price-index/market.js +120 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +12 -0
  8. package/dist/_internal/server/features/products/list-public.js +10 -1
  9. package/dist/_internal/server/features/products/listing-tab-counts.d.ts +25 -1
  10. package/dist/_internal/server/features/products/listing-tab-counts.js +128 -10
  11. package/dist/_internal/server/features/stores/tabs.d.ts +7 -2
  12. package/dist/_internal/server/features/stores/tabs.js +5 -3
  13. package/dist/_internal/shared/search/match.js +28 -2
  14. package/dist/constants/api-endpoints.d.ts +7 -0
  15. package/dist/constants/api-endpoints.js +7 -0
  16. package/dist/features/auctions/components/AuctionDetailPageView.js +8 -7
  17. package/dist/features/categories/api/[id]/price-guidance/route.js +1 -24
  18. package/dist/features/categories/components/BrandDetailPageView.js +14 -6
  19. package/dist/features/categories/components/BrandDetailTabs.d.ts +7 -1
  20. package/dist/features/categories/components/BrandDetailTabs.js +18 -3
  21. package/dist/features/categories/components/BundleCollage.js +3 -2
  22. package/dist/features/categories/components/BundleItemsPicker.js +3 -4
  23. package/dist/features/categories/components/CategoryDetailPageView.js +17 -7
  24. package/dist/features/categories/components/CategoryDetailTabs.d.ts +7 -1
  25. package/dist/features/categories/components/CategoryDetailTabs.js +29 -7
  26. package/dist/features/classified/components/ClassifiedDetailPageView.js +10 -1
  27. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +10 -1
  28. package/dist/features/live/components/LiveItemDetailPageView.js +10 -1
  29. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +8 -7
  30. package/dist/features/price-index/components/PriceIndexToolView.d.ts +21 -0
  31. package/dist/features/price-index/components/PriceIndexToolView.js +230 -27
  32. package/dist/features/price-index/lookup-target.d.ts +48 -0
  33. package/dist/features/price-index/lookup-target.js +107 -0
  34. package/dist/features/products/components/AvailabilityScopeHint.d.ts +22 -0
  35. package/dist/features/products/components/AvailabilityScopeHint.js +27 -0
  36. package/dist/features/products/components/CompareOverlay.d.ts +2 -1
  37. package/dist/features/products/components/CompareOverlay.js +2 -1
  38. package/dist/features/products/components/GroupSettingsPanel.js +2 -1
  39. package/dist/features/products/components/ProductDetailPageView.js +7 -2
  40. package/dist/features/products/components/SublistingCarouselSection.js +2 -1
  41. package/dist/features/products/repository/products.repository.d.ts +1 -0
  42. package/dist/features/products/repository/products.repository.js +5 -1
  43. package/dist/features/products/utils/group-member-image.d.ts +9 -2
  44. package/dist/features/products/utils/group-member-image.js +2 -1
  45. package/dist/features/seller/components/SellerProductsView.js +3 -2
  46. package/dist/features/stores/components/StoreAuctionsListing.js +2 -1
  47. package/dist/features/tester/seed-data/authored/content-discovery__authenticity-checker.js +114 -2
  48. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +12 -0
  49. package/dist/seed/products-beyblade-x-seed-data.js +36 -1
  50. package/dist/server.d.ts +2 -0
  51. package/dist/server.js +8 -0
  52. package/package.json +1 -1
  53. package/scripts/seed-cli.mjs +133 -27
@@ -8,13 +8,20 @@ import { Badge } from "../../../ui/components/Badge.js";
8
8
  import { Button } from "../../../ui/components/Button.js";
9
9
  import { Form } from "../../../ui/components/Form.js";
10
10
  import { FieldInput } from "../../../ui/forms/FieldInput.js";
11
+ import { PaginatedSelect } from "../../../ui/components/PaginatedSelect.js";
12
+ import { TextLink } from "../../../ui/components/TextLink.js";
11
13
  import { MediaImage } from "../../media/MediaImage.js";
12
14
  import { TOOL_ENDPOINTS } from "../../../constants/api-endpoints.js";
13
15
  import { apiClient } from "../../../http/ApiClient.js";
14
16
  import { useSiteSearch } from "../../search/hooks/useSiteSearch.js";
17
+ import { useSearchIndex } from "../../search/hooks/useSearchIndex.js";
18
+ import { searchIndex } from "../../../_internal/shared/search/match.js";
19
+ import { useOptionalSession } from "../../../react/contexts/SessionContext.js";
20
+ import { ROUTES } from "../../../next/routing/route-map.js";
15
21
  import { formatCurrency } from "../../../utils/number.formatter.js";
16
22
  import { normalizeError } from "../../../errors/normalize.js";
17
23
  import { toUserMessage } from "../../../errors/error-display-map.js";
24
+ import { PRICE_LOOKUP_INDEX_TYPES, priceLookupTargetFor } from "../lookup-target.js";
18
25
  /** Human labels. Falls back to the raw value so a new condition still renders. */
19
26
  const CONDITION_LABEL = {
20
27
  new: "New",
@@ -47,6 +54,14 @@ const __O = {
47
54
  xAuto: "overflow-x-auto",
48
55
  hidden: "overflow-hidden",
49
56
  };
57
+ /**
58
+ * How many options the dropdown offers.
59
+ *
60
+ * Larger than `PRICE_INDEX_SEARCH_LIMIT`'s 8 because this is a PICK list rather
61
+ * than a result set — the cost of an extra row is a line of text, not a
62
+ * Firestore document, so the bound that matters is what a person will read.
63
+ */
64
+ const DROPDOWN_LIMIT = 20;
50
65
  const money = (v) => formatCurrency(v, "INR");
51
66
  function relativeDay(iso) {
52
67
  if (!iso)
@@ -79,22 +94,71 @@ function BandCard({ band, threshold }) {
79
94
  ? ` · via ${band.lastSold.via}`
80
95
  : ""] }))] })) : (_jsx(LowConfidence, { sampleSize: band.sampleSize, threshold: threshold })), band.byCondition && band.byCondition.length > 0 && (_jsx(ConditionTable, { rows: band.byCondition, threshold: threshold }))] }));
81
96
  }
82
- function ResultCard({ row, threshold }) {
83
- return (_jsxs(Stack, { gap: "md", padding: "card", rounded: "lg", surface: "card", border: "subtle", className: "min-w-0", children: [_jsxs(Row, { gap: "md", align: "start", className: "min-w-0", children: [row.coverImage && (_jsx(Div, { rounded: "lg", className: `w-16 h-16 flex-shrink-0 ${__O.hidden}`, children: _jsx(MediaImage, { src: row.coverImage, alt: row.name, size: "thumbnail" }) })), _jsxs(Stack, { gap: "none", className: "flex-1 min-w-0", children: [_jsx(Heading, { level: 3, children: row.name }), row.path && (_jsx(Text, { size: "xs", color: "muted", className: "min-w-0 break-words", children: row.path }))] })] }), row.bands && row.bands.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Stack, { gap: "sm", className: "min-w-0", children: row.bands.map((b) => (_jsx(BandCard, { band: b, threshold: threshold }, b.listingType))) }), _jsxs(Text, { size: "xs", color: "muted", children: ["Completed sales on LetItRip", row.windowDays ? ` over the last ${row.windowDays} days` : "", row.computedAt ? ` · as of ${new Date(row.computedAt).toLocaleDateString("en-IN")}` : ""] })] })) : (
97
+ /**
98
+ * The competitor-crawl band: what this thing is typically ASKED for.
99
+ *
100
+ * ## 🛑 Side by side with the sold band, never averaged with it
101
+ *
102
+ * `price-bands.md` states the rule this component exists to honour: "They are
103
+ * rendered side by side and never averaged. The gap between them is the most
104
+ * useful thing we can tell a seller." An ask and a sale are different events,
105
+ * and a blended figure would be a statistic describing neither.
106
+ *
107
+ * ## 🛑 "Sold out" is not "sold"
108
+ *
109
+ * 76–93% of the crawled catalogues are sold out, and a sold-out row proves the
110
+ * item is GONE, not that it changed hands at that price. So `soldOutShare` is
111
+ * rendered as prose rather than dropped — without it, a reader takes the median
112
+ * for a clearing price, which is the single wrong conclusion this data invites.
113
+ *
114
+ * ## 🛑 An inherited band says whose band it is
115
+ *
116
+ * `MARKET_BANDS` is keyed on era and part-group leaves, never on the ~216
117
+ * named-model leaves — the crawl measured a generation, not a bey. So a model
118
+ * lookup resolves its era's band, and the card must attribute it: "typical for
119
+ * Burst DB" is true, "typical for Rage Longinus" is not.
120
+ */
121
+ function MarketCard({ market }) {
122
+ const hasRange = market.p25 !== undefined && market.p75 !== undefined;
123
+ const soldOutPct = market.soldOutShare === undefined ? null : Math.round(market.soldOutShare * 100);
124
+ return (_jsxs(Stack, { gap: "sm", padding: "sm", rounded: "lg", border: "default", className: "min-w-0", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", wrap: true, className: "min-w-0", children: [_jsx(Badge, { variant: "secondary", children: "Typical asking price" }), market.sampleSize !== undefined && (_jsxs(Text, { size: "xs", color: "muted", children: [market.sampleSize, " ", market.sampleSize === 1 ? "listing" : "listings"] }))] }), _jsxs(Row, { gap: "lg", wrap: true, className: "min-w-0", children: [hasRange && _jsx(Figure, { label: "Usual low", value: money(market.p25) }), _jsx(Figure, { label: "Typical ask", value: money(market.median) }), hasRange && _jsx(Figure, { label: "Usual high", value: money(market.p75) })] }), market.inherited && (_jsxs(Text, { size: "sm", color: "muted", children: ["Measured for", " ", _jsx(Span, { weight: "semibold", children: market.viaName ?? market.viaCategoryId }), " as a whole \u2014 the crawl priced the generation, not this exact item."] })), soldOutPct !== null && (_jsxs(Text, { size: "sm", color: "muted", children: ["These are asking prices from other shops, and about ", soldOutPct, "% of listings at this level are sold out \u2014 so an ask is not proof anything changed hands at it."] })), _jsxs(Text, { size: "xs", color: "muted", className: "min-w-0 break-words", children: [market.source ? `Source: ${market.source}. ` : "", "Tax-inclusive", market.asOf
125
+ ? ` · measured ${new Date(market.asOf).toLocaleDateString("en-IN")}`
126
+ : ""] })] }));
127
+ }
128
+ /**
129
+ * What a signed-out visitor sees where the market band would be.
130
+ *
131
+ * 🛑 This is NOT the guest price gate. The sold band above it is public by
132
+ * decision (see this file's header). The market band is withheld for a
133
+ * different reason, already settled at
134
+ * `categories/api/[id]/price-guidance/route.ts`: it is competitor-derived, and
135
+ * publishing it on an unauthenticated, cacheable surface is the "neighbouring
136
+ * surface publishes the range" leak. The copy therefore says what is behind
137
+ * sign-in and why it is worth having, rather than implying no data exists.
138
+ */
139
+ function MarketSignInPrompt() {
140
+ return (_jsxs(Stack, { gap: "xs", padding: "sm", rounded: "lg", surface: "muted", className: "min-w-0", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Asking prices elsewhere" }), _jsxs(Text, { size: "sm", color: "muted", children: ["We measured what other Indian shops ask for this, from a crawl of fifteen catalogues.", " ", _jsx(TextLink, { href: String(ROUTES.AUTH.LOGIN), children: "Sign in" }), " to see the range."] })] }));
141
+ }
142
+ function ResultCard({ row, threshold, market, canSeeMarket, }) {
143
+ return (_jsxs(Stack, { gap: "md", padding: "card", rounded: "lg", surface: "card", border: "subtle", className: "min-w-0", children: [_jsxs(Row, { gap: "md", align: "start", className: "min-w-0", children: [row.coverImage && (_jsx(Div, { rounded: "lg", className: `w-16 h-16 flex-shrink-0 ${__O.hidden}`, children: _jsx(MediaImage, { src: row.coverImage, alt: row.name, size: "thumbnail" }) })), _jsxs(Stack, { gap: "none", className: "flex-1 min-w-0", children: [_jsx(Heading, { level: 3, children: row.name }), row.path && (_jsx(Text, { size: "xs", color: "muted", className: "min-w-0 break-words", children: row.path }))] })] }), (!row.bands || row.bands.length === 0) &&
144
+ (market ? _jsx(MarketCard, { market: market }) : canSeeMarket ? null : _jsx(MarketSignInPrompt, {})), row.bands && row.bands.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Stack, { gap: "sm", className: "min-w-0", children: row.bands.map((b) => (_jsx(BandCard, { band: b, threshold: threshold }, b.listingType))) }), market ? _jsx(MarketCard, { market: market }) : canSeeMarket ? null : _jsx(MarketSignInPrompt, {}), _jsxs(Text, { size: "xs", color: "muted", children: ["Completed sales on LetItRip", row.windowDays ? ` over the last ${row.windowDays} days` : "", row.computedAt ? ` · as of ${new Date(row.computedAt).toLocaleDateString("en-IN")}` : ""] })] })) : (
84
145
  /*
85
146
  * 🛑 "No sales recorded yet" — never ₹0, and never a blank card. The
86
147
  * absence of a band means nothing has sold here, which is a fact about
87
148
  * our marketplace and not a failure of the lookup.
149
+ *
150
+ * 🛑 The wording is DIFFERENT when a market band is showing above it.
151
+ * "Prices appear once something sells" was written for a card that had
152
+ * nothing else on it; printed under a real asking-price band it reads
153
+ * as though the band were not a price, which is the opposite of what
154
+ * this card now says.
88
155
  */
89
- _jsx(Text, { size: "sm", color: "muted", children: "No completed sales recorded here yet. Prices appear once something sells on LetItRip." }))] }));
156
+ _jsx(Text, { size: "sm", color: "muted", children: market
157
+ ? "Nothing has sold here on LetItRip yet, so the figures above are asking prices elsewhere rather than our own sales."
158
+ : "No completed sales recorded here yet. Prices appear once something sells on LetItRip." }))] }));
90
159
  }
91
160
  /** Stable identity — a `useMemo` dependency inside `useSiteSearch`. */
92
161
  const identityLabel = (typeKey) => typeKey;
93
- /** `/categories/{slug}` -> `{slug}`. Category documents are pure slugs (id === slug). */
94
- function categoryIdFromHref(href) {
95
- const m = /^\/categories\/([^/?#]+)/.exec(href);
96
- return m ? m[1] : null;
97
- }
98
162
  export function PriceIndexToolView({ initialQuery = "", initialData = null, searchIndexEnabled, }) {
99
163
  const [draft, setDraft] = useState(initialQuery);
100
164
  /**
@@ -105,24 +169,108 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
105
169
  * keystroke against a route that holds a Firestore query plus a multi-get —
106
170
  * which is Root Cause #94's recurring server bill, and `audit-client-poll-cost`
107
171
  * exists because of it. The query key only moves when somebody submits.
172
+ *
173
+ * 🛑 Still here after the dropdown landed, because it drives the FALLBACK
174
+ * path: the dropdown's options come from the local search artifact, so when
175
+ * that artifact is disabled or fails to load there is nothing to pick from
176
+ * and the tool must still work. Deleting this would have made a kill-switch
177
+ * flip turn the whole page into an empty combobox.
108
178
  */
109
179
  const [term, setTerm] = useState(initialQuery.trim());
180
+ /**
181
+ * What the dropdown picked: the category id to price, plus its label.
182
+ *
183
+ * 🛑 The LABEL is kept alongside the id, not looked up again. The option list
184
+ * is rebuilt per keystroke inside `loadOptions`, so by the time results
185
+ * arrive the option that produced the selection may no longer be in the list
186
+ * — and `PaginatedSelect` renders the trigger from the value it was given.
187
+ * Without this the chosen item's name would vanish from the control the
188
+ * moment the query that found it changed.
189
+ */
190
+ const [picked, setPicked] = useState(null);
191
+ /** Signed in? Decides whether the market half is requested at all. */
192
+ const session = useOptionalSession();
193
+ const canSeeMarket = Boolean(session?.user?.uid);
110
194
  /*
111
- * 🛑 TIER 1 ADOPTION. `searchPriceIndex` (the fallback below) pays a
112
- * `searchTxt` push-down query to turn `term` into category leaves —
113
- * exactly the resolution `useSiteSearch` already does in the browser, at
114
- * zero reads, for every other search surface on the site. Resolving here
115
- * removes that query from the HOT path; the server is left with only the
116
- * one multi-get it cannot avoid.
195
+ * The artifact, for the dropdown. `active` is true as soon as the control is
196
+ * on the page: unlike the old submit-driven flow there is no "intent"
197
+ * threshold to wait for — a combobox the user has just clicked must have
198
+ * options, and the artifact is a one-time ~420 KB load with a module-scope
199
+ * singleton behind it, not a per-keystroke cost.
200
+ */
201
+ const dropdownIndex = useSearchIndex({
202
+ active: true,
203
+ indexEnabled: searchIndexEnabled,
204
+ });
205
+ /**
206
+ * Options for the dropdown, resolved LOCALLY on every keystroke.
117
207
  *
118
- * `active` mirrors `term`'s own gate (2+ chars) rather than firing on
119
- * mount — consistent with every other `useSiteSearch` caller, which loads
120
- * the ~420 KB artifact only once intent is shown.
208
+ * 🛑 `async` only to satisfy `loadOptions`' signature — nothing here awaits.
209
+ * `searchIndex` is a linear scan over ~3,700 prepared records, measured at
210
+ * 0.76 ms by `verify-search-local.mjs`, so there is no network round trip to
211
+ * debounce and no Firestore read to bill. This is the whole reason the
212
+ * dropdown can be a dropdown.
213
+ *
214
+ * 🛑 Hits that cannot be priced are DROPPED, not shown disabled. An option
215
+ * that selects to a card reading "no data" is worse than an absent one — it
216
+ * looks like the tool failed rather than like the item is not priceable. See
217
+ * `priceLookupTargetFor` for which types resolve and why.
218
+ */
219
+ const loadOptions = useCallback(async (query, page) => {
220
+ const idx = dropdownIndex.index;
221
+ if (!idx || !query.trim())
222
+ return { items: [], hasMore: false };
223
+ const hits = searchIndex(idx.artifact, idx.prepared, query, {
224
+ types: PRICE_LOOKUP_INDEX_TYPES,
225
+ limit: DROPDOWN_LIMIT,
226
+ });
227
+ const seen = new Set();
228
+ const items = [];
229
+ for (const hit of hits) {
230
+ const target = priceLookupTargetFor(hit.href);
231
+ if (!target || seen.has(target))
232
+ continue;
233
+ seen.add(target);
234
+ items.push({
235
+ value: target,
236
+ /*
237
+ * The type and the detail both earn their place: `detail` is the
238
+ * taxonomy path for a category and the classification for a part,
239
+ * which is how a reader tells three results named "105" apart.
240
+ */
241
+ label: `${hit.title}${hit.detail ? ` — ${hit.detail}` : ""}`,
242
+ });
243
+ }
244
+ // One page: the matcher already ranked and capped. Paging a ranked list
245
+ // would invite scrolling past the answer rather than refining the query.
246
+ return { items, hasMore: false, nextPage: page };
247
+ }, [dropdownIndex.index]);
248
+ /**
249
+ * 🛑 The dropdown is only usable with an artifact behind it. On `disabled` or
250
+ * `failed` the page falls back to the original text box, which hits the
251
+ * server-side `searchTxt` path. Reported as a state rather than silently
252
+ * degrading, because an empty combobox is indistinguishable from an empty
253
+ * catalogue.
254
+ */
255
+ const dropdownUsable = dropdownIndex.status === "ready" || dropdownIndex.status === "loading" ||
256
+ dropdownIndex.status === "idle";
257
+ /*
258
+ * 🛑 TIER 1 ADOPTION, kept for the FALLBACK path only.
259
+ *
260
+ * `searchPriceIndex` pays a `searchTxt` push-down query to turn a free-text
261
+ * term into category leaves — the resolution the local artifact does for
262
+ * free. When the dropdown is driving, no term is ever submitted and this
263
+ * stays idle; it matters when somebody lands on a shared `?q=` link.
264
+ *
265
+ * 🛑 Its `types` now come from `PRICE_LOOKUP_INDEX_TYPES`, not a hand-written
266
+ * `["model", "category"]`. That literal is why spares were unreachable: the
267
+ * index has had a `part` type the whole time and this call never asked for
268
+ * it, so no amount of typing a part name could surface one.
121
269
  */
122
270
  const local = useSiteSearch(term, {
123
271
  active: term.length >= 2,
124
272
  indexEnabled: searchIndexEnabled,
125
- types: ["model", "category"],
273
+ types: PRICE_LOOKUP_INDEX_TYPES,
126
274
  label: identityLabel,
127
275
  });
128
276
  const localIds = React.useMemo(() => {
@@ -131,7 +279,13 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
131
279
  const ids = new Set();
132
280
  for (const group of local.result.groups) {
133
281
  for (const hit of group.hits) {
134
- const id = categoryIdFromHref(hit.href);
282
+ /*
283
+ * 🛑 `priceLookupTargetFor`, not a bare `/categories/` regex. The old
284
+ * `categoryIdFromHref` matched that one shape and returned `null` for
285
+ * everything else, so a part hit was discarded here even on the rare
286
+ * occasion one arrived.
287
+ */
288
+ const id = priceLookupTargetFor(hit.href);
135
289
  if (id)
136
290
  ids.add(id);
137
291
  }
@@ -139,6 +293,14 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
139
293
  return [...ids];
140
294
  }, [local.result]);
141
295
  const localReady = local.status === "ready";
296
+ /**
297
+ * The ids actually being looked up.
298
+ *
299
+ * A dropdown pick wins outright: it is an explicit choice of ONE thing, and
300
+ * mixing it with the term-resolved set would answer a question the user
301
+ * stopped asking the moment they picked.
302
+ */
303
+ const lookupIds = React.useMemo(() => (picked ? [picked.id] : localReady ? localIds : []), [picked, localReady, localIds]);
142
304
  /*
143
305
  * The bands-only request. Enabled ONLY once local resolution has actually
144
306
  * produced something — an empty `localIds` with the gate open would be a
@@ -146,19 +308,51 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
146
308
  * but which is still a request worth not making.
147
309
  */
148
310
  const bandsQuery = useQuery({
149
- queryKey: ["tool", "price-index", "bands", localIds],
150
- queryFn: () => apiClient.get(TOOL_ENDPOINTS.PRICE_INDEX_BANDS(localIds)),
151
- enabled: localReady && localIds.length > 0,
311
+ queryKey: ["tool", "price-index", "bands", lookupIds],
312
+ queryFn: () => apiClient.get(TOOL_ENDPOINTS.PRICE_INDEX_BANDS(lookupIds)),
313
+ enabled: lookupIds.length > 0,
152
314
  staleTime: Infinity,
153
315
  refetchOnWindowFocus: false,
154
316
  });
317
+ /**
318
+ * The MARKET half — a separate request, on purpose.
319
+ *
320
+ * 🛑 `enabled: canSeeMarket && …`. A guest never issues it, so the gate costs
321
+ * a signed-out visitor nothing and cannot 401-spam the console. The server
322
+ * enforces it too (`auth: true`); this is the client half of one decision,
323
+ * not the decision itself.
324
+ *
325
+ * 🛑 A separate request rather than two more fields on `bandsQuery`, because
326
+ * that route is PUBLIC and edge-cached: folding viewer-dependent content into
327
+ * it would let one signed-in lookup populate the entry every guest then
328
+ * reads. The full argument is in `price-index/market.ts`.
329
+ *
330
+ * 🛑 Its failure is deliberately NOT surfaced as the page's error. The sold
331
+ * band is the page; the ask is context. A 401 or a cold-start blip on the
332
+ * context must not replace a working result with "could not look that up".
333
+ */
334
+ const marketQuery = useQuery({
335
+ queryKey: ["tool", "price-index", "market", lookupIds],
336
+ queryFn: () => apiClient.get(TOOL_ENDPOINTS.PRICE_INDEX_MARKET(lookupIds)),
337
+ enabled: canSeeMarket && lookupIds.length > 0,
338
+ staleTime: Infinity,
339
+ refetchOnWindowFocus: false,
340
+ });
341
+ /** `categoryId` -> its market band, for the per-card join. */
342
+ const marketById = React.useMemo(() => {
343
+ const m = new Map();
344
+ for (const row of marketQuery.data?.results ?? [])
345
+ m.set(row.categoryId, row);
346
+ return m;
347
+ }, [marketQuery.data]);
155
348
  /*
156
349
  * The FALLBACK. Fires only when the local index is off, failed, or
157
350
  * (rarely) resolved to nothing — never alongside the local path, so a
158
351
  * working index never pays for both. Same cache posture as it always had.
159
352
  */
160
- const fallbackEnabled = term.length >= 2 && (local.status === "failed" || local.status === "disabled" ||
161
- (localReady && localIds.length === 0));
353
+ const fallbackEnabled = !picked && term.length >= 2 &&
354
+ (local.status === "failed" || local.status === "disabled" ||
355
+ (localReady && localIds.length === 0));
162
356
  const fallbackQuery = useQuery({
163
357
  queryKey: ["tool", "price-index", term],
164
358
  queryFn: () => apiClient.get(TOOL_ENDPOINTS.PRICE_INDEX_SEARCH(term)),
@@ -175,11 +369,11 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
175
369
  * how many ids were resolved, so there is nothing left uncounted the way
176
370
  * the query-based path's search step could leave behind.
177
371
  */
178
- const query = localReady && localIds.length > 0
372
+ const query = lookupIds.length > 0
179
373
  ? {
180
374
  data: bandsQuery.data
181
375
  ? {
182
- query: term,
376
+ query: picked ? picked.label : term,
183
377
  results: bandsQuery.data.results,
184
378
  truncated: false,
185
379
  /*
@@ -215,5 +409,14 @@ export function PriceIndexToolView({ initialQuery = "", initialData = null, sear
215
409
  : null;
216
410
  const threshold = data?.minConfidentSamples ?? 3;
217
411
  const hasQuery = (data?.query ?? "").length > 0;
218
- return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(TrendingUp, { className: "w-5 h-5 text-primary-600", "aria-hidden": "true" }), _jsx(Heading, { level: 1, children: "What it sold for" })] }), _jsx(Text, { color: "muted", children: "Search any model, part or set to see what it actually sold for on LetItRip \u2014 the range, the median, the average and the most recent sale, broken down by condition." })] }), _jsx(Form, { onSubmit: (e) => e.preventDefault(), children: () => (_jsxs(Row, { gap: "sm", align: "end", wrap: true, className: "min-w-0", children: [_jsx(Div, { className: "flex-1 min-w-[16rem]", children: _jsx(FieldInput, { name: "q", label: "Search", value: draft, onChange: setDraft, placeholder: "e.g. Dragoon G, Storm Pegasus, rubber flat\u2026" }) }), _jsxs(Button, { type: "submit", isLoading: loading, onClick: () => run(draft), children: [_jsx(Search, { className: "w-4 h-4", "aria-hidden": "true" }), "Look up"] })] })) }), error && (_jsx(Text, { size: "sm", color: "error", role: "alert", children: error })), !hasQuery && !loading && !error && (_jsxs(Stack, { gap: "xs", padding: "card", rounded: "lg", surface: "muted", className: "min-w-0", children: [_jsx(Text, { weight: "semibold", children: "Why these prices are different" }), _jsxs(Text, { size: "sm", color: "muted", children: ["Everything here is a completed sale on LetItRip \u2014 not an asking price, and not a guess. We publish a figure only once", " ", threshold, " or more sales support it, and the index is recomputed once a day, so it is deliberately a day behind."] })] })), hasQuery && data && data.results.length === 0 && !loading && (_jsxs(Text, { color: "muted", children: ["Nothing matched \u201C", data.query, "\u201D. Try a shorter term, or the model name on its own."] })), data && data.results.length > 0 && (_jsxs(Stack, { gap: "md", className: "min-w-0", children: [data.results.map((row) => (_jsx(ResultCard, { row: row, threshold: threshold }, row.categoryId))), data.truncated && (_jsxs(Text, { size: "sm", color: "muted", children: ["Showing the closest ", data.results.length, " matches. Narrow the search to see others."] }))] }))] }) }) }));
412
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(TrendingUp, { className: "w-5 h-5 text-primary-600", "aria-hidden": "true" }), _jsx(Heading, { level: 1, children: "What it sold for" })] }), _jsx(Text, { color: "muted", children: "Pick any bey, part or set to see what it actually sold for on LetItRip \u2014 the range, the median, the average and the most recent sale, broken down by condition \u2014 alongside what other Indian shops are asking for the same thing." })] }), dropdownUsable ? (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(PaginatedSelect, { value: picked?.id ?? null, onChange: (value, option) => setPicked(value && option ? { id: value, label: option.label } : null), loadOptions: loadOptions, placeholder: "Pick a bey, part or set\u2026", searchPlaceholder: "Type a name, code or part \u2014 e.g. Dragoon G, 1B, rubber flat", noResultsText: "Nothing in our catalogue matches that.", ariaLabel: "Search the catalogue for a price lookup" }), _jsx(Text, { size: "xs", color: "muted", children: "Models, parts and spares, eras and brands \u2014 straight from our own catalogue, so what you pick is a real thing we can price." })] })) : (
413
+ /*
414
+ * 🛑 The fallback, for a disabled or failed search artifact. Without
415
+ * it a kill-switch flip would leave an empty combobox and no way to
416
+ * search at all — the dropdown has no options without the artifact,
417
+ * but the server-side `searchTxt` path still works.
418
+ */
419
+ _jsx(Form, { onSubmit: (e) => e.preventDefault(), children: () => (_jsxs(Row, { gap: "sm", align: "end", wrap: true, className: "min-w-0", children: [_jsx(Div, { className: "flex-1 min-w-[16rem]", children: _jsx(FieldInput, { name: "q", label: "Search", value: draft, onChange: setDraft, placeholder: "e.g. Dragoon G, Storm Pegasus, rubber flat\u2026" }) }), _jsxs(Button, { type: "submit", isLoading: loading, onClick: () => run(draft), children: [_jsx(Search, { className: "w-4 h-4", "aria-hidden": "true" }), "Look up"] })] })) })), error && (_jsx(Text, { size: "sm", color: "error", role: "alert", children: error })), !hasQuery && !loading && !error && (_jsxs(Stack, { gap: "xs", padding: "card", rounded: "lg", surface: "muted", className: "min-w-0", children: [_jsx(Text, { weight: "semibold", children: "Two numbers, and the gap matters" }), _jsxs(Text, { size: "sm", color: "muted", children: [_jsx(Span, { weight: "semibold", children: "Sold" }), " is a completed sale on LetItRip \u2014 not an asking price and not a guess. We publish a figure only once ", threshold, " or more sales support it, and the index is recomputed once a day, so it is deliberately a day behind."] }), _jsxs(Text, { size: "sm", color: "muted", children: [_jsx(Span, { weight: "semibold", children: "Asked" }), " is what other Indian shops list the same thing for, measured across fifteen catalogues.", canSeeMarket
420
+ ? " Shown beside our own sales — never averaged with them, because an ask is not a sale."
421
+ : " Sign in to see it beside our own sales."] })] })), hasQuery && data && data.results.length === 0 && !loading && (_jsxs(Text, { color: "muted", children: ["Nothing matched \u201C", data.query, "\u201D. Try a shorter term, or the model name on its own."] })), data && data.results.length > 0 && (_jsxs(Stack, { gap: "md", className: "min-w-0", children: [data.results.map((row) => (_jsx(ResultCard, { row: row, threshold: threshold, market: marketById.get(row.categoryId), canSeeMarket: canSeeMarket }, row.categoryId))), data.truncated && (_jsxs(Text, { size: "sm", color: "muted", children: ["Showing the closest ", data.results.length, " matches. Narrow the search to see others."] }))] }))] }) }) }));
219
422
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The index types the price tool searches.
3
+ *
4
+ * 🛑 Exactly the types that can RESOLVE TO A PRICEABLE NODE, which is a
5
+ * narrower set than "everything in the index" and deliberately so:
6
+ *
7
+ * `model` -> its model leaf (href is `/categories/{categoryId}`)
8
+ * `category` -> itself, incl. the era and part-group leaves the crawl
9
+ * actually measured
10
+ * `part` -> its part-group leaf via `PART_SLOT_CATEGORY` — this is the
11
+ * one that makes spares work, and those leaves
12
+ * (`category-metal-energy-rings`, `category-burst-layers`, …)
13
+ * are precisely where `MARKET_BANDS` has component pricing
14
+ * `brand` -> itself; brands are `categories` rows with
15
+ * `categoryType: "brand"`
16
+ *
17
+ * 🛑 What is NOT here, and why — each of these was asked for and cannot be
18
+ * delivered from the client index as it stands:
19
+ *
20
+ * `group` `groupedListings` is not a category, so no band can
21
+ * attach to it at all.
22
+ * the 10 listing A product record's href is `/products/{slug}` and the
23
+ * types record carries no `categoryId`, so resolving one needs a
24
+ * server read per hit. A price band is a statement about a
25
+ * leaf, not about one seller's listing.
26
+ * `bundle`, NOT IN THE INDEX. No `IndexSource` emits them and the
27
+ * `franchise` `category` source rejects any `categoryType` other than
28
+ * `sublisting`, so adding them is an index-build change,
29
+ * not a filter here.
30
+ *
31
+ * 🛑 `brand` is included as asked, and the honest caveat is that a brand row is
32
+ * a tier-0 node OUTSIDE the forest with no `parentIds`, so it inherits no band
33
+ * and will usually resolve to "no market evidence". That renders as an honest
34
+ * empty rather than a wrong number, but it is a thin result and the reason is
35
+ * the data, not the lookup.
36
+ */
37
+ export declare const PRICE_LOOKUP_INDEX_TYPES: readonly string[];
38
+ /**
39
+ * The category id whose band answers a search hit, or `null` when the hit
40
+ * cannot be priced.
41
+ *
42
+ * 🛑 `null` is a real answer and must not be collapsed into a guess. The
43
+ * alternative — falling back to some ancestor, or to the hit's own slug as if
44
+ * it were a category id — would attach a band to a node that does not have one
45
+ * and render a confident number for the wrong thing. A dropped option is
46
+ * visible; a misattributed price is not.
47
+ */
48
+ export declare function priceLookupTargetFor(href: string): string | null;
@@ -0,0 +1,107 @@
1
+ /*
2
+ * WHY: The realised-price tool looks prices up per CATEGORY leaf, but the thing
3
+ * a person searches for is a model, a part, a brand or an era — four
4
+ * different index types with four different href shapes. Turning a search
5
+ * hit into "the leaf whose band answers this" was previously a single
6
+ * regex over `/categories/...` inside the view, which silently dropped
7
+ * every part hit; the tool asked the index for `["model", "category"]`
8
+ * only, so spares were unreachable even though `part` is indexed.
9
+ * WHAT: One pure function from a search hit to the category id to look up, plus
10
+ * the type list the tool should ask the matcher for.
11
+ *
12
+ * EXPORTS:
13
+ * PRICE_LOOKUP_INDEX_TYPES — which index types the tool searches
14
+ * priceLookupTargetFor — a hit's href -> the category id, or null
15
+ *
16
+ * 🛑 Pure. No React, no Firestore, no fetch — it runs inside a `loadOptions`
17
+ * callback on every keystroke.
18
+ *
19
+ * @tag domain:price-index
20
+ * @tag layer:feature
21
+ * @tag pattern:pure-function
22
+ * @tag access:isomorphic
23
+ * @tag consumers:PriceIndexToolView
24
+ * @tag sideEffects:none
25
+ */
26
+ import { PART_SLOT_CATEGORY } from "../parts/schemas/firestore.js";
27
+ /**
28
+ * The index types the price tool searches.
29
+ *
30
+ * 🛑 Exactly the types that can RESOLVE TO A PRICEABLE NODE, which is a
31
+ * narrower set than "everything in the index" and deliberately so:
32
+ *
33
+ * `model` -> its model leaf (href is `/categories/{categoryId}`)
34
+ * `category` -> itself, incl. the era and part-group leaves the crawl
35
+ * actually measured
36
+ * `part` -> its part-group leaf via `PART_SLOT_CATEGORY` — this is the
37
+ * one that makes spares work, and those leaves
38
+ * (`category-metal-energy-rings`, `category-burst-layers`, …)
39
+ * are precisely where `MARKET_BANDS` has component pricing
40
+ * `brand` -> itself; brands are `categories` rows with
41
+ * `categoryType: "brand"`
42
+ *
43
+ * 🛑 What is NOT here, and why — each of these was asked for and cannot be
44
+ * delivered from the client index as it stands:
45
+ *
46
+ * `group` `groupedListings` is not a category, so no band can
47
+ * attach to it at all.
48
+ * the 10 listing A product record's href is `/products/{slug}` and the
49
+ * types record carries no `categoryId`, so resolving one needs a
50
+ * server read per hit. A price band is a statement about a
51
+ * leaf, not about one seller's listing.
52
+ * `bundle`, NOT IN THE INDEX. No `IndexSource` emits them and the
53
+ * `franchise` `category` source rejects any `categoryType` other than
54
+ * `sublisting`, so adding them is an index-build change,
55
+ * not a filter here.
56
+ *
57
+ * 🛑 `brand` is included as asked, and the honest caveat is that a brand row is
58
+ * a tier-0 node OUTSIDE the forest with no `parentIds`, so it inherits no band
59
+ * and will usually resolve to "no market evidence". That renders as an honest
60
+ * empty rather than a wrong number, but it is a thin result and the reason is
61
+ * the data, not the lookup.
62
+ */
63
+ export const PRICE_LOOKUP_INDEX_TYPES = [
64
+ "model",
65
+ "category",
66
+ "part",
67
+ "brand",
68
+ ];
69
+ /** `/categories/{id}` and `/brands/{id}` — category documents are pure slugs. */
70
+ const DIRECT_CATEGORY_HREF = /^\/(?:categories|brands)\/([^/?#]+)/;
71
+ /**
72
+ * `/parts/{classification}/{code}`.
73
+ *
74
+ * 🛑 TWO segments, and the first is the slot. `ROUTES.PUBLIC.PART_DETAIL`'s own
75
+ * comment explains why a code alone cannot be a URL: "`105` is a Metal Fight
76
+ * spin-track height, a bare number in Burst is a disc, and `1-60` is a Beyblade
77
+ * X ratchet." The slot is what places the part in the taxonomy.
78
+ */
79
+ const PART_HREF = /^\/parts\/([^/?#]+)\/([^/?#]+)/;
80
+ /**
81
+ * The category id whose band answers a search hit, or `null` when the hit
82
+ * cannot be priced.
83
+ *
84
+ * 🛑 `null` is a real answer and must not be collapsed into a guess. The
85
+ * alternative — falling back to some ancestor, or to the hit's own slug as if
86
+ * it were a category id — would attach a band to a node that does not have one
87
+ * and render a confident number for the wrong thing. A dropped option is
88
+ * visible; a misattributed price is not.
89
+ */
90
+ export function priceLookupTargetFor(href) {
91
+ const direct = DIRECT_CATEGORY_HREF.exec(href);
92
+ if (direct)
93
+ return direct[1] ?? null;
94
+ const part = PART_HREF.exec(href);
95
+ if (part) {
96
+ const slot = part[1];
97
+ /*
98
+ * `PART_SLOT_CATEGORY` is a `Record<PartSlot, string>`, so a 21st slot is a
99
+ * compile error there rather than a silent miss here — but the href came
100
+ * off an artifact built from live documents, so a slot this build has never
101
+ * heard of is still possible and resolves to `null` rather than
102
+ * `undefined` leaking into a URL.
103
+ */
104
+ return PART_SLOT_CATEGORY[slot] ?? null;
105
+ }
106
+ return null;
107
+ }
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import type { ListingType } from "../types/index";
3
+ export interface AvailabilityScopeHintProps {
4
+ /**
5
+ * Every published row in this tab's scope, all availability scopes.
6
+ * `undefined` means the count is unknown — the hint renders nothing rather
7
+ * than guessing (Root Cause #72: unknown must never read as a number).
8
+ */
9
+ total?: number;
10
+ /** Rows the AVAILABLE scope renders. Same `undefined` rule. */
11
+ available?: number;
12
+ /**
13
+ * The listing types this tab spans. The hidden-rows noun ("sold", "ended",
14
+ * "sold & ended") is DERIVED from their plugins via `availabilityTabsFor`,
15
+ * which is the same function that labels the scope tabs — so the hint and
16
+ * the control it points at can never disagree about the word. A hand-written
17
+ * string here would be a tenth place the `ListingType` union is enumerated
18
+ * by hand (Root Cause #61).
19
+ */
20
+ types: readonly ListingType[];
21
+ }
22
+ export declare function AvailabilityScopeHint({ total, available, types, }: AvailabilityScopeHintProps): React.JSX.Element | null;
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, Row, Text } from "../../../ui/index.js";
4
+ import { useUrlTable } from "../../../react/hooks/useUrlTable.js";
5
+ import { AVAILABILITY_VALUES } from "../../../constants/field-names.js";
6
+ import { TABLE_KEYS } from "../../../constants/table-keys.js";
7
+ import { availabilityTabsFor } from "../../../_internal/shared/listing-types/_registry.js";
8
+ export function AvailabilityScopeHint({ total, available, types, }) {
9
+ const table = useUrlTable();
10
+ const scope = table.get(TABLE_KEYS.AVAILABILITY) ||
11
+ AVAILABILITY_VALUES.AVAILABLE;
12
+ // Only the default scope withholds anything. In Ended or All the visitor is
13
+ // already looking at the rows this hint would point them to.
14
+ if (scope !== AVAILABILITY_VALUES.AVAILABLE)
15
+ return null;
16
+ if (total === undefined || available === undefined)
17
+ return null;
18
+ const hidden = total - available;
19
+ if (hidden <= 0)
20
+ return null;
21
+ // availabilityTabsFor()[1] is the unavailable scope — "Sold", "Ended" or
22
+ // "Sold & Ended", already derived per type.
23
+ const noun = (availabilityTabsFor(types)[1]?.label ?? "Sold").toLowerCase();
24
+ return (_jsxs(Row, { align: "center", gap: "sm", padding: "sm", surface: "muted", rounded: "lg", border: "subtle", className: "mb-4 flex-wrap", children: [_jsx(Text, { size: "sm", color: "muted", children: hidden === 1
25
+ ? `1 listing here is ${noun} and hidden by the Available filter.`
26
+ : `${hidden} listings here are ${noun} and hidden by the Available filter.` }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => table.set(TABLE_KEYS.AVAILABILITY, AVAILABILITY_VALUES.ALL), children: "Show all" })] }));
27
+ }
@@ -15,6 +15,7 @@
15
15
  * `GET /api/products?ids=<csv>` (BK3 listByIds endpoint).
16
16
  */
17
17
  import type { ListingType } from "../types";
18
+ import type { ProductImage } from "../schemas/firestore";
18
19
  /** Subset of ProductDocument the overlay needs. Kept loose so it can be fed
19
20
  * sanitized public payloads directly. */
20
21
  export interface CompareProductLike {
@@ -24,7 +25,7 @@ export interface CompareProductLike {
24
25
  price?: number;
25
26
  currency?: string;
26
27
  mainImage?: string;
27
- images?: string[];
28
+ images?: readonly (string | ProductImage)[];
28
29
  condition?: string;
29
30
  brand?: string;
30
31
  category?: string;
@@ -21,6 +21,7 @@ import { normalizeListingType } from "../utils/listing-type.js";
21
21
  import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js";
22
22
  import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints.js";
23
23
  import { EntityTypeahead } from "../../search/components/EntityTypeahead.js";
24
+ import { productImageUrls } from "../schemas/firestore.js";
24
25
  const DEFAULT_LABELS = {
25
26
  title: "Compare items",
26
27
  empty: "Select up to 4 items from the listing to compare.",
@@ -74,7 +75,7 @@ function customFieldValue(field) {
74
75
  }
75
76
  function CompareColumn({ item, productType, labels, onRemove, onClose, customFieldKeys }) {
76
77
  const href = detailHref(item, productType);
77
- const img = item.mainImage ?? item.images?.[0];
78
+ const img = item.mainImage || productImageUrls(item.images)[0];
78
79
  return (_jsxs(Stack, { gap: "md", className: COLUMN_CARD_CLASS, children: [_jsxs(Row, { justify: "between", align: "start", gap: "sm", children: [_jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] hover:underline line-clamp-2", children: item.title ?? item.id }), onRemove && (_jsx(IconButton, { type: "button", onClick: () => onRemove(item.id), "aria-label": `${labels.remove} ${item.title ?? item.id}`, variant: "ghost", rounded: "full", className: CLS_REMOVE_BTN, children: _jsx(X, { className: "h-4 w-4" }) }))] }), _jsx(FieldRow, { label: labels.field.image, children: _jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, children: _jsx(Div, { className: `relative aspect-square ${__O.hidden}`, rounded: "lg", surface: "subtle", children: img ? (_jsx(MediaImage, { src: img, alt: item.title ?? item.id, size: "card" })) : null }) }) }), _jsx(FieldRow, { label: labels.field.price, children: _jsx(Text, { size: "base", weight: "semibold", color: "primary", children: _jsx(GatedPrice, { children: priceLabel(item) }) }) }), _jsx(FieldRow, { label: labels.field.condition, children: _jsx(Text, { children: item.condition ? _jsx(Span, { className: CHIP_CLASS, children: item.condition }) : "—" }) }), _jsx(FieldRow, { label: labels.field.brand, children: _jsx(Text, { children: item.brand ? _jsx(Span, { className: CHIP_CLASS, children: item.brand }) : "—" }) }), _jsx(FieldRow, { label: labels.field.category, children: _jsx(Text, { children: (Array.isArray(item.categoryNames) ? item.categoryNames[0] : item.categoryName) || (Array.isArray(item.categorySlugs) ? item.categorySlugs[0] : item.category) ? (_jsx(Span, { className: CHIP_CLASS, children: (Array.isArray(item.categoryNames) ? item.categoryNames[0] : item.categoryName) ?? (Array.isArray(item.categorySlugs) ? item.categorySlugs[0] : item.category) })) : ("—") }) }), _jsx(FieldRow, { label: labels.field.store, children: _jsx(Text, { size: "sm", color: "muted", children: item.storeName ?? "—" }) }), customFieldKeys.map((key) => (_jsx(FieldRow, { label: key, children: _jsx(Text, { size: "sm", children: customFieldValue(item.customFields?.find((f) => f.key === key)) }) }, key))), _jsx(Button, { asChild: true, variant: "primary", size: "sm", className: "mt-auto", children: _jsx(Link, { href: href, target: "_blank", rel: REL_NOOPENER, onClick: onClose, children: labels.view }) })] }));
79
80
  }
80
81
  function useFetchedProducts(productIds, passedItems, isOpen) {