@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
@@ -27,6 +27,7 @@ import { safeRead } from "../../../errors/safe-read.js";
27
27
  import { breadcrumbJsonLd, collectionPageJsonLd, faqJsonLd, } from "../../../seo/json-ld.js";
28
28
  import { toStoreListItem } from "../../../_internal/server/features/stores/adapters.js";
29
29
  import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility.js";
30
+ import { productImageUrls } from "../../products/schemas/firestore.js";
30
31
  const __O = {
31
32
  hidden: "overflow-hidden",
32
33
  };
@@ -170,7 +171,7 @@ export async function CategoryDetailPageView({ slug }) {
170
171
  // Four of the ten tabs used to have no count at all and could never hide.
171
172
  categoriesIn
172
173
  ? listingTabCounts(CATEGORY_PAGE_TABS, { categoriesIn })
173
- : Promise.resolve({}),
174
+ : Promise.resolve({ available: {}, total: {} }),
174
175
  // SB-UNI-D — bundles fetched from the categories collection. We pull
175
176
  // all active bundle rows; the carousel filters by category affinity.
176
177
  categoriesIn
@@ -419,13 +420,22 @@ export async function CategoryDetailPageView({ slug }) {
419
420
  // `tabCounts` is keyed by tabSlug and already covers every listing type plus
420
421
  // bundles; `stores` is added here because this page resolves its store list
421
422
  // eagerly to render the tab body anyway.
422
- const counts = { ...tabCounts, stores: storeCount };
423
+ // Two maps, not one: the BADGE is what the default (available) scope will
424
+ // render, while HIDING must stay keyed on the total — a tab whose rows have
425
+ // all ended still has a browsable archive behind the Ended/All scopes.
426
+ // `stores` is added to both because this page resolves its store list
427
+ // eagerly to render the tab body anyway, and a store has no availability
428
+ // scope of its own.
429
+ const counts = { ...tabCounts.available, stores: storeCount };
430
+ const totals = { ...tabCounts.total, stores: storeCount };
423
431
  // Header pills read the same numbers the tabs do. `?? 0` is safe here (a pill
424
432
  // simply doesn't render) — unlike the tab bar, where an unknown count must
425
433
  // stay visible rather than collapse to "hide me".
426
- const productCount = counts.products ?? category?.metrics?.totalProductCount ?? category?.metrics?.productCount ?? 0;
427
- const auctionCount = counts.auctions ?? category?.metrics?.totalAuctionCount ?? category?.metrics?.auctionCount ?? 0;
428
- const preOrderCount = counts["pre-orders"] ?? 0;
434
+ // Header pills read TOTALS, deliberately: "47 products" is a statement about
435
+ // what this category holds, not about what one scope is currently showing.
436
+ const productCount = totals.products ?? category?.metrics?.totalProductCount ?? category?.metrics?.productCount ?? 0;
437
+ const auctionCount = totals.auctions ?? category?.metrics?.totalAuctionCount ?? category?.metrics?.auctionCount ?? 0;
438
+ const preOrderCount = totals["pre-orders"] ?? 0;
429
439
  /*
430
440
  * G3b — OWN vs CHILDREN, so one number stops answering two questions.
431
441
  *
@@ -465,7 +475,7 @@ export async function CategoryDetailPageView({ slug }) {
465
475
  const mosaicImages = hasCover
466
476
  ? []
467
477
  : (productsResult?.items ?? [])
468
- .map((row) => row.mainImage ?? row.images?.[0] ?? null)
478
+ .map((row) => row.mainImage || productImageUrls(row.images)[0] || null)
469
479
  .filter((src) => !!src)
470
480
  .slice(0, 5);
471
481
  const hasMosaic = mosaicImages.length >= MOSAIC_MIN;
@@ -587,5 +597,5 @@ export async function CategoryDetailPageView({ slug }) {
587
597
  imageUrl: coverOf(b),
588
598
  countLabel: countOf(b),
589
599
  } }) }) }));
590
- })(), directChildrenWithListings.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: __W, padding: "inline", children: _jsxs(Div, { padding: "b-2xs", children: [parentLadder.length > 0 && (_jsx(Div, { padding: "b-2xs", children: _jsx(SubcategoryCardRow, { items: parentLadder, label: "Parent category" }) })), _jsx(SubcategoryCardRow, { items: toSubcategoryItems(directChildrenWithListings, childCategories) })] }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
600
+ })(), directChildrenWithListings.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: __W, padding: "inline", children: _jsxs(Div, { padding: "b-2xs", children: [parentLadder.length > 0 && (_jsx(Div, { padding: "b-2xs", children: _jsx(SubcategoryCardRow, { items: parentLadder, label: "Parent category" }) })), _jsx(SubcategoryCardRow, { items: toSubcategoryItems(directChildrenWithListings, childCategories) })] }) }) })), _jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(CategoryDetailTabs, { categorySlug: slug, categoryId: category?.id, initialProductsData: productsResult ?? undefined, initialBundles: bundlesResult ?? [], initialStores: categoryStores, counts: counts, totals: totals, enabledListingTypes: enabledListingTypes(settings), enabledCategoryTypes: enabledCategoryTypes(settings) }) }) }), groupedListings.length > 0 && (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "xl", children: _jsx(GroupedListingsCarousel, { groups: groupedListings }) }) })), relatedCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-t", padding: "y-lg", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 2, className: "mb-4", size: "xl", weight: "semibold", children: "Related Categories" }), _jsx(CategoryGrid, { categories: relatedCategories, getHref: (c) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(c.slug)) })] }) }))] }));
591
601
  }
@@ -17,9 +17,15 @@ export interface CategoryDetailTabsProps {
17
17
  * were structurally uncountable and could never hide (Root Cause #61).
18
18
  */
19
19
  counts?: Record<string, number | undefined>;
20
+ /**
21
+ * Every published row per tab, all availability scopes. Drives hide-at-zero
22
+ * and the "N ended — show all" hint; see `ListingTabCountResult`. Omitted,
23
+ * it falls back to `counts`, i.e. the pre-split behaviour.
24
+ */
25
+ totals?: Record<string, number | undefined>;
20
26
  /** Enabled listing types (e.g. ["standard","auction","pre-order"]). When omitted, all tabs shown. */
21
27
  enabledListingTypes?: string[];
22
28
  /** Enabled category types (e.g. ["category","brand","bundle"]). When omitted, all tabs shown. */
23
29
  enabledCategoryTypes?: string[];
24
30
  }
25
- export declare function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles, initialStores, counts, enabledListingTypes, enabledCategoryTypes, }: CategoryDetailTabsProps): React.JSX.Element;
31
+ export declare function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles, initialStores, counts, totals, enabledListingTypes, enabledCategoryTypes, }: CategoryDetailTabsProps): React.JSX.Element;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { EmptyState, Tabs, TabsList, TabsTrigger } from "../../../ui/index.js";
5
+ import { AvailabilityScopeHint } from "../../products/components/AvailabilityScopeHint.js";
5
6
  import { CategoryProductsListing } from "./CategoryProductsListing.js";
