@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,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;
@@ -0,0 +1,250 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /**
4
+ * D13 โ€” build a complete bey from parts you already hold, or break one down.
5
+ *
6
+ * โ”€โ”€ ๐Ÿ›‘ THE SLOT SET COMES FROM THE MODEL, NOT FROM A FIXED FIELD LIST โ”€โ”€โ”€โ”€โ”€โ”€
7
+ *
8
+ * The rows are whatever `modelComposition` says this model's slots are, which
9
+ * is why the composition had to exist first: an HMS bey has a Running Core
10
+ * where a Plastic-Gen bey has a separate Spin Gear and Blade Base, and a 4D bey
11
+ * has a combined bottom. A hard-coded five-row form could not express either.
12
+ *
13
+ * When the model has no recorded composition the drawer still works โ€” the
14
+ * seller adds free rows โ€” because 37 of 259 compositions did not join to a
15
+ * category and a seller holding those parts should not be blocked by our data
16
+ * gap.
17
+ *
18
+ * โ”€โ”€ ๐Ÿ›‘ IT SHOWS AVAILABLE, NOT STOCK, AND SAYS WHY โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
19
+ *
20
+ * The server gates on `availableQuantity`: a component with 10 in stock and 3
21
+ * reserved has SEVEN assemblable. So the picker shows the available figure and
22
+ * labels it, because a seller looking at "10 in stock" and being refused at 8
23
+ * would read that as a bug. The client never decides โ€” it previews what the
24
+ * server will say.
25
+ *
26
+ * โ”€โ”€ ๐Ÿ›‘ IT IS A `<SectionForm>`, NOT A HAND-ROLLED FORM โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
27
+ *
28
+ * `audit-form-sectionised` caught the first version rendering `FieldSelect` and
29
+ * `FieldInput` directly, and it was right: a hand-rolled form gets no
30
+ * `FormShellContext`, so no `aria-invalid` wiring, no error summary, no
31
+ * jump-to-field and no pinned mobile action bar.
32
+ *
33
+ * The slots are DATA (one row per `modelComposition` slot), so
34
+ * `buildSectionsFromSchema` cannot express them โ€” but `SectionDef.render` can,
35
+ * and `SectionForm` takes explicit sections. So this is a real adoption rather
36
+ * than a marker: the dynamic rows live inside one `render` slot and the form
37
+ * machinery is inherited. A suppression here would have hidden the violation
38
+ * instead of removing it (Root Cause #22).
39
+ *
40
+ * โ”€โ”€ ๐Ÿ›‘ AND IT REPORTS THE ARITHMETIC AFTERWARDS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
41
+ *
42
+ * The route returns `before` / `after` / `taken` per component, and this
43
+ * renders all three. A toast reading "built 2" is the shape of silent
44
+ * wrongness this codebase keeps paying for: it is indistinguishable from a
45
+ * build that consumed the wrong parts.
46
+ */
47
+ import React from "react";
48
+ import { SideDrawer, Stack, Row, Div, Heading, Text, Button, Badge, FieldInput, FieldSelect, } from "../../../ui/index.js";
49
+ import { SectionForm } from "../../shell/SectionForm.js";
50
+ import { PART_SLOT_LABEL } from "../schemas/firestore.js";
51
+ import { normalizeError } from "../../../errors/normalize.js";
52
+ import { trackError, ErrorCategory, ErrorSeverity, } from "../../../monitoring/error-tracking.js";
53
+ const MAX_COMPONENTS = 8;
54
+ /** Rows seeded from the model's own slots, pre-matched where possible. */
55
+ function seedRows(compositions, options) {
56
+ const slots = compositions[0]?.slots ?? [];
57
+ if (slots.length === 0) {
58
+ return [{ slotKey: "", slotLabel: "", productId: "", quantity: 1 }];
59
+ }
60
+ return slots.slice(0, MAX_COMPONENTS).map((s) => {
61
+ /*
62
+ * Pre-match on the glossary id when the seller's listing declares one โ€”
63
+ * that is what `partGlossaryId` on a product is FOR. Falls back to nothing
64
+ * rather than to a name guess: picking the wrong part for the seller is
65
+ * worse than making them pick.
66
+ */
67
+ const hit = s.partId
68
+ ? options.find((o) => o.partGlossaryId === s.partId && o.availableQuantity > 0)
69
+ : undefined;
70
+ return {
71
+ slotKey: s.slotKey,
72
+ slotLabel: PART_SLOT_LABEL[s.slotKey] ?? s.slotKey,
73
+ productId: hit?.productId ?? "",
74
+ quantity: 1,
75
+ expectedPartName: s.partName,
76
+ };
77
+ });
78
+ }
79
+ export function AssemblyDrawer({ isOpen, onClose, direction, targetTitle, targetAvailable, compositions, options, onSubmit, }) {
80
+ const isAssemble = direction === "assemble";
81
+ const [rows, setRows] = React.useState(() => seedRows(compositions, options));
82
+ const [units, setUnits] = React.useState(1);
83
+ const [busy, setBusy] = React.useState(false);
84
+ const [error, setError] = React.useState(null);
85
+ const [outcome, setOutcome] = React.useState(null);
86
+ // Re-seed when the drawer opens on a different listing.
87
+ React.useEffect(() => {
88
+ if (isOpen) {
89
+ setRows(seedRows(compositions, options));
90
+ setUnits(1);
91
+ setError(null);
92
+ setOutcome(null);
93
+ }
94
+ }, [isOpen, compositions, options]);
95
+ const byId = React.useMemo(() => new Map(options.map((o) => [o.productId, o])), [options]);
96
+ const filled = rows.filter((r) => r.productId && r.slotKey);
97
+ /*
98
+ * ๐Ÿ›‘ The SAME duplicate rule the server enforces, previewed rather than
99
+ * duplicated as a decision: two rows for one product would each be planned
100
+ * against the same read value and the second would overdraw, so the server
101
+ * refuses it. Showing that here means the seller sees why before submitting.
102
+ */
103
+ const duplicateIds = filled
104
+ .map((r) => r.productId)
105
+ .filter((id, i, a) => a.indexOf(id) !== i);
106
+ const shortfalls = isAssemble
107
+ ? filled.filter((r) => {
108
+ const o = byId.get(r.productId);
109
+ return o ? o.availableQuantity < r.quantity * units : false;
110
+ })
111
+ : [];
112
+ const overPartOut = !isAssemble && typeof targetAvailable === "number" && units > targetAvailable;
113
+ const blocked = filled.length === 0 ||
114
+ duplicateIds.length > 0 ||
115
+ shortfalls.length > 0 ||
116
+ overPartOut ||
117
+ busy;
118
+ const setRow = (i, patch) => setRows((prev) => prev.map((r, j) => (i === j ? { ...r, ...patch } : r)));
119
+ /**
120
+ * ๐Ÿ›‘ The WORDS are ours; the FIGURES come from the server.
121
+ *
122
+ * `audit-raw-error-text` blocks rendering a thrown `.message`, and it is
123
+ * right: this codebase once rendered a Node require stack inside the
124
+ * "Place your bid" modal (Root Cause #86). But the one genuinely useful
125
+ * thing about a 409 here is the NUMBERS, which no constant can carry โ€” so
126
+ * the server sends `{ needs, has }` as structured data and this composes the
127
+ * sentence from authored copy around them.
128
+ *
129
+ * Anything without that shape falls back to one authored line and never to
130
+ * server text.
131
+ */
132
+ const messageFor = (detail) => {
133
+ if (detail?.code === "ASSEMBLY_INSUFFICIENT" && typeof detail.needs === "number") {
134
+ return detail.kind === "part-out"
135
+ ? `Only ${detail.has} available to part out โ€” you asked for ${detail.needs}.`
136
+ : `A component is short: it needs ${detail.needs} and ${detail.has} are available. Restock it, or build fewer.`;
137
+ }
138
+ return "Could not complete that. Check the components and try again.";
139
+ };
140
+ const submit = async () => {
141
+ setBusy(true);
142
+ setError(null);
143
+ try {
144
+ const result = await onSubmit({
145
+ direction,
146
+ components: filled.map((r) => ({
147
+ slotKey: r.slotKey,
148
+ productId: r.productId,
149
+ quantity: r.quantity,
150
+ })),
151
+ units,
152
+ });
153
+ setOutcome(result);
154
+ }
155
+ catch (err) {
156
+ /*
157
+ * `normalizeError` first, per Root Cause #31 โ€” and its result is used for
158
+ * REPORTING only. What the seller reads is composed by `messageFor` from
159
+ * the structured figures, never from `e.message`.
160
+ */
161
+ const e = normalizeError(err);
162
+ const detail = err && typeof err === "object" && "detail" in err
163
+ ? (err.detail ?? null)
164
+ : null;
165
+ setError(messageFor(detail));
166
+ // The raw text still reaches the recorder, which is where it belongs.
167
+ trackError(e.message, ErrorCategory.API, ErrorSeverity.MEDIUM, {
168
+ component: "AssemblyDrawer",
169
+ action: direction,
170
+ });
171
+ }
172
+ finally {
173
+ setBusy(false);
174
+ }
175
+ };
176
+ /*
177
+ * `SectionForm` is value-driven, and the rows are a LIST with add/remove โ€”
178
+ * which a flat values object cannot express. So the values object carries the
179
+ * one genuinely scalar field (`units`) and the rows stay in local state,
180
+ * mutated inside the section's own `render`. Stated rather than worked
181
+ * around: pretending the list is a value would mean rebuilding it from a
182
+ * record on every keystroke.
183
+ */
184
+ const formValues = React.useMemo(() => ({ units }), [units]);
185
+ const sections = React.useMemo(() => [
186
+ {
187
+ id: "components",
188
+ label: isAssemble ? "Components to consume" : "Components to receive",
189
+ required: true,
190
+ // Media-free, but the rows hold uncommitted picks, so keep it mounted.
191
+ keepMounted: true,
192
+ render: () => (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [rows.map((r, i) => {
193
+ const o = r.productId ? byId.get(r.productId) : undefined;
194
+ const need = r.quantity * units;
195
+ const short = isAssemble && o ? o.availableQuantity < need : false;
196
+ return (_jsxs(Stack, { gap: "xs", padding: "sm", surface: "card", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsxs(Row, { justify: "between", align: "center", gap: "sm", className: "min-w-0", children: [_jsx(Text, { size: "xs", color: "muted", transform: "uppercase", children: r.slotLabel || `Component ${i + 1}` }), r.expectedPartName && (_jsxs(Text, { size: "xs", color: "faint", className: "truncate", children: ["expects ", r.expectedPartName] }))] }), _jsx(FieldSelect, { name: `component-${i}`, label: "Part listing", value: r.productId, onChange: (v) => setRow(i, { productId: v }), options: [
197
+ { value: "", label: "โ€” none โ€”" },
198
+ ...options
199
+ .filter((opt) => !isAssemble || opt.availableQuantity > 0)
200
+ .map((opt) => ({
201
+ value: opt.productId,
202
+ // ๐Ÿ›‘ "available", spelled out. A bare number reads as
203
+ // stock, and the server gates on available.
204
+ label: `${opt.title}${opt.colourway ? ` ยท ${opt.colourway}` : ""} โ€” ${opt.availableQuantity} available`,
205
+ })),
206
+ ] }), _jsx(FieldInput, { name: `qty-${i}`, type: "number", label: "Per unit", value: String(r.quantity), onChange: (v) => setRow(i, { quantity: Math.max(1, Number(v) || 1) }) }), short && o && (_jsxs(Text, { size: "xs", color: "error", role: "alert", children: ["Needs ", need, ", ", o.availableQuantity, " available."] }))] }, `${r.slotKey}-${i}`));
207
+ }), rows.length < MAX_COMPONENTS && (_jsx(Button, { variant: "ghost", onClick: () => setRows((prev) => [
208
+ ...prev,
209
+ { slotKey: "", slotLabel: "", productId: "", quantity: 1 },
210
+ ]), children: "Add a component" }))] })),
211
+ },
212
+ {
213
+ id: "quantity",
214
+ label: isAssemble ? "How many to build" : "How many to part out",
215
+ required: true,
216
+ render: () => (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(FieldInput, { name: "units", type: "number", label: isAssemble ? "Units to build" : "Units to part out", value: String(units), onChange: (v) => setUnits(Math.max(1, Math.min(99, Number(v) || 1))) }), duplicateIds.length > 0 && (_jsx(Text, { size: "sm", color: "error", role: "alert", children: "The same part is selected twice. Combine it into one row with the total quantity." })), overPartOut && (_jsxs(Text, { size: "sm", color: "error", role: "alert", children: ["Only ", targetAvailable, " available to part out."] })), error && (_jsx(Text, { size: "sm", color: "error", role: "alert", children: error })), blocked && !busy && (_jsx(Text, { size: "xs", color: "muted", children: "Pick at least one component, and clear any shortfall above." }))] })),
217
+ },
218
+ ], [
219
+ rows,
220
+ units,
221
+ options,
222
+ byId,
223
+ isAssemble,
224
+ duplicateIds.length,
225
+ overPartOut,
226
+ targetAvailable,
227
+ error,
228
+ blocked,
229
+ busy,
230
+ ]);
231
+ return (_jsx(SideDrawer, { isOpen: isOpen, onClose: onClose, title: isAssemble ? "Assemble from parts" : "Part out", children: _jsxs(Stack, { gap: "md", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Heading, { level: 3, size: "base", children: targetTitle }), _jsx(Text, { size: "sm", color: "muted", children: isAssemble
232
+ ? "Each component's stock goes down; this listing's goes up."
233
+ : "This listing's stock goes down; each component's goes up." }), compositions.length === 0 && (_jsx(Text, { size: "xs", color: "muted", children: "We have no recorded part breakdown for this model, so the slots are blank \u2014 fill in what it is actually made of." }))] }), outcome ? (
234
+ /* โ”€โ”€ the arithmetic, not a count โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
235
+ _jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsxs(Badge, { variant: "success", children: [isAssemble ? "Built" : "Parted out", " ", outcome.unitsBuilt] }), _jsx(Stack, { gap: "xs", className: "min-w-0", children: outcome.consumed.map((c) => (_jsxs(Row, { gap: "sm", align: "center", padding: "inline", surface: "card", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsx(Div, { className: "flex-1 min-w-0", children: _jsx(Text, { size: "sm", color: "primary", className: "truncate", children: byId.get(c.productId)?.title ?? c.productId }) }), _jsxs(Text, { size: "sm", color: "muted", numeric: true, className: "flex-shrink-0", children: [c.before, " \u2192 ", c.after] })] }, c.productId))) }), _jsx(Button, { onClick: onClose, children: "Done" })] })) : (
236
+ /*
237
+ * ONE section holding the dynamic rows. `SectionForm` owns the
238
+ * FormShellContext, the error summary and the pinned mobile action
239
+ * bar; the rows themselves are data, so they live in `render`.
240
+ */
241
+ _jsx(SectionForm, { sections: sections, values: formValues, onChange: () => {
242
+ /*
243
+ * The rows are held in local state because they are a LIST with
244
+ * add/remove, which a flat values object cannot express. This
245
+ * handler exists so `SectionForm`'s contract is satisfied; every
246
+ * real mutation goes through `setRow` / `setRows` inside the
247
+ * section's own render.
248
+ */
249
+ }, onSubmit: submit, submitLabel: isAssemble ? "Build" : "Part out", isLoading: busy, onCancel: onClose, cancelLabel: "Cancel" }))] }) }));
250
+ }