@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.
- 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/BreadcrumbTrail.d.ts +24 -1
- package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
- 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/CategoryInlineSelect.d.ts +17 -1
- package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
- package/dist/features/seller/components/SellerProductShell.js +30 -1
- 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-generated.d.ts +24 -0
- package/dist/seed/_helpers/category-content-generated.js +1837 -0
- 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,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
|
+
}
|