6
7
  import { AuctionsIndexListing } from "../../products/components/AuctionsIndexListing.js";
7
8
  import { PreOrdersIndexListing } from "../../pre-orders/components/PreOrdersIndexListing.js";
@@ -29,6 +30,21 @@ const TAB_TYPE_MAP = {
29
30
  bundles: { kind: "category", type: "bundle" },
30
31
  stores: { kind: "entity", type: "stores" },
31
32
  };
33
+ /**
34
+ * The listing types a tab spans, for the availability-scope hint.
35
+ *
36
+ * Derived from `TAB_TYPE_MAP` rather than hand-written: that map is already
37
+ * built from `ALL_LISTING_TYPES`, so a tenth type is covered without a second
38
+ * enumeration (Root Cause #61). The combined Art & Stickers tab is the one
39
+ * entry that spans two, and `GENERIC_TAB_LISTING_TYPES` already says so.
40
+ */
41
+ function typesForTab(id) {
42
+ const generic = GENERIC_TAB_LISTING_TYPES[id];
43
+ if (generic)
44
+ return generic;
45
+ const mapping = TAB_TYPE_MAP[id];
46
+ return mapping?.kind === "listing" ? [mapping.type] : [];
47
+ }
32
48
  /** Listing types rendered by the shared products listing rather than a dedicated one. */
