@mohasinac/appkit 4.45.0 → 4.47.0

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 (160) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
  3. package/dist/_internal/server/features/categories/live-counts.js +72 -0
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
  5. package/dist/_internal/server/features/orders/adapters.js +29 -0
  6. package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
  7. package/dist/_internal/server/features/parts/assembly.js +246 -0
  8. package/dist/_internal/server/features/parts/data.d.ts +65 -0
  9. package/dist/_internal/server/features/parts/data.js +170 -0
  10. package/dist/_internal/server/features/parts/og.d.ts +34 -0
  11. package/dist/_internal/server/features/parts/og.js +66 -0
  12. package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
  13. package/dist/_internal/server/features/search/search-everything.js +120 -0
  14. package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
  15. package/dist/_internal/server/features/search/search-sources.js +138 -0
  16. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
  17. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
  18. package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
  19. package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
  20. package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
  21. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
  22. package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
  23. package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
  24. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
  26. package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
  27. package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
  28. package/dist/_internal/shared/actions/action-registry.js +36 -0
  29. package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
  30. package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
  31. package/dist/_internal/shared/features/shipping/protections.js +127 -0
  32. package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
  33. package/dist/_internal/shared/search/part-aliases.js +104 -0
  34. package/dist/constants/api-endpoints.d.ts +18 -12
  35. package/dist/constants/api-endpoints.js +6 -8
  36. package/dist/constants/field-names.d.ts +0 -2
  37. package/dist/constants/field-names.js +0 -2
  38. package/dist/constants/index.d.ts +1 -1
  39. package/dist/constants/index.js +1 -1
  40. package/dist/core/index.d.ts +0 -3
  41. package/dist/core/index.js +0 -2
  42. package/dist/errors/messages.d.ts +0 -7
  43. package/dist/errors/messages.js +0 -8
  44. package/dist/features/admin/components/AdminSectionsView.js +83 -32
  45. package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
  46. package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
  47. package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
  48. package/dist/features/admin/schemas/firestore.d.ts +15 -0
  49. package/dist/features/admin/types/index.d.ts +0 -1
  50. package/dist/features/auth/permissions/constants.d.ts +1 -1
  51. package/dist/features/auth/permissions/constants.js +0 -2
  52. package/dist/features/categories/api/[id]/description-template/route.js +40 -1
  53. package/dist/features/categories/components/BrandDetailPageView.js +14 -3
  54. package/dist/features/categories/components/BreadcrumbTrail.d.ts +24 -1
  55. package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
  56. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  57. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  58. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  59. package/dist/features/categories/repository/categories.repository.js +168 -22
  60. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  61. package/dist/features/categories/repository/category-counts.js +224 -0
  62. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  63. package/dist/features/categories/schemas/category-counts.js +116 -0
  64. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  65. package/dist/features/categories/schemas/firestore.js +0 -1
  66. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  67. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  68. package/dist/features/corporate/schemas/index.d.ts +4 -4
  69. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  70. package/dist/features/orders/types/index.d.ts +30 -0
  71. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  72. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  73. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  74. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  75. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  76. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  77. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  78. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  79. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  80. package/dist/features/parts/schemas/firestore.js +181 -0
  81. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  82. package/dist/features/parts/utils/colourway-match.js +98 -0
  83. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  84. package/dist/features/parts/utils/part-breakdown.js +77 -0
  85. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  86. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  87. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  88. package/dist/features/products/components/ProductTabsShell.js +4 -1
  89. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  90. package/dist/features/products/repository/category-assignment.js +304 -0
  91. package/dist/features/products/repository/products.repository.d.ts +58 -1
  92. package/dist/features/products/repository/products.repository.js +255 -28
  93. package/dist/features/products/schemas/firestore.d.ts +147 -6
  94. package/dist/features/products/schemas/firestore.js +49 -0
  95. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  96. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  97. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  98. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  99. package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
  100. package/dist/features/seller/components/SellerProductShell.js +30 -1
  101. package/dist/features/seller/components/SellerProductsView.js +129 -3
  102. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  103. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  104. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  105. package/dist/features/tester/seed-data/authored/index.js +2 -0
  106. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  107. package/dist/index.d.ts +11 -16
  108. package/dist/index.js +26 -18
  109. package/dist/next/routing/route-map.d.ts +6 -2
  110. package/dist/next/routing/route-map.js +14 -1
  111. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  112. package/dist/providers/db-firebase/base.repository.js +31 -0
  113. package/dist/repositories/index.d.ts +1 -2
  114. package/dist/repositories/index.js +0 -1
  115. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  116. package/dist/seed/_helpers/category-content-generated.js +1837 -0
  117. package/dist/seed/_helpers/category-content.d.ts +29 -0
  118. package/dist/seed/_helpers/category-content.js +98 -1
  119. package/dist/seed/_helpers/category-forest.js +104 -24
  120. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  121. package/dist/seed/_helpers/category-parts.js +4594 -0
  122. package/dist/seed/carousel-slides-seed-data.js +81 -8
  123. package/dist/seed/categories-seed-data.js +44 -6
  124. package/dist/seed/homepage-sections-seed-data.js +170 -9
  125. package/dist/seed/index.d.ts +2 -0
  126. package/dist/seed/index.js +13 -0
  127. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  128. package/dist/seed/model-composition-seed-data.js +8918 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  130. package/dist/seed/part-glossary-seed-data.js +10729 -0
  131. package/dist/seed/products-standard-seed-data.js +52 -0
  132. package/dist/seed/seed-purpose.d.ts +103 -4
  133. package/dist/seed/seed-purpose.js +162 -11
  134. package/dist/seo/json-ld.d.ts +10 -1
  135. package/dist/seo/json-ld.js +9 -1
  136. package/dist/server.d.ts +7 -0
  137. package/dist/server.js +11 -0
  138. package/dist/styles.css +1 -1
  139. package/dist/tailwind-utilities.css +1 -1
  140. package/dist/utils/id-generators.d.ts +30 -0
  141. package/dist/utils/id-generators.js +31 -1
  142. package/dist/utils/search-txt-builders.js +22 -0
  143. package/dist/utils/search-txt.d.ts +22 -0
  144. package/dist/utils/search-txt.js +17 -0
  145. package/dist/values/success-messages.d.ts +0 -4
  146. package/dist/values/success-messages.js +0 -5
  147. package/package.json +1 -1
  148. package/scripts/seed-cli.mjs +292 -16
  149. package/dist/core/copilot-log.repository.d.ts +0 -44
  150. package/dist/core/copilot-log.repository.js +0 -61
  151. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  152. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  153. package/dist/features/copilot/components/index.d.ts +0 -2
  154. package/dist/features/copilot/components/index.js +0 -1
  155. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  156. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  157. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  158. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  159. package/dist/features/copilot/index.d.ts +0 -3
  160. package/dist/features/copilot/index.js +0 -3
