@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.
Files changed (220) hide show
  1. package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
  2. package/dist/_internal/server/features/categories/adapters.js +235 -0
  3. package/dist/_internal/server/features/checkout/actions.js +161 -22
  4. package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
  5. package/dist/_internal/server/features/orders/adapters.js +21 -0
  6. package/dist/_internal/server/features/refunds/actions.js +1 -1
  7. package/dist/_internal/server/features/seo/sitemap.js +54 -15
  8. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  9. package/dist/_internal/server/features/stores/adapters.js +6 -0
  10. package/dist/_internal/server/functions/scheduled.d.ts +4 -2
  11. package/dist/_internal/server/functions/scheduled.js +64 -6
  12. package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
  13. package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
  14. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
  15. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
  16. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
  17. package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
  18. package/dist/_internal/server/jobs/core/onCategoryWrite.js +54 -0
  19. package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
  20. package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
  21. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
  22. package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
  23. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
  24. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
  25. package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
  26. package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
  27. package/dist/_internal/server/jobs/handlers/index.js +3 -1
  28. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
  29. package/dist/_internal/server/jobs/handlers/messages.js +5 -2
  30. package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
  31. package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
  32. package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
  33. package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
  34. package/dist/_internal/shared/actions/action-registry.js +34 -0
  35. package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
  36. package/dist/_internal/shared/checkout/order-math.js +194 -7
  37. package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
  38. package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
  39. package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
  40. package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
  41. package/dist/_internal/shared/fees/calculator.d.ts +105 -0
  42. package/dist/_internal/shared/fees/calculator.js +143 -0
  43. package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
  44. package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
  45. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
  46. package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
  47. package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
  48. package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
  49. package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
  50. package/dist/_internal/shared/templating/placeholders.js +73 -0
  51. package/dist/client.d.ts +2 -0
  52. package/dist/client.js +1 -0
  53. package/dist/constants/api-endpoints.d.ts +42 -0
  54. package/dist/constants/api-endpoints.js +14 -0
  55. package/dist/features/admin/actions/admin-actions.d.ts +1 -1
  56. package/dist/features/admin/actions/admin-actions.js +1 -1
  57. package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
  58. package/dist/features/admin/components/AdminProductEditorView.js +8 -1
  59. package/dist/features/admin/components/AdminProductsView.js +78 -7
  60. package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
  61. package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
  62. package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
  63. package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
  64. package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
  65. package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
  66. package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
  67. package/dist/features/auth/schemas/firestore.d.ts +0 -1
  68. package/dist/features/blog/schemas/firestore.d.ts +2 -2
  69. package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
  70. package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
  71. package/dist/features/categories/api/[id]/description-template/route.js +252 -0
  72. package/dist/features/categories/api/route.js +156 -11
  73. package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
  74. package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
  75. package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
  76. package/dist/features/categories/constants/listing-categories.d.ts +17 -0
  77. package/dist/features/categories/constants/listing-categories.js +53 -0
  78. package/dist/features/categories/hooks/useCategoryTree.js +28 -2
  79. package/dist/features/categories/repository/categories.repository.d.ts +62 -11
  80. package/dist/features/categories/repository/categories.repository.js +178 -35
  81. package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
  82. package/dist/features/categories/repository/price-index.repository.js +85 -0
  83. package/dist/features/categories/schemas/category-content.d.ts +206 -0
  84. package/dist/features/categories/schemas/category-content.js +28 -0
  85. package/dist/features/categories/schemas/firestore.d.ts +74 -5
  86. package/dist/features/categories/schemas/firestore.js +74 -3
  87. package/dist/features/categories/schemas/index.d.ts +1047 -0
  88. package/dist/features/categories/schemas/index.js +96 -0
  89. package/dist/features/categories/server.d.ts +1 -0
  90. package/dist/features/categories/server.js +7 -0
  91. package/dist/features/categories/types/index.d.ts +9 -0
  92. package/dist/features/events/schemas/firestore.d.ts +2 -2
  93. package/dist/features/faq/schemas/firestore.d.ts +2 -1
  94. package/dist/features/faq/schemas/firestore.js +20 -8
  95. package/dist/features/orders/schemas/firestore.d.ts +70 -0
  96. package/dist/features/orders/types/index.d.ts +55 -0
  97. package/dist/features/products/columns/index.d.ts +0 -1
  98. package/dist/features/products/columns/index.js +5 -1
  99. package/dist/features/products/components/ProductFilters.d.ts +0 -9
  100. package/dist/features/products/components/ProductForm.d.ts +27 -1
  101. package/dist/features/products/components/ProductForm.js +26 -12
  102. package/dist/features/products/constants/sieve.d.ts +0 -6
  103. package/dist/features/products/constants/sieve.js +35 -2
  104. package/dist/features/products/repository/products.repository.d.ts +115 -1
  105. package/dist/features/products/repository/products.repository.js +491 -39
  106. package/dist/features/products/schemas/firestore.d.ts +65 -5
  107. package/dist/features/products/schemas/firestore.js +9 -0
  108. package/dist/features/products/schemas/product-features.d.ts +140 -2
  109. package/dist/features/products/schemas/product-features.js +62 -1
  110. package/dist/features/promotions/repository/coupons.repository.js +6 -1
  111. package/dist/features/promotions/schemas/firestore.d.ts +44 -1
  112. package/dist/features/promotions/schemas/firestore.js +76 -2
  113. package/dist/features/seller/actions/seller-actions.js +17 -2
  114. package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
  115. package/dist/features/seller/components/PrintCenterView.js +3 -2
  116. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
  117. package/dist/features/seller/components/SellerOrdersView.js +6 -1
  118. package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
  119. package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
  120. package/dist/features/seller/components/SellerProductShell.js +101 -9
  121. package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
  122. package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
  123. package/dist/features/seller/components/SellerProductsView.js +108 -3
  124. package/dist/features/seller/types/index.d.ts +17 -0
  125. package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
  126. package/dist/features/seller/utils/product-draft-mapping.js +17 -0
  127. package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
  128. package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
  129. package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
  130. package/dist/features/stores/schemas/firestore.d.ts +31 -2
  131. package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
  132. package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
  133. package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
  134. package/dist/features/tax-codes/schemas/firestore.js +82 -0
  135. package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
  136. package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
  137. package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
  138. package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
  139. package/dist/features/tester/actions/index.d.ts +0 -1
  140. package/dist/features/tester/actions/index.js +0 -1
  141. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
  142. package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
  143. package/dist/features/tester/components/index.d.ts +0 -4
  144. package/dist/features/tester/components/index.js +0 -2
  145. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
  146. package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
  147. package/dist/features/tester/schemas/firestore.d.ts +0 -6
  148. package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
  149. package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
  150. package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
  151. package/dist/features/tester/seed-data/authored/index.js +0 -2
  152. package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
  153. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +8 -27
  154. package/dist/index.d.ts +21 -7
  155. package/dist/index.js +53 -7
  156. package/dist/next/routing/route-map.d.ts +2 -4
  157. package/dist/next/routing/route-map.js +1 -2
  158. package/dist/repositories/index.d.ts +1 -0
  159. package/dist/repositories/index.js +1 -0
  160. package/dist/schemas/registry.d.ts +506 -0
  161. package/dist/seed/_helpers/brand-rows.d.ts +35 -0
  162. package/dist/seed/_helpers/brand-rows.js +576 -0
  163. package/dist/seed/_helpers/category-content.d.ts +39 -0
  164. package/dist/seed/_helpers/category-content.js +634 -0
  165. package/dist/seed/_helpers/category-forest.d.ts +2 -0
  166. package/dist/seed/_helpers/category-forest.js +585 -243
  167. package/dist/seed/_helpers/category-models.d.ts +5 -0
  168. package/dist/seed/_helpers/category-models.js +1364 -0
  169. package/dist/seed/_helpers/category-tree.js +17 -1
  170. package/dist/seed/_helpers/character-features.d.ts +10 -0
  171. package/dist/seed/_helpers/character-features.js +118 -0
  172. package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
  173. package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
  174. package/dist/seed/categories-seed-data.js +147 -169
  175. package/dist/seed/index.d.ts +1 -0
  176. package/dist/seed/index.js +1 -0
  177. package/dist/seed/manifest.d.ts +1 -1
  178. package/dist/seed/manifest.js +5 -0
  179. package/dist/seed/product-features-seed-data.d.ts +1 -8
  180. package/dist/seed/product-features-seed-data.js +358 -154
  181. package/dist/seed/products-art-seed-data.js +16 -4
  182. package/dist/seed/products-auctions-seed-data.js +6 -6
  183. package/dist/seed/products-classifieds-seed-data.js +17 -7
  184. package/dist/seed/products-digital-codes-seed-data.js +12 -12
  185. package/dist/seed/products-preorders-seed-data.js +8 -8
  186. package/dist/seed/products-prize-draws-seed-data.js +25 -8
  187. package/dist/seed/products-standard-seed-data.js +10 -60
  188. package/dist/seed/products-stickers-seed-data.js +6 -6
  189. package/dist/seed/site-settings-seed-data.js +24 -0
  190. package/dist/seed/store-extensions-seed-data.js +10 -4
  191. package/dist/seed/tax-codes-seed-data.d.ts +34 -0
  192. package/dist/seed/tax-codes-seed-data.js +100 -0
  193. package/dist/seed/test-data-ttl.d.ts +29 -0
  194. package/dist/seed/test-data-ttl.js +31 -0
  195. package/dist/server-entry.d.ts +1 -1
  196. package/dist/server-entry.js +6 -1
  197. package/dist/server.d.ts +2 -1
  198. package/dist/server.js +5 -2
  199. package/dist/styles.css +40 -0
  200. package/dist/ui/components/PaginatedSelect.d.ts +14 -0
  201. package/dist/ui/components/PaginatedSelect.js +0 -0
  202. package/dist/ui/components/PaginatedSelect.style.css +40 -0
  203. package/dist/utils/physical-location.d.ts +23 -0
  204. package/dist/utils/physical-location.js +52 -0
  205. package/dist/utils/search-txt-builders.d.ts +20 -0
  206. package/dist/utils/search-txt-builders.js +36 -0
  207. package/package.json +1 -1
  208. package/scripts/seed-cli.mjs +4 -1
  209. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
  210. package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
  211. package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
  212. package/dist/features/tester/actions/leaderboard-actions.js +0 -5
  213. package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
  214. package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
  215. package/dist/features/tester/components/TesterHubView.d.ts +0 -5
  216. package/dist/features/tester/components/TesterHubView.js +0 -110
  217. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
  218. package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
  219. package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
  220. 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 testerSandboxCleanup already documents for why it leaves
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 testerSandboxCleanup already documents for why it leaves
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
- return apiClient.put(ADMIN_ENDPOINTS.CATEGORY_BY_ID(categoryId), payload);
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, renderGroupSettings: isEdit && productId
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
- const toggleField = (rowId, field) => {
206
- const row = selection.rows.find((r) => r.id === rowId);
207
- if (row)
208
- void handleToggle(rowId, field, !row[field]);
209
- };
210
- const bulkToggle = (field) => () => {
211
- selection.selectedIds.forEach((rowId) => toggleField(rowId, field));
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) => ({