@mohasinac/appkit 4.45.0 → 4.46.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 (152) 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/CategoryDetailPageView.js +89 -14
  55. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  56. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  57. package/dist/features/categories/repository/categories.repository.js +168 -22
  58. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  59. package/dist/features/categories/repository/category-counts.js +224 -0
  60. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  61. package/dist/features/categories/schemas/category-counts.js +116 -0
  62. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  63. package/dist/features/categories/schemas/firestore.js +0 -1
  64. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  65. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  66. package/dist/features/corporate/schemas/index.d.ts +4 -4
  67. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  68. package/dist/features/orders/types/index.d.ts +30 -0
  69. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  70. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  71. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  72. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  73. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  74. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  75. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  76. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  77. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  78. package/dist/features/parts/schemas/firestore.js +181 -0
  79. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  80. package/dist/features/parts/utils/colourway-match.js +98 -0
  81. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  82. package/dist/features/parts/utils/part-breakdown.js +77 -0
  83. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  84. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  85. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  86. package/dist/features/products/components/ProductTabsShell.js +4 -1
  87. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  88. package/dist/features/products/repository/category-assignment.js +304 -0
  89. package/dist/features/products/repository/products.repository.d.ts +58 -1
  90. package/dist/features/products/repository/products.repository.js +255 -28
  91. package/dist/features/products/schemas/firestore.d.ts +147 -6
  92. package/dist/features/products/schemas/firestore.js +49 -0
  93. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  94. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  95. package/dist/features/seller/components/SellerProductsView.js +129 -3
  96. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  97. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  98. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  99. package/dist/features/tester/seed-data/authored/index.js +2 -0
  100. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  101. package/dist/index.d.ts +11 -16
  102. package/dist/index.js +26 -18
  103. package/dist/next/routing/route-map.d.ts +6 -2
  104. package/dist/next/routing/route-map.js +14 -1
  105. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  106. package/dist/providers/db-firebase/base.repository.js +31 -0
  107. package/dist/repositories/index.d.ts +1 -2
  108. package/dist/repositories/index.js +0 -1
  109. package/dist/seed/_helpers/category-content.d.ts +29 -0
  110. package/dist/seed/_helpers/category-content.js +98 -1
  111. package/dist/seed/_helpers/category-forest.js +104 -24
  112. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  113. package/dist/seed/_helpers/category-parts.js +4594 -0
  114. package/dist/seed/carousel-slides-seed-data.js +81 -8
  115. package/dist/seed/categories-seed-data.js +44 -6
  116. package/dist/seed/homepage-sections-seed-data.js +170 -9
  117. package/dist/seed/index.d.ts +2 -0
  118. package/dist/seed/index.js +13 -0
  119. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  120. package/dist/seed/model-composition-seed-data.js +8918 -0
  121. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  122. package/dist/seed/part-glossary-seed-data.js +10729 -0
  123. package/dist/seed/products-standard-seed-data.js +52 -0
  124. package/dist/seed/seed-purpose.d.ts +103 -4
  125. package/dist/seed/seed-purpose.js +162 -11
  126. package/dist/seo/json-ld.d.ts +10 -1
  127. package/dist/seo/json-ld.js +9 -1
  128. package/dist/server.d.ts +7 -0
  129. package/dist/server.js +11 -0
  130. package/dist/styles.css +1 -1
  131. package/dist/tailwind-utilities.css +1 -1
  132. package/dist/utils/id-generators.d.ts +30 -0
  133. package/dist/utils/id-generators.js +31 -1
  134. package/dist/utils/search-txt-builders.js +22 -0
  135. package/dist/utils/search-txt.d.ts +22 -0
  136. package/dist/utils/search-txt.js +17 -0
  137. package/dist/values/success-messages.d.ts +0 -4
  138. package/dist/values/success-messages.js +0 -5
  139. package/package.json +1 -1
  140. package/scripts/seed-cli.mjs +292 -16
  141. package/dist/core/copilot-log.repository.d.ts +0 -44
  142. package/dist/core/copilot-log.repository.js +0 -61
  143. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  144. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  145. package/dist/features/copilot/components/index.d.ts +0 -2
  146. package/dist/features/copilot/components/index.js +0 -1
  147. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  148. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  149. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  150. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  151. package/dist/features/copilot/index.d.ts +0 -3
  152. package/dist/features/copilot/index.js +0 -3
