@mohasinac/appkit 4.63.4 → 4.64.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 (232) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/categories/adapters.js +13 -0
  4. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  5. package/dist/_internal/server/features/checkout/actions.js +22 -5
  6. package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
  7. package/dist/_internal/server/features/events/like-actions.js +67 -0
  8. package/dist/_internal/server/features/events/likes.d.ts +65 -0
  9. package/dist/_internal/server/features/events/likes.js +146 -0
  10. package/dist/_internal/server/features/orders/adapters.js +3 -1
  11. package/dist/_internal/server/features/seo/index.d.ts +3 -0
  12. package/dist/_internal/server/features/seo/index.js +2 -0
  13. package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
  14. package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
  15. package/dist/_internal/server/features/seo/robots.js +20 -1
  16. package/dist/_internal/server/features/seo/sitemap.js +125 -27
  17. package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
  18. package/dist/_internal/server/features/seo/static-params.js +228 -0
  19. package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
  20. package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
  21. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  22. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  23. package/dist/_internal/server/features/stores/adapters.js +88 -1
  24. package/dist/_internal/server/functions/scheduled.d.ts +16 -1
  25. package/dist/_internal/server/functions/scheduled.js +23 -1
  26. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  27. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  28. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  29. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  30. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  31. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  32. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  33. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  34. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  35. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  36. package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
  37. package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
  38. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  39. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  40. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  41. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  42. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
  43. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
  44. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  45. package/dist/_internal/server/notifications/channel-health.js +2 -2
  46. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  47. package/dist/_internal/server/notifications/send-guard.js +4 -2
  48. package/dist/_internal/shared/actions/action-registry.js +89 -0
  49. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  50. package/dist/_internal/shared/checkout/order-math.js +1 -1
  51. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  52. package/dist/_internal/shared/features/checkout/config.js +2 -0
  53. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  54. package/dist/_internal/shared/features/messaging/config.js +4 -1
  55. package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
  56. package/dist/_internal/shared/features/seller-standing/config.js +153 -0
  57. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  58. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  59. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  60. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  61. package/dist/_internal/shared/fees/calculator.js +7 -0
  62. package/dist/cli/index.js +2 -0
  63. package/dist/client.d.ts +2 -2
  64. package/dist/client.js +1 -1
  65. package/dist/constants/api-endpoints.d.ts +39 -0
  66. package/dist/constants/api-endpoints.js +16 -0
  67. package/dist/constants/notification-types.d.ts +1 -1
  68. package/dist/constants/notification-types.js +1 -1
  69. package/dist/core/integration-keys.d.ts +5 -0
  70. package/dist/core/integration-keys.js +10 -0
  71. package/dist/features/about/components/FeesView.js +6 -0
  72. package/dist/features/about/components/HelpPageView.js +9 -1
  73. package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
  74. package/dist/features/account/components/BecomeSellerView.js +2 -2
  75. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  76. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  77. package/dist/features/account/types/index.d.ts +2 -0
  78. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  79. package/dist/features/admin/actions/notification-actions.js +154 -9
  80. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  81. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  82. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  83. package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
  84. package/dist/features/admin/components/AdminStoreEditorView.js +31 -2
  85. package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
  86. package/dist/features/admin/components/AdminStoresView.js +59 -2
  87. package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
  88. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  89. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  90. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  91. package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
  92. package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
  93. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  94. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  95. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  96. package/dist/features/admin/schemas/firestore.js +8 -3
  97. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  98. package/dist/features/auth/checkout-value-otp.js +1 -1
  99. package/dist/features/auth/permissions/constants.d.ts +2 -2
  100. package/dist/features/auth/permissions/constants.js +2 -1
  101. package/dist/features/blog/components/BlogIndexPageView.js +2 -2
  102. package/dist/features/blog/components/BlogListView.js +2 -1
  103. package/dist/features/blog/components/BlogPostView.js +2 -1
  104. package/dist/features/blog/constants/category-badge.d.ts +33 -0
  105. package/dist/features/blog/constants/category-badge.js +40 -0
  106. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  107. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  108. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  109. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  110. package/dist/features/cart/repository/cart.repository.js +2 -1
  111. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  112. package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
  113. package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
  114. package/dist/features/categories/schemas/firestore.d.ts +25 -0
  115. package/dist/features/categories/utils/load-category-options.d.ts +33 -0
  116. package/dist/features/categories/utils/load-category-options.js +50 -0
  117. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  118. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  119. package/dist/features/events/components/EventCard.js +4 -12
  120. package/dist/features/events/components/EventInsights.d.ts +27 -0
  121. package/dist/features/events/components/EventInsights.js +10 -0
  122. package/dist/features/events/components/EventLikeButton.d.ts +21 -0
  123. package/dist/features/events/components/EventLikeButton.js +59 -0
  124. package/dist/features/events/components/EventLikersRail.d.ts +23 -0
  125. package/dist/features/events/components/EventLikersRail.js +54 -0
  126. package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
  127. package/dist/features/events/components/EventTypeBadge.js +7 -0
  128. package/dist/features/events/components/EventsListPageView.js +2 -2
  129. package/dist/features/events/components/SpinWheel.d.ts +41 -0
  130. package/dist/features/events/components/SpinWheel.js +128 -0
  131. package/dist/features/events/components/SpinWheelView.js +36 -15
  132. package/dist/features/events/constants/event-type-meta.d.ts +54 -0
  133. package/dist/features/events/constants/event-type-meta.js +109 -0
  134. package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
  135. package/dist/features/events/repository/event-likes.repository.js +151 -0
  136. package/dist/features/events/schemas/firestore.d.ts +111 -0
  137. package/dist/features/events/schemas/likes.d.ts +81 -0
  138. package/dist/features/events/schemas/likes.js +59 -0
  139. package/dist/features/events/types/index.d.ts +7 -0
  140. package/dist/features/events/utils/carousel-slide.d.ts +42 -0
  141. package/dist/features/events/utils/carousel-slide.js +174 -0
  142. package/dist/features/events/utils/event-charts.d.ts +65 -0
  143. package/dist/features/events/utils/event-charts.js +172 -0
  144. package/dist/features/faq/constants/help-guides.js +9 -0
  145. package/dist/features/faq/schemas/firestore.js +1 -0
  146. package/dist/features/faq/types/index.d.ts +19 -0
  147. package/dist/features/homepage/components/BrandsSection.js +25 -1
  148. package/dist/features/homepage/components/HeroCarousel.js +47 -10
  149. package/dist/features/homepage/lib/section-renderer.js +1 -1
  150. package/dist/features/homepage/types/index.d.ts +7 -0
  151. package/dist/features/orders/actions/refund-actions.js +1 -0
  152. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  153. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  154. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  155. package/dist/features/orders/repository/orders.repository.js +2 -0
  156. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  157. package/dist/features/orders/types/index.d.ts +5 -2
  158. package/dist/features/search/action-index/settings-entries.js +9 -0
  159. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  160. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  161. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  162. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  163. package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
  164. package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
  165. package/dist/features/stores/components/SellerStandingPanel.js +25 -0
  166. package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
  167. package/dist/features/stores/components/SellerTierBadge.js +19 -0
  168. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +33 -2
  169. package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
  170. package/dist/features/stores/components/StoreCreateForm.js +107 -0
  171. package/dist/features/stores/components/StoreGuideHubView.js +18 -5
  172. package/dist/features/stores/components/StoreHeader.js +2 -1
  173. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  174. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  175. package/dist/features/stores/repository/store.repository.js +52 -1
  176. package/dist/features/stores/schemas/firestore.d.ts +76 -7
  177. package/dist/features/stores/schemas/firestore.js +21 -6
  178. package/dist/features/stores/schemas/index.d.ts +88 -2
  179. package/dist/features/stores/schemas/index.js +14 -0
  180. package/dist/features/stores/schemas/store-create.d.ts +47 -0
  181. package/dist/features/stores/schemas/store-create.js +86 -0
  182. package/dist/features/stores/types/index.d.ts +42 -0
  183. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  184. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  185. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  186. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  187. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  188. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  189. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  190. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  191. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  192. package/dist/features/whatsapp-bot/components/index.js +2 -0
  193. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  194. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  195. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  196. package/dist/features/whatsapp-bot/index.js +3 -0
  197. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  198. package/dist/features/whatsapp-bot/manifest.js +13 -0
  199. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  200. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  201. package/dist/features/whatsapp-bot/server.js +7 -0
  202. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  203. package/dist/features/whatsapp-bot/types/index.js +1 -0
  204. package/dist/index.d.ts +23 -6
  205. package/dist/index.js +38 -12
  206. package/dist/next/routing/route-map.d.ts +6 -1
  207. package/dist/next/routing/route-map.js +14 -0
  208. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  209. package/dist/providers/db-firebase/base.repository.js +1 -1
  210. package/dist/repositories/index.d.ts +1 -1
  211. package/dist/repositories/index.js +1 -1
  212. package/dist/schemas/registry.d.ts +115 -0
  213. package/dist/schemas/registry.js +2 -1
  214. package/dist/security/pii-encrypt.js +1 -1
  215. package/dist/server.d.ts +15 -2
  216. package/dist/server.js +32 -2
  217. package/dist/styles.css +36 -1
  218. package/dist/tailwind-utilities.css +1 -1
  219. package/dist/ui/components/HorizontalScroller.js +15 -2
  220. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  221. package/dist/ui/components/NavigationLoader.js +79 -30
  222. package/dist/ui/components/PageHeroHeading.d.ts +40 -0
  223. package/dist/ui/components/PageHeroHeading.js +15 -0
  224. package/dist/ui/components/Typography.d.ts +3 -3
  225. package/dist/ui/components/Typography.js +59 -31
  226. package/dist/ui/components/Typography.style.css +35 -0
  227. package/dist/ui/index.d.ts +2 -0
  228. package/dist/ui/index.js +1 -0
  229. package/package.json +1 -1
  230. package/scripts/seed-cli.mjs +4 -2
  231. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  232. package/dist/features/orders/utils/order-announcement-message.js +0 -16
