@mohasinac/appkit 4.45.0 → 4.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
  3. package/dist/_internal/server/features/categories/live-counts.js +72 -0
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
  5. package/dist/_internal/server/features/orders/adapters.js +29 -0
  6. package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
  7. package/dist/_internal/server/features/parts/assembly.js +246 -0
  8. package/dist/_internal/server/features/parts/data.d.ts +65 -0
  9. package/dist/_internal/server/features/parts/data.js +170 -0
  10. package/dist/_internal/server/features/parts/og.d.ts +34 -0
  11. package/dist/_internal/server/features/parts/og.js +66 -0
  12. package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
  13. package/dist/_internal/server/features/search/search-everything.js +120 -0
  14. package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
  15. package/dist/_internal/server/features/search/search-sources.js +138 -0
  16. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
  17. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
  18. package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
  19. package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
  20. package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
  21. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
  22. package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
  23. package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
  24. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
  26. package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
  27. package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
  28. package/dist/_internal/shared/actions/action-registry.js +36 -0
  29. package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
  30. package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
  31. package/dist/_internal/shared/features/shipping/protections.js +127 -0
  32. package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
  33. package/dist/_internal/shared/search/part-aliases.js +104 -0
  34. package/dist/constants/api-endpoints.d.ts +18 -12
  35. package/dist/constants/api-endpoints.js +6 -8
  36. package/dist/constants/field-names.d.ts +0 -2
  37. package/dist/constants/field-names.js +0 -2
  38. package/dist/constants/index.d.ts +1 -1
  39. package/dist/constants/index.js +1 -1
  40. package/dist/core/index.d.ts +0 -3
  41. package/dist/core/index.js +0 -2
  42. package/dist/errors/messages.d.ts +0 -7
  43. package/dist/errors/messages.js +0 -8
  44. package/dist/features/admin/components/AdminSectionsView.js +83 -32
  45. package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
  46. package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
  47. package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
  48. package/dist/features/admin/schemas/firestore.d.ts +15 -0
  49. package/dist/features/admin/types/index.d.ts +0 -1
  50. package/dist/features/auth/permissions/constants.d.ts +1 -1
  51. package/dist/features/auth/permissions/constants.js +0 -2
  52. package/dist/features/categories/api/[id]/description-template/route.js +40 -1
  53. package/dist/features/categories/components/BrandDetailPageView.js +14 -3
  54. package/dist/features/categories/components/BreadcrumbTrail.d.ts +24 -1
  55. package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
  56. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  57. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  58. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  59. package/dist/features/categories/repository/categories.repository.js +168 -22
  60. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  61. package/dist/features/categories/repository/category-counts.js +224 -0
  62. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  63. package/dist/features/categories/schemas/category-counts.js +116 -0
  64. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  65. package/dist/features/categories/schemas/firestore.js +0 -1
  66. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  67. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  68. package/dist/features/corporate/schemas/index.d.ts +4 -4
  69. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  70. package/dist/features/orders/types/index.d.ts +30 -0
  71. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  72. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  73. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  74. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  75. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  76. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  77. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  78. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  79. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  80. package/dist/features/parts/schemas/firestore.js +181 -0
  81. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  82. package/dist/features/parts/utils/colourway-match.js +98 -0
  83. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  84. package/dist/features/parts/utils/part-breakdown.js +77 -0
  85. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  86. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  87. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  88. package/dist/features/products/components/ProductTabsShell.js +4 -1
  89. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  90. package/dist/features/products/repository/category-assignment.js +304 -0
  91. package/dist/features/products/repository/products.repository.d.ts +58 -1
  92. package/dist/features/products/repository/products.repository.js +255 -28
  93. package/dist/features/products/schemas/firestore.d.ts +147 -6
  94. package/dist/features/products/schemas/firestore.js +49 -0
  95. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  96. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  97. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  98. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  99. package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
  100. package/dist/features/seller/components/SellerProductShell.js +30 -1
  101. package/dist/features/seller/components/SellerProductsView.js +129 -3
  102. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  103. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  104. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  105. package/dist/features/tester/seed-data/authored/index.js +2 -0
  106. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  107. package/dist/index.d.ts +11 -16
  108. package/dist/index.js +26 -18
  109. package/dist/next/routing/route-map.d.ts +6 -2
  110. package/dist/next/routing/route-map.js +14 -1
  111. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  112. package/dist/providers/db-firebase/base.repository.js +31 -0
  113. package/dist/repositories/index.d.ts +1 -2
  114. package/dist/repositories/index.js +0 -1
  115. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  116. package/dist/seed/_helpers/category-content-generated.js +1837 -0
  117. package/dist/seed/_helpers/category-content.d.ts +29 -0
  118. package/dist/seed/_helpers/category-content.js +98 -1
  119. package/dist/seed/_helpers/category-forest.js +104 -24
  120. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  121. package/dist/seed/_helpers/category-parts.js +4594 -0
  122. package/dist/seed/carousel-slides-seed-data.js +81 -8
  123. package/dist/seed/categories-seed-data.js +44 -6
  124. package/dist/seed/homepage-sections-seed-data.js +170 -9
  125. package/dist/seed/index.d.ts +2 -0
  126. package/dist/seed/index.js +13 -0
  127. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  128. package/dist/seed/model-composition-seed-data.js +8918 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  130. package/dist/seed/part-glossary-seed-data.js +10729 -0
  131. package/dist/seed/products-standard-seed-data.js +52 -0
  132. package/dist/seed/seed-purpose.d.ts +103 -4
  133. package/dist/seed/seed-purpose.js +162 -11
  134. package/dist/seo/json-ld.d.ts +10 -1
  135. package/dist/seo/json-ld.js +9 -1
  136. package/dist/server.d.ts +7 -0
  137. package/dist/server.js +11 -0
  138. package/dist/styles.css +1 -1
  139. package/dist/tailwind-utilities.css +1 -1
  140. package/dist/utils/id-generators.d.ts +30 -0
  141. package/dist/utils/id-generators.js +31 -1
  142. package/dist/utils/search-txt-builders.js +22 -0
  143. package/dist/utils/search-txt.d.ts +22 -0
  144. package/dist/utils/search-txt.js +17 -0
  145. package/dist/values/success-messages.d.ts +0 -4
  146. package/dist/values/success-messages.js +0 -5
  147. package/package.json +1 -1
  148. package/scripts/seed-cli.mjs +292 -16
  149. package/dist/core/copilot-log.repository.d.ts +0 -44
  150. package/dist/core/copilot-log.repository.js +0 -61
  151. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  152. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  153. package/dist/features/copilot/components/index.d.ts +0 -2
  154. package/dist/features/copilot/components/index.js +0 -1
  155. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  156. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  157. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  158. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  159. package/dist/features/copilot/index.d.ts +0 -3
  160. package/dist/features/copilot/index.js +0 -3
