@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
|
@@ -96,6 +96,9 @@ export declare const ADMIN_ENDPOINTS: {
|
|
|
96
96
|
readonly PAYMENTS_SETTINGS: "/api/admin/payments/settings";
|
|
97
97
|
readonly PRODUCTS: "/api/admin/products";
|
|
98
98
|
readonly PRODUCT_BY_ID: (id: string) => string;
|
|
99
|
+
/** One request for up to 50 flag writes — replaces a client-side `forEach`
|
|
100
|
+
* of single-row PATCHes that fired one invocation per selected row. */
|
|
101
|
+
readonly PRODUCTS_BULK: "/api/admin/products/bulk";
|
|
99
102
|
readonly ORDERS: "/api/admin/orders";
|
|
100
103
|
readonly ORDER_BY_ID: (id: string) => string;
|
|
101
104
|
readonly ORDER_REFUND: (id: string) => string;
|
|
@@ -198,6 +201,8 @@ export declare const ADMIN_ENDPOINTS: {
|
|
|
198
201
|
readonly SUBLISTING_CATEGORY_BY_ID: (id: string) => string;
|
|
199
202
|
readonly PRODUCT_FEATURES: "/api/admin/features";
|
|
200
203
|
readonly PRODUCT_FEATURE_BY_ID: (id: string) => string;
|
|
204
|
+
readonly TAX_CODES: "/api/admin/tax-codes";
|
|
205
|
+
readonly TAX_CODE_BY_ID: (id: string) => string;
|
|
201
206
|
readonly TEAM: "/api/admin/team";
|
|
202
207
|
readonly TEAM_MEMBER: (uid: string) => string;
|
|
203
208
|
readonly USER_HARD_BAN: (uid: string) => string;
|
|
@@ -259,6 +264,15 @@ export declare const CATEGORY_ENDPOINTS: {
|
|
|
259
264
|
readonly ROOT: (pageSize?: number) => string;
|
|
260
265
|
/** @param qs — pre-built querystring (no leading `?`) for structured filters */
|
|
261
266
|
readonly FILTERED: (qs: string) => string;
|
|
267
|
+
/**
|
|
268
|
+
* The inherited description template plus the category's public product
|
|
269
|
+
* defaults, for the seller form's template picker.
|
|
270
|
+
*
|
|
271
|
+
* `variant` narrows which body is wanted (`pre_owned`, `new_in_box`, …) and
|
|
272
|
+
* is part of the cache key — the response is edge-cached for an hour, so a
|
|
273
|
+
* category change in the form costs zero function invocations on a hit.
|
|
274
|
+
*/
|
|
275
|
+
readonly DESCRIPTION_TEMPLATE: (id: string, variant?: string) => string;
|
|
262
276
|
};
|
|
263
277
|
export declare const CHECKOUT_ENDPOINTS: {
|
|
264
278
|
readonly PREFLIGHT: "/api/checkout/preflight";
|
|
@@ -563,6 +577,9 @@ export declare const API_ENDPOINTS: {
|
|
|
563
577
|
readonly PAYMENTS_SETTINGS: "/api/admin/payments/settings";
|
|
564
578
|
readonly PRODUCTS: "/api/admin/products";
|
|
565
579
|
readonly PRODUCT_BY_ID: (id: string) => string;
|
|
580
|
+
/** One request for up to 50 flag writes — replaces a client-side `forEach`
|
|
581
|
+
* of single-row PATCHes that fired one invocation per selected row. */
|
|
582
|
+
readonly PRODUCTS_BULK: "/api/admin/products/bulk";
|
|
566
583
|
readonly ORDERS: "/api/admin/orders";
|
|
567
584
|
readonly ORDER_BY_ID: (id: string) => string;
|
|
568
585
|
readonly ORDER_REFUND: (id: string) => string;
|
|
@@ -665,6 +682,8 @@ export declare const API_ENDPOINTS: {
|
|
|
665
682
|
readonly SUBLISTING_CATEGORY_BY_ID: (id: string) => string;
|
|
666
683
|
readonly PRODUCT_FEATURES: "/api/admin/features";
|
|
667
684
|
readonly PRODUCT_FEATURE_BY_ID: (id: string) => string;
|
|
685
|
+
readonly TAX_CODES: "/api/admin/tax-codes";
|
|
686
|
+
readonly TAX_CODE_BY_ID: (id: string) => string;
|
|
668
687
|
readonly TEAM: "/api/admin/team";
|
|
669
688
|
readonly TEAM_MEMBER: (uid: string) => string;
|
|
670
689
|
readonly USER_HARD_BAN: (uid: string) => string;
|
|
@@ -726,6 +745,15 @@ export declare const API_ENDPOINTS: {
|
|
|
726
745
|
readonly ROOT: (pageSize?: number) => string;
|
|
727
746
|
/** @param qs — pre-built querystring (no leading `?`) for structured filters */
|
|
728
747
|
readonly FILTERED: (qs: string) => string;
|
|
748
|
+
/**
|
|
749
|
+
* The inherited description template plus the category's public product
|
|
750
|
+
* defaults, for the seller form's template picker.
|
|
751
|
+
*
|
|
752
|
+
* `variant` narrows which body is wanted (`pre_owned`, `new_in_box`, …) and
|
|
753
|
+
* is part of the cache key — the response is edge-cached for an hour, so a
|
|
754
|
+
* category change in the form costs zero function invocations on a hit.
|
|
755
|
+
*/
|
|
756
|
+
readonly DESCRIPTION_TEMPLATE: (id: string, variant?: string) => string;
|
|
729
757
|
};
|
|
730
758
|
readonly BEFORE_AFTER: {
|
|
731
759
|
readonly LIST: "/api/before-after";
|
|
@@ -1032,6 +1060,9 @@ export declare const API_ROUTES: {
|
|
|
1032
1060
|
readonly PAYMENTS_SETTINGS: "/api/admin/payments/settings";
|
|
1033
1061
|
readonly PRODUCTS: "/api/admin/products";
|
|
1034
1062
|
readonly PRODUCT_BY_ID: (id: string) => string;
|
|
1063
|
+
/** One request for up to 50 flag writes — replaces a client-side `forEach`
|
|
1064
|
+
* of single-row PATCHes that fired one invocation per selected row. */
|
|
1065
|
+
readonly PRODUCTS_BULK: "/api/admin/products/bulk";
|
|
1035
1066
|
readonly ORDERS: "/api/admin/orders";
|
|
1036
1067
|
readonly ORDER_BY_ID: (id: string) => string;
|
|
1037
1068
|
readonly ORDER_REFUND: (id: string) => string;
|
|
@@ -1134,6 +1165,8 @@ export declare const API_ROUTES: {
|
|
|
1134
1165
|
readonly SUBLISTING_CATEGORY_BY_ID: (id: string) => string;
|
|
1135
1166
|
readonly PRODUCT_FEATURES: "/api/admin/features";
|
|
1136
1167
|
readonly PRODUCT_FEATURE_BY_ID: (id: string) => string;
|
|
1168
|
+
readonly TAX_CODES: "/api/admin/tax-codes";
|
|
1169
|
+
readonly TAX_CODE_BY_ID: (id: string) => string;
|
|
1137
1170
|
readonly TEAM: "/api/admin/team";
|
|
1138
1171
|
readonly TEAM_MEMBER: (uid: string) => string;
|
|
1139
1172
|
readonly USER_HARD_BAN: (uid: string) => string;
|
|
@@ -1195,6 +1228,15 @@ export declare const API_ROUTES: {
|
|
|
1195
1228
|
readonly ROOT: (pageSize?: number) => string;
|
|
1196
1229
|
/** @param qs — pre-built querystring (no leading `?`) for structured filters */
|
|
1197
1230
|
readonly FILTERED: (qs: string) => string;
|
|
1231
|
+
/**
|
|
1232
|
+
* The inherited description template plus the category's public product
|
|
1233
|
+
* defaults, for the seller form's template picker.
|
|
1234
|
+
*
|
|
1235
|
+
* `variant` narrows which body is wanted (`pre_owned`, `new_in_box`, …) and
|
|
1236
|
+
* is part of the cache key — the response is edge-cached for an hour, so a
|
|
1237
|
+
* category change in the form costs zero function invocations on a hit.
|
|
1238
|
+
*/
|
|
1239
|
+
readonly DESCRIPTION_TEMPLATE: (id: string, variant?: string) => string;
|
|
1198
1240
|
};
|
|
1199
1241
|
readonly BEFORE_AFTER: {
|
|
1200
1242
|
readonly LIST: "/api/before-after";
|
|
@@ -127,6 +127,9 @@ export const ADMIN_ENDPOINTS = {
|
|
|
127
127
|
PAYMENTS_SETTINGS: "/api/admin/payments/settings",
|
|
128
128
|
PRODUCTS: "/api/admin/products",
|
|
129
129
|
PRODUCT_BY_ID: (id) => `/api/admin/products/${id}`,
|
|
130
|
+
/** One request for up to 50 flag writes — replaces a client-side `forEach`
|
|
131
|
+
* of single-row PATCHes that fired one invocation per selected row. */
|
|
132
|
+
PRODUCTS_BULK: "/api/admin/products/bulk",
|
|
130
133
|
ORDERS: "/api/admin/orders",
|
|
131
134
|
ORDER_BY_ID: (id) => `/api/admin/orders/${id}`,
|
|
132
135
|
ORDER_REFUND: (id) => `/api/admin/orders/${id}/refund`,
|
|
@@ -230,6 +233,8 @@ export const ADMIN_ENDPOINTS = {
|
|
|
230
233
|
SUBLISTING_CATEGORY_BY_ID: (id) => `/api/admin/sublisting-categories/${id}`,
|
|
231
234
|
PRODUCT_FEATURES: "/api/admin/features",
|
|
232
235
|
PRODUCT_FEATURE_BY_ID: (id) => `/api/admin/features/${id}`,
|
|
236
|
+
TAX_CODES: "/api/admin/tax-codes",
|
|
237
|
+
TAX_CODE_BY_ID: (id) => `/api/admin/tax-codes/${id}`,
|
|
233
238
|
TEAM: "/api/admin/team",
|
|
234
239
|
TEAM_MEMBER: (uid) => `/api/admin/team/${uid}`,
|
|
235
240
|
USER_HARD_BAN: (uid) => `/api/admin/users/${uid}/hard-ban`,
|
|
@@ -303,6 +308,15 @@ export const CATEGORY_ENDPOINTS = {
|
|
|
303
308
|
ROOT: (pageSize = 20) => `/api/categories?tier=0&pageSize=${pageSize}`,
|
|
304
309
|
/** @param qs — pre-built querystring (no leading `?`) for structured filters */
|
|
305
310
|
FILTERED: (qs) => `/api/categories?${qs}`,
|
|
311
|
+
/**
|
|
312
|
+
* The inherited description template plus the category's public product
|
|
313
|
+
* defaults, for the seller form's template picker.
|
|
314
|
+
*
|
|
315
|
+
* `variant` narrows which body is wanted (`pre_owned`, `new_in_box`, …) and
|
|
316
|
+
* is part of the cache key — the response is edge-cached for an hour, so a
|
|
317
|
+
* category change in the form costs zero function invocations on a hit.
|
|
318
|
+
*/
|
|
319
|
+
DESCRIPTION_TEMPLATE: (id, variant) => `/api/categories/${id}/description-template${variant ? `?variant=${variant}` : ""}`,
|
|
306
320
|
};
|
|
307
321
|
// ---------------------------------------------------------------------------
|
|
308
322
|
// Checkout
|
|
@@ -27,7 +27,7 @@ export declare function adminUpdateUser(adminId: string, uid: string, input: Use
|
|
|
27
27
|
* removed. Orders, reviews, addresses, payouts, etc. are left untouched —
|
|
28
28
|
* they're business/audit records that reference this uid and deleting
|
|
29
29
|
* them would corrupt other parties' (sellers', buyers') own history, the
|
|
30
|
-
* same reasoning
|
|
30
|
+
* same reasoning testDataCleanup already documents for why it leaves
|
|
31
31
|
* orders/reviews/wishlists/history alone when a test product disappears.
|
|
32
32
|
*/
|
|
33
33
|
export declare function adminDeleteUser(adminId: string, uid: string): Promise<void>;
|
|
@@ -156,7 +156,7 @@ export async function adminUpdateUser(adminId, uid, input) {
|
|
|
156
156
|
* removed. Orders, reviews, addresses, payouts, etc. are left untouched —
|
|
157
157
|
* they're business/audit records that reference this uid and deleting
|
|
158
158
|
* them would corrupt other parties' (sellers', buyers') own history, the
|
|
159
|
-
* same reasoning
|
|
159
|
+
* same reasoning testDataCleanup already documents for why it leaves
|
|
160
160
|
* orders/reviews/wishlists/history alone when a test product disappears.
|
|
161
161
|
*/
|
|
162
162
|
export async function adminDeleteUser(adminId, uid) {
|
|
@@ -4,12 +4,88 @@ import { useApiMutation } from "@mohasinac/appkit/client";
|
|
|
4
4
|
import React from "react";
|
|
5
5
|
import { useQuery } from "@tanstack/react-query";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { Card, CardBody, ConfirmDeleteModal, Div, Input, PaginatedSelect, Stack, StackedViewShell, Text, Toggle, useToast, Show, StickyToolbar } from "../../../ui";
|
|
7
|
+
import { Button, Card, CardBody, ConfirmDeleteModal, Div, Input, PaginatedSelect, Select, Span, Stack, StackedViewShell, Text, Textarea, Toggle, useToast, Show, StickyToolbar } from "../../../ui";
|
|
8
|
+
import { usedPlaceholders } from "../../../_internal/shared/templating/placeholders";
|
|
8
9
|
import { apiClient } from "../../../http";
|
|
9
10
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
10
11
|
import { CategoryQuickCreateForm } from "./CategoryQuickCreateForm";
|
|
11
12
|
import { FieldInput, FormErrorSummary, FormShellContext, useFormShellState } from "../../../ui/forms";
|
|
12
13
|
import { SectionForm, useSectionFormNav } from "../../shell";
|
|
14
|
+
/*
|
|
15
|
+
* ── Content & Templates (C2c) ────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* 🛑 `highlights` and `faqs` have had NO editor since they shipped, and B3
|
|
18
|
+
* added `contentBody`, `descriptionTemplates` and `productDefaults` with none
|
|
19
|
+
* either. All five are in `CATEGORIES_UPDATABLE_FIELDS`, so the repository has
|
|
20
|
+
* always been willing — the only thing missing was somewhere to type them.
|
|
21
|
+
*
|
|
22
|
+
* ## Why string arrays are newline-separated textareas
|
|
23
|
+
*
|
|
24
|
+
* `highlights`, `inTheBox` and `seoKeywords` are flat `string[]`. A drag-handle
|
|
25
|
+
* repeater for each would be three widgets to build and three to maintain, and
|
|
26
|
+
* an admin pasting six bullets from a competitor's page wants to paste six
|
|
27
|
+
* lines. The textarea IS the better tool here, not a shortcut — order is
|
|
28
|
+
* preserved, reordering is a cut-and-paste, and a blank line is dropped rather
|
|
29
|
+
* than stored as an empty bullet.
|
|
30
|
+
*
|
|
31
|
+
* Repeaters are reserved for the shapes that genuinely need per-row fields:
|
|
32
|
+
* FAQs (question + answer), specifications (name + value + unit) and templates
|
|
33
|
+
* (label + variant + body).
|
|
34
|
+
*/
|
|
35
|
+
const linesToList = (raw) => raw
|
|
36
|
+
.split("\n")
|
|
37
|
+
.map((s) => s.trim())
|
|
38
|
+
.filter((s) => s.length > 0);
|
|
39
|
+
const listToLines = (list) => (list ?? []).join("\n");
|
|
40
|
+
const TEMPLATE_VARIANT_OPTIONS = [
|
|
41
|
+
{ value: "default", label: "Default — any condition" },
|
|
42
|
+
{ value: "new_in_box", label: "New in box" },
|
|
43
|
+
{ value: "new_in_packet", label: "New in packet" },
|
|
44
|
+
{ value: "pre_owned", label: "Pre-owned / used" },
|
|
45
|
+
{ value: "replica", label: "Reproduction" },
|
|
46
|
+
];
|
|
47
|
+
const AUTHENTICITY_OPTIONS = [
|
|
48
|
+
{ value: "", label: "Not set — the seller chooses" },
|
|
49
|
+
{ value: "original", label: "Original" },
|
|
50
|
+
{ value: "reproduction", label: "Reproduction (midfake)" },
|
|
51
|
+
{ value: "unverified", label: "Unverified" },
|
|
52
|
+
];
|
|
53
|
+
const CONDITION_DEFAULT_OPTIONS = [
|
|
54
|
+
{ value: "", label: "Not set — the seller chooses" },
|
|
55
|
+
{ value: "new", label: "New" },
|
|
56
|
+
{ value: "like_new", label: "Like New" },
|
|
57
|
+
{ value: "good", label: "Good" },
|
|
58
|
+
{ value: "fair", label: "Fair" },
|
|
59
|
+
{ value: "used", label: "Used" },
|
|
60
|
+
{ value: "refurbished", label: "Refurbished" },
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* A minimal add/remove repeater.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately NOT a new appkit primitive: three local shapes with three
|
|
66
|
+
* different row renderers is not yet the Rule of Three on a *component* — it
|
|
67
|
+
* is one container used three times with a render prop, which is what this is.
|
|
68
|
+
* Promote it when a fourth caller appears outside this file.
|
|
69
|
+
*/
|
|
70
|
+
function Repeater({ rows, onChange, blank, addLabel, renderRow, max, }) {
|
|
71
|
+
return (_jsxs(Stack, { gap: "sm", children: [rows.map((row, i) => (_jsx(Div, { rounded: "lg", border: "default", padding: "sm", children: _jsxs(Stack, { gap: "xs", children: [renderRow(row, (patch) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)))), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => onChange(rows.filter((_, j) => j !== i)), children: "Remove" })] }) }, i))), rows.length < max ? (_jsx(Button, { variant: "secondary", size: "sm", onClick: () => onChange([...rows, blank()]), children: addLabel })) : (_jsxs(Text, { size: "xs", color: "muted", children: ["Maximum of ", max, " reached."] }))] }));
|
|
72
|
+
}
|
|
73
|
+
async function loadTaxCodeOptions(query, page) {
|
|
74
|
+
const params = new URLSearchParams({ page: String(page), pageSize: "25" });
|
|
75
|
+
if (query)
|
|
76
|
+
params.set("q", query);
|
|
77
|
+
const res = await apiClient.get(`${ADMIN_ENDPOINTS.TAX_CODES}?${params.toString()}`);
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
const payload = res?.data ?? res;
|
|
80
|
+
const items = payload?.items ?? payload?.data ?? [];
|
|
81
|
+
return {
|
|
82
|
+
items: items.map((t) => ({
|
|
83
|
+
value: t.id,
|
|
84
|
+
label: t.label ?? `${t.hsnCode ?? t.id} — ${t.gstRate ?? "?"}%`,
|
|
85
|
+
})),
|
|
86
|
+
hasMore: payload?.hasMore ?? false,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
13
89
|
/**
|
|
14
90
|
* Covers every field the form actually edits.
|
|
15
91
|
*
|
|
@@ -33,6 +109,32 @@ const categoryFormSchema = z.object({
|
|
|
33
109
|
.or(z.literal("")),
|
|
34
110
|
isActive: z.boolean().optional(),
|
|
35
111
|
showInMenu: z.boolean().optional(),
|
|
112
|
+
// ── Content & Templates ──────────────────────────────────────────────────
|
|
113
|
+
contentBody: z
|
|
114
|
+
.string()
|
|
115
|
+
.max(12000, "Keep the body under 12,000 characters")
|
|
116
|
+
.optional()
|
|
117
|
+
.or(z.literal("")),
|
|
118
|
+
highlightsText: z.string().optional().or(z.literal("")),
|
|
119
|
+
taxCodeId: z.string().optional().or(z.literal("")),
|
|
120
|
+
defaultCondition: z.string().optional().or(z.literal("")),
|
|
121
|
+
defaultAuthenticity: z.string().optional().or(z.literal("")),
|
|
122
|
+
inTheBoxText: z.string().optional().or(z.literal("")),
|
|
123
|
+
seoTitleTemplate: z.string().max(200).optional().or(z.literal("")),
|
|
124
|
+
seoDescriptionTemplate: z.string().max(400).optional().or(z.literal("")),
|
|
125
|
+
seoKeywordsText: z.string().optional().or(z.literal("")),
|
|
126
|
+
/*
|
|
127
|
+
* Typed in a text input and sent through `Number()`, so validated as a
|
|
128
|
+
* numeric STRING here rather than coerced — the same treatment `order`
|
|
129
|
+
* already gets two fields up. A category default is a PACKED weight (C5a),
|
|
130
|
+
* which is why the helper text says so: it drives the shipping estimate, and
|
|
131
|
+
* seeding an item weight there under-estimates every booking.
|
|
132
|
+
*/
|
|
133
|
+
weightG: z.string().regex(/^\d*$/, "Weight must be a whole number of grams").optional().or(z.literal("")),
|
|
134
|
+
dimL: z.string().regex(/^\d*\.?\d*$/, "Length must be a number").optional().or(z.literal("")),
|
|
135
|
+
dimW: z.string().regex(/^\d*\.?\d*$/, "Width must be a number").optional().or(z.literal("")),
|
|
136
|
+
dimH: z.string().regex(/^\d*\.?\d*$/, "Height must be a number").optional().or(z.literal("")),
|
|
137
|
+
fragile: z.boolean().optional(),
|
|
36
138
|
});
|
|
37
139
|
const EMPTY_VALUES = {
|
|
38
140
|
name: "",
|
|
@@ -42,6 +144,20 @@ const EMPTY_VALUES = {
|
|
|
42
144
|
order: "",
|
|
43
145
|
isActive: true,
|
|
44
146
|
showInMenu: true,
|
|
147
|
+
contentBody: "",
|
|
148
|
+
highlightsText: "",
|
|
149
|
+
taxCodeId: "",
|
|
150
|
+
defaultCondition: "",
|
|
151
|
+
defaultAuthenticity: "",
|
|
152
|
+
inTheBoxText: "",
|
|
153
|
+
seoTitleTemplate: "",
|
|
154
|
+
seoDescriptionTemplate: "",
|
|
155
|
+
seoKeywordsText: "",
|
|
156
|
+
weightG: "",
|
|
157
|
+
dimL: "",
|
|
158
|
+
dimW: "",
|
|
159
|
+
dimH: "",
|
|
160
|
+
fragile: false,
|
|
45
161
|
};
|
|
46
162
|
const __P = {
|
|
47
163
|
p4: "p-[var(--appkit-space-4)]",
|
|
@@ -62,6 +178,80 @@ async function loadCategoryOptions(query, page) {
|
|
|
62
178
|
hasMore: payload?.hasMore ?? false,
|
|
63
179
|
};
|
|
64
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* The content half of the PUT payload.
|
|
183
|
+
*
|
|
184
|
+
* 🛑 Every field is sent on EVERY save, including as an empty array or an
|
|
185
|
+
* omitted key — because the route spreads this straight into
|
|
186
|
+
* `categoriesRepository.update()`, and an admin who DELETES all five
|
|
187
|
+
* highlights must end up with no highlights rather than with the old five.
|
|
188
|
+
* Only sending non-empty values is how a removal silently fails, which is the
|
|
189
|
+
* defect the whole C2b-fix commit was about one layer down.
|
|
190
|
+
*
|
|
191
|
+
* `productDefaults` is rebuilt whole rather than merged, for the same reason —
|
|
192
|
+
* clearing the tax code has to mean clearing it. The advisory
|
|
193
|
+
* `priceGuidance` half is CARRIED THROUGH from the loaded document and never
|
|
194
|
+
* edited here: it is seed-owned market data plus C1's rollup output, and an
|
|
195
|
+
* editor that round-tripped it would let an admin overwrite a measured band by
|
|
196
|
+
* hand with nothing recording that they had.
|
|
197
|
+
*/
|
|
198
|
+
function buildContentPayload(v, faqs, specs, templates, carriedPriceGuidance) {
|
|
199
|
+
const num = (s) => {
|
|
200
|
+
const t = s.trim();
|
|
201
|
+
if (t === "")
|
|
202
|
+
return undefined;
|
|
203
|
+
const n = Number(t);
|
|
204
|
+
return Number.isFinite(n) ? n : undefined;
|
|
205
|
+
};
|
|
206
|
+
const str = (s) => {
|
|
207
|
+
const t = String(s ?? "").trim();
|
|
208
|
+
return t === "" ? undefined : t;
|
|
209
|
+
};
|
|
210
|
+
const list = (s) => {
|
|
211
|
+
const out = linesToList(String(s ?? ""));
|
|
212
|
+
return out.length === 0 ? undefined : out;
|
|
213
|
+
};
|
|
214
|
+
const l = num(String(v.dimL ?? ""));
|
|
215
|
+
const w = num(String(v.dimW ?? ""));
|
|
216
|
+
const h = num(String(v.dimH ?? ""));
|
|
217
|
+
const keptSpecs = specs.filter((s) => s.name.trim() !== "" && s.value.trim() !== "");
|
|
218
|
+
const authenticity = str(v.defaultAuthenticity);
|
|
219
|
+
/*
|
|
220
|
+
* Assembled field by field rather than through a `Record<string, unknown>`
|
|
221
|
+
* accumulator. The accumulator version was shorter and `audit-unknown-leakage`
|
|
222
|
+
* was right to reject it: `unknown` in a payload builder is how a wrong key
|
|
223
|
+
* or a wrong shape reaches Firestore with nothing to catch it, and this
|
|
224
|
+
* object's whole job is to be the right shape.
|
|
225
|
+
*/
|
|
226
|
+
const defaults = {
|
|
227
|
+
...(str(v.taxCodeId) ? { taxCodeId: str(v.taxCodeId) } : {}),
|
|
228
|
+
...(str(v.defaultCondition) ? { defaultCondition: str(v.defaultCondition) } : {}),
|
|
229
|
+
...(authenticity === "original" || authenticity === "reproduction" || authenticity === "unverified"
|
|
230
|
+
? { defaultAuthenticity: authenticity }
|
|
231
|
+
: {}),
|
|
232
|
+
...(list(v.inTheBoxText) ? { inTheBox: list(v.inTheBoxText) } : {}),
|
|
233
|
+
...(str(v.seoTitleTemplate) ? { seoTitleTemplate: str(v.seoTitleTemplate) } : {}),
|
|
234
|
+
...(str(v.seoDescriptionTemplate)
|
|
235
|
+
? { seoDescriptionTemplate: str(v.seoDescriptionTemplate) }
|
|
236
|
+
: {}),
|
|
237
|
+
...(list(v.seoKeywordsText) ? { seoKeywords: list(v.seoKeywordsText) } : {}),
|
|
238
|
+
...(num(String(v.weightG ?? "")) != null ? { weightG: num(String(v.weightG ?? "")) } : {}),
|
|
239
|
+
...(keptSpecs.length > 0 ? { specifications: keptSpecs } : {}),
|
|
240
|
+
// All three or none — a box with a length and no width is not a dimension.
|
|
241
|
+
...(l != null && w != null && h != null ? { dimensionsCm: { l, w, h } } : {}),
|
|
242
|
+
...(v.fragile === true ? { fragile: true } : {}),
|
|
243
|
+
...(carriedPriceGuidance !== undefined ? { priceGuidance: carriedPriceGuidance } : {}),
|
|
244
|
+
};
|
|
245
|
+
return {
|
|
246
|
+
contentBody: String(v.contentBody ?? ""),
|
|
247
|
+
highlights: linesToList(String(v.highlightsText ?? "")),
|
|
248
|
+
faqs: faqs.filter((f) => f.question.trim() !== "" && f.answer.trim() !== ""),
|
|
249
|
+
descriptionTemplates: templates
|
|
250
|
+
.filter((t) => t.label.trim() !== "" && t.body.trim() !== "")
|
|
251
|
+
.map((t) => ({ ...t, id: t.id || toCategorySlug(t.label) || "template" })),
|
|
252
|
+
productDefaults: defaults,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
65
255
|
function toCategorySlug(str) {
|
|
66
256
|
return str
|
|
67
257
|
.toLowerCase()
|
|
@@ -73,6 +263,19 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
73
263
|
const isEdit = Boolean(categoryId);
|
|
74
264
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
|
75
265
|
const [values, setValues] = React.useState(EMPTY_VALUES);
|
|
266
|
+
/*
|
|
267
|
+
* The three repeater arrays live OUTSIDE `values`.
|
|
268
|
+
*
|
|
269
|
+
* `CategoryFormValues` carries an index signature of `JsonValue` so
|
|
270
|
+
* `SectionForm` can map a Zod path back to the panel that owns it, and an
|
|
271
|
+
* array of objects satisfies that only after a cast at every read. Keeping
|
|
272
|
+
* them as their own typed state costs one extra dependency on the sections
|
|
273
|
+
* memo and buys real types at every use — the alternative is a `as any` in a
|
|
274
|
+
* render loop, which is where this codebase's worst defects live.
|
|
275
|
+
*/
|
|
276
|
+
const [faqs, setFaqs] = React.useState([]);
|
|
277
|
+
const [specs, setSpecs] = React.useState([]);
|
|
278
|
+
const [templates, setTemplates] = React.useState([]);
|
|
76
279
|
/* UI state, not a form value — whether the user has taken the slug over. */
|
|
77
280
|
const [slugManual, setSlugManual] = React.useState(false);
|
|
78
281
|
const { showToast } = useToast();
|
|
@@ -112,7 +315,29 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
112
315
|
order: cat.order !== undefined ? String(cat.order) : "",
|
|
113
316
|
isActive: cat.isActive ?? true,
|
|
114
317
|
showInMenu: cat.display?.showInMenu ?? true,
|
|
318
|
+
contentBody: cat.contentBody ?? "",
|
|
319
|
+
highlightsText: listToLines(cat.highlights),
|
|
320
|
+
taxCodeId: cat.productDefaults?.taxCodeId ?? "",
|
|
321
|
+
defaultCondition: cat.productDefaults?.defaultCondition ?? "",
|
|
322
|
+
defaultAuthenticity: cat.productDefaults?.defaultAuthenticity ?? "",
|
|
323
|
+
inTheBoxText: listToLines(cat.productDefaults?.inTheBox),
|
|
324
|
+
seoTitleTemplate: cat.productDefaults?.seoTitleTemplate ?? "",
|
|
325
|
+
seoDescriptionTemplate: cat.productDefaults?.seoDescriptionTemplate ?? "",
|
|
326
|
+
seoKeywordsText: listToLines(cat.productDefaults?.seoKeywords),
|
|
327
|
+
weightG: cat.productDefaults?.weightG != null ? String(cat.productDefaults.weightG) : "",
|
|
328
|
+
dimL: cat.productDefaults?.dimensionsCm?.l != null ? String(cat.productDefaults.dimensionsCm.l) : "",
|
|
329
|
+
dimW: cat.productDefaults?.dimensionsCm?.w != null ? String(cat.productDefaults.dimensionsCm.w) : "",
|
|
330
|
+
dimH: cat.productDefaults?.dimensionsCm?.h != null ? String(cat.productDefaults.dimensionsCm.h) : "",
|
|
331
|
+
fragile: cat.productDefaults?.fragile ?? false,
|
|
115
332
|
});
|
|
333
|
+
setFaqs((cat.faqs ?? []).map((f) => ({ ...f })));
|
|
334
|
+
setSpecs((cat.productDefaults?.specifications ?? []).map((s) => ({ ...s })));
|
|
335
|
+
setTemplates((cat.descriptionTemplates ?? []).map((t) => ({
|
|
336
|
+
id: t.id,
|
|
337
|
+
label: t.label,
|
|
338
|
+
variant: t.variant,
|
|
339
|
+
body: t.body,
|
|
340
|
+
})));
|
|
116
341
|
}, [categoryQuery.data]);
|
|
117
342
|
const saveMutation = useApiMutation({
|
|
118
343
|
errorMessage: "Failed to save category.",
|
|
@@ -128,7 +353,25 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
128
353
|
display: { showInMenu: values.showInMenu ?? true },
|
|
129
354
|
};
|
|
130
355
|
if (isEdit) {
|
|
131
|
-
|
|
356
|
+
/*
|
|
357
|
+
* 🛑 The content fields go on the PUT only, and that is not a
|
|
358
|
+
* limitation to work around.
|
|
359
|
+
*
|
|
360
|
+
* `POST /api/admin/categories`' schema is a `z.object()` naming nine
|
|
361
|
+
* keys, and Zod STRIPS unknown ones — so sending a description
|
|
362
|
+
* template on create would return 200, show a success toast and write
|
|
363
|
+
* nothing (Root Cause #76b, the admin grouped-listings shape). The
|
|
364
|
+
* section renders only in edit mode so the form never shows a field it
|
|
365
|
+
* is about to discard.
|
|
366
|
+
*
|
|
367
|
+
* It is also the right workflow: creating a category is naming it and
|
|
368
|
+
* placing it in the tree. Writing its SEO body and its five
|
|
369
|
+
* description templates is a separate sitting.
|
|
370
|
+
*/
|
|
371
|
+
return apiClient.put(ADMIN_ENDPOINTS.CATEGORY_BY_ID(categoryId), {
|
|
372
|
+
...payload,
|
|
373
|
+
...buildContentPayload(values, faqs, specs, templates, categoryQuery.data?.productDefaults?.["priceGuidance"]),
|
|
374
|
+
});
|
|
132
375
|
}
|
|
133
376
|
return apiClient.post(ADMIN_ENDPOINTS.CATEGORIES, payload);
|
|
134
377
|
},
|
|
@@ -178,7 +421,30 @@ export function AdminCategoryEditorView({ categoryId, onSaved, onDeleted, embedd
|
|
|
178
421
|
fields: ["order", "isActive", "showInMenu"],
|
|
179
422
|
render: ({ values: v, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Input, { label: "Display order", value: String(v.order ?? ""), onChange: (e) => onChange({ order: e.target.value }), type: "number", min: 0, placeholder: "0", error: errors.order }), _jsx(Toggle, { label: "Active", checked: v.isActive ?? true, onChange: (checked) => onChange({ isActive: checked }) }), _jsx(Toggle, { label: "Show in menu", checked: v.showInMenu ?? true, onChange: (checked) => onChange({ showInMenu: checked }) })] })),
|
|
180
423
|
},
|
|
181
|
-
|
|
424
|
+
/*
|
|
425
|
+
* 🛑 EDIT ONLY. The create route's `z.object()` would strip every one of
|
|
426
|
+
* these and answer 200, so the form must not offer them yet. See
|
|
427
|
+
* buildContentPayload's header.
|
|
428
|
+
*/
|
|
429
|
+
...(isEdit
|
|
430
|
+
? [
|
|
431
|
+
{
|
|
432
|
+
id: "content",
|
|
433
|
+
label: "Content & Templates",
|
|
434
|
+
fields: ["contentBody", "highlightsText"],
|
|
435
|
+
render: ({ values: v, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Category page body" }), _jsx(Textarea, { value: String(v.contentBody ?? ""), onChange: (e) => onChange({ contentBody: e.target.value }), rows: 8, placeholder: "400\u2013900 characters of keyword-rich HTML with links to sibling and parent categories\u2026" }), errors.contentBody ? (_jsx(Text, { size: "sm", variant: "error", role: "alert", children: errors.contentBody })) : (_jsx(Text, { size: "xs", color: "muted", children: "Rendered on the category page. Distinct from the short Description above, which also feeds the SEO meta and every card. 36 of worldhobbyshop's 46 categories carry one of these and it is their actual ranking mechanism; no Shopify competitor in the crawl has any." }))] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Highlights \u2014 one per line" }), _jsx(Textarea, { value: String(v.highlightsText ?? ""), onChange: (e) => onChange({ highlightsText: e.target.value }), rows: 4, placeholder: "Genuine Takara Tomy stock\nChecked and photographed before listing" }), _jsx(Text, { size: "xs", color: "muted", children: "Short \"why shop this\" bullets, shown above the FAQs. Blank lines are dropped. Up to 12." })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Category FAQs" }), _jsx(Text, { size: "xs", color: "muted", children: "Rendered as an accordion on the category page and emitted as FAQPage structured data. Keep platform-wide answers in the global FAQ collection instead \u2014 two copies of one answer means the one that gets fixed is never the one being read." }), _jsx(Repeater, { rows: faqs, onChange: setFaqs, blank: () => ({ question: "", answer: "" }), addLabel: "Add an FAQ", max: 20, renderRow: (row, set) => (_jsxs(_Fragment, { children: [_jsx(Input, { label: "Question", value: row.question, onChange: (e) => set({ question: e.target.value }), placeholder: "Are these compatible with Burst launchers?" }), _jsx(Textarea, { value: row.answer, onChange: (e) => set({ answer: e.target.value }), rows: 3, placeholder: "Answer\u2026" })] })) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Description templates" }), _jsxs(Text, { size: "xs", color: "muted", children: ["What the seller form offers when a listing is filed here. One per condition band \u2014 worldhobbyshop reuses exactly two across 1,195 listings. Slots like", " ", _jsx(Span, { family: "mono", children: "{{title}}" }), ",", " ", _jsx(Span, { family: "mono", children: "{{brand}}" }), ",", " ", _jsx(Span, { family: "mono", children: "{{condition}}" }), " and", " ", _jsx(Span, { family: "mono", children: "{{series}}" }), " are filled from the listing; an unresolved one becomes empty and is reported to the seller, never left as a literal brace pair."] }), _jsx(Repeater, { rows: templates, onChange: setTemplates, blank: () => ({ id: "", label: "", variant: "default", body: "" }), addLabel: "Add a template", max: 8, renderRow: (row, set) => (_jsxs(_Fragment, { children: [_jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2", children: [_jsx(Input, { label: "Label", value: row.label, onChange: (e) => set({ label: e.target.value }), placeholder: "Pre-owned \u2014 condition as pictured" }), _jsx(Select, { label: "Condition band", value: row.variant, onChange: (e) => set({ variant: e.target.value }), options: TEMPLATE_VARIANT_OPTIONS })] }), _jsx(Textarea, { value: row.body, onChange: (e) => set({ body: e.target.value }), rows: 6, placeholder: "Important things to know about {{title}} \u2014 HTML permitted\u2026" }), _jsx(Text, { size: "xs", color: "muted", children: usedPlaceholders(row.body).length > 0
|
|
436
|
+
? `Slots used: ${usedPlaceholders(row.body).map((p) => `{{${p}}}`).join(", ")}`
|
|
437
|
+
: "No slots used — this body will read identically on every listing." })] })) })] })] })),
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
id: "defaults",
|
|
441
|
+
label: "Product defaults",
|
|
442
|
+
fields: ["taxCodeId", "weightG", "dimL", "dimW", "dimH", "seoTitleTemplate", "seoDescriptionTemplate"],
|
|
443
|
+
render: ({ values: v, onChange, errors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Text, { size: "xs", color: "muted", children: "What a listing filed under this category inherits. Written server-side when the product omits the field, and resolved PER FIELD up the ancestor chain \u2014 so a named-model leaf can supply its own features while the tax code and spec rows come from the line above it." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Tax code" }), _jsx(PaginatedSelect, { value: String(v.taxCodeId ?? "") || null, onChange: (val) => onChange({ taxCodeId: val ?? "" }), loadOptions: loadTaxCodeOptions, placeholder: "Search tax codes\u2026", searchPlaceholder: "HSN or label\u2026", noResultsText: "No tax codes found", ariaLabel: "Tax code" }), _jsx(Text, { size: "xs", color: "muted", children: "\uD83D\uDED1 This is the GST mechanism, not a nicety. A listing whose category resolves no tax code falls back to a default rate and is flagged for triage \u2014 and the measure of success is that the fallback fires for nothing." })] }), _jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2", children: [_jsx(Select, { label: "Default condition", value: String(v.defaultCondition ?? ""), onChange: (e) => onChange({ defaultCondition: e.target.value }), options: CONDITION_DEFAULT_OPTIONS }), _jsx(Select, { label: "Default authenticity", value: String(v.defaultAuthenticity ?? ""), onChange: (e) => onChange({ defaultAuthenticity: e.target.value }), options: AUTHENTICITY_OPTIONS })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Specification rows" }), _jsx(Repeater, { rows: specs, onChange: setSpecs, blank: () => ({ name: "", value: "", unit: "" }), addLabel: "Add a specification", max: 20, renderRow: (row, set) => (_jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-3", children: [_jsx(Input, { label: "Name", value: row.name, onChange: (e) => set({ name: e.target.value }), placeholder: "System" }), _jsx(Input, { label: "Value", value: row.value, onChange: (e) => set({ value: e.target.value }), placeholder: "Magne Core" }), _jsx(Input, { label: "Unit", value: row.unit ?? "", onChange: (e) => set({ unit: e.target.value }), placeholder: "g" })] })) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "What's in the box \u2014 one per line" }), _jsx(Textarea, { value: String(v.inTheBoxText ?? ""), onChange: (e) => onChange({ inTheBoxText: e.target.value }), rows: 4, placeholder: "Beyblade top\nLauncher\nRipcord\nManual" }), _jsx(Text, { size: "xs", color: "muted", children: "Read by the description-template endpoint to fill the new-in-box body's list. Deliberately NOT written to the product's `features` field, which holds feature ids \u2014 putting prose there would break every facet on the listing." })] }), _jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-2", children: [_jsx(Input, { label: "SEO title template", value: String(v.seoTitleTemplate ?? ""), onChange: (e) => onChange({ seoTitleTemplate: e.target.value }), placeholder: "{{title}} \u2014 {{brand}} | {{siteName}}", error: errors.seoTitleTemplate }), _jsx(Input, { label: "SEO description template", value: String(v.seoDescriptionTemplate ?? ""), onChange: (e) => onChange({ seoDescriptionTemplate: e.target.value }), placeholder: "Buy {{title}} in India\u2026", error: errors.seoDescriptionTemplate })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "SEO keywords \u2014 one per line" }), _jsx(Textarea, { value: String(v.seoKeywordsText ?? ""), onChange: (e) => onChange({ seoKeywordsText: e.target.value }), rows: 3 })] }), _jsxs(Div, { layout: "grid", gap: "4", className: "sm:grid-cols-4", children: [_jsx(Input, { label: "Packed weight (g)", value: String(v.weightG ?? ""), onChange: (e) => onChange({ weightG: e.target.value }), placeholder: "300", error: errors.weightG, helperText: "Boxed, not the bare item." }), _jsx(Input, { label: "Length (cm)", value: String(v.dimL ?? ""), onChange: (e) => onChange({ dimL: e.target.value }), error: errors.dimL }), _jsx(Input, { label: "Width (cm)", value: String(v.dimW ?? ""), onChange: (e) => onChange({ dimW: e.target.value }), error: errors.dimW }), _jsx(Input, { label: "Height (cm)", value: String(v.dimH ?? ""), onChange: (e) => onChange({ dimH: e.target.value }), error: errors.dimH })] }), _jsx(Text, { size: "xs", color: "muted", children: "\uD83D\uDED1 The weight is a PACKED weight \u2014 a bey is ~30 g and ~300 g boxed. This figure drives the shipping estimate the buyer pays, and the seller's booking is capped at what was collected, so an item weight here under-estimates every booking and the difference is absorbed silently. All three dimensions or none." }), _jsx(Toggle, { label: "Fragile by default", checked: v.fragile === true, onChange: (checked) => onChange({ fragile: checked }) })] })),
|
|
444
|
+
},
|
|
445
|
+
]
|
|
446
|
+
: []),
|
|
447
|
+
], [isEdit, faqs, specs, templates]);
|
|
182
448
|
const nav = useSectionFormNav(sections, values);
|
|
183
449
|
const { shellCtx } = useFormShellState(categoryFormSchema, {
|
|
184
450
|
sections: nav.sectionMeta,
|
|
@@ -205,7 +205,14 @@ export function AdminProductEditorView({ productId, onSaved, onDeleted, embedded
|
|
|
205
205
|
label: "Details",
|
|
206
206
|
required: true,
|
|
207
207
|
fields: ["title", "price", "storeId", "categorySlug", "brandSlug"],
|
|
208
|
-
render: () => (_jsx(Stack, { gap: "md", children: _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Classification" }), _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: "Store" }), _jsx(PaginatedSelect, { value: product.storeId ?? null, onChange: handleStoreSelect, loadOptions: loadStoreOptions, placeholder: "Search stores\u2026", searchPlaceholder: "Type store name\u2026", noResultsText: "No stores found", ariaLabel: "Store" })] }), _jsx(ProductForm, { product: product, onChange: setProduct,
|
|
208
|
+
render: () => (_jsx(Stack, { gap: "md", children: _jsxs(Card, { variant: "outlined", padding: "lg", children: [_jsx(Text, { className: __P.sectionLabel, color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Classification" }), _jsxs(Stack, { gap: "md", children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: "Store" }), _jsx(PaginatedSelect, { value: product.storeId ?? null, onChange: handleStoreSelect, loadOptions: loadStoreOptions, placeholder: "Search stores\u2026", searchPlaceholder: "Type store name\u2026", noResultsText: "No stores found", ariaLabel: "Store" })] }), _jsx(ProductForm, { product: product, onChange: setProduct,
|
|
209
|
+
/*
|
|
210
|
+
* The ONLY place the Tax & GST section is shown. LetItRip
|
|
211
|
+
* invoices under its own GSTIN, so the rate is an admin
|
|
212
|
+
* determination — the seller forms deliberately omit it and
|
|
213
|
+
* let `deriveTaxonomy` resolve it from the category.
|
|
214
|
+
*/
|
|
215
|
+
canEditTax: true, renderGroupSettings: isEdit && productId
|
|
209
216
|
? (p) => (_jsx(GroupSettingsPanel, { productId: productId, productSlug: p.slug ?? productId, groupId: p.groupId, isGroupParent: p.isGroupParent, groupParentSlug: p.groupParentSlug, groupChildSlugs: p.groupChildSlugs, groupTitle: p.groupTitle, isAuction: modeFromProduct(p) === "auction", storeProductsEndpoint: ADMIN_ENDPOINTS.PRODUCTS, onGroupChanged: () => productQuery.refetch() }))
|
|
210
217
|
: undefined, renderGroupJoinField: ({ label, value, onChange, disabled }) => (_jsx(GroupInlineSelect, { scope: "admin", value: value ?? "", onChange: (id) => onChange(id || undefined), disabled: disabled, label: label, placeholder: "None (standalone product)", allowCreate: false })), renderCategorySelector: ({ label, value, onChange, disabled }) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: label }), _jsx(PaginatedSelect, { value: value || null, onChange: (v) => onChange(v ?? ""), loadOptions: loadCategoryOptions, placeholder: "Search categories\u2026", searchPlaceholder: "Type category name\u2026", noResultsText: "No categories found", ariaLabel: label, disabled: disabled, createLabel: "Category", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(CategoryQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })), renderBrandSelector: (args) => (_jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: args.label }), _jsx(PaginatedSelect, { value: args.multi ? null : (args.value || null), onChange: (v) => args.onValueChange(v ?? ""), loadOptions: loadBrandOptions, placeholder: "Search brands\u2026", searchPlaceholder: "Type brand name\u2026", noResultsText: "No brands found", ariaLabel: args.label, disabled: args.disabled, createLabel: "Brand", renderCreateForm: ({ onCreated, onCancel }) => (_jsx(BrandQuickCreateForm, { onSaved: (id, name) => onCreated({ value: id, label: name }), onCancel: onCancel })) })] })) })] })] }) })),
|
|
211
218
|
},
|
|
@@ -72,6 +72,60 @@ export function AdminProductsView({ children, ...props }) {
|
|
|
72
72
|
showToast(err?.message ?? "Failed to update product.", "error");
|
|
73
73
|
}
|
|
74
74
|
}, [showToast, overrides]);
|
|
75
|
+
/** Flip one flag across a selection, optimistically, in chunks. */
|
|
76
|
+
const setFlagOverrides = useCallback((ids, field, value) => {
|
|
77
|
+
setOverrides((o) => {
|
|
78
|
+
const next = { ...o };
|
|
79
|
+
for (const id of ids)
|
|
80
|
+
next[id] = { ...next[id], [field]: value };
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
}, []);
|
|
84
|
+
const handleBulkToggle = useCallback(async (ids, field, value) => {
|
|
85
|
+
setFlagOverrides(ids, field, value);
|
|
86
|
+
/*
|
|
87
|
+
* Chunked at the route's own bound.
|
|
88
|
+
*
|
|
89
|
+
* Select-all takes the CURRENT PAGE (`setSelectedIds(rows.map(...))` in
|
|
90
|
+
* DataListingView) and `pageSize` is user-settable from the pagination
|
|
91
|
+
* control, so a selection larger than the route's `max(50)` is
|
|
92
|
+
* reachable — sending it whole would 400 the entire batch on a Zod
|
|
93
|
+
* error. Chunking keeps the server's bound honest without making the UI
|
|
94
|
+
* enforce a number it does not own.
|
|
95
|
+
*
|
|
96
|
+
* Still a collapse, not a fan-out: 200 rows is 4 sequential requests
|
|
97
|
+
* rather than the 200 concurrent ones this replaced.
|
|
98
|
+
*/
|
|
99
|
+
const CHUNK = 50;
|
|
100
|
+
let updated = 0;
|
|
101
|
+
let failedCount = 0;
|
|
102
|
+
const failedIds = [];
|
|
103
|
+
try {
|
|
104
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
105
|
+
const part = await apiClient.post(ADMIN_ENDPOINTS.PRODUCTS_BULK, { field, value, ids: ids.slice(i, i + CHUNK) });
|
|
106
|
+
updated += part?.summary?.updated ?? 0;
|
|
107
|
+
failedCount += part?.summary?.failed ?? 0;
|
|
108
|
+
for (const f of part?.failed ?? [])
|
|
109
|
+
failedIds.push(f.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
void normalizeError(err);
|
|
114
|
+
// The request itself failed — roll every row back, including any
|
|
115
|
+
// earlier chunk, because the admin cannot tell which landed.
|
|
116
|
+
setFlagOverrides(ids, field, !value);
|
|
117
|
+
showToast(err?.message ?? "Bulk update failed.", "error");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (failedIds.length > 0) {
|
|
121
|
+
// Roll back ONLY the rows the server named, so the ones that
|
|
122
|
+
// succeeded keep their new state.
|
|
123
|
+
setFlagOverrides(failedIds, field, !value);
|
|
124
|
+
showToast(`Updated ${updated} of ${ids.length}; ${failedCount} failed.`, "warning");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
showToast(`Updated ${updated} product${updated === 1 ? "" : "s"}.`, "success");
|
|
128
|
+
}, [setFlagOverrides, showToast]);
|
|
75
129
|
const handleQuickEdit = useCallback(async (id, values) => {
|
|
76
130
|
const prev = overrides[id];
|
|
77
131
|
setOverrides((o) => ({ ...o, [id]: { ...o[id], ...values } }));
|
|
@@ -202,13 +256,30 @@ export function AdminProductsView({ children, ...props }) {
|
|
|
202
256
|
[ROW_ACTION_ID.PROMOTE]: "isPromoted",
|
|
203
257
|
[ROW_ACTION_ID.SALE]: "isOnSale",
|
|
204
258
|
};
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
259
|
+
/*
|
|
260
|
+
* 🛑 ONE request, not one per row.
|
|
261
|
+
*
|
|
262
|
+
* This was `selectedIds.forEach((rowId) => toggleField(rowId, field))`
|
|
263
|
+
* — a fire-and-forget `forEach` of single-row PATCHes. Selecting 200
|
|
264
|
+
* products and clicking Feature fired **200 concurrent requests**, each
|
|
265
|
+
* its own function invocation, with `clearSelection()` running
|
|
266
|
+
* immediately so nothing reported what actually happened. A row that
|
|
267
|
+
* failed raised its own toast, up to 200 of them.
|
|
268
|
+
*
|
|
269
|
+
* The bulk route returns `{updated, failed, summary}`, so a partial
|
|
270
|
+
* failure is now a single message naming the count instead of silence.
|
|
271
|
+
* Selection is cleared only AFTER the request resolves — clearing first
|
|
272
|
+
* is what made it look finished the instant it was clicked.
|
|
273
|
+
*
|
|
274
|
+
* The optimistic `overrides` are still applied per row so the switches
|
|
275
|
+
* move immediately; a failure rolls back only the ids the server names.
|
|
276
|
+
*/
|
|
277
|
+
const bulkToggle = (field) => async () => {
|
|
278
|
+
const ids = [...selection.selectedIds];
|
|
279
|
+
if (ids.length === 0)
|
|
280
|
+
return;
|
|
281
|
+
const value = !selection.rows.find((r) => r.id === ids[0])?.[field];
|
|
282
|
+
await handleBulkToggle(ids, field, value);
|
|
212
283
|
selection.clearSelection();
|
|
213
284
|
};
|
|
214
285
|
return ADMIN_BULK_ACTIONS.products.map((id) => ({
|