@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
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useApiMutation } from "@mohasinac/appkit/client";
4
4
  import React, { useState, useCallback } from "react";
5
+ import { alertMetricsForScope } from "../../store-extensions/constants/alert-metrics";
5
6
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
7
  import { Alert, Badge, Button, Div, Heading, Row, Section, Stack, Text, Toggle, useToast } from "../../../ui";
7
8
  import { apiClient } from "../../../http";
@@ -39,15 +40,24 @@ const OPERATOR_OPTIONS = ANALYTICS_ALERT_OPERATORS.map((value) => ({
39
40
  value,
40
41
  label: OPERATOR_LABELS[value] ?? value,
41
42
  }));
42
- const METRIC_OPTIONS = [
43
- { value: "daily_revenue", label: "Daily Revenue (₹)" },
44
- { value: "daily_orders", label: "Daily Orders" },
45
- { value: "low_stock", label: "Low Stock (products)" },
46
- { value: "cancelled_orders", label: "Cancelled Orders" },
47
- { value: "pending_payouts", label: "Pending Payouts (₹)" },
48
- { value: "avg_rating", label: "Average Rating" },
49
- { value: "reviews_count", label: "New Reviews" },
50
- ];
43
+ /*
44
+ * 🛑 DERIVED, never a hand-written list — that list WAS the defect.
45
+ *
46
+ * These seven values (daily_revenue, daily_orders, low_stock,
47
+ * cancelled_orders, pending_payouts, avg_rating, reviews_count) shared ZERO
48
+ * tokens with the four the seed wrote, and nothing validated either because
49
+ * `metric` was `string`. It stayed invisible because no evaluator read the
50
+ * field at all, so an alert a seller configured could never fire.
51
+ *
52
+ * Now from `alertMetricsForScope("seller")`, which the evaluator switches
53
+ * over exhaustively — so a metric the job cannot compute is not offerable.
54
+ */
55
+ const METRIC_OPTIONS = alertMetricsForScope("seller").map(({ value, def }) => ({
56
+ value,
57
+ label: def.label,
58
+ }));
59
+ /** Helper text per metric, so "Orders in the window" says what it counts. */
60
+ const METRIC_DESCRIPTIONS = Object.fromEntries(alertMetricsForScope("seller").map(({ value, def }) => [value, def.describes]));
51
61
  const WINDOW_OPTIONS = [
52
62
  { value: "1", label: "Last 1 hour" },
53
63
  { value: "6", label: "Last 6 hours" },
@@ -74,11 +84,11 @@ function AlertCard({ alert, onToggle, onDelete, }) {
74
84
  const metricLabel = METRIC_OPTIONS.find((m) => m.value === alert.metric)?.label ?? alert.metric;
75
85
  const opLabel = OPERATOR_OPTIONS.find((o) => o.value === alert.operator)?.label ?? alert.operator;
76
86
  const windowLabel = WINDOW_OPTIONS.find((w) => w.value === String(alert.windowHours))?.label ?? `${alert.windowHours}h`;
77
- return (_jsxs(Row, { surface: "card", padding: "sm", align: "start", justify: "between", gap: "md", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [_jsxs(Row, { className: "mb-1", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: alert.label }), _jsx(Badge, { variant: alert.isActive ? "success" : "default", children: alert.isActive ? "Active" : "Paused" })] }), _jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: [metricLabel, " ", opLabel, " ", alert.threshold, " \u00B7 ", windowLabel] }), _jsx(Row, { gap: "xs", wrap: true, children: alert.notifyChannels.map((ch) => (_jsx(Badge, { variant: OPERATOR_BADGE[alert.operator] ?? "default", children: ch }, ch))) }), alert.lastTriggeredAt && (_jsxs(Text, { className: "mt-1", color: "faint", size: "xs", children: ["Last triggered: ", new Date(alert.lastTriggeredAt).toLocaleString("en-IN")] }))] }), _jsxs(Row, { className: "shrink-0", align: "center", gap: "sm", children: [_jsx(Toggle, { checked: alert.isActive, onChange: (v) => onToggle(alert.id, v), size: "sm" }), _jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-analytics-alert"], onClick: () => onDelete(alert.id), className: "text-error hover:text-error" })] })] }));
87
+ return (_jsxs(Row, { surface: "card", padding: "sm", align: "start", justify: "between", gap: "md", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [_jsxs(Row, { className: "mb-1", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: alert.label }), _jsx(Badge, { variant: alert.isActive ? "success" : "default", children: alert.isActive ? "Active" : "Paused" })] }), _jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: [metricLabel, " ", opLabel, " ", alert.threshold, " \u00B7 ", windowLabel] }), METRIC_DESCRIPTIONS[alert.metric] ? (_jsx(Text, { className: "mb-1", color: "muted", size: "xs", children: METRIC_DESCRIPTIONS[alert.metric] })) : null, alert.lastTriggeredAt ? (_jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: ["Last fired ", new Date(alert.lastTriggeredAt).toLocaleString(), alert.lastObservedValue != null ? ` at ${alert.lastObservedValue}` : ""] })) : null, _jsx(Row, { gap: "xs", wrap: true, children: alert.notifyChannels.map((ch) => (_jsx(Badge, { variant: OPERATOR_BADGE[alert.operator] ?? "default", children: ch }, ch))) }), alert.lastTriggeredAt && (_jsxs(Text, { className: "mt-1", color: "faint", size: "xs", children: ["Last triggered: ", new Date(alert.lastTriggeredAt).toLocaleString("en-IN")] }))] }), _jsxs(Row, { className: "shrink-0", align: "center", gap: "sm", children: [_jsx(Toggle, { checked: alert.isActive, onChange: (v) => onToggle(alert.id, v), size: "sm" }), _jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-analytics-alert"], onClick: () => onDelete(alert.id), className: "text-error hover:text-error" })] })] }));
78
88
  }
79
89
  const EMPTY_DRAFT = {
80
90
  label: "",
81
- metric: "daily_orders",
91
+ metric: "orders_count",
82
92
  operator: ">",
83
93
  threshold: "",
84
94
  windowHours: "24",
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { formatPhysicalLocation } from "../../../utils/physical-location";
3
4
  import { OrderAddonBadges } from "../../orders/components/OrderAddonBadges";
4
5
  import { normalizeError } from "../../../errors/normalize";
5
6
  import { Row, SIEVE_OP, sieveFilter } from "@mohasinac/appkit/client";
@@ -362,7 +363,11 @@ export function SellerOrdersView({ orderDetailApiBase = SELLER_ENDPOINTS.ORDERS,
362
363
  key: "physicalLocation",
363
364
  header: "Staging",
364
365
  className: "w-28",
365
- render: (row) => row.physicalLocation ? (_jsxs(Span, { size: "xs", className: "font-mono", color: "muted", children: [row.physicalLocation.zone, "/", row.physicalLocation.shelf, "/", row.physicalLocation.bin] })) : (_jsx(Span, { size: "xs", color: "muted", children: "\u2014" })),
366
+ render: (row) => {
367
+ // Shared formatter — see utils/physical-location.ts.
368
+ const loc = formatPhysicalLocation(row.physicalLocation);
369
+ return loc ? (_jsx(Span, { size: "xs", className: "font-mono", color: "muted", children: loc })) : (_jsx(Span, { size: "xs", color: "muted", children: "\u2014" }));
370
+ },
366
371
  },
367
372
  {
368
373
  key: "updatedAt",
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import { z } from "zod";
5
- import { Alert, Badge, Checkbox, Div, FormField, FormGroup, Heading, Label, Row, Stack, Text, Toggle } from "../../../ui";
5
+ import { Alert, Badge, Checkbox, Div, FormField, FormGroup, Heading, Label, Row, Span, Stack, Text, Toggle } from "../../../ui";
6
6
  import { StackedViewShell } from "../../../ui";
7
7
  import { FormShellContext, useFormShellState, applyZodIssues, FormErrorSummary } from "../../../ui/forms";
8
8
  import { SectionForm, useSectionFormNav, annotate } from "../../shell";
@@ -54,6 +54,17 @@ const payoutSettingsDraftSchema = z.object({
54
54
  }),
55
55
  minimumThreshold: annotate(z.string(), { section: "preferences", order: 2, row: "pair" }),
56
56
  emiEnabled: annotate(z.boolean(), { section: "preferences", order: 3, row: "quarter" }),
57
+ /*
58
+ * How the seller types prices. Lives in PAYOUT settings, not the store
59
+ * profile, because payout is what it actually changes: at 5% a ₹999
60
+ * inclusive listing pays out ₹951.43, while the same number typed ex-tax is
61
+ * stored as ₹1,048.95 and pays out ₹999.00.
62
+ *
63
+ * 🛑 It does NOT re-price existing listings — the gross-up happens at write
64
+ * time — so the label has to say so, or a seller flips it and assumes their
65
+ * catalogue moved.
66
+ */
67
+ pricesIncludeGst: annotate(z.boolean(), { section: "preferences", order: 4, row: "quarter" }),
57
68
  }).superRefine((v, ctx) => {
58
69
  if (v.method === "upi") {
59
70
  if (!v.upiId.trim()) {
@@ -89,6 +100,8 @@ const DEFAULT_DRAFT = {
89
100
  autoPayout: true,
90
101
  minimumThreshold: "",
91
102
  emiEnabled: false,
103
+ // Inclusive is the documented default — see StoreDocument.pricesIncludeGst.
104
+ pricesIncludeGst: true,
92
105
  };
93
106
  export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SETTINGS }) {
94
107
  const [draft, setDraft] = useState(DEFAULT_DRAFT);
@@ -119,6 +132,8 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
119
132
  ? String(res.data.preferences.minimumThreshold)
120
133
  : "",
121
134
  emiEnabled: res?.data?.emiEnabled ?? false,
135
+ // Absent means inclusive, matching the schema default exactly.
136
+ pricesIncludeGst: res?.data?.pricesIncludeGst ?? true,
122
137
  });
123
138
  })
124
139
  .catch(console.error)
@@ -146,6 +161,7 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
146
161
  const body = {
147
162
  ...methodFields,
148
163
  emiEnabled: draft.emiEnabled,
164
+ pricesIncludeGst: draft.pricesIncludeGst,
149
165
  taxInfo: {
150
166
  gstin: draft.gstin.trim(),
151
167
  pan: draft.pan.trim(),
@@ -212,8 +228,8 @@ export function SellerPayoutSettingsView({ apiBase = SELLER_ENDPOINTS.PAYOUT_SET
212
228
  {
213
229
  id: "preferences",
214
230
  label: "Preferences",
215
- fields: ["autoPayout", "minimumThreshold", "emiEnabled"],
216
- render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Payout Preferences" }), _jsx(Toggle, { checked: values.autoPayout, onChange: (checked) => onChange({ autoPayout: checked }), label: "Enable auto-payout \u2014 automatically transfer earnings on schedule", disabled: busy }), _jsx(FormField, { name: "minimumThreshold", label: "Minimum Payout Threshold (\u20B9)", type: "number", value: values.minimumThreshold, onChange: (v) => onChange({ minimumThreshold: v }), placeholder: "e.g. 500", helpText: "Payouts are held until your balance exceeds this amount. Leave blank for no minimum.", disabled: busy }), _jsxs(Div, { className: `${__P.p3} bg-[var(--appkit-color-surface-alt)] border border-[var(--appkit-color-border)]`, rounded: "lg", children: [_jsx(Text, { className: "mb-1", size: "sm", weight: "medium", children: "Payout Schedule" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Auto-payouts run every Monday for the previous week's settled orders. Manual payouts can be requested from the Payouts page at any time." })] }), _jsx(Toggle, { checked: values.emiEnabled, onChange: (checked) => onChange({ emiEnabled: checked }), label: "Offer EMI financing on eligible orders", disabled: busy }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Only shown to buyers when platform-wide EMI is also enabled and their cart subtotal from your store exceeds the site's minimum order value. Orders on an EMI plan hold your payout until every installment is paid, unless you mark the item \"ship before EMI completes\"." })] })),
231
+ fields: ["autoPayout", "minimumThreshold", "emiEnabled", "pricesIncludeGst"],
232
+ render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Payout Preferences" }), _jsx(Toggle, { checked: values.autoPayout, onChange: (checked) => onChange({ autoPayout: checked }), label: "Enable auto-payout \u2014 automatically transfer earnings on schedule", disabled: busy }), _jsx(FormField, { name: "minimumThreshold", label: "Minimum Payout Threshold (\u20B9)", type: "number", value: values.minimumThreshold, onChange: (v) => onChange({ minimumThreshold: v }), placeholder: "e.g. 500", helpText: "Payouts are held until your balance exceeds this amount. Leave blank for no minimum.", disabled: busy }), _jsxs(Div, { className: `${__P.p3} bg-[var(--appkit-color-surface-alt)] border border-[var(--appkit-color-border)]`, rounded: "lg", children: [_jsx(Text, { className: "mb-1", size: "sm", weight: "medium", children: "Payout Schedule" }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Auto-payouts run every Monday for the previous week's settled orders. Manual payouts can be requested from the Payouts page at any time." })] }), _jsx(Toggle, { checked: values.emiEnabled, onChange: (checked) => onChange({ emiEnabled: checked }), label: "Offer EMI financing on eligible orders", disabled: busy }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Only shown to buyers when platform-wide EMI is also enabled and their cart subtotal from your store exceeds the site's minimum order value. Orders on an EMI plan hold your payout until every installment is paid, unless you mark the item \"ship before EMI completes\"." }), _jsx(Toggle, { checked: values.pricesIncludeGst, onChange: (checked) => onChange({ pricesIncludeGst: checked }), label: "The prices I enter already include GST", disabled: busy }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: ["Buyers always see a tax-inclusive price, so this only changes how your own entry is read. Leave it", " ", _jsx(Span, { weight: "semibold", children: "on" }), " and a \u20B9999 listing at 5% GST means the buyer pays \u20B9999 and you earn \u20B9951.43 \u2014 the \u20B947.57 is GST we collect and remit for you. Turn it", " ", _jsx(Span, { weight: "semibold", children: "off" }), " and \u20B9999 is treated as your ex-GST price: the listing is shown at \u20B91,048.95 and you earn \u20B9999."] }), _jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "sm", children: "Changing this does not re-price listings you have already published. It applies to products you save from now on." })] })),
217
233
  },
