@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
@@ -15,6 +15,7 @@ import { PRODUCT_FIELDS } from "../../../constants/field-names.js";
15
15
  import { annotate } from "../../shell/field-ui-meta.js";
16
16
  import { SectionForm, useSectionFormNav, buildSectionsFromSchema, visibleValues, } from "../../shell/index.js";
17
17
  import { applyZodIssues } from "../../../ui/forms/apply-zod-issues.js";
18
+ import { productImageUrls } from "../schemas/firestore.js";
18
19
  const groupTitleSchema = z.object({
19
20
  groupTitle: annotate(z.string().min(1, "Group title is required"), {
20
21
  section: "group", sectionLabel: "Group", sectionRequired: true, quick: true,
@@ -351,7 +352,7 @@ export function GroupSettingsPanel({ productId, productSlug, groupId, isGroupPar
351
352
  setOpen(next);
352
353
  if (next && isGroupParent && children === null)
353
354
  loadChildren();
354
- }, paddingX: "none", paddingY: "xs", justify: "between", className: "w-full text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, size: "sm", weight: "semibold", color: "muted", children: "Group Settings" }), _jsx(Span, { size: "xs", className: "group-hover:text-[var(--appkit-color-text-muted)] dark:group-hover:text-[var(--appkit-color-text-faint)] transition-colors", color: "faint", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: `mt-3 ${__P.p4}`, surface: "muted", rounded: "lg", border: "default", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(FormShellContext.Provider, { value: titleShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: titleSections, values: titleDraft, onChange: (partial) => setTitleDraft((d) => ({ ...d, ...partial })), onSubmit: () => void saveTitle(), onValidationChange: () => titleForm.validate(titleDraft), schema: groupTitleSchema, openIds: titleNav.openIds, onOpenChange: titleNav.setOpenIds, submitLabel: "Save title", isLoading: loading })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: "Selection rules" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(FieldInput, { name: "groupMinMembers", label: "Min items in a selection", type: "number", value: ruleDraft.min, onChange: (v) => setRuleDraft((d) => ({ ...d, min: v })), hint: "Blank = no minimum" }), _jsx(FieldInput, { name: "groupMaxMembers", label: "Max items in a selection", type: "number", value: ruleDraft.max, onChange: (v) => setRuleDraft((d) => ({ ...d, max: v })), hint: "Blank = no maximum" }), _jsx(FieldInput, { name: "groupMaxUnitsPerMember", label: "Max units of any one item", type: "number", value: ruleDraft.perMember, onChange: (v) => setRuleDraft((d) => ({ ...d, perMember: v })), hint: "Blank = no per-item cap" }), _jsx(FieldInput, { name: "groupDiscountPercent", label: "Group discount (%)", type: "number", value: ruleDraft.discount, onChange: (v) => setRuleDraft((d) => ({ ...d, discount: v })), hint: "Off the selection subtotal. Applies to new cart lines; a line already in a cart keeps the price it was quoted and is re-checked at checkout." })] }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", className: "mt-2", onClick: () => void saveRules(), isLoading: loading, children: "Save rules" })] }), _jsxs(Div, { children: [_jsxs(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), _jsx(Text, { className: "mb-2", color: "faint", size: "xs", children: "Each listing has its own stock. Editing here saves them together \u2014 it does not pool them." }), loading && !children ? (_jsx(Text, { size: "xs", color: "faint", children: "Loading\u2026" })) : (_jsxs(Div, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", children: [_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [_jsx(Span, { padding: "pill-2xs", weight: "semibold", className: "bg-[var(--appkit-color-primary)]/10 text-[var(--appkit-color-primary)] text-[10px]", rounded: "default", children: "Parent" }), _jsx(Text, { className: "flex-1", color: "primary", size: "sm", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [child.images?.[0] ? (_jsx(Div, { className: "relative w-8 h-8 flex-shrink-0 border border-[var(--appkit-color-border)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: child.images[0], alt: child.title, size: "thumbnail" }) })) : (_jsx(Div, { className: "w-8 h-8", surface: "subtle", rounded: "full" })), _jsx(Text, { className: "flex-1 truncate", color: "primary", size: "sm", children: child.title }), child.status && child.status !== "published" ? (_jsx(Span, { size: "xs", surface: "warning-surface", className: "text-warning", padding: "pill-2xs", rounded: "default", children: child.status })) : null, (motDraft[child.id] ?? child.madeToOrder) === true ? (_jsx(Text, { size: "xs", color: "muted", children: "Made to order" })) : (_jsx(Input, { type: "number", "aria-label": `Stock for ${child.title}`, className: "w-20", value: stockDraft[child.id] ??
355
+ }, paddingX: "none", paddingY: "xs", justify: "between", className: "w-full text-left group", "aria-expanded": open, children: [_jsx(Heading, { level: 3, size: "sm", weight: "semibold", color: "muted", children: "Group Settings" }), _jsx(Span, { size: "xs", className: "group-hover:text-[var(--appkit-color-text-muted)] dark:group-hover:text-[var(--appkit-color-text-faint)] transition-colors", color: "faint", children: open ? "▲" : "▼" })] }), open && (_jsxs(Stack, { gap: "sm", className: `mt-3 ${__P.p4}`, surface: "muted", rounded: "lg", border: "default", children: [!groupId && !groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", color: "muted", children: "Group related listings together \u2014 e.g. a set, bundle, or multi-part item. Parts can be sold individually but shown together." }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: startGroup, isLoading: loading, children: "Start a group" })] })), isGroupParent && groupId && (_jsxs(Stack, { gap: "md", children: [_jsxs(FormShellContext.Provider, { value: titleShellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: titleSections, values: titleDraft, onChange: (partial) => setTitleDraft((d) => ({ ...d, ...partial })), onSubmit: () => void saveTitle(), onValidationChange: () => titleForm.validate(titleDraft), schema: groupTitleSchema, openIds: titleNav.openIds, onOpenChange: titleNav.setOpenIds, submitLabel: "Save title", isLoading: loading })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: "Selection rules" }), _jsxs(Grid, { cols: 2, gap: "sm", children: [_jsx(FieldInput, { name: "groupMinMembers", label: "Min items in a selection", type: "number", value: ruleDraft.min, onChange: (v) => setRuleDraft((d) => ({ ...d, min: v })), hint: "Blank = no minimum" }), _jsx(FieldInput, { name: "groupMaxMembers", label: "Max items in a selection", type: "number", value: ruleDraft.max, onChange: (v) => setRuleDraft((d) => ({ ...d, max: v })), hint: "Blank = no maximum" }), _jsx(FieldInput, { name: "groupMaxUnitsPerMember", label: "Max units of any one item", type: "number", value: ruleDraft.perMember, onChange: (v) => setRuleDraft((d) => ({ ...d, perMember: v })), hint: "Blank = no per-item cap" }), _jsx(FieldInput, { name: "groupDiscountPercent", label: "Group discount (%)", type: "number", value: ruleDraft.discount, onChange: (v) => setRuleDraft((d) => ({ ...d, discount: v })), hint: "Off the selection subtotal. Applies to new cart lines; a line already in a cart keeps the price it was quoted and is re-checked at checkout." })] }), _jsx(Button, { type: "button", variant: "secondary", size: "sm", className: "mt-2", onClick: () => void saveRules(), isLoading: loading, children: "Save rules" })] }), _jsxs(Div, { children: [_jsxs(Text, { className: "mb-2", color: "muted", size: "xs", weight: "semibold", children: ["Members (", childSlugsCount + 1, " \u2014 including this listing)"] }), _jsx(Text, { className: "mb-2", color: "faint", size: "xs", children: "Each listing has its own stock. Editing here saves them together \u2014 it does not pool them." }), loading && !children ? (_jsx(Text, { size: "xs", color: "faint", children: "Loading\u2026" })) : (_jsxs(Div, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", children: [_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [_jsx(Span, { padding: "pill-2xs", weight: "semibold", className: "bg-[var(--appkit-color-primary)]/10 text-[var(--appkit-color-primary)] text-[10px]", rounded: "default", children: "Parent" }), _jsx(Text, { className: "flex-1", color: "primary", size: "sm", children: productSlug })] }), (children ?? []).filter((c) => c.id !== productId).map((child) => (_jsxs(Row, { align: "center", gap: "sm", padding: "y-xs", children: [productImageUrls(child.images)[0] ? (_jsx(Div, { className: "relative w-8 h-8 flex-shrink-0 border border-[var(--appkit-color-border)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: productImageUrls(child.images)[0], alt: child.title, size: "thumbnail" }) })) : (_jsx(Div, { className: "w-8 h-8", surface: "subtle", rounded: "full" })), _jsx(Text, { className: "flex-1 truncate", color: "primary", size: "sm", children: child.title }), child.status && child.status !== "published" ? (_jsx(Span, { size: "xs", surface: "warning-surface", className: "text-warning", padding: "pill-2xs", rounded: "default", children: child.status })) : null, (motDraft[child.id] ?? child.madeToOrder) === true ? (_jsx(Text, { size: "xs", color: "muted", children: "Made to order" })) : (_jsx(Input, { type: "number", "aria-label": `Stock for ${child.title}`, className: "w-20", value: stockDraft[child.id] ??
355
356
  (child.availableQuantity ?? 0).toString(), onChange: (e) => setStockDraft((d) => ({ ...d, [child.id]: e.target.value })) })), _jsx(Checkbox, { label: "MTO", "aria-label": `Made to order for ${child.title}`, checked: motDraft[child.id] ?? child.madeToOrder === true, onChange: (e) => setMotDraft((d) => ({ ...d, [child.id]: e.target.checked })) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => requestUnlinkChild(child.id), isLoading: loading, className: `${CLS_DELETE_LINK} text-[length:var(--appkit-text-xs)]`, children: "Unlink" })] }, child.id)))] })), (children ?? []).length > 0 && (_jsx(Button, { type: "button", variant: "secondary", size: "sm", className: "mt-2", onClick: () => void saveGroupStock(), isLoading: loading, children: "Save group stock" }))] }), _jsxs(Row, { gap: "sm", wrap: true, children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => setShowAddModal(true), children: "Add child listing" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestDissolveGroup, isLoading: loading, className: CLS_DELETE_LINK, children: "Dissolve group" })] })] })), !isGroupParent && groupParentSlug && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", color: "muted", children: ["Part of:", " ", _jsx(Span, { weight: "medium", color: "primary", children: groupTitle ?? groupParentSlug })] }), _jsxs(Text, { size: "xs", color: "muted", children: ["Parent listing: ", _jsx(Code, { className: "font-mono", children: groupParentSlug })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: requestLeaveGroup, isLoading: loading, className: `${CLS_DELETE_LINK} w-fit`, children: "Leave group" })] }))] })), (() => {