@@ -0,0 +1,77 @@
1
+ /**
2
+ * D12 — the `{{partBreakdown}}` description slot.
3
+ *
4
+ * A listing filed under a named-model leaf can state what the bey is made of,
5
+ * from the same `modelComposition` the model page renders. The corpus knows
6
+ * 259 models down to the slot; before this, a seller's auto-filled description
7
+ * could say the series and the system and not one part.
8
+ *
9
+ * ── 🛑 WHY A SERVER-RESOLVED SLOT AND NOT A CLIENT ONE ─────────────────────
10
+ *
11
+ * Every other `{{slot}}` is resolved by the seller form from the draft it is
12
+ * already holding — title, brand, condition, the category path. A part
13
+ * breakdown is a Firestore read, so the form cannot compute it, and asking the
14
+ * form to fetch it would be a second request per category change on a surface
15
+ * that already makes one. It rides the template endpoint's existing response.
16
+ *
17
+ * ── 🛑 ABSENCE REMOVES THE LINE, IT DOES NOT EMPTY IT ──────────────────────
18
+ *
19
+ * 49 of 259 compositions did not join to a category, and most of the
20
+ * catalogue is not a Beyblade at all. `interpolate` resolves an unfilled slot
21
+ * to an empty string, so a body carrying `<li><strong>Parts:</strong>
22
+ * {{partBreakdown}}</li>` would render an **empty bullet** on every listing
23
+ * without a composition — a visible artifact that reads as broken data.
24
+ *
25
+ * So the endpoint strips the whole line instead. That is why the strip is
26
+ * LINE-based rather than element-based: the seeded bodies are template
27
+ * literals with exactly one `<li>` or `<p>` per source line, which makes
28
+ * "the line containing the slot" an exact, predictable unit. A body that put
29
+ * two elements on one line would lose both — stated here rather than guarded
30
+ * against, because the guard would be a second HTML parser.
31
+ */
32
+ import { PART_SLOT_LABEL } from "../schemas/firestore.js";
33
+ /** The slot name, in ONE place — the endpoint, the formatter and the seeded bodies all key on it. */
34
+ export const PART_BREAKDOWN_SLOT = "partBreakdown";
35
+ /**
36
+ * `Attack Ring — Metal Attack Ring (19 g) · Weight Disk — CWD Chain Attacker`
37
+ *
38
+ * 🛑 ONE LINE, not a `<ul>`. A slot that emits markup breaks the moment a
39
+ * template author drops it mid-sentence, and a second HTML-shaped slot beside
40
+ * a plain one is two things that must agree about the same data. One
41
+ * composable value, usable anywhere.
42
+ *
43
+ * Returns `""` when there is nothing to say, which is the signal the caller
44
+ * uses to strip the line.
45
+ */
46
+ export function formatPartBreakdown(compositions, parts) {
47
+ // The first composition only. A remake shares a model's name and not its
48
+ // parts, so concatenating two would describe a bey that does not exist.
49
+ const comp = compositions[0];
50
+ const slots = comp?.slots ?? [];
51
+ if (slots.length === 0)
52
+ return "";
53
+ const pieces = slots
54
+ .map((s) => {
55
+ const label = PART_SLOT_LABEL[s.slotKey] ?? s.slotKey;
56
+ const name = (s.partName ?? "").trim();
57
+ if (!name)
58
+ return null;
59
+ const weight = s.weightG ?? (s.partId ? parts?.get(s.partId)?.weightG : undefined);
60
+ return `${label} — ${name}${typeof weight === "number" ? ` (${weight} g)` : ""}`;
61
+ })
62
+ .filter((p) => p !== null);
63
+ return pieces.join(" · ");
64
+ }
65
+ /**
66
+ * Remove the source line that carries `{{slot}}`.
67
+ *
68
+ * Used when the slot has no value, so the body arrives without the line rather
69
+ * than with an empty one. See the header for why this is line-based.
70
+ */
71
+ export function stripSlotLine(body, slot) {
72
+ const needle = `{{${slot}}}`;
73
+ return body
74
+ .split("\n")
75
+ .filter((line) => !line.includes(needle))
76
+ .join("\n");
77
+ }
@@ -9,6 +9,19 @@ export interface ProductDetailPageViewProps {
9
9
  * When absent, the component falls back to fetching by slug (backward compat).
10
10
  */
11
11
  initialProduct?: import("../schemas/firestore").ProductDocument | null;
12
+ /**
13
+ * D12 — the part composition for this listing's model, as a ready node.
14
+ *
15
+ * 🛑 A NODE, not a document: the composition is a Firestore read and this
16
+ * component is already at its read budget, so the page resolves it (where
17
+ * it is already reading the product) and passes the rendered panel down.
18
+ * Passing the raw documents would make this component read too, and a
19
+ * second reader of the same data is the drift this codebase keeps closing.
20
+ *
21
+ * Absent for every listing that is not filed under a named-model leaf,
22
+ * which is most of them — the tab then does not render at all.
23
+ */
24
+ compositionContent?: React.ReactNode;
12
25
  /**
13
26
  * Render prop for offer UI. Receives the resolved product fields.
14
27
  *
@@ -65,4 +78,4 @@ export interface ProductDetailPageViewProps {
65
78
  */
66
79
  emiEnabled?: boolean;
67
80
  }