218
234
  ], [busy, current]);
219
235
  const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, draft);
@@ -9,6 +9,35 @@ export interface SellerProductDraft {
9
9
  brand?: string;
10
10
  condition?: string;
11
11
  tags?: string[];
12
+ authenticity?: "original" | "reproduction" | "unverified";
13
+ specifications?: {
14
+ name: string;
15
+ value: string;
16
+ unit?: string;
17
+ }[];
18
+ /** Feature IDS, never prose — `array-contains` is what faceting runs on. */
19
+ features?: string[];
20
+ /**
21
+ * 🛑 OPTIONAL, on purpose, and the reason is C5a.
22
+ *
23
+ * The figure a carrier bills on is the PACKED PARCEL, not the item: a bey is
24
+ * ~30 g and ~300 g boxed. A required product weight would therefore be an
25
+ * item weight that systematically under-estimates every booking — and since
26
+ * the booking is capped at the amount collected from the buyer, systematic
27
+ * under-estimation means silently absorbing the difference.
28
+ *
29
+ * So the category's PACKED default drives the checkout estimate, this field
30
+ * refines it when a seller bothers, and the real parcel figure is entered at
31
+ * shipment time, when the seller has the box in front of them.
32
+ */
33
+ weightG?: number;
34
+ dimensionsCm?: {
35
+ l: number;
36
+ w: number;
37
+ h: number;
38
+ };
39
+ appliedTemplateId?: string;
40
+ appliedTemplateBody?: string;
12
41
  mainImage?: string;
13
42
  images?: string[];
14
43
  video?: MediaField;
@@ -121,10 +150,29 @@ export interface SellerProductShellProps {
121
150
  onChange: (v: string) => void;
122
151
  }) => React.ReactNode;
