@mohasinac/appkit 4.42.18 → 4.44.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/_internal/client/features/lottery/LotteryDetailView.js +22 -1
- package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
- package/dist/_internal/server/features/categories/adapters.js +235 -0
- package/dist/_internal/server/features/checkout/actions.js +161 -22
- package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
- package/dist/_internal/server/features/orders/adapters.js +21 -0
- package/dist/_internal/server/features/products/list-public.d.ts +24 -0
- package/dist/_internal/server/features/products/list-public.js +10 -2
- package/dist/_internal/server/features/refunds/actions.js +1 -1
- package/dist/_internal/server/features/seo/sitemap.js +54 -15
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +6 -0
- package/dist/_internal/server/functions/scheduled.d.ts +4 -2
- package/dist/_internal/server/functions/scheduled.js +64 -6
- package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
- package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
- package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +59 -0
- package/dist/_internal/server/jobs/core/onProductWrite.js +31 -0
- package/dist/_internal/server/jobs/core/onReviewWrite.js +8 -0
- package/dist/_internal/server/jobs/core/onStoreWrite.d.ts +1 -1
- package/dist/_internal/server/jobs/core/onStoreWrite.js +8 -2
- package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
- package/dist/_internal/server/jobs/core/revalidateNotify.d.ts +28 -0
- package/dist/_internal/server/jobs/core/revalidateNotify.js +125 -0
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
- package/dist/_internal/server/jobs/handlers/index.js +3 -1
- package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
- package/dist/_internal/server/jobs/handlers/messages.js +5 -2
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
- package/dist/_internal/shared/actions/action-registry.js +34 -0
- package/dist/_internal/shared/cache/revalidate-targets.d.ts +49 -0
- package/dist/_internal/shared/cache/revalidate-targets.js +157 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
- package/dist/_internal/shared/checkout/order-math.js +194 -7
- package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
- package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
- package/dist/_internal/shared/fees/calculator.d.ts +105 -0
- package/dist/_internal/shared/fees/calculator.js +143 -0
- package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
- package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
- package/dist/_internal/shared/templating/placeholders.js +73 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/api-endpoints.d.ts +42 -0
- package/dist/constants/api-endpoints.js +14 -0
- package/dist/features/admin/actions/admin-actions.d.ts +1 -1
- package/dist/features/admin/actions/admin-actions.js +1 -1
- package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
- package/dist/features/admin/components/AdminProductEditorView.js +8 -1
- package/dist/features/admin/components/AdminProductsView.js +78 -7
- package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
- package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
- package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
- package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
- package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
- package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
- package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
- package/dist/features/auctions/components/AuctionBottomActions.js +10 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auctions/components/AuctionLiveStatusBadge.d.ts +20 -0
- package/dist/features/auctions/components/AuctionLiveStatusBadge.js +21 -0
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +1 -1
- package/dist/features/auctions/components/PlaceBidFormClient.js +20 -2
- package/dist/features/auctions/hooks/useIsAuctionEnded.d.ts +41 -0
- package/dist/features/auctions/hooks/useIsAuctionEnded.js +76 -0
- package/dist/features/auth/schemas/firestore.d.ts +0 -1
- package/dist/features/blog/api/route.js +16 -1
- package/dist/features/blog/schemas/firestore.d.ts +2 -2
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
- package/dist/features/categories/actions/category-actions.d.ts +2 -2
- package/dist/features/categories/actions/category-actions.js +22 -10
- package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
- package/dist/features/categories/api/[id]/description-template/route.js +252 -0
- package/dist/features/categories/api/route.js +156 -11
- package/dist/features/categories/components/CategoriesIndexListing.d.ts +1 -3
- package/dist/features/categories/components/CategoriesIndexListing.js +1 -1
- package/dist/features/categories/components/CategoriesIndexPageView.d.ts +27 -1
- package/dist/features/categories/components/CategoriesIndexPageView.js +24 -33
- package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
- package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
- package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
- package/dist/features/categories/constants/listing-categories.d.ts +17 -0
- package/dist/features/categories/constants/listing-categories.js +53 -0
- package/dist/features/categories/hooks/useCategoryTree.js +28 -2
- package/dist/features/categories/repository/categories.repository.d.ts +73 -12
- package/dist/features/categories/repository/categories.repository.js +203 -42
- package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
- package/dist/features/categories/repository/price-index.repository.js +85 -0
- package/dist/features/categories/schemas/category-content.d.ts +206 -0
- package/dist/features/categories/schemas/category-content.js +28 -0
- package/dist/features/categories/schemas/firestore.d.ts +74 -5
- package/dist/features/categories/schemas/firestore.js +74 -3
- package/dist/features/categories/schemas/index.d.ts +1047 -0
- package/dist/features/categories/schemas/index.js +96 -0
- package/dist/features/categories/server.d.ts +1 -0
- package/dist/features/categories/server.js +7 -0
- package/dist/features/categories/types/index.d.ts +9 -0
- package/dist/features/events/schemas/firestore.d.ts +2 -2
- package/dist/features/faq/schemas/firestore.d.ts +2 -1
- package/dist/features/faq/schemas/firestore.js +20 -8
- package/dist/features/faq/utils/getHomepageFaqsCached.d.ts +20 -0
- package/dist/features/faq/utils/getHomepageFaqsCached.js +22 -0
- package/dist/features/homepage/components/MarketplaceHomepageView.js +13 -3
- package/dist/features/homepage/lib/live-stats.js +18 -8
- package/dist/features/media/MediaImage.js +19 -2
- package/dist/features/orders/schemas/firestore.d.ts +70 -0
- package/dist/features/orders/types/index.d.ts +55 -0
- package/dist/features/products/actions/product-actions.d.ts +0 -9
- package/dist/features/products/actions/product-actions.js +25 -0
- package/dist/features/products/columns/index.d.ts +0 -1
- package/dist/features/products/columns/index.js +5 -1
- package/dist/features/products/components/ArtStickersListView.js +7 -1
- package/dist/features/products/components/PrizeDrawsSection.js +5 -0
- package/dist/features/products/components/ProductFilters.d.ts +0 -9
- package/dist/features/products/components/ProductForm.d.ts +27 -1
- package/dist/features/products/components/ProductForm.js +26 -12
- package/dist/features/products/components/ProductsIndexPageView.js +1 -1
- package/dist/features/products/constants/sieve.d.ts +0 -6
- package/dist/features/products/constants/sieve.js +35 -2
- package/dist/features/products/repository/products.repository.d.ts +115 -1
- package/dist/features/products/repository/products.repository.js +491 -39
- package/dist/features/products/schemas/firestore.d.ts +65 -5
- package/dist/features/products/schemas/firestore.js +9 -0
- package/dist/features/products/schemas/product-features.d.ts +140 -2
- package/dist/features/products/schemas/product-features.js +62 -1
- package/dist/features/promotions/repository/coupons.repository.js +6 -1
- package/dist/features/promotions/schemas/firestore.d.ts +44 -1
- package/dist/features/promotions/schemas/firestore.js +76 -2
- package/dist/features/reviews/repository/reviews.repository.d.ts +22 -0
- package/dist/features/reviews/repository/reviews.repository.js +35 -0
- package/dist/features/seller/actions/seller-actions.js +17 -2
- package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
- package/dist/features/seller/components/PrintCenterView.js +3 -2
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
- package/dist/features/seller/components/SellerOrdersView.js +6 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
- package/dist/features/seller/components/SellerProductShell.js +101 -9
- package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
- package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +108 -3
- package/dist/features/seller/types/index.d.ts +17 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
- package/dist/features/seller/utils/product-draft-mapping.js +17 -0
- package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
- package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
- package/dist/features/stores/schemas/firestore.d.ts +31 -2
- package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
- package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
- package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
- package/dist/features/tax-codes/schemas/firestore.js +82 -0
- package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
- package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
- package/dist/features/tester/actions/index.d.ts +0 -1
- package/dist/features/tester/actions/index.js +0 -1
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
- package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
- package/dist/features/tester/components/index.d.ts +0 -4
- package/dist/features/tester/components/index.js +0 -2
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
- package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
- package/dist/features/tester/schemas/firestore.d.ts +0 -6
- package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
- package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
- package/dist/features/tester/seed-data/authored/index.js +0 -2
- package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +10 -29
- package/dist/index.d.ts +21 -7
- package/dist/index.js +53 -7
- package/dist/next/routing/route-map.d.ts +2 -4
- package/dist/next/routing/route-map.js +1 -2
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +1 -0
- package/dist/schemas/registry.d.ts +506 -0
- package/dist/seed/_helpers/brand-rows.d.ts +35 -0
- package/dist/seed/_helpers/brand-rows.js +576 -0
- package/dist/seed/_helpers/category-content.d.ts +39 -0
- package/dist/seed/_helpers/category-content.js +634 -0
- package/dist/seed/_helpers/category-forest.d.ts +2 -0
- package/dist/seed/_helpers/category-forest.js +585 -243
- package/dist/seed/_helpers/category-models.d.ts +5 -0
- package/dist/seed/_helpers/category-models.js +1364 -0
- package/dist/seed/_helpers/category-tree.js +17 -1
- package/dist/seed/_helpers/character-features.d.ts +10 -0
- package/dist/seed/_helpers/character-features.js +118 -0
- package/dist/seed/_helpers/media.d.ts +1 -1
- package/dist/seed/_helpers/media.js +44 -8
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
- package/dist/seed/categories-seed-data.js +147 -169
- package/dist/seed/index.d.ts +1 -0
- package/dist/seed/index.js +1 -0
- package/dist/seed/manifest.d.ts +1 -1
- package/dist/seed/manifest.js +5 -0
- package/dist/seed/orders-seed-data.js +5 -3
- package/dist/seed/product-features-seed-data.d.ts +1 -8
- package/dist/seed/product-features-seed-data.js +358 -154
- package/dist/seed/products-art-seed-data.js +16 -4
- package/dist/seed/products-auctions-seed-data.js +6 -6
- package/dist/seed/products-classifieds-seed-data.js +17 -7
- package/dist/seed/products-digital-codes-seed-data.js +12 -12
- package/dist/seed/products-preorders-seed-data.js +8 -8
- package/dist/seed/products-prize-draws-seed-data.js +25 -8
- package/dist/seed/products-standard-seed-data.js +10 -60
- package/dist/seed/products-stickers-seed-data.js +6 -6
- package/dist/seed/site-settings-seed-data.js +24 -0
- package/dist/seed/store-extensions-seed-data.js +10 -4
- package/dist/seed/tax-codes-seed-data.d.ts +34 -0
- package/dist/seed/tax-codes-seed-data.js +100 -0
- package/dist/seed/test-data-ttl.d.ts +29 -0
- package/dist/seed/test-data-ttl.js +31 -0
- package/dist/server-entry.d.ts +1 -1
- package/dist/server-entry.js +6 -1
- package/dist/server.d.ts +4 -1
- package/dist/server.js +11 -2
- package/dist/styles.css +40 -0
- package/dist/ui/components/PaginatedSelect.d.ts +14 -0
- package/dist/ui/components/PaginatedSelect.js +0 -0
- package/dist/ui/components/PaginatedSelect.style.css +40 -0
- package/dist/utils/media-url.d.ts +26 -0
- package/dist/utils/media-url.js +144 -0
- package/dist/utils/physical-location.d.ts +23 -0
- package/dist/utils/physical-location.js +52 -0
- package/dist/utils/search-txt-builders.d.ts +20 -0
- package/dist/utils/search-txt-builders.js +36 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -1
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
- package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
- package/dist/features/tester/actions/leaderboard-actions.js +0 -5
- package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
- package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
- package/dist/features/tester/components/TesterHubView.d.ts +0 -5
- package/dist/features/tester/components/TesterHubView.js +0 -110
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
- package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
- package/dist/features/tester/seed-data/tester-ttl.js +0 -6
|
@@ -38,8 +38,12 @@ import { useMediaUpload } from "../../media";
|
|
|
38
38
|
import { MediaUploadList } from "../../media/upload/MediaUploadList";
|
|
39
39
|
import { createCatalogueItemSchema } from "../schemas/validation";
|
|
40
40
|
/** PUBLIC endpoints — this is a buyer surface; the admin ones would 403. */
|
|
41
|
-
async function loadOptionsFrom(endpoint, query, page
|
|
42
|
-
|
|
41
|
+
async function loadOptionsFrom(endpoint, query, page,
|
|
42
|
+
/** Extra query params. Passed as an object rather than baked into
|
|
43
|
+
* `endpoint`, because this builds its own `?` and a pre-existing query
|
|
44
|
+
* string would produce `...?listingOnly=true?page=1`. */
|
|
45
|
+
extra) {
|
|
46
|
+
const params = new URLSearchParams({ page: String(page), pageSize: "25", ...extra });
|
|
43
47
|
if (query)
|
|
44
48
|
params.set("q", query);
|
|
45
49
|
const res = await apiClient.get(`${endpoint}?${params.toString()}`);
|
|
@@ -49,7 +53,14 @@ async function loadOptionsFrom(endpoint, query, page) {
|
|
|
49
53
|
hasMore: data.hasMore ?? false,
|
|
50
54
|
};
|
|
51
55
|
}
|
|
52
|
-
|
|
56
|
+
/*
|
|
57
|
+
* 🛑 `listingOnly=true` is load-bearing. Without it this picker offered brand
|
|
58
|
+
* and bundle rows as categories — measured against production: "Takara-Tomy",
|
|
59
|
+
* "Original Collector's Set", "Metal Fusion Duo". They share the `categories`
|
|
60
|
+
* collection and a plain listing category omits `categoryType`, so the test
|
|
61
|
+
* cannot be a query clause; the route applies it in memory.
|
|
62
|
+
*/
|
|
63
|
+
const loadCategoryOptions = (q, p) => loadOptionsFrom(CATEGORY_ENDPOINTS.LIST, q, p, { listingOnly: "true" });
|
|
53
64
|
const loadBrandOptions = (q, p) => loadOptionsFrom(BRAND_ENDPOINTS.LIST, q, p);
|
|
54
65
|
export function CatalogueItemEditorView({ item, onSaved }) {
|
|
55
66
|
const isCreate = !item;
|
|
@@ -9,8 +9,8 @@ export declare function listCategories(params?: {
|
|
|
9
9
|
page?: number;
|
|
10
10
|
pageSize?: number;
|
|
11
11
|
}): Promise<FirebaseSieveResult<CategoryDocument>>;
|
|
12
|
-
export declare
|
|
13
|
-
export declare
|
|
12
|
+
export declare const listTopLevelCategories: (limit?: number) => Promise<CategoryDocument[]>;
|
|
13
|
+
export declare const listBrandCategories: (limit?: number) => Promise<CategoryDocument[]>;
|
|
14
14
|
export declare function getCategoryById(id: string): Promise<CategoryDocument | null>;
|
|
15
15
|
export declare function getCategoryBySlug(slug: string): Promise<CategoryDocument | null>;
|
|
16
16
|
export declare function getCategoryChildren(parentId: string): Promise<CategoryDocument[]>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cache } from "react";
|
|
1
2
|
import { serverLogger } from "../../../monitoring";
|
|
2
3
|
import { categoriesRepository } from "../repository/categories.repository";
|
|
3
4
|
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
|
|
@@ -39,19 +40,30 @@ export async function listCategories(params) {
|
|
|
39
40
|
};
|
|
40
41
|
return categoriesRepository.list(sieve);
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
/*
|
|
44
|
+
* Both of these fetched the ENTIRE matching set and `.slice()`d it to 12, on
|
|
45
|
+
* every homepage render. The bound is now pushed into Firestore (with headroom
|
|
46
|
+
* for the post-fetch filters), and both are `cache()`-wrapped so a second reader
|
|
47
|
+
* in the same request tree — the nav, a footer, a sibling section — costs
|
|
48
|
+
* nothing. Measured 2026-10-09 against a 50K-reads/day free tier.
|
|
49
|
+
*
|
|
50
|
+
* The in-memory `isActive !== false` filter is gone: both queries already
|
|
51
|
+
* require `isActive == true`, and a Firestore equality excludes documents
|
|
52
|
+
* lacking the field, so it could never match anything the query had not already
|
|
53
|
+
* admitted. `hidePublicTestData` stays — that one is a real post-filter, which
|
|
54
|
+
* is why the repository fetches with headroom rather than exactly `limit`.
|
|
55
|
+
*/
|
|
56
|
+
export const listTopLevelCategories = cache(async (limit = 12) => {
|
|
57
|
+
const all = await categoriesRepository.getCategoriesByTier(0, limit);
|
|
44
58
|
return hidePublicTestData(all)
|
|
45
|
-
.filter((c) => c.isActive !== false)
|
|
46
59
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
47
60
|
.slice(0, limit);
|
|
48
|
-
}
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
61
|
+
});
|
|
62
|
+
export const listBrandCategories = cache(async (limit = 12) => {
|
|
63
|
+
// Over-fetch a small margin so `hidePublicTestData` cannot under-fill the row.
|
|
64
|
+
const brands = await categoriesRepository.getBrandCategories(limit + 8);
|
|
65
|
+
return hidePublicTestData(brands).slice(0, limit);
|
|
66
|
+
});
|
|
55
67
|
export async function getCategoryById(id) {
|
|
56
68
|
return categoriesRepository.findById(id);
|
|
57
69
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feat-categories — GET /api/categories/[id]/description-template
|
|
3
|
+
*
|
|
4
|
+
* The ONE read the seller form makes when a category or condition changes.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // app/api/categories/[id]/description-template/route.ts
|
|
8
|
+
* export { categoryDescriptionTemplateGET as GET } from "@mohasinac/appkit";
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* ## Why an endpoint rather than letting the client walk the tree
|
|
12
|
+
*
|
|
13
|
+
* A description template lives on the category and INHERITS: a named-model
|
|
14
|
+
* leaf like `category-dragoon-g` carries no body of its own, and the right one
|
|
15
|
+
* is on the `category-original-engine-gear` line above it. Resolving that on
|
|
16
|
+
* the client would be one request per ancestor, on every category change, from
|
|
17
|
+
* a form — up to five round trips to answer one question.
|
|
18
|
+
*
|
|
19
|
+
* Here it is **two reads worst case and zero on a cache hit**: the leaf, then
|
|
20
|
+
* one bounded `getAll` over at most four ancestors, behind a one-hour edge
|
|
21
|
+
* cache. The response depends only on `(id, variant)` and category content
|
|
22
|
+
* changes when an admin edits it, so an hour of staleness costs a seller
|
|
23
|
+
* nothing and saves a function invocation per keystroke-free form open.
|
|
24
|
+
*
|
|
25
|
+
* ## 🛑 NO MONEY IN THIS PAYLOAD
|
|
26
|
+
*
|
|
27
|
+
* `Cache-Control: public` means a CDN holds this and anyone can fetch it, so
|
|
28
|
+
* `priceGuidance` is deliberately stripped even though it sits in the same
|
|
29
|
+
* `productDefaults` object. The band is competitor-derived market data, not a
|
|
30
|
+
* listing price — but prices are withheld from signed-out visitors precisely
|
|
31
|
+
* because hiding one number is pointless if a neighbouring surface publishes
|
|
32
|
+
* the range, and a publicly-cached endpoint is exactly such a surface. The
|
|
33
|
+
* seller's price hint is C1's job, from a surface that knows who is asking.
|
|
34
|
+
*
|
|
35
|
+
* `taxCodeId` is stripped for a different reason: nothing on the client reads
|
|
36
|
+
* it. The rate is resolved server-side by `deriveTaxonomy`, which is the whole
|
|
37
|
+
* point of the reference being server-resolved (C2a) — a client that knew the
|
|
38
|
+
* code could not do anything correct with it.
|
|
39
|
+
*/
|
|
40
|
+
import { NextResponse } from "next/server.js";
|
|
41
|
+
export declare function GET(request: Request, context: {
|
|
42
|
+
params: Promise<{
|
|
43
|
+
id: string;
|
|
44
|
+
}>;
|
|
45
|
+
}): Promise<NextResponse>;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* feat-categories — GET /api/categories/[id]/description-template
|
|
3
|
+
*
|
|
4
|
+
* The ONE read the seller form makes when a category or condition changes.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // app/api/categories/[id]/description-template/route.ts
|
|
8
|
+
* export { categoryDescriptionTemplateGET as GET } from "@mohasinac/appkit";
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* ## Why an endpoint rather than letting the client walk the tree
|
|
12
|
+
*
|
|
13
|
+
* A description template lives on the category and INHERITS: a named-model
|
|
14
|
+
* leaf like `category-dragoon-g` carries no body of its own, and the right one
|
|
15
|
+
* is on the `category-original-engine-gear` line above it. Resolving that on
|
|
16
|
+
* the client would be one request per ancestor, on every category change, from
|
|
17
|
+
* a form — up to five round trips to answer one question.
|
|
18
|
+
*
|
|
19
|
+
* Here it is **two reads worst case and zero on a cache hit**: the leaf, then
|
|
20
|
+
* one bounded `getAll` over at most four ancestors, behind a one-hour edge
|
|
21
|
+
* cache. The response depends only on `(id, variant)` and category content
|
|
22
|
+
* changes when an admin edits it, so an hour of staleness costs a seller
|
|
23
|
+
* nothing and saves a function invocation per keystroke-free form open.
|
|
24
|
+
*
|
|
25
|
+
* ## 🛑 NO MONEY IN THIS PAYLOAD
|
|
26
|
+
*
|
|
27
|
+
* `Cache-Control: public` means a CDN holds this and anyone can fetch it, so
|
|
28
|
+
* `priceGuidance` is deliberately stripped even though it sits in the same
|
|
29
|
+
* `productDefaults` object. The band is competitor-derived market data, not a
|
|
30
|
+
* listing price — but prices are withheld from signed-out visitors precisely
|
|
31
|
+
* because hiding one number is pointless if a neighbouring surface publishes
|
|
32
|
+
* the range, and a publicly-cached endpoint is exactly such a surface. The
|
|
33
|
+
* seller's price hint is C1's job, from a surface that knows who is asking.
|
|
34
|
+
*
|
|
35
|
+
* `taxCodeId` is stripped for a different reason: nothing on the client reads
|
|
36
|
+
* it. The rate is resolved server-side by `deriveTaxonomy`, which is the whole
|
|
37
|
+
* point of the reference being server-resolved (C2a) — a client that knew the
|
|
38
|
+
* code could not do anything correct with it.
|
|
39
|
+
*/
|
|
40
|
+
import { NextResponse } from "next/server.js";
|
|
41
|
+
import { normalizeError } from "../../../../../errors/normalize";
|
|
42
|
+
import { serverLogger } from "../../../../../monitoring";
|
|
43
|
+
/*
|
|
44
|
+
* The repository directly, which is the established shape for an appkit API
|
|
45
|
+
* route (blog/[slug], products/[id] and seller/products all do the same). The
|
|
46
|
+
* `getProviders()` contract exposes only the generic `IRepository`, so the
|
|
47
|
+
* bounded `findByIds` ancestor walk this route needs is not reachable through
|
|
48
|
+
* it — and the alternative is four sequential reads on a route the seller form
|
|
49
|
+
* hits on every category change.
|
|
50
|
+
*/
|
|
51
|
+
import { categoriesRepository } from "../../../repository/categories.repository";
|
|
52
|
+
import { usedPlaceholders } from "../../../../../_internal/shared/templating/placeholders";
|
|
53
|
+
/**
|
|
54
|
+
* The variants a caller may ask for.
|
|
55
|
+
*
|
|
56
|
+
* 🛑 Validated against the union rather than passed through. An unrecognised
|
|
57
|
+
* variant must fall back to `default` and not reach a `templates.find()` that
|
|
58
|
+
* would answer `null` — because `null` here means "this category has no
|
|
59
|
+
* template", which the picker renders as "No template yet". A typo'd variant
|
|
60
|
+
* would therefore look like missing content rather than a bad request, and
|
|
61
|
+
* nobody would ever find it.
|
|
62
|
+
*/
|
|
63
|
+
const VARIANTS = [
|
|
64
|
+
"default",
|
|
65
|
+
"new_in_box",
|
|
66
|
+
"new_in_packet",
|
|
67
|
+
"pre_owned",
|
|
68
|
+
"replica",
|
|
69
|
+
];
|
|
70
|
+
/** At most four ancestors on a five-tier tree, so the walk is bounded by shape. */
|
|
71
|
+
const MAX_ANCESTORS = 8;
|
|
72
|
+
function toPublicDefaults(d) {
|
|
73
|
+
if (!d)
|
|
74
|
+
return {};
|
|
75
|
+
/*
|
|
76
|
+
* An explicit allow-list, not a spread-and-delete. Root Cause #70: a
|
|
77
|
+
* deny-list keyed on a TypeScript interface is structurally blind to fields
|
|
78
|
+
* the interface does not declare, and this object gains fields over time.
|
|
79
|
+
*/
|
|
80
|
+
const out = {};
|
|
81
|
+
if (d.specifications?.length)
|
|
82
|
+
out.specifications = d.specifications;
|
|
83
|
+
if (d.inTheBox?.length)
|
|
84
|
+
out.inTheBox = d.inTheBox;
|
|
85
|
+
if (d.defaultCondition)
|
|
86
|
+
out.defaultCondition = d.defaultCondition;
|
|
87
|
+
if (d.defaultAuthenticity)
|
|
88
|
+
out.defaultAuthenticity = d.defaultAuthenticity;
|
|
89
|
+
if (d.seoKeywords?.length)
|
|
90
|
+
out.seoKeywords = d.seoKeywords;
|
|
91
|
+
if (d.faqTemplates?.length)
|
|
92
|
+
out.faqTemplates = d.faqTemplates;
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
function pickTemplate(doc, variant) {
|
|
96
|
+
const all = doc?.descriptionTemplates;
|
|
97
|
+
if (!all?.length)
|
|
98
|
+
return null;
|
|
99
|
+
/*
|
|
100
|
+
* The asked-for variant, else the category's `default`, else nothing. NOT
|
|
101
|
+
* "the first template in the array": a category offering a reproduction
|
|
102
|
+
* body and a new-in-box body has no natural first, and answering with
|
|
103
|
+
* whichever the author happened to list first would silently hand a
|
|
104
|
+
* brand-new listing the reproduction disclosure.
|
|
105
|
+
*/
|
|
106
|
+
return (all.find((t) => t.variant === variant) ??
|
|
107
|
+
all.find((t) => t.variant === "default") ??
|
|
108
|
+
null);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The `productDefaults` fields the picker can inherit, resolved INDIVIDUALLY.
|
|
112
|
+
*
|
|
113
|
+
* 🛑 PER FIELD, not per object, and the first version of this route got it
|
|
114
|
+
* wrong in exactly the way C2a's `applyCategoryDefaults` was written to avoid.
|
|
115
|
+
*
|
|
116
|
+
* A named-model leaf DOES carry a `productDefaults` object — its
|
|
117
|
+
* `defaultFeatures` (blader, battle type, spin) — while the spec rows and the
|
|
118
|
+
* in-the-box list live on the tier-3 line above it. Stopping at "the leaf has
|
|
119
|
+
* an object, so stop looking" therefore returned the blader and silently
|
|
120
|
+
* dropped the specs, so the picker could never say "this will add 2
|
|
121
|
+
* specification rows". Caught by a test asserting the picker still gets what
|
|
122
|
+
* it needs, which the money-stripping assertion sitting beside it would
|
|
123
|
+
* otherwise have made look fine.
|
|
124
|
+
*/
|
|
125
|
+
const INHERITABLE_DEFAULT_KEYS = [
|
|
126
|
+
"specifications",
|
|
127
|
+
"inTheBox",
|
|
128
|
+
"defaultCondition",
|
|
129
|
+
"defaultAuthenticity",
|
|
130
|
+
"seoKeywords",
|
|
131
|
+
"faqTemplates",
|
|
132
|
+
];
|
|
133
|
+
const isEmptyValue = (v) => v == null || (Array.isArray(v) && v.length === 0);
|
|
134
|
+
/** Fill only the keys still missing, from `src`. Returns what is still absent. */
|
|
135
|
+
function mergeDefaults(into, src) {
|
|
136
|
+
if (src) {
|
|
137
|
+
for (const k of INHERITABLE_DEFAULT_KEYS) {
|
|
138
|
+
if (isEmptyValue(into[k]) && !isEmptyValue(src[k])) {
|
|
139
|
+
into[k] = src[k];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return INHERITABLE_DEFAULT_KEYS.filter((k) => isEmptyValue(into[k]));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Walk the ancestors nearest-first for whatever the leaf did not supply.
|
|
147
|
+
*
|
|
148
|
+
* Extracted because `audit-code-quality` flagged the inline version at
|
|
149
|
+
* brace-depth 6, and it was right — "for each ancestor, if we still need a
|
|
150
|
+
* template, if this one has it" is three questions in one block, and the
|
|
151
|
+
* nesting is what hides the ordering guarantee below.
|
|
152
|
+
*/
|
|
153
|
+
async function resolveInherited(parentIds, variant, seed) {
|
|
154
|
+
const out = { ...seed };
|
|
155
|
+
if (parentIds.length === 0)
|
|
156
|
+
return out;
|
|
157
|
+
// `parentIds` is root-first/nearest-last, so reverse it. ONE `getAll`: four
|
|
158
|
+
// sequential reads would be four round trips on a route the seller form hits
|
|
159
|
+
// on every category change.
|
|
160
|
+
const nearestFirst = [...parentIds].reverse();
|
|
161
|
+
const ancestors = await categoriesRepository.findByIds(nearestFirst);
|
|
162
|
+
/*
|
|
163
|
+
* 🛑 Re-indexed into the ASKED-FOR order rather than trusted.
|
|
164
|
+
*
|
|
165
|
+
* `findByIds` dedupes and drops ids that no longer exist, so its result is
|
|
166
|
+
* in request order but may be SHORTER — and "nearest ancestor wins" is the
|
|
167
|
+
* whole semantics here. Walking `nearestFirst` instead of the result means a
|
|
168
|
+
* missing ancestor cannot silently promote a more distant one to first
|
|
169
|
+
* place, which would hand a leaf the ROOT's generic body while reporting
|
|
170
|
+
* that it came from the line.
|
|
171
|
+
*/
|
|
172
|
+
const byId = new Map(ancestors.map((a) => [a.id, a]));
|
|
173
|
+
for (const pid of nearestFirst) {
|
|
174
|
+
const d = byId.get(pid);
|
|
175
|
+
if (!d)
|
|
176
|
+
continue;
|
|
177
|
+
const found = out.template ? null : pickTemplate(d, variant);
|
|
178
|
+
if (found) {
|
|
179
|
+
out.template = found;
|
|
180
|
+
out.inheritedFrom = pid;
|
|
181
|
+
out.inheritedFromName = d.name ?? null;
|
|
182
|
+
}
|
|
183
|
+
// PER FIELD — see INHERITABLE_DEFAULT_KEYS. Stop only once the template is
|
|
184
|
+
// settled AND no inheritable default is still missing.
|
|
185
|
+
const stillMissing = mergeDefaults(out.defaults, d.productDefaults);
|
|
186
|
+
if (out.template && stillMissing.length === 0)
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
export async function GET(request, context) {
|
|
192
|
+
try {
|
|
193
|
+
const { id } = await context.params;
|
|
194
|
+
const raw = new URL(request.url).searchParams.get("variant") ?? "default";
|
|
195
|
+
const variant = VARIANTS.includes(raw)
|
|
196
|
+
? raw
|
|
197
|
+
: "default";
|
|
198
|
+
const leafDoc = await categoriesRepository.findById(id);
|
|
199
|
+
if (!leafDoc) {
|
|
200
|
+
return NextResponse.json({ success: false, error: "Category not found" }, { status: 404 });
|
|
201
|
+
}
|
|
202
|
+
const leaf = leafDoc;
|
|
203
|
+
const parentIds = (leaf.parentIds ?? []).slice(0, MAX_ANCESTORS);
|
|
204
|
+
/*
|
|
205
|
+
* The leaf's own defaults are COPIED into the accumulator rather than
|
|
206
|
+
* referenced, so the per-field merge below cannot mutate the document the
|
|
207
|
+
* repository cached.
|
|
208
|
+
*/
|
|
209
|
+
const { template, inheritedFrom, inheritedFromName, defaults } = await resolveInherited(parentIds, variant, {
|
|
210
|
+
template: pickTemplate(leaf, variant),
|
|
211
|
+
inheritedFrom: null,
|
|
212
|
+
inheritedFromName: null,
|
|
213
|
+
defaults: { ...(leaf.productDefaults ?? {}) },
|
|
214
|
+
});
|
|
215
|
+
/*
|
|
216
|
+
* 🛑 `placeholders` is RE-DERIVED here and not trusted from the stored
|
|
217
|
+
* template.
|
|
218
|
+
*
|
|
219
|
+
* The field's own contract says absent means "re-parse", never "no slots",
|
|
220
|
+
* and the seeded bodies deliberately leave it unset so there is one
|
|
221
|
+
* implementation rather than a hand-written list that drifts the first time
|
|
222
|
+
* a body is edited. The picker needs the real list to report "2 of 9 slots
|
|
223
|
+
* could not be filled", and a stale list is how a literal `{{series}}`
|
|
224
|
+
* reaches a published description.
|
|
225
|
+
*/
|
|
226
|
+
const placeholders = template ? usedPlaceholders(template.body) : [];
|
|
227
|
+
return NextResponse.json({
|
|
228
|
+
success: true,
|
|
229
|
+
data: {
|
|
230
|
+
template: template
|
|
231
|
+
? { ...template, placeholders }
|
|
232
|
+
: null,
|
|
233
|
+
/** Null when the template came from the category itself. */
|
|
234
|
+
inheritedFrom,
|
|
235
|
+
inheritedFromName,
|
|
236
|
+
defaults: toPublicDefaults(defaults),
|
|
237
|
+
},
|
|
238
|
+
}, {
|
|
239
|
+
headers: {
|
|
240
|
+
// One hour at the edge. The response is a pure function of
|
|
241
|
+
// (id, variant) over admin-authored content; an hour of staleness
|
|
242
|
+
// costs a seller nothing and removes an invocation per form open.
|
|
243
|
+
"Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
const e = normalizeError(err);
|
|
249
|
+
serverLogger.error("description-template read failed", { message: e.message });
|
|
250
|
+
return NextResponse.json({ success: false, error: "Could not load the category template" }, { status: 500 });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -17,6 +17,10 @@ import { z } from "zod";
|
|
|
17
17
|
import { mediaUrlSchema } from "../../../validation/schemas";
|
|
18
18
|
import { getProviders } from "../../../contracts";
|
|
19
19
|
import { createRouteHandler } from "../../../next";
|
|
20
|
+
import { toCategoryListItem } from "../../../_internal/server/features/categories/adapters";
|
|
21
|
+
import { isListingCategory } from "../constants/listing-categories";
|
|
22
|
+
import { planSearchTxt } from "../../../utils/search-txt-query";
|
|
23
|
+
import { matchesAllSearchTerms } from "../../../utils/search-txt";
|
|
20
24
|
import { normalizeError } from "../../../errors/normalize";
|
|
21
25
|
const CACHE_CONTROL_PUBLIC = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
|
|
22
26
|
function buildTreeFromFlat(items, rootId) {
|
|
@@ -77,10 +81,65 @@ export async function GET(request) {
|
|
|
77
81
|
const rawFilters = param(url, "filters");
|
|
78
82
|
const tier = numParam(url, "tier");
|
|
79
83
|
const pageSizeParam = numParam(url, "pageSize");
|
|
84
|
+
/*
|
|
85
|
+
* 🛑 `?page=` MEANS "give me a paged flat list", and reading it fixes two
|
|
86
|
+
* live bugs rather than adding a feature.
|
|
87
|
+
*
|
|
88
|
+
* (a) `useAdminListingData` ALWAYS sends `page` and `pageSize`, so
|
|
89
|
+
* `/admin/categories` has been requesting `?flat=true&page=N` while
|
|
90
|
+
* this handler ignored `page` entirely — the pager advanced the URL
|
|
91
|
+
* and returned page 1's rows every time. It also returned a bare
|
|
92
|
+
* array with no `total`, so `extractCategoryTotal` fell back to
|
|
93
|
+
* `mappedRows.length`, making `totalPages` 1 and disabling Next. The
|
|
94
|
+
* list showed 50 of 58 categories with no way to reach the rest.
|
|
95
|
+
*
|
|
96
|
+
* (b) `loadOptionsFrom` (the catalogue editor's category and brand
|
|
97
|
+
* pickers) sends ONLY `page`/`pageSize`/`q` — none of which `isFiltered`
|
|
98
|
+
* tested — so it fell through to TREE mode and then read `.items` off
|
|
99
|
+
* a bare array of nested nodes. `data.items ?? []` meant both pickers
|
|
100
|
+
* were permanently empty. Same shape as the `useCategoryTree` bug:
|
|
101
|
+
* `isFiltered` did not include the params real callers send.
|
|
102
|
+
*/
|
|
103
|
+
const pageParam = numParam(url, "page");
|
|
104
|
+
const isPaged = pageParam !== null && pageParam > 0;
|
|
105
|
+
/*
|
|
106
|
+
* `?listingOnly=true` drops brand / bundle / sublisting rows.
|
|
107
|
+
*
|
|
108
|
+
* Applied in memory because the test cannot be a query clause — a plain
|
|
109
|
+
* listing category OMITS `categoryType`, so there is no value to match
|
|
110
|
+
* and an inequality would exclude every real category. See
|
|
111
|
+
* `constants/listing-categories.ts`.
|
|
112
|
+
*
|
|
113
|
+
* Server-side rather than per-caller: three surfaces had hand-written the
|
|
114
|
+
* same Set and the fourth (`loadCategoryOptions`, the catalogue editor's
|
|
115
|
+
* category picker) shipped without one, offering "Takara-Tomy" and
|
|
116
|
+
* "Original Collector's Set" as categories.
|
|
117
|
+
*/
|
|
118
|
+
const listingOnly = param(url, "listingOnly") === "true";
|
|
119
|
+
/*
|
|
120
|
+
* `q` is read now that `searchTxt` exists.
|
|
121
|
+
*
|
|
122
|
+
* `loadOptionsFrom` has always sent it and this handler never read it, so
|
|
123
|
+
* the catalogue editor's category picker returned the same unfiltered page
|
|
124
|
+
* for every term — the search box accepted typing and changed nothing.
|
|
125
|
+
* Root Cause #62's shape, and the second half of why that picker was
|
|
126
|
+
* useless (the first being that it got a nested tree, §A1c).
|
|
127
|
+
*/
|
|
128
|
+
const q = (param(url, "q") ?? "").trim();
|
|
80
129
|
const { db } = getProviders();
|
|
81
130
|
if (!db) {
|
|
82
131
|
return NextResponse.json({ success: false, error: "Database provider not registered" }, { status: 503 });
|
|
83
132
|
}
|
|
133
|
+
/*
|
|
134
|
+
* 🛑 Typed as the DOCUMENT, not as `CategoryItem`.
|
|
135
|
+
*
|
|
136
|
+
* This was `getRepository<CategoryItem>`, which is a cast-by-generic: the
|
|
137
|
+
* repository returns raw Firestore documents at runtime, so every return
|
|
138
|
+
* site below was handing out `createdBy`, `createdByStoreId` and the
|
|
139
|
+
* unbounded `metrics.productIds[]`/`auctionIds[]` while tsc believed the
|
|
140
|
+
* payload was already projected. Naming the real shape is what makes the
|
|
141
|
+
* missing `toCategoryListItem` calls a compile error instead of a leak.
|
|
142
|
+
*/
|
|
84
143
|
const repo = db.getRepository("categories");
|
|
85
144
|
// -- Single slug lookup ----------------------------------------------------
|
|
86
145
|
if (slug) {
|
|
@@ -92,7 +151,7 @@ export async function GET(request) {
|
|
|
92
151
|
if (!category) {
|
|
93
152
|
return NextResponse.json({ success: false, error: "Category not found" }, { status: 404 });
|
|
94
153
|
}
|
|
95
|
-
return NextResponse.json({ success: true, data: category });
|
|
154
|
+
return NextResponse.json({ success: true, data: toCategoryListItem(category) });
|
|
96
155
|
}
|
|
97
156
|
// -- Build Sieve filter string from query params ----------------------------
|
|
98
157
|
const parts = [];
|
|
@@ -117,6 +176,7 @@ export async function GET(request) {
|
|
|
117
176
|
// -- Filtered flat list modes -----------------------------------------------
|
|
118
177
|
// If any filter is active, or ?flat=true → return flat array
|
|
119
178
|
const isFiltered = flat === "true" ||
|
|
179
|
+
isPaged ||
|
|
120
180
|
parentId !== null ||
|
|
121
181
|
featured === "true" ||
|
|
122
182
|
isBrand === "true" ||
|
|
@@ -125,14 +185,95 @@ export async function GET(request) {
|
|
|
125
185
|
type !== null;
|
|
126
186
|
if (isFiltered) {
|
|
127
187
|
const perPage = pageSizeParam !== null && pageSizeParam > 0 ? pageSizeParam : 200;
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
188
|
+
const page = pageParam ?? 1;
|
|
189
|
+
/*
|
|
190
|
+
* 🛑 `listingOnly` and `q` both force an in-memory window, and `total`
|
|
191
|
+
* is the reason.
|
|
192
|
+
*
|
|
193
|
+
* Each removes rows AFTER the query, so the repository's own `total`
|
|
194
|
+
* would be an over-count and `hasMore` would promise a "Load more" page
|
|
195
|
+
* that renders empty. Rather than ship a number that is wrong in a
|
|
196
|
+
* direction nobody can see, fetch one bounded window, filter it, and
|
|
197
|
+
* paginate the result — so `total` is exact.
|
|
198
|
+
*
|
|
199
|
+
* Affordable because this collection is small and hard-capped at 500 by
|
|
200
|
+
* `base.ts` anyway: 58 rows today, ~330 after the taxonomy seed. Past
|
|
201
|
+
* the cap the window truncates, which is the same ceiling the sitemap
|
|
202
|
+
* builder has and is tracked as its own item — it is not made worse
|
|
203
|
+
* here.
|
|
204
|
+
*
|
|
205
|
+
* The MATCH uses `matchesAllSearchTerms` over the stored `searchTxt`,
|
|
206
|
+
* not `name.includes()`, so this route and the admin route's real
|
|
207
|
+
* push-down agree on what a term means: word prefixes, accent-folded,
|
|
208
|
+
* all terms ANDed, lineage included via `ancestors[].name`. Two
|
|
209
|
+
* different notions of "matches" across two pickers over one collection
|
|
210
|
+
* is the drift this reuse avoids.
|
|
211
|
+
*/
|
|
212
|
+
const plan = planSearchTxt(q);
|
|
213
|
+
// A query that normalised to no usable token must return NOTHING.
|
|
214
|
+
// Emitting no clause returns the whole collection and calls it a result.
|
|
215
|
+
if (plan.empty) {
|
|
216
|
+
const res = isPaged
|
|
217
|
+
? NextResponse.json({
|
|
218
|
+
success: true,
|
|
219
|
+
data: { items: [], total: 0, page, perPage, totalPages: 0, hasMore: false },
|
|
220
|
+
})
|
|
221
|
+
: NextResponse.json({ success: true, data: [] });
|
|
222
|
+
res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
|
|
223
|
+
return res;
|
|
224
|
+
}
|
|
225
|
+
const terms = plan.head ? [plan.head, ...plan.rest] : [];
|
|
226
|
+
const needsWindow = listingOnly || terms.length > 0;
|
|
227
|
+
const windowed = needsWindow
|
|
228
|
+
? await repo.findAll({ filters, sort: "order", order: "asc", perPage: 500 })
|
|
229
|
+
: null;
|
|
230
|
+
let items;
|
|
231
|
+
let total;
|
|
232
|
+
if (windowed) {
|
|
233
|
+
const kept = windowed.data
|
|
234
|
+
.filter((d) => (listingOnly ? isListingCategory(d) : true))
|
|
235
|
+
.filter((d) => (terms.length ? matchesAllSearchTerms(d.searchTxt, terms) : true))
|
|
236
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
237
|
+
total = kept.length;
|
|
238
|
+
const offset = (page - 1) * perPage;
|
|
239
|
+
items = (isPaged ? kept.slice(offset, offset + perPage) : kept).map(toCategoryListItem);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const result = await repo.findAll({
|
|
243
|
+
filters,
|
|
244
|
+
sort: "order",
|
|
245
|
+
order: "asc",
|
|
246
|
+
page: pageParam ?? undefined,
|
|
247
|
+
perPage,
|
|
248
|
+
});
|
|
249
|
+
total = result.total;
|
|
250
|
+
items = result.data
|
|
251
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
252
|
+
.map(toCategoryListItem);
|
|
253
|
+
}
|
|
254
|
+
const totalPages = total === 0 ? 0 : Math.max(1, Math.ceil(total / perPage));
|
|
255
|
+
/*
|
|
256
|
+
* Shape switches on `?page=`, deliberately, and only there.
|
|
257
|
+
*
|
|
258
|
+
* A paged caller gets an envelope carrying `total`/`totalPages`/`hasMore`
|
|
259
|
+
* — without `total` the admin pager computes `totalPages = 1` from the
|
|
260
|
+
* row count and disables Next, which is the "50 of 58 rows" bug. A
|
|
261
|
+
* caller that sends no `page` keeps the bare array it has always had,
|
|
262
|
+
* because `useCategories` and `useTopCategories` are TYPED as
|
|
263
|
+
* `CategoryItem[]` and would throw on `.map` of an object (Root Cause
|
|
264
|
+
* #20 — a public shape change must update its call sites, and these
|
|
265
|
+
* callers never ask for paging, so the correct move is not to change
|
|
266
|
+
* their shape at all).
|
|
267
|
+
*
|
|
268
|
+
* `hasMore` exists because `loadOptionsFrom` reads it for "Load more";
|
|
269
|
+
* it used to read it off a bare array and always got `false`.
|
|
270
|
+
*/
|
|
271
|
+
const res = isPaged
|
|
272
|
+
? NextResponse.json({
|
|
273
|
+
success: true,
|
|
274
|
+
data: { items, total, page, perPage, totalPages, hasMore: page < totalPages },
|
|
275
|
+
})
|
|
276
|
+
: NextResponse.json({ success: true, data: items });
|
|
136
277
|
res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
|
|
137
278
|
return res;
|
|
138
279
|
}
|
|
@@ -143,8 +284,12 @@ export async function GET(request) {
|
|
|
143
284
|
order: "asc",
|
|
144
285
|
perPage: 500,
|
|
145
286
|
});
|
|
287
|
+
// Project BEFORE nesting — `CategoryTreeNode extends CategoryItem`, so the
|
|
288
|
+
// children carry the public shape too rather than being raw documents
|
|
289
|
+
// reachable one level down.
|
|
290
|
+
const allItems = allResult.data.map(toCategoryListItem);
|
|
146
291
|
if (tree === "true" || !flat) {
|
|
147
|
-
const treeNodes = buildTreeFromFlat(
|
|
292
|
+
const treeNodes = buildTreeFromFlat(allItems, rootId);
|
|
148
293
|
const res = NextResponse.json({ success: true, data: treeNodes });
|
|
149
294
|
res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
|
|
150
295
|
return res;
|
|
@@ -153,7 +298,7 @@ export async function GET(request) {
|
|
|
153
298
|
// because isFiltered catches it, but kept for clarity)
|
|
154
299
|
const res = NextResponse.json({
|
|
155
300
|
success: true,
|
|
156
|
-
data:
|
|
301
|
+
data: allItems.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
157
302
|
});
|
|
158
303
|
res.headers.set("Cache-Control", CACHE_CONTROL_PUBLIC);
|
|
159
304
|
return res;
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import type { CategoryItem } from "../types";
|
|
3
2
|
export interface CategoriesIndexListingProps {
|
|
4
|
-
initialData?: CategoryItem[];
|
|
5
3
|
/** When true, filters to brands only (for the standalone /brands page) */
|
|
6
4
|
brandsOnly?: boolean;
|
|
7
5
|
}
|
|
8
|
-
export declare function CategoriesIndexListing({
|
|
6
|
+
export declare function CategoriesIndexListing({ brandsOnly }: CategoriesIndexListingProps): React.JSX.Element;
|
|
@@ -63,7 +63,7 @@ const SORT_OPTIONS = [
|
|
|
63
63
|
{ value: sortBy(CATEGORY_FIELDS.NAME), label: "Name Z–A" },
|
|
64
64
|
{ value: sortBy("metrics.productCount", "DESC"), label: "Most Products" },
|
|
65
65
|
];
|
|
66
|
-
export function CategoriesIndexListing({
|
|
66
|
+
export function CategoriesIndexListing({ brandsOnly = false }) {
|
|
67
67
|
const table = useUrlTable({ defaults: { pageSize: String(PAGE_SIZE), sort: DEFAULT_SORT } });
|
|
68
68
|
const [searchInput, setSearchInput] = useState(table.get(TABLE_KEYS.QUERY) || "");
|
|
69
69
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
type SearchParams = Record<string, string | string[]>;
|
|
3
3
|
export interface CategoriesIndexPageViewProps {
|
|
4
|
+
/**
|
|
5
|
+
* @deprecated Accepted and ignored. Kept so the consumer page shim compiles
|
|
6
|
+
* either way; remove once no caller passes it.
|
|
7
|
+
*/
|
|
4
8
|
searchParams?: SearchParams;
|
|
5
9
|
}
|
|
6
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The categories index.
|
|
12
|
+
*
|
|
13
|
+
* 🛑 This view deliberately performs NO Firestore read.
|
|
14
|
+
*
|
|
15
|
+
* It used to fetch `pageSize: 200` and hand the result to
|
|
16
|
+
* `CategoriesIndexListing` as `initialData` — which destructured it as
|
|
17
|
+
* `initialData: _` and threw it away, then fetched `/api/categories?flat=true`
|
|
18
|
+
* regardless. So the read was 200 documents per render, on a route that
|
|
19
|
+
* regenerated every 300s, rendering nothing. Measured 2026-10-09 while tracing
|
|
20
|
+
* 2.1M Firestore reads/week against a 50K/day free tier.
|
|
21
|
+
*
|
|
22
|
+
* Deleting it also removes this page from Root Cause #30's blast radius
|
|
23
|
+
* entirely: you cannot freeze the wrong data under a `staleTime: Infinity` key
|
|
24
|
+
* you never seeded. The long "MUST MATCH CategoriesIndexListing's DEFAULT_SORT"
|
|
25
|
+
* warning that used to live here is now moot for the same reason — there is no
|
|
26
|
+
* second parser to keep in sync.
|
|
27
|
+
*
|
|
28
|
+
* Unlike the other listing pages, making this one static costs no SEO: its grid
|
|
29
|
+
* was already client-rendered, so there was never a server-rendered grid to
|
|
30
|
+
* lose (contrast `/products`, where the grid IS in the SSR HTML and must stay).
|
|
31
|
+
*/
|
|
32
|
+
export declare function CategoriesIndexPageView(_props?: CategoriesIndexPageViewProps): Promise<React.JSX.Element>;
|
|
7
33
|
export {};
|