@@ -9,8 +9,11 @@ import { CATEGORIES_COLLECTION, CATEGORY_FIELDS } from "../../../../features/cat
9
9
  import { isListingCategory } from "../../../../features/categories/constants/listing-categories.js";
10
10
  import { STORE_COLLECTION, STORE_FIELDS } from "../../../../features/stores/schemas/firestore.js";
11
11
  import { SCAMMER_COLLECTION } from "../../../../features/scams/schemas/firestore.js";
12
+ import { BEYS_COLLECTION } from "../../../../features/beys/schemas/firestore.js";
13
+ import { PART_GLOSSARY_COLLECTION, ALL_PART_SLOTS } from "../../../../features/parts/schemas/firestore.js";
12
14
  import { serverLogger } from "../../../../monitoring/server-logger.js";
13
15
  import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
16
+ import { PRODUCT_DETAIL_LISTING_TYPES } from "./product-detail-types.js";
14
17
  // Product field strings — matches consumer field-names.ts
15
18
  const PRODUCT_STATUS = "status";
16
19
  const PRODUCT_STATUS_PUBLISHED = "published";
@@ -137,10 +140,25 @@ function staticPages(baseUrl) {
137
140
  page(String(ROUTES.PUBLIC.PRE_ORDERS), "daily", 0.7),
138
141
  page(String(ROUTES.PUBLIC.BUNDLES), "weekly", 0.6),
139
142
  page(String(ROUTES.PUBLIC.PRIZE_DRAWS), "daily", 0.7),
140
- page(String(ROUTES.PUBLIC.CLASSIFIED), "daily", 0.6),
141
- page(String(ROUTES.PUBLIC.DIGITAL_CODES), "daily", 0.6),
142
- page(String(ROUTES.PUBLIC.LIVE), "hourly", 0.7),
143
+ /*
144
+ * 🛑 `/classified`, `/digital-codes` and `/live` are NOT listed, and must
145
+ * not be re-added. All three index pages were reduced to redirect stubs
146
+ * that export `robots: { index: false, follow: false }` and immediately
147
+ * `redirect()` to `/products?listingType=…`. Submitting a URL that
148
+ * declares itself noindex is a direct contradiction — we ask Google to
149
+ * crawl it and then tell it not to keep it. Their DETAIL pages are still
150
+ * submitted by `fetchClassifiedUrls`/`fetchDigitalCodeUrls`/`fetchLiveUrls`
151
+ * below; it is only the dead index that goes.
152
+ */
143
153
  page(String(ROUTES.PUBLIC.ART), "weekly", 0.6),
154
+ /*
155
+ * Hubs that exist, are linked, and were never submitted.
156
+ * `/franchise` is the index for a whole `categoryType`; `/parts` is the
157
+ * Beyblade parts gallery — both are real pages with real content and both
158
+ * were absent from the sitemap entirely (measured 2026-10-24).
159
+ */
160
+ page(String(ROUTES.PUBLIC.FRANCHISE), "weekly", 0.6),
161
+ page(String(ROUTES.PUBLIC.PARTS), "weekly", 0.6),
144
162
  /*
145
163
  * `daily`, matching the other one-of-a-kind types rather than `/art`'s
146
164
  * weekly: most spares are quantity 1, so the page turns over as fast as
@@ -161,16 +179,13 @@ function staticPages(baseUrl) {
161
179
  page(String(ROUTES.PUBLIC.SCAM_FAQS), "weekly", 0.7),
162
180
  ];
163
181
  }
164
- // "art" and "stickers" listing types render at the same PRODUCT_DETAIL route
165
- // as "standard" (see route-map.ts's ART comment) — no dedicated detail page.
166
- const PRODUCT_DETAIL_LISTING_TYPES = ["standard", "art", "stickers"];
167
182
  async function fetchProductUrls(baseUrl) {
168
183
  try {
169
184
  const db = getAdminDb();
170
185
  const snap = await db
171
186
  .collection(PRODUCT_COLLECTION)
172
187
  .where(PRODUCT_STATUS, "==", PRODUCT_STATUS_PUBLISHED)
173
- .where("listingType", "in", PRODUCT_DETAIL_LISTING_TYPES)
188
+ .where("listingType", "in", [...PRODUCT_DETAIL_LISTING_TYPES])
174
189
  .select(PRODUCT_SLUG, PRODUCT_UPDATED_AT, TEST_DATA_FIELD)
175
190
  .limit(5000)
176
191
  .get();
@@ -322,36 +337,38 @@ async function fetchCategoryTypeUrls(baseUrl, categoryType, buildUrl, label) {
322
337
  return sitemapSectionFailed(label, err);
323
338
  }
324
339
  }
325
- /**
326
- * Plain listing categories — the /categories/{slug} pages.
327
- *
328
- * These CANNOT be selected by an equality on `categoryType`, because a plain
329
- * listing category omits the field entirely rather than storing "category".
330
- * `.where(CATEGORY_TYPE, "==", <anything>)` therefore excludes every one of
331
- * them, and a Firestore `!=` would be worse still — it silently drops documents
332
- * that lack the field, which is exactly the set we want.
333
- *
334
- * So: select on `isActive` alone (the one predicate that IS stored on every
335
- * row) and reject the three discriminated kinds in memory. `categoryType` must
336
- * stay in the `.select()` list or it comes back undefined and nothing is
337
- * rejected.
338
- */
340
+ /** A rollup of zero, or no rollup at all, means nothing to show. */
341
+ function hasStock(metrics) {
342
+ return (metrics?.totalProductCount ?? 0) + (metrics?.totalAuctionCount ?? 0) > 0;
343
+ }
339
344
  async function fetchCategoryUrls(baseUrl) {
340
345
  try {
341
346
  const db = getAdminDb();
342
347
  const docs = await readAllDocs(db
343
348
  .collection(CATEGORIES_COLLECTION)
344
349
  .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true)
345
- .select(CATEGORY_FIELDS.SLUG, CATEGORY_FIELDS.UPDATED_AT, CATEGORY_FIELDS.CATEGORY_TYPE, TEST_DATA_FIELD), "category");
346
- return docs
350
+ .select(CATEGORY_FIELDS.SLUG, CATEGORY_FIELDS.UPDATED_AT, CATEGORY_FIELDS.CATEGORY_TYPE, CATEGORY_FIELDS.TIER, CATEGORY_FIELDS.METRICS, TEST_DATA_FIELD), "category");
351
+ let uncounted = 0;
352
+ const kept = docs
347
353
  .filter((doc) => {
348
354
  const data = doc.data();
349
355
  if (isTestDoc(data))
350
356
  return false;
351
- return isListingCategory({
357
+ if (!isListingCategory({
352
358
  categoryType: data[CATEGORY_FIELDS.CATEGORY_TYPE],
353
- });
354
- })
359
+ })) {
360
+ return false;
361
+ }
362
+ const metrics = data[CATEGORY_FIELDS.METRICS];
363
+ if (metrics?.totalProductCount === undefined && metrics?.totalAuctionCount === undefined) {
364
+ uncounted++;
365
+ }
366
+ return data[CATEGORY_FIELDS.TIER] === 0 || hasStock(metrics);
367
+ });
368
+ if (uncounted > 0) {
369
+ serverLogger.warn(`sitemap: ${uncounted} active categories have no rollup counter and were judged empty`, { section: "category", uncounted });
370
+ }
371
+ return kept
355
372
  .map((doc) => {
356
373
  const data = doc.data();
357
374
  const slug = data[CATEGORY_FIELDS.SLUG] ?? doc.id;
@@ -370,6 +387,19 @@ async function fetchCategoryUrls(baseUrl) {
370
387
  }
371
388
  const fetchBrandUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "brand", (slug) => ROUTES.PUBLIC.BRAND_DETAIL(slug), "brand");
372
389
  const fetchBundleUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "bundle", (slug) => ROUTES.PUBLIC.BUNDLE_DETAIL(slug), "bundle");
