@mohasinac/appkit 4.43.0 → 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/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/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 +54 -0
- 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/{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/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/auth/schemas/firestore.d.ts +0 -1
- package/dist/features/blog/schemas/firestore.d.ts +2 -2
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
- 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/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 +62 -11
- package/dist/features/categories/repository/categories.repository.js +178 -35
- 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/orders/schemas/firestore.d.ts +70 -0
- package/dist/features/orders/types/index.d.ts +55 -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/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/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/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 +8 -27
- 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/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/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 +2 -1
- package/dist/server.js +5 -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/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
|
@@ -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;
|
|
@@ -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;
|