@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,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,10 +1,14 @@
|
|
|
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[]>;
|
|
@@ -20,16 +24,64 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
20
24
|
* caller still slices to its own limit.
|
|
21
25
|
*/
|
|
22
26
|
getCategoriesByTier(tier: number, limit?: number): Promise<CategoryDocument[]>;
|
|
23
|
-
getCategoriesByRootId(rootId: string): Promise<CategoryDocument[]>;
|
|
24
|
-
getChildren(parentId: string): Promise<CategoryDocument[]>;
|
|
25
27
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
31
64
|
*/
|
|
32
|
-
|
|
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.
|
|
82
|
+
*/
|
|
83
|
+
findByTaxCodeId(taxCodeId: string): Promise<CategoryDocument[]>;
|
|
84
|
+
getChildren(parentId: string): Promise<CategoryDocument[]>;
|
|
33
85
|
/**
|
|
34
86
|
* Every descendant DOCUMENT at any depth.
|
|
35
87
|
*
|
|
@@ -169,7 +221,6 @@ export declare class CategoriesRepository extends BaseRepository<CategoryDocumen
|
|
|
169
221
|
* SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
|
|
170
222
|
* human-entered category name.
|
|
171
223
|
*/
|
|
172
|
-
incrementViewCount(categoryId: string): Promise<void>;
|
|
173
224
|
generateSublistingId(name: string): string;
|
|
174
225
|
}
|
|
175
226
|
export declare const categoriesRepository: CategoriesRepository;
|
|
@@ -1,15 +1,45 @@
|
|
|
1
1
|
import { normalizeError } from "../../../errors/normalize";
|
|
2
2
|
import { increment, arrayUnion, arrayRemove, } from "../../../contracts/field-ops";
|
|
3
3
|
import { DatabaseError } from "../../../errors";
|
|
4
|
-
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
4
|
+
import { BaseRepository, prepareForFirestore, parseSieveDateValue, applySieveToFirestore, } from "../../../providers/db-firebase";
|
|
5
|
+
import { buildCategorySearchTxt } from "../../../utils/search-txt-builders";
|
|
6
|
+
import { planSearchTxt, refineSearchTxt, emptySearchResult, } from "../../../utils/search-txt-query";
|
|
5
7
|
import { PRODUCT_FIELDS } from "../../../constants/field-names";
|
|
6
8
|
import { CATEGORY_FIELDS, CATEGORIES_COLLECTION, MIN_ITEMS_FOR_FEATURED, buildCategoryTree, calculateCategoryFields, canBeFeatured, createCategoryId, isValidCategoryMove, } from "../schemas";
|
|
7
9
|
export class CategoriesRepository extends BaseRepository {
|
|
8
10
|
constructor() {
|
|
9
11
|
super(CATEGORIES_COLLECTION);
|
|
10
12
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
/** Derived on every write path via `applyWriteHooks`. */
|
|
14
|
+
buildSearchTxtFor(data) {
|
|
15
|
+
return buildCategorySearchTxt(data);
|
|
16
|
+
}
|
|
17
|
+
async list(model, opts) {
|
|
18
|
+
/*
|
|
19
|
+
* `q` is a searchTxt pushdown, NOT a Sieve filter.
|
|
20
|
+
*
|
|
21
|
+
* `plan.head` becomes one `array-contains` on the longest term and
|
|
22
|
+
* `plan.rest` is AND-refined in memory — the same two-part shape the other
|
|
23
|
+
* ten migrated collections use. Going through `planSearchTxt` rather than
|
|
24
|
+
* hand-rolling it is what buys the `empty` guard: a query that normalises
|
|
25
|
+
* to no usable token (`"!!!"`, `"—"`) must return NOTHING, because
|
|
26
|
+
* emitting no clause at all returns the whole collection and calls it a
|
|
27
|
+
* search result. That is the live bug `products` still has (A2).
|
|
28
|
+
*/
|
|
29
|
+
const plan = planSearchTxt(opts?.search);
|
|
30
|
+
if (plan.empty)
|
|
31
|
+
return emptySearchResult();
|
|
32
|
+
if (!plan.head) {
|
|
33
|
+
return this.sieveQuery(model, CategoriesRepository.SIEVE_FIELDS);
|
|
34
|
+
}
|
|
35
|
+
const baseQuery = this.getCollection().where(CATEGORY_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
36
|
+
const result = await applySieveToFirestore({
|
|
37
|
+
baseQuery,
|
|
38
|
+
model,
|
|
39
|
+
fields: CategoriesRepository.SIEVE_FIELDS,
|
|
40
|
+
mapDoc: (snap) => this.mapDoc(snap),
|
|
41
|
+
});
|
|
42
|
+
return refineSearchTxt(result, plan.rest);
|
|
13
43
|
}
|
|
14
44
|
async createWithHierarchy(input) {
|
|
15
45
|
try {
|
|
@@ -141,21 +171,139 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
141
171
|
throw new DatabaseError(`Failed to retrieve categories by tier: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
142
172
|
}
|
|
143
173
|
}
|
|
144
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Every category sharing a root, tier-then-order.
|
|
176
|
+
*
|
|
177
|
+
* 🛑 `limit` is for DISPLAY callers only, and it is a real truncation.
|
|
178
|
+
* `buildTree(rootId)` must pass nothing: a tree built from a truncated node
|
|
179
|
+
* list silently drops whole branches, which is the exact failure
|
|
180
|
+
* `getDescendants`'s docstring was rewritten to avoid. A display grid of
|
|
181
|
+
* sibling categories, by contrast, renders ~20 tiles and has no reason to
|
|
182
|
+
* read 1,500 documents to do it.
|
|
183
|
+
*
|
|
184
|
+
* Paginated rather than one unbounded `.get()`: the previous form read the
|
|
185
|
+
* whole root subtree into memory on every category page render, which at the
|
|
186
|
+
* ~330-node forest this plan seeds is ~9% of a 50K/day Firestore budget for
|
|
187
|
+
* ONE page view.
|
|
188
|
+
*/
|
|
189
|
+
/**
|
|
190
|
+
* Several categories by id, in ONE round trip, in the order asked for.
|
|
191
|
+
*
|
|
192
|
+
* The ancestor-walk primitive. A five-tier tree means a leaf has up to four
|
|
193
|
+
* ancestors, and resolving an inherited description template or an inherited
|
|
194
|
+
* `productDefaults` means reading them nearest-first until the answer is
|
|
195
|
+
* found — which as four sequential `findById` calls is four round trips, on
|
|
196
|
+
* a route the seller form hits on every category change. Rule #6 budgets
|
|
197
|
+
* about three for a whole request.
|
|
198
|
+
*
|
|
199
|
+
* 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled. Callers
|
|
200
|
+
* walk the result looking for the first document that declares a thing, so
|
|
201
|
+
* "nearest first" has to survive the round trip — Firestore's `getAll`
|
|
202
|
+
* already answers in argument order, and this keeps that guarantee explicit
|
|
203
|
+
* rather than incidental. A caller needing to know WHICH id was missing
|
|
204
|
+
* should compare lengths; none does, because an ancestor that no longer
|
|
205
|
+
* exists is a data problem for the reconcile job, not a reason to fail a
|
|
206
|
+
* form.
|
|
207
|
+
*
|
|
208
|
+
* Bounded at 30 to match `array-contains-any`'s cap, so this cannot become
|
|
209
|
+
* the unbounded read that `getCategoriesByRootId` had to be rewritten to
|
|
210
|
+
* avoid being.
|
|
211
|
+
*/
|
|
212
|
+
async findByIds(ids) {
|
|
213
|
+
const unique = Array.from(new Set(ids.filter(Boolean))).slice(0, 30);
|
|
214
|
+
if (unique.length === 0)
|
|
215
|
+
return [];
|
|
145
216
|
try {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
|
|
217
|
+
const refs = unique.map((id) => this.db.collection(this.collection).doc(id));
|
|
218
|
+
const snaps = await this.db.getAll(...refs);
|
|
219
|
+
return snaps
|
|
220
|
+
.filter((s) => s.exists)
|
|
221
|
+
.map((s) => this.mapDoc(s));
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
const e = normalizeError(err);
|
|
225
|
+
/*
|
|
226
|
+
* 🛑 The raw `.message` is NOT interpolated into the thrown error — only
|
|
227
|
+
* the normalised `kind` and a count.
|
|
228
|
+
*
|
|
229
|
+
* Several older methods in this file do interpolate it, and
|
|
230
|
+
* `audit-raw-error-text` flagged this one for trying. The reasoning is
|
|
231
|
+
* Root Cause #86's: a thrown value's message is written for a developer,
|
|
232
|
+
* and this one reaches a user through the route's 500 branch — which is
|
|
233
|
+
* how a Node `MODULE_NOT_FOUND` with a full require stack ended up
|
|
234
|
+
* rendered inside the "Place your bid" modal.
|
|
235
|
+
*
|
|
236
|
+
* The ids are counted rather than listed. A category id is not
|
|
237
|
+
* sensitive, but 30 of them in one line is noise against Hobby's ~4 KB/s
|
|
238
|
+
* log budget.
|
|
239
|
+
*
|
|
240
|
+
* (`normalizeError` has to be the FIRST statement, not merely nearby:
|
|
241
|
+
* `audit-catch-normalize` looks within a few lines of the catch, and the
|
|
242
|
+
* first draft put this comment above the call and tripped it.)
|
|
243
|
+
*/
|
|
244
|
+
throw new DatabaseError(`Failed to read categories by id (${unique.length} requested): ${e.kind}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async getCategoriesByRootId(rootId, opts) {
|
|
248
|
+
try {
|
|
249
|
+
const cap = opts?.limit;
|
|
250
|
+
const pageSize = cap ? Math.min(cap, 300) : 300;
|
|
251
|
+
const out = [];
|
|
252
|
+
let cursor = null;
|
|
253
|
+
for (;;) {
|
|
254
|
+
let q = this.db
|
|
255
|
+
.collection(this.collection)
|
|
256
|
+
.where(CATEGORY_FIELDS.ROOT_ID, "==", rootId)
|
|
257
|
+
.orderBy(CATEGORY_FIELDS.TIER, "asc")
|
|
258
|
+
.orderBy(CATEGORY_FIELDS.ORDER, "asc")
|
|
259
|
+
.limit(pageSize);
|
|
260
|
+
if (cursor)
|
|
261
|
+
q = q.startAfter(cursor);
|
|
262
|
+
const page = await q.get();
|
|
263
|
+
if (page.empty)
|
|
264
|
+
break;
|
|
265
|
+
for (const doc of page.docs)
|
|
266
|
+
out.push(this.mapDoc(doc));
|
|
267
|
+
if (cap && out.length >= cap)
|
|
268
|
+
return out.slice(0, cap);
|
|
269
|
+
if (page.size < pageSize)
|
|
270
|
+
break;
|
|
271
|
+
cursor = page.docs[page.docs.length - 1];
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
153
274
|
}
|
|
154
275
|
catch (error) {
|
|
155
276
|
void normalizeError(error);
|
|
156
277
|
throw new DatabaseError(`Failed to retrieve categories by rootId: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
157
278
|
}
|
|
158
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Categories whose `productDefaults.taxCodeId` points at `taxCodeId`.
|
|
282
|
+
*
|
|
283
|
+
* 🛑 Exists so DELETE on a tax code can REFUSE while it is referenced.
|
|
284
|
+
* `taxCodeId` is a plain string with no referential integrity, and a
|
|
285
|
+
* dangling reference does not error — `findResolvable` answers `null`,
|
|
286
|
+
* whose correct handling is "leave the product's gstRate alone", so every
|
|
287
|
+
* subsequent listing under that category silently derives NO TAX. It does
|
|
288
|
+
* not fail; it under-charges, until someone reads an invoice.
|
|
289
|
+
*
|
|
290
|
+
* One equality on a dot-path — served by the automatic index, no composite
|
|
291
|
+
* to declare. Bounded at 200: the answer is used to NAME referrers in a 409
|
|
292
|
+
* message, and the first five are what the admin reads.
|
|
293
|
+
*/
|
|
294
|
+
async findByTaxCodeId(taxCodeId) {
|
|
295
|
+
try {
|
|
296
|
+
const snap = await this.getCollection()
|
|
297
|
+
.where(CATEGORY_FIELDS.PRODUCT_DEFAULTS_TAX_CODE_ID, "==", taxCodeId)
|
|
298
|
+
.limit(200)
|
|
299
|
+
.get();
|
|
300
|
+
return snap.docs.map((d) => this.mapDoc(d));
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
void normalizeError(error);
|
|
304
|
+
throw new DatabaseError(`Failed to find categories by tax code: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
159
307
|
async getChildren(parentId) {
|
|
160
308
|
try {
|
|
161
309
|
const snapshot = await this.db
|
|
@@ -173,17 +321,16 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
173
321
|
throw new DatabaseError(`Failed to retrieve children categories: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
174
322
|
}
|
|
175
323
|
}
|
|
176
|
-
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
324
|
+
/*
|
|
325
|
+
* 🛑 `getDescendantIds` was DELETED (2026-10-10). It was
|
|
326
|
+
* `getDescendants(id).then(d => d.map(x => x.id))`, and its only caller —
|
|
327
|
+
* CategoryDetailPageView — already held the documents from its own
|
|
328
|
+
* `getDescendants` call in the same render. So it re-ran the identical
|
|
329
|
+
* paginated subtree query and discarded everything but the ids: ~330 wasted
|
|
330
|
+
* reads per page view at the forest size this plan seeds, and two answers
|
|
331
|
+
* that could disagree whenever one read failed and the other did not.
|
|
332
|
+
* Call `getDescendants` and `.map(d => d.id)` at the one site that needs it.
|
|
182
333
|
*/
|
|
183
|
-
async getDescendantIds(categoryId) {
|
|
184
|
-
const docs = await this.getDescendants(categoryId);
|
|
185
|
-
return docs.map((d) => d.id);
|
|
186
|
-
}
|
|
187
334
|
/**
|
|
188
335
|
* Every descendant DOCUMENT at any depth.
|
|
189
336
|
*
|
|
@@ -696,24 +843,19 @@ export class CategoriesRepository extends BaseRepository {
|
|
|
696
843
|
.get();
|
|
697
844
|
return snap.docs.map((d) => this.mapDoc(d));
|
|
698
845
|
}
|
|
846
|
+
/*
|
|
847
|
+
* 🛑 `incrementViewCount` DELETED 2026-10-10 — zero callers since
|
|
848
|
+
* 2026-08-31, when it was removed from the category-detail render path (a
|
|
849
|
+
* Firestore write per render, and a second counter racing `pageViews`).
|
|
850
|
+
*
|
|
851
|
+
* Measured: `viewCount` is absent from all 58 category documents. Deleting
|
|
852
|
+
* the writer nobody calls is what stops the dead field reading as live. See
|
|
853
|
+
* the products repository for the same removal.
|
|
854
|
+
*/
|
|
699
855
|
/**
|
|
700
856
|
* SB-UNI-B — derive the canonical `sublisting-{slug}` ID from a
|
|
701
857
|
* human-entered category name.
|
|
702
858
|
*/
|
|
703
|
-
async incrementViewCount(categoryId) {
|
|
704
|
-
try {
|
|
705
|
-
await this.db
|
|
706
|
-
.collection(this.collection)
|
|
707
|
-
.doc(categoryId)
|
|
708
|
-
.update({ [CATEGORY_FIELDS.VIEW_COUNT]: increment(1) });
|
|
709
|
-
// Fire-and-forget analytics: a lost view increment changes nothing that
|
|
710
|
-
// renders, so it must never break the category read path it rides along
|
|
711
|
-
// with — the count is approximate by design.
|
|
712
|
-
}
|
|
713
|
-
catch (_err) {
|
|
714
|
-
void normalizeError(_err);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
859
|
generateSublistingId(name) {
|
|
718
860
|
const base = name
|
|
719
861
|
.toLowerCase()
|
|
@@ -730,6 +872,7 @@ CategoriesRepository.SIEVE_FIELDS = {
|
|
|
730
872
|
isActive: { canFilter: true, canSort: false },
|
|
731
873
|
isFeatured: { canFilter: true, canSort: false },
|
|
732
874
|
isBrand: { canFilter: true, canSort: false },
|
|
875
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
733
876
|
categoryType: { canFilter: true, canSort: false },
|
|
734
877
|
isSearchable: { canFilter: true, canSort: false },
|
|
735
878
|
parentId: { canFilter: true, canSort: false, path: "parentIds" },
|