356
357
  const content = (_jsx(AddChildContent, { addTab: addTab, setAddTab: setAddTab, createForm: createForm, setCreateForm: setCreateForm, productSlug: productSlug, storeProductsEndpoint: storeProductsEndpoint, linkTargets: linkTargets, setLinkTargets: setLinkTargets, onAddCreate: addCreateChild, onAddLink: addLinkChild, loading: loading }));
357
358
  const close = () => setShowAddModal(false);
@@ -24,6 +24,7 @@ const CLS_BUNDLE_ROW = "flex items-center justify-between gap-[var(--appkit-spac
24
24
  const CLS_BUNDLE_LABEL = "text-[length:var(--appkit-text-xs)] text-success dark:text-success font-medium uppercase tracking-wide mb-0.5";
25
25
  const CLS_BUNDLE_CTA = "flex-shrink-0 rounded-lg bg-success-solid hover:brightness-110 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-success-on-solid transition-colors";
26
26
  import { ROUTES } from "../../../next/routing/route-map.js";
27
+ import { productImageUrls } from "../schemas/firestore.js";
27
28
  import { PART_SLOT_LABEL } from "../../parts/schemas/firestore.js";
28
29
  import { getModelsUsingPart } from "../../../_internal/server/features/parts/data.js";
29
30
  import { ctaAnchorProps } from "../../layout/cta-anchor.js";