123
152
  /**
124
- * Render a template selector at the top of the Basic step.
125
- * Receives a callback to apply the selected template to the draft.
153
+ * Render the category description-template picker, between the
154
+ * Category/Condition row and the Description field.
155
+ *
156
+ * 🛑 The slot existed with ZERO consumers and a single `onApply` prop, which
157
+ * was not enough to build a picker with: it could apply a body but could not
158
+ * know WHICH body to fetch, or whether applying one would destroy something
159
+ * the seller had written. C2b widens it rather than adding a second slot.
160
+ *
161
+ * `isDescriptionDirty` is the one that matters. It is the clobber rule in a
162
+ * boolean: true when the description is non-empty after trim AND not
163
+ * byte-identical to a body applied this session. The picker must not
164
+ * overwrite when it is true — it offers a "Replace description" action
165
+ * instead, which is destructive and therefore carries a confirmation.
126
166
  */
127
167
  renderTemplateSelector?: (props: {
168
+ /** The leaf the seller has chosen. Empty until they pick one. */
169
+ categoryId: string;
170
+ /** Drives which `variant` is fetched — new_in_box vs pre_owned. */
171
+ condition: string;
172
+ /** Resolved `{{slot}}` values the picker fills the body with. */
173
+ context: Record<string, string>;
174
+ /** See above. The picker must not silently overwrite when true. */
175
+ isDescriptionDirty: boolean;
128
176
  onApply: (partial: Partial<SellerProductDraft>) => void;
129
177
  }) => React.ReactNode;
130
178
  /** Called with current draft when user clicks "Save as Template". */
@@ -14,6 +14,7 @@ import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
14
14
  import { DEFAULT_MIN_OFFER_PERCENT } from "../../../_internal/shared/features/offers/config";
15
15
  import { PRODUCT_MAX_IMAGES, PRODUCT_MAX_VIDEOS, PRODUCT_IMAGE_INDEX_MAX, } from "../../../_internal/shared/media/limits";
16
16
  import { isFinalSale } from "../../products/constants/final-sale";
17
+ import { ProductFeaturesSelector } from "../../products/components/ProductFeaturesSelector";
17
18
  import { normalizeError } from "../../../errors/normalize";