@@ -0,0 +1,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
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * D12 — what a bey is MADE OF, rendered on its model page and its listings.
3
+ *
4
+ * The corpus knows the composition of 259 models down to the slot, with a
5
+ * per-part weight on 98 of them. Until this existed that data had no reader,
6
+ * and a buyer reading "Storm Pegasus 105RF" had no way to learn that `105` is
7
+ * a spin-track height and `RF` is a rubber flat tip.
8
+ *
9
+ * ── 🛑 THE SLOT SET IS DATA, AND IT MERGES ─────────────────────────────────
10
+ *
11
+ * Nothing here enumerates slots. A `Record<Generation, Slot[]>` cannot express
12
+ * what the corpus actually contains — `Spin Gear + Blade Base → RUNNING CORE`
13
+ * (HMS) and `SPIN TRACK + TIP combined: F:D` (4D) — so the rows are whatever
14
+ * the document carries, in the order it carries them, and `slotMerges` is
15
+ * rendered as a note rather than being flattened away. A system with a
16
+ * combined module genuinely has fewer rows, and padding it to a fixed five
17
+ * would claim a part that does not exist.
18
+ *
19
+ * ── 🛑 NO MONEY ON THIS PANEL ──────────────────────────────────────────────
20
+ *
21
+ * It renders on a PUBLIC page, so a price here would need `<GatedPrice>` and
22
+ * would make a server component a client one. A part's own page carries its
23
+ * listings; this panel carries facts about mouldings. There is nothing to gate
24
+ * because there is nothing to leak.
25
+ */
26
+ import React from "react";
27
+ import { type ModelCompositionDocument, type PartGlossaryDocument } from "../schemas/firestore";
28
+ export interface ModelCompositionPanelProps {
29
+ compositions: readonly ModelCompositionDocument[];
30
+ /** Glossary rows for the slots' `partId`s, so a row can link and show weight. */
31
+ parts?: Map<string, PartGlossaryDocument>;
32
+ /** `(slot, codeOrName)` → the part's page. Supplied by the consumer's route map. */
33
+ hrefForPart: (slot: string, tail: string) => string;
34
+ /** Heading level, so this sits correctly inside a tab or a page section. */
35
+ headingLevel?: 2 | 3 | 4;
36
+ }
37
+ export declare function ModelCompositionPanel({ compositions, parts, hrefForPart, headingLevel, }: ModelCompositionPanelProps): React.JSX.Element | null;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Stack, Row, Div, Heading, Text, Span, TextLink, Badge } from "../../../ui/index.js";
3
+ import { PART_SLOT_LABEL, } from "../schemas/firestore.js";
4
+ /** `performance-tip--rf` → `rf`. The id's tail IS the URL segment. */
5
+ function tailOf(partId) {
6
+ const i = partId.indexOf("--");
7
+ return i >= 0 ? partId.slice(i + 2) : partId;
8
+ }
9
+ /**
10
+ * One slot row.
11
+ *
12
+ * Extracted because `audit-code-quality` flagged the inline version at
13
+ * brace-depth 6, and it was right — "for each composition, for each slot, if
14
+ * the part resolved, if a weight exists" is four questions in one block, and
15
+ * the nesting is what hides the two judgements this row actually makes: which
16
+ * weight wins, and whether the corpus's own slot label adds anything.
17
+ */
18
+ function SlotRow({ slot, part, hrefForPart, }) {
19
+ // The slot's own figure wins over the glossary's: a composition records the
20
+ // weight of the part AS FITTED, and a glossary row records the mould.
21
+ const weight = slot.weightG ?? part?.weightG;
22
+ const canonical = PART_SLOT_LABEL[slot.slotKey] ?? slot.slotKey;
23
+ const showCorpusLabel = Boolean(slot.slotLabel) && slot.slotLabel.toLowerCase() !== canonical.toLowerCase();
24
+ return (_jsxs(Row, { gap: "md", align: "start", padding: "inline", surface: "card", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsxs(Div, { className: "w-36 flex-shrink-0", children: [_jsx(Text, { size: "xs", color: "muted", transform: "uppercase", children: canonical }), showCorpusLabel && (_jsx(Text, { size: "xs", color: "faint", children: slot.slotLabel }))] }), _jsxs(Div, { className: "flex-1 min-w-0", children: [slot.partId ? (_jsx(TextLink, { href: hrefForPart(slot.slotKey, tailOf(slot.partId)), children: slot.partName })) : (_jsx(Text, { color: "primary", children: slot.partName })), typeof weight === "number" && (_jsxs(Span, { size: "xs", color: "muted", children: [" ", "\u00B7 ", weight, " g"] }))] })] }));
25
+ }
26
+ export function ModelCompositionPanel({ compositions, parts, hrefForPart, headingLevel = 3, }) {
27
+ if (compositions.length === 0)
28
+ return null;
29
+ return (_jsx(Stack, { gap: "lg", className: "min-w-0", children: compositions.map((comp) => {
30
+ const slots = comp.slots ?? [];
31
+ const merges = comp.slotMerges ?? [];
32
+ return (_jsxs(Stack, { gap: "sm", className: "min-w-0", children: [compositions.length > 1 && (_jsx(Heading, { level: headingLevel, children: comp.name })), _jsxs(Row, { gap: "xs", wrap: true, align: "center", className: "min-w-0", children: [comp.system && _jsx(Badge, { variant: "default", children: comp.system }), comp.series && _jsx(Badge, { variant: "default", children: comp.series }), comp.productCode && _jsx(Badge, { variant: "info", children: comp.productCode })] }), slots.length === 0 ? (_jsx(Text, { color: "muted", size: "sm", children: "No part breakdown recorded for this model yet." })) : (_jsx(Stack, { gap: "xs", className: "min-w-0", children: slots.map((s, i) => (_jsx(SlotRow, { slot: s, part: s.partId ? parts?.get(s.partId) : undefined, hrefForPart: hrefForPart }, `${comp.id}-${s.slotKey}-${i}`))) })), merges.length > 0 && (_jsx(Text, { size: "xs", color: "muted", children: merges.map((m, i) => (_jsxs(Span, { children: [i > 0 ? " · " : "", m.merges.map((x) => PART_SLOT_LABEL[x] ?? x).join(" + "), " are one moulded module here (", PART_SLOT_LABEL[m.into] ?? m.into, ")."] }, i))) }))] }, comp.id));
33
+ }) }));
34
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * D12 — one part's public page.
3
+ *
4
+ * The question it answers is *"what does RF mean on a Beyblade"*, which is a
5
+ * real search with a real answer nobody in the fifteen-site crawl owns. It
6
+ * also answers the commerce question beside it: raikages is the ONLY source in
7
+ * the crawl with component-level pricing (energy rings ₹149–249, tips
8
+ * ₹129–149), and they sell parts without ever explaining them.
9
+ *
10
+ * ── 🛑 NO PRICES ON THIS PAGE, DELIBERATELY ────────────────────────────────
11
+ *
12
+ * Prices are hidden from signed-out visitors, so every amount on a public
13
+ * surface needs `<GatedPrice>` — a client component. The listing rows here
14
+ * carry a title, a condition and a link; the price lives one click away on the
15
+ * listing itself, where the gate already is. Same rule as `/search`: a row
16
+ * with nothing to gate has nothing to leak, and this stays a server component.
17
+ *
18
+ * ── 🛑 "USED BY" IS A STORED ARRAY, NOT A QUERY ────────────────────────────
19
+ *
20
+ * `usedBy` is written by the seed generator, so the model list is one
21
+ * `getAll` over ids already in hand — no index, no scan, bounded by how many
22
+ * models really use the part. 681 of 753 parts carry one.
23
+ */
24
+ import React from "react";
25
+ import { type PartGlossaryDocument, type ModelCompositionDocument } from "../schemas/firestore";
26
+ /** One live listing row. Deliberately money-free — see the header. */
27
+ export interface PartListingRow {
28
+ id: string;
29
+ title: string;
30
+ slug?: string;
31
+ condition?: string;
32
+ href: string;
33
+ }
34
+ export interface PartDetailPageViewProps {
35
+ part: PartGlossaryDocument;
36
+ /** Models that use it, hydrated from `usedBy`. */
37
+ usedBy: readonly ModelCompositionDocument[];
38
+ /** Live listings matching the part, from the shared public product query. */
39
+ listings: readonly PartListingRow[];
40
+ /** `total` is a FLOOR when the bounded fetch saturated. */
41
+ listingsTruncated?: boolean;
42
+ /** Where "see all listings" goes — a real search, not a fabricated filter. */
43
+ allListingsHref: string;
44
+ /** A model's own page, when the composition resolved to a category. */
45
+ hrefForModel: (categoryId: string) => string;
46
+ hrefForClassification: (slot: string) => string;
47
+ }
48
+ export declare function PartDetailPageView({ part, usedBy, listings, listingsTruncated, allListingsHref, hrefForModel, hrefForClassification, }: PartDetailPageViewProps): React.JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Section, Container, Stack, Row, Div, Heading, Text, Span, TextLink, Badge, } from "../../../ui/index.js";
3
+ import { PART_SLOT_LABEL, } from "../schemas/firestore.js";
4
+ export function PartDetailPageView({ part, usedBy, listings, listingsTruncated, allListingsHref, hrefForModel, hrefForClassification, }) {
5
+ const classification = PART_SLOT_LABEL[part.slot] ?? part.classification ?? part.slot;
6
+ /*
7
+ * 🛑 Only 23 of 753 parts carry a code, and that is a fact about the sources
8
+ * rather than missing data. So the code renders as a badge when present and
9
+ * nothing stands in for it when absent — an em-dash beside "Code" would
10
+ * imply we looked it up and found none.
11
+ */
12
+ const aliases = (part.aliases ?? []).filter((a) => a && a !== part.name && a !== part.code);
13
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Text, { size: "sm", color: "muted", children: _jsx(TextLink, { href: hrefForClassification(part.slot), children: classification }) }), _jsx(Heading, { level: 1, children: part.name }), _jsxs(Row, { gap: "xs", wrap: true, align: "center", className: "min-w-0", children: [part.code && _jsx(Badge, { variant: "info", children: part.code }), typeof part.weightG === "number" && (_jsxs(Badge, { variant: "default", children: [part.weightG, " g"] }))] }), aliases.length > 0 && (_jsxs(Text, { size: "sm", color: "muted", children: ["Also written: ", aliases.join(" · ")] }))] }), _jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: usedBy.length > 0
14
+ ? `Used by ${usedBy.length} ${usedBy.length === 1 ? "model" : "models"}`
15
+ : "Used by" }), usedBy.length === 0 ? (_jsx(Text, { color: "muted", size: "sm", children: "No model in our database records this part yet." })) : (_jsx(Stack, { gap: "xs", className: "min-w-0", children: usedBy.map((m) => (_jsxs(Row, { gap: "sm", align: "center", padding: "inline", surface: "card", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [m.categoryId ? (_jsx(TextLink, { href: hrefForModel(m.categoryId), children: m.name })) : (
16
+ /* 49 of 259 compositions did not join to a category.
17
+ A link to a page that does not exist is worse than
18
+ plain text, so the name renders unlinked. */
19
+ _jsx(Text, { color: "primary", children: m.name })), m.system && (_jsxs(Span, { size: "xs", color: "muted", children: [" ", "\u00B7 ", m.system] }))] }), m.productCode && (_jsx(Text, { size: "xs", color: "faint", className: "flex-shrink-0", children: m.productCode }))] }, m.id))) }))] }), _jsxs(Stack, { gap: "sm", className: "min-w-0", children: [_jsxs(Row, { justify: "between", align: "center", wrap: true, gap: "sm", className: "min-w-0", children: [_jsxs(Heading, { level: 2, size: "lg", children: ["Listings", listingsTruncated && (_jsxs(Span, { size: "sm", color: "muted", children: [" ", "(", listings.length, "+)"] }))] }), listings.length > 0 && (_jsx(TextLink, { href: allListingsHref, children: "See all listings \u2192" }))] }), listings.length === 0 ? (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Text, { color: "muted", size: "sm", children: "Nobody is selling this part right now." }), _jsx(TextLink, { href: allListingsHref, children: "Search the catalogue \u2192" })] })) : (_jsx(Stack, { gap: "xs", className: "min-w-0", children: listings.map((l) => (_jsxs(Div, { surface: "card", padding: "sm", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsx(TextLink, { href: l.href, children: l.title }), l.condition && (_jsx(Text, { size: "xs", color: "muted", children: l.condition }))] }, l.id))) }))] }), part.source && (_jsxs(Text, { size: "xs", color: "faint", children: ["Source: ", part.source] }))] }) }) }));
20
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * D12 — the parts database index, and one classification's listing.
3
+ *
4
+ * ── 🛑 THE INDEX COSTS ZERO FIRESTORE READS ────────────────────────────────
5
+ *
6
+ * The 20 classifications come from `ALL_PART_SLOTS` + `PART_SLOT_LABEL`, both
7
+ * compile-checked, so the index is static. The tempting alternative — showing
8
+ * a count per classification — is one query per slot, or a 753-document scan,
9
+ * on a page that exists only to route. Rule #6 budgets ~3 round trips for a
10
+ * whole render; 20 is not a rounding error.
11
+ *
12
+ * A count would also be the thing most likely to be wrong later: it is a
13
+ * denormalised mirror of a collection the seed rewrites (Root Cause #42), and
14
+ * the honest version of "we don't know how many" is to not say a number.
15
+ *
16
+ * The classification page below DOES read — one equality, bounded at 120,
17
+ * which clears the largest real bucket (87 attack rings, measured).
18
+ */
19
+ import React from "react";
20
+ import { type PartGlossaryDocument, type PartSlot } from "../schemas/firestore";
21
+ export interface PartsIndexPageViewProps {
22
+ hrefForClassification: (slot: string) => string;
23
+ }
24
+ export declare function PartsIndexPageView({ hrefForClassification }: PartsIndexPageViewProps): React.JSX.Element;
25
+ export interface PartClassificationPageViewProps {
26
+ slot: PartSlot;
27
+ parts: readonly PartGlossaryDocument[];
28
+ /** The bounded fetch saturated, so the count renders as a floor. */
29
+ truncated?: boolean;
30
+ hrefForPart: (slot: string, tail: string) => string;
31
+ partsIndexHref: string;
32
+ }
33
+ export declare function PartClassificationPageView({ slot, parts, truncated, hrefForPart, partsIndexHref, }: PartClassificationPageViewProps): React.JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Section, Container, Stack, Row, Div, Grid, Heading, Text, Span, TextLink, Badge, } from "../../../ui/index.js";
3
+ import { ALL_PART_SLOTS, PART_SLOT_LABEL, } from "../schemas/firestore.js";
4
+ export function PartsIndexPageView({ hrefForClassification }) {
5
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 1, children: "Parts database" }), _jsxs(Text, { color: "muted", children: ["Every component we have a record of, by what it is. A code like", " ", _jsx(Span, { weight: "semibold", children: "RF" }), " or ", _jsx(Span, { weight: "semibold", children: "105" }), " means different things in different generations, so parts are grouped by their classification rather than by code."] })] }), _jsx(Grid, { cols: "navTiles", gap: "sm", className: "min-w-0", children: ALL_PART_SLOTS.map((slot) => (_jsx(Div, { surface: "card", padding: "sm", rounded: "md", border: "subtle", className: "min-w-0", children: _jsx(TextLink, { href: hrefForClassification(slot), children: PART_SLOT_LABEL[slot] }) }, slot))) })] }) }) }));
6
+ }
7
+ /** `performance-tip--rf` → `rf`. The id's tail IS the URL segment. */
8
+ function tailOf(partId) {
9
+ const i = partId.indexOf("--");
10
+ return i >= 0 ? partId.slice(i + 2) : partId;
11
+ }
12
+ export function PartClassificationPageView({ slot, parts, truncated, hrefForPart, partsIndexHref, }) {
13
+ const label = PART_SLOT_LABEL[slot] ?? slot;
14
+ return (_jsx(Section, { padding: "y-lg", children: _jsx(Container, { size: "lg", children: _jsxs(Stack, { gap: "lg", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", color: "muted", children: _jsx(TextLink, { href: partsIndexHref, children: "Parts" }) }), _jsx(Heading, { level: 1, children: label }), _jsx(Text, { color: "muted", size: "sm", children: parts.length === 0
15
+ ? "No parts recorded in this classification yet."
16
+ : `${parts.length}${truncated ? "+" : ""} recorded.` })] }), parts.length > 0 && (_jsx(Stack, { gap: "xs", className: "min-w-0", children: parts.map((p) => (_jsxs(Row, { gap: "sm", align: "center", padding: "inline", surface: "card", rounded: "md", border: "subtle", className: "min-w-0", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [_jsx(TextLink, { href: hrefForPart(p.slot, tailOf(p.id)), children: p.name }), typeof p.weightG === "number" && (_jsxs(Span, { size: "xs", color: "muted", children: [" ", "\u00B7 ", p.weightG, " g"] }))] }), p.code && (_jsx(Badge, { variant: "info", className: "flex-shrink-0", children: p.code }))] }, p.id))) }))] }) }) }));
17
+ }