33
49
  const GENERIC_TAB_LISTING_TYPES = {
34
50
  [pluginFor("standard").tabSlug]: ["standard"],
@@ -50,8 +66,12 @@ const GENERIC_TAB_LISTING_TYPES = {
50
66
  */
51
67
  [pluginFor("spares").tabSlug]: ["spares"],
52
68
  };
53
- export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles = [], initialStores = [], counts, enabledListingTypes, enabledCategoryTypes, }) {
69
+ export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsData, initialBundles = [], initialStores = [], counts, totals, enabledListingTypes, enabledCategoryTypes, }) {
54
70
  const countFor = (id) => counts?.[id];
71
+ // Falls back to the badge when no totals map is supplied, so a caller that
72
+ // has not been migrated keeps today's hide behaviour rather than hiding
73
+ // everything.
74
+ const totalFor = (id) => totals?.[id] ?? counts?.[id];
55
75
  const visibleTabs = CATEGORY_PAGE_TABS.filter((t) => {
56
76
  const mapping = TAB_TYPE_MAP[t.id];
57
77
  if (mapping) {
@@ -64,11 +84,13 @@ export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsDa
64
84
  return false;
65
85
  }
66
86
  }
67
- // Hide a tab only when its count is known and explicitly zero. `undefined`
68
- // now means the count query FAILED (every tab is counted), so keeping it
69
- // visible is the fail-open choice: an empty tab is a far smaller error than
70
- // hiding a tab that holds real listings.
71
- const count = countFor(t.id);
87
+ // 🛑 TOTAL, not the badge. The badge counts what the default (available)
88
+ // scope renders, and a tab whose rows have all ended badges 0 while still
89
+ // holding a browsable archive behind the Ended/All scopes — hiding it
90
+ // would strand that archive. `undefined` still means the count query
91
+ // FAILED, so the tab stays visible: an empty tab is a far smaller error
92
+ // than hiding a tab that holds real listings (Root Cause #59).
93
+ const count = totalFor(t.id);
72
94
  return count === undefined || count > 0;
73
95
  });
74
96
  const firstTabId = (visibleTabs[0]?.id ?? "products");
@@ -78,5 +100,5 @@ export function CategoryDetailTabs({ categorySlug, categoryId, initialProductsDa
78
100
  if (visibleTabs.length === 0) {
79
101
  return (_jsx(EmptyState, { title: "Nothing listed here yet", description: "This category has no active listings right now. Check back soon." }));
80
102
  }
81
- return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), GENERIC_TAB_LISTING_TYPES[activeTab] && (_jsx(CategoryProductsListing, { categorySlug: categorySlug, categoryId: categoryId, listingTypes: GENERIC_TAB_LISTING_TYPES[activeTab], initialData: activeTab === pluginFor("standard").tabSlug ? initialProductsData : undefined })), activeTab === pluginFor("auction").tabSlug && (_jsx(AuctionsIndexListing, { categorySlug: categorySlug })), activeTab === pluginFor("pre-order").tabSlug && (_jsx(PreOrdersIndexListing, { categorySlug: categorySlug })), activeTab === pluginFor("prize-draw").tabSlug && (_jsx(PrizeDrawsIndexListing, { categorySlug: categorySlug })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles })), activeTab === "stores" && (_jsx(CategoryStoresListing, { stores: initialStores }))] }));
103
+ return (_jsxs(_Fragment, { children: [_jsx(Tabs, { value: activeTab, onChange: (v) => setActiveTab(v), className: "mb-6", children: _jsx(TabsList, { children: visibleTabs.map((t) => (_jsx(TabsTrigger, { value: t.id, badge: countFor(t.id), children: t.label }, t.id))) }) }), _jsx(AvailabilityScopeHint, { total: totalFor(activeTab), available: countFor(activeTab), types: typesForTab(activeTab) }), GENERIC_TAB_LISTING_TYPES[activeTab] && (_jsx(CategoryProductsListing, { categorySlug: categorySlug, categoryId: categoryId, listingTypes: GENERIC_TAB_LISTING_TYPES[activeTab], initialData: activeTab === pluginFor("standard").tabSlug ? initialProductsData : undefined })), activeTab === pluginFor("auction").tabSlug && (_jsx(AuctionsIndexListing, { categorySlug: categorySlug })), activeTab === pluginFor("pre-order").tabSlug && (_jsx(PreOrdersIndexListing, { categorySlug: categorySlug })), activeTab === pluginFor("prize-draw").tabSlug && (_jsx(PrizeDrawsIndexListing, { categorySlug: categorySlug })), activeTab === "bundles" && (_jsx(CategoryBundlesListing, { initialBundles: initialBundles })), activeTab === "stores" && (_jsx(CategoryStoresListing, { stores: initialStores }))] }));
82
104
  }