18
19
  /**
19
20
  * "bundle" is a categoryType, not a ListingType, so it can't live in
@@ -76,7 +77,74 @@ function fromRupeesString(rupeeStr) {
76
77
  }
77
78
  // ── Step 1: Basic ─────────────────────────────────────────────────────────────
78
79
  function StepBasic({ values, onChange, renderCategorySelector, renderBrandSelector, renderTemplateSelector, }) {
79
- return (_jsxs(Stack, { gap: "md", children: [renderTemplateSelector?.({ onApply: onChange }), _jsx(FormField, { name: "title", label: "Title", type: "text", value: values.title ?? "", onChange: (v) => onChange({ title: v }), placeholder: "e.g. Charizard Base Set PSA 9" }), _jsx(FormField, { name: "description", label: "Description", type: "textarea", value: values.description ?? "", onChange: (v) => onChange({ description: v }), placeholder: "Describe your listing in detail\u2026" }), _jsxs(FormGroup, { columns: 2, children: [renderCategorySelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Category" }), renderCategorySelector({ value: values.category ?? "", onChange: (v) => onChange({ category: v }) })] })) : (_jsx(FormField, { name: "category", label: "Category", type: "text", value: values.category ?? "", onChange: (v) => onChange({ category: v }), placeholder: "e.g. Trading Cards" })), _jsx(FormField, { name: "condition", label: "Condition", type: "select", value: values.condition ?? "new", onChange: (v) => onChange({ condition: v }), options: CONDITION_OPTIONS })] }), renderBrandSelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Brand" }), renderBrandSelector({ value: values.brand ?? "", onChange: (v) => onChange({ brand: v }) })] })) : (_jsx(FormField, { name: "brand", label: "Brand", type: "text", value: values.brand ?? "", onChange: (v) => onChange({ brand: v }), placeholder: "e.g. Pok\u00E9mon Company" })), _jsx(TagInput, { label: "Tags", value: values.tags ?? [], onChange: (tags) => onChange({ tags }), placeholder: "pokemon, psa9, charizard, holo" }), _jsx(BarcodeField, { value: values.barcodeId ?? "", onChange: (v) => onChange({ barcodeId: v || undefined }), onScan: (v) => onChange({ barcodeId: v }), helperText: "Leave blank to auto-generate. Scan a pre-printed sticker to link it." })] }));
80
+ /*
81
+ * 🛑 The clobber rule, in one expression.
82
+ *
83
+ * Dirty means: non-empty after trim, AND not byte-identical to a body this
84
+ * session applied. A seller who has typed anything owns the field; a seller
85
+ * looking at a body we filled in does not, so a category change may refresh
86
+ * it silently.
87
+ *
88
+ * Byte-identical rather than "did they focus the box": a seller can click
89
+ * into the description, change nothing, and click away, and that must not
90
+ * count as authorship.
91
+ */
92
+ const description = values.description ?? "";
93
+ const isDescriptionDirty = description.trim() !== "" && description !== (values.appliedTemplateBody ?? "");
94
+ /*
95
+ * The `{{slot}}` values the picker interpolates. Only what the draft
96
+ * genuinely knows — an unresolvable slot becomes the empty string and the
97
+ * picker reports it, which is `interpolate`'s contract. Never a literal
98
+ * `{{series}}` in a published description.
99
+ *
100
+ * `condition` resolves to the LABEL, not the raw enum: a body reading
101
+ * "Condition: like_new" is a template leaking its storage format.
102
+ */
103
+ const templateContext = {
104
+ title: values.title ?? "",
105
+ brand: values.brand ?? "",
106
+ condition: CONDITION_OPTIONS.find((o) => o.value === (values.condition ?? "new"))?.label ?? "",
107
+ siteName: "LetItRip",
108
+ };
109
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(FormField, { name: "title", label: "Title", type: "text", value: values.title ?? "", onChange: (v) => onChange({ title: v }), placeholder: "e.g. Charizard Base Set PSA 9" }), _jsxs(FormGroup, { columns: 2, children: [renderCategorySelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Category" }), renderCategorySelector({ value: values.category ?? "", onChange: (v) => onChange({ category: v }) })] })) : (_jsx(FormField, { name: "category", label: "Category", type: "text", value: values.category ?? "", onChange: (v) => onChange({ category: v }), placeholder: "e.g. Trading Cards" })), _jsx(FormField, { name: "condition", label: "Condition", type: "select", value: values.condition ?? "new", onChange: (v) => onChange({ condition: v }), options: CONDITION_OPTIONS })] }), renderTemplateSelector?.({
110
+ categoryId: values.category ?? "",
111
+ condition: values.condition ?? "new",
112
+ context: templateContext,
113
+ isDescriptionDirty,
114
+ onApply: onChange,
115
+ }), _jsx(FormField, { name: "description", label: "Description", type: "textarea", value: description, onChange: (v) => onChange({ description: v }), placeholder: "Describe your listing in detail\u2026" }), renderBrandSelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Brand" }), renderBrandSelector({ value: values.brand ?? "", onChange: (v) => onChange({ brand: v }) })] })) : (_jsx(FormField, { name: "brand", label: "Brand", type: "text", value: values.brand ?? "", onChange: (v) => onChange({ brand: v }), placeholder: "e.g. Pok\u00E9mon Company" })), _jsx(TagInput, { label: "Tags", value: values.tags ?? [], onChange: (tags) => onChange({ tags }), placeholder: "pokemon, psa9, charizard, holo" }), _jsx(BarcodeField, { value: values.barcodeId ?? "", onChange: (v) => onChange({ barcodeId: v || undefined }), onScan: (v) => onChange({ barcodeId: v }), helperText: "Leave blank to auto-generate. Scan a pre-printed sticker to link it." })] }));
116
+ }
117
+ // ── Step 1b: Details — the inherited-but-editable fields (C2c) ───────────────
118
+ const AUTHENTICITY_OPTIONS = [
119
+ { value: "original", label: "Original — licensed manufacture (Takara, Takara Tomy, Hasbro…)" },
120
+ { value: "reproduction", label: "Reproduction / midfake — a copy with no manufacturer's marque" },
121
+ { value: "unverified", label: "Unverified — I cannot confirm which it is" },
122
+ ];
123
+ /**
124
+ * 🛑 Three options, and there is deliberately no fourth.
125
+ *
126
+ * A poor-quality counterfeit — brittle plastic, cadmium or lead in the metal
127
+ * parts — is not a listing state this marketplace offers. It is a scam report.
128
+ * Offering "fake" as a dropdown value would make selling one a supported
129
+ * workflow.
130
+ *
131
+ * `unverified` exists so a seller who genuinely does not know has an honest
132
+ * answer available. Without it the only way to list is to claim something they
133
+ * cannot stand behind, and the field stops meaning anything.
134
+ */
135
+ function StepDetails({ values, onChange, storeId, listingType, }) {
136
+ const specs = values.specifications ?? [];
137
+ const setSpecs = (next) => onChange({ specifications: next });
138
+ const dims = values.dimensionsCm;
139
+ const setDim = (key, raw) => {
140
+ const n = Number(raw);
141
+ const base = dims ?? { l: 0, w: 0, h: 0 };
142
+ const next = { ...base, [key]: Number.isFinite(n) ? n : 0 };
143
+ // All three or none: a box with a length and no width is not a dimension,
144
+ // and the shipping estimate reads all three or ignores the field.
145
+ onChange({ dimensionsCm: next.l > 0 && next.w > 0 && next.h > 0 ? next : undefined });
146
+ };
147
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(FormField, { name: "authenticity", label: "Authenticity", type: "select", required: true, value: values.authenticity ?? "", onChange: (v) => onChange({ authenticity: v }), options: [{ value: "", label: "Choose one…" }, ...AUTHENTICITY_OPTIONS] }), _jsx(Text, { size: "xs", color: "muted", children: "Shown on the listing whenever it is not Original. A reproduction is a legitimate thing to sell here as long as it is labelled \u2014 what is not allowed is selling one as the real thing. If you believe an item is a cheap counterfeit, report it rather than listing it." }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Features" }), _jsx(Text, { size: "xs", color: "muted", children: "Condition, authenticity, release type, battle type and spin direction. Some are filled in from the category when you save \u2014 remove any that do not apply and your choice sticks." }), _jsx(ProductFeaturesSelector, { value: values.features ?? [], onChange: (next) => onChange({ features: next }), productType: listingType === "bundle" ? "all" : listingType, ...(storeId ? { storeId } : {}) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Specifications" }), _jsx(Text, { size: "xs", color: "muted", children: "Left blank, the category fills these in. Anything you enter here is kept." }), specs.map((row, i) => (_jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { name: `spec-name-${i}`, label: "Name", type: "text", value: row.name, onChange: (v) => setSpecs(specs.map((r, j) => (j === i ? { ...r, name: v } : r))) }), _jsx(FormField, { name: `spec-value-${i}`, label: "Value", type: "text", value: row.value, onChange: (v) => setSpecs(specs.map((r, j) => (j === i ? { ...r, value: v } : r))) }), _jsxs(Row, { gap: "sm", align: "end", children: [_jsx(FormField, { name: `spec-unit-${i}`, label: "Unit", type: "text", value: row.unit ?? "", onChange: (v) => setSpecs(specs.map((r, j) => (j === i ? { ...r, unit: v } : r))) }), _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setSpecs(specs.filter((_, j) => j !== i)), children: "Remove" })] })] }, i))), _jsx(Button, { variant: "secondary", size: "sm", onClick: () => setSpecs([...specs, { name: "", value: "" }]), children: "Add a specification" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "medium", children: "Weight and size (optional)" }), _jsx(Text, { size: "xs", color: "muted", children: "\uD83D\uDED1 Leave these blank unless you know them \u2014 the category's own packed-parcel figures are used for the shipping estimate, and you enter the real weight of the box when you book the shipment. A figure here should be the PACKED weight, not the bare item." }), _jsxs(FormGroup, { columns: 4, children: [_jsx(FormField, { name: "weightG", label: "Packed weight (g)", type: "number", value: values.weightG != null ? String(values.weightG) : "", onChange: (v) => onChange({ weightG: v.trim() === "" ? undefined : Math.max(0, Number(v) || 0) }) }), _jsx(FormField, { name: "dimL", label: "Length (cm)", type: "number", value: dims?.l ? String(dims.l) : "", onChange: (v) => setDim("l", v) }), _jsx(FormField, { name: "dimW", label: "Width (cm)", type: "number", value: dims?.w ? String(dims.w) : "", onChange: (v) => setDim("w", v) }), _jsx(FormField, { name: "dimH", label: "Height (cm)", type: "number", value: dims?.h ? String(dims.h) : "", onChange: (v) => setDim("h", v) })] })] })] }));
80
148
  }
