@mohasinac/appkit 4.45.0 → 4.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/categories/live-counts.d.ts +41 -0
  3. package/dist/_internal/server/features/categories/live-counts.js +72 -0
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +17 -0
  5. package/dist/_internal/server/features/orders/adapters.js +29 -0
  6. package/dist/_internal/server/features/parts/assembly.d.ts +126 -0
  7. package/dist/_internal/server/features/parts/assembly.js +246 -0
  8. package/dist/_internal/server/features/parts/data.d.ts +65 -0
  9. package/dist/_internal/server/features/parts/data.js +170 -0
  10. package/dist/_internal/server/features/parts/og.d.ts +34 -0
  11. package/dist/_internal/server/features/parts/og.js +66 -0
  12. package/dist/_internal/server/features/search/search-everything.d.ts +75 -0
  13. package/dist/_internal/server/features/search/search-everything.js +120 -0
  14. package/dist/_internal/server/features/search/search-sources.d.ts +17 -0
  15. package/dist/_internal/server/features/search/search-sources.js +138 -0
  16. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.d.ts +70 -0
  17. package/dist/_internal/server/jobs/core/categoryTaxonomyBackfill.js +302 -0
  18. package/dist/_internal/server/jobs/core/countersReconcile.js +128 -19
  19. package/dist/_internal/server/jobs/core/jobRunners.js +12 -0
  20. package/dist/_internal/server/jobs/core/onCategoryWrite.js +73 -3
  21. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -43
  22. package/dist/_internal/server/jobs/core/productViewWindows.d.ts +2 -0
  23. package/dist/_internal/server/jobs/core/productViewWindows.js +173 -0
  24. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  25. package/dist/_internal/server/jobs/core/testDataCleanup.js +27 -1
  26. package/dist/_internal/server/jobs/handlers/pageViewRollup.d.ts +20 -0
  27. package/dist/_internal/server/jobs/handlers/pageViewRollup.js +25 -1
  28. package/dist/_internal/shared/actions/action-registry.js +36 -0
  29. package/dist/_internal/shared/features/promotions/schema.d.ts +2 -2
  30. package/dist/_internal/shared/features/shipping/protections.d.ts +93 -1
  31. package/dist/_internal/shared/features/shipping/protections.js +127 -0
  32. package/dist/_internal/shared/search/part-aliases.d.ts +44 -0
  33. package/dist/_internal/shared/search/part-aliases.js +104 -0
  34. package/dist/constants/api-endpoints.d.ts +18 -12
  35. package/dist/constants/api-endpoints.js +6 -8
  36. package/dist/constants/field-names.d.ts +0 -2
  37. package/dist/constants/field-names.js +0 -2
  38. package/dist/constants/index.d.ts +1 -1
  39. package/dist/constants/index.js +1 -1
  40. package/dist/core/index.d.ts +0 -3
  41. package/dist/core/index.js +0 -2
  42. package/dist/errors/messages.d.ts +0 -7
  43. package/dist/errors/messages.js +0 -8
  44. package/dist/features/admin/components/AdminSectionsView.js +83 -32
  45. package/dist/features/admin/components/AdminSiteSettingsView.js +6 -2
  46. package/dist/features/admin/components/sections/sectionTemplates.d.ts +117 -0
  47. package/dist/features/admin/components/sections/sectionTemplates.js +213 -0
  48. package/dist/features/admin/schemas/firestore.d.ts +15 -0
  49. package/dist/features/admin/types/index.d.ts +0 -1
  50. package/dist/features/auth/permissions/constants.d.ts +1 -1
  51. package/dist/features/auth/permissions/constants.js +0 -2
  52. package/dist/features/categories/api/[id]/description-template/route.js +40 -1
  53. package/dist/features/categories/components/BrandDetailPageView.js +14 -3
  54. package/dist/features/categories/components/BreadcrumbTrail.d.ts +24 -1
  55. package/dist/features/categories/components/BreadcrumbTrail.js +45 -4
  56. package/dist/features/categories/components/CategoryDetailPageView.js +89 -14
  57. package/dist/features/categories/components/CategoryTemplatePicker.js +9 -1
  58. package/dist/features/categories/repository/categories.repository.d.ts +65 -0
  59. package/dist/features/categories/repository/categories.repository.js +168 -22
  60. package/dist/features/categories/repository/category-counts.d.ts +143 -0
  61. package/dist/features/categories/repository/category-counts.js +224 -0
  62. package/dist/features/categories/schemas/category-counts.d.ts +155 -0
  63. package/dist/features/categories/schemas/category-counts.js +116 -0
  64. package/dist/features/categories/schemas/firestore.d.ts +0 -2
  65. package/dist/features/categories/schemas/firestore.js +0 -1
  66. package/dist/features/categories/utils/breadcrumb-chain.d.ts +194 -0
  67. package/dist/features/categories/utils/breadcrumb-chain.js +202 -0
  68. package/dist/features/corporate/schemas/index.d.ts +4 -4
  69. package/dist/features/orders/schemas/firestore.d.ts +13 -0
  70. package/dist/features/orders/types/index.d.ts +30 -0
  71. package/dist/features/parts/components/AssemblyDrawer.d.ts +107 -0
  72. package/dist/features/parts/components/AssemblyDrawer.js +250 -0
  73. package/dist/features/parts/components/ModelCompositionPanel.d.ts +37 -0
  74. package/dist/features/parts/components/ModelCompositionPanel.js +34 -0
  75. package/dist/features/parts/components/PartDetailPageView.d.ts +48 -0
  76. package/dist/features/parts/components/PartDetailPageView.js +20 -0
  77. package/dist/features/parts/components/PartsIndexPageView.d.ts +33 -0
  78. package/dist/features/parts/components/PartsIndexPageView.js +17 -0
  79. package/dist/features/parts/schemas/firestore.d.ts +257 -0
  80. package/dist/features/parts/schemas/firestore.js +181 -0
  81. package/dist/features/parts/utils/colourway-match.d.ts +62 -0
  82. package/dist/features/parts/utils/colourway-match.js +98 -0
  83. package/dist/features/parts/utils/part-breakdown.d.ts +53 -0
  84. package/dist/features/parts/utils/part-breakdown.js +77 -0
  85. package/dist/features/products/components/ProductDetailPageView.d.ts +14 -1
  86. package/dist/features/products/components/ProductDetailPageView.js +22 -3
  87. package/dist/features/products/components/ProductTabsShell.d.ts +3 -1
  88. package/dist/features/products/components/ProductTabsShell.js +4 -1
  89. package/dist/features/products/repository/category-assignment.d.ts +132 -0
  90. package/dist/features/products/repository/category-assignment.js +304 -0
  91. package/dist/features/products/repository/products.repository.d.ts +58 -1
  92. package/dist/features/products/repository/products.repository.js +255 -28
  93. package/dist/features/products/schemas/firestore.d.ts +147 -6
  94. package/dist/features/products/schemas/firestore.js +49 -0
  95. package/dist/features/search/components/SearchResultsPageView.d.ts +33 -0
  96. package/dist/features/search/components/SearchResultsPageView.js +21 -0
  97. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  98. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  99. package/dist/features/seller/components/SellerProductShell.d.ts +26 -1
  100. package/dist/features/seller/components/SellerProductShell.js +30 -1
  101. package/dist/features/seller/components/SellerProductsView.js +129 -3
  102. package/dist/features/tester/seed-data/authored/admin__site-system.js +0 -15
  103. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.d.ts +2 -0
  104. package/dist/features/tester/seed-data/authored/content-discovery__parts-database.js +166 -0
  105. package/dist/features/tester/seed-data/authored/index.js +2 -0
  106. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +28 -1
  107. package/dist/index.d.ts +11 -16
  108. package/dist/index.js +26 -18
  109. package/dist/next/routing/route-map.d.ts +6 -2
  110. package/dist/next/routing/route-map.js +14 -1
  111. package/dist/providers/db-firebase/base.repository.d.ts +31 -0
  112. package/dist/providers/db-firebase/base.repository.js +31 -0
  113. package/dist/repositories/index.d.ts +1 -2
  114. package/dist/repositories/index.js +0 -1
  115. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  116. package/dist/seed/_helpers/category-content-generated.js +1837 -0
  117. package/dist/seed/_helpers/category-content.d.ts +29 -0
  118. package/dist/seed/_helpers/category-content.js +98 -1
  119. package/dist/seed/_helpers/category-forest.js +104 -24
  120. package/dist/seed/_helpers/category-parts.d.ts +9 -0
  121. package/dist/seed/_helpers/category-parts.js +4594 -0
  122. package/dist/seed/carousel-slides-seed-data.js +81 -8
  123. package/dist/seed/categories-seed-data.js +44 -6
  124. package/dist/seed/homepage-sections-seed-data.js +170 -9
  125. package/dist/seed/index.d.ts +2 -0
  126. package/dist/seed/index.js +13 -0
  127. package/dist/seed/model-composition-seed-data.d.ts +15 -0
  128. package/dist/seed/model-composition-seed-data.js +8918 -0
  129. package/dist/seed/part-glossary-seed-data.d.ts +15 -0
  130. package/dist/seed/part-glossary-seed-data.js +10729 -0
  131. package/dist/seed/products-standard-seed-data.js +52 -0
  132. package/dist/seed/seed-purpose.d.ts +103 -4
  133. package/dist/seed/seed-purpose.js +162 -11
  134. package/dist/seo/json-ld.d.ts +10 -1
  135. package/dist/seo/json-ld.js +9 -1
  136. package/dist/server.d.ts +7 -0
  137. package/dist/server.js +11 -0
  138. package/dist/styles.css +1 -1
  139. package/dist/tailwind-utilities.css +1 -1
  140. package/dist/utils/id-generators.d.ts +30 -0
  141. package/dist/utils/id-generators.js +31 -1
  142. package/dist/utils/search-txt-builders.js +22 -0
  143. package/dist/utils/search-txt.d.ts +22 -0
  144. package/dist/utils/search-txt.js +17 -0
  145. package/dist/values/success-messages.d.ts +0 -4
  146. package/dist/values/success-messages.js +0 -5
  147. package/package.json +1 -1
  148. package/scripts/seed-cli.mjs +292 -16
  149. package/dist/core/copilot-log.repository.d.ts +0 -44
  150. package/dist/core/copilot-log.repository.js +0 -61
  151. package/dist/features/copilot/components/AdminCopilotView.d.ts +0 -21
  152. package/dist/features/copilot/components/AdminCopilotView.js +0 -51
  153. package/dist/features/copilot/components/index.d.ts +0 -2
  154. package/dist/features/copilot/components/index.js +0 -1
  155. package/dist/features/copilot/hooks/useCopilotChat.d.ts +0 -25
  156. package/dist/features/copilot/hooks/useCopilotChat.js +0 -65
  157. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +0 -15
  158. package/dist/features/copilot/hooks/useCopilotFeedback.js +0 -15
  159. package/dist/features/copilot/index.d.ts +0 -3
  160. package/dist/features/copilot/index.js +0 -3
@@ -0,0 +1,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,6 @@ export interface AnalyticsTopProduct {
37
37
  title: string;
38
38
  revenue: number;
39
39
  orders: number;
40
- viewCount?: number;
41
40
  mainImage?: string;
42
41
  }
43
42
  export interface AnalyticsSummary {
@@ -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:copilot:view" | "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";
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
- const ldBreadcrumb = breadcrumbJsonLd([
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
- { name: brand?.name ?? "Brand", url: brandPath },
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 items = [
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
- return _jsx(Breadcrumb, { items: items, className: className });
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 { ROUTES } from "../../../next/routing/route-map.js";
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
- const ldBreadcrumb = breadcrumbJsonLd([
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
- ...(category?.ancestors ?? [])
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"