68
- export declare function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, productFeatures, codEnabled, emiEnabled, }: ProductDetailPageViewProps): Promise<import("react").JSX.Element>;
81
+ export declare function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, compositionContent, productFeatures, codEnabled, emiEnabled, }: ProductDetailPageViewProps): Promise<import("react").JSX.Element>;
@@ -69,7 +69,7 @@ function StarRating({ value }) {
69
69
  // ---------------------------------------------------------------------------
70
70
  // Component
71
71
  // ---------------------------------------------------------------------------
72
- export async function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, productFeatures, codEnabled, emiEnabled, }) {
72
+ export async function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, compositionContent, productFeatures, codEnabled, emiEnabled, }) {
73
73
  // Use pre-fetched data when available to avoid a redundant repository call.
74
74
  // The page layer wraps getProductForDetail in React.cache(), so both
75
75
  // generateMetadata() and this component share the same in-flight promise.
@@ -102,6 +102,25 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
102
102
  const inStock = effectiveStock !== null ? effectiveStock > 0 : p.status === "published";
103
103
  const avgRating = typeof p.avgRating === "number" ? p.avgRating : null;
104
104
  const reviewCount = typeof p.reviewCount === "number" ? p.reviewCount : null;
105
+ /*
106
+ * D8/H2 — real social proof. "N people viewed this in the last week" is a
107
+ * number we MEASURED, which is what makes it usable: H2's whole boundary is
108
+ * "use persuasion that is TRUE, refuse persuasion that is FALSE", and
109
+ * nobody in the fifteen-site crawl shows this despite 76–93% of their
110
+ * catalogues being sold out.
111
+ *
112
+ * 🛑 `typeof === "number"`, not truthiness. `undefined` means the nightly
113
+ * pass has not rolled this product up yet; `0` means measured and nobody
114
+ * looked. Rendering "0 people viewed this" is worse than rendering nothing,
115
+ * and `?? 0` is exactly how the admin Top Products table came to show
116
+ * "0 views" on every row as though it were a measurement (A5).
117
+ *
118
+ * 🛑 And a floor of 2, so a single visitor is never told "1 person viewed
119
+ * this" — which on a quiet listing is usually them. Below the floor there is
120
+ * no claim rather than a technically-true one that reads as desperate.
121
+ */
122
+ const views7d = typeof p.views7d === "number" ? p.views7d : null;
123
+ const showViews = views7d !== null && views7d >= 2;
105
124
  const category = typeof p.category === "string" ? p.category : null;
106
125
  const categoryName = typeof p.categoryName === "string" ? p.categoryName : null;
107
126
  // categorySlugs/categoryNames: multi-category support (S-uni-formshell-prep)
@@ -212,7 +231,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
212
231
  ? "Refurbished"
213
232
  : condition === "used"
214
233
  ? "Used"
215
- : condition })), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title || "Untitled Product" })] }), avgRating !== null ? (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(StarRating, { value: avgRating }), _jsxs(Span, { size: "sm", color: "muted", children: [avgRating.toFixed(1), reviewCount ? ` (${reviewCount} reviews)` : ""] }), _jsxs(Span, { size: "xs", weight: "medium", surface: inStock ? "success-surface" : "danger-surface", className: `ml-auto ${inStock ? "text-success" : "text-error"}`, padding: "pill-sm", rounded: "full", children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
234
+ : condition })), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title || "Untitled Product" })] }), avgRating !== null ? (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(StarRating, { value: avgRating }), _jsxs(Span, { size: "sm", color: "muted", children: [avgRating.toFixed(1), reviewCount ? ` (${reviewCount} reviews)` : ""] }), showViews && (_jsxs(Span, { size: "sm", color: "muted", children: ["\u00B7 ", views7d.toLocaleString(), " viewed this week"] })), _jsxs(Span, { size: "xs", weight: "medium", surface: inStock ? "success-surface" : "danger-surface", className: `ml-auto ${inStock ? "text-success" : "text-error"}`, padding: "pill-sm", rounded: "full", children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
216
235
  ? ` — only ${effectiveStock} left`
217
236
  : ""] })] })) : (_jsxs(Span, { layout: "inline-flex", gap: "xs", className: `w-fit .5 ${inStock ? CLS_STOCK_IN : CLS_STOCK_OUT}`, rounded: "full", padding: "pill-md", size: "xs", weight: "medium", children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
218
237
  ? ` — only ${effectiveStock} left`
@@ -267,7 +286,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
267
286
  ? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
268
287
  : undefined, renderGroupSection: groupId
269
288
  ? () => (_jsx(ShowGroupSection, { groupId: groupId, currentSlug: String(p.slug ?? product.id), isParent: isGroupParent, groupTitle: groupTitle }))
270
- : undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: allSpecs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: allSpecs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, ingredientsContent: ingredients.length > 0 ? (_jsx(Ul, { spacing: "comfortable", children: ingredients.map((item, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-1 flex-shrink-0 h-1.5 w-1.5 bg-primary-400", rounded: "full" }), item] }, i))) })) : undefined, howToUseContent: howToUse.length > 0 ? (_jsx(Ol, { spacing: "loose", children: howToUse.map((step, i) => (_jsxs(Li, { layout: "flex-start", gap: "3", textSize: "sm", color: "muted", children: [_jsx(Span, { layout: "flex-center", size: "xs", weight: "bold", className: "flex-shrink-0 h-6 w-6 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", children: i + 1 }), step] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
289
+ : undefined, renderTabs: () => (_jsx(ProductTabsShell, { partsContent: compositionContent, descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: allSpecs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: allSpecs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, ingredientsContent: ingredients.length > 0 ? (_jsx(Ul, { spacing: "comfortable", children: ingredients.map((item, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-1 flex-shrink-0 h-1.5 w-1.5 bg-primary-400", rounded: "full" }), item] }, i))) })) : undefined, howToUseContent: howToUse.length > 0 ? (_jsx(Ol, { spacing: "loose", children: howToUse.map((step, i) => (_jsxs(Li, { layout: "flex-start", gap: "3", textSize: "sm", color: "muted", children: [_jsx(Span, { layout: "flex-center", size: "xs", weight: "bold", className: "flex-shrink-0 h-6 w-6 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", children: i + 1 }), step] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
271
290
  id: s.id,
272
291
  label: s.title,
273
292
  content: _jsx(CustomSectionTabContent, { section: s }),
@@ -7,6 +7,8 @@ export interface CustomTabDef {
7
7
  export interface ProductTabsShellProps {
8
8
  descriptionContent?: React.ReactNode;
9
9
  specsContent?: React.ReactNode;
10
+ /** D12 — part composition. Pass non-null to surface the tab. */
11
+ partsContent?: React.ReactNode;
10
12
  /** W1-34 — grading tab content (PSA/BGS/CGC etc). Pass non-null to surface the tab. */
11
13
  gradingContent?: React.ReactNode;
12
14
  ingredientsContent?: React.ReactNode;
@@ -18,4 +20,4 @@ export interface ProductTabsShellProps {
18
20
  customTabs?: CustomTabDef[];
19
21
  className?: string;
20
22
  }
21
- export declare function ProductTabsShell({ descriptionContent, specsContent, gradingContent, ingredientsContent, howToUseContent, reviewsContent, qaContent, customTabs, className, }: ProductTabsShellProps): React.JSX.Element | null;
23
+ export declare function ProductTabsShell({ descriptionContent, specsContent, partsContent, gradingContent, ingredientsContent, howToUseContent, reviewsContent, qaContent, customTabs, className, }: ProductTabsShellProps): React.JSX.Element | null;
@@ -6,6 +6,8 @@ import { Tabs, TabsList, TabsTrigger } from "../../../ui/components/Tabs.js";
6
6
  const STATIC_TABS = [
7
7
  { id: "description", label: "Description" },
8
8
  { id: "specs", label: "Specifications" },
9
+ /** D12 — the part composition, when the listing is filed under a named model. */
10
+ { id: "parts", label: "Parts" },
9
11
  { id: "grading", label: "Grading" },
10
12
  { id: "ingredients", label: "Ingredients" },
11
13
  { id: "howToUse", label: "How to Use" },
@@ -13,10 +15,11 @@ const STATIC_TABS = [
13
15
  // W1-34 — Q&A tab; consumers pass `qaContent` to surface it.
14
16
  { id: "qa", label: "Q&A" },
15
17
  ];
16
- export function ProductTabsShell({ descriptionContent, specsContent, gradingContent, ingredientsContent, howToUseContent, reviewsContent, qaContent, customTabs = [], className = "", }) {
18
+ export function ProductTabsShell({ descriptionContent, specsContent, partsContent, gradingContent, ingredientsContent, howToUseContent, reviewsContent, qaContent, customTabs = [], className = "", }) {
17
19
  const staticContentMap = {
18
20
  description: descriptionContent,
19
21
  specs: specsContent,
22
+ parts: partsContent,
20
23
  grading: gradingContent,
21
24
  ingredients: ingredientsContent,
22
25
  howToUse: howToUseContent,
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 🛑 A CAP, because the closure is `array-contains`-indexed and Firestore's
3
+ * `array-contains-any` takes at most 30 comparison values.
4
+ *
5
+ * 4 assignments x 5 tiers (0-4) = 20 closure entries worst case, which leaves
6
+ * headroom under that 30. It also bounds the index-entry cost: the seed loader
7
+ * commits at most 20,000 array elements per batch, and every closure entry is
8
+ * one index entry per product.
9
+ */
10
+ export declare const MAX_CATEGORY_ASSIGNMENTS = 4;
11
+ /**
12
+ * 🛑 THE FLIP. `false` = the leaf/non-leaf rule REPORTS; `true` = it refuses.
13
+ *
14
+ * It ships `false` and is flipped only once the data is clean, and the reason is
15
+ * a measurement: **70 of the 71 hand-authored products file at a non-leaf
16
+ * today** and not one of them is a lot. Enforcing first would make the whole
17
+ * catalogue unsaveable — and worse, it would make the very seed writes that fix
18
+ * the data fail, because those also go through `deriveTaxonomy`.
19
+ *
20
+ * This is the G3a lesson, which cost a symmetric rule its credibility: the plan
21
+ * specified "a lot takes a non-leaf while singles take leaves", measurement
22
+ * rejected the second half, and the half-rule has been live since. The rule is
23
+ * right; enforcing it before its data is not.
24
+ *
25
+ * Flip procedure, and it is a ratchet rather than a judgement call: run
26
+ * `findLeafRuleOffenders` over every seeded row, confirm **0**, then set `true`.
27
+ * A hand count is not the measurement — the earlier one said 58 of 70 and this
28
+ * pass measured 70 of 71.
29
+ */
30
+ export declare const ENFORCE_LEAF_FILING = false;
31
+ /** A category document, reduced to the fields assignment actually needs. */
32
+ export interface ResolvedCategory {
33
+ id: string;
34
+ name?: string;
35
+ /** The tree this assignment belongs to. Two assignments may not share one. */
36
+ rootId?: string;
37
+ /** Depth. Decides the default: deepest wins, because depth is specificity. */
38
+ tier?: number;
39
+ /**
40
+ * 🛑 `undefined` is NOT `false` here. It means `buildCategoryTree` has not
41
+ * rebuilt the tree, which is a fact about our data rather than about the
42
+ * caller's input — so it gets its own refusal once enforcement is on.
43
+ */
44
+ isLeaf?: boolean;
45
+ /** Root-first / nearest-last, as `category-tree.ts` writes it. */
46
+ parentIds: string[];
47
+ /** Root-first, parallel to `parentIds`. */
48
+ ancestorNames: string[];
49
+ }
50
+ /**
51
+ * The authored assignment list, from whichever field the caller used.
52
+ *
53
+ * Deliberately tolerant of all three existing shapes, because every one of them
54
+ * is live today and none should need editing to keep working:
55
+ *
56
+ * - `categoryIds: [a, b]` the new multi-select
57
+ * - `category: "a"` what `CategoryInlineSelect` writes, and what
58
+ * `productCreateSchema` declares
59
+ * - `categorySlugs: [a, ...]` the seed, and the admin `.passthrough()` route
60
+ *
61
+ * 🛑 The scalar still WINS over `categorySlugs`, unchanged from before this
62
+ * change and for the recorded reason: the seller edit form sends a stale
63
+ * derived chain alongside a fresh `category`, so preferring the chain resolved
64
+ * the OLD leaf and wrote it back over the user's choice.
65
+ *
66
+ * Duplicates are deduped rather than refused. A duplicate assignment is exactly
67
+ * equivalent to not having it, so there is no semantic question for the caller
68
+ * to answer — unlike a duplicate assembly component, which would change
69
+ * arithmetic.
70
+ */
71
+ export declare function readAssignments(input: {
72
+ categoryIds?: string[];
73
+ category?: string;
74
+ categorySlugs?: string[];
75
+ }): string[];
76
+ /** Which assignments break the leaf rule, and why. Reporting shape, no throw. */
77
+ export declare function findLeafRuleOffenders(resolved: ResolvedCategory[], isLot: boolean): {
78
+ id: string;
79
+ name?: string;
80
+ reason: "lot-at-leaf" | "single-at-branch" | "not-indexed";
81
+ }[];
82
+ /**
83
+ * Refuse the shapes that are not allowed. Throws; never corrects.
84
+ *
85
+ * 🛑 REFUSED, never corrected — silently dropping a second assignment, or
86
+ * re-filing one tier up, decides a taxonomy question on the seller's behalf and
87
+ * leaves them a listing they can no longer find (Root Cause #76 inverted).
88
+ */
89
+ export declare function assertAssignmentShape(resolved: ResolvedCategory[], isLot: boolean, opts?: {
90
+ enforceLeafFiling?: boolean;
91
+ warn?: (msg: string, meta?: object) => void;
92
+ }): void;
93
+ /**
94
+ * Which assignment owns the breadcrumb, the canonical chain and index 0.
95
+ *
96
+ * Three deterministic levels:
97
+ * 1. the DEEPEST assignment (highest `tier`);
98
+ * 2. tie -> the highest listing count, which the caller supplies;
99
+ * 3. still tied -> the order the seller picked.
100
+ *
101
+ * 🛑 "Most listings" CANNOT be the first criterion, and this is the one place
102
+ * the instruction had to be corrected. A franchise node spans every product
103
+ * type, so `category-beyblade` outranks `category-dragoon-g` on count for
104
+ * EVERY Beyblade product — which would put `Home > Franchises > Beyblade` above
105
+ * every listing and silently widen the realised-price band from "Dragoon G" to
106
+ * "all Beyblade", because that band is keyed on the snapshotted
107
+ * `categorySlugs[0]`.
108
+ *
109
+ * Depth decides because depth IS specificity, which is also the rule singles
110
+ * are filed by. A model leaf is tier 4 and a franchise leaf is tier 1, so the
111
+ * tie-break only ever runs between two equally specific assignments — exactly
112
+ * where "which is busier" is the right question.
113
+ *
114
+ * `counts` is consulted ONLY on a tie, so the common single-assignment case
115
+ * costs no extra read at all.
116
+ */
117
+ export declare function pickDefaultAssignment(resolved: ResolvedCategory[], counts?: Map<string, number>): number;
118
+ /**
119
+ * The derived closure and the display names.
120
+ *
121
+ * `categorySlugs` = the default chain leaf-first, then each remaining chain
122
+ * leaf-first, deduped. See the header for why the order is load-bearing.
123
+ *
124
+ * `categoryNames` = the DEFAULT chain only. A union has no readable path, and
125
+ * the breadcrumb builder reverses this array to render root-first — so keeping
126
+ * it single-chain is what leaves every existing breadcrumb byte-identical.
127
+ */
128
+ export declare function buildTaxonomyClosure(resolved: ResolvedCategory[], defaultIdx: number): {
129
+ categorySlugs: string[];
130
+ categoryNames: string[];
131
+ categoryIdNames: string[];
132
+ };
@@ -0,0 +1,304 @@
1
+ /*
2
+ * WHY: a product could be filed under exactly ONE category, and the ancestor
3
+ * chain was derived onto it. That cannot express "this is a Dragoon G AND
4
+ * it is Beyblade" — two independent axes, which is what the franchise root
5
+ * exists for. Phase I makes assignment multiple.
6
+ *
7
+ * WHAT: the pure half of multi-category assignment — resolve the authored list,
8
+ * refuse the two shapes that are not allowed, pick the DEFAULT, and build
9
+ * the derived closure. No Firestore, no I/O, so every rule here is
10
+ * testable without a database.
11
+ *
12
+ * EXPORTS: MAX_CATEGORY_ASSIGNMENTS, ENFORCE_LEAF_FILING, ResolvedCategory,
13
+ * readAssignments, assertAssignmentShape, pickDefaultAssignment,
14
+ * buildTaxonomyClosure, findLeafRuleOffenders
15
+ *
16
+ * ── 🛑 THE CLOSURE IS ORDERED, AND THAT IS NOT COSMETIC ────────────────────
17
+ *
18
+ * `categorySlugs[0]` is read as "the leaf" in ~30 places across both repos —
19
+ * measured, not assumed: the order-item snapshot (`expand-order-items.ts`,
20
+ * whose own comment says *"`categorySlugs[0]` is the leaf — the chain is stored
21
+ * self-first"*), BOTH count writers (`category-counts.ts`,
22
+ * `countersReconcile.ts`), BOTH triggers (`onProductWrite.ts`), eight detail
23
+ * views, `ProductForm.tsx` x5, the price-index data path,
24
+ * `listing-adapters.ts`, `productTableColumns.tsx`, `CompareOverlay.tsx`, and
25
+ * `rewriteProductChains`'s own `current[0]`.
26
+ *
27
+ * A set-shaped union of two chains destroys that invariant SILENTLY. Nothing
28
+ * throws; the order snapshot simply starts recording whichever id happened to
29
+ * land first, and the realised-price band moves with it. So the closure is
30
+ * ordered DEFAULT CHAIN FIRST and index 0 stays the default leaf — every one of
31
+ * those readers remains correct by construction rather than by being found.
32
+ *
33
+ * ── 🛑 ONE ASSIGNMENT PER ROOT ─────────────────────────────────────────────
34
+ *
35
+ * The user's rule, verbatim: *"a category can be multiple, they should not be
36
+ * under same tree."* It is also the only rule under which `categoryCounts`'
37
+ * invariant `own + sum(children.total) === total` still holds: a product
38
+ * assigned to both an ancestor and its descendant would be counted `own` at the
39
+ * ancestor AND contribute `total` up from the descendant, so the parent's two
40
+ * figures would overlap and no invariant could be asserted.
41
+ *
42
+ * @tag domain:products
43
+ * @tag layer:repository
44
+ * @tag pattern:none
45
+ * @tag access:server-only
46
+ * @tag consumers:ProductRepository.deriveTaxonomy
47
+ * @tag sideEffects:none
48
+ */
49
+ import { ConflictError, ValidationError } from "../../../errors/index.js";
50
+ /**
51
+ * 🛑 A CAP, because the closure is `array-contains`-indexed and Firestore's
52
+ * `array-contains-any` takes at most 30 comparison values.
53
+ *
54
+ * 4 assignments x 5 tiers (0-4) = 20 closure entries worst case, which leaves
55
+ * headroom under that 30. It also bounds the index-entry cost: the seed loader
56
+ * commits at most 20,000 array elements per batch, and every closure entry is
57
+ * one index entry per product.
58
+ */
59
+ export const MAX_CATEGORY_ASSIGNMENTS = 4;
60
+ /**
61
+ * 🛑 THE FLIP. `false` = the leaf/non-leaf rule REPORTS; `true` = it refuses.
62
+ *
63
+ * It ships `false` and is flipped only once the data is clean, and the reason is
64
+ * a measurement: **70 of the 71 hand-authored products file at a non-leaf
65
+ * today** and not one of them is a lot. Enforcing first would make the whole
66
+ * catalogue unsaveable — and worse, it would make the very seed writes that fix
67
+ * the data fail, because those also go through `deriveTaxonomy`.
68
+ *
69
+ * This is the G3a lesson, which cost a symmetric rule its credibility: the plan
70
+ * specified "a lot takes a non-leaf while singles take leaves", measurement
71
+ * rejected the second half, and the half-rule has been live since. The rule is
72
+ * right; enforcing it before its data is not.
73
+ *
74
+ * Flip procedure, and it is a ratchet rather than a judgement call: run
75
+ * `findLeafRuleOffenders` over every seeded row, confirm **0**, then set `true`.
76
+ * A hand count is not the measurement — the earlier one said 58 of 70 and this
77
+ * pass measured 70 of 71.
78
+ */
79
+ export const ENFORCE_LEAF_FILING = false;
80
+ /**
81
+ * The authored assignment list, from whichever field the caller used.
82
+ *
83
+ * Deliberately tolerant of all three existing shapes, because every one of them
84
+ * is live today and none should need editing to keep working:
85
+ *
86
+ * - `categoryIds: [a, b]` the new multi-select
87
+ * - `category: "a"` what `CategoryInlineSelect` writes, and what
88
+ * `productCreateSchema` declares
89
+ * - `categorySlugs: [a, ...]` the seed, and the admin `.passthrough()` route
90
+ *
91
+ * 🛑 The scalar still WINS over `categorySlugs`, unchanged from before this
92
+ * change and for the recorded reason: the seller edit form sends a stale
93
+ * derived chain alongside a fresh `category`, so preferring the chain resolved
94
+ * the OLD leaf and wrote it back over the user's choice.
95
+ *
96
+ * Duplicates are deduped rather than refused. A duplicate assignment is exactly
97
+ * equivalent to not having it, so there is no semantic question for the caller
98
+ * to answer — unlike a duplicate assembly component, which would change
99
+ * arithmetic.
100
+ */
101
+ export function readAssignments(input) {
102
+ const raw = Array.isArray(input.categoryIds) && input.categoryIds.length > 0
103
+ ? input.categoryIds
104
+ : input.category
105
+ ? [input.category]
106
+ : Array.isArray(input.categorySlugs) && input.categorySlugs.length > 0
107
+ ? /*
108
+ * Only the HEAD, never the whole array. `categorySlugs` is a derived
109
+ * closure: treating its ancestors as assignments would file a product
110
+ * at every tier of its own chain, which the one-per-root rule would
111
+ * then immediately refuse.
112
+ */
113
+ [input.categorySlugs[0]]
114
+ : [];
115
+ const seen = new Set();
116
+ const out = [];
117
+ for (const id of raw) {
118
+ const trimmed = typeof id === "string" ? id.trim() : "";
119
+ if (!trimmed || seen.has(trimmed))
120
+ continue;
121
+ seen.add(trimmed);
122
+ out.push(trimmed);
123
+ }
124
+ return out;
125
+ }
126
+ /** Which assignments break the leaf rule, and why. Reporting shape, no throw. */
127
+ export function findLeafRuleOffenders(resolved, isLot) {
128
+ const out = [];
129
+ for (const c of resolved) {
130
+ if (isLot) {
131
+ if (c.isLeaf === true)
132
+ out.push({ id: c.id, name: c.name, reason: "lot-at-leaf" });
133
+ continue;
134
+ }
135
+ if (c.isLeaf === true)
136
+ continue;
137
+ out.push({
138
+ id: c.id,
139
+ name: c.name,
140
+ reason: c.isLeaf === undefined ? "not-indexed" : "single-at-branch",
141
+ });
142
+ }
143
+ return out;
144
+ }
145
+ /**
146
+ * Refuse the shapes that are not allowed. Throws; never corrects.
147
+ *
148
+ * 🛑 REFUSED, never corrected — silently dropping a second assignment, or
149
+ * re-filing one tier up, decides a taxonomy question on the seller's behalf and
150
+ * leaves them a listing they can no longer find (Root Cause #76 inverted).
151
+ */
152
+ export function assertAssignmentShape(resolved, isLot, opts = {}) {
153
+ if (resolved.length > MAX_CATEGORY_ASSIGNMENTS) {
154
+ throw new ValidationError(`A listing can be filed under at most ${MAX_CATEGORY_ASSIGNMENTS} categories; this one names ${resolved.length}.`);
155
+ }
156
+ /*
157
+ * One per root. Grouped on `rootId` rather than on an ancestor walk, because
158
+ * `rootId` is already on every category document and a walk would cost a read
159
+ * per assignment on the write path.
160
+ *
161
+ * A row with no `rootId` is skipped rather than refused: like `isLeaf`, its
162
+ * absence means the tree has not been rebuilt, and refusing a write over our
163
+ * own stale derived field turns a maintenance gap into a seller-facing error.
164
+ */
165
+ const byRoot = new Map();
166
+ for (const c of resolved) {
167
+ if (!c.rootId)
168
+ continue;
169
+ const group = byRoot.get(c.rootId);
170
+ if (group)
171
+ group.push(c);
172
+ else
173
+ byRoot.set(c.rootId, [c]);
174
+ }
175
+ for (const group of byRoot.values()) {
176
+ if (group.length < 2)
177
+ continue;
178
+ const named = group.map((c) => `"${c.name ?? c.id}"`).join(" and ");
179
+ throw new ValidationError(`${named} are in the same category tree, so a listing cannot be filed under both. ` +
180
+ `Pick one, or pick the category above them that covers both.`);
181
+ }
182
+ const offenders = findLeafRuleOffenders(resolved, isLot);
183
+ if (offenders.length === 0)
184
+ return;
185
+ /*
186
+ * The lot half has been enforced since G3a and stays enforced regardless of
187
+ * the flag — it is the half measurement VALIDATED, and the argument is about
188
+ * the items rather than about counting: filing "12 assorted Burst beys" under
189
+ * `category-dragoon-g` asserts all twelve are Dragoon G.
190
+ */
191
+ const lotAtLeaf = offenders.find((o) => o.reason === "lot-at-leaf");
192
+ if (lotAtLeaf) {
193
+ throw new ValidationError(`A lot holds several different items, so it cannot be filed under "${lotAtLeaf.name ?? lotAtLeaf.id}", ` +
194
+ `which is a single-item category. Pick a broader category — its parent.`);
195
+ }
196
+ if (!opts.enforceLeafFiling) {
197
+ opts.warn?.("Product files at a non-leaf category — allowed today, refused once ENFORCE_LEAF_FILING flips", { offenders });
198
+ return;
199
+ }
200
+ const notIndexed = offenders.find((o) => o.reason === "not-indexed");
201
+ if (notIndexed) {
202
+ /*
203
+ * 409, not 400: the request is well formed and the seller is not confused —
204
+ * OUR category tree has not been rebuilt, so we cannot tell whether that
205
+ * node is a leaf. The correct next action is to rebuild the tree, not to
206
+ * fix a field. Same distinction as removing a booked lottery slot.
207
+ */
208
+ throw new ConflictError(`"${notIndexed.name ?? notIndexed.id}" has not been indexed yet, so we cannot tell whether it is a ` +
209
+ `single-item category. Rebuild the category tree and try again.`);
210
+ }
211
+ const first = offenders[0];
212
+ throw new ValidationError(`"${first.name ?? first.id}" has subcategories, so it describes a group rather than one item. ` +
213
+ `Pick the specific category this listing belongs to — or mark it as a lot if it holds several different items.`);
214
+ }
215
+ /**
216
+ * Which assignment owns the breadcrumb, the canonical chain and index 0.
217
+ *
218
+ * Three deterministic levels:
219
+ * 1. the DEEPEST assignment (highest `tier`);
220
+ * 2. tie -> the highest listing count, which the caller supplies;
221
+ * 3. still tied -> the order the seller picked.
222
+ *
223
+ * 🛑 "Most listings" CANNOT be the first criterion, and this is the one place
224
+ * the instruction had to be corrected. A franchise node spans every product
225
+ * type, so `category-beyblade` outranks `category-dragoon-g` on count for
226
+ * EVERY Beyblade product — which would put `Home > Franchises > Beyblade` above
227
+ * every listing and silently widen the realised-price band from "Dragoon G" to
228
+ * "all Beyblade", because that band is keyed on the snapshotted
229
+ * `categorySlugs[0]`.
230
+ *
231
+ * Depth decides because depth IS specificity, which is also the rule singles
232
+ * are filed by. A model leaf is tier 4 and a franchise leaf is tier 1, so the
233
+ * tie-break only ever runs between two equally specific assignments — exactly
234
+ * where "which is busier" is the right question.
235
+ *
236
+ * `counts` is consulted ONLY on a tie, so the common single-assignment case
237
+ * costs no extra read at all.
238
+ */
239
+ export function pickDefaultAssignment(resolved, counts) {
240
+ if (resolved.length <= 1)
241
+ return 0;
242
+ let best = 0;
243
+ for (let i = 1; i < resolved.length; i += 1) {
244
+ const a = resolved[i];
245
+ const b = resolved[best];
246
+ const tierA = a.tier ?? -1;
247
+ const tierB = b.tier ?? -1;
248
+ if (tierA > tierB) {
249
+ best = i;
250
+ continue;
251
+ }
252
+ if (tierA < tierB)
253
+ continue;
254
+ /*
255
+ * `undefined` is not 0 here either: a category with no counts row has not
256
+ * been counted, and defaulting it to 0 would let an uncounted node lose a
257
+ * tie it might have won. Absent on BOTH sides falls through to authored
258
+ * order, which is level 3.
259
+ */
260
+ const countA = counts?.get(a.id);
261
+ const countB = counts?.get(b.id);
262
+ if (countA !== undefined && (countB === undefined || countA > countB))
263
+ best = i;
264
+ }
265
+ return best;
266
+ }
267
+ /**
268
+ * The derived closure and the display names.
269
+ *
270
+ * `categorySlugs` = the default chain leaf-first, then each remaining chain
271
+ * leaf-first, deduped. See the header for why the order is load-bearing.
272
+ *
273
+ * `categoryNames` = the DEFAULT chain only. A union has no readable path, and
274
+ * the breadcrumb builder reverses this array to render root-first — so keeping
275
+ * it single-chain is what leaves every existing breadcrumb byte-identical.
276
+ */
277
+ export function buildTaxonomyClosure(resolved, defaultIdx) {
278
+ if (resolved.length === 0) {
279
+ return { categorySlugs: [], categoryNames: [], categoryIdNames: [] };
280
+ }
281
+ const def = resolved[defaultIdx] ?? resolved[0];
282
+ const ordered = [def, ...resolved.filter((c) => c.id !== def.id)];
283
+ const categorySlugs = [];
284
+ const seen = new Set();
285
+ for (const c of ordered) {
286
+ // Self first, root last — byte-identical to what the seed hand-writes and
287
+ // to what `rewriteProductChains` produces. Those three must agree.
288
+ for (const id of [c.id, ...c.parentIds.slice().reverse()]) {
289
+ if (!id || seen.has(id))
290
+ continue;
291
+ seen.add(id);
292
+ categorySlugs.push(id);
293
+ }
294
+ }
295
+ const categoryNames = [def.name, ...def.ancestorNames.slice().reverse()].filter((n) => !!n);
296
+ /*
297
+ * I5b — one display name per ASSIGNMENT, in the same order, so the breadcrumb
298
+ * row can offer the other categories as links without a second read on a
299
+ * cached page. Deliberately NOT their full chains: the alternate crumb shows
300
+ * the other categories, not N stacked trails.
301
+ */
302
+ const categoryIdNames = ordered.map((c) => c.name ?? c.id);
303
+ return { categorySlugs, categoryNames, categoryIdNames };
304
+ }