@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
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
|
|
3
|
-
import { sortBy } from "../../../constants/sort";
|
|
4
|
-
import { CATEGORY_FIELDS } from "../../../constants/field-names";
|
|
5
|
-
import { categoriesRepository } from "../../../repositories";
|
|
6
3
|
import { Container, Heading, Main, Section } from "../../../ui";
|
|
7
4
|
import { AdSlot } from "../../homepage/components/AdSlot";
|
|
8
5
|
import { CategoriesIndexListing } from "./CategoriesIndexListing";
|
|
9
|
-
import { safeRead } from "../../../errors/safe-read";
|
|
10
|
-
import { hidePublicTestData } from "../../../_internal/server/features/tester/visibility";
|
|
11
6
|
function sp(params, key) {
|
|
12
7
|
const v = params[key];
|
|
13
8
|
return Array.isArray(v) ? v[0] ?? "" : v ?? "";
|
|
@@ -39,32 +34,28 @@ function buildCategoryFilters(params) {
|
|
|
39
34
|
}
|
|
40
35
|
return parts.join(",");
|
|
41
36
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* single unfiltered read published sandbox fixtures of all three.
|
|
67
|
-
*/
|
|
68
|
-
const initialData = hidePublicTestData((result?.items ?? []));
|
|
69
|
-
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Categories" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(CategoriesIndexListing, { initialData: initialData }), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
|
|
37
|
+
/**
|
|
38
|
+
* The categories index.
|
|
39
|
+
*
|
|
40
|
+
* 🛑 This view deliberately performs NO Firestore read.
|
|
41
|
+
*
|
|
42
|
+
* It used to fetch `pageSize: 200` and hand the result to
|
|
43
|
+
* `CategoriesIndexListing` as `initialData` — which destructured it as
|
|
44
|
+
* `initialData: _` and threw it away, then fetched `/api/categories?flat=true`
|
|
45
|
+
* regardless. So the read was 200 documents per render, on a route that
|
|
46
|
+
* regenerated every 300s, rendering nothing. Measured 2026-10-09 while tracing
|
|
47
|
+
* 2.1M Firestore reads/week against a 50K/day free tier.
|
|
48
|
+
*
|
|
49
|
+
* Deleting it also removes this page from Root Cause #30's blast radius
|
|
50
|
+
* entirely: you cannot freeze the wrong data under a `staleTime: Infinity` key
|
|
51
|
+
* you never seeded. The long "MUST MATCH CategoriesIndexListing's DEFAULT_SORT"
|
|
52
|
+
* warning that used to live here is now moot for the same reason — there is no
|
|
53
|
+
* second parser to keep in sync.
|
|
54
|
+
*
|
|
55
|
+
* Unlike the other listing pages, making this one static costs no SEO: its grid
|
|
56
|
+
* was already client-rendered, so there was never a server-rendered grid to
|
|
57
|
+
* lose (contrast `/products`, where the grid IS in the SSR HTML and must stay).
|
|
58
|
+
*/
|
|
59
|
+
export async function CategoriesIndexPageView(_props = {}) {
|
|
60
|
+
return (_jsx(Main, { children: _jsx(Section, { padding: "y-2xl", children: _jsxs(Container, { size: "xl", children: [_jsx(Heading, { level: 1, className: "mb-8", color: "primary", size: "3xl", weight: "semibold", children: "Categories" }), _jsx(AdSlot, { id: "listing-sidebar-top", className: "mb-6" }), _jsx(CategoriesIndexListing, {}), _jsx(AdSlot, { id: "listing-sidebar-bottom", className: "mt-8" })] }) }) }));
|
|
70
61
|
}
|
|
@@ -21,6 +21,16 @@ const __O = {
|
|
|
21
21
|
hidden: "overflow-hidden",
|
|
22
22
|
xAuto: "overflow-x-auto",
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Read and display bounds for this page. Every one of these replaced an
|
|
26
|
+
* unbounded read — see the comments at each use site for what each one cost.
|
|
27
|
+
*/
|
|
28
|
+
/** Tiles rendered in "Related Categories". A sample, not an index. */
|
|
29
|
+
const RELATED_CATEGORY_LIMIT = 24;
|
|
30
|
+
/** Documents read to fill those tiles. Headroom for the non-listing filter. */
|
|
31
|
+
const RELATED_CATEGORY_FETCH = 60;
|
|
32
|
+
/** Descendant slugs the Stores tab will match on → at most 4 chunked queries. */
|
|
33
|
+
const STORE_CATEGORY_SLUG_MAX = 120;
|
|
24
34
|
export async function CategoryDetailPageView({ slug }) {
|
|
25
35
|
// The category IS this page's subject — a read failure must surface rather
|
|
26
36
|
// than render an anonymous, empty category page. `undefined` still means
|
|
@@ -75,8 +85,18 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
75
85
|
// Related categories — every other category sharing this category's root
|
|
76
86
|
// (siblings + cousins across the tree, up to the tier-0/1/2 depth the
|
|
77
87
|
// catalog actually uses), not just direct children.
|
|
88
|
+
//
|
|
89
|
+
// 🛑 BOUNDED. This read is a display grid, and it used to be an unbounded
|
|
90
|
+
// `.get()` over the whole root subtree — ~9% of a 50K/day Firestore budget
|
|
91
|
+
// for one page render once the forest reaches ~330 nodes, and it rendered
|
|
92
|
+
// every one of those as a tile. The section is a SAMPLE of nearby
|
|
93
|
+
// categories; the tier-grouped children grid above it is the real index.
|
|
94
|
+
// Fetch headroom is larger than the display cap because the filter below
|
|
95
|
+
// drops this category plus every brand/bundle/sublisting row.
|
|
96
|
+
// `buildTree` still passes no limit — a truncated node list there would
|
|
97
|
+
// silently drop whole branches.
|
|
78
98
|
category?.rootId
|
|
79
|
-
? safeRead(() => categoriesRepository.getCategoriesByRootId(category.rootId), {
|
|
99
|
+
? safeRead(() => categoriesRepository.getCategoriesByRootId(category.rootId, { limit: RELATED_CATEGORY_FETCH }), {
|
|
80
100
|
route: "/categories/[slug]",
|
|
81
101
|
key: "category.rootSiblings",
|
|
82
102
|
fallback: [],
|
|
@@ -98,7 +118,9 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
98
118
|
fallback: null,
|
|
99
119
|
}),
|
|
100
120
|
]);
|
|
101
|
-
const relatedCategories = rootSiblingCategories
|
|
121
|
+
const relatedCategories = rootSiblingCategories
|
|
122
|
+
.filter((c) => c.id !== category?.id && (!c.categoryType || c.categoryType === "category"))
|
|
123
|
+
.slice(0, RELATED_CATEGORY_LIMIT);
|
|
102
124
|
// Stores tab — stores whose storeCategory is this category or ANY descendant.
|
|
103
125
|
//
|
|
104
126
|
// `storeCategory` is a single slug with no ancestor chain (unlike a product's
|
|
@@ -108,14 +130,25 @@ export async function CategoryDetailPageView({ slug }) {
|
|
|
108
130
|
// an unbounded N+1. Both are fixed by pipe-joining the slugs into OR-groups:
|
|
109
131
|
// the enhanced Sieve adapter turns a same-field OR into a Firestore `in`,
|
|
110
132
|
// which caps at 30 values, hence the chunking.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
133
|
+
//
|
|
134
|
+
// 🛑 The descendant ids are REUSED from `childCategories` above, not fetched
|
|
135
|
+
// again. `getDescendantIds` is literally `getDescendants().map(d => d.id)`,
|
|
136
|
+
// so calling it here ran the identical paginated subtree query a second time
|
|
137
|
+
// and threw the documents away — on a ~330-node root that is ~330 wasted
|
|
138
|
+
// reads per page view, and the two answers could disagree if one read failed
|
|
139
|
+
// while the other succeeded.
|
|
140
|
+
//
|
|
141
|
+
// 🛑 And the fan-out is CAPPED, nearest-first. One `listStores` query per 30
|
|
142
|
+
// slugs is 11 queries at ~330 nodes and 51 at ~1,500; sorting by tier takes
|
|
143
|
+
// the closest descendants, which is what someone browsing a root cares
|
|
144
|
+
// about. `storeCount` was already a floor (each chunk caps at pageSize 50),
|
|
145
|
+
// so this tightens a floor rather than making an exact number wrong.
|
|
146
|
+
const descendantIdsByProximity = [...childCategories]
|
|
147
|
+
.sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0))
|
|
148
|
+
.map((c) => c.id);
|
|
149
|
+
const storeCategorySlugs = [slug, ...descendantIdsByProximity]
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.slice(0, STORE_CATEGORY_SLUG_MAX);
|
|
119
152
|
const SLUG_CHUNK = 30;
|
|
120
153
|
const slugChunks = [];
|
|
121
154
|
for (let i = 0; i < storeCategorySlugs.length; i += SLUG_CHUNK) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seller form's category description-template picker — C2c.
|
|
3
|
+
*
|
|
4
|
+
* Fills `SellerProductShell`'s `renderTemplateSelector` slot, which existed
|
|
5
|
+
* with zero consumers until C2b widened it (Root Cause #8's shape: a slot
|
|
6
|
+
* shell rendered with no render props shows a layout skeleton and no content).
|
|
7
|
+
*
|
|
8
|
+
* ## What it is for
|
|
9
|
+
*
|
|
10
|
+
* Our sellers write every description from scratch because no
|
|
11
|
+
* category→description mechanism has ever existed. The competitor crawl shows
|
|
12
|
+
* the model: worldhobbyshop reuses TWO bodies across 1,195 listings — a
|
|
13
|
+
* pre-owned disclosure and a new-in-box spec block — and tcgindia generates
|
|
14
|
+
* five templated FAQs per product. The bodies live on the CATEGORY, so fixing
|
|
15
|
+
* the copy fixes every future listing at once.
|
|
16
|
+
*
|
|
17
|
+
* ## 🛑 The clobber rule is the whole design
|
|
18
|
+
*
|
|
19
|
+
* `isDescriptionDirty` arrives from the shell: non-empty after trim AND not
|
|
20
|
+
* byte-identical to a body applied this session. When it is false the picker
|
|
21
|
+
* may fill silently — the seller is looking at either an empty box or our own
|
|
22
|
+
* text. When it is TRUE it must not overwrite; it offers **Replace
|
|
23
|
+
* description** instead, which is destructive and therefore carries a
|
|
24
|
+
* `confirmation` on its ActionDef (Rule #7), not a `window.confirm`.
|
|
25
|
+
*
|
|
26
|
+
* ## 🛑 "No template" must never blank the field
|
|
27
|
+
*
|
|
28
|
+
* A category with no body at any ancestor renders "No template yet" and leaves
|
|
29
|
+
* whatever is in the box alone. Blanking a field to signal absence is how a
|
|
30
|
+
* seller loses work to a feature that was trying to help.
|
|
31
|
+
*/
|
|
32
|
+
import React from "react";
|
|
33
|
+
import type { CategoryTemplateVariant } from "../schemas/category-content";
|
|
34
|
+
/** What the picker may apply to the draft. Deliberately narrow. */
|
|
35
|
+
export interface CategoryTemplateApply {
|
|
36
|
+
description?: string;
|
|
37
|
+
appliedTemplateId?: string;
|
|
38
|
+
appliedTemplateBody?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface CategoryTemplatePickerProps {
|
|
41
|
+
/** The leaf the seller has chosen. Empty until they pick one. */
|
|
42
|
+
categoryId: string;
|
|
43
|
+
/** The raw `condition` enum from the draft — mapped to a variant below. */
|
|
44
|
+
condition: string;
|
|
45
|
+
/** Resolved `{{slot}}` values, supplied by the shell. */
|
|
46
|
+
context: Record<string, string>;
|
|
47
|
+
/** True when the seller has authored text we must not overwrite. */
|
|
48
|
+
isDescriptionDirty: boolean;
|
|
49
|
+
onApply: (partial: CategoryTemplateApply) => void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Which body a condition wants.
|
|
53
|
+
*
|
|
54
|
+
* 🛑 The variants are the conditions that change what the copy must SAY, not
|
|
55
|
+
* every `CONDITION_OPTIONS` value — a "pre-owned" and a "used" listing want
|
|
56
|
+
* the same disclosure paragraph, which is why `CategoryTemplateVariant` has
|
|
57
|
+
* five members against the form's six conditions.
|
|
58
|
+
*
|
|
59
|
+
* `refurbished` maps to `pre_owned` rather than `default`: it has been owned
|
|
60
|
+
* and worked on, so the disclosure applies. Mapping it to `default` would hand
|
|
61
|
+
* a refurbished item a brand-new-item body, which is the one wrong answer
|
|
62
|
+
* available here.
|
|
63
|
+
*/
|
|
64
|
+
export declare function variantForCondition(condition: string): CategoryTemplateVariant;
|
|
65
|
+
export declare function CategoryTemplatePicker({ categoryId, condition, context, isDescriptionDirty, onApply, }: CategoryTemplatePickerProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The seller form's category description-template picker — C2c.
|
|
5
|
+
*
|
|
6
|
+
* Fills `SellerProductShell`'s `renderTemplateSelector` slot, which existed
|
|
7
|
+
* with zero consumers until C2b widened it (Root Cause #8's shape: a slot
|
|
8
|
+
* shell rendered with no render props shows a layout skeleton and no content).
|
|
9
|
+
*
|
|
10
|
+
* ## What it is for
|
|
11
|
+
*
|
|
12
|
+
* Our sellers write every description from scratch because no
|
|
13
|
+
* category→description mechanism has ever existed. The competitor crawl shows
|
|
14
|
+
* the model: worldhobbyshop reuses TWO bodies across 1,195 listings — a
|
|
15
|
+
* pre-owned disclosure and a new-in-box spec block — and tcgindia generates
|
|
16
|
+
* five templated FAQs per product. The bodies live on the CATEGORY, so fixing
|
|
17
|
+
* the copy fixes every future listing at once.
|
|
18
|
+
*
|
|
19
|
+
* ## 🛑 The clobber rule is the whole design
|
|
20
|
+
*
|
|
21
|
+
* `isDescriptionDirty` arrives from the shell: non-empty after trim AND not
|
|
22
|
+
* byte-identical to a body applied this session. When it is false the picker
|
|
23
|
+
* may fill silently — the seller is looking at either an empty box or our own
|
|
24
|
+
* text. When it is TRUE it must not overwrite; it offers **Replace
|
|
25
|
+
* description** instead, which is destructive and therefore carries a
|
|
26
|
+
* `confirmation` on its ActionDef (Rule #7), not a `window.confirm`.
|
|
27
|
+
*
|
|
28
|
+
* ## 🛑 "No template" must never blank the field
|
|
29
|
+
*
|
|
30
|
+
* A category with no body at any ancestor renders "No template yet" and leaves
|
|
31
|
+
* whatever is in the box alone. Blanking a field to signal absence is how a
|
|
32
|
+
* seller loses work to a feature that was trying to help.
|
|
33
|
+
*/
|
|
34
|
+
import React from "react";
|
|
35
|
+
import { useQuery } from "@tanstack/react-query";
|
|
36
|
+
import { Button, Div, Row, Stack, Text } from "../../../ui";
|
|
37
|
+
import { apiClient } from "../../../http";
|
|
38
|
+
import { CATEGORY_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
39
|
+
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
40
|
+
import { interpolate } from "../../../_internal/shared/templating/placeholders";
|
|
41
|
+
const PANEL_CLASS = "rounded-lg border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-3)] py-[var(--appkit-space-3)]";
|
|
42
|
+
/**
|
|
43
|
+
* Which body a condition wants.
|
|
44
|
+
*
|
|
45
|
+
* 🛑 The variants are the conditions that change what the copy must SAY, not
|
|
46
|
+
* every `CONDITION_OPTIONS` value — a "pre-owned" and a "used" listing want
|
|
47
|
+
* the same disclosure paragraph, which is why `CategoryTemplateVariant` has
|
|
48
|
+
* five members against the form's six conditions.
|
|
49
|
+
*
|
|
50
|
+
* `refurbished` maps to `pre_owned` rather than `default`: it has been owned
|
|
51
|
+
* and worked on, so the disclosure applies. Mapping it to `default` would hand
|
|
52
|
+
* a refurbished item a brand-new-item body, which is the one wrong answer
|
|
53
|
+
* available here.
|
|
54
|
+
*/
|
|
55
|
+
export function variantForCondition(condition) {
|
|
56
|
+
switch (condition) {
|
|
57
|
+
case "new":
|
|
58
|
+
return "new_in_box";
|
|
59
|
+
case "like_new":
|
|
60
|
+
case "good":
|
|
61
|
+
case "fair":
|
|
62
|
+
case "used":
|
|
63
|
+
case "refurbished":
|
|
64
|
+
return "pre_owned";
|
|
65
|
+
default:
|
|
66
|
+
return "default";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function unwrap(res) {
|
|
70
|
+
const body = res?.data ?? res;
|
|
71
|
+
return body && typeof body === "object" && "template" in body ? body : null;
|
|
72
|
+
}
|
|
73
|
+
/** "2 of 9 slots could not be filled: {{year}}, {{type}}" */
|
|
74
|
+
function unresolvedNote(unresolved, total) {
|
|
75
|
+
if (unresolved.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
const named = unresolved.map((n) => `{{${n}}}`).join(", ");
|
|
78
|
+
return `${unresolved.length} of ${total} slots could not be filled: ${named}`;
|
|
79
|
+
}
|
|
80
|
+
/** "adds 2 specification rows · sets condition to Used" */
|
|
81
|
+
function describeDefaults(defaults) {
|
|
82
|
+
const parts = [];
|
|
83
|
+
const specs = defaults.specifications?.length ?? 0;
|
|
84
|
+
if (specs > 0)
|
|
85
|
+
parts.push(`${specs} specification row${specs === 1 ? "" : "s"}`);
|
|
86
|
+
const box = defaults.inTheBox?.length ?? 0;
|
|
87
|
+
if (box > 0)
|
|
88
|
+
parts.push(`a "what's in the box" list of ${box}`);
|
|
89
|
+
const kw = defaults.seoKeywords?.length ?? 0;
|
|
90
|
+
if (kw > 0)
|
|
91
|
+
parts.push(`${kw} SEO keyword${kw === 1 ? "" : "s"}`);
|
|
92
|
+
const faqs = defaults.faqTemplates?.length ?? 0;
|
|
93
|
+
if (faqs > 0)
|
|
94
|
+
parts.push(`${faqs} listing FAQ${faqs === 1 ? "" : "s"}`);
|
|
95
|
+
return parts;
|
|
96
|
+
}
|
|
97
|
+
export function CategoryTemplatePicker({ categoryId, condition, context, isDescriptionDirty, onApply, }) {
|
|
98
|
+
const variant = variantForCondition(condition);
|
|
99
|
+
/*
|
|
100
|
+
* Keyed on (category, variant) — the same pair the endpoint's edge cache is
|
|
101
|
+
* keyed on, so switching back to a condition the seller already looked at
|
|
102
|
+
* costs nothing at all. Fired on change, never per keystroke: `condition` is
|
|
103
|
+
* a select and `categoryId` comes from a picker.
|
|
104
|
+
*/
|
|
105
|
+
const query = useQuery({
|
|
106
|
+
queryKey: ["category-description-template", categoryId, variant],
|
|
107
|
+
enabled: categoryId.length > 0,
|
|
108
|
+
staleTime: 60 * 60 * 1000,
|
|
109
|
+
queryFn: async () => unwrap(await apiClient.get(CATEGORY_ENDPOINTS.DESCRIPTION_TEMPLATE(categoryId, variant))),
|
|
110
|
+
});
|
|
111
|
+
const data = query.data ?? null;
|
|
112
|
+
const template = data?.template ?? null;
|
|
113
|
+
/*
|
|
114
|
+
* Interpolated here rather than on apply, so the unresolved-slot note is
|
|
115
|
+
* accurate BEFORE the seller commits. A note that appears only after the
|
|
116
|
+
* text lands is a report on something they can no longer choose about.
|
|
117
|
+
*/
|
|
118
|
+
const filled = React.useMemo(() => (template ? interpolate(template.body, context) : null), [template, context]);
|
|
119
|
+
/*
|
|
120
|
+
* Auto-fill when the seller owns nothing. Deliberately an effect rather than
|
|
121
|
+
* a render-time write: the body has to reach the draft, which is the
|
|
122
|
+
* shell's state.
|
|
123
|
+
*
|
|
124
|
+
* 🛑 Guarded on `isDescriptionDirty` reading FALSE, which covers all three
|
|
125
|
+
* silent cases the plan specifies — never touched, cleared, or still showing
|
|
126
|
+
* a body we applied. The seller who typed one character gets the button
|
|
127
|
+
* instead.
|
|
128
|
+
*
|
|
129
|
+
* `filled.text` is in the dependency list, so changing the category or the
|
|
130
|
+
* condition refreshes a body we own. That is the "Updated for Burst Dual
|
|
131
|
+
* Layer" case, and it is the reason this is not a one-shot.
|
|
132
|
+
*/
|
|
133
|
+
const applied = data && template && filled;
|
|
134
|
+
React.useEffect(() => {
|
|
135
|
+
if (!applied || isDescriptionDirty)
|
|
136
|
+
return;
|
|
137
|
+
onApply({
|
|
138
|
+
description: filled.text,
|
|
139
|
+
appliedTemplateId: template.id,
|
|
140
|
+
appliedTemplateBody: filled.text,
|
|
141
|
+
});
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
143
|
+
}, [applied, isDescriptionDirty, filled?.text, template?.id]);
|
|
144
|
+
if (categoryId.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
if (query.isLoading) {
|
|
147
|
+
return (_jsx(Div, { className: PANEL_CLASS, children: _jsx(Text, { size: "xs", color: "muted", children: "Looking for a description template for this category\u2026" }) }));
|
|
148
|
+
}
|
|
149
|
+
/*
|
|
150
|
+
* 🛑 A failed fetch says so, and does NOT fall through to "No template yet".
|
|
151
|
+
* Those are different facts, and conflating them is Root Cause #72's rule
|
|
152
|
+
* one level out: absence must never be reported as a measured zero.
|
|
153
|
+
*/
|
|
154
|
+
if (query.isError) {
|
|
155
|
+
return (_jsx(Div, { className: PANEL_CLASS, children: _jsx(Text, { size: "xs", color: "muted", children: "Could not load the category's description template. Your description is untouched \u2014 write your own, or try again in a moment." }) }));
|
|
156
|
+
}
|
|
157
|
+
if (!data || !template || !filled) {
|
|
158
|
+
return (_jsx(Div, { className: PANEL_CLASS, children: _jsx(Text, { size: "xs", color: "muted", children: "No description template for this category yet \u2014 write your own, and an admin can add one so the next listing starts from it." }) }));
|
|
159
|
+
}
|
|
160
|
+
const note = unresolvedNote(filled.unresolved, template.placeholders?.length ?? filled.unresolved.length);
|
|
161
|
+
const extras = describeDefaults(data.defaults);
|
|
162
|
+
const source = data.inheritedFromName
|
|
163
|
+
? `Inherited from ${data.inheritedFromName}`
|
|
164
|
+
: "From this category";
|
|
165
|
+
return (_jsx(Div, { className: PANEL_CLASS, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Row, { gap: "sm", align: "center", justify: "between", wrap: true, children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: template.label }), _jsxs(Text, { size: "xs", color: "muted", children: [source, isDescriptionDirty ? "" : " · applied"] })] }), isDescriptionDirty ? (_jsx(Button, { action: ACTIONS.STORE["replace-description"], size: "sm", onClick: () => onApply({
|
|
166
|
+
description: filled.text,
|
|
167
|
+
appliedTemplateId: template.id,
|
|
168
|
+
appliedTemplateBody: filled.text,
|
|
169
|
+
}) })) : null] }), note ? (_jsx(Text, { size: "xs", color: "muted", children: note })) : null, extras.length > 0 ? (_jsxs(Text, { size: "xs", color: "muted", children: ["This category also supplies ", extras.join(", "), " when you save."] })) : null, isDescriptionDirty ? (_jsx(Text, { size: "xs", color: "muted", children: "Your own description is kept \u2014 nothing was overwritten." })) : null] }) }));
|
|
170
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `categoryType` values that are NOT browsable listing categories.
|
|
3
|
+
*
|
|
4
|
+
* - `brand` — a manufacturer/publisher row, browsed at `/brands/{slug}`
|
|
5
|
+
* - `bundle` — a priced bundle, browsed at `/bundles/{slug}`
|
|
6
|
+
* - `sublisting`— a grading/variant axis (`"PSA 10"`, `"105RF"`), never browsed
|
|
7
|
+
*/
|
|
8
|
+
export declare const NON_LISTING_CATEGORY_TYPES: ReadonlySet<string>;
|
|
9
|
+
/**
|
|
10
|
+
* True when a category row is a real, browsable listing category.
|
|
11
|
+
*
|
|
12
|
+
* Structural parameter on purpose: callers hold `CategoryDocument` (server) or
|
|
13
|
+
* `CategoryItem` (client) and neither should have to be imported here.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isListingCategory(cat: {
|
|
16
|
+
categoryType?: string | null;
|
|
17
|
+
}): boolean;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: four surfaces need to answer "is this row a browsable listing category,
|
|
3
|
+
* or is it a brand / bundle / sublisting sharing the collection?" — the
|
|
4
|
+
* sitemap, the filter-facet hook, the public categories route, and the
|
|
5
|
+
* catalogue editor's picker. Three of them had already hand-written the
|
|
6
|
+
* same Set, which is how the fourth got shipped without one.
|
|
7
|
+
*
|
|
8
|
+
* WHAT: one Set and one predicate. No imports, so it is safe in a client
|
|
9
|
+
* bundle (Root Cause #6) and safe in a server-only metadata path
|
|
10
|
+
* (Root Cause #76).
|
|
11
|
+
*
|
|
12
|
+
* 🛑 THE TEST CANNOT BE A FIRESTORE CLAUSE, and that is the whole reason this
|
|
13
|
+
* exists as a predicate rather than a query filter. A plain listing category
|
|
14
|
+
* **OMITS** `categoryType` entirely — that is the convention every seeded
|
|
15
|
+
* listing category follows — so there is no value to match on and
|
|
16
|
+
* `where("categoryType", "not-in", [...])` would exclude every real category
|
|
17
|
+
* (a Firestore inequality drops documents that lack the field). It has to be
|
|
18
|
+
* applied in memory, after the fetch.
|
|
19
|
+
*
|
|
20
|
+
* Measured cost of not doing this: the `/products` category facet offered 11
|
|
21
|
+
* options of which **9 were not categories** — "Takara-Tomy", "Hasbro",
|
|
22
|
+
* "Burst Battlers Pack", "X-Series Starter Set" — and the catalogue editor's
|
|
23
|
+
* category picker offered "Original Collector's Set" and "Metal Fusion Duo".
|
|
24
|
+
* It fails silently because a short list of plausible names is
|
|
25
|
+
* indistinguishable from a correct one.
|
|
26
|
+
*
|
|
27
|
+
* @tag domain:categories
|
|
28
|
+
* @tag layer:constants
|
|
29
|
+
* @tag access:isomorphic
|
|
30
|
+
* @tag sideEffects:none
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* `categoryType` values that are NOT browsable listing categories.
|
|
34
|
+
*
|
|
35
|
+
* - `brand` — a manufacturer/publisher row, browsed at `/brands/{slug}`
|
|
36
|
+
* - `bundle` — a priced bundle, browsed at `/bundles/{slug}`
|
|
37
|
+
* - `sublisting`— a grading/variant axis (`"PSA 10"`, `"105RF"`), never browsed
|
|
38
|
+
*/
|
|
39
|
+
export const NON_LISTING_CATEGORY_TYPES = new Set([
|
|
40
|
+
"brand",
|
|
41
|
+
"bundle",
|
|
42
|
+
"sublisting",
|
|
43
|
+
]);
|
|
44
|
+
/**
|
|
45
|
+
* True when a category row is a real, browsable listing category.
|
|
46
|
+
*
|
|
47
|
+
* Structural parameter on purpose: callers hold `CategoryDocument` (server) or
|
|
48
|
+
* `CategoryItem` (client) and neither should have to be imported here.
|
|
49
|
+
*/
|
|
50
|
+
export function isListingCategory(cat) {
|
|
51
|
+
const kind = cat.categoryType;
|
|
52
|
+
return typeof kind !== "string" || !NON_LISTING_CATEGORY_TYPES.has(kind);
|
|
53
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { apiClient } from "../../../http";
|
|
4
4
|
import { CATEGORY_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
5
|
+
import { isListingCategory } from "../constants/listing-categories";
|
|
5
6
|
/**
|
|
6
7
|
* Returns all categories flattened in DFS tree order:
|
|
7
8
|
* parent → its children → their children, etc.
|
|
@@ -11,11 +12,36 @@ export function useCategoryTree(opts) {
|
|
|
11
12
|
const { data, isLoading } = useQuery({
|
|
12
13
|
queryKey: ["categories", "tree"],
|
|
13
14
|
queryFn: async () => {
|
|
14
|
-
|
|
15
|
+
/*
|
|
16
|
+
* 🛑 `flat=true` is load-bearing, not a preference.
|
|
17
|
+
*
|
|
18
|
+
* `/api/categories` computes `isFiltered` from flat/parentId/featured/
|
|
19
|
+
* isBrand/showOnHomepage/tier/type. `pageSize` and `sort` are NOT in
|
|
20
|
+
* that set — so a request carrying only those falls through to TREE
|
|
21
|
+
* mode, which returns a NESTED payload with only roots at the top
|
|
22
|
+
* level. `flattenTree` below then builds its adjacency map from roots
|
|
23
|
+
* alone and `dfs(null)` emits tier-0 only.
|
|
24
|
+
*
|
|
25
|
+
* Measured against production data before this fix: the category
|
|
26
|
+
* facet on /products, /auctions, /pre-orders and /prize-draws showed
|
|
27
|
+
* **11 options instead of 47** — and 9 of those 11 were not categories
|
|
28
|
+
* at all. It listed "Takara-Tomy", "Hasbro", "Burst Battlers Pack" and
|
|
29
|
+
* "X-Series Starter Set", because brand and bundle rows live in the
|
|
30
|
+
* same collection with empty `parentIds`, so tree mode surfaced them
|
|
31
|
+
* as roots.
|
|
32
|
+
*
|
|
33
|
+
* It failed silently because a short list of plausible-looking names
|
|
34
|
+
* is indistinguishable from a correct one.
|
|
35
|
+
*
|
|
36
|
+
* `sort` was also dropped: BOTH branches hardcode `sort: "order"`, and
|
|
37
|
+
* this hook re-orders by DFS anyway, so passing it implied a control
|
|
38
|
+
* that never existed.
|
|
39
|
+
*/
|
|
40
|
+
const res = await apiClient.get(`${CATEGORY_ENDPOINTS.LIST}?flat=true&pageSize=500`);
|
|
15
41
|
const items = Array.isArray(res)
|
|
16
42
|
? res
|
|
17
43
|
: res.items ?? res.data ?? [];
|
|
18
|
-
return flattenTree(items);
|
|
44
|
+
return flattenTree(items.filter(isListingCategory));
|
|
19
45
|
},
|
|
20
46
|
enabled: opts?.enabled ?? true,
|
|
21
47
|
staleTime: 5 * 60 * 1000, // 5 min
|
|
@@ -1,25 +1,87 @@
|
|
|
1
1
|
import { BaseRepository, type FirebaseSieveFields, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
2
2
|
import { type CategoryCreateInput, type CategoryDocument, type CategoryMoveInput, type CategoryTreeNode } from "../schemas";
|
|
3
|
-
import type { FirestoreDocument } from "@mohasinac/appkit";
|
|
3
|
+
import type { FirestoreDocument, JsonValue } from "@mohasinac/appkit";
|
|
4
4
|
export declare class CategoriesRepository extends BaseRepository<CategoryDocument> {
|
|
5
5
|
static readonly SIEVE_FIELDS: FirebaseSieveFields;
|
|
6
6
|
constructor();
|
|
7
|
-
|
|
7
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
8
|
+
protected buildSearchTxtFor(data: Record<string, JsonValue>): string[] | null;
|
|
9
|
+
list(model: SieveModel, opts?: {
|
|
10
|
+
search?: string;
|
|
11
|
+
}): Promise<FirebaseSieveResult<CategoryDocument>>;
|
|
8
12
|
createWithHierarchy(input: CategoryCreateInput): Promise<CategoryDocument>;
|
|
9
13
|
getCategoryBySlug(slug: string): Promise<CategoryDocument | null>;
|
|
10
14
|
getRootCategories(): Promise<CategoryDocument[]>;
|
|
11
15
|
getLeafCategories(): Promise<CategoryDocument[]>;
|
|
12
|
-
getCategoriesByTier(tier: number): Promise<CategoryDocument[]>;
|
|
13
|
-
getCategoriesByRootId(rootId: string): Promise<CategoryDocument[]>;
|
|
14
|
-
getChildren(parentId: string): Promise<CategoryDocument[]>;
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* @param limit 0 = unbounded (the historical behaviour). When > 0 the bound is
|
|
18
|
+
* pushed into Firestore instead of slicing after the read.
|
|
19
|
+
*
|
|
20
|
+
* 🛑 The limit is applied with HEADROOM, not exactly, because `!isBrand` is a
|
|
21
|
+
* post-fetch filter: brand rows live in this same collection (they are
|
|
22
|
+
* `CategoryDocument`s discriminated by `isBrand`), so a bare `.limit(n)` could
|
|
23
|
+
* return n brand rows, filter all of them out, and yield an empty tier. The
|
|
24
|
+
* caller still slices to its own limit.
|
|
25
|
+
*/
|
|
26
|
+
getCategoriesByTier(tier: number, limit?: number): Promise<CategoryDocument[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Every category sharing a root, tier-then-order.
|
|
29
|
+
*
|
|
30
|
+
* 🛑 `limit` is for DISPLAY callers only, and it is a real truncation.
|
|
31
|
+
* `buildTree(rootId)` must pass nothing: a tree built from a truncated node
|
|
32
|
+
* list silently drops whole branches, which is the exact failure
|
|
33
|
+
* `getDescendants`'s docstring was rewritten to avoid. A display grid of
|
|
34
|
+
* sibling categories, by contrast, renders ~20 tiles and has no reason to
|
|
35
|
+
* read 1,500 documents to do it.
|
|
36
|
+
*
|
|
37
|
+
* Paginated rather than one unbounded `.get()`: the previous form read the
|
|
38
|
+
* whole root subtree into memory on every category page render, which at the
|
|
39
|
+
* ~330-node forest this plan seeds is ~9% of a 50K/day Firestore budget for
|
|
40
|
+
* ONE page view.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Several categories by id, in ONE round trip, in the order asked for.
|
|
44
|
+
*
|
|
45
|
+
* The ancestor-walk primitive. A five-tier tree means a leaf has up to four
|
|
46
|
+
* ancestors, and resolving an inherited description template or an inherited
|
|
47
|
+
* `productDefaults` means reading them nearest-first until the answer is
|
|
48
|
+
* found — which as four sequential `findById` calls is four round trips, on
|
|
49
|
+
* a route the seller form hits on every category change. Rule #6 budgets
|
|
50
|
+
* about three for a whole request.
|
|
51
|
+
*
|
|
52
|
+
* 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled. Callers
|
|
53
|
+
* walk the result looking for the first document that declares a thing, so
|
|
54
|
+
* "nearest first" has to survive the round trip — Firestore's `getAll`
|
|
55
|
+
* already answers in argument order, and this keeps that guarantee explicit
|
|
56
|
+
* rather than incidental. A caller needing to know WHICH id was missing
|
|
57
|
+
* should compare lengths; none does, because an ancestor that no longer
|
|
58
|
+
* exists is a data problem for the reconcile job, not a reason to fail a
|
|
59
|
+
* form.
|
|
60
|
+
*
|
|
61
|
+
* Bounded at 30 to match `array-contains-any`'s cap, so this cannot become
|
|
62
|
+
* the unbounded read that `getCategoriesByRootId` had to be rewritten to
|
|
63
|
+
* avoid being.
|
|
64
|
+
*/
|
|
65
|
+
findByIds(ids: string[]): Promise<CategoryDocument[]>;
|
|
66
|
+
getCategoriesByRootId(rootId: string, opts?: {
|
|
67
|
+
limit?: number;
|
|
68
|
+
}): Promise<CategoryDocument[]>;
|
|
69
|
+
/**
|
|
70
|
+
* Categories whose `productDefaults.taxCodeId` points at `taxCodeId`.
|
|
71
|
+
*
|
|
72
|
+
* 🛑 Exists so DELETE on a tax code can REFUSE while it is referenced.
|
|
73
|
+
* `taxCodeId` is a plain string with no referential integrity, and a
|
|
74
|
+
* dangling reference does not error — `findResolvable` answers `null`,
|
|
75
|
+
* whose correct handling is "leave the product's gstRate alone", so every
|
|
76
|
+
* subsequent listing under that category silently derives NO TAX. It does
|
|
77
|
+
* not fail; it under-charges, until someone reads an invoice.
|
|
78
|
+
*
|
|
79
|
+
* One equality on a dot-path — served by the automatic index, no composite
|
|
80
|
+
* to declare. Bounded at 200: the answer is used to NAME referrers in a 409
|
|
81
|
+
* message, and the first five are what the admin reads.
|
|
21
82
|
*/
|
|
22
|
-
|
|
83
|
+
findByTaxCodeId(taxCodeId: string): Promise<CategoryDocument[]>;
|
|
84
|
+
getChildren(parentId: string): Promise<CategoryDocument[]>;
|
|
23
85
|
/**
|
|
24
86
|
* Every descendant DOCUMENT at any depth.
|
|
25
87
|
*
|
|
@@ -159,7 +221,6 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
159
221
|
* SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
|
|
160
222
|
* human-entered category name.
|
|
161
223
|
*/
|
|
162
|
-
incrementViewCount(categoryId: string): Promise<void>;
|
|
163
224
|
generateSublistingId(name: string): string;
|
|
164
225
|
}
|
|
165
226
|
export declare const categoriesRepository: CategoriesRepository;
|