@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,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
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Compare colourways the way a human would type them.
3
+ *
4
+ * 🛑 The vocabulary is OPEN-ENDED and seller-entered — deliberately not a
5
+ * `productFeature`, because that vocabulary is ~169 curated terms in 14 groups
6
+ * and colourways are per-part, which would give a facet hundreds of single-use
7
+ * values (the long-tail-tag anti-pattern this project rejected when redeyemerch
8
+ * was crawled).
9
+ *
10
+ * Open-ended means "Transparent Black", "transparent-black" and
11
+ * "transparent black" are the same colour typed three ways, and a strict `===`
12
+ * would call a buildable bey unbuildable. Normalising is not a nicety here; it
13
+ * is the difference between a working rollup and one that reports nothing.
14
+ */
15
+ export declare function normaliseColourway(value: string | null | undefined): string;
16
+ export interface SlotRequirement {
17
+ /** The `partGlossary` id the slot needs. */
18
+ partId?: string;
19
+ /** The colourway it needs. Absent means ANY colourway will do. */
20
+ expectedColourway?: string;
21
+ }
22
+ export interface PartHolding {
23
+ /** The part listing's own product id. */
24
+ productId: string;
25
+ partGlossaryId?: string;
26
+ colourway?: string;
27
+ /**
28
+ * 🛑 AVAILABLE, never `stockQuantity`. 10 in stock with 3 reserved is SEVEN
29
+ * assemblable, and building from the wrong field assembles a bey out of parts
30
+ * somebody has already bought — A4's bug through a new door.
31
+ */
32
+ availableQuantity: number;
33
+ }
34
+ /**
35
+ * Does this holding satisfy that slot?
36
+ *
37
+ * Two rules, and the second is the one that gets written wrong:
38
+ *
39
+ * 1. the glossary id must match — a Driver is not a Bit;
40
+ * 2. the colourway must match **when the slot names one**. 🛑 A slot with no
41
+ * `expectedColourway` accepts ANY, including a holding that declares one.
42
+ * The reverse — a slot that names a colour accepting a holding that
43
+ * declares none — is REFUSED, because "colour unknown" is not evidence of
44
+ * the right colour, and this is exactly where a cheerful default produces
45
+ * the wrong physical part.
46
+ */
47
+ export declare function satisfiesSlot(slot: SlotRequirement, holding: PartHolding): boolean;
48
+ export interface SlotMatch {
49
+ slot: SlotRequirement;
50
+ /** Every holding that satisfies the slot, richest first. */
51
+ candidates: PartHolding[];
52
+ /** How many complete units this slot alone could supply. */
53
+ buildable: number;
54
+ }
55
+ /**
56
+ * Match one slot against what a seller holds.
57
+ *
58
+ * `buildable` is the SUM across candidates, because two listings of the same
59
+ * part in the same colourway are two piles of the same thing — a seller with
60
+ * 2 silver MBs in one listing and 3 in another can build five.
61
+ */
62
+ export declare function matchSlotToHoldings(slot: SlotRequirement, holdings: readonly PartHolding[]): SlotMatch;
@@ -0,0 +1,98 @@
1
+ /*
2
+ * WHY: one part code can be two physically different units that are not
3
+ * interchangeable. The user's case, and the fixture pair to build first:
4
+ *
5
+ * performance-tip--mb "Metal Ball"
6
+ * -> silver ships with L-Drago Guardian S130MB (rare, costly)
7
+ * -> transparent-black ships with Poison Zurafa S130MB
8
+ *
9
+ * Same mould, same weight, same stats, same behaviour. To build an L-Drago
10
+ * you need the silver one; the black one will not do. Same for their S130
11
+ * tracks.
12
+ *
13
+ * WHAT: the ONE predicate for "does this part listing satisfy this composition
14
+ * slot". Pure, so the case above is assertable without a database.
15
+ *
16
+ * EXPORTS: normaliseColourway, satisfiesSlot, matchSlotToHoldings
17
+ *
18
+ * ── 🛑 WHY THIS IS A SEPARATE MODULE AND NOT AN INLINE `===` ───────────────
19
+ *
20
+ * Because it will be needed in at least three places that must agree — the
21
+ * "buildable now" rollup, the assembly drawer's per-slot picker, and (once it
22
+ * bands by colourway) the price index. An inline comparison is Root Cause #75's
23
+ * shape: the copy made before a rule was added never learns about it, and the
24
+ * rule here has two non-obvious halves (unset means ANY, and comparison is
25
+ * normalised).
26
+ *
27
+ * @tag domain:parts
28
+ * @tag layer:util
29
+ * @tag pattern:none
30
+ * @tag access:shared
31
+ * @tag consumers:buildable-now rollup, assembly drawer, priceIndexRollup
32
+ * @tag sideEffects:none
33
+ */
34
+ /**
35
+ * Compare colourways the way a human would type them.
36
+ *
37
+ * 🛑 The vocabulary is OPEN-ENDED and seller-entered — deliberately not a
38
+ * `productFeature`, because that vocabulary is ~169 curated terms in 14 groups
39
+ * and colourways are per-part, which would give a facet hundreds of single-use
40
+ * values (the long-tail-tag anti-pattern this project rejected when redeyemerch
41
+ * was crawled).
42
+ *
43
+ * Open-ended means "Transparent Black", "transparent-black" and
44
+ * "transparent black" are the same colour typed three ways, and a strict `===`
45
+ * would call a buildable bey unbuildable. Normalising is not a nicety here; it
46
+ * is the difference between a working rollup and one that reports nothing.
47
+ */
48
+ export function normaliseColourway(value) {
49
+ if (!value)
50
+ return "";
51
+ return value
52
+ .toLowerCase()
53
+ .trim()
54
+ .replace(/[^a-z0-9]+/g, "-")
55
+ .replace(/^-+|-+$/g, "");
56
+ }
57
+ /**
58
+ * Does this holding satisfy that slot?
59
+ *
60
+ * Two rules, and the second is the one that gets written wrong:
61
+ *
62
+ * 1. the glossary id must match — a Driver is not a Bit;
63
+ * 2. the colourway must match **when the slot names one**. 🛑 A slot with no
64
+ * `expectedColourway` accepts ANY, including a holding that declares one.
65
+ * The reverse — a slot that names a colour accepting a holding that
66
+ * declares none — is REFUSED, because "colour unknown" is not evidence of
67
+ * the right colour, and this is exactly where a cheerful default produces
68
+ * the wrong physical part.
69
+ */
70
+ export function satisfiesSlot(slot, holding) {
71
+ if (!slot.partId)
72
+ return false;
73
+ if (holding.partGlossaryId !== slot.partId)
74
+ return false;
75
+ if (holding.availableQuantity <= 0)
76
+ return false;
77
+ const want = normaliseColourway(slot.expectedColourway);
78
+ if (!want)
79
+ return true; // the slot is colour-agnostic
80
+ return normaliseColourway(holding.colourway) === want;
81
+ }
82
+ /**
83
+ * Match one slot against what a seller holds.
84
+ *
85
+ * `buildable` is the SUM across candidates, because two listings of the same
86
+ * part in the same colourway are two piles of the same thing — a seller with
87
+ * 2 silver MBs in one listing and 3 in another can build five.
88
+ */
89
+ export function matchSlotToHoldings(slot, holdings) {
90
+ const candidates = holdings
91
+ .filter((h) => satisfiesSlot(slot, h))
92
+ .sort((a, b) => b.availableQuantity - a.availableQuantity);
93
+ return {
94
+ slot,
95
+ candidates,
96
+ buildable: candidates.reduce((sum, h) => sum + h.availableQuantity, 0),
97
+ };
98
+ }
@@ -0,0 +1,53 @@
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 { type ModelCompositionDocument, type PartGlossaryDocument } from "../schemas/firestore";
33
+ /** The slot name, in ONE place — the endpoint, the formatter and the seeded bodies all key on it. */
34
+ export declare 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 declare function formatPartBreakdown(compositions: readonly ModelCompositionDocument[], parts?: Map<string, PartGlossaryDocument>): string;
47
+ /**
48
+ * Remove the source line that carries `{{slot}}`.
49
+ *
50
+ * Used when the slot has no value, so the body arrives without the line rather
51
+ * than with an empty one. See the header for why this is line-based.
52
+ */
53
+ export declare function stripSlotLine(body: string, slot: string): string;