@@ -0,0 +1,37 @@
1
+ /**
2
+ * D12 — what a bey is MADE OF, rendered on its model page and its listings.
3
+ *
4
+ * The corpus knows the composition of 259 models down to the slot, with a
5
+ * per-part weight on 98 of them. Until this existed that data had no reader,
6
+ * and a buyer reading "Storm Pegasus 105RF" had no way to learn that `105` is
7
+ * a spin-track height and `RF` is a rubber flat tip.
8
+ *
9
+ * ── 🛑 THE SLOT SET IS DATA, AND IT MERGES ─────────────────────────────────
10
+ *
11
+ * Nothing here enumerates slots. A `Record<Generation, Slot[]>` cannot express
12
+ * what the corpus actually contains — `Spin Gear + Blade Base → RUNNING CORE`
13
+ * (HMS) and `SPIN TRACK + TIP combined: F:D` (4D) — so the rows are whatever
14
+ * the document carries, in the order it carries them, and `slotMerges` is
15
+ * rendered as a note rather than being flattened away. A system with a
16
+ * combined module genuinely has fewer rows, and padding it to a fixed five
17
+ * would claim a part that does not exist.
18
+ *
19
+ * ── 🛑 NO MONEY ON THIS PANEL ──────────────────────────────────────────────
20
+ *
21
+ * It renders on a PUBLIC page, so a price here would need `<GatedPrice>` and
22
+ * would make a server component a client one. A part's own page carries its
23
+ * listings; this panel carries facts about mouldings. There is nothing to gate
24
+ * because there is nothing to leak.
25
+ */
26
+ import React from "react";
27
+ import { type ModelCompositionDocument, type PartGlossaryDocument } from "../schemas/firestore";
28
+ export interface ModelCompositionPanelProps {
29
+ compositions: readonly ModelCompositionDocument[];
30
+ /** Glossary rows for the slots' `partId`s, so a row can link and show weight. */
31
+ parts?: Map<string, PartGlossaryDocument>;
32
+ /** `(slot, codeOrName)` → the part's page. Supplied by the consumer's route map. */
33
+ hrefForPart: (slot: string, tail: string) => string;
34
+ /** Heading level, so this sits correctly inside a tab or a page section. */
35
+ headingLevel?: 2 | 3 | 4;
36
+ }
37
+ export declare function ModelCompositionPanel({ compositions, parts, hrefForPart, headingLevel, }: ModelCompositionPanelProps): React.JSX.Element | null;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Stack, Row, Div, Heading, Text, Span, TextLink, Badge } from "../../../ui/index.js";
3
+ import { PART_SLOT_LABEL, } from "../schemas/firestore.js";
4
+ /** `performance-tip--rf` → `rf`. The id's tail IS the URL segment. */
5
+ function tailOf(partId) {
6
+ const i = partId.indexOf("--");
7
+ return i >= 0 ? partId.slice(i + 2) : partId;
8
+ }
9
+ /**
10
+ * One slot row.
11
+ *
12
+ * Extracted because `audit-code-quality` flagged the inline version at
13
+ * brace-depth 6, and it was right — "for each composition, for each slot, if
14
+ * the part resolved, if a weight exists" is four questions in one block, and
15
+ * the nesting is what hides the two judgements this row actually makes: which
16
+ * weight wins, and whether the corpus's own slot label adds anything.
17
+ */
18
+ function SlotRow({ slot, part, hrefForPart, }) {
19
+ // The slot's own figure wins over the glossary's: a composition records the
20
+ // weight of the part AS FITTED, and a glossary row records the mould.
21
+ const weight = slot.weightG ?? part?.weightG;
22
+ const canonical = PART_SLOT_LABEL[slot.slotKey] ?? slot.slotKey;
23
+ const showCorpusLabel = Boolean(slot.slotLabel) && slot.slotLabel.toLowerCase() !== canonical.toLowerCase();
24
+ return (_jsxs(Row, { gap: "md", align: "start", padding: "inline", surface: "card", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsxs(Div, { className: "w-36 flex-shrink-0", children: [_jsx(Text, { size: "xs", color: "muted", transform: "uppercase", children: canonical }), showCorpusLabel && (_jsx(Text, { size: "xs", color: "faint", children: slot.slotLabel }))] }), _jsxs(Div, { className: "flex-1 min-w-0", children: [slot.partId ? (_jsx(TextLink, { href: hrefForPart(slot.slotKey, tailOf(slot.partId)), children: slot.partName })) : (_jsx(Text, { color: "primary", children: slot.partName })), typeof weight === "number" && (_jsxs(Span, { size: "xs", color: "muted", children: [" ", "\u00B7 ", weight, " g"] }))] })] }));
25
+ }
26
+ export function ModelCompositionPanel({ compositions, parts, hrefForPart, headingLevel = 3, }) {
27
+ if (compositions.length === 0)
28
+ return null;
29
+ return (_jsx(Stack, { gap: "lg", className: "min-w-0", children: compositions.map((comp) => {
30
+ const slots = comp.slots ?? [];
31
+ const merges = comp.slotMerges ?? [];
32
+ return (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [compositions.length > 1 && (_jsx(Heading, { level: headingLevel, children: comp.name })), _jsxs(Row, { gap: "xs", wrap: true, align: "center", className: "min-w-0", children: [comp.system && _jsx(Badge, { variant: "default", children: comp.system }), comp.series && _jsx(Badge, { variant: "default", children: comp.series }), comp.productCode && _jsx(Badge, { variant: "info", children: comp.productCode })] }), slots.length === 0 ? (_jsx(Text, { color: "muted", size: "sm", children: "No part breakdown recorded for this model yet." })) : (_jsx(Stack, { gap: "xs", className: "min-w-0", children: slots.map((s, i) => (_jsx(SlotRow, { slot: s, part: s.partId ? parts?.get(s.partId) : undefined, hrefForPart: hrefForPart }, `${comp.id}-${s.slotKey}-${i}`))) })), merges.length > 0 && (_jsx(Text, { size: "xs", color: "muted", children: merges.map((m, i) => (_jsxs(Span, { children: [i > 0 ? " · " : "", m.merges.map((x) => PART_SLOT_LABEL[x] ?? x).join(" + "), " are one moulded module here (", PART_SLOT_LABEL[m.into] ?? m.into, ")."] }, i))) }))] }, comp.id));
33
+ }) }));
34
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * D12 — one part's public page.
3
+ *
4
+ * The question it answers is *"what does RF mean on a Beyblade"*, which is a
5
+ * real search with a real answer nobody in the fifteen-site crawl owns. It
6
+ * also answers the commerce question beside it: raikages is the ONLY source in
7
+ * the crawl with component-level pricing (energy rings ₹149–249, tips
8
+ * ₹129–149), and they sell parts without ever explaining them.
9
+ *
10
+ * ── 🛑 NO PRICES ON THIS PAGE, DELIBERATELY ────────────────────────────────
11
+ *
12
+ * Prices are hidden from signed-out visitors, so every amount on a public
13
+ * surface needs `<GatedPrice>` — a client component. The listing rows here
14
+ * carry a title, a condition and a link; the price lives one click away on the
15
+ * listing itself, where the gate already is. Same rule as `/search`: a row
16
+ * with nothing to gate has nothing to leak, and this stays a server component.
17
+ *
18
+ * ── 🛑 "USED BY" IS A STORED ARRAY, NOT A QUERY ────────────────────────────
19
+ *
20
+ * `usedBy` is written by the seed generator, so the model list is one
21
+ * `getAll` over ids already in hand — no index, no scan, bounded by how many
22
+ * models really use the part. 681 of 753 parts carry one.
23
+ */
24
+ import React from "react";
25
+ import { type PartGlossaryDocument, type ModelCompositionDocument } from "../schemas/firestore";
26
+ /** One live listing row. Deliberately money-free — see the header. */
27
+ export interface PartListingRow {
28
+ id: string;
29
+ title: string;
30
+ slug?: string;
31
+ condition?: string;
32
+ href: string;
33
+ }
34
+ export interface PartDetailPageViewProps {
35
+ part: PartGlossaryDocument;
36
+ /** Models that use it, hydrated from `usedBy`. */
37
+ usedBy: readonly ModelCompositionDocument[];
38
+ /** Live listings matching the part, from the shared public product query. */
39
+ listings: readonly PartListingRow[];
40
+ /** `total` is a FLOOR when the bounded fetch saturated. */
41
+ listingsTruncated?: boolean;
42
+ /** Where "see all listings" goes — a real search, not a fabricated filter. */
43
+ allListingsHref: string;
44
+ /** A model's own page, when the composition resolved to a category. */
45
+ hrefForModel: (categoryId: string) => string;
46
+ hrefForClassification: (slot: string) => string;
47
+ }
48
+ export declare function PartDetailPageView({ part, usedBy, listings, listingsTruncated, allListingsHref, hrefForModel, hrefForClassification, }: PartDetailPageViewProps): React.JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Section, Container, Stack, Row, Div, Heading, Text, Span, TextLink, Badge, } from "../../../ui/index.js";
3
+ import { PART_SLOT_LABEL, } from "../schemas/firestore.js";
4
+ export function PartDetailPageView({ part, usedBy, listings, listingsTruncated, allListingsHref, hrefForModel, hrefForClassification, }) {
5
+ const classification = PART_SLOT_LABEL[part.slot] ?? part.classification ?? part.slot;
6
+ /*
7
+ * 🛑 Only 23 of 753 parts carry a code, and that is a fact about the sources
8
+ * rather than missing data. So the code renders as a badge when present and
9
+ * nothing stands in for it when absent — an em-dash beside "Code" would
10
+ * imply we looked it up and found none.
11
+ */
12
+ const aliases = (part.aliases ?? []).filter((a) => a && a !== part.name && a !== part.code);
13
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Text, { size: "sm", color: "muted", children: _jsx(TextLink, { href: hrefForClassification(part.slot), children: classification }) }), _jsx(Heading, { level: 1, children: part.name }), _jsxs(Row, { gap: "xs", wrap: true, align: "center", className: "min-w-0", children: [part.code && _jsx(Badge, { variant: "info", children: part.code }), typeof part.weightG === "number" && (_jsxs(Badge, { variant: "default", children: [part.weightG, " g"] }))] }), aliases.length > 0 && (_jsxs(Text, { size: "sm", color: "muted", children: ["Also written: ", aliases.join(" · ")] }))] }), _jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: usedBy.length > 0
14
+ ? `Used by ${usedBy.length} ${usedBy.length === 1 ? "model" : "models"}`
15
+ : "Used by" }), usedBy.length === 0 ? (_jsx(Text, { color: "muted", size: "sm", children: "No model in our database records this part yet." })) : (_jsx(Stack, { gap: "xs", className: "min-w-0", children: usedBy.map((m) => (_jsxs(Row, { gap: "sm", align: "center", padding: "inline", surface: "card", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [m.categoryId ? (_jsx(TextLink, { href: hrefForModel(m.categoryId), children: m.name })) : (
16
+ /* 49 of 259 compositions did not join to a category.
17
+ A link to a page that does not exist is worse than
18
+ plain text, so the name renders unlinked. */
19
+ _jsx(Text, { color: "primary", children: m.name })), m.system && (_jsxs(Span, { size: "xs", color: "muted", children: [" ", "\u00B7 ", m.system] }))] }), m.productCode && (_jsx(Text, { size: "xs", color: "faint", className: "flex-shrink-0", children: m.productCode }))] }, m.id))) }))] }), _jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsxs(Row, { justify: "between", align: "center", wrap: true, gap: "sm", className: "min-w-0", children: [_jsxs(Heading, { level: 2, size: "lg", children: ["Listings", listingsTruncated && (_jsxs(Span, { size: "sm", color: "muted", children: [" ", "(", listings.length, "+)"] }))] }), listings.length > 0 && (_jsx(TextLink, { href: allListingsHref, children: "See all listings \u2192" }))] }), listings.length === 0 ? (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Text, { color: "muted", size: "sm", children: "Nobody is selling this part right now." }), _jsx(TextLink, { href: allListingsHref, children: "Search the catalogue \u2192" })] })) : (_jsx(Stack, { gap: "xs", className: "min-w-0", children: listings.map((l) => (_jsxs(Div, { surface: "card", padding: "sm", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsx(TextLink, { href: l.href, children: l.title }), l.condition && (_jsx(Text, { size: "xs", color: "muted", children: l.condition }))] }, l.id))) }))] }), part.source && (_jsxs(Text, { size: "xs", color: "faint", children: ["Source: ", part.source] }))] }) }) }));
20
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * D12 — the parts database index, and one classification's listing.
3
+ *
4
+ * ── 🛑 THE INDEX COSTS ZERO FIRESTORE READS ────────────────────────────────
5
+ *
6
+ * The 20 classifications come from `ALL_PART_SLOTS` + `PART_SLOT_LABEL`, both
7
+ * compile-checked, so the index is static. The tempting alternative — showing
8
+ * a count per classification — is one query per slot, or a 753-document scan,
9
+ * on a page that exists only to route. Rule #6 budgets ~3 round trips for a
10
+ * whole render; 20 is not a rounding error.
11
+ *
12
+ * A count would also be the thing most likely to be wrong later: it is a
13
+ * denormalised mirror of a collection the seed rewrites (Root Cause #42), and
14
+ * the honest version of "we don't know how many" is to not say a number.
15
+ *
16
+ * The classification page below DOES read — one equality, bounded at 120,
17
+ * which clears the largest real bucket (87 attack rings, measured).
18
+ */
19
+ import React from "react";
20
+ import { type PartGlossaryDocument, type PartSlot } from "../schemas/firestore";
21
+ export interface PartsIndexPageViewProps {
22
+ hrefForClassification: (slot: string) => string;
23
+ }
24
+ export declare function PartsIndexPageView({ hrefForClassification }: PartsIndexPageViewProps): React.JSX.Element;
25
+ export interface PartClassificationPageViewProps {
26
+ slot: PartSlot;
27
+ parts: readonly PartGlossaryDocument[];
28
+ /** The bounded fetch saturated, so the count renders as a floor. */
29
+ truncated?: boolean;
30
+ hrefForPart: (slot: string, tail: string) => string;
31
+ partsIndexHref: string;
32
+ }
33
+ export declare function PartClassificationPageView({ slot, parts, truncated, hrefForPart, partsIndexHref, }: PartClassificationPageViewProps): React.JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Section, Container, Stack, Row, Div, Grid, Heading, Text, Span, TextLink, Badge, } from "../../../ui/index.js";
3
+ import { ALL_PART_SLOTS, PART_SLOT_LABEL, } from "../schemas/firestore.js";
4
+ export function PartsIndexPageView({ hrefForClassification }) {
5
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 1, children: "Parts database" }), _jsxs(Text, { color: "muted", children: ["Every component we have a record of, by what it is. A code like", " ", _jsx(Span, { weight: "semibold", children: "RF" }), " or ", _jsx(Span, { weight: "semibold", children: "105" }), " means different things in different generations, so parts are grouped by their classification rather than by code."] })] }), _jsx(Grid, { cols: "navTiles", gap: "sm", className: "min-w-0", children: ALL_PART_SLOTS.map((slot) => (_jsx(Div, { surface: "card", padding: "sm", rounded: "md", border: "subtle", className: "min-w-0", children: _jsx(TextLink, { href: hrefForClassification(slot), children: PART_SLOT_LABEL[slot] }) }, slot))) })] }) }) }));
6
+ }
7
+ /** `performance-tip--rf` → `rf`. The id's tail IS the URL segment. */
8
+ function tailOf(partId) {
9
+ const i = partId.indexOf("--");
10
+ return i >= 0 ? partId.slice(i + 2) : partId;
11
+ }
12
+ export function PartClassificationPageView({ slot, parts, truncated, hrefForPart, partsIndexHref, }) {
13
+ const label = PART_SLOT_LABEL[slot] ?? slot;
14
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", color: "muted", children: _jsx(TextLink, { href: partsIndexHref, children: "Parts" }) }), _jsx(Heading, { level: 1, children: label }), _jsx(Text, { color: "muted", size: "sm", children: parts.length === 0
15
+ ? "No parts recorded in this classification yet."
16
+ : `${parts.length}${truncated ? "+" : ""} recorded.` })] }), parts.length > 0 && (_jsx(Stack, { gap: "xs", className: "min-w-0", children: parts.map((p) => (_jsxs(Row, { gap: "sm", align: "center", padding: "inline", surface: "card", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [_jsx(TextLink, { href: hrefForPart(p.slot, tailOf(p.id)), children: p.name }), typeof p.weightG === "number" && (_jsxs(Span, { size: "xs", color: "muted", children: [" ", "\u00B7 ", p.weightG, " g"] }))] }), p.code && (_jsx(Badge, { variant: "info", className: "flex-shrink-0", children: p.code }))] }, p.id))) }))] }) }) }));
17
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * E4/D12 — the PART GLOSSARY and MODEL COMPOSITION collections.
3
+ *
4
+ * Seeded from `docs/research/part-composition-2026-10/`, which is extracted
5
+ * from the local corpus by two committed scripts. Nothing here is hand-typed.
6
+ *
7
+ * ── 🛑 KEYED ON (classification, code), NEVER ON THE CODE ALONE ────────────
8
+ *
9
+ * `105` is a Metal Fight spin-track height, a bare number in Burst is a disc,
10
+ * and `1-60` is a Beyblade X ratchet. Code-alone would explain a Burst disc as
11
+ * a spin track — the same namespacing argument as the feature `group` field,
12
+ * and the reason the document id carries both halves.
13
+ *
14
+ * ── 🛑 WHY `modelComposition` IS A SIBLING, NOT A FIELD ON `categories` ────
15
+ *
16
+ * A named model IS a category leaf (section 4.4), so composition "belongs" to that
17
+ * document — and putting it there is still wrong for two measured reasons.
18
+ * `handleCategoryWrite` calls `notifyRevalidate` as its FIRST statement,
19
+ * unconditionally, so any write to a category drops that page's ISR entry on a
20
+ * project paused at 15.5× its ISR cap. And `CategoryDetailPageView` is already
21
+ * at Rule #6's read ceiling with seven parallel reads. Same conclusion as
22
+ * `categoryCounts` and `priceIndex`, for the same reasons.
23
+ *
24
+ * ── 🛑 THE SLOT SET IS DATA, KEYED ON `system` ─────────────────────────────
25
+ *
26
+ * Slots MERGE: the corpus contains `Spin Gear + Blade Base → RUNNING CORE`
27
+ * (HMS) and `SPIN TRACK + TIP combined: F:D` (4D), 13 such lines extracted. So
28
+ * a fixed field-set per generation cannot express the data, and a
29
+ * `Record<Generation, Slot[]>` would be the hand-written enumeration of Root
30
+ * Cause #61 with a merge case it structurally cannot hold.
31
+ */
32
+ import type { BaseDocument } from "../../../_internal/shared/types/base-document";
33
+ export declare const PART_GLOSSARY_COLLECTION: "partGlossary";
34
+ export declare const MODEL_COMPOSITION_COLLECTION: "modelComposition";
35
+ /**
36
+ * The canonical slot keys, as extracted. Mirrors `SLOT_VOCAB` in
37
+ * `scripts/extract-part-composition.mjs` — the script is the source and this
38
+ * is the type, so a slot that appears in one and not the other is a
39
+ * compile-visible gap rather than a silent one.
40
+ */
41
+ export declare const PART_SLOT_VALUES: {
42
+ readonly BIT_CHIP: "bit-chip";
43
+ readonly ATTACK_RING: "attack-ring";
44
+ readonly WEIGHT_DISK: "weight-disk";
45
+ readonly SPIN_GEAR: "spin-gear";
46
+ readonly ENGINE_GEAR: "engine-gear";
47
+ readonly BLADE_BASE: "blade-base";
48
+ readonly RUNNING_CORE: "running-core";
49
+ readonly FACE_BOLT: "face-bolt";
50
+ readonly ENERGY_RING: "energy-ring";
51
+ readonly FUSION_WHEEL: "fusion-wheel";
52
+ readonly CHROME_WHEEL: "chrome-wheel";
53
+ readonly SPIN_TRACK: "spin-track";
54
+ readonly PERFORMANCE_TIP: "performance-tip";
55
+ readonly LAYER: "layer";
56
+ readonly DISC: "disc";
57
+ readonly DRIVER: "driver";
58
+ readonly ARMOUR: "armour";
59
+ readonly BLADE: "blade";
60
+ readonly RATCHET: "ratchet";
61
+ readonly BIT: "bit";
62
+ };
63
+ export type PartSlot = (typeof PART_SLOT_VALUES)[keyof typeof PART_SLOT_VALUES];
64
+ /**
65
+ * Buyer-facing label per slot.
66
+ *
67
+ * 🛑 A `Record<PartSlot, string>`, so a 21st slot is a COMPILE ERROR rather
68
+ * than a page heading that reads `performance-tip`. That is the whole reason
69
+ * it is not derived by title-casing the key: "Armour" is not "armour"
70
+ * title-cased in the corpus, and `bit-chip` is "Bit Chip" rather than
71
+ * "Bit-chip".
72
+ *
73
+ * Every label here was read off the live documents' own `classification`
74
+ * field, not invented — all 20 slots are populated (87 attack rings down to
75
+ * 4 armour pieces), so none of these is a guess about an empty bucket.
76
+ */
77
+ export declare const PART_SLOT_LABEL: Record<PartSlot, string>;
78
+ /**
79
+ * Which CATEGORY node a part of this classification is filed under.
80
+ *
81
+ * ── 🛑 A PART IS A PLAIN CATEGORY LEAF, `categoryType` OMITTED ──────────────
82
+ *
83
+ * The user's instruction was *"treat parts as also categories, let admins
84
+ * create or add more of them"*, and this map is how both halves are answered at
85
+ * once: a part leaf under these nodes gets `deriveTaxonomy`'s ancestor chain,
86
+ * `countersReconcile`'s tally, the sitemap, breadcrumbs, SEO, an OG image and
87
+ * **the existing admin category editor** — create, edit and delete included —
88
+ * for no new code.
89
+ *
90
+ * 🛑 `categoryType` is OMITTED rather than given a new `"part"` value, and that
91
+ * is the decisive design point. 92 files read `categoryType`, and section 4.4
92
+ * records that `categoryType: "sublisting"` carries SEVEN live defects
93
+ * precisely because a new discriminator value opts out of every path that
94
+ * works — `deriveTaxonomy` never reads it, `countersReconcile` leaves its count
95
+ * permanently 0, and the sitemap excludes it by name. An omitted type changes
96
+ * none of those 92 files and inherits every one of those paths.
97
+ *
98
+ * ── 🛑 KEYED ON THE SLOT ALONE, NOT ON (slot, generation) ───────────────────
99
+ *
100
+ * The first version derived a part's generation from its `usedBy` models, which
101
+ * measured **681 of 753 placeable, 72 unplaceable and 1 spanning two
102
+ * generations**. That was solving a problem that does not exist: each of the 20
103
+ * classification names belongs to exactly ONE generation's vocabulary. A
104
+ * "Performance Tip" is Metal-era by definition, a "Driver" is Burst, a "Bit" is
105
+ * X and a "Bit Chip" is Plastic Gen — the words are not shared. Keying on the
106
+ * slot places **all 753** and cannot be wrong about a part whose `usedBy` is
107
+ * thin.
108
+ *
109
+ * A `Record<PartSlot, string>`, so a 21st classification cannot compile without
110
+ * being given a home — and a part leaf with no parent is a tier-0 orphan, which
111
+ * is the live defect the store sublisting POST still has.
112
+ */
113
+ export declare const PART_SLOT_CATEGORY: Record<PartSlot, string>;
114
+ /** Every slot key, for an index page that must not hand-write the list. */
115
+ export declare const ALL_PART_SLOTS: readonly PartSlot[];
116
+ /**
117
+ * A URL segment to a slot, or `null`.
118
+ *
119
+ * 🛑 Returns `null` rather than casting. A bare `as PartSlot` on a URL segment
120
+ * is how `/parts/nonsense/x` becomes a doc read for `nonsense--x` that quietly
121
+ * misses — the route must 404, which it can only do if the guard says so.
122
+ */
123
+ export declare function partSlotFromSegment(segment: string): PartSlot | null;
124
+ /** One part. */
125
+ export interface PartGlossaryDocument extends BaseDocument {
126
+ /** `${slot}--${codeOrSlug}` — both halves, because a code is not unique. */
127
+ id: string;
128
+ /** Buyer-facing label: "Performance Tip", "Fusion Wheel". */
129
+ classification: string;
130
+ /** The canonical slot key this classification maps to. */
131
+ slot: PartSlot;
132
+ /**
133
+ * The short code as printed on the part. `null` for a part the corpus knows
134
+ * only by name.
135
+ *
136
+ * 🛑 `null`, not `""`. An empty string reads as a declared empty code; 736
137
+ * of 759 parts genuinely have no code in any source we hold, and that is a
138
+ * fact about the sources rather than missing data.
139
+ */
140
+ code: string | null;
141
+ /** The spoken name — what a buyer types. */
142
+ name: string;
143
+ /** Grams, when a source confirmed one. 97 of 759 do. */
144
+ weightG?: number;
145
+ /**
146
+ * Model ids that use this part — the single most useful thing a part page
147
+ * says, and the reason the glossary is worth having as a collection rather
148
+ * than a lookup table.
149
+ */
150
+ usedBy?: string[];
151
+ /** Code, name and the spoken form of the code — feeds `searchTxt`. */
152
+ aliases?: string[];
153
+ /** Which extractor source contributed the row. */
154
+ source?: string;
155
+ searchTxt?: string[];
156
+ }
157
+ /** One slot of one model's composition. */
158
+ export interface ModelSlot {
159
+ slotKey: PartSlot;
160
+ /** The corpus's own term for the slot, e.g. "METAL ATTACK RING (MAR)". */
161
+ slotLabel: string;
162
+ partName: string;
163
+ weightG?: number;
164
+ /** The `partGlossary` id, when the part resolved to a glossary row. */
165
+ partId?: string;
166
+ /**
167
+ * I9 — which COLOURWAY of that part this model needs.
168
+ *
169
+ * ── 🛑 THE CASE THIS EXISTS FOR, in the user's own words ────────────────
170
+ *
171
+ * *"there is a part called metal ball, it comes with 2 beyblades — Poison
172
+ * Zurafa S130MB and L-Drago Guardian S130MB. But the L-Drago version is very
173
+ * rare and costly and is in silver, while the Zurafa one is transparent black
174
+ * with a metal tip. Both behave the same way and have the same design, but to
175
+ * make an L-Drago bey I need the silver one and for Zurafa the black
176
+ * transparent one. Same with their S130s."*
177
+ *
178
+ * So: ONE glossary row (`performance-tip--mb`), same mould, same weight, same
179
+ * stats — and TWO physical variants that are NOT interchangeable and differ
180
+ * wildly in value. No stat distinguishes them.
181
+ *
182
+ * ── 🛑 WHY THIS FIELD RATHER THAN MATCHING ON `partId` ALONE ────────────
183
+ *
184
+ * The "buildable now" rollup would otherwise report *"you can build an
185
+ * L-Drago Guardian S130MB"* while the only MB on the shelf is the Zurafa
186
+ * black. A confident, specific, wrong answer with no error anywhere — and the
187
+ * seller discovers it holding the wrong part. Matching has to be
188
+ * `(partId, colourway)`.
189
+ *
190
+ * ── 🛑 UNSET MEANS "ANY", AND NEVER "THE FIRST ONE FOUND" ───────────────
191
+ *
192
+ * Most slots are genuinely colour-agnostic. Forcing a value everywhere would
193
+ * invent a constraint the corpus does not support, and defaulting to whatever
194
+ * happened to be in the bin is the same class of guess the price index
195
+ * refuses when its sample is too small.
196
+ */
197
+ expectedColourway?: string;
198
+ }
199
+ /**
200
+ * Two slots collapsing into one, in systems where they are a single moulded
201
+ * module.
202
+ *
203
+ * 🛑 This is why the slot set cannot be a per-generation constant: HMS has a
204
+ * `RUNNING CORE` where Plastic Gen has a separate Spin Gear and Blade Base,
205
+ * and 4D has a combined `4D Bottom` where MFB has a Spin Track and a tip.
206
+ */
207
+ export interface ModelSlotMerge {
208
+ into: PartSlot;
209
+ merges: PartSlot[];
210
+ }
211
+ export interface ModelCompositionDocument extends BaseDocument {
212
+ /** The corpus doc id, e.g. `gen2-mfb-big-bang-pegasus`. */
213
+ id: string;
214
+ name: string;
215
+ /** `gen1` … `gen4`, normalised — never the raw freeform header. */
216
+ generation: string | null;
217
+ /**
218
+ * The parenthetical from the raw header, kept because it is often the
219
+ * interesting part — but never a value anything groups or filters on
220
+ * (Root Cause #33).
221
+ */
222
+ generationNote?: string;
223
+ system?: string | null;
224
+ series?: string | null;
225
+ productCode?: string | null;
226
+ slots: ModelSlot[];
227
+ slotMerges?: ModelSlotMerge[];
228
+ /** The corpus file, so a claim can be traced back. */
229
+ source?: string;
230
+ /** The named-model category this composition describes, when one exists. */
231
+ categoryId?: string;
232
+ searchTxt?: string[];
233
+ }
234
+ /** `(slot, code|name)` → document id. The ONE place this is composed. */
235
+ export declare function partGlossaryId(slot: PartSlot, codeOrName: string): string;
236
+ /**
237
+ * Whether a listing of this TYPE is the physical assembly the composition
238
+ * describes.
239
+ *
240
+ * 🛑 Found by measuring rather than by reasoning, and it is a real defect the
241
+ * first version of D12 shipped: of 71 live products exactly ONE files under a
242
+ * leaf that has a composition, and that one is
243
+ * `art-dranzer-phoenix-poster` — so the only listing in the whole catalogue
244
+ * that got a "Parts" tab was a POSTER, offering an attack ring, a weight disk
245
+ * and a blade base as its contents. Nothing errored; it rendered a correct
246
+ * part list for the wrong kind of object.
247
+ *
248
+ * The distinction is whether the listing IS the model or merely DEPICTS or
249
+ * REFERENCES it. A print, a sticker sheet and a redemption code all sit under
250
+ * the model's leaf on purpose (section 4.4: a sticker sheet belongs beside the
251
+ * bey it is for) and none of them is made of its parts.
252
+ *
253
+ * 🛑 A `Record<ListingType, boolean>`, so a tenth listing type cannot compile
254
+ * without answering the question — the lesson of Root Cause #61, which this
255
+ * codebase has paid for ten hand-written enumerations of one union.
256
+ */
257
+ export declare const LISTING_TYPE_HAS_COMPOSITION: Record<import("../../products/types/index").ListingType, boolean>;
@@ -0,0 +1,181 @@
1
+ export const PART_GLOSSARY_COLLECTION = "partGlossary";
2
+ export const MODEL_COMPOSITION_COLLECTION = "modelComposition";
3
+ /**
4
+ * The canonical slot keys, as extracted. Mirrors `SLOT_VOCAB` in
5
+ * `scripts/extract-part-composition.mjs` — the script is the source and this
6
+ * is the type, so a slot that appears in one and not the other is a
7
+ * compile-visible gap rather than a silent one.
8
+ */
9
+ export const PART_SLOT_VALUES = {
10
+ BIT_CHIP: "bit-chip",
11
+ ATTACK_RING: "attack-ring",
12
+ WEIGHT_DISK: "weight-disk",
13
+ SPIN_GEAR: "spin-gear",
14
+ ENGINE_GEAR: "engine-gear",
15
+ BLADE_BASE: "blade-base",
16
+ RUNNING_CORE: "running-core",
17
+ FACE_BOLT: "face-bolt",
18
+ ENERGY_RING: "energy-ring",
19
+ FUSION_WHEEL: "fusion-wheel",
20
+ CHROME_WHEEL: "chrome-wheel",
21
+ SPIN_TRACK: "spin-track",
22
+ PERFORMANCE_TIP: "performance-tip",
23
+ LAYER: "layer",
24
+ DISC: "disc",
25
+ DRIVER: "driver",
26
+ ARMOUR: "armour",
27
+ BLADE: "blade",
28
+ RATCHET: "ratchet",
29
+ BIT: "bit",
30
+ };
31
+ /**
32
+ * Buyer-facing label per slot.
33
+ *
34
+ * 🛑 A `Record<PartSlot, string>`, so a 21st slot is a COMPILE ERROR rather
35
+ * than a page heading that reads `performance-tip`. That is the whole reason
36
+ * it is not derived by title-casing the key: "Armour" is not "armour"
37
+ * title-cased in the corpus, and `bit-chip` is "Bit Chip" rather than
38
+ * "Bit-chip".
39
+ *
40
+ * Every label here was read off the live documents' own `classification`
41
+ * field, not invented — all 20 slots are populated (87 attack rings down to
42
+ * 4 armour pieces), so none of these is a guess about an empty bucket.
43
+ */
44
+ export const PART_SLOT_LABEL = {
45
+ "bit-chip": "Bit Chip",
46
+ "attack-ring": "Attack Ring",
47
+ "weight-disk": "Weight Disk",
48
+ "spin-gear": "Spin Gear",
49
+ "engine-gear": "Engine Gear",
50
+ "blade-base": "Blade Base",
51
+ "running-core": "Running Core",
52
+ "face-bolt": "Face Bolt",
53
+ "energy-ring": "Energy Ring",
54
+ "fusion-wheel": "Fusion Wheel",
55
+ "chrome-wheel": "Chrome Wheel",
56
+ "spin-track": "Spin Track",
57
+ "performance-tip": "Performance Tip",
58
+ layer: "Layer",
59
+ disc: "Disc",
60
+ driver: "Driver",
61
+ armour: "Armour",
62
+ blade: "Blade",
63
+ ratchet: "Ratchet",
64
+ bit: "Bit",
65
+ };
66
+ /**
67
+ * Which CATEGORY node a part of this classification is filed under.
68
+ *
69
+ * ── 🛑 A PART IS A PLAIN CATEGORY LEAF, `categoryType` OMITTED ──────────────
70
+ *
71
+ * The user's instruction was *"treat parts as also categories, let admins
72
+ * create or add more of them"*, and this map is how both halves are answered at
73
+ * once: a part leaf under these nodes gets `deriveTaxonomy`'s ancestor chain,
74
+ * `countersReconcile`'s tally, the sitemap, breadcrumbs, SEO, an OG image and
75
+ * **the existing admin category editor** — create, edit and delete included —
76
+ * for no new code.
77
+ *
78
+ * 🛑 `categoryType` is OMITTED rather than given a new `"part"` value, and that
79
+ * is the decisive design point. 92 files read `categoryType`, and section 4.4
80
+ * records that `categoryType: "sublisting"` carries SEVEN live defects
81
+ * precisely because a new discriminator value opts out of every path that
82
+ * works — `deriveTaxonomy` never reads it, `countersReconcile` leaves its count
83
+ * permanently 0, and the sitemap excludes it by name. An omitted type changes
84
+ * none of those 92 files and inherits every one of those paths.
85
+ *
86
+ * ── 🛑 KEYED ON THE SLOT ALONE, NOT ON (slot, generation) ───────────────────
87
+ *
88
+ * The first version derived a part's generation from its `usedBy` models, which
89
+ * measured **681 of 753 placeable, 72 unplaceable and 1 spanning two
90
+ * generations**. That was solving a problem that does not exist: each of the 20
91
+ * classification names belongs to exactly ONE generation's vocabulary. A
92
+ * "Performance Tip" is Metal-era by definition, a "Driver" is Burst, a "Bit" is
93
+ * X and a "Bit Chip" is Plastic Gen — the words are not shared. Keying on the
94
+ * slot places **all 753** and cannot be wrong about a part whose `usedBy` is
95
+ * thin.
96
+ *
97
+ * A `Record<PartSlot, string>`, so a 21st classification cannot compile without
98
+ * being given a home — and a part leaf with no parent is a tier-0 orphan, which
99
+ * is the live defect the store sublisting POST still has.
100
+ */
101
+ export const PART_SLOT_CATEGORY = {
102
+ // Plastic Generation / HMS — Takara, pre-2006.
103
+ "attack-ring": "category-original-attack-rings",
104
+ "weight-disk": "category-original-weight-disks",
105
+ "blade-base": "category-original-blade-bases",
106
+ "bit-chip": "category-original-bit-chips",
107
+ "spin-gear": "category-original-spin-gears",
108
+ "engine-gear": "category-original-engine-gears",
109
+ "running-core": "category-original-running-cores",
110
+ // Metal Fight / 4D.
111
+ "face-bolt": "category-metal-face-bolts",
112
+ "energy-ring": "category-metal-energy-rings",
113
+ "fusion-wheel": "category-metal-fusion-wheels",
114
+ "chrome-wheel": "category-metal-chrome-wheels",
115
+ "spin-track": "category-metal-spin-tracks",
116
+ "performance-tip": "category-metal-performance-tips",
117
+ // Burst.
118
+ layer: "category-burst-layers",
119
+ disc: "category-burst-discs",
120
+ driver: "category-burst-drivers",
121
+ armour: "category-burst-chips-armour",
122
+ // Beyblade X.
123
+ blade: "category-x-blades",
124
+ ratchet: "category-x-ratchets",
125
+ bit: "category-x-bits",
126
+ };
127
+ /** Every slot key, for an index page that must not hand-write the list. */
128
+ export const ALL_PART_SLOTS = Object.values(PART_SLOT_VALUES);
129
+ /**
130
+ * A URL segment to a slot, or `null`.
131
+ *
132
+ * 🛑 Returns `null` rather than casting. A bare `as PartSlot` on a URL segment
133
+ * is how `/parts/nonsense/x` becomes a doc read for `nonsense--x` that quietly
134
+ * misses — the route must 404, which it can only do if the guard says so.
135
+ */
136
+ export function partSlotFromSegment(segment) {
137
+ const hit = ALL_PART_SLOTS.find((s) => s === segment);
138
+ return hit ?? null;
139
+ }
140
+ /** `(slot, code|name)` → document id. The ONE place this is composed. */
141
+ export function partGlossaryId(slot, codeOrName) {
142
+ const tail = codeOrName
143
+ .toLowerCase()
144
+ .replace(/[^a-z0-9]+/g, "-")
145
+ .replace(/^-+|-+$/g, "");
146
+ return `${slot}--${tail}`;
147
+ }
148
+ /**
149
+ * Whether a listing of this TYPE is the physical assembly the composition
150
+ * describes.
151
+ *
152
+ * 🛑 Found by measuring rather than by reasoning, and it is a real defect the
153
+ * first version of D12 shipped: of 71 live products exactly ONE files under a
154
+ * leaf that has a composition, and that one is
155
+ * `art-dranzer-phoenix-poster` — so the only listing in the whole catalogue
156
+ * that got a "Parts" tab was a POSTER, offering an attack ring, a weight disk
157
+ * and a blade base as its contents. Nothing errored; it rendered a correct
158
+ * part list for the wrong kind of object.
159
+ *
160
+ * The distinction is whether the listing IS the model or merely DEPICTS or
161
+ * REFERENCES it. A print, a sticker sheet and a redemption code all sit under
162
+ * the model's leaf on purpose (section 4.4: a sticker sheet belongs beside the
163
+ * bey it is for) and none of them is made of its parts.
164
+ *
165
+ * 🛑 A `Record<ListingType, boolean>`, so a tenth listing type cannot compile
166
+ * without answering the question — the lesson of Root Cause #61, which this
167
+ * codebase has paid for ten hand-written enumerations of one union.
168
+ */
169
+ export const LISTING_TYPE_HAS_COMPOSITION = {
170
+ standard: true,
171
+ auction: true,
172
+ "pre-order": true,
173
+ "prize-draw": true,
174
+ classified: true,
175
+ live: true,
176
+ // Depicts the model; is not the model.
177
+ art: false,
178
+ stickers: false,
179
+ // A redemption code has no physical parts at all.
180
+ "digital-code": false,
181
+ };