@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.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
- package/dist/_internal/server/features/categories/live-counts.js +72 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
- package/dist/_internal/server/features/orders/adapters.js +29 -0
- package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
- package/dist/_internal/server/features/parts/assembly.js +246 -0
- package/dist/_internal/server/features/parts/data.d.ts +65 -0
- package/dist/_internal/server/features/parts/data.js +170 -0
- package/dist/_internal/server/features/parts/og.d.ts +34 -0
- package/dist/_internal/server/features/parts/og.js +66 -0
- package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
- package/dist/_internal/server/features/search/search-everything.js +120 -0
- package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
- package/dist/_internal/server/features/search/search-sources.js +138 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
- package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
- package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
- package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
- package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
- package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
- package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
- package/dist/_internal/shared/actions/action-registry.js +36 -0
- package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
- package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
- package/dist/_internal/shared/features/shipping/protections.js +127 -0
- package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
- package/dist/_internal/shared/search/part-aliases.js +104 -0
- package/dist/constants/api-endpoints.d.ts +18 -12
- package/dist/constants/api-endpoints.js +6 -8
- package/dist/constants/field-names.d.ts +0 -2
- package/dist/constants/field-names.js +0 -2
- package/dist/constants/index.d.ts +1 -1
- package/dist/constants/index.js +1 -1
- package/dist/core/index.d.ts +0 -3
- package/dist/core/index.js +0 -2
- package/dist/errors/messages.d.ts +0 -7
- package/dist/errors/messages.js +0 -8
- package/dist/features/admin/components/AdminSectionsView.js +83 -32
- package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
- package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
- package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
- package/dist/features/admin/schemas/firestore.d.ts +15 -0
- package/dist/features/admin/types/index.d.ts +0 -1
- package/dist/features/auth/permissions/constants.d.ts +1 -1
- package/dist/features/auth/permissions/constants.js +0 -2
- package/dist/features/categories/api/[id]/description-template/route.js +40 -1
- package/dist/features/categories/components/BrandDetailPageView.js +14 -3
- package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
- package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
- package/dist/features/categories/repository/categories.repository.d.ts +65 -0
- package/dist/features/categories/repository/categories.repository.js +168 -22
- package/dist/features/categories/repository/category-counts.d.ts +143 -0
- package/dist/features/categories/repository/category-counts.js +224 -0
- package/dist/features/categories/schemas/category-counts.d.ts +155 -0
- package/dist/features/categories/schemas/category-counts.js +116 -0
- package/dist/features/categories/schemas/firestore.d.ts +0 -2
- package/dist/features/categories/schemas/firestore.js +0 -1
- package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
- package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
- package/dist/features/corporate/schemas/index.d.ts +4 -4
- package/dist/features/orders/schemas/firestore.d.ts +13 -0
- package/dist/features/orders/types/index.d.ts +30 -0
- package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
- package/dist/features/parts/components/AssemblyDrawer.js +250 -0
- package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
- package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
- package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
- package/dist/features/parts/components/PartDetailPageView.js +20 -0
- package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
- package/dist/features/parts/components/PartsIndexPageView.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +257 -0
- package/dist/features/parts/schemas/firestore.js +181 -0
- package/dist/features/parts/utils/colourway-match.d.ts +62 -0
- package/dist/features/parts/utils/colourway-match.js +98 -0
- package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
- package/dist/features/parts/utils/part-breakdown.js +77 -0
- package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
- package/dist/features/products/components/ProductDetailPageView.js +22 -3
- package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
- package/dist/features/products/components/ProductTabsShell.js +4 -1
- package/dist/features/products/repository/category-assignment.d.ts +132 -0
- package/dist/features/products/repository/category-assignment.js +304 -0
- package/dist/features/products/repository/products.repository.d.ts +58 -1
- package/dist/features/products/repository/products.repository.js +255 -28
- package/dist/features/products/schemas/firestore.d.ts +147 -6
- package/dist/features/products/schemas/firestore.js +49 -0
- package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
- package/dist/features/search/components/SearchResultsPageView.js +21 -0
- package/dist/features/seller/components/SellerProductsView.js +129 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
- package/dist/index.d.ts +11 -16
- package/dist/index.js +26 -18
- package/dist/next/routing/route-map.d.ts +6 -2
- package/dist/next/routing/route-map.js +14 -1
- package/dist/providers/db-firebase/base.repository.d.ts +31 -0
- package/dist/providers/db-firebase/base.repository.js +31 -0
- package/dist/repositories/index.d.ts +1 -2
- package/dist/repositories/index.js +0 -1
- package/dist/seed/_helpers/category-content.d.ts +29 -0
- package/dist/seed/_helpers/category-content.js +98 -1
- package/dist/seed/_helpers/category-forest.js +104 -24
- package/dist/seed/_helpers/category-parts.d.ts +9 -0
- package/dist/seed/_helpers/category-parts.js +4594 -0
- package/dist/seed/carousel-slides-seed-data.js +81 -8
- package/dist/seed/categories-seed-data.js +44 -6
- package/dist/seed/homepage-sections-seed-data.js +170 -9
- package/dist/seed/index.d.ts +2 -0
- package/dist/seed/index.js +13 -0
- package/dist/seed/model-composition-seed-data.d.ts +15 -0
- package/dist/seed/model-composition-seed-data.js +8918 -0
- package/dist/seed/part-glossary-seed-data.d.ts +15 -0
- package/dist/seed/part-glossary-seed-data.js +10729 -0
- package/dist/seed/products-standard-seed-data.js +52 -0
- package/dist/seed/seed-purpose.d.ts +103 -4
- package/dist/seed/seed-purpose.js +162 -11
- package/dist/seo/json-ld.d.ts +10 -1
- package/dist/seo/json-ld.js +9 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/id-generators.d.ts +30 -0
- package/dist/utils/id-generators.js +31 -1
- package/dist/utils/search-txt-builders.js +22 -0
- package/dist/utils/search-txt.d.ts +22 -0
- package/dist/utils/search-txt.js +17 -0
- package/dist/values/success-messages.d.ts +0 -4
- package/dist/values/success-messages.js +0 -5
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +292 -16
- package/dist/core/copilot-log.repository.d.ts +0 -44
- package/dist/core/copilot-log.repository.js +0 -61
- package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
- package/dist/features/copilot/components/AdminCopilotView.js +0 -51
- package/dist/features/copilot/components/index.d.ts +0 -2
- package/dist/features/copilot/components/index.js +0 -1
- package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
- package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
- package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
- package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
- package/dist/features/copilot/index.d.ts +0 -3
- 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
|
+
}
|