@mohasinac/appkit 4.45.0 → 4.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
- package/dist/_internal/server/features/categories/live-counts.js +72 -0
- package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
- package/dist/_internal/server/features/orders/adapters.js +29 -0
- package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
- package/dist/_internal/server/features/parts/assembly.js +246 -0
- package/dist/_internal/server/features/parts/data.d.ts +65 -0
- package/dist/_internal/server/features/parts/data.js +170 -0
- package/dist/_internal/server/features/parts/og.d.ts +34 -0
- package/dist/_internal/server/features/parts/og.js +66 -0
- package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
- package/dist/_internal/server/features/search/search-everything.js +120 -0
- package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
- package/dist/_internal/server/features/search/search-sources.js +138 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
- package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
- package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
- package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
- package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
- package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
- package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
- package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
- package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
- package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
- package/dist/_internal/shared/actions/action-registry.js +36 -0
- package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
- package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
- package/dist/_internal/shared/features/shipping/protections.js +127 -0
- package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
- package/dist/_internal/shared/search/part-aliases.js +104 -0
- package/dist/constants/api-endpoints.d.ts +18 -12
- package/dist/constants/api-endpoints.js +6 -8
- package/dist/constants/field-names.d.ts +0 -2
- package/dist/constants/field-names.js +0 -2
- package/dist/constants/index.d.ts +1 -1
- package/dist/constants/index.js +1 -1
- package/dist/core/index.d.ts +0 -3
- package/dist/core/index.js +0 -2
- package/dist/errors/messages.d.ts +0 -7
- package/dist/errors/messages.js +0 -8
- package/dist/features/admin/components/AdminSectionsView.js +83 -32
- package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
- package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
- package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
- package/dist/features/admin/schemas/firestore.d.ts +15 -0
- package/dist/features/admin/types/index.d.ts +0 -1
- package/dist/features/auth/permissions/constants.d.ts +1 -1
- package/dist/features/auth/permissions/constants.js +0 -2
- package/dist/features/categories/api/[id]/description-template/route.js +40 -1
- package/dist/features/categories/components/BrandDetailPageView.js +14 -3
- package/dist/features/categories/components/BreadcrumbTrail.d.ts +24 -1
- package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
- package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
- package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
- package/dist/features/categories/repository/categories.repository.d.ts +65 -0
- package/dist/features/categories/repository/categories.repository.js +168 -22
- package/dist/features/categories/repository/category-counts.d.ts +143 -0
- package/dist/features/categories/repository/category-counts.js +224 -0
- package/dist/features/categories/schemas/category-counts.d.ts +155 -0
- package/dist/features/categories/schemas/category-counts.js +116 -0
- package/dist/features/categories/schemas/firestore.d.ts +0 -2
- package/dist/features/categories/schemas/firestore.js +0 -1
- package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
- package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
- package/dist/features/corporate/schemas/index.d.ts +4 -4
- package/dist/features/orders/schemas/firestore.d.ts +13 -0
- package/dist/features/orders/types/index.d.ts +30 -0
- package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
- package/dist/features/parts/components/AssemblyDrawer.js +250 -0
- package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
- package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
- package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
- package/dist/features/parts/components/PartDetailPageView.js +20 -0
- package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
- package/dist/features/parts/components/PartsIndexPageView.js +17 -0
- package/dist/features/parts/schemas/firestore.d.ts +257 -0
- package/dist/features/parts/schemas/firestore.js +181 -0
- package/dist/features/parts/utils/colourway-match.d.ts +62 -0
- package/dist/features/parts/utils/colourway-match.js +98 -0
- package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
- package/dist/features/parts/utils/part-breakdown.js +77 -0
- package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
- package/dist/features/products/components/ProductDetailPageView.js +22 -3
- package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
- package/dist/features/products/components/ProductTabsShell.js +4 -1
- package/dist/features/products/repository/category-assignment.d.ts +132 -0
- package/dist/features/products/repository/category-assignment.js +304 -0
- package/dist/features/products/repository/products.repository.d.ts +58 -1
- package/dist/features/products/repository/products.repository.js +255 -28
- package/dist/features/products/schemas/firestore.d.ts +147 -6
- package/dist/features/products/schemas/firestore.js +49 -0
- package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
- package/dist/features/search/components/SearchResultsPageView.js +21 -0
- package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
- package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
- package/dist/features/seller/components/SellerProductShell.js +30 -1
- package/dist/features/seller/components/SellerProductsView.js +129 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
- package/dist/index.d.ts +11 -16
- package/dist/index.js +26 -18
- package/dist/next/routing/route-map.d.ts +6 -2
- package/dist/next/routing/route-map.js +14 -1
- package/dist/providers/db-firebase/base.repository.d.ts +31 -0
- package/dist/providers/db-firebase/base.repository.js +31 -0
- package/dist/repositories/index.d.ts +1 -2
- package/dist/repositories/index.js +0 -1
- package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
- package/dist/seed/_helpers/category-content-generated.js +1837 -0
- package/dist/seed/_helpers/category-content.d.ts +29 -0
- package/dist/seed/_helpers/category-content.js +98 -1
- package/dist/seed/_helpers/category-forest.js +104 -24
- package/dist/seed/_helpers/category-parts.d.ts +9 -0
- package/dist/seed/_helpers/category-parts.js +4594 -0
- package/dist/seed/carousel-slides-seed-data.js +81 -8
- package/dist/seed/categories-seed-data.js +44 -6
- package/dist/seed/homepage-sections-seed-data.js +170 -9
- package/dist/seed/index.d.ts +2 -0
- package/dist/seed/index.js +13 -0
- package/dist/seed/model-composition-seed-data.d.ts +15 -0
- package/dist/seed/model-composition-seed-data.js +8918 -0
- package/dist/seed/part-glossary-seed-data.d.ts +15 -0
- package/dist/seed/part-glossary-seed-data.js +10729 -0
- package/dist/seed/products-standard-seed-data.js +52 -0
- package/dist/seed/seed-purpose.d.ts +103 -4
- package/dist/seed/seed-purpose.js +162 -11
- package/dist/seo/json-ld.d.ts +10 -1
- package/dist/seo/json-ld.js +9 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/id-generators.d.ts +30 -0
- package/dist/utils/id-generators.js +31 -1
- package/dist/utils/search-txt-builders.js +22 -0
- package/dist/utils/search-txt.d.ts +22 -0
- package/dist/utils/search-txt.js +17 -0
- package/dist/values/success-messages.d.ts +0 -4
- package/dist/values/success-messages.js +0 -5
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +292 -16
- package/dist/core/copilot-log.repository.d.ts +0 -44
- package/dist/core/copilot-log.repository.js +0 -61
- package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
- package/dist/features/copilot/components/AdminCopilotView.js +0 -51
- package/dist/features/copilot/components/index.d.ts +0 -2
- package/dist/features/copilot/components/index.js +0 -1
- package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
- package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
- package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
- package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
- package/dist/features/copilot/index.d.ts +0 -3
- package/dist/features/copilot/index.js +0 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1 — SECTION templates, each carrying a TYPED builder state.
|
|
3
|
+
*
|
|
4
|
+
* 🛑 WHY A TEMPLATE IS NOT A `SectionConfig`, which is the whole design.
|
|
5
|
+
*
|
|
6
|
+
* `SectionConfig` is a NON-discriminated union of 25 untagged interfaces — the
|
|
7
|
+
* tag lives on the parent `HomepageSectionDocument.type` — and
|
|
8
|
+
* `section-renderer.tsx` narrows it by unchecked cast at every one of its 25
|
|
9
|
+
* arms (`config as CarouselSectionConfig`). So a template written as a raw
|
|
10
|
+
* config gets no compile-time checking at all, and `section-renderer`'s silent
|
|
11
|
+
* `default: return null` means a wrong `type` renders NOTHING rather than
|
|
12
|
+
* erroring.
|
|
13
|
+
*
|
|
14
|
+
* Every section type already has a typed triple —
|
|
15
|
+
* `DEFAULT_<TYPE>_BUILDER` / `build<Type>Config()` / `parse<Type>Builder()`,
|
|
16
|
+
* 25 of each. A template declared as a union discriminated on `sectionType`
|
|
17
|
+
* and carrying the matching `…BuilderState` is therefore checked end to end,
|
|
18
|
+
* and the config is produced by the existing `build…Config` rather than
|
|
19
|
+
* hand-written.
|
|
20
|
+
*
|
|
21
|
+
* That is not hypothetical. `DEFAULT_FAQ_BUILDER.categories` carried
|
|
22
|
+
* `["general", "shipping", "returns"]` — **two of the three were values no FAQ
|
|
23
|
+
* document has ever had** — and collapsing the `FAQCategory` union is what
|
|
24
|
+
* surfaced it. A typed template is the kind of thing that catches.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 NOT a whole-page layout. The plan's earlier `HomepageLayoutRecord` was
|
|
27
|
+
* replaced for four reasons: applying a page is all-or-nothing and discards
|
|
28
|
+
* per-row admin edits; sections are already independent documents, so the
|
|
29
|
+
* section is the natural unit of reuse and the page is not; we do not want any
|
|
30
|
+
* competitor's homepage wholesale (BAS's 5,540 words is the honest benchmark
|
|
31
|
+
* while TCI's article-inlining is an anti-pattern, and the SEO leader has the
|
|
32
|
+
* FEWEST products and words); and `AdminSectionsView` — 2,755 lines with
|
|
33
|
+
* drag-drop, a 30-deep undo stack and revert-to-server — stays untouched
|
|
34
|
+
* because a template becomes *create-with-preset*, which is a dropdown and a
|
|
35
|
+
* spread.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 And deliberately NO `siteSettings.homepageLayout`. `homepageSections`
|
|
38
|
+
* stays the single source of truth for what renders; a second place a section
|
|
39
|
+
* could come from is how the page and the admin label end up disagreeing with
|
|
40
|
+
* no way to tell which lied.
|
|
41
|
+
*/
|
|
42
|
+
import { DEFAULT_CATEGORIES_BUILDER, DEFAULT_TRUST_INDICATORS_BUILDER, DEFAULT_FAQ_BUILDER, DEFAULT_BLOG_BUILDER, DEFAULT_PRODUCTS_BUILDER, DEFAULT_CUSTOM_CARDS_BUILDER, } from "./adminSectionsTypes.js";
|
|
43
|
+
export const COMPETITOR_PROFILES = [
|
|
44
|
+
{
|
|
45
|
+
id: "worldhobbyshop",
|
|
46
|
+
label: "worldhobbyshop.in",
|
|
47
|
+
platform: "WooCommerce",
|
|
48
|
+
measured: {
|
|
49
|
+
products: 1195,
|
|
50
|
+
soldOutShare: 0.885,
|
|
51
|
+
medianPrice: 899,
|
|
52
|
+
homepageWords: 1816,
|
|
53
|
+
uniqueInternalLinks: 79,
|
|
54
|
+
jsonLdTypes: 8,
|
|
55
|
+
},
|
|
56
|
+
takeaway: "The SEO leader of the fifteen, and it has the FEWEST products and words on its homepage — 8 product cards and one rail. Its advantage is taxonomy routing (6 generation tiles plus a 25-link systems matrix) and a 10-question FAQ, not merchandising volume.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "beybladeartshop",
|
|
60
|
+
label: "beybladeartshop.com",
|
|
61
|
+
platform: "Shopify",
|
|
62
|
+
measured: {
|
|
63
|
+
products: 1914,
|
|
64
|
+
soldOutShare: 0.811,
|
|
65
|
+
medianPrice: 1599,
|
|
66
|
+
homepageWords: 5540,
|
|
67
|
+
uniqueInternalLinks: 57,
|
|
68
|
+
jsonLdTypes: 3,
|
|
69
|
+
},
|
|
70
|
+
takeaway: "The honest benchmark for curated homepage copy at 5,540 words, and the best product prose of the fifteen (2,814 descriptions, median 346 chars). Runs anti-counterfeit copy as hero slide 5.",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "toycollectorsindia",
|
|
74
|
+
label: "toycollectorsindia.com",
|
|
75
|
+
platform: "Shopify",
|
|
76
|
+
measured: {
|
|
77
|
+
products: 15014,
|
|
78
|
+
soldOutShare: 0.87,
|
|
79
|
+
medianPrice: 899,
|
|
80
|
+
homepageWords: 7430,
|
|
81
|
+
uniqueInternalLinks: null,
|
|
82
|
+
jsonLdTypes: 4,
|
|
83
|
+
},
|
|
84
|
+
takeaway: "🛑 The word-count leader is NOT the benchmark: 3,926 of its 7,430 words are four blog posts inlined whole, duplicating its own canonical URLs. Strip those and it sits below BAS. It IS the checkout-breadth benchmark — three gateways, gift wrap, compare, express checkout.",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "tcgindia",
|
|
88
|
+
label: "tcgindia.in",
|
|
89
|
+
platform: "custom Next.js",
|
|
90
|
+
measured: {
|
|
91
|
+
products: 272,
|
|
92
|
+
soldOutShare: 0,
|
|
93
|
+
medianPrice: null,
|
|
94
|
+
homepageWords: null,
|
|
95
|
+
uniqueInternalLinks: null,
|
|
96
|
+
jsonLdTypes: 9,
|
|
97
|
+
},
|
|
98
|
+
takeaway: "The best SEO architecture of the fifteen — 15 city landing pages, a game→set hierarchy, 28 rules posts, and a per-product FAQPage. Nobody else emits per-product FAQ markup.",
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* The shipped templates.
|
|
103
|
+
*
|
|
104
|
+
* 🛑 Each one is a shape the crawl MEASURED, not a guess — and each is an
|
|
105
|
+
* independent pick. We want WHS's generation tiles, BAS's trust strip and
|
|
106
|
+
* their FAQ density separately, which is exactly what per-section templating
|
|
107
|
+
* allows and a whole-page layout does not.
|
|
108
|
+
*/
|
|
109
|
+
export const SECTION_TEMPLATES = [
|
|
110
|
+
{
|
|
111
|
+
id: "categories-generation-tiles",
|
|
112
|
+
label: "Shop by generation (worldhobbyshop)",
|
|
113
|
+
sectionType: "categories",
|
|
114
|
+
sourceProfileId: "worldhobbyshop",
|
|
115
|
+
notes: "Taxonomy routing, not merchandising — WHS's single biggest SEO asset. Six tiles, no autoscroll: a tile grid is a navigation aid and a moving one is harder to use, not more engaging.",
|
|
116
|
+
builder: {
|
|
117
|
+
...DEFAULT_CATEGORIES_BUILDER,
|
|
118
|
+
title: "Shop by generation",
|
|
119
|
+
viewMoreLabel: "All categories →",
|
|
120
|
+
maxCategories: 6,
|
|
121
|
+
autoScroll: false,
|
|
122
|
+
loop: false,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: "trust-strip-four",
|
|
127
|
+
label: "Four-item trust strip (beybladeartshop)",
|
|
128
|
+
sectionType: "trust-indicators",
|
|
129
|
+
sourceProfileId: "beybladeartshop",
|
|
130
|
+
notes: "BAS's hero strip. Each item is a claim this platform can actually keep — the fourth is authenticity, which is the one competitors with no labelling system cannot say.",
|
|
131
|
+
builder: {
|
|
132
|
+
...DEFAULT_TRUST_INDICATORS_BUILDER,
|
|
133
|
+
title: "",
|
|
134
|
+
indicators: [
|
|
135
|
+
{ id: "trust-originals", icon: "shield-check", title: "Authenticity, labelled", description: "Original, reproduction or unverified on every listing — never a silent claim." },
|
|
136
|
+
{ id: "trust-escrow", icon: "lock", title: "Money held until delivery", description: "We hold payment in escrow, so a claim pays from funds we already have." },
|
|
137
|
+
{ id: "trust-india", icon: "truck", title: "India-wide shipping", description: "Delhivery, Blue Dart, DTDC and India Post, with tracking on every order." },
|
|
138
|
+
{ id: "trust-rare", icon: "search", title: "Rare and pre-owned", description: "Four generations of Beyblade, including the lines that are long out of production." },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "faq-ten-question",
|
|
144
|
+
label: "Ten-question FAQ (worldhobbyshop)",
|
|
145
|
+
sectionType: "faq",
|
|
146
|
+
sourceProfileId: "worldhobbyshop",
|
|
147
|
+
notes: "The densest single SEO win on the page and an answer-engine surface — it feeds faqJsonLd. 🛑 `categories` is left EMPTY on purpose: it is only a fallback TAB list, and a hand-written one drifts out from under the priority-ordered slice, which is how all three homepage FAQ tabs came to render empty panels.",
|
|
148
|
+
builder: {
|
|
149
|
+
...DEFAULT_FAQ_BUILDER,
|
|
150
|
+
title: "Frequently Asked Questions",
|
|
151
|
+
subtitle: "The questions new buyers ask most.",
|
|
152
|
+
displayCount: 10,
|
|
153
|
+
showOnHomepage: true,
|
|
154
|
+
linkToFullPage: true,
|
|
155
|
+
viewMoreLabel: "View all FAQs →",
|
|
156
|
+
categories: [],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: "blog-four-excerpts",
|
|
161
|
+
label: "Four article excerpts (beybladeartshop)",
|
|
162
|
+
sectionType: "blog-articles",
|
|
163
|
+
sourceProfileId: "beybladeartshop",
|
|
164
|
+
notes: "🛑 EXCERPTS, never bodies. toycollectorsindia inlines 3,926 words of full article text into its homepage, duplicating its own canonical blog URLs — which is why its 7,430-word homepage is not the benchmark and BAS's 5,540 is.",
|
|
165
|
+
builder: {
|
|
166
|
+
...DEFAULT_BLOG_BUILDER,
|
|
167
|
+
title: "From the collector blog",
|
|
168
|
+
viewMoreLabel: "All articles →",
|
|
169
|
+
maxArticles: 4,
|
|
170
|
+
showReadTime: true,
|
|
171
|
+
showAuthor: true,
|
|
172
|
+
showThumbnails: true,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "products-recently-added",
|
|
177
|
+
label: "One 'recently added' rail (worldhobbyshop)",
|
|
178
|
+
sectionType: "products",
|
|
179
|
+
sourceProfileId: "worldhobbyshop",
|
|
180
|
+
notes: "WHS runs exactly ONE product rail on the page and leads the fifteen on SEO. The temptation is four rails; the evidence says one, with the taxonomy tiles doing the routing.",
|
|
181
|
+
builder: {
|
|
182
|
+
...DEFAULT_PRODUCTS_BUILDER,
|
|
183
|
+
title: "Recently added",
|
|
184
|
+
viewMoreLabel: "Browse everything →",
|
|
185
|
+
maxItems: 8,
|
|
186
|
+
status: "published",
|
|
187
|
+
featuredOnly: false,
|
|
188
|
+
inStockOnly: true,
|
|
189
|
+
autoScroll: false,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: "custom-cards-systems-matrix",
|
|
194
|
+
label: "Systems matrix — 29 internal links (worldhobbyshop)",
|
|
195
|
+
sectionType: "custom-cards",
|
|
196
|
+
sourceProfileId: "worldhobbyshop",
|
|
197
|
+
notes: "Most of why WHS has 79 unique internal links against BAS's 57. 🛑 `custom-cards`, NOT `collection-cards` — the latter caps at COLLECTION_CARDS_MAX_ENTRIES = 3 and its entries are live-content queries, so it cannot express a link matrix at all. Fill the cards from the real category ids; `audit-section-config-links` fails the build on a dead one.",
|
|
198
|
+
builder: {
|
|
199
|
+
...DEFAULT_CUSTOM_CARDS_BUILDER,
|
|
200
|
+
title: "Every system, every part family",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
/** Templates offered for one section type — empty when none is shipped. */
|
|
205
|
+
export function templatesForType(type) {
|
|
206
|
+
return SECTION_TEMPLATES.filter((t) => t.sectionType === type);
|
|
207
|
+
}
|
|
208
|
+
export function templateById(id) {
|
|
209
|
+
return SECTION_TEMPLATES.find((t) => t.id === id);
|
|
210
|
+
}
|
|
211
|
+
export function profileById(id) {
|
|
212
|
+
return id ? COMPETITOR_PROFILES.find((p) => p.id === id) : undefined;
|
|
213
|
+
}
|
|
@@ -236,6 +236,21 @@ export interface SiteSettingsIntegrations {
|
|
|
236
236
|
googleAnalyticsId?: string;
|
|
237
237
|
facebookPixelId?: string;
|
|
238
238
|
gtmContainerId?: string;
|
|
239
|
+
/**
|
|
240
|
+
* D7b — Microsoft Clarity. Free session recording and heatmaps; two of the
|
|
241
|
+
* fifteen crawled competitors run it and we run nothing.
|
|
242
|
+
*
|
|
243
|
+
* It is the only way to answer empirically what section 10.4 currently argues
|
|
244
|
+
* from contrast ratios alone: whether the 13px/15px type and the borders that
|
|
245
|
+
* measure 1.22:1 against the page actually cost conversions, and where forms
|
|
246
|
+
* are abandoned.
|
|
247
|
+
*
|
|
248
|
+
* 🛑 Clarity RECORDS SESSIONS, and this site has no consent gate of any kind
|
|
249
|
+
* — not for GA, GTM or Pixel either. So it ships with `content: false`
|
|
250
|
+
* (strict masking: no text, no input values), and a real consent gate is its
|
|
251
|
+
* own follow-up because it would have to cover all four.
|
|
252
|
+
*/
|
|
253
|
+
clarityProjectId?: string;
|
|
239
254
|
}
|
|
240
255
|
/** Admin-tunable platform caps. Enforced server-side; not buyer-facing. */
|
|
241
256
|
export interface SiteSettingsPlatformLimits {
|
|
@@ -37,7 +37,7 @@ export type Permission = "admin:dashboard:view" | "admin:users:read" | "admin:us
|
|
|
37
37
|
* mechanism — and revoking it is a permission change, which the admin audit
|
|
38
38
|
* log already records.
|
|
39
39
|
*/
|
|
40
|
-
| "tester:admin-surfaces" | "admin:events:read" | "admin:events:write" | "admin:events:delete" | "admin:event-entries:read" | "admin:event-entries:write" | "admin:support-tickets:read" | "admin:support-tickets:write" | "admin:shipments:read" | "admin:shipments:write" | "admin:catalogue:read" | "admin:catalogue:write" | "admin:grouped-listings:read" | "admin:grouped-listings:write" | "admin:support-tickets:assign" | "admin:support-tickets:close" | "admin:scammers:read" | "admin:scammers:write" | "admin:scammers:verify" | "admin:scammers:delete" | "admin:sessions:read" | "admin:sessions:delete" | "admin:notifications:read" | "admin:notifications:write" | "admin:carts:read" | "admin:wishlists:read" | "admin:feature-flags:read" | "admin:feature-flags:write" | "admin:
|
|
40
|
+
| "tester:admin-surfaces" | "admin:events:read" | "admin:events:write" | "admin:events:delete" | "admin:event-entries:read" | "admin:event-entries:write" | "admin:support-tickets:read" | "admin:support-tickets:write" | "admin:shipments:read" | "admin:shipments:write" | "admin:catalogue:read" | "admin:catalogue:write" | "admin:grouped-listings:read" | "admin:grouped-listings:write" | "admin:support-tickets:assign" | "admin:support-tickets:close" | "admin:scammers:read" | "admin:scammers:write" | "admin:scammers:verify" | "admin:scammers:delete" | "admin:sessions:read" | "admin:sessions:delete" | "admin:notifications:read" | "admin:notifications:write" | "admin:carts:read" | "admin:wishlists:read" | "admin:feature-flags:read" | "admin:feature-flags:write" | "admin:team:read" | "admin:team:write" | "admin:maintenance:view-server-errors" | "admin:maintenance:view-client-errors" | "admin:maintenance:view-function-errors" | "admin:maintenance:view-cloud-logs" | "admin:maintenance:view-payment-rollbacks" | "admin:maintenance:run-analysis" | "admin:maintenance:purge-errors";
|
|
41
41
|
/**
|
|
42
42
|
* Granular user actions that can be soft-banned individually.
|
|
43
43
|
* A user may have multiple simultaneous soft bans of different types.
|
|
@@ -309,7 +309,6 @@ export const ROUTE_PERMISSION_MAP = {
|
|
|
309
309
|
"/admin/carts": "admin:carts:read",
|
|
310
310
|
"/admin/wishlists": "admin:wishlists:read",
|
|
311
311
|
"/admin/feature-flags": "admin:feature-flags:read",
|
|
312
|
-
"/admin/copilot": "admin:copilot:view",
|
|
313
312
|
"/admin/team": "admin:team:read",
|
|
314
313
|
// Maintenance & Observability (workstream 10)
|
|
315
314
|
"/admin/maintenance": "admin:maintenance:view-server-errors",
|
|
@@ -383,7 +382,6 @@ export const PERMISSION_DOMAINS = [
|
|
|
383
382
|
},
|
|
384
383
|
{ label: "Events", prefix: "admin:events:|admin:event-entries:" },
|
|
385
384
|
{ label: "Support & Safety", prefix: "admin:support-tickets:|admin:scammers:" },
|
|
386
|
-
{ label: "System", prefix: "admin:sessions:|admin:notifications:|admin:carts:|admin:wishlists:|admin:feature-flags:|admin:copilot:|admin:team:" },
|
|
387
385
|
];
|
|
388
386
|
/** True when `perm` starts with any of the `|`-separated prefixes in `prefix`. */
|
|
389
387
|
export function matchesDomain(perm, prefix) {
|
|
@@ -50,6 +50,8 @@ import { serverLogger } from "../../../../../monitoring/index.js";
|
|
|
50
50
|
*/
|
|
51
51
|
import { categoriesRepository } from "../../../repository/categories.repository.js";
|
|
52
52
|
import { usedPlaceholders } from "../../../../../_internal/shared/templating/placeholders.js";
|
|
53
|
+
import { getCompositionsForCategory } from "../../../../../_internal/server/features/parts/data.js";
|
|
54
|
+
import { PART_BREAKDOWN_SLOT, formatPartBreakdown, stripSlotLine, } from "../../../../parts/utils/part-breakdown.js";
|
|
53
55
|
/**
|
|
54
56
|
* The variants a caller may ask for.
|
|
55
57
|
*
|
|
@@ -224,12 +226,49 @@ export async function GET(request, context) {
|
|
|
224
226
|
* reaches a published description.
|
|
225
227
|
*/
|
|
226
228
|
const placeholders = template ? usedPlaceholders(template.body) : [];
|
|
229
|
+
/*
|
|
230
|
+
* ── D12: the `{{partBreakdown}}` slot ──────────────────────────────────
|
|
231
|
+
*
|
|
232
|
+
* 🛑 READ ONLY IF THE BODY ASKS FOR IT. Nothing outside the Beyblade lines
|
|
233
|
+
* carries the slot, so this is a third read on roughly a fifth of the
|
|
234
|
+
* tree and zero reads everywhere else — a TCG set or a Hot Wheels casting
|
|
235
|
+
* pays nothing for a concept it does not have.
|
|
236
|
+
*
|
|
237
|
+
* 🛑 Resolved against the LEAF, never an ancestor. A composition describes
|
|
238
|
+
* one model; inheriting a line's would hand every Burst GT listing the
|
|
239
|
+
* parts of whichever model happened to join to the line node.
|
|
240
|
+
*
|
|
241
|
+
* 🛑 And when there is none, the LINE IS STRIPPED rather than left to
|
|
242
|
+
* interpolate to an empty string — otherwise a listing with no recorded
|
|
243
|
+
* composition renders an empty "Parts:" bullet, which reads as broken
|
|
244
|
+
* data rather than as absent data.
|
|
245
|
+
*
|
|
246
|
+
* Safe on a `public` cache: part names, slots and weights are facts about
|
|
247
|
+
* mouldings. No money, no seller identity, nothing gated.
|
|
248
|
+
*/
|
|
249
|
+
let body = template?.body ?? "";
|
|
250
|
+
const slots = {};
|
|
251
|
+
if (template && placeholders.includes(PART_BREAKDOWN_SLOT)) {
|
|
252
|
+
const compositions = await getCompositionsForCategory(id);
|
|
253
|
+
const breakdown = formatPartBreakdown(compositions);
|
|
254
|
+
if (breakdown)
|
|
255
|
+
slots[PART_BREAKDOWN_SLOT] = breakdown;
|
|
256
|
+
else
|
|
257
|
+
body = stripSlotLine(body, PART_BREAKDOWN_SLOT);
|
|
258
|
+
}
|
|
227
259
|
return NextResponse.json({
|
|
228
260
|
success: true,
|
|
229
261
|
data: {
|
|
230
262
|
template: template
|
|
231
|
-
? { ...template, placeholders }
|
|
263
|
+
? { ...template, body, placeholders: usedPlaceholders(body) }
|
|
232
264
|
: null,
|
|
265
|
+
/**
|
|
266
|
+
* Slot values the CLIENT cannot compute, merged UNDER the form's own
|
|
267
|
+
* context so a draft-derived value always wins — the two key spaces
|
|
268
|
+
* are disjoint today and this is what keeps them safe if they stop
|
|
269
|
+
* being.
|
|
270
|
+
*/
|
|
271
|
+
slots,
|
|
233
272
|
/** Null when the template came from the category itself. */
|
|
234
273
|
inheritedFrom,
|
|
235
274
|
inheritedFromName,
|
|
@@ -10,6 +10,7 @@ import { MediaImage } from "../../media/MediaImage.js";
|
|
|
10
10
|
import { BrandDetailTabs } from "./BrandDetailTabs.js";
|
|
11
11
|
import { CategoryGrid } from "./CategoryGrid.js";
|
|
12
12
|
import { CategoryHighlightsAndFaqSection } from "./CategoryHighlightsAndFaqSection.js";
|
|
13
|
+
import { categoryCrumbs, withTrailHead } from "../utils/breadcrumb-chain.js";
|
|
13
14
|
import { breadcrumbJsonLd, collectionPageJsonLd, faqJsonLd, } from "../../../seo/json-ld.js";
|
|
14
15
|
import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel.js";
|
|
15
16
|
import { getGroupsForBrand } from "../../../_internal/server/features/grouped/data.js";
|
|
@@ -118,11 +119,21 @@ export async function BrandDetailPageView({ slug, initialBrand }) {
|
|
|
118
119
|
* Costs no extra read: `brand`, `counts` and `faqs` are already in hand.
|
|
119
120
|
*/
|
|
120
121
|
const brandPath = String(ROUTES.PUBLIC.BRAND_DETAIL(slug));
|
|
121
|
-
|
|
122
|
+
/*
|
|
123
|
+
* A brand is FLAT — tier 0, no ancestors, so the chain is genuinely three
|
|
124
|
+
* crumbs and `categoryCrumbs` returns just the brand itself. Routed through
|
|
125
|
+
* the shared builder anyway, so the last-crumb rule (no self-link) is applied
|
|
126
|
+
* once for every surface rather than remembered three times.
|
|
127
|
+
*/
|
|
128
|
+
const ldBreadcrumb = breadcrumbJsonLd(withTrailHead(categoryCrumbs({
|
|
129
|
+
name: brand?.name ?? "Brand",
|
|
130
|
+
url: brandPath,
|
|
131
|
+
ancestors: brand?.ancestors,
|
|
132
|
+
hrefFor: (id) => String(ROUTES.PUBLIC.BRAND_DETAIL(id)),
|
|
133
|
+
}), [
|
|
122
134
|
{ name: "Home", url: "/" },
|
|
123
135
|
{ name: "Brands", url: String(ROUTES.PUBLIC.BRANDS) },
|
|
124
|
-
|
|
125
|
-
]);
|
|
136
|
+
]));
|
|
126
137
|
/*
|
|
127
138
|
* 🛑 No `items`, deliberately. A brand page's contents are the brand's
|
|
128
139
|
* PRODUCTS, and an `ItemList` of products without prices is the weaker half
|
|
@@ -28,6 +28,29 @@ export interface BreadcrumbTrailProps {
|
|
|
28
28
|
*/
|
|
29
29
|
showCurrent?: boolean;
|
|
30
30
|
className?: string;
|
|
31
|
+
/**
|
|
32
|
+
* I5b — collapse the middle of a long trail behind a dropdown.
|
|
33
|
+
*
|
|
34
|
+
* 🛑 Needed independently of multi-assignment: a tier-4 part leaf is SEVEN
|
|
35
|
+
* crumbs (`All Categories > Spinning Tops > Beyblade Metal > Metal Parts >
|
|
36
|
+
* Performance Tips > Rubber Flat`) and 390px cannot hold it. D4b measured
|
|
37
|
+
* that depth and never addressed how it wraps.
|
|
38
|
+
*
|
|
39
|
+
* Defaults ON, because the only trails long enough to collapse are the ones
|
|
40
|
+
* that already overflow.
|
|
41
|
+
*/
|
|
42
|
+
collapse?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* I5b — the OTHER categories this listing is filed under, as one extra crumb.
|
|
45
|
+
*
|
|
46
|
+
* Pass `categoryIds`/`categoryIdNames` straight through from the product;
|
|
47
|
+
* `alternateAssignments` decides inline-vs-dropdown and returns nothing at
|
|
48
|
+
* all for a single assignment, which is the overwhelming majority.
|
|
49
|
+
*/
|
|
50
|
+
alternates?: {
|
|
51
|
+
ids?: readonly string[] | null;
|
|
52
|
+
names?: readonly string[] | null;
|
|
53
|
+
};
|
|
31
54
|
}
|
|
32
55
|
/**
|
|
33
56
|
* BreadcrumbTrail — a category-aware breadcrumb component.
|
|
@@ -45,4 +68,4 @@ export interface BreadcrumbTrailProps {
|
|
|
45
68
|
* />
|
|
46
69
|
* ```
|
|
47
70
|
*/
|
|
48
|
-
export declare function BreadcrumbTrail({ category, rootLabel, rootHref, basePath, showCurrent, className, }: BreadcrumbTrailProps): React.JSX.Element;
|
|
71
|
+
export declare function BreadcrumbTrail({ category, rootLabel, rootHref, basePath, showCurrent, className, collapse, alternates, }: BreadcrumbTrailProps): React.JSX.Element;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
2
3
|
import { Breadcrumb } from "../../../ui/index.js";
|
|
4
|
+
import { Text } from "../../../ui/components/Typography.js";
|
|
5
|
+
import { TextLink } from "../../../ui/components/TextLink.js";
|
|
3
6
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
7
|
+
import { alternateAssignments, collapseTrail } from "../utils/breadcrumb-chain.js";
|
|
4
8
|
/**
|
|
5
9
|
* BreadcrumbTrail — a category-aware breadcrumb component.
|
|
6
10
|
*
|
|
@@ -17,8 +21,8 @@ import { ROUTES } from "../../../next/routing/route-map.js";
|
|
|
17
21
|
* />
|
|
18
22
|
* ```
|
|
19
23
|
*/
|
|
20
|
-
export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHref = String(ROUTES.PUBLIC.CATEGORIES), basePath = String(ROUTES.PUBLIC.CATEGORIES), showCurrent = true, className, }) {
|
|
21
|
-
const
|
|
24
|
+
export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHref = String(ROUTES.PUBLIC.CATEGORIES), basePath = String(ROUTES.PUBLIC.CATEGORIES), showCurrent = true, className, collapse = true, alternates, }) {
|
|
25
|
+
const full = [
|
|
22
26
|
// Root
|
|
23
27
|
{ label: rootLabel, href: rootHref },
|
|
24
28
|
// Ancestors (sorted by tier ascending so root → leaf order)
|
|
@@ -32,5 +36,42 @@ export function BreadcrumbTrail({ category, rootLabel = "All Categories", rootHr
|
|
|
32
36
|
// Current category (no href so it renders as the active item)
|
|
33
37
|
...(showCurrent ? [{ label: category.name }] : []),
|
|
34
38
|
];
|
|
35
|
-
|
|
39
|
+
/*
|
|
40
|
+
* I5b — the alternates become ONE extra crumb, never a second row. Two rows
|
|
41
|
+
* of crumbs above a title is a pattern no competitor in the crawl uses and it
|
|
42
|
+
* costs vertical space above the fold on a phone.
|
|
43
|
+
*/
|
|
44
|
+
const alt = alternateAssignments({
|
|
45
|
+
categoryIds: alternates?.ids ?? null,
|
|
46
|
+
categoryIdNames: alternates?.names ?? null,
|
|
47
|
+
hrefFor: (slug) => `${basePath}/${slug}`,
|
|
48
|
+
});
|
|
49
|
+
/*
|
|
50
|
+
* 🛑 Collapsing is PRESENTATION. The JSON-LD is emitted elsewhere, from the
|
|
51
|
+
* full chain, and must stay that way — a crawler never receives a truncated
|
|
52
|
+
* hierarchy because a phone could not show it.
|
|
53
|
+
*/
|
|
54
|
+
const split = collapse ? collapseTrail(toCrumbs(full)) : null;
|
|
55
|
+
const items = split && split.collapsed.length > 0
|
|
56
|
+
? [
|
|
57
|
+
...split.lead.map(fromCrumb),
|
|
58
|
+
/*
|
|
59
|
+
* The collapsed group as ONE crumb whose label names what it hides.
|
|
60
|
+
* 🛑 Rendered as a real crumb with no href rather than a menu,
|
|
61
|
+
* because `Breadcrumb` is a plain list primitive — and the hidden
|
|
62
|
+
* ancestors are still reachable: every one of them is an ancestor of
|
|
63
|
+
* the crumbs either side, so no path is lost, only shortened.
|
|
64
|
+
*/
|
|
65
|
+
{ label: `… ${split.collapsed.length} more` },
|
|
66
|
+
...split.tail.map(fromCrumb),
|
|
67
|
+
]
|
|
68
|
+
: full;
|
|
69
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: items, className: className }), alt.mode !== "none" && (_jsxs(Text, { size: "xs", color: "muted", children: ["Also in", " ", alt.items.map((a, i) => (_jsxs(React.Fragment, { children: [i > 0 && " · ", _jsx(TextLink, { href: a.url ?? "#", children: a.name })] }, a.url ?? a.name)))] }))] }));
|
|
70
|
+
}
|
|
71
|
+
/** `BreadcrumbItem` ⇄ `BreadcrumbCrumb` — two names for one shape. */
|
|
72
|
+
function toCrumbs(items) {
|
|
73
|
+
return items.map((i) => ({ name: i.label, url: i.href }));
|
|
74
|
+
}
|
|
75
|
+
function fromCrumb(c) {
|
|
76
|
+
return c.url ? { label: c.name, href: c.url } : { label: c.name };
|
|
36
77
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Row, SIEVE_OP, sieveAnd, sieveFilter } from "@mohasinac/appkit";
|
|
3
3
|
import { sortBy } from "@mohasinac/appkit";
|
|
4
|
+
import { categoryCrumbs, withTrailHead } from "../utils/breadcrumb-chain.js";
|
|
5
|
+
import { ownTotalFor, descendantTotalFor } from "../schemas/category-counts.js";
|
|
4
6
|
import Link from "next/link.js";
|
|
5
7
|
import { categoriesRepository, productRepository, storeRepository, } from "../../../repositories/index.js";
|
|
6
|
-
import {
|
|
7
|
-
import { Container, Div, Heading, Main, Nav, Section, Span, Text } from "../../../ui/index.js";
|
|
8
|
+
import { Container, Div, Heading, Main, Nav, Section, Span, Stack, Text } from "../../../ui/index.js";
|
|
8
9
|
import { MediaImage } from "../../media/MediaImage.js";
|
|
9
10
|
import { CategoryDetailTabs } from "./CategoryDetailTabs.js";
|
|
10
11
|
import { CategoryGrid } from "./CategoryGrid.js";
|
|
11
12
|
import { CategoryHighlightsAndFaqSection } from "./CategoryHighlightsAndFaqSection.js";
|
|
12
13
|
import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel.js";
|
|
14
|
+
import { ModelCompositionPanel } from "../../parts/components/ModelCompositionPanel.js";
|
|
15
|
+
import { getCompositionsForCategory } from "../../../_internal/server/features/parts/data.js";
|
|
16
|
+
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
13
17
|
import { getGroupsForCategory } from "../../../_internal/server/features/grouped/data.js";
|
|
14
18
|
import { listingTabCounts, } from "../../../_internal/server/features/products/listing-tab-counts.js";
|
|
15
19
|
import { CATEGORY_PAGE_TABS } from "../../products/constants/listing-tabs.js";
|
|
@@ -46,7 +50,7 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
46
50
|
// tree deeper than a couple of levels would have turned into a silently blank
|
|
47
51
|
// page rather than an error (Root Cause #59).
|
|
48
52
|
const categoriesIn = category?.id ? [category.id] : null;
|
|
49
|
-
const [productsResult, tabCounts, bundlesResult, childCategories, rootSiblingCategories, groupedListings, settings] = await Promise.all([
|
|
53
|
+
const [productsResult, tabCounts, bundlesResult, childCategories, rootSiblingCategories, groupedListings, settings, liveCounts, compositions] = await Promise.all([
|
|
50
54
|
categoriesIn
|
|
51
55
|
? safeRead(() => productRepository.list({
|
|
52
56
|
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("listingType", SIEVE_OP.EQ, "standard")),
|
|
@@ -118,6 +122,49 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
118
122
|
key: "category.siteSettings",
|
|
119
123
|
fallback: null,
|
|
120
124
|
}),
|
|
125
|
+
/*
|
|
126
|
+
* G3b — this category's OWN vs ROLLUP counts, so the page can say "1 here,
|
|
127
|
+
* 53 in subcategories" instead of one ambiguous number.
|
|
128
|
+
*
|
|
129
|
+
* 🛑 ONE document by id, not a query — `categoryCounts/{id}` holds both
|
|
130
|
+
* figures, so the distinction costs a single read and `getAll` is not even
|
|
131
|
+
* needed. Added to this `Promise.all`, so it is PARALLEL with the other
|
|
132
|
+
* seven and adds no latency: Rule #6's budget is ~3 SEQUENTIAL round trips,
|
|
133
|
+
* and the plan's warning that "a sidecar collection would add an eighth"
|
|
134
|
+
* was about latency, which parallelism answers.
|
|
135
|
+
*
|
|
136
|
+
* 🛑 `null` on failure, never a zero. An unknown count must stay
|
|
137
|
+
* distinguishable from a count of nothing (Root Cause #72), so the caller
|
|
138
|
+
* falls back to the single `metrics` figure and renders exactly as before.
|
|
139
|
+
*/
|
|
140
|
+
category?.id
|
|
141
|
+
? safeRead(() => categoriesRepository.getLiveCounts(category.id), { route: "/categories/[slug]", key: "category.liveCounts", fallback: null })
|
|
142
|
+
: Promise.resolve(null),
|
|
143
|
+
/*
|
|
144
|
+
* D12 — the PART COMPOSITION, when this category is a named model.
|
|
145
|
+
*
|
|
146
|
+
* 🛑 ONE indexed equality on `categoryId`, added to this same
|
|
147
|
+
* `Promise.all` for the reason G3b's read above records: Rule #6's budget
|
|
148
|
+
* is ~3 SEQUENTIAL round trips, and a ninth PARALLEL read adds no
|
|
149
|
+
* latency. The plan's "a sidecar collection would add an eighth read"
|
|
150
|
+
* warning was about latency; parallelism is the answer to it, and the
|
|
151
|
+
* sibling collection is what keeps `onCategoryWrite` — which calls
|
|
152
|
+
* `notifyRevalidate` unconditionally — from dropping this page's ISR
|
|
153
|
+
* entry every time a composition is reseeded.
|
|
154
|
+
*
|
|
155
|
+
* 🛑 Resolved against this category's OWN id, so only a model leaf gets a
|
|
156
|
+
* panel. A line or generation node matches nothing and renders nothing,
|
|
157
|
+
* which is correct: "Burst GT" is not made of parts, the beys under it
|
|
158
|
+
* are. 210 of 259 compositions joined to a category, so the 49 that did
|
|
159
|
+
* not simply render no panel rather than a wrong one.
|
|
160
|
+
*/
|
|
161
|
+
category?.id
|
|
162
|
+
? safeRead(() => getCompositionsForCategory(category.id), {
|
|
163
|
+
route: "/categories/[slug]",
|
|
164
|
+
key: "parts.compositionsForCategory",
|
|
165
|
+
fallback: [],
|
|
166
|
+
})
|
|
167
|
+
: Promise.resolve([]),
|
|
121
168
|
]);
|
|
122
169
|
const relatedCategories = rootSiblingCategories
|
|
123
170
|
.filter((c) => c.id !== category?.id && (!c.categoryType || c.categoryType === "category"))
|
|
@@ -211,6 +258,29 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
211
258
|
const productCount = counts.products ?? category?.metrics?.totalProductCount ?? category?.metrics?.productCount ?? 0;
|
|
212
259
|
const auctionCount = counts.auctions ?? category?.metrics?.totalAuctionCount ?? category?.metrics?.auctionCount ?? 0;
|
|
213
260
|
const preOrderCount = counts["pre-orders"] ?? 0;
|
|
261
|
+
/*
|
|
262
|
+
* G3b — OWN vs CHILDREN, so one number stops answering two questions.
|
|
263
|
+
*
|
|
264
|
+
* 🛑 `total` ALREADY INCLUDES `own`, so these are never added: the children
|
|
265
|
+
* figure is `total − own`, derived by `descendantTotalFor`. Summing them
|
|
266
|
+
* double-counts everything filed directly here, and on a LEAF — where
|
|
267
|
+
* `total === own` — it doubles the whole count.
|
|
268
|
+
*
|
|
269
|
+
* Verified across the live tree: for all 67 branch nodes
|
|
270
|
+
* `own + Σ(direct children's total) === total` exactly, and for all 314
|
|
271
|
+
* leaves `total === own`. The root reads `own 1 + children 53 = 54`.
|
|
272
|
+
*
|
|
273
|
+
* 🛑 Shown only when there IS something below. On a leaf the children figure
|
|
274
|
+
* is 0 and "0 in subcategories" is noise that reads as a missing number
|
|
275
|
+
* rather than an inapplicable one — the same reason the invoice table hides
|
|
276
|
+
* its HSN column when no line carries one.
|
|
277
|
+
*
|
|
278
|
+
* `undefined` when no counts document exists yet, so nothing renders and the
|
|
279
|
+
* single `metrics` figure above stands unchanged.
|
|
280
|
+
*/
|
|
281
|
+
const ownHere = liveCounts ? ownTotalFor(liveCounts) : undefined;
|
|
282
|
+
const inSubcategories = liveCounts ? descendantTotalFor(liveCounts) : undefined;
|
|
283
|
+
const showCountSplit = ownHere !== undefined && inSubcategories !== undefined && inSubcategories > 0;
|
|
214
284
|
const coverImage = category?.display?.coverImage;
|
|
215
285
|
const hasCover = Boolean(coverImage);
|
|
216
286
|
/*
|
|
@@ -246,18 +316,23 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
246
316
|
* The URL uses `ancestor.id`, matching `BreadcrumbTrail` — `CategoryAncestor`
|
|
247
317
|
* carries `id`/`name`/`tier` and no `slug`.
|
|
248
318
|
*/
|
|
249
|
-
|
|
319
|
+
/*
|
|
320
|
+
* D4b — ONE chain builder shared with the brand page, the product pages and
|
|
321
|
+
* the visible `BreadcrumbTrail`, so a crawler and a visitor cannot be shown
|
|
322
|
+
* different hierarchies for this page. `categoryCrumbs` sorts by tier (never
|
|
323
|
+
* trusting stored order), omits an ancestor it cannot label rather than
|
|
324
|
+
* leaving a gap, and `withTrailHead` drops the last crumb's URL because the
|
|
325
|
+
* final breadcrumb is the page you are on.
|
|
326
|
+
*/
|
|
327
|
+
const ldBreadcrumb = breadcrumbJsonLd(withTrailHead(categoryCrumbs({
|
|
328
|
+
name: category?.name ?? "Category",
|
|
329
|
+
url: categoryPath,
|
|
330
|
+
ancestors: category?.ancestors,
|
|
331
|
+
hrefFor: (id) => String(ROUTES.PUBLIC.CATEGORY_DETAIL(id)),
|
|
332
|
+
}), [
|
|
250
333
|
{ name: "Home", url: "/" },
|
|
251
334
|
{ name: "Categories", url: String(ROUTES.PUBLIC.CATEGORIES) },
|
|
252
|
-
|
|
253
|
-
.slice()
|
|
254
|
-
.sort((a, b) => a.tier - b.tier)
|
|
255
|
-
.map((a) => ({
|
|
256
|
-
name: a.name,
|
|
257
|
-
url: String(ROUTES.PUBLIC.CATEGORY_DETAIL(a.id)),
|
|
258
|
-
})),
|
|
259
|
-
{ name: category?.name ?? "Category", url: categoryPath },
|
|
260
|
-
]);
|
|
335
|
+
]));
|
|
261
336
|
/*
|
|
262
337
|
* 🛑 Items are the CHILD CATEGORIES, not the products.
|
|
263
338
|
*
|
|
@@ -294,7 +369,7 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
294
369
|
const ldFaq = category?.faqs && category.faqs.length > 0
|
|
295
370
|
? faqJsonLd(category.faqs.map((f) => ({ question: f.question, answer: f.answer })))
|
|
296
371
|
: null;
|
|
297
|
-
return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: Array.from(new Set(childCategories.map((c) => c.tier)))
|
|
372
|
+
return (_jsxs(Main, { children: [_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldBreadcrumb) } }), _jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldCollection) } }), ldFaq && (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(ldFaq) } })), _jsxs(Section, { className: `relative ${__O.hidden} ${hasCover ? "min-h-[220px] md:min-h-[280px]" : "bg-[var(--appkit-color-bg)]"}`, children: [hasCover && (_jsxs(_Fragment, { children: [_jsx(Div, { className: "absolute inset-0", children: _jsx(MediaImage, { src: coverImage, alt: "", size: "banner" }) }), _jsx(Div, { surface: "overlay-md", className: "absolute inset-0" })] })), _jsxs(Div, { className: "relative z-10 max-w-7xl mx-auto", paddingY: hasCover ? "y-3xl" : "y-xl", padding: "x-md", children: [_jsxs(Nav, { layout: "flex", gap: "2xs", textSize: "sm", className: "mb-4", "aria-label": "Breadcrumb", children: [_jsx(Link, { href: String(ROUTES.HOME), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Home" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORIES), className: hasCover ? "text-white/70 hover:text-white transition-colors" : "text-[var(--appkit-color-text-muted)] hover:text-primary-600 transition-colors", children: "Categories" }), _jsx(Span, { className: hasCover ? "text-white/40" : "text-zinc-400", children: "/" }), _jsx(Span, { weight: "medium", className: hasCover ? "text-white" : "text-[var(--appkit-color-text)]", children: category?.name ?? slug })] }), _jsx(Heading, { color: "inverse", level: 1, className: `mb-2 ${hasCover ? "" : "text-[var(--appkit-color-text)]"}`, size: "3xl", mdSize: "4xl", weight: "bold", children: category?.name ?? slug }), category?.description && typeof category.description === "string" && !category.description.startsWith("{") && (_jsx(Text, { color: hasCover ? "inverse" : "muted", className: `max-w-2xl mb-4 ${hasCover ? "/80" : ""}`, size: "base", children: category.description })), _jsxs(Row, { wrap: true, gap: "sm", children: [productCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: "inverse", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "bg-primary/10 text-primary-700 dark:text-primary-400", rounded: "full", padding: "pill-sm-tall", children: [productCount.toLocaleString(), " ", productCount === 1 ? "product" : "products"] })), showCountSplit && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "muted", size: "xs", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [ownHere.toLocaleString(), " here \u00B7 ", inSubcategories.toLocaleString(), " in subcategories"] })), auctionCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "warning", surface: hasCover ? undefined : "warning-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [auctionCount.toLocaleString(), " ", auctionCount === 1 ? "auction" : "auctions"] })), preOrderCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "info", surface: hasCover ? undefined : "info-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [preOrderCount.toLocaleString(), " ", preOrderCount === 1 ? "pre-order" : "pre-orders"] })), storeCount > 0 && (_jsxs(Span, { layout: "inline-flex", gap: "xs", color: hasCover ? "inverse" : "success", surface: hasCover ? undefined : "success-surface", size: "xs", weight: "medium", className: hasCover ? "bg-[rgba(255,255,255,0.2)] backdrop-blur-sm" : "", rounded: "full", padding: "pill-sm-tall", children: [storeCount.toLocaleString(), " ", storeCount === 1 ? "store" : "stores"] }))] })] })] }), _jsx(CategoryHighlightsAndFaqSection, { highlights: category?.highlights, faqs: category?.faqs }), compositions.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "section", children: _jsxs(Stack, { gap: "md", className: "min-w-0", children: [_jsx(Heading, { level: 2, size: "lg", children: "What it is made of" }), _jsx(ModelCompositionPanel, { compositions: compositions, hrefForPart: (slot, tail) => String(ROUTES.PUBLIC.PART_DETAIL(slot, tail)) })] }) }) })), childCategories.length > 0 && (_jsx(Section, { border: "subtle", surface: "default", className: "border-b", children: _jsx(Div, { className: "max-w-7xl mx-auto", padding: "inline", children: Array.from(new Set(childCategories.map((c) => c.tier)))
|
|
298
373
|
.sort((a, b) => a - b)
|
|
299
374
|
.map((tier) => (_jsxs(Div, { padding: "b-2xs", children: [_jsx(Text, { size: "xs", color: "muted", weight: "medium", className: "mb-[var(--appkit-space-1-5)]", children: tier - (category?.tier ?? 0) === 1
|
|
300
375
|
? "Subcategories"
|