@@ -135,8 +136,12 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
135
136
  const title = String(p.title ?? p.name ?? "");
136
137
  const price = typeof p.price === "number" ? p.price : null;
137
138
  const originalPrice = typeof p.originalPrice === "number" ? p.originalPrice : null;
138
- const images = Array.isArray(p.images)
139
- ? p.images
139
+ // `images` is `ProductImage[]` since A6 — `productImageUrls` is the one
140
+ // normaliser (it also accepts the legacy `string[]`). Casting it to
141
+ // `string[]` handed the gallery objects and rendered a placeholder.
142
+ const galleryUrls = productImageUrls(p.images);
143
+ const images = galleryUrls.length > 0
144
+ ? galleryUrls
140
145
  : typeof p.mainImage === "string"
141
146
  ? [p.mainImage]
142
147
  : [];
@@ -9,6 +9,7 @@ import { formatCurrency } from "../../../utils/number.formatter.js";
9
9
  import { normalizeListingType } from "../utils/listing-type.js";
10
10
  import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js";
11
11
  import { SUBLISTING_CATEGORY_ENDPOINTS } from "../../../constants/api-endpoints.js";
12
+ import { productImageUrls } from "../schemas/firestore.js";
12
13
  const __O = {
13
14
  hidden: "overflow-hidden",
14
15
  xAuto: "overflow-x-auto",
@@ -18,7 +19,7 @@ function getHref(listing) {
18
19
  return pluginFor(normalizeListingType(listing)).detailRoute(slug);
19
20
  }
20
21
  function ListingThumb({ listing, isCurrent, }) {
21
- const image = listing.images?.[0] ?? listing.mainImage ?? "";
22
+ const image = productImageUrls(listing.images)[0] || listing.mainImage || "";
22
23
  const href = getHref(listing);
23
24
  const price = formatCurrency(listing.price, listing.currency ?? "INR");
24
25
  return (_jsxs(Link, { href: href, "aria-label": listing.title, className: "flex flex-col items-center gap-[var(--appkit-space-1-5)] flex-shrink-0 w-16 group", children: [_jsx(Div, { rounded: "full", className: `relative w-14 h-14 overflow-hidden border-2 transition-all ${isCurrent
@@ -694,6 +694,7 @@ export declare class ProductRepository extends BaseRepository<ProductDocument> {
694
694
  status?: string;
695
695
  categoriesIn?: string[];
696
696
  search?: string;
697
+ brandName?: string;
697
698
  }): Promise<FirebaseSieveResult<ProductDocument>>;
698
699
  /**
699
700
  * How many products of each listing type exist within a scope — the numbers
@@ -1451,7 +1451,11 @@ export class ProductRepository extends BaseRepository {
1451
1451
  }
1452
1452
  return baseQuery;
1453
1453
  }
1454
- async list(model, opts) {
1454
+ async list(model,
1455
+ // `brandName` was already honoured by `buildScopedQuery` and simply not
1456
+ // declared here, so a brand-scoped caller had to express the brand as a
1457
+ // Sieve clause instead — a second spelling of one filter.
1458
+ opts) {
1455
1459
  /*
1456
1460
  * 🛑 THE EMPTY GUARD — a non-blank query that yields NO usable token must
1457
1461
  * return nothing, not everything.
@@ -1,3 +1,4 @@
1
+ import type { ProductImage } from "../schemas/firestore";
1
2
  /**
2
3
  * The thumbnail for one product-group member, from whichever shape the caller
3
4
  * supplied.
@@ -33,7 +34,13 @@
33
34
  export interface GroupMemberImageSource {
34
35
  /** The public projection's single thumbnail. */
35
36
  image?: string;
36
- /** A raw document's gallery. Only read when `image` is absent. */
37
- images?: string[];
37
+ /**
38
+ * A raw document's gallery. Only read when `image` is absent.
39
+ *
40
+ * 🛑 `ProductImage[]` since A6 — a raw document holds objects, a legacy one
41
+ * holds strings, and the public projection holds neither. `productImageUrls`
42
+ * is the one normaliser that spans all three.
43
+ */
44
+ images?: readonly (string | ProductImage)[];
38
45
  }
39
46
  export declare function resolveGroupMemberImage(m: GroupMemberImageSource): string;
@@ -1,3 +1,4 @@
1
+ import { productImageUrls } from "../schemas/firestore.js";
1
2
  export function resolveGroupMemberImage(m) {
2
- return m.image ?? m.images?.[0] ?? "";
3
+ return m.image || productImageUrls(m.images)[0] || "";
3
4
  }
@@ -34,6 +34,7 @@ import { DataListingView } from "../../admin/components/DataListingView.js";
34
34
  import { AssemblyDrawer, } from "../../parts/components/AssemblyDrawer.js";
35
35
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
36
36
  import { buildBulkAction } from "../../../_internal/shared/actions/bulk-helpers.js";
37
+ import { productImageUrls } from "../../products/schemas/firestore.js";
37
38
  const FILTER_KEYS = [
38
39
  "status",
39
40
  "category",
@@ -579,8 +580,8 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
579
580
  secondary: [priceLabel, stockLabel, secondaryBase, statusLabel].filter(Boolean).join(" · "),
580
581
  status: statusLabel,
581
582
  updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
582
- imageUrl: toStringValue(item.mainImage ?? item.images?.[0], undefined),
583
- image: toStringValue(item.mainImage ?? item.images?.[0], undefined),
583
+ imageUrl: toStringValue(item.mainImage, undefined) || productImageUrls(item.images)[0],
584
+ image: toStringValue(item.mainImage, undefined) || productImageUrls(item.images)[0],
584
585
  listingKind: kind,
585
586
  price: priceRaw ? `₹${priceRaw.toLocaleString("en-IN")}` : "—",
586
587
  stock: stockLabel || "—",
@@ -16,6 +16,7 @@ import { AUCTION_PUBLIC_SORT_OPTIONS } from "../../products/constants/sieve.js";
16
16
  import { AvailabilityTabs } from "../../products/components/AvailabilityTabs.js";
17
17
  import { AVAILABILITY_VALUES } from "../../../constants/field-names.js";
18
18
  import { TABLE_KEYS } from "../../../constants/table-keys.js";
19
+ import { productImageUrls } from "../../products/schemas/firestore.js";
19
20
  const STORE_AUCTION_TYPES = ["auction"];
20
21
  const __P = {
21
22
  p3: "p-[var(--appkit-space-3)]",
@@ -75,7 +76,7 @@ export function StoreAuctionsListing({ storeId, initialData }) {
75
76
  description: p.description,
76
77
  price: p.price ?? 0,
77
78
  currency: p.currency || getDefaultCurrency(),
78
- mainImage: p.mainImage ?? p.images?.[0],
79
+ mainImage: p.mainImage || productImageUrls(p.images)[0],
79
80
  images: p.images,
80
81
  listingType: "auction",
81
82
  auctionEndDate: p.auctionEndDate,
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  * WHY: Authored six-part procedures for content-discovery/authenticity-checker.
3
- * WHAT: 16 cases, keyed by full checklist id.
3
+ * WHAT: 22 cases, keyed by full checklist id.
4
4
  *
5
5
  * 🛑 EVERY CASE IS WRITTEN AS BEFORE → AFTER, because this feature's whole
6
6
  * failure mode is a confident, plausible, wrong answer returned with HTTP 200. A
@@ -44,7 +44,7 @@ export const authored = {
44
44
  startPage: "/tools/authenticity",
45
45
  steps: [
46
46
  "Open /tools/authenticity signed out.",
47
- "Confirm the LOOKUP FORM itself is there and usable — Model name, Code on the box, Brand, Generation, How it was sold, and a 'Check it' button.",
47
+ "Confirm the LOOKUP FORM itself is there and usable — 'Which bey is it?', 'Code on the box', a collapsed 'More details' holding the name-as-printed, brand, generation and how-it-was-sold fields, and a 'Check it' button.",
48
48
  "Confirm there is no sign-in card and no 'Sign in to run a lookup' text anywhere on the page.",
49
49
  "Enter A0813 as the code and submit, still signed out.",
50
50
  "Confirm a real assessment comes back rather than an error or a prompt to sign in.",
@@ -475,4 +475,116 @@ export const authoredTypeahead = {
475
475
  expectedUiState: "The result renders immediately. The network log shows no fetch to the search artifact or any /api/search* endpoint.",
476
476
  endResult: "Read-only; nothing persists.",
477
477
  },
478
+ /* ── The model picker and the per-model code list (2026-10-20) ──────────
479
+ * 🛑 The reported bug these exist for: typing "l drago" returned
480
+ * "Inconclusive — Nothing contradicts it, and nothing confirms it either",
481
+ * while the tool held 33 recorded box codes for Lightning L-Drago, 35 for
482
+ * Meteo and 27 for Destructor and had no way to reach any of them. Every
483
+ * table was keyed by CODE, so naming a bey could never surface them.
484
+ *
485
+ * So each case compares the answer against data the tool DEMONSTRABLY HAS.
486
+ * "Check the picker works" passes against the bug; "picking Lightning
487
+ * L-Drago must list its 33 codes" does not.
488
+ */
489
+ "checklist-content-discovery-authenticity-checker-model-picker-finds-l-drago": {
490
+ roles: ["guest"],
491
+ startPage: "/tools/authenticity",
492
+ steps: [
493
+ "Open /tools/authenticity.",
494
+ "Confirm the FIRST field is 'Which bey is it?' and is a searchable select, not a plain text box.",
495
+ "Open it and type: l drago",
496
+ "Confirm all three L-Drago releases are offered — Lightning L-Drago 100HF, Meteo L-Drago LW105LF and L-Drago Destructor F:S.",
497
+ "Confirm each offered row states how many box codes are on file for it.",
498
+ "Clear the search and type: pegasis",
499
+ "Confirm Storm Pegasus still appears, even though the list spells it Pegasus.",
500
+ "Answer no if 'l drago' offers nothing, or if the field is a free-text input with no list.",
501
+ ],
502
+ inputs: { search: "l drago" },
503
+ expectedBehaviour: "BEFORE: the first field was a free-text 'Model name'. Typing 'l drago' matched four releases, so the engine refused to pick one and returned an inconclusive verdict — correct, and useless, because the three L-Dragos it declined to choose between have 95 recorded box codes between them and the screen never said so. AFTER: the picker resolves exactly one bey, which is what unlocks the per-model code list. It searches aliases as well as labels, because Takara Tomy romanises the line Pegasis and Hasbro prints Pegasus — a label-only filter would find neither from the other.",
504
+ expectedUiState: "A searchable select labelled 'Which bey is it?' listing three L-Drago rows, each annotated with a box-code count. Beys with codes on file are offered before beys without.",
505
+ endResult: "Nothing is written. The picker only reads a bundled list.",
506
+ },
507
+ "checklist-content-discovery-authenticity-checker-picked-model-lists-its-box-codes": {
508
+ roles: ["guest"],
509
+ startPage: "/tools/authenticity",
510
+ steps: [
511
+ "Open /tools/authenticity.",
512
+ "In 'Which bey is it?', search for and pick: Lightning L-Drago 100HF",
513
+ "Leave 'Code on the box' EMPTY.",
514
+ "Press 'Check it'.",
515
+ "Read the verdict line, then look for a panel headed 'Box codes recorded for Lightning L-Drago 100HF'.",
516
+ "Count the codes it lists.",
517
+ "Answer no if the verdict is 'Inconclusive — Nothing contradicts it, and nothing confirms it either', or if no code list appears.",
518
+ ],
519
+ inputs: { modelSlug: "lightning-l-drago-100hf" },
520
+ expectedBehaviour: "BEFORE: naming this bey with no code produced 'Inconclusive — Nothing contradicts it, and nothing confirms it either' and a physical checklist. True, in that nothing had been checked — and it withheld the 33 box codes the tables record for this exact bey, which is the single most useful thing the tool holds and the one thing the user had asked for. AFTER: the verdict is 'I know this one. Now find the code stamped on the box.' — still not a judgement, because nothing has been checked, but with the next step named and the 33 codes listed to check against.",
521
+ expectedUiState: "Verdict badge 'Need a bit more', headline 'I know this one. Now find the code stamped on the box.', a caveat naming the count, and a panel listing 33 codes as badges. A link reading 'Everything we hold on Lightning L-Drago 100HF' points at the model's own category page.",
522
+ expectedData: { recordedCodes: 33 },
523
+ endResult: "Read-only.",
524
+ },
525
+ "checklist-content-discovery-authenticity-checker-picked-model-plus-its-own-code": {
526
+ roles: ["guest"],
527
+ startPage: "/tools/authenticity",
528
+ steps: [
529
+ "Pick 'Lightning L-Drago 100HF' in 'Which bey is it?'.",
530
+ "Enter A0910 in 'Code on the box' — a code recorded against this exact bey.",
531
+ "Press 'Check it' and read the verdict.",
532
+ "Expand 'How I got there' and find the row about the code.",
533
+ "Press 'Start over'. Pick Lightning L-Drago 100HF again, this time enter A0310 — a real code recorded against a DIFFERENT bey.",
534
+ "Read the verdict and expand the working-out again.",
535
+ "Answer no if the second case reads as a mismatch, a discrepancy, or anything implying the item is wrong.",
536
+ ],
537
+ inputs: { modelSlug: "lightning-l-drago-100hf", recordedCode: "A0910", otherBeyCode: "A0310" },
538
+ expectedBehaviour: "A0910 is in this bey's own list, so the check reads as agreement — the strongest thing the tool can say from data, and it still says so is not proof. A0310 is recorded, but on Earth Aquila: that is WEAK SUPPORT, not a mismatch, because a production date covers whatever was boxed that day. The distinction is the whole point. Reporting 'that code belongs to a different bey' as a discrepancy would manufacture suspicion out of how the tables are organised, and the panel highlights the code you entered inside the list so you can see where it sits.",
539
+ expectedUiState: "First run: 'Consistent with an original', and a row reading that the code is recorded on genuine boxes of this exact bey. Second run: still not adverse — a neutral row saying the code is recorded but not against this bey, with the phrase 'weak support rather than a mismatch'.",
540
+ endResult: "Read-only.",
541
+ },
542
+ "checklist-content-discovery-authenticity-checker-missing-code-is-not-an-accusation": {
543
+ roles: ["guest"],
544
+ startPage: "/tools/authenticity",
545
+ steps: [
546
+ "Pick 'Lightning L-Drago 100HF' in 'Which bey is it?'.",
547
+ "Enter Z9999 in 'Code on the box' — well-formed, and in none of the tables.",
548
+ "Press 'Check it'.",
549
+ "Read the verdict, the code panel and the working-out.",
550
+ "Answer no if any sentence implies the item is fake, counterfeit or a reproduction, or if the verdict is more severe than 'Inconclusive'.",
551
+ ],
552
+ inputs: { modelSlug: "lightning-l-drago-100hf", code: "Z9999" },
553
+ expectedBehaviour: "A code absent from a model's list means UNRECORDED, never wrong. These tables are what collectors photographed, not a manufacturing record: 38 of 221 canonical beys have any recorded code at all, new genuine codes turn up regularly, and counterfeiters print real codes anyway. The panel must say so where a user will actually read it, not only inside the collapsed working-out — the reading 'my code is not in the list, so it is fake' is the one this whole feature has to defend against.",
554
+ expectedUiState: "Verdict 'Inconclusive'. A neutral row reading 'Not among the 33 codes recorded for Lightning L-Drago 100HF' whose text says 'unrecorded, not wrong'. The code panel carries a line beginning 'A code missing from this list means unrecorded, not fake.'",
555
+ endResult: "Read-only.",
556
+ },
557
+ "checklist-content-discovery-authenticity-checker-working-out-is-collapsed-by-default": {
558
+ roles: ["guest"],
559
+ startPage: "/tools/authenticity",
560
+ steps: [
561
+ "Pick 'Lightning L-Drago 100HF' and enter A0910. Press 'Check it'.",
562
+ "WITHOUT expanding anything, confirm you can read the verdict badge, the headline, the caveat and the recorded-code list.",
563
+ "Confirm the reasoning is behind a single collapsed control reading 'How I got there — N checks'.",
564
+ "Expand it and confirm every individual check is still there, each with its own confidence chip.",
565
+ "Press 'Start over'. Now enter the code A0813 with no model picked — a code the sources record as a midfake batch.",
566
+ "Confirm the working-out is ALREADY OPEN this time, without being clicked.",
567
+ "Answer no if the reasoning is expanded by default on the clean run, or collapsed on the midfake run.",
568
+ ],
569
+ inputs: { modelSlug: "lightning-l-drago-100hf", cleanCode: "A0910", midfakeCode: "A0813" },
570
+ expectedBehaviour: "BEFORE: every check rendered expanded under a 'What I checked' heading — up to eleven rows of reasoning between the buyer and the answer they asked for. AFTER: it is collapsed, not deleted, because 'why should I believe this' has to stay one tap away for a tool whose entire posture is that it hedges and cites. A CONCERN forces it open: the one case where the reasoning IS the answer is the case where something is wrong, and the user who never expands it must not be the user who never finds out.",
571
+ expectedUiState: "Clean run: verdict block, code panel, a collapsed 'How I got there — N checks', then the physical checks. Midfake run: the same control, already open.",
572
+ endResult: "Read-only.",
573
+ },
574
+ "checklist-content-discovery-authenticity-checker-result-scrolls-into-view": {
575
+ roles: ["guest"],
576
+ startPage: "/tools/authenticity",
577
+ steps: [
578
+ "Resize the browser to a phone viewport (about 390 x 740) or use device emulation.",
579
+ "Pick 'Lightning L-Drago 100HF' and enter A0910.",
580
+ "Scroll so the 'Check it' button is visible, and note what is on screen.",
581
+ "Press 'Check it'.",
582
+ "Without scrolling yourself, confirm the verdict badge and headline are now on screen.",
583
+ "Answer no if the page does not move and the verdict is below the fold.",
584
+ ],
585
+ inputs: { viewport: "390x740", modelSlug: "lightning-l-drago-100hf", code: "A0910" },
586
+ expectedBehaviour: "BEFORE: the form is taller than a phone viewport, so the answer rendered below the fold and pressing 'Check it' looked like it had done nothing — at the one moment this tool must not be ambiguous. AFTER: the result is scrolled into view on every fresh assessment, including the re-run a clarifying question triggers, and anchored at the top of the viewport rather than the middle so the badge and headline land where the eye already is.",
587
+ expectedUiState: "After submitting, the verdict badge and headline are visible without manual scrolling.",
588
+ endResult: "Read-only.",
589
+ },
478
590
  };
@@ -3301,6 +3301,18 @@ const rawTesterChecklistItems = [
3301
3301
  { key: "regional-code-reads-as-regional", label: "A Korean or Middle-East code names its region instead of coming back unrecorded" },
3302
3302
  { key: "series-window-names-the-production-run", label: "A derivable date names its Metal-series production window, or names none outside all four" },
3303
3303
  { key: "temporal-context-for-an-unmatched-code", label: "An unmatched code reports what else the tables record from the same month, framed as context not a match" },
3304
+ /* ── The model picker and the per-model code list (2026-10-20) ───
3305
+ * The reported bug: naming a bey the tool holds 33 recorded box
3306
+ * codes for produced "Nothing contradicts it, and nothing confirms
3307
+ * it either" and never mentioned them. Every case here compares the
3308
+ * answer against data the tool demonstrably has.
3309
+ */
3310
+ { key: "model-picker-finds-l-drago", label: "The model picker finds all three L-Dragos by partial name, and each shows how many box codes are on file" },
3311
+ { key: "picked-model-lists-its-box-codes", label: "Picking a model with no code returns the codes recorded for that exact bey, not a bare 'inconclusive'" },
3312
+ { key: "picked-model-plus-its-own-code", label: "A code recorded against the picked bey reads as agreement; one recorded against a different bey reads as weak support, not a mismatch" },
3313
+ { key: "missing-code-is-not-an-accusation", label: "A code absent from the picked model's list says 'unrecorded', never 'fake', and the verdict stays inconclusive" },
3314
+ { key: "working-out-is-collapsed-by-default", label: "The verdict is readable without expanding anything, and a concern auto-opens the working-out" },
3315
+ { key: "result-scrolls-into-view", label: "Submitting scrolls the verdict into view on a phone rather than leaving it below the fold" },
3304
3316
  ],
3305
3317
  },
3306
3318
  {
@@ -51,10 +51,44 @@ import { seedGallery } from "./_helpers/media.js";
51
51
  import { PRODUCT_FIELDS } from "../constants/field-names.js";
52
52
  import { withFinalSale } from "./_helpers/final-sale-fixtures.js";
53
53
  import { withDerivedCategoryNames } from "./_helpers/category-names.js";
54
+ import { buildSearchTxt } from "../utils/search-txt.js";
54
55
  const STORE_ID = "store-letitrip-official";
55
56
  const STORE_NAME = "LetItRip Official";
56
57
  const BRAND_SLUG = "brand-takara-tomy";
57
58
  const BRAND = "Takara-Tomy";
59
+ /**
60
+ * Build the `searchTxt` prefix index for a row.
61
+ *
62
+ * 🛑 THIS FILE SHIPPED WITHOUT IT AND EVERY LISTING WAS UNSEARCHABLE.
63
+ *
64
+ * `searchTxt` is the ONLY field the public product search queries. A row
65
+ * without it is not ranked lower — it is absent. Measured on production
66
+ * before this fix: 12 of the 14 real listings had no `searchTxt` at all, and
67
+ * the only two that did were the auction and the pre-order, whose own files
68
+ * already applied a tokeniser. So `?q=aero` returned 1 of the 3 AeroPegasus
69
+ * products and `?q=beyblade` returned 2 of 14, while a nonsense control
70
+ * correctly returned 0 — the search was working perfectly against an index
71
+ * that was almost empty.
72
+ *
73
+ * CLAUDE.md documents this exact trap on this exact pipeline: a wrapper
74
+ * missed when a seed file is created, with no error anywhere, because an
75
+ * absent field is indistinguishable from "no match" at every layer above.
76
+ *
77
+ * 🛑 Applied LAST, after `withDerivedCategoryNames`. The category names are
78
+ * derived by that wrapper, so tokenising before it indexes `undefined` and
79
+ * silently loses every category term ("Basic Line", "Beyblade X Tops") from
80
+ * the index.
81
+ */
82
+ function withSearchTokens(p) {
83
+ return {
84
+ ...p,
85
+ searchTxt: buildSearchTxt([
86
+ p.title, p.description, p.brand, p.brandSlug,
87
+ p.categoryNames, p.tags, p.condition,
88
+ p.specifications?.map((s) => `${s.name} ${s.value}`),
89
+ ]),
90
+ };
91
+ }
58
92
  const _raw = [
59
93
  // ===== Standalone tops =====
60
94
  {
@@ -438,4 +472,5 @@ export const productsBeybladeXSeedData = _raw
438
472
  seedPurpose: "permanent",
439
473
  }))
440
474
  .map(withFinalSale)
441
- .map(withDerivedCategoryNames);
475
+ .map(withDerivedCategoryNames)
476
+ .map(withSearchTokens);
package/dist/server.d.ts CHANGED
@@ -289,6 +289,8 @@ export { processRefundAction, type ProcessRefundInput, } from "./_internal/serve
289
289
  export { computeCheckoutFees, computePayoutDeduction, computeCodHandlingFee, computeWhatsAppNotifyFee, computeGiftWrapFee, computeShipmentProtectionFee, computeSaleProtectionFee, } from "./_internal/shared/fees/calculator";
290
290
  export { searchPriceIndex, getPriceIndexBandsForIds, PRICE_INDEX_SEARCH_LIMIT, PRICE_INDEX_MIN_QUERY_LENGTH } from "./_internal/server/features/price-index/search";
291
291
  export type { PriceIndexSearchResponse, PriceIndexSearchResult, PriceIndexSearchResultBand, } from "./_internal/server/features/price-index/search";
292
+ export { getPriceIndexMarketForIds } from "./_internal/server/features/price-index/market";
293
+ export type { PriceIndexMarketRow } from "./_internal/server/features/price-index/market";
292
294
  export { protectionAvailability, computeProtectionFees, adjudicateClaim, badgesFor, showsRiskNotice, showsSafetyBadge, returnDestination, rtoDestination, PROTECTION_KINDS, } from "./_internal/shared/features/shipping/protections";
293
295
  export type { ProtectionKind, ProtectionAvailability, ProtectionTierInput, ProtectionUnavailableReason, ClaimFinding, ClaimOutcome, } from "./_internal/shared/features/shipping/protections";
294
296
  export type { FeeCommissionRates, CheckoutFees, PayoutDeduction, CodHandlingFeeRates, WhatsAppNotifyFeeRates, GiftWrapFeeRates, ShipmentProtectionFeeRates, SaleProtectionFeeRates, } from "./_internal/shared/fees/calculator";
package/dist/server.js CHANGED
@@ -846,6 +846,14 @@ export { computeCheckoutFees, computePayoutDeduction, computeCodHandlingFee, com
846
846
  // client.ts (verify-entries would catch firebase-admin, but the import chain
847
847
  // is the thing to keep clean — Root Cause #6).
848
848
  export { searchPriceIndex, getPriceIndexBandsForIds, PRICE_INDEX_SEARCH_LIMIT, PRICE_INDEX_MIN_QUERY_LENGTH } from "./_internal/server/features/price-index/search.js";
849
+ /*
850
+ * The MARKET half of the same tool, and a separate export because it is a
851
+ * separate AUTHENTICATED endpoint — the competitor-derived band must not ride
852
+ * the public, edge-cached sold response. The full argument is in that module's
853
+ * own header; the short version is that merging them would either poison a
854
+ * public cache with viewer-dependent content or cost the sold half its cache.
855
+ */
856
+ export { getPriceIndexMarketForIds } from "./_internal/server/features/price-index/market.js";
849
857
  export { protectionAvailability, computeProtectionFees, adjudicateClaim, badgesFor, showsRiskNotice, showsSafetyBadge, returnDestination, rtoDestination, PROTECTION_KINDS, } from "./_internal/shared/features/shipping/protections.js";
850
858
  // [SERVER-ONLY] Payout refund deduction action
851
859
  export { applyRefundDeductionAction, } from "./_internal/server/features/payouts/actions.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.53.1",
3
+ "version": "4.53.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -387,6 +387,104 @@ const COLLECTION_MAP = {
387
387
  * A root with no mapping yields NO franchise (living collectibles are not an
388
388
  * IP) — see `FRANCHISE_BY_CATEGORY_ROOT`.
389
389
  */
390
+ /**
391
+ * The real, sellable catalogue — everything else in the product seed is demo
392
+ * furniture and is DROPPED HERE, before anything is written.
393
+ *
394
+ * ── 🛑 WHY A FILTER AND NOT A DELETION ────────────────────────────────────
395
+ *
396
+ * The fixtures stay in their files on purpose: they are the only coverage
397
+ * several features have (every `preOrderProductionStatus` value, a sold-out
398
+ * reservation, a depleted digital-code pool, an ended-unsold auction), and
399
+ * the tester checklist deep-links to them by id. Deleting them would silently
400
+ * remove that coverage. Not LOADING them is the part that was wanted.
401
+ *
402
+ * ── 🛑 WHY THIS HAD TO EXIST ──────────────────────────────────────────────
403
+ *
404
+ * `appkit-seed load` writes every registered row and `purge` deletes
405
+ * `seedPurpose: "test"` afterwards — so the demo listings came BACK on every
406
+ * reseed, and anyone who ran `load` to fix imagery silently republished 76
407
+ * fake products to the live storefront. That happened twice in one session.
408
+ * The owner's instruction was "delete them from the seed itself and make sure
409
+ * they do not return"; this is the point where that can be guaranteed, since
410
+ * every write path funnels through here.
411
+ *
412
+ * ── The discriminator is the STORE, not the listing type ──────────────────
413
+ *
414
+ * `store-beyblade-arena` is the fictional demo seller; `store-letitrip-
415
+ * official` is the real one. Keying on the store rather than on a per-file
416
+ * allow-list means a fixture added to ANY product seed file later is excluded
417
+ * automatically, which is the failure mode this is replacing — a list that
418
+ * has to be remembered is a list that drifts.
419
+ *
420
+ * Set `SEED_INCLUDE_DEMO=1` to load them anyway, for local demo work.
421
+ */
422
+ const REAL_STORE_IDS = new Set(["store-letitrip-official"]);
423
+
424
+ function realListingsOnly(products) {
425
+ if (process.env.SEED_INCLUDE_DEMO === "1") {
426
+ console.log("▌ SEED_INCLUDE_DEMO=1 — demo listings INCLUDED");
427
+ return products;
428
+ }
429
+ /*
430
+ * 🛑 TWO rules, because the store alone is not enough. Six prize-draw
431
+ * fixtures live under the REAL store and are still demo furniture — their
432
+ * imagery is a 460-byte generated colour tile from /images/seed-tiles/,
433
+ * which is the giveaway: a listing nobody photographed is a listing nobody
434
+ * intends to sell. Measured before writing this: every one of the six, and
435
+ * nothing real, matches.
436
+ */
437
+ const isPlaceholder = (p) => {
438
+ const urls = [p?.mainImage, ...(Array.isArray(p?.images) ? p.images : [])]
439
+ .map((v) => (typeof v === "string" ? v : v?.url))
440
+ .filter(Boolean);
441
+ return urls.length > 0 && urls.every((u) => String(u).includes("/images/seed-tiles/"));
442
+ };
443
+ const kept = products.filter((p) => REAL_STORE_IDS.has(p?.storeId) && !isPlaceholder(p));
444
+ const dropped = products.length - kept.length;
445
+ if (dropped > 0) {
446
+ console.log(` • ${dropped} demo listing(s) not loaded (set SEED_INCLUDE_DEMO=1 to include)`);
447
+ }
448
+ return kept;
449
+ }
450
+
451
+ /**
452
+ * Drop bundle categories whose members are not in the loaded catalogue.
453
+ *
454
+ * 🛑 A bundle is ALL-OR-NOTHING — `canAddToCart` buys every member together —
455
+ * so a bundle missing even one member is unsellable, and one missing ALL of
456
+ * them is a card that opens an empty page. Measured right after the demo
457
+ * listings stopped loading: of the five seeded bundles, three had zero
458
+ * surviving members and the other two had 1-of-4 and 2-of-3. All five were
459
+ * still `isActive: true`, and all five rendered on the HOMEPAGE as collage
460
+ * cards built from `display.mosaicImages` — which are baked at seed time from
461
+ * the members' own imagery, so they kept showing pictures of products that no
462
+ * longer exist. That is 92 placeholder-tile references on the homepage.
463
+ *
464
+ * Derived rather than hand-maintained for the same reason as the product
465
+ * filter: a bundle that becomes valid later starts loading again on its own,
466
+ * and one that breaks stops, with nobody having to remember either.
467
+ *
468
+ * Only `categoryType: "bundle"` rows are considered. Every other category is
469
+ * taxonomy and is never touched — rebuilding the tree is expensive and is
470
+ * explicitly not what this is for.
471
+ */
472
+ function loadableBundlesOnly(categories, loadedProducts) {
473
+ const live = new Set(loadedProducts.map((p) => p?.id).filter(Boolean));
474
+ let dropped = 0;
475
+ const kept = categories.filter((c) => {
476
+ if (c?.categoryType !== "bundle") return true;
477
+ const ids = Array.isArray(c.bundleProductIds) ? c.bundleProductIds : [];
478
+ const ok = ids.length > 0 && ids.every((id) => live.has(id));
479
+ if (!ok) dropped++;
480
+ return ok;
481
+ });
482
+ if (dropped > 0) {
483
+ console.log(` • ${dropped} bundle(s) not loaded — members missing from the catalogue`);
484
+ }
485
+ return kept;
486
+ }
487
+
390
488
  function withFranchise(products) {
391
489
  let stamped = 0;
392
490
  const out = products.map((p) => {
@@ -469,32 +567,13 @@ function withDerivedCategoryFields(products) {
469
567
  return out;
470
568
  }
471
569
 
472
- const SEED_DATA_MAP = {
473
- users: usersSeedData,
474
- /*
475
- * store-extensions. COLLECTION_MAP was only HALF the wiring: load() reads
476
- * SEED_DATA_MAP and short-circuits on an empty array, so adding the ten
477
- * collection names alone produced "no seed data, skipping" for all ten.
478
- * Same shape as the art/stickers note further down -- added everywhere
479
- * except the map that actually runs.
480
- */
481
- payoutMethods: payoutMethodsSeedData,
482
- shippingConfigs: shippingConfigsSeedData,
483
- analyticsCards: analyticsCardsSeedData,
484
- analyticsAlerts: analyticsAlertsSeedData,
485
- storeCategories: storeCategoriesSeedData,
486
- listingTemplates: listingTemplatesSeedData,
487
- moderationQueue: moderationQueueSeedData,
488
- reports: reportsSeedData,
489
- itemRequests: itemRequestsSeedData,
490
- storeGoogleConfig: storeGoogleConfigSeedData,
491
- addresses: addressesSeedData,
492
- storeAddresses: storeAddressesSeedData,
493
- // SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
494
- // standalone brands collection/seed exists anymore.
495
- categories: categoriesSeedData,
496
- stores: storesSeedData,
497
- products: withFranchise(withDerivedCategoryFields([
570
+ /**
571
+ * The real catalogue, computed ONCE so the bundle filter and the product
572
+ * loader cannot disagree about what "loaded" means. Deriving it twice is
573
+ * Root Cause #75's shape — two copies of one rule, one of which learns about
574
+ * a change and the other does not.
575
+ */
576
+ const REAL_PRODUCTS = realListingsOnly([
498
577
  ...(productsStandardSeedData || []),
499
578
  // Real, wiki-verified Beyblade X listings under store-letitrip-official —
500
579
  // added 2026-10-19 for PhonePe merchant registration, which requires at
@@ -536,7 +615,34 @@ const SEED_DATA_MAP = {
536
615
  * what should happen to a leaf that now has BOTH a real listing and a
537
616
  * generated one.
538
617
  */
539
- ])),
618
+ ]);
619
+
620
+ const SEED_DATA_MAP = {
621
+ users: usersSeedData,
622
+ /*
623
+ * store-extensions. COLLECTION_MAP was only HALF the wiring: load() reads
624
+ * SEED_DATA_MAP and short-circuits on an empty array, so adding the ten
625
+ * collection names alone produced "no seed data, skipping" for all ten.
626
+ * Same shape as the art/stickers note further down -- added everywhere
627
+ * except the map that actually runs.
628
+ */
629
+ payoutMethods: payoutMethodsSeedData,
630
+ shippingConfigs: shippingConfigsSeedData,
631
+ analyticsCards: analyticsCardsSeedData,
632
+ analyticsAlerts: analyticsAlertsSeedData,
633
+ storeCategories: storeCategoriesSeedData,
634
+ listingTemplates: listingTemplatesSeedData,
635
+ moderationQueue: moderationQueueSeedData,
636
+ reports: reportsSeedData,
637
+ itemRequests: itemRequestsSeedData,
638
+ storeGoogleConfig: storeGoogleConfigSeedData,
639
+ addresses: addressesSeedData,
640
+ storeAddresses: storeAddressesSeedData,
641
+ // SB-UNI-C: brands were merged into categories (categoryType:"brand") — no
642
+ // standalone brands collection/seed exists anymore.
643
+ categories: loadableBundlesOnly(categoriesSeedData, REAL_PRODUCTS),
644
+ stores: storesSeedData,
645
+ products: withFranchise(withDerivedCategoryFields(REAL_PRODUCTS)),
540
646
  orders: ordersSeedData,
541
647
  reviews: reviewsSeedData,
542
648
  bids: bidsSeedData,