@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,194 @@
1
+ /**
2
+ * D4b — ONE breadcrumb chain builder, for the category page, the brand page and
3
+ * the product detail pages.
4
+ *
5
+ * 🛑 A `BreadcrumbList` is the one JSON-LD node whose entire value is the ORDER,
6
+ * and it is now emitted over a 416-node / 5-tier / 39-root forest rather than
7
+ * the 47-node / 2-tier tree it was written against. Measured 2026-10-12:
8
+ *
9
+ * per tier: t0=39 t1=23 t2=65 t3=71 t4=218
10
+ * deepest node -> 7 crumbs:
11
+ * Home > Categories > Spinning Tops > Beyblade Burst > Burst Tops > GT
12
+ * > Ace Dragon Sting Charge Zan
13
+ *
14
+ * Three surfaces render this chain and they must agree, or a crawler and a
15
+ * visitor are shown different hierarchies for one page — which is worse than
16
+ * emitting no breadcrumb, because a mismatched `BreadcrumbList` is a
17
+ * structured-data error against a page that looks fine:
18
+ *
19
+ * 1. the visible `BreadcrumbTrail`
20
+ * 2. this `BreadcrumbList`
21
+ * 3. `categoryNames[]` on a product — the denormalised display cache
22
+ *
23
+ * 🛑 AND THE THIRD IS STORED LEAF-FIRST. Measured:
24
+ *
25
+ * categorySlugs: category-x-basic > category-x-tops > … > category-spinning-tops
26
+ * categoryNames: Basic Line > Beyblade X Tops > … > Spinning Tops
27
+ *
28
+ * So anything rendering `categoryNames` in stored order shows the trail
29
+ * BACKWARDS. Every current consumer takes `[0]` — the leaf — which is correct
30
+ * for a single chip and is exactly why the reversal has never shown up. This
31
+ * module reverses it once, here, so no call site has to remember.
32
+ *
33
+ * 🛑 `ancestors[]` IS THE SOURCE FOR A CATEGORY; `categoryNames`+`categorySlugs`
34
+ * FOR A PRODUCT. Both `ancestors[]` and `parentIds[]` exist on a category and
35
+ * describe the same chain — verified equal in length on all 416 live rows — so
36
+ * neither is wrong, but two owners of one fact is Root Cause #42 waiting. The
37
+ * choice is `ancestors[]` because it carries the display NAME, so the visible
38
+ * trail needs no extra read; `parentIds[]` is ids only and stays what the
39
+ * repository derives `categorySlugs` from.
40
+ */
41
+ export interface BreadcrumbCrumb {
42
+ name: string;
43
+ /** Absent on the final crumb — the current page is not a link. */
44
+ url?: string;
45
+ }
46
+ /** The shape `CategoryDocument.ancestors[]` entries have. */
47
+ export interface AncestorLike {
48
+ id?: string | null;
49
+ name?: string | null;
50
+ tier?: number | null;
51
+ }
52
+ export interface CategoryChainInput {
53
+ /** The category's own display name. */
54
+ name?: string | null;
55
+ /** Its own URL. Omitted on the last crumb, so pass it and let the caller decide. */
56
+ url?: string;
57
+ ancestors?: readonly AncestorLike[] | null;
58
+ /** Builds a URL from an ancestor id. */
59
+ hrefFor: (id: string) => string;
60
+ }
61
+ /**
62
+ * Root → leaf, from a category's `ancestors[]`.
63
+ *
64
+ * 🛑 SORTED BY TIER, never taken as stored. An earlier revision of the category
65
+ * page asserted `ancestors[]` is stored root-first and used it as given; the
66
+ * visible `BreadcrumbTrail` on that same page had always sorted first, so the
67
+ * stored order was relied on nowhere. Sorting is the only safe reading, and a
68
+ * reversed breadcrumb is worse than none.
69
+ *
70
+ * 🛑 A BROKEN ANCESTOR IS OMITTED, NOT RENDERED AS A GAP. An entry with no id
71
+ * or no name cannot be linked or labelled, and emitting `{ name: "" }` puts an
72
+ * empty crumb in the middle of the trail — which reads as a missing level
73
+ * rather than as an absent one. `deriveTaxonomy` warns and leaves a chain alone
74
+ * when a leaf does not resolve (Root Cause #101), so a partial chain is a state
75
+ * that really occurs. Measured 0 in production today, which is why the branch
76
+ * needs a test rather than trust.
77
+ *
78
+ * A ROOT yields just itself — 39 of the 416 live rows are tier 0 (6 category
79
+ * roots plus 28 brands and 5 bundles), so this is the common case, not an edge.
80
+ * The caller's `Home` / `Categories` prefix is what keeps it a valid list.
81
+ */
82
+ export declare function categoryCrumbs(input: CategoryChainInput): BreadcrumbCrumb[];
83
+ export interface ProductChainInput {
84
+ /** `[leaf, …ancestors, root]` — the STORED order. */
85
+ categorySlugs?: readonly string[] | null;
86
+ /** Parallel to `categorySlugs`, same stored leaf-first order. */
87
+ categoryNames?: readonly string[] | null;
88
+ hrefFor: (slug: string) => string;
89
+ }
90
+ /**
91
+ * Root → leaf, from a product's stored chain.
92
+ *
93
+ * 🛑 THE TWO ARRAYS ARE PARALLEL AND MAY DISAGREE IN LENGTH. `deriveTaxonomy`
94
+ * writes both together, but a row written before it existed — or one whose leaf
95
+ * did not resolve — can carry a `categorySlugs` with no matching name. Pairing
96
+ * by index past the shorter array would label one category with another's name,
97
+ * which is a wrong hierarchy rendered confidently. So the chain stops at the
98
+ * shorter of the two, and a slug with no name is dropped rather than shown as
99
+ * its raw id.
100
+ *
101
+ * 🛑 A LOT FILES AT A NON-LEAF (G3a), so its chain is genuinely SHORTER and the
102
+ * last crumb is a line or generation node rather than a named model. That is
103
+ * correct and must not be padded: fabricating a leaf to fill the final position
104
+ * would claim the lot is one specific model, which is the same false claim the
105
+ * filing rule refuses at the write. Nothing here looks at `isLot` — the chain
106
+ * is simply as long as it is, which is why no special case is needed.
107
+ */
108
+ export declare function productCrumbs(input: ProductChainInput): BreadcrumbCrumb[];
109
+ /**
110
+ * I5b — 🛑 WHY `productCrumbs` NEEDED NO CHANGE FOR MULTI-ASSIGNMENT, and why
111
+ * that must be asserted rather than admired.
112
+ *
113
+ * A product's `categorySlugs` is now the CLOSURE over every assignment, while
114
+ * `categoryNames` is the DEFAULT chain only. Pairing by index would be a bug if
115
+ * the closure were a set — but it is ordered default-chain-first, so
116
+ * `min(slugs.length, names.length)` clamps to exactly the default chain and the
117
+ * indices line up. Measured: a 7-entry closure with 5 names yields the 5-crumb
118
+ * default trail, root-first.
119
+ *
120
+ * That is correct BY CONSTRUCTION and it is also fragile-looking, which is the
121
+ * dangerous combination — someone will eventually "fix" the pairing or make the
122
+ * closure a set. `verify-breadcrumb-chain.mjs` asserts it so that change fails
123
+ * loudly instead of silently relabelling a hierarchy.
124
+ */
125
+ /**
126
+ * Crumbs to show before the middle collapses behind a dropdown.
127
+ *
128
+ * 🛑 A real measurement, not a preference: a tier-4 part leaf is **7 crumbs**
129
+ * (`Home > Categories > Spinning Tops > Beyblade Metal > Metal Parts >
130
+ * Performance Tips > Rubber Flat`) and 390px cannot hold it. D4b measured that
131
+ * depth and never addressed how it wraps.
132
+ *
133
+ * 5 keeps `Home`, one collapsed group, and the last two — which is the pattern
134
+ * every large catalogue converges on, because the last two are the only crumbs
135
+ * a visitor navigates by.
136
+ */
137
+ export declare const TRAIL_COLLAPSE_THRESHOLD = 5;
138
+ export interface CollapsedTrail {
139
+ /** Always the first crumb. Never collapsed — it is the only guaranteed exit. */
140
+ lead: BreadcrumbCrumb[];
141
+ /** The middle, to be rendered behind a dropdown. Empty when the trail is short. */
142
+ collapsed: BreadcrumbCrumb[];
143
+ /** The last two: the parent you would click, and the page you are on. */
144
+ tail: BreadcrumbCrumb[];
145
+ }
146
+ /**
147
+ * Split a trail into lead / collapsed / tail for ONE row of crumbs.
148
+ *
149
+ * 🛑 Presentation only. The JSON-LD must still emit the FULL trail — a crawler
150
+ * never receives a truncated hierarchy just because a phone cannot show it.
151
+ */
152
+ export declare function collapseTrail(crumbs: readonly BreadcrumbCrumb[], threshold?: number): CollapsedTrail;
153
+ export interface AlternateTrailsInput {
154
+ /** Every assignment, default first. */
155
+ categoryIds?: readonly string[] | null;
156
+ /** One display name per assignment, parallel to `categoryIds`. */
157
+ categoryIdNames?: readonly string[] | null;
158
+ hrefFor: (slug: string) => string;
159
+ }
160
+ export interface AlternateTrails {
161
+ /**
162
+ * `none` when there is one assignment — which is the overwhelming majority,
163
+ * and the case that must render EXACTLY as before with no extra crumb and no
164
+ * post-hydration shift.
165
+ */
166
+ mode: "none" | "inline" | "dropdown";
167
+ items: BreadcrumbCrumb[];
168
+ }
169
+ /**
170
+ * The assignments that are NOT the default, as one crumb's worth of links.
171
+ *
172
+ * *"if it is less than 3 we can add a single crumb to show them all, for more
173
+ * than that we can just add a dropdown"* — so ≤2 alternates render inline and
174
+ * 3 (the maximum, since `MAX_CATEGORY_ASSIGNMENTS` is 4) opens a dropdown.
175
+ *
176
+ * 🛑 ONE ROW, never stacked trails. Two rows of crumbs above a product title is
177
+ * a pattern no competitor in the crawl uses and it costs vertical space above
178
+ * the fold on a phone.
179
+ *
180
+ * 🛑 And the links must be REAL `<a>` elements in the server-rendered DOM, which
181
+ * is this function's whole reason for returning data rather than a component: a
182
+ * crawler does not open menus, so alternates injected on open would trade the
183
+ * SEO this exists for against tidiness.
184
+ */
185
+ export declare function alternateAssignments(input: AlternateTrailsInput): AlternateTrails;
186
+ /**
187
+ * Prepend the two fixed crumbs every trail starts with, and drop the final
188
+ * crumb's URL.
189
+ *
190
+ * The last crumb must not be a link — it is the page you are on, and Google's
191
+ * own guidance is that the final breadcrumb identifies the current page. Doing
192
+ * it here rather than at three call sites is what stops one of them forgetting.
193
+ */
194
+ export declare function withTrailHead(crumbs: readonly BreadcrumbCrumb[], head: readonly BreadcrumbCrumb[]): BreadcrumbCrumb[];
@@ -0,0 +1,202 @@
1
+ /**
2
+ * D4b — ONE breadcrumb chain builder, for the category page, the brand page and
3
+ * the product detail pages.
4
+ *
5
+ * 🛑 A `BreadcrumbList` is the one JSON-LD node whose entire value is the ORDER,
6
+ * and it is now emitted over a 416-node / 5-tier / 39-root forest rather than
7
+ * the 47-node / 2-tier tree it was written against. Measured 2026-10-12:
8
+ *
9
+ * per tier: t0=39 t1=23 t2=65 t3=71 t4=218
10
+ * deepest node -> 7 crumbs:
11
+ * Home > Categories > Spinning Tops > Beyblade Burst > Burst Tops > GT
12
+ * > Ace Dragon Sting Charge Zan
13
+ *
14
+ * Three surfaces render this chain and they must agree, or a crawler and a
15
+ * visitor are shown different hierarchies for one page — which is worse than
16
+ * emitting no breadcrumb, because a mismatched `BreadcrumbList` is a
17
+ * structured-data error against a page that looks fine:
18
+ *
19
+ * 1. the visible `BreadcrumbTrail`
20
+ * 2. this `BreadcrumbList`
21
+ * 3. `categoryNames[]` on a product — the denormalised display cache
22
+ *
23
+ * 🛑 AND THE THIRD IS STORED LEAF-FIRST. Measured:
24
+ *
25
+ * categorySlugs: category-x-basic > category-x-tops > … > category-spinning-tops
26
+ * categoryNames: Basic Line > Beyblade X Tops > … > Spinning Tops
27
+ *
28
+ * So anything rendering `categoryNames` in stored order shows the trail
29
+ * BACKWARDS. Every current consumer takes `[0]` — the leaf — which is correct
30
+ * for a single chip and is exactly why the reversal has never shown up. This
31
+ * module reverses it once, here, so no call site has to remember.
32
+ *
33
+ * 🛑 `ancestors[]` IS THE SOURCE FOR A CATEGORY; `categoryNames`+`categorySlugs`
34
+ * FOR A PRODUCT. Both `ancestors[]` and `parentIds[]` exist on a category and
35
+ * describe the same chain — verified equal in length on all 416 live rows — so
36
+ * neither is wrong, but two owners of one fact is Root Cause #42 waiting. The
37
+ * choice is `ancestors[]` because it carries the display NAME, so the visible
38
+ * trail needs no extra read; `parentIds[]` is ids only and stays what the
39
+ * repository derives `categorySlugs` from.
40
+ */
41
+ /**
42
+ * Root → leaf, from a category's `ancestors[]`.
43
+ *
44
+ * 🛑 SORTED BY TIER, never taken as stored. An earlier revision of the category
45
+ * page asserted `ancestors[]` is stored root-first and used it as given; the
46
+ * visible `BreadcrumbTrail` on that same page had always sorted first, so the
47
+ * stored order was relied on nowhere. Sorting is the only safe reading, and a
48
+ * reversed breadcrumb is worse than none.
49
+ *
50
+ * 🛑 A BROKEN ANCESTOR IS OMITTED, NOT RENDERED AS A GAP. An entry with no id
51
+ * or no name cannot be linked or labelled, and emitting `{ name: "" }` puts an
52
+ * empty crumb in the middle of the trail — which reads as a missing level
53
+ * rather than as an absent one. `deriveTaxonomy` warns and leaves a chain alone
54
+ * when a leaf does not resolve (Root Cause #101), so a partial chain is a state
55
+ * that really occurs. Measured 0 in production today, which is why the branch
56
+ * needs a test rather than trust.
57
+ *
58
+ * A ROOT yields just itself — 39 of the 416 live rows are tier 0 (6 category
59
+ * roots plus 28 brands and 5 bundles), so this is the common case, not an edge.
60
+ * The caller's `Home` / `Categories` prefix is what keeps it a valid list.
61
+ */
62
+ export function categoryCrumbs(input) {
63
+ const ancestors = (input.ancestors ?? [])
64
+ .filter((a) => !!a && !!a.id && !!a.name)
65
+ .slice()
66
+ .sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0));
67
+ const out = ancestors.map((a) => ({
68
+ name: String(a.name),
69
+ url: input.hrefFor(String(a.id)),
70
+ }));
71
+ if (input.name)
72
+ out.push({ name: input.name, url: input.url });
73
+ return out;
74
+ }
75
+ /**
76
+ * Root → leaf, from a product's stored chain.
77
+ *
78
+ * 🛑 THE TWO ARRAYS ARE PARALLEL AND MAY DISAGREE IN LENGTH. `deriveTaxonomy`
79
+ * writes both together, but a row written before it existed — or one whose leaf
80
+ * did not resolve — can carry a `categorySlugs` with no matching name. Pairing
81
+ * by index past the shorter array would label one category with another's name,
82
+ * which is a wrong hierarchy rendered confidently. So the chain stops at the
83
+ * shorter of the two, and a slug with no name is dropped rather than shown as
84
+ * its raw id.
85
+ *
86
+ * 🛑 A LOT FILES AT A NON-LEAF (G3a), so its chain is genuinely SHORTER and the
87
+ * last crumb is a line or generation node rather than a named model. That is
88
+ * correct and must not be padded: fabricating a leaf to fill the final position
89
+ * would claim the lot is one specific model, which is the same false claim the
90
+ * filing rule refuses at the write. Nothing here looks at `isLot` — the chain
91
+ * is simply as long as it is, which is why no special case is needed.
92
+ */
93
+ export function productCrumbs(input) {
94
+ const slugs = input.categorySlugs ?? [];
95
+ const names = input.categoryNames ?? [];
96
+ const n = Math.min(slugs.length, names.length);
97
+ const out = [];
98
+ // Stored leaf-first; reverse ONCE, here, so no call site has to know.
99
+ for (let i = n - 1; i >= 0; i--) {
100
+ const slug = slugs[i];
101
+ const name = names[i];
102
+ if (!slug || !name)
103
+ continue;
104
+ out.push({ name: String(name), url: input.hrefFor(String(slug)) });
105
+ }
106
+ return out;
107
+ }
108
+ /**
109
+ * I5b — 🛑 WHY `productCrumbs` NEEDED NO CHANGE FOR MULTI-ASSIGNMENT, and why
110
+ * that must be asserted rather than admired.
111
+ *
112
+ * A product's `categorySlugs` is now the CLOSURE over every assignment, while
113
+ * `categoryNames` is the DEFAULT chain only. Pairing by index would be a bug if
114
+ * the closure were a set — but it is ordered default-chain-first, so
115
+ * `min(slugs.length, names.length)` clamps to exactly the default chain and the
116
+ * indices line up. Measured: a 7-entry closure with 5 names yields the 5-crumb
117
+ * default trail, root-first.
118
+ *
119
+ * That is correct BY CONSTRUCTION and it is also fragile-looking, which is the
120
+ * dangerous combination — someone will eventually "fix" the pairing or make the
121
+ * closure a set. `verify-breadcrumb-chain.mjs` asserts it so that change fails
122
+ * loudly instead of silently relabelling a hierarchy.
123
+ */
124
+ /**
125
+ * Crumbs to show before the middle collapses behind a dropdown.
126
+ *
127
+ * 🛑 A real measurement, not a preference: a tier-4 part leaf is **7 crumbs**
128
+ * (`Home > Categories > Spinning Tops > Beyblade Metal > Metal Parts >
129
+ * Performance Tips > Rubber Flat`) and 390px cannot hold it. D4b measured that
130
+ * depth and never addressed how it wraps.
131
+ *
132
+ * 5 keeps `Home`, one collapsed group, and the last two — which is the pattern
133
+ * every large catalogue converges on, because the last two are the only crumbs
134
+ * a visitor navigates by.
135
+ */
136
+ export const TRAIL_COLLAPSE_THRESHOLD = 5;
137
+ /**
138
+ * Split a trail into lead / collapsed / tail for ONE row of crumbs.
139
+ *
140
+ * 🛑 Presentation only. The JSON-LD must still emit the FULL trail — a crawler
141
+ * never receives a truncated hierarchy just because a phone cannot show it.
142
+ */
143
+ export function collapseTrail(crumbs, threshold = TRAIL_COLLAPSE_THRESHOLD) {
144
+ if (crumbs.length <= threshold) {
145
+ return { lead: [...crumbs], collapsed: [], tail: [] };
146
+ }
147
+ return {
148
+ lead: [crumbs[0]],
149
+ collapsed: crumbs.slice(1, crumbs.length - 2),
150
+ tail: crumbs.slice(crumbs.length - 2),
151
+ };
152
+ }
153
+ /**
154
+ * The assignments that are NOT the default, as one crumb's worth of links.
155
+ *
156
+ * *"if it is less than 3 we can add a single crumb to show them all, for more
157
+ * than that we can just add a dropdown"* — so ≤2 alternates render inline and
158
+ * 3 (the maximum, since `MAX_CATEGORY_ASSIGNMENTS` is 4) opens a dropdown.
159
+ *
160
+ * 🛑 ONE ROW, never stacked trails. Two rows of crumbs above a product title is
161
+ * a pattern no competitor in the crawl uses and it costs vertical space above
162
+ * the fold on a phone.
163
+ *
164
+ * 🛑 And the links must be REAL `<a>` elements in the server-rendered DOM, which
165
+ * is this function's whole reason for returning data rather than a component: a
166
+ * crawler does not open menus, so alternates injected on open would trade the
167
+ * SEO this exists for against tidiness.
168
+ */
169
+ export function alternateAssignments(input) {
170
+ const ids = input.categoryIds ?? [];
171
+ const names = input.categoryIdNames ?? [];
172
+ // Index 0 is the default — it is already the visible trail's last crumb.
173
+ const items = [];
174
+ for (let i = 1; i < Math.min(ids.length, names.length); i++) {
175
+ const id = ids[i];
176
+ const name = names[i];
177
+ // A name-less assignment is dropped rather than shown as its raw id, the
178
+ // same rule `productCrumbs` applies for the same reason.
179
+ if (!id || !name)
180
+ continue;
181
+ items.push({ name: String(name), url: input.hrefFor(String(id)) });
182
+ }
183
+ if (items.length === 0)
184
+ return { mode: "none", items: [] };
185
+ return { mode: items.length > 2 ? "dropdown" : "inline", items };
186
+ }
187
+ /**
188
+ * Prepend the two fixed crumbs every trail starts with, and drop the final
189
+ * crumb's URL.
190
+ *
191
+ * The last crumb must not be a link — it is the page you are on, and Google's
192
+ * own guidance is that the final breadcrumb identifies the current page. Doing
193
+ * it here rather than at three call sites is what stops one of them forgetting.
194
+ */
195
+ export function withTrailHead(crumbs, head) {
196
+ const all = [...head, ...crumbs];
197
+ if (all.length > 0) {
198
+ const last = all[all.length - 1];
199
+ all[all.length - 1] = { name: last.name };
200
+ }
201
+ return all;
202
+ }
@@ -34,9 +34,9 @@ export declare const corporateInquirySchema: z.ZodObject<{
34
34
  customBranding: boolean;
35
35
  createdAt?: string | undefined;
36
36
  phone?: string | undefined;
37
+ units?: number | undefined;
37
38
  adminNote?: string | undefined;
38
39
  designation?: string | undefined;
39
- units?: number | undefined;
40
40
  budgetPerUnit?: number | undefined;
41
41
  totalBudget?: number | undefined;
42
42
  deliveryDateRequired?: string | undefined;
@@ -48,9 +48,9 @@ export declare const corporateInquirySchema: z.ZodObject<{
48
48
  status?: "pending" | "contacted" | "proposal_sent" | "negotiating" | "closed_won" | "closed_lost" | undefined;
49
49
  createdAt?: string | undefined;
50
50
  phone?: string | undefined;
51
+ units?: number | undefined;
51
52
  adminNote?: string | undefined;
52
53
  designation?: string | undefined;
53
- units?: number | undefined;
54
54
  budgetPerUnit?: number | undefined;
55
55
  totalBudget?: number | undefined;
56
56
  deliveryDateRequired?: string | undefined;
@@ -78,8 +78,8 @@ export declare const submitCorporateInquirySchema: z.ZodObject<{
78
78
  customBranding: boolean;
79
79
  message?: string | undefined;
80
80
  phone?: string | undefined;
81
- designation?: string | undefined;
82
81
  units?: number | undefined;
82
+ designation?: string | undefined;
83
83
  budgetPerUnit?: number | undefined;
84
84
  totalBudget?: number | undefined;
85
85
  deliveryDateRequired?: string | undefined;
@@ -89,8 +89,8 @@ export declare const submitCorporateInquirySchema: z.ZodObject<{
89
89
  contactPerson: string;
90
90
  message?: string | undefined;
91
91
  phone?: string | undefined;
92
- designation?: string | undefined;
93
92
  units?: number | undefined;
93
+ designation?: string | undefined;
94
94
  budgetPerUnit?: number | undefined;
95
95
  totalBudget?: number | undefined;
96
96
  deliveryDateRequired?: string | undefined;
@@ -212,6 +212,19 @@ export interface OrderDocumentItem {
212
212
  * Absent on every order written before this, which is why the rollup keeps
213
213
  * its products-map fallback rather than treating absence as a condition.
214
214
  */
215
+ /**
216
+ * I8 — the category's DISPLAY NAME at order time, beside `categorySlug`.
217
+ *
218
+ * 🛑 A slug survives a rename; a NAME does not. `categorySlug` alone lets the
219
+ * price index band a past sale correctly, which is what it was added for —
220
+ * but a receipt has to SHOW something, and re-resolving the name at render
221
+ * time would make a renamed or deleted category change what a past invoice
222
+ * says. Same reasoning as `hsnCode` / `gstRate` / `finalSale` above: a past
223
+ * sale's facts are fixed.
224
+ */
225
+ categoryName?: string;
226
+ /** I8 — the brand display name at order time, for the same reason. */
227
+ brand?: string;
215
228
  condition?: string;
216
229
  /** SB8-F â€" per-item reveal status; flips through pending â†' open â†' revealed/closed. */
217
230
  prizeRevealStatus?: "pending" | "open" | "closed" | "revealed";
@@ -65,6 +65,36 @@ export interface OrderItem {
65
65
  */
66
66
  hsnCode?: string;
67
67
  gstRate?: 0 | 5 | 12 | 18 | 28;
68
+ /**
69
+ * I8 — the TAXONOMY snapshot, surfaced for the first time.
70
+ *
71
+ * 🛑 The third instance of Root Cause #57 in this one adapter. `categorySlug`
72
+ * has been snapshotted onto the order document since C1, with a recorded
73
+ * reason — *"a product re-categorised next year must not retroactively move
74
+ * last year's sale into a band it was never part of"* — and
75
+ * `orderDocumentToOrder` forwarded 13 fields, none of them this one. So the
76
+ * data was right on the document and reached no client surface, which is why
77
+ * no order or invoice page could show a category even though the facts were
78
+ * there.
79
+ *
80
+ * `categoryName` and `brand` are new. A slug survives a rename; a NAME is
81
+ * what a receipt must show, and re-resolving it at render time would let a
82
+ * renamed or deleted category change what a past invoice says. Snapshotting
83
+ * is the whole point: *"the transaction data still keeps it."*
84
+ */
85
+ categorySlug?: string;
86
+ categoryName?: string;
87
+ brand?: string;
88
+ /** Snapshotted condition — the price-index band's dominant variable. */
89
+ condition?: string;
90
+ /**
91
+ * The grouped-line keys. `groupOrderItemsByLine` collapses a bundle or a
92
+ * grouped cart line for the receipt and keys on exactly these, so without
93
+ * them a client renders N rows where the buyer bought one thing.
94
+ */
95
+ groupSlug?: string;
96
+ groupTitle?: string;
97
+ groupSource?: "bundle" | "product-group" | "grouped-listing";
68
98
  }
69
99
  /**
70
100
  * One entry on an order's timeline — the client-facing shape of
@@ -0,0 +1,107 @@
1
+ /**
2
+ * D13 — build a complete bey from parts you already hold, or break one down.
3
+ *
4
+ * ── 🛑 THE SLOT SET COMES FROM THE MODEL, NOT FROM A FIXED FIELD LIST ──────
5
+ *
6
+ * The rows are whatever `modelComposition` says this model's slots are, which
7
+ * is why the composition had to exist first: an HMS bey has a Running Core
8
+ * where a Plastic-Gen bey has a separate Spin Gear and Blade Base, and a 4D bey
9
+ * has a combined bottom. A hard-coded five-row form could not express either.
10
+ *
11
+ * When the model has no recorded composition the drawer still works — the
12
+ * seller adds free rows — because 37 of 259 compositions did not join to a
13
+ * category and a seller holding those parts should not be blocked by our data
14
+ * gap.
15
+ *
16
+ * ── 🛑 IT SHOWS AVAILABLE, NOT STOCK, AND SAYS WHY ────────────────────────
17
+ *
18
+ * The server gates on `availableQuantity`: a component with 10 in stock and 3
19
+ * reserved has SEVEN assemblable. So the picker shows the available figure and
20
+ * labels it, because a seller looking at "10 in stock" and being refused at 8
21
+ * would read that as a bug. The client never decides — it previews what the
22
+ * server will say.
23
+ *
24
+ * ── 🛑 IT IS A `<SectionForm>`, NOT A HAND-ROLLED FORM ────────────────────
25
+ *
26
+ * `audit-form-sectionised` caught the first version rendering `FieldSelect` and
27
+ * `FieldInput` directly, and it was right: a hand-rolled form gets no
28
+ * `FormShellContext`, so no `aria-invalid` wiring, no error summary, no
29
+ * jump-to-field and no pinned mobile action bar.
30
+ *
31
+ * The slots are DATA (one row per `modelComposition` slot), so
32
+ * `buildSectionsFromSchema` cannot express them — but `SectionDef.render` can,
33
+ * and `SectionForm` takes explicit sections. So this is a real adoption rather
34
+ * than a marker: the dynamic rows live inside one `render` slot and the form
35
+ * machinery is inherited. A suppression here would have hidden the violation
36
+ * instead of removing it (Root Cause #22).
37
+ *
38
+ * ── 🛑 AND IT REPORTS THE ARITHMETIC AFTERWARDS ───────────────────────────
39
+ *
40
+ * The route returns `before` / `after` / `taken` per component, and this
41
+ * renders all three. A toast reading "built 2" is the shape of silent
42
+ * wrongness this codebase keeps paying for: it is indistinguishable from a
43
+ * build that consumed the wrong parts.
44
+ */
45
+ import React from "react";
46
+ import { type ModelCompositionDocument } from "../schemas/firestore";
47
+ /** A part listing the seller holds, as the picker needs it. */
48
+ export interface AssemblyPartOption {
49
+ productId: string;
50
+ title: string;
51
+ /** 🛑 AVAILABLE, not stock. The server gates on this. */
52
+ availableQuantity: number;
53
+ colourway?: string;
54
+ /** The `partGlossary` id, so a row can be pre-matched to its slot. */
55
+ partGlossaryId?: string;
56
+ slotKey?: string;
57
+ }
58
+ export interface AssemblyRow {
59
+ slotKey: string;
60
+ slotLabel: string;
61
+ /** Empty until the seller picks a part listing. */
62
+ productId: string;
63
+ quantity: number;
64
+ /** The part name the composition expects, as a hint. */
65
+ expectedPartName?: string;
66
+ }
67
+ /** What the route sends when a 409 names a shortfall. */
68
+ export interface AssemblyErrorDetail {
69
+ code?: string;
70
+ kind?: "component" | "part-out";
71
+ productId?: string;
72
+ needs?: number;
73
+ has?: number;
74
+ }
75
+ export interface AssemblyOutcome {
76
+ unitsBuilt: number;
77
+ consumed: Array<{
78
+ productId: string;
79
+ before: number;
80
+ after: number;
81
+ taken: number;
82
+ }>;
83
+ }
84
+ export interface AssemblyDrawerProps {
85
+ isOpen: boolean;
86
+ onClose: () => void;
87
+ direction: "assemble" | "part-out";
88
+ /** The complete-bey listing being built or broken down. */
89
+ targetTitle: string;
90
+ /** Its own available figure — a part-out cannot exceed it. */
91
+ targetAvailable?: number;
92
+ /** The model's composition, when one is recorded. */
93
+ compositions: readonly ModelCompositionDocument[];
94
+ /** Part listings the seller holds. */
95
+ options: readonly AssemblyPartOption[];
96
+ /** Resolves to the route's result, or throws with a readable message. */
97
+ onSubmit: (input: {
98
+ direction: "assemble" | "part-out";
99
+ components: Array<{
100
+ slotKey: string;
101
+ productId: string;
102
+ quantity: number;
103
+ }>;
104
+ units: number;
105
+ }) => Promise<AssemblyOutcome>;
106
+ }
107
+ export declare function AssemblyDrawer({ isOpen, onClose, direction, targetTitle, targetAvailable, compositions, options, onSubmit, }: AssemblyDrawerProps): React.JSX.Element;