@@ -23,6 +23,7 @@ import { getSiteSettingsGlobal } from "../../admin/utils/getSiteSettingsGlobal.j
23
23
  import { safeRead } from "../../../errors/safe-read.js";
24
24
  import { DEFAULT_MIN_OFFER_PERCENT, offersEnabledFor, resolveOfferBounds, } from "../../../_internal/shared/features/offers/config.js";
25
25
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
26
+ import { productImageUrls } from "../../products/schemas/firestore.js";
26
27
  function toDescriptionHtml(raw) {
27
28
  if (!raw)
28
29
  return "";
@@ -43,7 +44,15 @@ export async function ClassifiedDetailPageView({ slug, initialProduct, renderOff
43
44
  const currency = p.currency || getDefaultCurrency();
44
45
  const title = String(p.title ?? p.name ?? "Classified Listing");
45
46
  const price = typeof p.price === "number" ? p.price : null;
46
- const images = Array.isArray(p.images) ? p.images : typeof p.mainImage === "string" ? [p.mainImage] : [];
47
+ // `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
48
+ // normaliser (it also accepts the legacy `string[]`). Casting it to
49
+ // `string[]` handed the gallery objects and rendered a placeholder.
50
+ const galleryUrls = productImageUrls(p.images);
51
+ const images = galleryUrls.length > 0
52
+ ? galleryUrls
53
+ : typeof p.mainImage === "string"
54
+ ? [p.mainImage]
55
+ : [];
47
56
  const meta = product.classified;
48
57
  const location = meta?.meetupArea ? [meta.meetupArea.locality, meta.meetupArea.city].filter(Boolean).join(", ") : null;
49
58
  const category = Array.isArray(p.categorySlugs) && p.categorySlugs.length > 0 ? String(p.categorySlugs[0]) : (typeof p.category === "string" ? p.category : null);
@@ -21,6 +21,7 @@ import { GroupedListingsCarousel } from "../../grouped/components/GroupedListing
21
21
  import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data.js";
22
22
  import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
23
23
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
24
+ import { productImageUrls } from "../../products/schemas/firestore.js";
24
25
  function toDescriptionHtml(raw) {
25
26
  if (!raw)
26
27
  return "";
@@ -41,7 +42,15 @@ export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPr
41
42
  const currency = p.currency || getDefaultCurrency();
42
43
  const title = String(p.title ?? p.name ?? "Digital Code");
43
44
  const price = typeof p.price === "number" ? p.price : null;
44
- const images = Array.isArray(p.images) ? p.images : typeof p.mainImage === "string" ? [p.mainImage] : [];
45
+ // `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
46
+ // normaliser (it also accepts the legacy `string[]`). Casting it to
47
+ // `string[]` handed the gallery objects and rendered a placeholder.
48
+ const galleryUrls = productImageUrls(p.images);
49
+ const images = galleryUrls.length > 0
50
+ ? galleryUrls
51
+ : typeof p.mainImage === "string"
52
+ ? [p.mainImage]
53
+ : [];
45
54
  const meta = product.digitalCode;
46
55
  const codesLeft = meta?.codesAvailable ?? 0;
47
56
  const category = Array.isArray(p.categorySlugs) && p.categorySlugs.length > 0 ? String(p.categorySlugs[0]) : (typeof p.category === "string" ? p.category : null);
@@ -24,6 +24,7 @@ import { GroupedListingsCarousel } from "../../grouped/components/GroupedListing
24
24
  import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data.js";
25
25
  import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
26
26
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
27
+ import { productImageUrls } from "../../products/schemas/firestore.js";
27
28
  function toDescriptionHtml(raw) {
28
29
  if (!raw)
29
30
  return "";
@@ -44,7 +45,15 @@ export async function LiveItemDetailPageView({ slug, initialProduct, renderActio
44
45
  const currency = p.currency || getDefaultCurrency();
45
46
  const title = String(p.title ?? p.name ?? "Live Listing");
46
47
  const price = typeof p.price === "number" ? p.price : null;
47
- const images = Array.isArray(p.images) ? p.images : typeof p.mainImage === "string" ? [p.mainImage] : [];
48
+ // `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
49
+ // normaliser (it also accepts the legacy `string[]`). Casting it to
50
+ // `string[]` handed the gallery objects and rendered a placeholder.
51
+ const galleryUrls = productImageUrls(p.images);
52
+ const images = galleryUrls.length > 0
53
+ ? galleryUrls
54
+ : typeof p.mainImage === "string"
55
+ ? [p.mainImage]
56
+ : [];
48
57
  const productVideo = p.video;
49
58
  const meta = product.liveItem;
50
59
  const jurisdictions = meta?.jurisdictionAllowed ?? [];
@@ -27,6 +27,7 @@ import { ProductGalleryClient } from "../../products/components/ProductGalleryCl
27
27
  import { ProductFeatureBadges } from "../../products/components/ProductFeatureBadges.js";
28
28
  import { isFinalSale } from "../../products/constants/final-sale.js";
29
29
  import { FeatureBadgeList } from "../../products/components/FeatureBadge.js";
30
+ import { productImageUrls } from "../../products/schemas/firestore.js";
30
31
  import { ShareButton } from "../../products/components/ShareButton.js";
31
32
  import { SublistingCarouselSection } from "../../products/components/SublistingCarouselSection.js";
32
33
  import { HistoryTracker } from "../../history/components/HistoryTracker.js";
@@ -86,8 +87,12 @@ export async function PreOrderDetailPageView({ id, initialPreOrder, onReserveNow
86
87
  const currency = p.currency || getDefaultCurrency();
87
88
  const title = String(p.title ?? p.name ?? "Pre-Order Item");
88
89
  const price = typeof p.price === "number" ? p.price : null;
89
- const images = Array.isArray(p.images)
90
- ? p.images
90
+ // `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
91
+ // normaliser (it also accepts the legacy `string[]`). Casting it to
92
+ // `string[]` handed the gallery objects and rendered a placeholder.
93
+ const galleryUrls = productImageUrls(p.images);
94
+ const images = galleryUrls.length > 0
95
+ ? galleryUrls
91
96
  : typeof p.mainImage === "string"
92
97
  ? [p.mainImage]
93
98
  : [];
@@ -153,11 +158,7 @@ export async function PreOrderDetailPageView({ id, initialPreOrder, onReserveNow
153
158
  ]);
154
159
  return (_jsxs(Main, { children: [_jsx(HistoryTracker, { productId: String(p.id ?? p.slug ?? ""), productType: "preorder", snapshot: {
155
160
  title: typeof p.title === "string" ? p.title : undefined,
156
- thumb: typeof p.mainImage === "string"
157
- ? p.mainImage
158
- : Array.isArray(p.images) && typeof p.images[0] === "string"
159
- ? p.images[0]
160
- : undefined,
161
+ thumb: typeof p.mainImage === "string" ? p.mainImage : images[0],
161
162
  price: typeof p.price === "number" ? p.price : undefined,
162
163
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
163
164
  storeName: typeof p.storeName === "string" ? p.storeName : undefined,
@@ -79,6 +79,27 @@ export interface PriceIndexToolResponse {
79
79
  truncated: boolean;
80
80
  minConfidentSamples: number;
81
81
  }
82
+ /**
83
+ * The competitor-crawl band for one leaf. Mirrors `PriceIndexMarketRow`.
84
+ *
85
+ * Every field but `median` and `categoryId` is optional because the crawl
86
+ * genuinely did not measure them everywhere, and an absent figure must render
87
+ * as absent. See `price-bands.md`: "a band with `n=2` is not evidence".
88
+ */
89
+ export interface PriceIndexMarketRowView {
90
+ categoryId: string;
91
+ median: number;
92
+ p25?: number;
93
+ p75?: number;
94
+ sampleSize?: number;
95
+ asOf?: string;
96
+ soldOutShare?: number;
97
+ source?: string;
98
+ inherited: boolean;
99
+ viaCategoryId?: string;
100
+ viaName?: string;
101
+ taxBasis: string;
102
+ }
82
103
  export interface PriceIndexToolViewProps {
83
104
  /** Seeded from `?q=` so a shared link reproduces the lookup. */
84
105
  initialQuery?: string;