390
+ /**
391
+ * `/franchise/{slug}` — what a product is FROM, where a brand is who made it.
392
+ *
393
+ * `"franchise"` has been a real `CategoryType` with a real detail route since
394
+ * 2026-10-14, and this call simply did not exist: **zero** franchise URLs were
395
+ * ever submitted (measured 2026-10-24). `fetchCategoryTypeUrls` already filters
396
+ * on `isActive`, which is the behaviour wanted here rather than an accident —
397
+ * 7 of the 8 franchises are deliberately inactive because an unstocked facet
398
+ * "renders as a card in the grid that opens an empty page", so only the stocked
399
+ * one is advertised while the rest still resolve for the `/franchise/*` links
400
+ * `CharacterHotspot` ships.
401
+ */
402
+ const fetchFranchiseUrls = (baseUrl) => fetchCategoryTypeUrls(baseUrl, "franchise", (slug) => ROUTES.PUBLIC.FRANCHISE_DETAIL(slug), "franchise");
373
403
  /**
374
404
  * `groupedListings` documents. `listSitemapGroupedListings` has existed since
375
405
  * the feature was built, but there was no public page for it to point at until
@@ -478,11 +508,71 @@ async function fetchScammerUrls(baseUrl) {
478
508
  return sitemapSectionFailed("scammer", err);
479
509
  }
480
510
  }
511
+ /**
512
+ * `/beys/{slug}` — 1,988 model reference pages, and `/parts/{slot}/{code}` —
513
+ * 2,488 part pages. Both carry real measured data (type, system, weight, spin,
514
+ * release code, WBO legality, which models use a part) and both had **zero**
515
+ * sitemap URLs, while 4,658 empty category pages were submitted. The index was
516
+ * precisely inverted.
517
+ *
518
+ * They are submitted together with their prerendering (`static-params.ts`), not
519
+ * before it: a page whose `<title>` still streams into `<body>` is not ready to
520
+ * be advertised.
521
+ */
522
+ async function fetchBeyUrls(baseUrl) {
523
+ try {
524
+ const docs = await readAllDocs(getAdminDb().collection(BEYS_COLLECTION).select(PRODUCT_UPDATED_AT, TEST_DATA_FIELD), "bey");
525
+ return docs
526
+ .filter((doc) => !isTestDoc(doc.data()))
527
+ .map((doc) => ({
528
+ url: `${baseUrl}${ROUTES.PUBLIC.BEY_DETAIL(doc.id)}`,
529
+ lastModified: doc.data()[PRODUCT_UPDATED_AT]?.toDate?.() ??
530
+ new Date(),
531
+ changeFrequency: "monthly",
532
+ priority: 0.4,
533
+ }));
534
+ }
535
+ catch (err) {
536
+ void normalizeError(err);
537
+ return sitemapSectionFailed("bey", err);
538
+ }
539
+ }
540
+ async function fetchPartUrls(baseUrl) {
541
+ try {
542
+ const docs = await readAllDocs(getAdminDb().collection(PART_GLOSSARY_COLLECTION).select(PRODUCT_UPDATED_AT, TEST_DATA_FIELD), "part");
543
+ const slots = new Set(ALL_PART_SLOTS);
544
+ return docs
545
+ .filter((doc) => !isTestDoc(doc.data()))
546
+ .map((doc) => {
547
+ // Document id is `{slot}--{tail}`, and the route segment IS the slot.
548
+ const sep = doc.id.indexOf("--");
549
+ if (sep === -1)
550
+ return null;
551
+ const classification = doc.id.slice(0, sep);
552
+ const code = doc.id.slice(sep + 2);
553
+ if (!code || !slots.has(classification))
554
+ return null;
555
+ return {
556
+ url: `${baseUrl}${ROUTES.PUBLIC.PART_DETAIL(classification, code)}`,
557
+ lastModified: doc.data()[PRODUCT_UPDATED_AT]?.toDate?.() ??
558
+ new Date(),
559
+ changeFrequency: "monthly",
560
+ priority: 0.4,
561
+ };
562
+ })
563
+ .filter((row) => row !== null);
564
+ }
565
+ catch (err) {
566
+ void normalizeError(err);
567
+ return sitemapSectionFailed("part", err);
568
+ }
569
+ }
481
570
  export async function buildSitemap({ baseUrl }) {
482
- const [productUrls, categoryUrls, brandUrls, bundleUrls, groupedListingUrls, eventUrls, blogUrls, auctionUrls, preOrderUrls, prizeDrawUrls, classifiedUrls, digitalCodeUrls, liveUrls, storeUrls, scammerUrls,] = await Promise.all([
571
+ const [productUrls, categoryUrls, brandUrls, franchiseUrls, bundleUrls, groupedListingUrls, eventUrls, blogUrls, auctionUrls, preOrderUrls, prizeDrawUrls, classifiedUrls, digitalCodeUrls, liveUrls, storeUrls, scammerUrls, beyUrls, partUrls,] = await Promise.all([
483
572
  fetchProductUrls(baseUrl),
484
573
  fetchCategoryUrls(baseUrl),
485
574
  fetchBrandUrls(baseUrl),
575
+ fetchFranchiseUrls(baseUrl),
486
576
  fetchBundleUrls(baseUrl),
487
577
  fetchGroupedListingUrls(baseUrl),
488
578
  fetchEventUrls(baseUrl),
@@ -495,6 +585,8 @@ export async function buildSitemap({ baseUrl }) {
495
585
  fetchLiveUrls(baseUrl),
496
586
  fetchStoreUrls(baseUrl),
497
587
  fetchScammerUrls(baseUrl),
588
+ fetchBeyUrls(baseUrl),
589
+ fetchPartUrls(baseUrl),
498
590
  ]);
499
591
  const staticUrls = staticPages(baseUrl);
500
592
  // Per-section count digest. A section that drops to zero is otherwise
@@ -509,6 +601,7 @@ export async function buildSitemap({ baseUrl }) {
509
601
  static: staticUrls.length,
510
602
  category: categoryUrls.length,
511
603
  brand: brandUrls.length,
604
+ franchise: franchiseUrls.length,
512
605
  bundle: bundleUrls.length,
513
606
  group: groupedListingUrls.length,
514
607
  blog: blogUrls.length,
@@ -522,6 +615,8 @@ export async function buildSitemap({ baseUrl }) {
522
615
  event: eventUrls.length,
523
616
  store: storeUrls.length,
524
617
  scammer: scammerUrls.length,
618
+ bey: beyUrls.length,
619
+ part: partUrls.length,
525
620
  };
526
621
  const empty = Object.entries(sections)
527
622
  .filter(([, n]) => n === 0)
@@ -537,6 +632,7 @@ export async function buildSitemap({ baseUrl }) {
537
632
  ...staticUrls,
538
633
  ...categoryUrls,
539
634
  ...brandUrls,
635
+ ...franchiseUrls,
540
636
  ...bundleUrls,
541
637
  ...groupedListingUrls,
542
638
  ...blogUrls,
@@ -550,5 +646,7 @@ export async function buildSitemap({ baseUrl }) {
550
646
  ...eventUrls,
551
647
  ...storeUrls,
552
648
  ...scammerUrls,
649
+ ...beyUrls,
650
+ ...partUrls,
553
651
  ];
554
652
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Slug enumerators for `generateStaticParams`.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Measured against production 2026-10-24: on EVERY dynamic route
7
+ * (`/products/[slug]`, `/brands/[slug]`, `/categories/[slug]`, `/blog/[slug]`,
8
+ * `/beys/[slug]`, `/parts/[c]/[code]`) the `<head>` ended at byte 8,180 with
9
+ * **no `<title>`, no `og:title` and no `description`** — the real `<title>` sat
10
+ * at byte 563,190, inside `<body>`. Google Search Console reported **12 indexed
11
+ * pages against 249 not indexed** on a 4,773-URL sitemap.
12
+ *
13
+ * The cause is that Next STREAMS metadata whenever a route is dynamic, and
14
+ * `.next/prerender-manifest.json` confirms no content detail route is in either
15
+ * the static or the ISR list — because only three files in the whole app export
16
+ * `generateStaticParams` (`[locale]/layout.tsx`, `parts/[classification]`,
17
+ * `collectibles/[city]`). Giving a route this function moves its metadata into
18
+ * `<head>` at build time and removes the streaming question entirely.
19
+ *
20
+ * `next.config.js`'s `htmlLimitedBots` is the belt for crawlers that cannot
21
+ * render; this is the braces.
22
+ *
23
+ * ## Why in appkit rather than inline in each `page.tsx`
24
+ *
25
+ * Four routes need the same bounded slug query. CLAUDE.md § SSR Architecture
26
+ * lists "a `page.tsx` with non-trivial Firestore querying" as a red flag and
27
+ * caps consumer shims at ~30 lines, and four hand-rolled copies would be past
28
+ * the Duplication Framework's Rule of Three before the first one shipped — the
29
+ * shape Root Cause #59 and #75 both describe.
30
+ *
31
+ * ## Three properties that are load-bearing
32
+ *
33
+ * 1. 🛑 **Every function FAILS OPEN, returning `[]`.** `generateStaticParams`
34
+ * runs at BUILD time, where a Firestore credential problem is a plausible
35
+ * environment failure rather than a code fault. Returning `[]` means "no
36
+ * page is prerendered", which is exactly today's behaviour — so a build
37
+ * degrades to the status quo instead of failing. Throwing here would turn a
38
+ * transient admin-SDK hiccup into a failed deploy.
39
+ * 2. 🛑 **`dynamicParams` stays at its default of `true`.** A slug absent from
40
+ * the returned list still renders on demand, so a product created after the
41
+ * last build is reachable immediately and is never a 404. Nothing here needs
42
+ * to be exhaustive to be correct — it is a prerender hint, not an allow-list.
43
+ * 3. **Test fixtures are excluded in memory, never by a Firestore inequality.**
44
+ * `where("isTestData", "!=", true)` excludes every document that LACKS the
45
+ * field — i.e. all real content — so such a query returns only test data.
46
+ * Same trap as `sitemap.ts` and CLAUDE.md § Tester QA Program. The field must
47
+ * therefore appear in each `.select(...)` list or nothing is filtered.
48
+ */
49
+ /**
50
+ * `/products/[slug]`.
51
+ *
52
+ * Scoped to the listing types whose plugin `detailRoute` actually RESOLVES to
53
+ * this route — `standard`, `art`, `stickers` and `spares` all return
54
+ * `ROUTES.PUBLIC.PRODUCT_DETAIL`. Every other type owns a dedicated route and
55
+ * the page `permanentRedirect`s away from here, so prerendering one would bake
56
+ * a redirect into the build output.
57
+ *
58
+ * 🛑 Imported rather than re-declared, because this list and `sitemap.ts`'s
59
+ * drifting apart is how `spares` ended up renderable at `/products/{slug}` and
60
+ * absent from the sitemap (measured 2026-10-24: 14 product URLs submitted
61
+ * against 15 that resolve).
62
+ */
63
+ export declare function productDetailSlugs(): Promise<string[]>;
64
+ /**
65
+ * `/brands/[slug]` — `categoryType: "brand"` rows. Brands are categories
66
+ * discriminated by that field, not a separate collection.
67
+ */
68
+ export declare function brandDetailSlugs(): Promise<string[]>;
69
+ /**
70
+ * `/franchise/[slug]`.
71
+ *
72
+ * 🛑 Only 1 of the 8 seeded franchises is `isActive` (measured 2026-10-24) —
73
+ * the other seven are deliberately inactive because a facet with no stock
74
+ * "renders as a card in the grid that opens an empty page" (`brand-rows.ts`
75
+ * trap 3). Their detail pages still RESOLVE, they are just not prerendered and
76
+ * not advertised, which is the correct treatment for a page that exists only so
77
+ * `CharacterHotspot`'s seventeen `/franchise/*` links land somewhere honest.
78
+ */
79
+ export declare function franchiseDetailSlugs(): Promise<string[]>;
80
+ /** `/blog/[slug]` — published posts only; drafts must not be prerendered. */
81
+ export declare function blogPostSlugs(): Promise<string[]>;
82
+ /**
83
+ * `/beys/[slug]` — 1,988 model reference pages.
84
+ *
85
+ * These carry real data (type, system, weight, spin, release code, parts) and
86
+ * were invisible to search: zero in the sitemap, and no `<title>` in `<head>`
87
+ * because the route is dynamic. The id IS the slug here, so the shared
88
+ * `slugsFrom` projection applies unchanged.
89
+ */
90
+ export declare function beyDetailSlugs(): Promise<string[]>;
91
+ /**
92
+ * `/parts/[classification]/[code]` — 2,488 part reference pages.
93
+ *
94
+ * Two params, so this cannot use `slugsFrom`: the document id is
95
+ * `{slot}--{tail}` and the route splits it across both segments. The URL
96
+ * segment IS the slot (`partSlotFromSegment` is an identity lookup against
97
+ * `ALL_PART_SLOTS`), so the split is the whole derivation.
98
+ *
99
+ * 🛑 Ids carrying no `--`, or an unrecognised slot, are SKIPPED rather than
100
+ * guessed at — a prerendered param that does not resolve is a build-time 404
101
+ * baked into the output. The count of skips is logged so a generator change
102
+ * that breaks the id shape is visible rather than silently halving the set.
103
+ */
104
+ export declare function partDetailParams(): Promise<{
105
+ classification: string;
106
+ code: string;
107
+ }[]>;
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Slug enumerators for `generateStaticParams`.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Measured against production 2026-10-24: on EVERY dynamic route
7
+ * (`/products/[slug]`, `/brands/[slug]`, `/categories/[slug]`, `/blog/[slug]`,
8
+ * `/beys/[slug]`, `/parts/[c]/[code]`) the `<head>` ended at byte 8,180 with
9
+ * **no `<title>`, no `og:title` and no `description`** — the real `<title>` sat
10
+ * at byte 563,190, inside `<body>`. Google Search Console reported **12 indexed
11
+ * pages against 249 not indexed** on a 4,773-URL sitemap.
12
+ *
13
+ * The cause is that Next STREAMS metadata whenever a route is dynamic, and
14
+ * `.next/prerender-manifest.json` confirms no content detail route is in either
15
+ * the static or the ISR list — because only three files in the whole app export
16
+ * `generateStaticParams` (`[locale]/layout.tsx`, `parts/[classification]`,
17
+ * `collectibles/[city]`). Giving a route this function moves its metadata into
18
+ * `<head>` at build time and removes the streaming question entirely.
19
+ *
20
+ * `next.config.js`'s `htmlLimitedBots` is the belt for crawlers that cannot
21
+ * render; this is the braces.
22
+ *
23
+ * ## Why in appkit rather than inline in each `page.tsx`
24
+ *
25
+ * Four routes need the same bounded slug query. CLAUDE.md § SSR Architecture
26
+ * lists "a `page.tsx` with non-trivial Firestore querying" as a red flag and
27
+ * caps consumer shims at ~30 lines, and four hand-rolled copies would be past
28
+ * the Duplication Framework's Rule of Three before the first one shipped — the
29
+ * shape Root Cause #59 and #75 both describe.
30
+ *
31
+ * ## Three properties that are load-bearing
32
+ *
33
+ * 1. 🛑 **Every function FAILS OPEN, returning `[]`.** `generateStaticParams`
34
+ * runs at BUILD time, where a Firestore credential problem is a plausible
35
+ * environment failure rather than a code fault. Returning `[]` means "no
36
+ * page is prerendered", which is exactly today's behaviour — so a build
37
+ * degrades to the status quo instead of failing. Throwing here would turn a
38
+ * transient admin-SDK hiccup into a failed deploy.
39
+ * 2. 🛑 **`dynamicParams` stays at its default of `true`.** A slug absent from
40
+ * the returned list still renders on demand, so a product created after the
41
+ * last build is reachable immediately and is never a 404. Nothing here needs
42
+ * to be exhaustive to be correct — it is a prerender hint, not an allow-list.
43
+ * 3. **Test fixtures are excluded in memory, never by a Firestore inequality.**
44
+ * `where("isTestData", "!=", true)` excludes every document that LACKS the
45
+ * field — i.e. all real content — so such a query returns only test data.
46
+ * Same trap as `sitemap.ts` and CLAUDE.md § Tester QA Program. The field must
47
+ * therefore appear in each `.select(...)` list or nothing is filtered.
48
+ */
49
+ import { normalizeError } from "../../../../errors/normalize.js";
50
+ import { getAdminDb } from "../../../../providers/db-firebase/index.js";
51
+ import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore.js";
52
+ import { BLOG_POSTS_COLLECTION, BLOG_POST_FIELDS } from "../../../../features/blog/schemas/firestore.js";
53
+ import { CATEGORIES_COLLECTION, CATEGORY_FIELDS } from "../../../../features/categories/schemas/firestore.js";
54
+ import { BEYS_COLLECTION } from "../../../../features/beys/schemas/firestore.js";
55
+ import { PART_GLOSSARY_COLLECTION, ALL_PART_SLOTS } from "../../../../features/parts/schemas/firestore.js";
56
+ import { PRODUCT_FIELDS } from "../../../../constants/field-names.js";
57
+ import { serverLogger } from "../../../../monitoring/server-logger.js";
58
+ import { PRODUCT_DETAIL_LISTING_TYPES } from "./product-detail-types.js";
59
+ const PRODUCT_STATUS = "status";
60
+ const PRODUCT_STATUS_PUBLISHED = "published";
61
+ const SLUG = "slug";
62
+ const TEST_DATA_FIELD = PRODUCT_FIELDS.IS_TEST_DATA;
63
+ /**
64
+ * A prerender budget, not a correctness bound — see property 2 above.
65
+ *
66
+ * Sized to clear today's real counts with room: 15 products, 28 brands, 22
67
+ * posts, 1 active franchise, **1,988 beys and 2,488 parts** (measured
68
+ * 2026-10-24). The two reference corpora are what set this number.
69
+ *
70
+ * 🛑 Hitting the budget TRUNCATES silently in SEO terms — the remainder still
71
+ * render on demand and still work, they just go back to streaming their
72
+ * metadata. Both call sites therefore log when they saturate it, because
73
+ * "some pages quietly stopped being prerendered" is exactly the kind of
74
+ * regression that reads as normal.
75
+ *
76
+ * 🛑 Prerendering ~4,500 extra pages has a real build cost, and a cold build
77
+ * on this repo already peaks near 8,100 MB against Hobby's 8,192 MB ceiling.
78
+ * Measure before and after with
79
+ * `node scripts/probe-build-docker.mjs --peak --memory 7g --cpus 2 --runs 3`
80
+ * rather than assuming it fits.
81
+ */
82
+ const MAX_PRERENDER = 5000;
83
+ const isTestDoc = (data) => data.isTestData === true;
84
+ /**
85
+ * Shared shape: project the slug field only, drop test fixtures, de-duplicate,
86
+ * and never throw.
87
+ *
88
+ * `.select()` matters for cost — Rule #6's Firestore budget is per document
89
+ * read, and projecting one field keeps a 2,000-row enumeration cheap.
90
+ */
91
+ async function slugsFrom(label, build) {
92
+ try {
93
+ const snap = await build(getAdminDb()).select(SLUG, TEST_DATA_FIELD).limit(MAX_PRERENDER).get();
94
+ const slugs = snap.docs
95
+ .filter((doc) => !isTestDoc(doc.data()))
96
+ .map((doc) => doc.data()[SLUG] ?? doc.id)
97
+ .filter((slug) => Boolean(slug));
98
+ // A duplicate param crashes the build with "duplicate slug" rather than
99
+ // being ignored, and `slug` is denormalised alongside the doc id on several
100
+ // of these collections, so two rows CAN carry the same value.
101
+ const unique = [...new Set(slugs)];
102
+ if (snap.size >= MAX_PRERENDER) {
103
+ serverLogger.warn(`generateStaticParams: "${label}" hit the ${MAX_PRERENDER}-document prerender budget; the remainder render on demand`, { section: label });
104
+ }
105
+ return unique;
106
+ }
107
+ catch (err) {
108
+ void normalizeError(err);
109
+ // Fail OPEN — see property 1. Logged at error so a section silently
110
+ // dropping to zero is greppable, matching sitemap.ts's convention.
111
+ serverLogger.error(`generateStaticParams: "${label}" failed and prerendered 0 pages`, {
112
+ section: label,
113
+ error: err,
114
+ });
115
+ return [];
116
+ }
117
+ }
118
+ /**
119
+ * `/products/[slug]`.
120
+ *
121
+ * Scoped to the listing types whose plugin `detailRoute` actually RESOLVES to
122
+ * this route — `standard`, `art`, `stickers` and `spares` all return
123
+ * `ROUTES.PUBLIC.PRODUCT_DETAIL`. Every other type owns a dedicated route and
124
+ * the page `permanentRedirect`s away from here, so prerendering one would bake
125
+ * a redirect into the build output.
126
+ *
127
+ * 🛑 Imported rather than re-declared, because this list and `sitemap.ts`'s
128
+ * drifting apart is how `spares` ended up renderable at `/products/{slug}` and
129
+ * absent from the sitemap (measured 2026-10-24: 14 product URLs submitted
130
+ * against 15 that resolve).
131
+ */
132
+ export function productDetailSlugs() {
133
+ return slugsFrom("product", (db) => db
134
+ .collection(PRODUCT_COLLECTION)
135
+ .where(PRODUCT_STATUS, "==", PRODUCT_STATUS_PUBLISHED)
136
+ .where("listingType", "in", [...PRODUCT_DETAIL_LISTING_TYPES]));
137
+ }
138
+ /**
139
+ * `/brands/[slug]` — `categoryType: "brand"` rows. Brands are categories
140
+ * discriminated by that field, not a separate collection.
141
+ */
142
+ export function brandDetailSlugs() {
143
+ return slugsFrom("brand", (db) => db
144
+ .collection(CATEGORIES_COLLECTION)
145
+ .where(CATEGORY_FIELDS.CATEGORY_TYPE, "==", "brand")
146
+ .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true));
147
+ }
148
+ /**
149
+ * `/franchise/[slug]`.
150
+ *
151
+ * 🛑 Only 1 of the 8 seeded franchises is `isActive` (measured 2026-10-24) —
152
+ * the other seven are deliberately inactive because a facet with no stock
153
+ * "renders as a card in the grid that opens an empty page" (`brand-rows.ts`
154
+ * trap 3). Their detail pages still RESOLVE, they are just not prerendered and
155
+ * not advertised, which is the correct treatment for a page that exists only so
156
+ * `CharacterHotspot`'s seventeen `/franchise/*` links land somewhere honest.
157
+ */
158
+ export function franchiseDetailSlugs() {
159
+ return slugsFrom("franchise", (db) => db
160
+ .collection(CATEGORIES_COLLECTION)
161
+ .where(CATEGORY_FIELDS.CATEGORY_TYPE, "==", "franchise")
162
+ .where(CATEGORY_FIELDS.IS_ACTIVE, "==", true));
163
+ }
164
+ /** `/blog/[slug]` — published posts only; drafts must not be prerendered. */
165
+ export function blogPostSlugs() {
166
+ return slugsFrom("blog", (db) => db.collection(BLOG_POSTS_COLLECTION).where(BLOG_POST_FIELDS.STATUS, "==", "published"));
167
+ }
168
+ /**
169
+ * `/beys/[slug]` — 1,988 model reference pages.
170
+ *
171
+ * These carry real data (type, system, weight, spin, release code, parts) and
172
+ * were invisible to search: zero in the sitemap, and no `<title>` in `<head>`
173
+ * because the route is dynamic. The id IS the slug here, so the shared
174
+ * `slugsFrom` projection applies unchanged.
175
+ */
176
+ export function beyDetailSlugs() {
177
+ return slugsFrom("bey", (db) => db.collection(BEYS_COLLECTION));
178
+ }
179
+ /**
180
+ * `/parts/[classification]/[code]` — 2,488 part reference pages.
181
+ *
182
+ * Two params, so this cannot use `slugsFrom`: the document id is
183
+ * `{slot}--{tail}` and the route splits it across both segments. The URL
184
+ * segment IS the slot (`partSlotFromSegment` is an identity lookup against
185
+ * `ALL_PART_SLOTS`), so the split is the whole derivation.
186
+ *
187
+ * 🛑 Ids carrying no `--`, or an unrecognised slot, are SKIPPED rather than
188
+ * guessed at — a prerendered param that does not resolve is a build-time 404
189
+ * baked into the output. The count of skips is logged so a generator change
190
+ * that breaks the id shape is visible rather than silently halving the set.
191
+ */
192
+ export async function partDetailParams() {
193
+ try {
194
+ const snap = await getAdminDb()
195
+ .collection(PART_GLOSSARY_COLLECTION)
196
+ .select()
197
+ .limit(MAX_PRERENDER)
198
+ .get();
199
+ const slots = new Set(ALL_PART_SLOTS);
200
+ let skipped = 0;
201
+ const params = [];
202
+ for (const doc of snap.docs) {
203
+ const sep = doc.id.indexOf("--");
204
+ const classification = sep === -1 ? "" : doc.id.slice(0, sep);
205
+ const code = sep === -1 ? "" : doc.id.slice(sep + 2);
206
+ if (!code || !slots.has(classification)) {
207
+ skipped++;
208
+ continue;
209
+ }
210
+ params.push({ classification, code });
211
+ }
212
+ if (skipped > 0) {
213
+ serverLogger.warn(`generateStaticParams: "part" skipped ${skipped} glossary id(s) that are not {slot}--{tail}`, { section: "part", skipped });
214
+ }
215
+ if (snap.size >= MAX_PRERENDER) {
216
+ serverLogger.warn(`generateStaticParams: "part" hit the ${MAX_PRERENDER}-document prerender budget; the remainder render on demand`, { section: "part" });
217
+ }
218
+ return params;
219
+ }
220
+ catch (err) {
221
+ void normalizeError(err);
222
+ serverLogger.error('generateStaticParams: "part" failed and prerendered 0 pages', {
223
+ section: "part",
224
+ error: err,
225
+ });
226
+ return [];
227
+ }
228
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Server-side link lists for the taxonomy hub pages.
3
+ *
4
+ * Feeds `<TaxonomyLinkIndex>`, which renders them as real `<a href>`s in the
5
+ * server HTML. See that component for why: measured 2026-10-24, `/categories`,
6
+ * `/brands` and `/parts` each emitted ZERO outbound entity links because all
7
+ * three filter client-side, so a crawler landing on a hub had nothing to
8
+ * follow and the franchise detail pages were reachable by no link at all.
9
+ *
10
+ * 🛑 Every function FAILS OPEN with `[]`. These pages are statically rendered;
11
+ * a Firestore hiccup at build time must degrade to "no index section" rather
12
+ * than fail the build or 500 the page. An empty list makes
13
+ * `<TaxonomyLinkIndex>` render nothing at all, which is the correct fallback —
14
+ * the interactive grid above it is unaffected either way.
15
+ *
16
+ * 🛑 A read here does NOT make the hub dynamic. Only `searchParams`,
17
+ * `cookies()` and `headers()` do (Root Cause #94); a Server Component awaiting
18
+ * Firestore stays static and simply bakes the result in at build/ISR time.
19
+ */
20
+ export interface TaxonomyLink {
21
+ href: string;
22
+ label: string;
23
+ }
24
+ /** Every active brand — 28 of them, and none was linked from `/brands`. */
25
+ export declare function brandLinks(): Promise<TaxonomyLink[]>;
26
+ /**
27
+ * Active franchises only.
28
+ *
29
+ * 7 of the 8 are deliberately `isActive: false` because an unstocked facet
30
+ * "renders as a card in the grid that opens an empty page" (`brand-rows.ts`
31
+ * trap 3), so this list is short on purpose. Linking the inactive ones would
32
+ * re-create exactly the empty-page problem the rest of this work removes.
33
+ */
34
+ export declare function franchiseLinks(): Promise<TaxonomyLink[]>;
35
+ /**
36
+ * The tier-0 category roots — the six real entry points a visitor drills down
37
+ * from, not the 4,698-row forest.
38
+ *
39
+ * `CategoryDetailPageView` already renders a root as a tier-grouped index of
40
+ * its descendants, so a root is where a crawl of the taxonomy should start.
41
+ * Listing every leaf here would put the 4,658 empty pages back in front of
42
+ * Google by another route, which is the thing being undone.
43
+ */
44
+ export declare function categoryRootLinks(): Promise<TaxonomyLink[]>;