81
149
  // ── Step 2: Media ─────────────────────────────────────────────────────────────
82
150
  function StepMedia({ values, onChange, storeSlug = "store", }) {
@@ -306,13 +374,7 @@ function StepShipping({ values, onChange, renderAddressSelector, }) {
306
374
  ] }), renderAddressSelector ? (_jsxs(_Fragment, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)] mb-1", size: "sm", weight: "medium", children: "Pickup Address (optional)" }), renderAddressSelector({
307
375
  value: values.pickupAddressId ?? "",
308
376
  onChange: (v) => onChange({ pickupAddressId: v }),
309
- })] })) : (_jsx(StoreAddressSelectorCreate, { label: "Pickup Address (optional)", value: values.pickupAddressId ?? "", onChange: (id) => onChange({ pickupAddressId: id }) })), _jsx(Toggle, { checked: !!values.insurance, onChange: (checked) => onChange({ insurance: checked, insuranceCost: checked ? values.insuranceCost ?? 0 : undefined }), label: "Offer shipping insurance" }), values.insurance && (_jsx(FormField, { name: "insuranceCost", label: "Insurance Cost (\u20B9)", type: "number", value: toRupeesString(values.insuranceCost), onChange: (v) => onChange({ insuranceCost: fromRupeesString(v) }), placeholder: "0" })), _jsx(FormField, { name: "gstRate", label: "GST Rate", type: "select", value: String(values.gstRate ?? 0), onChange: (v) => onChange({ gstRate: Number(v) }), options: [
310
- { value: "0", label: "Exempt (0%)" },
311
- { value: "5", label: "5%" },
312
- { value: "12", label: "12%" },
313
- { value: "18", label: "18%" },
314
- { value: "28", label: "28%" },
315
- ] }), _jsx(FormField, { name: "hsnCode", label: "HSN Code", value: values.hsnCode ?? "", onChange: (v) => onChange({ hsnCode: v }), placeholder: "e.g. 9503" })] }));
377
+ })] })) : (_jsx(StoreAddressSelectorCreate, { label: "Pickup Address (optional)", value: values.pickupAddressId ?? "", onChange: (id) => onChange({ pickupAddressId: id }) })), _jsx(Toggle, { checked: !!values.insurance, onChange: (checked) => onChange({ insurance: checked, insuranceCost: checked ? values.insuranceCost ?? 0 : undefined }), label: "Offer shipping insurance" }), values.insurance && (_jsx(FormField, { name: "insuranceCost", label: "Insurance Cost (\u20B9)", type: "number", value: toRupeesString(values.insuranceCost), onChange: (v) => onChange({ insuranceCost: fromRupeesString(v) }), placeholder: "0" }))] }));
316
378
  }
317
379
  // ── Step: Returns ─────────────────────────────────────────────────────────
