@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,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare colourways the way a human would type them.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 The vocabulary is OPEN-ENDED and seller-entered — deliberately not a
|
|
5
|
+
* `productFeature`, because that vocabulary is ~169 curated terms in 14 groups
|
|
6
|
+
* and colourways are per-part, which would give a facet hundreds of single-use
|
|
7
|
+
* values (the long-tail-tag anti-pattern this project rejected when redeyemerch
|
|
8
|
+
* was crawled).
|
|
9
|
+
*
|
|
10
|
+
* Open-ended means "Transparent Black", "transparent-black" and
|
|
11
|
+
* "transparent black" are the same colour typed three ways, and a strict `===`
|
|
12
|
+
* would call a buildable bey unbuildable. Normalising is not a nicety here; it
|
|
13
|
+
* is the difference between a working rollup and one that reports nothing.
|
|
14
|
+
*/
|
|
15
|
+
export declare function normaliseColourway(value: string | null | undefined): string;
|
|
16
|
+
export interface SlotRequirement {
|
|
17
|
+
/** The `partGlossary` id the slot needs. */
|
|
18
|
+
partId?: string;
|
|
19
|
+
/** The colourway it needs. Absent means ANY colourway will do. */
|
|
20
|
+
expectedColourway?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PartHolding {
|
|
23
|
+
/** The part listing's own product id. */
|
|
24
|
+
productId: string;
|
|
25
|
+
partGlossaryId?: string;
|
|
26
|
+
colourway?: string;
|
|
27
|
+
/**
|
|
28
|
+
* 🛑 AVAILABLE, never `stockQuantity`. 10 in stock with 3 reserved is SEVEN
|
|
29
|
+
* assemblable, and building from the wrong field assembles a bey out of parts
|
|
30
|
+
* somebody has already bought — A4's bug through a new door.
|
|
31
|
+
*/
|
|
32
|
+
availableQuantity: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Does this holding satisfy that slot?
|
|
36
|
+
*
|
|
37
|
+
* Two rules, and the second is the one that gets written wrong:
|
|
38
|
+
*
|
|
39
|
+
* 1. the glossary id must match — a Driver is not a Bit;
|
|
40
|
+
* 2. the colourway must match **when the slot names one**. 🛑 A slot with no
|
|
41
|
+
* `expectedColourway` accepts ANY, including a holding that declares one.
|
|
42
|
+
* The reverse — a slot that names a colour accepting a holding that
|
|
43
|
+
* declares none — is REFUSED, because "colour unknown" is not evidence of
|
|
44
|
+
* the right colour, and this is exactly where a cheerful default produces
|
|
45
|
+
* the wrong physical part.
|
|
46
|
+
*/
|
|
47
|
+
export declare function satisfiesSlot(slot: SlotRequirement, holding: PartHolding): boolean;
|
|
48
|
+
export interface SlotMatch {
|
|
49
|
+
slot: SlotRequirement;
|
|
50
|
+
/** Every holding that satisfies the slot, richest first. */
|
|
51
|
+
candidates: PartHolding[];
|
|
52
|
+
/** How many complete units this slot alone could supply. */
|
|
53
|
+
buildable: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Match one slot against what a seller holds.
|
|
57
|
+
*
|
|
58
|
+
* `buildable` is the SUM across candidates, because two listings of the same
|
|
59
|
+
* part in the same colourway are two piles of the same thing — a seller with
|
|
60
|
+
* 2 silver MBs in one listing and 3 in another can build five.
|
|
61
|
+
*/
|
|
62
|
+
export declare function matchSlotToHoldings(slot: SlotRequirement, holdings: readonly PartHolding[]): SlotMatch;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: one part code can be two physically different units that are not
|
|
3
|
+
* interchangeable. The user's case, and the fixture pair to build first:
|
|
4
|
+
*
|
|
5
|
+
* performance-tip--mb "Metal Ball"
|
|
6
|
+
* -> silver ships with L-Drago Guardian S130MB (rare, costly)
|
|
7
|
+
* -> transparent-black ships with Poison Zurafa S130MB
|
|
8
|
+
*
|
|
9
|
+
* Same mould, same weight, same stats, same behaviour. To build an L-Drago
|
|
10
|
+
* you need the silver one; the black one will not do. Same for their S130
|
|
11
|
+
* tracks.
|
|
12
|
+
*
|
|
13
|
+
* WHAT: the ONE predicate for "does this part listing satisfy this composition
|
|
14
|
+
* slot". Pure, so the case above is assertable without a database.
|
|
15
|
+
*
|
|
16
|
+
* EXPORTS: normaliseColourway, satisfiesSlot, matchSlotToHoldings
|
|
17
|
+
*
|
|
18
|
+
* ── 🛑 WHY THIS IS A SEPARATE MODULE AND NOT AN INLINE `===` ───────────────
|
|
19
|
+
*
|
|
20
|
+
* Because it will be needed in at least three places that must agree — the
|
|
21
|
+
* "buildable now" rollup, the assembly drawer's per-slot picker, and (once it
|
|
22
|
+
* bands by colourway) the price index. An inline comparison is Root Cause #75's
|
|
23
|
+
* shape: the copy made before a rule was added never learns about it, and the
|
|
24
|
+
* rule here has two non-obvious halves (unset means ANY, and comparison is
|
|
25
|
+
* normalised).
|
|
26
|
+
*
|
|
27
|
+
* @tag domain:parts
|
|
28
|
+
* @tag layer:util
|
|
29
|
+
* @tag pattern:none
|
|
30
|
+
* @tag access:shared
|
|
31
|
+
* @tag consumers:buildable-now rollup, assembly drawer, priceIndexRollup
|
|
32
|
+
* @tag sideEffects:none
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Compare colourways the way a human would type them.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 The vocabulary is OPEN-ENDED and seller-entered — deliberately not a
|
|
38
|
+
* `productFeature`, because that vocabulary is ~169 curated terms in 14 groups
|
|
39
|
+
* and colourways are per-part, which would give a facet hundreds of single-use
|
|
40
|
+
* values (the long-tail-tag anti-pattern this project rejected when redeyemerch
|
|
41
|
+
* was crawled).
|
|
42
|
+
*
|
|
43
|
+
* Open-ended means "Transparent Black", "transparent-black" and
|
|
44
|
+
* "transparent black" are the same colour typed three ways, and a strict `===`
|
|
45
|
+
* would call a buildable bey unbuildable. Normalising is not a nicety here; it
|
|
46
|
+
* is the difference between a working rollup and one that reports nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function normaliseColourway(value) {
|
|
49
|
+
if (!value)
|
|
50
|
+
return "";
|
|
51
|
+
return value
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.trim()
|
|
54
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
55
|
+
.replace(/^-+|-+$/g, "");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Does this holding satisfy that slot?
|
|
59
|
+
*
|
|
60
|
+
* Two rules, and the second is the one that gets written wrong:
|
|
61
|
+
*
|
|
62
|
+
* 1. the glossary id must match — a Driver is not a Bit;
|
|
63
|
+
* 2. the colourway must match **when the slot names one**. 🛑 A slot with no
|
|
64
|
+
* `expectedColourway` accepts ANY, including a holding that declares one.
|
|
65
|
+
* The reverse — a slot that names a colour accepting a holding that
|
|
66
|
+
* declares none — is REFUSED, because "colour unknown" is not evidence of
|
|
67
|
+
* the right colour, and this is exactly where a cheerful default produces
|
|
68
|
+
* the wrong physical part.
|
|
69
|
+
*/
|
|
70
|
+
export function satisfiesSlot(slot, holding) {
|
|
71
|
+
if (!slot.partId)
|
|
72
|
+
return false;
|
|
73
|
+
if (holding.partGlossaryId !== slot.partId)
|
|
74
|
+
return false;
|
|
75
|
+
if (holding.availableQuantity <= 0)
|
|
76
|
+
return false;
|
|
77
|
+
const want = normaliseColourway(slot.expectedColourway);
|
|
78
|
+
if (!want)
|
|
79
|
+
return true; // the slot is colour-agnostic
|
|
80
|
+
return normaliseColourway(holding.colourway) === want;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Match one slot against what a seller holds.
|
|
84
|
+
*
|
|
85
|
+
* `buildable` is the SUM across candidates, because two listings of the same
|
|
86
|
+
* part in the same colourway are two piles of the same thing — a seller with
|
|
87
|
+
* 2 silver MBs in one listing and 3 in another can build five.
|
|
88
|
+
*/
|
|
89
|
+
export function matchSlotToHoldings(slot, holdings) {
|
|
90
|
+
const candidates = holdings
|
|
91
|
+
.filter((h) => satisfiesSlot(slot, h))
|
|
92
|
+
.sort((a, b) => b.availableQuantity - a.availableQuantity);
|
|
93
|
+
return {
|
|
94
|
+
slot,
|
|
95
|
+
candidates,
|
|
96
|
+
buildable: candidates.reduce((sum, h) => sum + h.availableQuantity, 0),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D12 — the `{{partBreakdown}}` description slot.
|
|
3
|
+
*
|
|
4
|
+
* A listing filed under a named-model leaf can state what the bey is made of,
|
|
5
|
+
* from the same `modelComposition` the model page renders. The corpus knows
|
|
6
|
+
* 259 models down to the slot; before this, a seller's auto-filled description
|
|
7
|
+
* could say the series and the system and not one part.
|
|
8
|
+
*
|
|
9
|
+
* ── 🛑 WHY A SERVER-RESOLVED SLOT AND NOT A CLIENT ONE ─────────────────────
|
|
10
|
+
*
|
|
11
|
+
* Every other `{{slot}}` is resolved by the seller form from the draft it is
|
|
12
|
+
* already holding — title, brand, condition, the category path. A part
|
|
13
|
+
* breakdown is a Firestore read, so the form cannot compute it, and asking the
|
|
14
|
+
* form to fetch it would be a second request per category change on a surface
|
|
15
|
+
* that already makes one. It rides the template endpoint's existing response.
|
|
16
|
+
*
|
|
17
|
+
* ── 🛑 ABSENCE REMOVES THE LINE, IT DOES NOT EMPTY IT ──────────────────────
|
|
18
|
+
*
|
|
19
|
+
* 49 of 259 compositions did not join to a category, and most of the
|
|
20
|
+
* catalogue is not a Beyblade at all. `interpolate` resolves an unfilled slot
|
|
21
|
+
* to an empty string, so a body carrying `<li><strong>Parts:</strong>
|
|
22
|
+
* {{partBreakdown}}</li>` would render an **empty bullet** on every listing
|
|
23
|
+
* without a composition — a visible artifact that reads as broken data.
|
|
24
|
+
*
|
|
25
|
+
* So the endpoint strips the whole line instead. That is why the strip is
|
|
26
|
+
* LINE-based rather than element-based: the seeded bodies are template
|
|
27
|
+
* literals with exactly one `<li>` or `<p>` per source line, which makes
|
|
28
|
+
* "the line containing the slot" an exact, predictable unit. A body that put
|
|
29
|
+
* two elements on one line would lose both — stated here rather than guarded
|
|
30
|
+
* against, because the guard would be a second HTML parser.
|
|
31
|
+
*/
|
|
32
|
+
import { type ModelCompositionDocument, type PartGlossaryDocument } from "../schemas/firestore";
|
|
33
|
+
/** The slot name, in ONE place — the endpoint, the formatter and the seeded bodies all key on it. */
|
|
34
|
+
export declare const PART_BREAKDOWN_SLOT = "partBreakdown";
|
|
35
|
+
/**
|
|
36
|
+
* `Attack Ring — Metal Attack Ring (19 g) · Weight Disk — CWD Chain Attacker`
|
|
37
|
+
*
|
|
38
|
+
* 🛑 ONE LINE, not a `<ul>`. A slot that emits markup breaks the moment a
|
|
39
|
+
* template author drops it mid-sentence, and a second HTML-shaped slot beside
|
|
40
|
+
* a plain one is two things that must agree about the same data. One
|
|
41
|
+
* composable value, usable anywhere.
|
|
42
|
+
*
|
|
43
|
+
* Returns `""` when there is nothing to say, which is the signal the caller
|
|
44
|
+
* uses to strip the line.
|
|
45
|
+
*/
|
|
46
|
+
export declare function formatPartBreakdown(compositions: readonly ModelCompositionDocument[], parts?: Map<string, PartGlossaryDocument>): string;
|
|
47
|
+
/**
|
|
48
|
+
* Remove the source line that carries `{{slot}}`.
|
|
49
|
+
*
|
|
50
|
+
* Used when the slot has no value, so the body arrives without the line rather
|
|
51
|
+
* than with an empty one. See the header for why this is line-based.
|
|
52
|
+
*/
|
|
53
|
+
export declare function stripSlotLine(body: string, slot: string): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D12 — the `{{partBreakdown}}` description slot.
|
|
3
|
+
*
|
|
4
|
+
* A listing filed under a named-model leaf can state what the bey is made of,
|
|
5
|
+
* from the same `modelComposition` the model page renders. The corpus knows
|
|
6
|
+
* 259 models down to the slot; before this, a seller's auto-filled description
|
|
7
|
+
* could say the series and the system and not one part.
|
|
8
|
+
*
|
|
9
|
+
* ── 🛑 WHY A SERVER-RESOLVED SLOT AND NOT A CLIENT ONE ─────────────────────
|
|
10
|
+
*
|
|
11
|
+
* Every other `{{slot}}` is resolved by the seller form from the draft it is
|
|
12
|
+
* already holding — title, brand, condition, the category path. A part
|
|
13
|
+
* breakdown is a Firestore read, so the form cannot compute it, and asking the
|
|
14
|
+
* form to fetch it would be a second request per category change on a surface
|
|
15
|
+
* that already makes one. It rides the template endpoint's existing response.
|
|
16
|
+
*
|
|
17
|
+
* ── 🛑 ABSENCE REMOVES THE LINE, IT DOES NOT EMPTY IT ──────────────────────
|
|
18
|
+
*
|
|
19
|
+
* 49 of 259 compositions did not join to a category, and most of the
|
|
20
|
+
* catalogue is not a Beyblade at all. `interpolate` resolves an unfilled slot
|
|
21
|
+
* to an empty string, so a body carrying `<li><strong>Parts:</strong>
|
|
22
|
+
* {{partBreakdown}}</li>` would render an **empty bullet** on every listing
|
|
23
|
+
* without a composition — a visible artifact that reads as broken data.
|
|
24
|
+
*
|
|
25
|
+
* So the endpoint strips the whole line instead. That is why the strip is
|
|
26
|
+
* LINE-based rather than element-based: the seeded bodies are template
|
|
27
|
+
* literals with exactly one `<li>` or `<p>` per source line, which makes
|
|
28
|
+
* "the line containing the slot" an exact, predictable unit. A body that put
|
|
29
|
+
* two elements on one line would lose both — stated here rather than guarded
|
|
30
|
+
* against, because the guard would be a second HTML parser.
|
|
31
|
+
*/
|
|
32
|
+
import { PART_SLOT_LABEL } from "../schemas/firestore.js";
|
|
33
|
+
/** The slot name, in ONE place — the endpoint, the formatter and the seeded bodies all key on it. */
|
|
34
|
+
export const PART_BREAKDOWN_SLOT = "partBreakdown";
|
|
35
|
+
/**
|
|
36
|
+
* `Attack Ring — Metal Attack Ring (19 g) · Weight Disk — CWD Chain Attacker`
|
|
37
|
+
*
|
|
38
|
+
* 🛑 ONE LINE, not a `<ul>`. A slot that emits markup breaks the moment a
|
|
39
|
+
* template author drops it mid-sentence, and a second HTML-shaped slot beside
|
|
40
|
+
* a plain one is two things that must agree about the same data. One
|
|
41
|
+
* composable value, usable anywhere.
|
|
42
|
+
*
|
|
43
|
+
* Returns `""` when there is nothing to say, which is the signal the caller
|
|
44
|
+
* uses to strip the line.
|
|
45
|
+
*/
|
|
46
|
+
export function formatPartBreakdown(compositions, parts) {
|
|
47
|
+
// The first composition only. A remake shares a model's name and not its
|
|
48
|
+
// parts, so concatenating two would describe a bey that does not exist.
|
|
49
|
+
const comp = compositions[0];
|
|
50
|
+
const slots = comp?.slots ?? [];
|
|
51
|
+
if (slots.length === 0)
|
|
52
|
+
return "";
|
|
53
|
+
const pieces = slots
|
|
54
|
+
.map((s) => {
|
|
55
|
+
const label = PART_SLOT_LABEL[s.slotKey] ?? s.slotKey;
|
|
56
|
+
const name = (s.partName ?? "").trim();
|
|
57
|
+
if (!name)
|
|
58
|
+
return null;
|
|
59
|
+
const weight = s.weightG ?? (s.partId ? parts?.get(s.partId)?.weightG : undefined);
|
|
60
|
+
return `${label} — ${name}${typeof weight === "number" ? ` (${weight} g)` : ""}`;
|
|
61
|
+
})
|
|
62
|
+
.filter((p) => p !== null);
|
|
63
|
+
return pieces.join(" · ");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Remove the source line that carries `{{slot}}`.
|
|
67
|
+
*
|
|
68
|
+
* Used when the slot has no value, so the body arrives without the line rather
|
|
69
|
+
* than with an empty one. See the header for why this is line-based.
|
|
70
|
+
*/
|
|
71
|
+
export function stripSlotLine(body, slot) {
|
|
72
|
+
const needle = `{{${slot}}}`;
|
|
73
|
+
return body
|
|
74
|
+
.split("\n")
|
|
75
|
+
.filter((line) => !line.includes(needle))
|
|
76
|
+
.join("\n");
|
|
77
|
+
}
|
|
@@ -9,6 +9,19 @@ export interface ProductDetailPageViewProps {
|
|
|
9
9
|
* When absent, the component falls back to fetching by slug (backward compat).
|
|
10
10
|
*/
|
|
11
11
|
initialProduct?: import("../schemas/firestore").ProductDocument | null;
|
|
12
|
+
/**
|
|
13
|
+
* D12 — the part composition for this listing's model, as a ready node.
|
|
14
|
+
*
|
|
15
|
+
* 🛑 A NODE, not a document: the composition is a Firestore read and this
|
|
16
|
+
* component is already at its read budget, so the page resolves it (where
|
|
17
|
+
* it is already reading the product) and passes the rendered panel down.
|
|
18
|
+
* Passing the raw documents would make this component read too, and a
|
|
19
|
+
* second reader of the same data is the drift this codebase keeps closing.
|
|
20
|
+
*
|
|
21
|
+
* Absent for every listing that is not filed under a named-model leaf,
|
|
22
|
+
* which is most of them — the tab then does not render at all.
|
|
23
|
+
*/
|
|
24
|
+
compositionContent?: React.ReactNode;
|
|
12
25
|
/**
|
|
13
26
|
* Render prop for offer UI. Receives the resolved product fields.
|
|
14
27
|
*
|
|
@@ -65,4 +78,4 @@ export interface ProductDetailPageViewProps {
|
|
|
65
78
|
*/
|
|
66
79
|
emiEnabled?: boolean;
|
|
67
80
|
}
|
|
68
|
-
export declare function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, productFeatures, codEnabled, emiEnabled, }: ProductDetailPageViewProps): Promise<import("react").JSX.Element>;
|
|
81
|
+
export declare function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, compositionContent, productFeatures, codEnabled, emiEnabled, }: ProductDetailPageViewProps): Promise<import("react").JSX.Element>;
|
|
@@ -69,7 +69,7 @@ function StarRating({ value }) {
|
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
70
70
|
// Component
|
|
71
71
|
// ---------------------------------------------------------------------------
|
|
72
|
-
export async function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, productFeatures, codEnabled, emiEnabled, }) {
|
|
72
|
+
export async function ProductDetailPageView({ slug, initialProduct, renderOfferAction, renderPrimaryActions, compositionContent, productFeatures, codEnabled, emiEnabled, }) {
|
|
73
73
|
// Use pre-fetched data when available to avoid a redundant repository call.
|
|
74
74
|
// The page layer wraps getProductForDetail in React.cache(), so both
|
|
75
75
|
// generateMetadata() and this component share the same in-flight promise.
|
|
@@ -102,6 +102,25 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
|
|
|
102
102
|
const inStock = effectiveStock !== null ? effectiveStock > 0 : p.status === "published";
|
|
103
103
|
const avgRating = typeof p.avgRating === "number" ? p.avgRating : null;
|
|
104
104
|
const reviewCount = typeof p.reviewCount === "number" ? p.reviewCount : null;
|
|
105
|
+
/*
|
|
106
|
+
* D8/H2 — real social proof. "N people viewed this in the last week" is a
|
|
107
|
+
* number we MEASURED, which is what makes it usable: H2's whole boundary is
|
|
108
|
+
* "use persuasion that is TRUE, refuse persuasion that is FALSE", and
|
|
109
|
+
* nobody in the fifteen-site crawl shows this despite 76–93% of their
|
|
110
|
+
* catalogues being sold out.
|
|
111
|
+
*
|
|
112
|
+
* 🛑 `typeof === "number"`, not truthiness. `undefined` means the nightly
|
|
113
|
+
* pass has not rolled this product up yet; `0` means measured and nobody
|
|
114
|
+
* looked. Rendering "0 people viewed this" is worse than rendering nothing,
|
|
115
|
+
* and `?? 0` is exactly how the admin Top Products table came to show
|
|
116
|
+
* "0 views" on every row as though it were a measurement (A5).
|
|
117
|
+
*
|
|
118
|
+
* 🛑 And a floor of 2, so a single visitor is never told "1 person viewed
|
|
119
|
+
* this" — which on a quiet listing is usually them. Below the floor there is
|
|
120
|
+
* no claim rather than a technically-true one that reads as desperate.
|
|
121
|
+
*/
|
|
122
|
+
const views7d = typeof p.views7d === "number" ? p.views7d : null;
|
|
123
|
+
const showViews = views7d !== null && views7d >= 2;
|
|
105
124
|
const category = typeof p.category === "string" ? p.category : null;
|
|
106
125
|
const categoryName = typeof p.categoryName === "string" ? p.categoryName : null;
|
|
107
126
|
// categorySlugs/categoryNames: multi-category support (S-uni-formshell-prep)
|
|
@@ -212,7 +231,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
|
|
|
212
231
|
? "Refurbished"
|
|
213
232
|
: condition === "used"
|
|
214
233
|
? "Used"
|
|
215
|
-
: condition })), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title || "Untitled Product" })] }), avgRating !== null ? (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(StarRating, { value: avgRating }), _jsxs(Span, { size: "sm", color: "muted", children: [avgRating.toFixed(1), reviewCount ? ` (${reviewCount} reviews)` : ""] }), _jsxs(Span, { size: "xs", weight: "medium", surface: inStock ? "success-surface" : "danger-surface", className: `ml-auto ${inStock ? "text-success" : "text-error"}`, padding: "pill-sm", rounded: "full", children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
|
|
234
|
+
: condition })), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title || "Untitled Product" })] }), avgRating !== null ? (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(StarRating, { value: avgRating }), _jsxs(Span, { size: "sm", color: "muted", children: [avgRating.toFixed(1), reviewCount ? ` (${reviewCount} reviews)` : ""] }), showViews && (_jsxs(Span, { size: "sm", color: "muted", children: ["\u00B7 ", views7d.toLocaleString(), " viewed this week"] })), _jsxs(Span, { size: "xs", weight: "medium", surface: inStock ? "success-surface" : "danger-surface", className: `ml-auto ${inStock ? "text-success" : "text-error"}`, padding: "pill-sm", rounded: "full", children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
|
|
216
235
|
? ` — only ${effectiveStock} left`
|
|
217
236
|
: ""] })] })) : (_jsxs(Span, { layout: "inline-flex", gap: "xs", className: `w-fit .5 ${inStock ? CLS_STOCK_IN : CLS_STOCK_OUT}`, rounded: "full", padding: "pill-md", size: "xs", weight: "medium", children: [inStock ? "✓ In Stock" : "✗ Out of Stock", inStock && effectiveStock !== null && effectiveStock <= 10
|
|
218
237
|
? ` — only ${effectiveStock} left`
|
|
@@ -267,7 +286,7 @@ export async function ProductDetailPageView({ slug, initialProduct, renderOfferA
|
|
|
267
286
|
? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
|
|
268
287
|
: undefined, renderGroupSection: groupId
|
|
269
288
|
? () => (_jsx(ShowGroupSection, { groupId: groupId, currentSlug: String(p.slug ?? product.id), isParent: isGroupParent, groupTitle: groupTitle }))
|
|
270
|
-
: undefined, renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: allSpecs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: allSpecs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, ingredientsContent: ingredients.length > 0 ? (_jsx(Ul, { spacing: "comfortable", children: ingredients.map((item, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-1 flex-shrink-0 h-1.5 w-1.5 bg-primary-400", rounded: "full" }), item] }, i))) })) : undefined, howToUseContent: howToUse.length > 0 ? (_jsx(Ol, { spacing: "loose", children: howToUse.map((step, i) => (_jsxs(Li, { layout: "flex-start", gap: "3", textSize: "sm", color: "muted", children: [_jsx(Span, { layout: "flex-center", size: "xs", weight: "bold", className: "flex-shrink-0 h-6 w-6 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", children: i + 1 }), step] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
|
|
289
|
+
: undefined, renderTabs: () => (_jsx(ProductTabsShell, { partsContent: compositionContent, descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: allSpecs.length > 0 ? (_jsx(Dl, { divide: "subtle", rounded: "xl", border: "subtle", className: "overflow-hidden", children: allSpecs.map((s, i) => (_jsxs(Row, { gap: "md", oddEven: "zebra", surface: "default", padding: "inline", children: [_jsx(Dt, { className: "w-36 flex-shrink-0", color: "primary", weight: "medium", children: s.name }), _jsxs(Dd, { className: "flex-1", color: "muted", children: [s.value, s.unit ? ` ${s.unit}` : ""] })] }, i))) })) : undefined, ingredientsContent: ingredients.length > 0 ? (_jsx(Ul, { spacing: "comfortable", children: ingredients.map((item, i) => (_jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-1 flex-shrink-0 h-1.5 w-1.5 bg-primary-400", rounded: "full" }), item] }, i))) })) : undefined, howToUseContent: howToUse.length > 0 ? (_jsx(Ol, { spacing: "loose", children: howToUse.map((step, i) => (_jsxs(Li, { layout: "flex-start", gap: "3", textSize: "sm", color: "muted", children: [_jsx(Span, { layout: "flex-center", size: "xs", weight: "bold", className: "flex-shrink-0 h-6 w-6 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", rounded: "full", children: i + 1 }), step] }, i))) })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: customSections.map((s) => ({
|
|
271
290
|
id: s.id,
|
|
272
291
|
label: s.title,
|
|
273
292
|
content: _jsx(CustomSectionTabContent, { section: s }),
|
|
@@ -7,6 +7,8 @@ export interface CustomTabDef {
|
|
|
7
7
|
export interface ProductTabsShellProps {
|
|
8
8
|
descriptionContent?: React.ReactNode;
|
|
9
9
|
specsContent?: React.ReactNode;
|
|
10
|
+
/** D12 — part composition. Pass non-null to surface the tab. */
|
|
11
|
+
partsContent?: React.ReactNode;
|
|
10
12
|
/** W1-34 — grading tab content (PSA/BGS/CGC etc). Pass non-null to surface the tab. */
|
|
11
13
|
gradingContent?: React.ReactNode;
|
|
12
14
|
ingredientsContent?: React.ReactNode;
|
|
@@ -18,4 +20,4 @@ export interface ProductTabsShellProps {
|
|
|
18
20
|
customTabs?: CustomTabDef[];
|
|
19
21
|
className?: string;
|
|
20
22
|
}
|
|
21
|
-
export declare function ProductTabsShell({ descriptionContent, specsContent, gradingContent, ingredientsContent, howToUseContent, reviewsContent, qaContent, customTabs, className, }: ProductTabsShellProps): React.JSX.Element | null;
|
|
23
|
+
export declare function ProductTabsShell({ descriptionContent, specsContent, partsContent, gradingContent, ingredientsContent, howToUseContent, reviewsContent, qaContent, customTabs, className, }: ProductTabsShellProps): React.JSX.Element | null;
|
|
@@ -6,6 +6,8 @@ import { Tabs, TabsList, TabsTrigger } from "../../../ui/components/Tabs.js";
|
|
|
6
6
|
const STATIC_TABS = [
|
|
7
7
|
{ id: "description", label: "Description" },
|
|
8
8
|
{ id: "specs", label: "Specifications" },
|
|
9
|
+
/** D12 — the part composition, when the listing is filed under a named model. */
|
|
10
|
+
{ id: "parts", label: "Parts" },
|
|
9
11
|
{ id: "grading", label: "Grading" },
|
|
10
12
|
{ id: "ingredients", label: "Ingredients" },
|
|
11
13
|
{ id: "howToUse", label: "How to Use" },
|
|
@@ -13,10 +15,11 @@ const STATIC_TABS = [
|
|
|
13
15
|
// W1-34 — Q&A tab; consumers pass `qaContent` to surface it.
|
|
14
16
|
{ id: "qa", label: "Q&A" },
|
|
15
17
|
];
|
|
16
|
-
export function ProductTabsShell({ descriptionContent, specsContent, gradingContent, ingredientsContent, howToUseContent, reviewsContent, qaContent, customTabs = [], className = "", }) {
|
|
18
|
+
export function ProductTabsShell({ descriptionContent, specsContent, partsContent, gradingContent, ingredientsContent, howToUseContent, reviewsContent, qaContent, customTabs = [], className = "", }) {
|
|
17
19
|
const staticContentMap = {
|
|
18
20
|
description: descriptionContent,
|
|
19
21
|
specs: specsContent,
|
|
22
|
+
parts: partsContent,
|
|
20
23
|
grading: gradingContent,
|
|
21
24
|
ingredients: ingredientsContent,
|
|
22
25
|
howToUse: howToUseContent,
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🛑 A CAP, because the closure is `array-contains`-indexed and Firestore's
|
|
3
|
+
* `array-contains-any` takes at most 30 comparison values.
|
|
4
|
+
*
|
|
5
|
+
* 4 assignments x 5 tiers (0-4) = 20 closure entries worst case, which leaves
|
|
6
|
+
* headroom under that 30. It also bounds the index-entry cost: the seed loader
|
|
7
|
+
* commits at most 20,000 array elements per batch, and every closure entry is
|
|
8
|
+
* one index entry per product.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MAX_CATEGORY_ASSIGNMENTS = 4;
|
|
11
|
+
/**
|
|
12
|
+
* 🛑 THE FLIP. `false` = the leaf/non-leaf rule REPORTS; `true` = it refuses.
|
|
13
|
+
*
|
|
14
|
+
* It ships `false` and is flipped only once the data is clean, and the reason is
|
|
15
|
+
* a measurement: **70 of the 71 hand-authored products file at a non-leaf
|
|
16
|
+
* today** and not one of them is a lot. Enforcing first would make the whole
|
|
17
|
+
* catalogue unsaveable — and worse, it would make the very seed writes that fix
|
|
18
|
+
* the data fail, because those also go through `deriveTaxonomy`.
|
|
19
|
+
*
|
|
20
|
+
* This is the G3a lesson, which cost a symmetric rule its credibility: the plan
|
|
21
|
+
* specified "a lot takes a non-leaf while singles take leaves", measurement
|
|
22
|
+
* rejected the second half, and the half-rule has been live since. The rule is
|
|
23
|
+
* right; enforcing it before its data is not.
|
|
24
|
+
*
|
|
25
|
+
* Flip procedure, and it is a ratchet rather than a judgement call: run
|
|
26
|
+
* `findLeafRuleOffenders` over every seeded row, confirm **0**, then set `true`.
|
|
27
|
+
* A hand count is not the measurement — the earlier one said 58 of 70 and this
|
|
28
|
+
* pass measured 70 of 71.
|
|
29
|
+
*/
|
|
30
|
+
export declare const ENFORCE_LEAF_FILING = false;
|
|
31
|
+
/** A category document, reduced to the fields assignment actually needs. */
|
|
32
|
+
export interface ResolvedCategory {
|
|
33
|
+
id: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
/** The tree this assignment belongs to. Two assignments may not share one. */
|
|
36
|
+
rootId?: string;
|
|
37
|
+
/** Depth. Decides the default: deepest wins, because depth is specificity. */
|
|
38
|
+
tier?: number;
|
|
39
|
+
/**
|
|
40
|
+
* 🛑 `undefined` is NOT `false` here. It means `buildCategoryTree` has not
|
|
41
|
+
* rebuilt the tree, which is a fact about our data rather than about the
|
|
42
|
+
* caller's input — so it gets its own refusal once enforcement is on.
|
|
43
|
+
*/
|
|
44
|
+
isLeaf?: boolean;
|
|
45
|
+
/** Root-first / nearest-last, as `category-tree.ts` writes it. */
|
|
46
|
+
parentIds: string[];
|
|
47
|
+
/** Root-first, parallel to `parentIds`. */
|
|
48
|
+
ancestorNames: string[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The authored assignment list, from whichever field the caller used.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately tolerant of all three existing shapes, because every one of them
|
|
54
|
+
* is live today and none should need editing to keep working:
|
|
55
|
+
*
|
|
56
|
+
* - `categoryIds: [a, b]` the new multi-select
|
|
57
|
+
* - `category: "a"` what `CategoryInlineSelect` writes, and what
|
|
58
|
+
* `productCreateSchema` declares
|
|
59
|
+
* - `categorySlugs: [a, ...]` the seed, and the admin `.passthrough()` route
|
|
60
|
+
*
|
|
61
|
+
* 🛑 The scalar still WINS over `categorySlugs`, unchanged from before this
|
|
62
|
+
* change and for the recorded reason: the seller edit form sends a stale
|
|
63
|
+
* derived chain alongside a fresh `category`, so preferring the chain resolved
|
|
64
|
+
* the OLD leaf and wrote it back over the user's choice.
|
|
65
|
+
*
|
|
66
|
+
* Duplicates are deduped rather than refused. A duplicate assignment is exactly
|
|
67
|
+
* equivalent to not having it, so there is no semantic question for the caller
|
|
68
|
+
* to answer — unlike a duplicate assembly component, which would change
|
|
69
|
+
* arithmetic.
|
|
70
|
+
*/
|
|
71
|
+
export declare function readAssignments(input: {
|
|
72
|
+
categoryIds?: string[];
|
|
73
|
+
category?: string;
|
|
74
|
+
categorySlugs?: string[];
|
|
75
|
+
}): string[];
|
|
76
|
+
/** Which assignments break the leaf rule, and why. Reporting shape, no throw. */
|
|
77
|
+
export declare function findLeafRuleOffenders(resolved: ResolvedCategory[], isLot: boolean): {
|
|
78
|
+
id: string;
|
|
79
|
+
name?: string;
|
|
80
|
+
reason: "lot-at-leaf" | "single-at-branch" | "not-indexed";
|
|
81
|
+
}[];
|
|
82
|
+
/**
|
|
83
|
+
* Refuse the shapes that are not allowed. Throws; never corrects.
|
|
84
|
+
*
|
|
85
|
+
* 🛑 REFUSED, never corrected — silently dropping a second assignment, or
|
|
86
|
+
* re-filing one tier up, decides a taxonomy question on the seller's behalf and
|
|
87
|
+
* leaves them a listing they can no longer find (Root Cause #76 inverted).
|
|
88
|
+
*/
|
|
89
|
+
export declare function assertAssignmentShape(resolved: ResolvedCategory[], isLot: boolean, opts?: {
|
|
90
|
+
enforceLeafFiling?: boolean;
|
|
91
|
+
warn?: (msg: string, meta?: object) => void;
|
|
92
|
+
}): void;
|
|
93
|
+
/**
|
|
94
|
+
* Which assignment owns the breadcrumb, the canonical chain and index 0.
|
|
95
|
+
*
|
|
96
|
+
* Three deterministic levels:
|
|
97
|
+
* 1. the DEEPEST assignment (highest `tier`);
|
|
98
|
+
* 2. tie -> the highest listing count, which the caller supplies;
|
|
99
|
+
* 3. still tied -> the order the seller picked.
|
|
100
|
+
*
|
|
101
|
+
* 🛑 "Most listings" CANNOT be the first criterion, and this is the one place
|
|
102
|
+
* the instruction had to be corrected. A franchise node spans every product
|
|
103
|
+
* type, so `category-beyblade` outranks `category-dragoon-g` on count for
|
|
104
|
+
* EVERY Beyblade product — which would put `Home > Franchises > Beyblade` above
|
|
105
|
+
* every listing and silently widen the realised-price band from "Dragoon G" to
|
|
106
|
+
* "all Beyblade", because that band is keyed on the snapshotted
|
|
107
|
+
* `categorySlugs[0]`.
|
|
108
|
+
*
|
|
109
|
+
* Depth decides because depth IS specificity, which is also the rule singles
|
|
110
|
+
* are filed by. A model leaf is tier 4 and a franchise leaf is tier 1, so the
|
|
111
|
+
* tie-break only ever runs between two equally specific assignments — exactly
|
|
112
|
+
* where "which is busier" is the right question.
|
|
113
|
+
*
|
|
114
|
+
* `counts` is consulted ONLY on a tie, so the common single-assignment case
|
|
115
|
+
* costs no extra read at all.
|
|
116
|
+
*/
|
|
117
|
+
export declare function pickDefaultAssignment(resolved: ResolvedCategory[], counts?: Map<string, number>): number;
|
|
118
|
+
/**
|
|
119
|
+
* The derived closure and the display names.
|
|
120
|
+
*
|
|
121
|
+
* `categorySlugs` = the default chain leaf-first, then each remaining chain
|
|
122
|
+
* leaf-first, deduped. See the header for why the order is load-bearing.
|
|
123
|
+
*
|
|
124
|
+
* `categoryNames` = the DEFAULT chain only. A union has no readable path, and
|
|
125
|
+
* the breadcrumb builder reverses this array to render root-first — so keeping
|
|
126
|
+
* it single-chain is what leaves every existing breadcrumb byte-identical.
|
|
127
|
+
*/
|
|
128
|
+
export declare function buildTaxonomyClosure(resolved: ResolvedCategory[], defaultIdx: number): {
|
|
129
|
+
categorySlugs: string[];
|
|
130
|
+
categoryNames: string[];
|
|
131
|
+
categoryIdNames: string[];
|
|
132
|
+
};
|