@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,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
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E4/D12 — the PART GLOSSARY and MODEL COMPOSITION collections.
|
|
3
|
+
*
|
|
4
|
+
* Seeded from `docs/research/part-composition-2026-10/`, which is extracted
|
|
5
|
+
* from the local corpus by two committed scripts. Nothing here is hand-typed.
|
|
6
|
+
*
|
|
7
|
+
* ── 🛑 KEYED ON (classification, code), NEVER ON THE CODE ALONE ────────────
|
|
8
|
+
*
|
|
9
|
+
* `105` is a Metal Fight spin-track height, a bare number in Burst is a disc,
|
|
10
|
+
* and `1-60` is a Beyblade X ratchet. Code-alone would explain a Burst disc as
|
|
11
|
+
* a spin track — the same namespacing argument as the feature `group` field,
|
|
12
|
+
* and the reason the document id carries both halves.
|
|
13
|
+
*
|
|
14
|
+
* ── 🛑 WHY `modelComposition` IS A SIBLING, NOT A FIELD ON `categories` ────
|
|
15
|
+
*
|
|
16
|
+
* A named model IS a category leaf (section 4.4), so composition "belongs" to that
|
|
17
|
+
* document — and putting it there is still wrong for two measured reasons.
|
|
18
|
+
* `handleCategoryWrite` calls `notifyRevalidate` as its FIRST statement,
|
|
19
|
+
* unconditionally, so any write to a category drops that page's ISR entry on a
|
|
20
|
+
* project paused at 15.5× its ISR cap. And `CategoryDetailPageView` is already
|
|
21
|
+
* at Rule #6's read ceiling with seven parallel reads. Same conclusion as
|
|
22
|
+
* `categoryCounts` and `priceIndex`, for the same reasons.
|
|
23
|
+
*
|
|
24
|
+
* ── 🛑 THE SLOT SET IS DATA, KEYED ON `system` ─────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* Slots MERGE: the corpus contains `Spin Gear + Blade Base → RUNNING CORE`
|
|
27
|
+
* (HMS) and `SPIN TRACK + TIP combined: F:D` (4D), 13 such lines extracted. So
|
|
28
|
+
* a fixed field-set per generation cannot express the data, and a
|
|
29
|
+
* `Record<Generation, Slot[]>` would be the hand-written enumeration of Root
|
|
30
|
+
* Cause #61 with a merge case it structurally cannot hold.
|
|
31
|
+
*/
|
|
32
|
+
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
33
|
+
export declare const PART_GLOSSARY_COLLECTION: "partGlossary";
|
|
34
|
+
export declare const MODEL_COMPOSITION_COLLECTION: "modelComposition";
|
|
35
|
+
/**
|
|
36
|
+
* The canonical slot keys, as extracted. Mirrors `SLOT_VOCAB` in
|
|
37
|
+
* `scripts/extract-part-composition.mjs` — the script is the source and this
|
|
38
|
+
* is the type, so a slot that appears in one and not the other is a
|
|
39
|
+
* compile-visible gap rather than a silent one.
|
|
40
|
+
*/
|
|
41
|
+
export declare const PART_SLOT_VALUES: {
|
|
42
|
+
readonly BIT_CHIP: "bit-chip";
|
|
43
|
+
readonly ATTACK_RING: "attack-ring";
|
|
44
|
+
readonly WEIGHT_DISK: "weight-disk";
|
|
45
|
+
readonly SPIN_GEAR: "spin-gear";
|
|
46
|
+
readonly ENGINE_GEAR: "engine-gear";
|
|
47
|
+
readonly BLADE_BASE: "blade-base";
|
|
48
|
+
readonly RUNNING_CORE: "running-core";
|
|
49
|
+
readonly FACE_BOLT: "face-bolt";
|
|
50
|
+
readonly ENERGY_RING: "energy-ring";
|
|
51
|
+
readonly FUSION_WHEEL: "fusion-wheel";
|
|
52
|
+
readonly CHROME_WHEEL: "chrome-wheel";
|
|
53
|
+
readonly SPIN_TRACK: "spin-track";
|
|
54
|
+
readonly PERFORMANCE_TIP: "performance-tip";
|
|
55
|
+
readonly LAYER: "layer";
|
|
56
|
+
readonly DISC: "disc";
|
|
57
|
+
readonly DRIVER: "driver";
|
|
58
|
+
readonly ARMOUR: "armour";
|
|
59
|
+
readonly BLADE: "blade";
|
|
60
|
+
readonly RATCHET: "ratchet";
|
|
61
|
+
readonly BIT: "bit";
|
|
62
|
+
};
|
|
63
|
+
export type PartSlot = (typeof PART_SLOT_VALUES)[keyof typeof PART_SLOT_VALUES];
|
|
64
|
+
/**
|
|
65
|
+
* Buyer-facing label per slot.
|
|
66
|
+
*
|
|
67
|
+
* 🛑 A `Record<PartSlot, string>`, so a 21st slot is a COMPILE ERROR rather
|
|
68
|
+
* than a page heading that reads `performance-tip`. That is the whole reason
|
|
69
|
+
* it is not derived by title-casing the key: "Armour" is not "armour"
|
|
70
|
+
* title-cased in the corpus, and `bit-chip` is "Bit Chip" rather than
|
|
71
|
+
* "Bit-chip".
|
|
72
|
+
*
|
|
73
|
+
* Every label here was read off the live documents' own `classification`
|
|
74
|
+
* field, not invented — all 20 slots are populated (87 attack rings down to
|
|
75
|
+
* 4 armour pieces), so none of these is a guess about an empty bucket.
|
|
76
|
+
*/
|
|
77
|
+
export declare const PART_SLOT_LABEL: Record<PartSlot, string>;
|
|
78
|
+
/**
|
|
79
|
+
* Which CATEGORY node a part of this classification is filed under.
|
|
80
|
+
*
|
|
81
|
+
* ── 🛑 A PART IS A PLAIN CATEGORY LEAF, `categoryType` OMITTED ──────────────
|
|
82
|
+
*
|
|
83
|
+
* The user's instruction was *"treat parts as also categories, let admins
|
|
84
|
+
* create or add more of them"*, and this map is how both halves are answered at
|
|
85
|
+
* once: a part leaf under these nodes gets `deriveTaxonomy`'s ancestor chain,
|
|
86
|
+
* `countersReconcile`'s tally, the sitemap, breadcrumbs, SEO, an OG image and
|
|
87
|
+
* **the existing admin category editor** — create, edit and delete included —
|
|
88
|
+
* for no new code.
|
|
89
|
+
*
|
|
90
|
+
* 🛑 `categoryType` is OMITTED rather than given a new `"part"` value, and that
|
|
91
|
+
* is the decisive design point. 92 files read `categoryType`, and section 4.4
|
|
92
|
+
* records that `categoryType: "sublisting"` carries SEVEN live defects
|
|
93
|
+
* precisely because a new discriminator value opts out of every path that
|
|
94
|
+
* works — `deriveTaxonomy` never reads it, `countersReconcile` leaves its count
|
|
95
|
+
* permanently 0, and the sitemap excludes it by name. An omitted type changes
|
|
96
|
+
* none of those 92 files and inherits every one of those paths.
|
|
97
|
+
*
|
|
98
|
+
* ── 🛑 KEYED ON THE SLOT ALONE, NOT ON (slot, generation) ───────────────────
|
|
99
|
+
*
|
|
100
|
+
* The first version derived a part's generation from its `usedBy` models, which
|
|
101
|
+
* measured **681 of 753 placeable, 72 unplaceable and 1 spanning two
|
|
102
|
+
* generations**. That was solving a problem that does not exist: each of the 20
|
|
103
|
+
* classification names belongs to exactly ONE generation's vocabulary. A
|
|
104
|
+
* "Performance Tip" is Metal-era by definition, a "Driver" is Burst, a "Bit" is
|
|
105
|
+
* X and a "Bit Chip" is Plastic Gen — the words are not shared. Keying on the
|
|
106
|
+
* slot places **all 753** and cannot be wrong about a part whose `usedBy` is
|
|
107
|
+
* thin.
|
|
108
|
+
*
|
|
109
|
+
* A `Record<PartSlot, string>`, so a 21st classification cannot compile without
|
|
110
|
+
* being given a home — and a part leaf with no parent is a tier-0 orphan, which
|
|
111
|
+
* is the live defect the store sublisting POST still has.
|
|
112
|
+
*/
|
|
113
|
+
export declare const PART_SLOT_CATEGORY: Record<PartSlot, string>;
|
|
114
|
+
/** Every slot key, for an index page that must not hand-write the list. */
|
|
115
|
+
export declare const ALL_PART_SLOTS: readonly PartSlot[];
|
|
116
|
+
/**
|
|
117
|
+
* A URL segment to a slot, or `null`.
|
|
118
|
+
*
|
|
119
|
+
* 🛑 Returns `null` rather than casting. A bare `as PartSlot` on a URL segment
|
|
120
|
+
* is how `/parts/nonsense/x` becomes a doc read for `nonsense--x` that quietly
|
|
121
|
+
* misses — the route must 404, which it can only do if the guard says so.
|
|
122
|
+
*/
|
|
123
|
+
export declare function partSlotFromSegment(segment: string): PartSlot | null;
|
|
124
|
+
/** One part. */
|
|
125
|
+
export interface PartGlossaryDocument extends BaseDocument {
|
|
126
|
+
/** `${slot}--${codeOrSlug}` — both halves, because a code is not unique. */
|
|
127
|
+
id: string;
|
|
128
|
+
/** Buyer-facing label: "Performance Tip", "Fusion Wheel". */
|
|
129
|
+
classification: string;
|
|
130
|
+
/** The canonical slot key this classification maps to. */
|
|
131
|
+
slot: PartSlot;
|
|
132
|
+
/**
|
|
133
|
+
* The short code as printed on the part. `null` for a part the corpus knows
|
|
134
|
+
* only by name.
|
|
135
|
+
*
|
|
136
|
+
* 🛑 `null`, not `""`. An empty string reads as a declared empty code; 736
|
|
137
|
+
* of 759 parts genuinely have no code in any source we hold, and that is a
|
|
138
|
+
* fact about the sources rather than missing data.
|
|
139
|
+
*/
|
|
140
|
+
code: string | null;
|
|
141
|
+
/** The spoken name — what a buyer types. */
|
|
142
|
+
name: string;
|
|
143
|
+
/** Grams, when a source confirmed one. 97 of 759 do. */
|
|
144
|
+
weightG?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Model ids that use this part — the single most useful thing a part page
|
|
147
|
+
* says, and the reason the glossary is worth having as a collection rather
|
|
148
|
+
* than a lookup table.
|
|
149
|
+
*/
|
|
150
|
+
usedBy?: string[];
|
|
151
|
+
/** Code, name and the spoken form of the code — feeds `searchTxt`. */
|
|
152
|
+
aliases?: string[];
|
|
153
|
+
/** Which extractor source contributed the row. */
|
|
154
|
+
source?: string;
|
|
155
|
+
searchTxt?: string[];
|
|
156
|
+
}
|
|
157
|
+
/** One slot of one model's composition. */
|
|
158
|
+
export interface ModelSlot {
|
|
159
|
+
slotKey: PartSlot;
|
|
160
|
+
/** The corpus's own term for the slot, e.g. "METAL ATTACK RING (MAR)". */
|
|
161
|
+
slotLabel: string;
|
|
162
|
+
partName: string;
|
|
163
|
+
weightG?: number;
|
|
164
|
+
/** The `partGlossary` id, when the part resolved to a glossary row. */
|
|
165
|
+
partId?: string;
|
|
166
|
+
/**
|
|
167
|
+
* I9 — which COLOURWAY of that part this model needs.
|
|
168
|
+
*
|
|
169
|
+
* ── 🛑 THE CASE THIS EXISTS FOR, in the user's own words ────────────────
|
|
170
|
+
*
|
|
171
|
+
* *"there is a part called metal ball, it comes with 2 beyblades — Poison
|
|
172
|
+
* Zurafa S130MB and L-Drago Guardian S130MB. But the L-Drago version is very
|
|
173
|
+
* rare and costly and is in silver, while the Zurafa one is transparent black
|
|
174
|
+
* with a metal tip. Both behave the same way and have the same design, but to
|
|
175
|
+
* make an L-Drago bey I need the silver one and for Zurafa the black
|
|
176
|
+
* transparent one. Same with their S130s."*
|
|
177
|
+
*
|
|
178
|
+
* So: ONE glossary row (`performance-tip--mb`), same mould, same weight, same
|
|
179
|
+
* stats — and TWO physical variants that are NOT interchangeable and differ
|
|
180
|
+
* wildly in value. No stat distinguishes them.
|
|
181
|
+
*
|
|
182
|
+
* ── 🛑 WHY THIS FIELD RATHER THAN MATCHING ON `partId` ALONE ────────────
|
|
183
|
+
*
|
|
184
|
+
* The "buildable now" rollup would otherwise report *"you can build an
|
|
185
|
+
* L-Drago Guardian S130MB"* while the only MB on the shelf is the Zurafa
|
|
186
|
+
* black. A confident, specific, wrong answer with no error anywhere — and the
|
|
187
|
+
* seller discovers it holding the wrong part. Matching has to be
|
|
188
|
+
* `(partId, colourway)`.
|
|
189
|
+
*
|
|
190
|
+
* ── 🛑 UNSET MEANS "ANY", AND NEVER "THE FIRST ONE FOUND" ───────────────
|
|
191
|
+
*
|
|
192
|
+
* Most slots are genuinely colour-agnostic. Forcing a value everywhere would
|
|
193
|
+
* invent a constraint the corpus does not support, and defaulting to whatever
|
|
194
|
+
* happened to be in the bin is the same class of guess the price index
|
|
195
|
+
* refuses when its sample is too small.
|
|
196
|
+
*/
|
|
197
|
+
expectedColourway?: string;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Two slots collapsing into one, in systems where they are a single moulded
|
|
201
|
+
* module.
|
|
202
|
+
*
|
|
203
|
+
* 🛑 This is why the slot set cannot be a per-generation constant: HMS has a
|
|
204
|
+
* `RUNNING CORE` where Plastic Gen has a separate Spin Gear and Blade Base,
|
|
205
|
+
* and 4D has a combined `4D Bottom` where MFB has a Spin Track and a tip.
|
|
206
|
+
*/
|
|
207
|
+
export interface ModelSlotMerge {
|
|
208
|
+
into: PartSlot;
|
|
209
|
+
merges: PartSlot[];
|
|
210
|
+
}
|
|
211
|
+
export interface ModelCompositionDocument extends BaseDocument {
|
|
212
|
+
/** The corpus doc id, e.g. `gen2-mfb-big-bang-pegasus`. */
|
|
213
|
+
id: string;
|
|
214
|
+
name: string;
|
|
215
|
+
/** `gen1` … `gen4`, normalised — never the raw freeform header. */
|
|
216
|
+
generation: string | null;
|
|
217
|
+
/**
|
|
218
|
+
* The parenthetical from the raw header, kept because it is often the
|
|
219
|
+
* interesting part — but never a value anything groups or filters on
|
|
220
|
+
* (Root Cause #33).
|
|
221
|
+
*/
|
|
222
|
+
generationNote?: string;
|
|
223
|
+
system?: string | null;
|
|
224
|
+
series?: string | null;
|
|
225
|
+
productCode?: string | null;
|
|
226
|
+
slots: ModelSlot[];
|
|
227
|
+
slotMerges?: ModelSlotMerge[];
|
|
228
|
+
/** The corpus file, so a claim can be traced back. */
|
|
229
|
+
source?: string;
|
|
230
|
+
/** The named-model category this composition describes, when one exists. */
|
|
231
|
+
categoryId?: string;
|
|
232
|
+
searchTxt?: string[];
|
|
233
|
+
}
|
|
234
|
+
/** `(slot, code|name)` → document id. The ONE place this is composed. */
|
|
235
|
+
export declare function partGlossaryId(slot: PartSlot, codeOrName: string): string;
|
|
236
|
+
/**
|
|
237
|
+
* Whether a listing of this TYPE is the physical assembly the composition
|
|
238
|
+
* describes.
|
|
239
|
+
*
|
|
240
|
+
* 🛑 Found by measuring rather than by reasoning, and it is a real defect the
|
|
241
|
+
* first version of D12 shipped: of 71 live products exactly ONE files under a
|
|
242
|
+
* leaf that has a composition, and that one is
|
|
243
|
+
* `art-dranzer-phoenix-poster` — so the only listing in the whole catalogue
|
|
244
|
+
* that got a "Parts" tab was a POSTER, offering an attack ring, a weight disk
|
|
245
|
+
* and a blade base as its contents. Nothing errored; it rendered a correct
|
|
246
|
+
* part list for the wrong kind of object.
|
|
247
|
+
*
|
|
248
|
+
* The distinction is whether the listing IS the model or merely DEPICTS or
|
|
249
|
+
* REFERENCES it. A print, a sticker sheet and a redemption code all sit under
|
|
250
|
+
* the model's leaf on purpose (section 4.4: a sticker sheet belongs beside the
|
|
251
|
+
* bey it is for) and none of them is made of its parts.
|
|
252
|
+
*
|
|
253
|
+
* 🛑 A `Record<ListingType, boolean>`, so a tenth listing type cannot compile
|
|
254
|
+
* without answering the question — the lesson of Root Cause #61, which this
|
|
255
|
+
* codebase has paid for ten hand-written enumerations of one union.
|
|
256
|
+
*/
|
|
257
|
+
export declare const LISTING_TYPE_HAS_COMPOSITION: Record<import("../../products/types/index").ListingType, boolean>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
export const PART_GLOSSARY_COLLECTION = "partGlossary";
|
|
2
|
+
export const MODEL_COMPOSITION_COLLECTION = "modelComposition";
|
|
3
|
+
/**
|
|
4
|
+
* The canonical slot keys, as extracted. Mirrors `SLOT_VOCAB` in
|
|
5
|
+
* `scripts/extract-part-composition.mjs` — the script is the source and this
|
|
6
|
+
* is the type, so a slot that appears in one and not the other is a
|
|
7
|
+
* compile-visible gap rather than a silent one.
|
|
8
|
+
*/
|
|
9
|
+
export const PART_SLOT_VALUES = {
|
|
10
|
+
BIT_CHIP: "bit-chip",
|
|
11
|
+
ATTACK_RING: "attack-ring",
|
|
12
|
+
WEIGHT_DISK: "weight-disk",
|
|
13
|
+
SPIN_GEAR: "spin-gear",
|
|
14
|
+
ENGINE_GEAR: "engine-gear",
|
|
15
|
+
BLADE_BASE: "blade-base",
|
|
16
|
+
RUNNING_CORE: "running-core",
|
|
17
|
+
FACE_BOLT: "face-bolt",
|
|
18
|
+
ENERGY_RING: "energy-ring",
|
|
19
|
+
FUSION_WHEEL: "fusion-wheel",
|
|
20
|
+
CHROME_WHEEL: "chrome-wheel",
|
|
21
|
+
SPIN_TRACK: "spin-track",
|
|
22
|
+
PERFORMANCE_TIP: "performance-tip",
|
|
23
|
+
LAYER: "layer",
|
|
24
|
+
DISC: "disc",
|
|
25
|
+
DRIVER: "driver",
|
|
26
|
+
ARMOUR: "armour",
|
|
27
|
+
BLADE: "blade",
|
|
28
|
+
RATCHET: "ratchet",
|
|
29
|
+
BIT: "bit",
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Buyer-facing label per slot.
|
|
33
|
+
*
|
|
34
|
+
* 🛑 A `Record<PartSlot, string>`, so a 21st slot is a COMPILE ERROR rather
|
|
35
|
+
* than a page heading that reads `performance-tip`. That is the whole reason
|
|
36
|
+
* it is not derived by title-casing the key: "Armour" is not "armour"
|
|
37
|
+
* title-cased in the corpus, and `bit-chip` is "Bit Chip" rather than
|
|
38
|
+
* "Bit-chip".
|
|
39
|
+
*
|
|
40
|
+
* Every label here was read off the live documents' own `classification`
|
|
41
|
+
* field, not invented — all 20 slots are populated (87 attack rings down to
|
|
42
|
+
* 4 armour pieces), so none of these is a guess about an empty bucket.
|
|
43
|
+
*/
|
|
44
|
+
export const PART_SLOT_LABEL = {
|
|
45
|
+
"bit-chip": "Bit Chip",
|
|
46
|
+
"attack-ring": "Attack Ring",
|
|
47
|
+
"weight-disk": "Weight Disk",
|
|
48
|
+
"spin-gear": "Spin Gear",
|
|
49
|
+
"engine-gear": "Engine Gear",
|
|
50
|
+
"blade-base": "Blade Base",
|
|
51
|
+
"running-core": "Running Core",
|
|
52
|
+
"face-bolt": "Face Bolt",
|
|
53
|
+
"energy-ring": "Energy Ring",
|
|
54
|
+
"fusion-wheel": "Fusion Wheel",
|
|
55
|
+
"chrome-wheel": "Chrome Wheel",
|
|
56
|
+
"spin-track": "Spin Track",
|
|
57
|
+
"performance-tip": "Performance Tip",
|
|
58
|
+
layer: "Layer",
|
|
59
|
+
disc: "Disc",
|
|
60
|
+
driver: "Driver",
|
|
61
|
+
armour: "Armour",
|
|
62
|
+
blade: "Blade",
|
|
63
|
+
ratchet: "Ratchet",
|
|
64
|
+
bit: "Bit",
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Which CATEGORY node a part of this classification is filed under.
|
|
68
|
+
*
|
|
69
|
+
* ── 🛑 A PART IS A PLAIN CATEGORY LEAF, `categoryType` OMITTED ──────────────
|
|
70
|
+
*
|
|
71
|
+
* The user's instruction was *"treat parts as also categories, let admins
|
|
72
|
+
* create or add more of them"*, and this map is how both halves are answered at
|
|
73
|
+
* once: a part leaf under these nodes gets `deriveTaxonomy`'s ancestor chain,
|
|
74
|
+
* `countersReconcile`'s tally, the sitemap, breadcrumbs, SEO, an OG image and
|
|
75
|
+
* **the existing admin category editor** — create, edit and delete included —
|
|
76
|
+
* for no new code.
|
|
77
|
+
*
|
|
78
|
+
* 🛑 `categoryType` is OMITTED rather than given a new `"part"` value, and that
|
|
79
|
+
* is the decisive design point. 92 files read `categoryType`, and section 4.4
|
|
80
|
+
* records that `categoryType: "sublisting"` carries SEVEN live defects
|
|
81
|
+
* precisely because a new discriminator value opts out of every path that
|
|
82
|
+
* works — `deriveTaxonomy` never reads it, `countersReconcile` leaves its count
|
|
83
|
+
* permanently 0, and the sitemap excludes it by name. An omitted type changes
|
|
84
|
+
* none of those 92 files and inherits every one of those paths.
|
|
85
|
+
*
|
|
86
|
+
* ── 🛑 KEYED ON THE SLOT ALONE, NOT ON (slot, generation) ───────────────────
|
|
87
|
+
*
|
|
88
|
+
* The first version derived a part's generation from its `usedBy` models, which
|
|
89
|
+
* measured **681 of 753 placeable, 72 unplaceable and 1 spanning two
|
|
90
|
+
* generations**. That was solving a problem that does not exist: each of the 20
|
|
91
|
+
* classification names belongs to exactly ONE generation's vocabulary. A
|
|
92
|
+
* "Performance Tip" is Metal-era by definition, a "Driver" is Burst, a "Bit" is
|
|
93
|
+
* X and a "Bit Chip" is Plastic Gen — the words are not shared. Keying on the
|
|
94
|
+
* slot places **all 753** and cannot be wrong about a part whose `usedBy` is
|
|
95
|
+
* thin.
|
|
96
|
+
*
|
|
97
|
+
* A `Record<PartSlot, string>`, so a 21st classification cannot compile without
|
|
98
|
+
* being given a home — and a part leaf with no parent is a tier-0 orphan, which
|
|
99
|
+
* is the live defect the store sublisting POST still has.
|
|
100
|
+
*/
|
|
101
|
+
export const PART_SLOT_CATEGORY = {
|
|
102
|
+
// Plastic Generation / HMS — Takara, pre-2006.
|
|
103
|
+
"attack-ring": "category-original-attack-rings",
|
|
104
|
+
"weight-disk": "category-original-weight-disks",
|
|
105
|
+
"blade-base": "category-original-blade-bases",
|
|
106
|
+
"bit-chip": "category-original-bit-chips",
|
|
107
|
+
"spin-gear": "category-original-spin-gears",
|
|
108
|
+
"engine-gear": "category-original-engine-gears",
|
|
109
|
+
"running-core": "category-original-running-cores",
|
|
110
|
+
// Metal Fight / 4D.
|
|
111
|
+
"face-bolt": "category-metal-face-bolts",
|
|
112
|
+
"energy-ring": "category-metal-energy-rings",
|
|
113
|
+
"fusion-wheel": "category-metal-fusion-wheels",
|
|
114
|
+
"chrome-wheel": "category-metal-chrome-wheels",
|
|
115
|
+
"spin-track": "category-metal-spin-tracks",
|
|
116
|
+
"performance-tip": "category-metal-performance-tips",
|
|
117
|
+
// Burst.
|
|
118
|
+
layer: "category-burst-layers",
|
|
119
|
+
disc: "category-burst-discs",
|
|
120
|
+
driver: "category-burst-drivers",
|
|
121
|
+
armour: "category-burst-chips-armour",
|
|
122
|
+
// Beyblade X.
|
|
123
|
+
blade: "category-x-blades",
|
|
124
|
+
ratchet: "category-x-ratchets",
|
|
125
|
+
bit: "category-x-bits",
|
|
126
|
+
};
|
|
127
|
+
/** Every slot key, for an index page that must not hand-write the list. */
|
|
128
|
+
export const ALL_PART_SLOTS = Object.values(PART_SLOT_VALUES);
|
|
129
|
+
/**
|
|
130
|
+
* A URL segment to a slot, or `null`.
|
|
131
|
+
*
|
|
132
|
+
* 🛑 Returns `null` rather than casting. A bare `as PartSlot` on a URL segment
|
|
133
|
+
* is how `/parts/nonsense/x` becomes a doc read for `nonsense--x` that quietly
|
|
134
|
+
* misses — the route must 404, which it can only do if the guard says so.
|
|
135
|
+
*/
|
|
136
|
+
export function partSlotFromSegment(segment) {
|
|
137
|
+
const hit = ALL_PART_SLOTS.find((s) => s === segment);
|
|
138
|
+
return hit ?? null;
|
|
139
|
+
}
|
|
140
|
+
/** `(slot, code|name)` → document id. The ONE place this is composed. */
|
|
141
|
+
export function partGlossaryId(slot, codeOrName) {
|
|
142
|
+
const tail = codeOrName
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
145
|
+
.replace(/^-+|-+$/g, "");
|
|
146
|
+
return `${slot}--${tail}`;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Whether a listing of this TYPE is the physical assembly the composition
|
|
150
|
+
* describes.
|
|
151
|
+
*
|
|
152
|
+
* 🛑 Found by measuring rather than by reasoning, and it is a real defect the
|
|
153
|
+
* first version of D12 shipped: of 71 live products exactly ONE files under a
|
|
154
|
+
* leaf that has a composition, and that one is
|
|
155
|
+
* `art-dranzer-phoenix-poster` — so the only listing in the whole catalogue
|
|
156
|
+
* that got a "Parts" tab was a POSTER, offering an attack ring, a weight disk
|
|
157
|
+
* and a blade base as its contents. Nothing errored; it rendered a correct
|
|
158
|
+
* part list for the wrong kind of object.
|
|
159
|
+
*
|
|
160
|
+
* The distinction is whether the listing IS the model or merely DEPICTS or
|
|
161
|
+
* REFERENCES it. A print, a sticker sheet and a redemption code all sit under
|
|
162
|
+
* the model's leaf on purpose (section 4.4: a sticker sheet belongs beside the
|
|
163
|
+
* bey it is for) and none of them is made of its parts.
|
|
164
|
+
*
|
|
165
|
+
* 🛑 A `Record<ListingType, boolean>`, so a tenth listing type cannot compile
|
|
166
|
+
* without answering the question — the lesson of Root Cause #61, which this
|
|
167
|
+
* codebase has paid for ten hand-written enumerations of one union.
|
|
168
|
+
*/
|
|
169
|
+
export const LISTING_TYPE_HAS_COMPOSITION = {
|
|
170
|
+
standard: true,
|
|
171
|
+
auction: true,
|
|
172
|
+
"pre-order": true,
|
|
173
|
+
"prize-draw": true,
|
|
174
|
+
classified: true,
|
|
175
|
+
live: true,
|
|
176
|
+
// Depicts the model; is not the model.
|
|
177
|
+
art: false,
|
|
178
|
+
stickers: false,
|
|
179
|
+
// A redemption code has no physical parts at all.
|
|
180
|
+
"digital-code": false,
|
|
181
|
+
};
|