318
380
  /**
@@ -516,13 +578,43 @@ productId, onSave, onPublish, onDiscard, isLoading = false, storeSlug, renderCat
516
578
  render: ({ values, onChange }) => (_jsx(StepMedia, { values: values, onChange: onChange, storeSlug: storeSlug })),
517
579
  schema: sectionSchemas.media,
518
580
  },
581
+ /*
582
+ * 🛑 Details sits between Basic and the type-specific step, not at the end.
583
+ *
584
+ * `authenticity` is required, and a required field buried after the
585
+ * optional ones is a field sellers reach by being bounced back to it from
586
+ * a failed submit. It also reads in the right order: what it IS, then what
587
+ * it is LIKE, then what it costs.
588
+ */
589
+ {
590
+ id: "details",
591
+ label: "Details",
592
+ required: true,
593
+ fields: ["authenticity", "features", "specifications", "weightG", "dimensionsCm"],
594
+ render: ({ values, onChange }) => (_jsx(StepDetails, { values: values, onChange: onChange, listingType: listingType, ...(storeSlug ? { storeId: storeSlug } : {}) })),
595
+ /*
596
+ * The one required field in the section, and the message says what to do
597
+ * rather than naming the field — "Authenticity is required" tells a
598
+ * seller nothing they cannot see.
599
+ */
600
+ schema: z
601
+ .object({
602
+ authenticity: z.enum(["original", "reproduction", "unverified"], {
603
+ errorMap: () => ({
604
+ message: "Say whether this is an original, a reproduction, or that you cannot confirm",
605
+ }),
606
+ }),
607
+ })
608
+ .passthrough(),
609
+ },
519
610
  ...(typeSpecificSection ? [typeSpecificSection] : []),
520
611
  {
521
612
  id: "pricing",
522
613
  label: "Pricing",
523
614
  required: true,
524
615
  quick: true,
525
- fields: ["price", "compareAtPrice", "stockQuantity", "allowOffers", "minOfferPercent", "gstRate", "hsnCode"],
616
+ // `gstRate` / `hsnCode` deliberately absent — admin-only, see StepPricing.
617
+ fields: ["price", "compareAtPrice", "stockQuantity", "allowOffers", "minOfferPercent"],
526
618
  render: ({ values, onChange }) => (_jsx(StepPricing, { values: values, onChange: onChange, listingType: listingType })),
527
619
  // Built per render because whether stock is required depends on the
528
620
  // listing type's own plugin — a digital code has no stock to speak of.
@@ -5,6 +5,18 @@ export interface SellerProductsFilterFieldsProps {
5
5
  id: string;
6
6
  label: string;
7
7
  }>;
8
+ /**
9
+ * The stock-level options, PASSED IN rather than declared here.
10
+ *
11
+ * They are derived from `LOW_STOCK_THRESHOLD`, and the predicate that reads
12
+ * the chosen value lives in `SellerProductsView` beside them — so declaring
13
+ * a second copy of the labels here is how the picker and the filter end up
14
+ * disagreeing about what "low" means.
15
+ */
16
+ stockStateOptions: {
17
+ value: string;
18
+ label: string;
19
+ }[];
8
20
  onChange: (next: Record<string, string>) => void;
9
21
  }
10
- export declare function SellerProductsFilterFields({ pendingFilters, statusOptions, onChange, }: SellerProductsFilterFieldsProps): React.JSX.Element;
22
+ export declare function SellerProductsFilterFields({ pendingFilters, statusOptions, stockStateOptions, onChange, }: SellerProductsFilterFieldsProps): React.JSX.Element;
@@ -12,7 +12,7 @@ const CONDITION_OPTIONS = [
12
12
  { value: "refurbished", label: "Refurbished" },
13
13
  { value: "for-parts", label: "For parts" },
14
14
  ];
15
- export function SellerProductsFilterFields({ pendingFilters, statusOptions, onChange, }) {
15
+ export function SellerProductsFilterFields({ pendingFilters, statusOptions, stockStateOptions, onChange, }) {
16
16
  const patch = (k, v) => onChange({ ...pendingFilters, [k]: v });
17
- return (_jsxs(_Fragment, { children: [_jsx(FilterChipGroup, { label: "Status", tabs: statusOptions, value: pendingFilters.status ?? "", onChange: (id) => patch("status", id) }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", weight: "semibold", color: "muted", transform: "uppercase", children: "Category" }), _jsx(CategoryInlineSelect, { value: pendingFilters.category ?? "", onChange: (v) => patch("category", v), placeholder: "Search categories\u2026" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", weight: "semibold", color: "muted", transform: "uppercase", children: "Brand" }), _jsx(BrandInlineSelect, { value: pendingFilters.brand ?? "", onChange: (v) => patch("brand", v), placeholder: "Search brands\u2026", allowCreate: false })] }), _jsx(FieldSelect, { name: "condition", label: "Condition", value: pendingFilters.condition ?? "", onChange: (v) => patch("condition", v), options: CONDITION_OPTIONS }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", weight: "semibold", color: "muted", transform: "uppercase", children: "Price (\u20B9 Rupees)" }), _jsxs(Row, { gap: "sm", children: [_jsx(Div, { className: "flex-1", children: _jsx(FieldInput, { name: "minPrice", type: "number", min: 0, value: pendingFilters.minPrice ?? "", onChange: (v) => patch("minPrice", v), placeholder: "min" }) }), _jsx(Div, { className: "flex-1", children: _jsx(FieldInput, { name: "maxPrice", type: "number", min: 0, value: pendingFilters.maxPrice ?? "", onChange: (v) => patch("maxPrice", v), placeholder: "max" }) })] })] }), _jsx(FieldInput, { name: "tags", label: "Tags (comma-separated)", type: "text", value: pendingFilters.tags ?? "", onChange: (v) => patch("tags", v), placeholder: "pokemon, vintage, holo" }), _jsx(FieldInput, { name: "badges", label: "Badges (feature slugs)", type: "text", value: pendingFilters.badges ?? "", onChange: (v) => patch("badges", v), placeholder: "feature-free-shipping, feature-verified" })] }));
17
+ return (_jsxs(_Fragment, { children: [_jsx(FilterChipGroup, { label: "Status", tabs: statusOptions, value: pendingFilters.status ?? "", onChange: (id) => patch("status", id) }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", weight: "semibold", color: "muted", transform: "uppercase", children: "Category" }), _jsx(CategoryInlineSelect, { value: pendingFilters.category ?? "", onChange: (v) => patch("category", v), placeholder: "Search categories\u2026" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", weight: "semibold", color: "muted", transform: "uppercase", children: "Brand" }), _jsx(BrandInlineSelect, { value: pendingFilters.brand ?? "", onChange: (v) => patch("brand", v), placeholder: "Search brands\u2026", allowCreate: false })] }), _jsx(FieldSelect, { name: "condition", label: "Condition", value: pendingFilters.condition ?? "", onChange: (v) => patch("condition", v), options: CONDITION_OPTIONS }), _jsxs(Stack, { gap: "xs", children: [_jsx(FieldSelect, { name: "stockState", label: "Stock level", value: pendingFilters.stockState ?? "", onChange: (v) => patch("stockState", v), options: stockStateOptions }), _jsx(Text, { size: "xs", color: "muted", children: "Applied to the listings on this page." })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "xs", weight: "semibold", color: "muted", transform: "uppercase", children: "Price (\u20B9 Rupees)" }), _jsxs(Row, { gap: "sm", children: [_jsx(Div, { className: "flex-1", children: _jsx(FieldInput, { name: "minPrice", type: "number", min: 0, value: pendingFilters.minPrice ?? "", onChange: (v) => patch("minPrice", v), placeholder: "min" }) }), _jsx(Div, { className: "flex-1", children: _jsx(FieldInput, { name: "maxPrice", type: "number", min: 0, value: pendingFilters.maxPrice ?? "", onChange: (v) => patch("maxPrice", v), placeholder: "max" }) })] })] }), _jsx(FieldInput, { name: "tags", label: "Tags (comma-separated)", type: "text", value: pendingFilters.tags ?? "", onChange: (v) => patch("tags", v), placeholder: "pokemon, vintage, holo" }), _jsx(FieldInput, { name: "badges", label: "Badges (feature slugs)", type: "text", value: pendingFilters.badges ?? "", onChange: (v) => patch("badges", v), placeholder: "feature-free-shipping, feature-verified" })] }));
18
18
  }
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { formatPhysicalLocation } from "../../../utils/physical-location";
3
4
  import { normalizeError } from "../../../errors/normalize";
