@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
- package/dist/_internal/server/features/categories/adapters.js +13 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
- package/dist/_internal/server/features/checkout/actions.js +22 -5
- package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
- package/dist/_internal/server/features/events/like-actions.js +67 -0
- package/dist/_internal/server/features/events/likes.d.ts +65 -0
- package/dist/_internal/server/features/events/likes.js +146 -0
- package/dist/_internal/server/features/orders/adapters.js +3 -1
- package/dist/_internal/server/features/seo/index.d.ts +3 -0
- package/dist/_internal/server/features/seo/index.js +2 -0
- package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
- package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
- package/dist/_internal/server/features/seo/robots.js +20 -1
- package/dist/_internal/server/features/seo/sitemap.js +125 -27
- package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
- package/dist/_internal/server/features/seo/static-params.js +228 -0
- package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
- package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
- package/dist/_internal/server/features/site-settings/adapters.js +20 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
- package/dist/_internal/server/features/stores/adapters.js +88 -1
- package/dist/_internal/server/functions/scheduled.d.ts +16 -1
- package/dist/_internal/server/functions/scheduled.js +23 -1
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
- package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
- package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
- package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
- package/dist/_internal/server/notifications/channel-health.js +2 -2
- package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
- package/dist/_internal/server/notifications/send-guard.js +4 -2
- package/dist/_internal/shared/actions/action-registry.js +89 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
- package/dist/_internal/shared/checkout/order-math.js +1 -1
- package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
- package/dist/_internal/shared/features/checkout/config.js +2 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
- package/dist/_internal/shared/features/messaging/config.js +4 -1
- package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
- package/dist/_internal/shared/features/seller-standing/config.js +153 -0
- package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
- package/dist/_internal/shared/features/site-settings/fees.js +2 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
- package/dist/_internal/shared/fees/calculator.d.ts +8 -0
- package/dist/_internal/shared/fees/calculator.js +7 -0
- package/dist/cli/index.js +2 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +39 -0
- package/dist/constants/api-endpoints.js +16 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/constants/notification-types.js +1 -1
- package/dist/core/integration-keys.d.ts +5 -0
- package/dist/core/integration-keys.js +10 -0
- package/dist/features/about/components/FeesView.js +6 -0
- package/dist/features/about/components/HelpPageView.js +9 -1
- package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
- package/dist/features/account/components/BecomeSellerView.js +2 -2
- package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
- package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
- package/dist/features/account/types/index.d.ts +2 -0
- package/dist/features/admin/actions/notification-actions.d.ts +17 -2
- package/dist/features/admin/actions/notification-actions.js +154 -9
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
- package/dist/features/admin/components/AdminOrdersView.js +2 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +31 -2
- package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
- package/dist/features/admin/components/AdminStoresView.js +59 -2
- package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
- package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
- package/dist/features/admin/constants/site-settings-tabs.js +12 -6
- package/dist/features/admin/repository/site-settings.repository.js +1 -1
- package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
- package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
- package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
- package/dist/features/admin/schemas/admin-user-form.js +44 -3
- package/dist/features/admin/schemas/firestore.d.ts +73 -0
- package/dist/features/admin/schemas/firestore.js +8 -3
- package/dist/features/auth/checkout-value-otp.d.ts +1 -1
- package/dist/features/auth/checkout-value-otp.js +1 -1
- package/dist/features/auth/permissions/constants.d.ts +2 -2
- package/dist/features/auth/permissions/constants.js +2 -1
- package/dist/features/blog/components/BlogIndexPageView.js +2 -2
- package/dist/features/blog/components/BlogListView.js +2 -1
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/blog/constants/category-badge.d.ts +33 -0
- package/dist/features/blog/constants/category-badge.js +40 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
- package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
- package/dist/features/cart/repository/cart.repository.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +2 -1
- package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
- package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
- package/dist/features/categories/schemas/firestore.d.ts +25 -0
- package/dist/features/categories/utils/load-category-options.d.ts +33 -0
- package/dist/features/categories/utils/load-category-options.js +50 -0
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
- package/dist/features/events/components/EventCard.js +4 -12
- package/dist/features/events/components/EventInsights.d.ts +27 -0
- package/dist/features/events/components/EventInsights.js +10 -0
- package/dist/features/events/components/EventLikeButton.d.ts +21 -0
- package/dist/features/events/components/EventLikeButton.js +59 -0
- package/dist/features/events/components/EventLikersRail.d.ts +23 -0
- package/dist/features/events/components/EventLikersRail.js +54 -0
- package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
- package/dist/features/events/components/EventTypeBadge.js +7 -0
- package/dist/features/events/components/EventsListPageView.js +2 -2
- package/dist/features/events/components/SpinWheel.d.ts +41 -0
- package/dist/features/events/components/SpinWheel.js +128 -0
- package/dist/features/events/components/SpinWheelView.js +36 -15
- package/dist/features/events/constants/event-type-meta.d.ts +54 -0
- package/dist/features/events/constants/event-type-meta.js +109 -0
- package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
- package/dist/features/events/repository/event-likes.repository.js +151 -0
- package/dist/features/events/schemas/firestore.d.ts +111 -0
- package/dist/features/events/schemas/likes.d.ts +81 -0
- package/dist/features/events/schemas/likes.js +59 -0
- package/dist/features/events/types/index.d.ts +7 -0
- package/dist/features/events/utils/carousel-slide.d.ts +42 -0
- package/dist/features/events/utils/carousel-slide.js +174 -0
- package/dist/features/events/utils/event-charts.d.ts +65 -0
- package/dist/features/events/utils/event-charts.js +172 -0
- package/dist/features/faq/constants/help-guides.js +9 -0
- package/dist/features/faq/schemas/firestore.js +1 -0
- package/dist/features/faq/types/index.d.ts +19 -0
- package/dist/features/homepage/components/BrandsSection.js +25 -1
- package/dist/features/homepage/components/HeroCarousel.js +47 -10
- package/dist/features/homepage/lib/section-renderer.js +1 -1
- package/dist/features/homepage/types/index.d.ts +7 -0
- package/dist/features/orders/actions/refund-actions.js +1 -0
- package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
- package/dist/features/orders/components/OrderAddonBadges.js +4 -3
- package/dist/features/orders/repository/orders.repository.d.ts +8 -0
- package/dist/features/orders/repository/orders.repository.js +2 -0
- package/dist/features/orders/schemas/firestore.d.ts +4 -0
- package/dist/features/orders/types/index.d.ts +5 -2
- package/dist/features/search/action-index/settings-entries.js +9 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
- package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
- package/dist/features/store-extensions/schemas/firestore.js +10 -0
- package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
- package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
- package/dist/features/stores/components/SellerStandingPanel.js +25 -0
- package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
- package/dist/features/stores/components/SellerTierBadge.js +19 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +33 -2
- package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
- package/dist/features/stores/components/StoreCreateForm.js +107 -0
- package/dist/features/stores/components/StoreGuideHubView.js +18 -5
- package/dist/features/stores/components/StoreHeader.js +2 -1
- package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
- package/dist/features/stores/repository/store.repository.d.ts +23 -1
- package/dist/features/stores/repository/store.repository.js +52 -1
- package/dist/features/stores/schemas/firestore.d.ts +76 -7
- package/dist/features/stores/schemas/firestore.js +21 -6
- package/dist/features/stores/schemas/index.d.ts +88 -2
- package/dist/features/stores/schemas/index.js +14 -0
- package/dist/features/stores/schemas/store-create.d.ts +47 -0
- package/dist/features/stores/schemas/store-create.js +86 -0
- package/dist/features/stores/types/index.d.ts +42 -0
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
- package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/components/index.js +2 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
- package/dist/features/whatsapp-bot/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/index.js +3 -0
- package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
- package/dist/features/whatsapp-bot/manifest.js +13 -0
- package/dist/features/whatsapp-bot/messages/en.json +7 -0
- package/dist/features/whatsapp-bot/server.d.ts +7 -0
- package/dist/features/whatsapp-bot/server.js +7 -0
- package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
- package/dist/features/whatsapp-bot/types/index.js +1 -0
- package/dist/index.d.ts +23 -6
- package/dist/index.js +38 -12
- package/dist/next/routing/route-map.d.ts +6 -1
- package/dist/next/routing/route-map.js +14 -0
- package/dist/providers/db-firebase/base.repository.d.ts +1 -1
- package/dist/providers/db-firebase/base.repository.js +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.js +1 -1
- package/dist/schemas/registry.d.ts +115 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server.d.ts +15 -2
- package/dist/server.js +32 -2
- package/dist/styles.css +36 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/HorizontalScroller.js +15 -2
- package/dist/ui/components/NavigationLoader.d.ts +14 -1
- package/dist/ui/components/NavigationLoader.js +79 -30
- package/dist/ui/components/PageHeroHeading.d.ts +40 -0
- package/dist/ui/components/PageHeroHeading.js +15 -0
- package/dist/ui/components/Typography.d.ts +3 -3
- package/dist/ui/components/Typography.js +59 -31
- package/dist/ui/components/Typography.style.css +35 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -2
- package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
- 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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[]>;
|