4
5
  import { toUserMessage } from "../../../errors/error-display-map";
5
6
  import { SIEVE_OP, Stack, sieveFilter } from "@mohasinac/appkit/client";
7
+ import { LOW_STOCK_THRESHOLD } from "../../store-extensions/constants/alert-metrics";
6
8
  import { sortBy } from "@mohasinac/appkit/client";
7
9
  import { useState, useCallback } from "react";
8
10
  import { useEntityDelete } from "../../../react/hooks/useEntityDelete";
@@ -39,7 +41,55 @@ const FILTER_KEYS = [
39
41
  "tags",
40
42
  "badges",
41
43
  "listingType",
44
+ /*
45
+ * The stock quick-filter. `FILTER_KEYS` had no stock key at all — stock
46
+ * appeared only inside a row's subtitle string, so there was no way to ask
47
+ * "what needs restocking" short of reading every row.
48
+ *
49
+ * Registered here (not in `buildExtraParams`) because it IS a drawer facet:
50
+ * it narrows the list the way `condition` does, so it should inflate the
51
+ * filter badge and be cleared by the drawer's Clear button. That is the
52
+ * opposite call from the availability SCOPE, which is a tab and deliberately
53
+ * stays out of `filterKeys` so a tab selection cannot be silently reset by
54
+ * Clear.
55
+ */
56
+ "stockState",
42
57
  ];
58
+ /**
59
+ * 🛑 Applied IN MEMORY, never as a Firestore clause.
60
+ *
61
+ * `availableQuantity <= N` is an inequality, and Firestore appends an
62
+ * inequality's field to the `orderBy` implicitly — so pairing one with the
63
+ * default `-createdAt` sort demands a composite index nobody declares, and the
64
+ * `FAILED_PRECONDITION` gets swallowed into an empty page (Root Cause #59).
65
+ * Same reasoning as the alert evaluator's `low_stock` branch.
66
+ *
67
+ * The honest cost is that it filters the PAGE rather than the collection: a
68
+ * seller on page 1 of 200 listings sees the low-stock rows from those 50. That
69
+ * is stated in the facet's own label rather than left for them to infer.
70
+ */
71
+ const STOCK_STATE_OPTIONS = [
72
+ { value: "", label: "Any stock level" },
73
+ { value: "low", label: `Low — ${LOW_STOCK_THRESHOLD} or fewer left` },
74
+ { value: "out", label: "Out of stock" },
75
+ { value: "in", label: "In stock" },
76
+ ];
77
+ function matchesStockState(stockValue, state) {
78
+ if (!state)
79
+ return true;
80
+ // 🛑 An unknown quantity matches NOTHING rather than everything. A listing
81
+ // whose stock the API did not report is not evidence of being low, and
82
+ // including it would put rows a seller cannot act on into a worklist.
83
+ if (stockValue == null)
84
+ return false;
85
+ if (state === "out")
86
+ return stockValue <= 0;
87
+ if (state === "low")
88
+ return stockValue > 0 && stockValue <= LOW_STOCK_THRESHOLD;
89
+ if (state === "in")
90
+ return stockValue > LOW_STOCK_THRESHOLD;
91
+ return true;
92
+ }
43
93
  const DEFAULT_SORT = "-createdAt";
44
94
  const SORT_OPTIONS = [
45
95
  { value: sortBy("createdAt", "DESC"), label: "Newest" },
@@ -86,6 +136,43 @@ const PRODUCT_COLUMNS = [
86
136
  className: "w-28 text-right",
87
137
  render: (row) => (_jsx(Span, { size: "sm", weight: "medium", className: "text-[var(--appkit-color-text)]", children: row.price })),
88
138
  },
139
+ /*
140
+ * 🛑 `row.stock` was COMPUTED in `mapRows` and no column rendered it.
141
+ *
142
+ * Data fetched and discarded — Root Cause #52's shape — and the reason it
143
+ * stayed invisible is that the figure also appears inside the row's
144
+ * `secondary` string, so the information was technically on screen while
145
+ * being unsortable, unfilterable and impossible to scan down a column.
146
+ *
147
+ * Both stock fields have been `canFilter`/`canSort` at the repository layer
148
+ * the whole time; only the view omitted them.
149
+ */
150
+ {
151
+ key: "stock",
152
+ header: "Stock",
153
+ className: "w-24 text-right",
154
+ render: (row) => {
155
+ if (row.stockValue == null)
156
+ return _jsx(Span, { size: "xs", color: "faint", children: "\u2014" });
157
+ /*
158
+ * Three states, not two, and the middle one is the point of the column.
159
+ * `LOW_STOCK_THRESHOLD` is imported rather than re-picked here: the
160
+ * alert, this column and the dashboard tile must agree on what "low"
161
+ * means, or a seller gets a warning from one and a clean bill from
162
+ * another.
163
+ */
164
+ /*
165
+ * Three states, and `Span` has no `warning` variant — so "low" is
166
+ * carried by WEIGHT plus a suffix rather than by colour alone. That is
167
+ * better anyway: `audit-a11y` records that white-on-warning fails
168
+ * contrast in this theme set, and a status conveyed only by colour is
169
+ * invisible to a colour-blind seller.
170
+ */
171
+ const isOut = row.stockValue <= 0;
172
+ const isLow = !isOut && row.stockValue <= LOW_STOCK_THRESHOLD;
173
+ return (_jsxs(Span, { size: "sm", weight: isOut || isLow ? "semibold" : "normal", color: isOut ? "error" : isLow ? "primary" : "muted", children: [row.stockValue, isOut ? " · out" : isLow ? " · low" : ""] }));
174
+ },
175
+ },
89
176
  {
90
177
  key: "status",
91
178
  header: "Status",
@@ -109,7 +196,13 @@ const PRODUCT_COLUMNS = [
109
196
  key: "physicalLocation",
110
197
  header: "Location",
111
198
  className: "w-28",
112
- render: (row) => row.physicalLocation ? (_jsxs(Span, { size: "xs", className: "font-mono text-[var(--appkit-color-text-muted)]", children: [row.physicalLocation.zone, "/", row.physicalLocation.shelf, "/", row.physicalLocation.bin] })) : (_jsx(Span, { size: "xs", color: "faint", children: "\u2014" })),
199
+ render: (row) => {
200
+ // One formatter, shared with SellerOrdersView and PrintCenterView. It
201
+ // also returns undefined for an all-blank location, which the bulk
202
+ // routes genuinely allow — this used to print a bare "//".
203
+ const loc = formatPhysicalLocation(row.physicalLocation);
204
+ return loc ? (_jsx(Span, { size: "xs", className: "font-mono text-[var(--appkit-color-text-muted)]", children: loc })) : (_jsx(Span, { size: "xs", color: "faint", children: "\u2014" }));
205
+ },
113
206
  },
114
207
  ];
115
208
  export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
@@ -288,7 +381,9 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
288
381
  * reserve and the current bid, both already in `auctionSecondary`, and
289
382
  * a "price" beside them would read as a third, competing figure.
290
383
  */
384
+ const availableRaw = typeof item.availableQuantity === "number" ? item.availableQuantity : null;
291
385
  const stockRaw = typeof item.stockQuantity === "number" ? item.stockQuantity : null;
386
+ const stockValue = availableRaw ?? stockRaw;
292
387
  const priceLabel = kind !== "auction" && priceRaw ? `₹${priceRaw.toLocaleString("en-IN")}` : "";
293
388
  const stockLabel = stockRaw === null ? "" : `${stockRaw} in stock`;
294
389
  const secondaryBase = auctionSecondary || toStringValue(item.condition, "");
@@ -303,13 +398,23 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
303
398
  listingKind: kind,
304
399
  price: priceRaw ? `₹${priceRaw.toLocaleString("en-IN")}` : "—",
305
400
  stock: stockLabel || "—",
401
+ stockValue,
306
402
  physicalLocation: item.physicalLocation &&
307
403
  typeof item.physicalLocation.zone === "string"
308
404
  ? item.physicalLocation
309
405
  : undefined,
310
406
  };
311
407
  });
312
- return rows.filter((r) => !deletedIds.has(r.id)).map((r) => statusOverrides.has(r.id) ? { ...r, status: statusOverrides.get(r.id) } : r);
408
+ /*
409
+ * The stock predicate runs HERE, in the same pass that already drops
410
+ * optimistically-deleted rows and applies status overrides — so there is
411
+ * one place a row can be removed from the rendered list rather than two
412
+ * that could disagree about which rows exist.
413
+ */
414
+ const stockState = sideTable.get("stockState") ?? "";
415
+ return rows
416
+ .filter((r) => !deletedIds.has(r.id) && matchesStockState(r.stockValue, stockState))
417
+ .map((r) => statusOverrides.has(r.id) ? { ...r, status: statusOverrides.get(r.id) } : r);
313
418
  },
314
419
  getTotal: (response, mappedRows) => typeof response.meta?.total === "number" ? response.meta.total : mappedRows.length,
315
420
  buildFilters: (state) => {
@@ -321,7 +426,7 @@ export function SellerProductsView({ onDeleteProduct, onCreateClick, }) {
321
426
  // same rows. The scope param carries the per-type meaning instead.
322
427
  return [statusFilter, kindFilter].filter(Boolean).join(",") || undefined;
323
428
  },
324
- renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsx(SellerProductsFilterFields, { pendingFilters: pendingFilters, statusOptions: STATUS_OPTIONS, onChange: setPendingFilters })),
429
+ renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsx(SellerProductsFilterFields, { pendingFilters: pendingFilters, statusOptions: STATUS_OPTIONS, stockStateOptions: STOCK_STATE_OPTIONS, onChange: setPendingFilters })),
325
430
  buildExtraParams: () => scope.extraParams,
326
431
  renderAboveContent: () => (_jsxs(Stack, { gap: "sm", children: [_jsx(TypeDropdown, { active: listingKind, onChange: handleKindChange }), scope.renderAboveContent()] })),
327
432
  buildBulkActions: (selection) => {
@@ -76,6 +76,23 @@ export interface SellerDashboardStats {
76
76
  totalRevenue: number;
77
77
  pendingPayouts: number;
78
78
  activeListings: number;
79
+ /**
80
+ * Published listings at or below `LOW_STOCK_THRESHOLD`, and at zero.
81
+ *
82
+ * 🛑 Two numbers rather than one "needs attention" count, because the two
83
+ * call for different actions: low means reorder, out means the listing
84
+ * cannot be bought right now. A combined figure hides which.
85
+ *
86
+ * Both from `availableQuantity`, not `stockQuantity` — purchasability gates
87
+ * on the first in three places, and reporting the second would tell a seller
88
+ * they have stock while buyers cannot buy.
89
+ *
90
+ * Optional because a store that does not exist yet returns the zeroed
91
+ * payload, and because an older client reading a newer field is a nicer
92
+ * failure than a required field a stale route omits.
93
+ */
94
+ lowStockListings?: number;
95
+ outOfStockListings?: number;
79
96
  totalOrders: number;
80
97
  pendingOrders: number;
81
98
  averageRating?: number;