@mohasinac/appkit 4.43.0 → 4.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/categories/adapters.d.ts +47 -0
- package/dist/_internal/server/features/categories/adapters.js +235 -0
- package/dist/_internal/server/features/checkout/actions.js +161 -22
- package/dist/_internal/server/features/checkout/expand-order-items.js +34 -0
- package/dist/_internal/server/features/orders/adapters.js +21 -0
- package/dist/_internal/server/features/refunds/actions.js +1 -1
- package/dist/_internal/server/features/seo/sitemap.js +54 -15
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +6 -0
- package/dist/_internal/server/functions/scheduled.d.ts +4 -2
- package/dist/_internal/server/functions/scheduled.js +64 -6
- package/dist/_internal/server/jobs/core/adminAnalytics.d.ts +0 -1
- package/dist/_internal/server/jobs/core/adminAnalytics.js +9 -1
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.d.ts +47 -0
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +468 -0
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +14 -1
- package/dist/_internal/server/jobs/core/countersReconcile.js +24 -8
- package/dist/_internal/server/jobs/core/onCategoryWrite.js +54 -0
- package/dist/_internal/server/jobs/core/priceIndexRollup.d.ts +47 -0
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +234 -0
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.d.ts → testDataCleanup.d.ts} +3 -3
- package/dist/_internal/server/jobs/core/{testerSandboxCleanup.js → testDataCleanup.js} +3 -3
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +5 -1
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/analyticsAlertEvaluate.js +2 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +3 -1
- package/dist/_internal/server/jobs/handlers/index.js +3 -1
- package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -2
- package/dist/_internal/server/jobs/handlers/messages.js +5 -2
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/priceIndexRollup.js +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/testDataCleanup.js +2 -0
- package/dist/_internal/shared/actions/action-registry.js +34 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +113 -3
- package/dist/_internal/shared/checkout/order-math.js +194 -7
- package/dist/_internal/shared/features/categories/price-guidance.d.ts +110 -0
- package/dist/_internal/shared/features/categories/price-guidance.js +136 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.d.ts +90 -0
- package/dist/_internal/shared/features/site-settings/gst-registration.js +126 -0
- package/dist/_internal/shared/fees/calculator.d.ts +105 -0
- package/dist/_internal/shared/fees/calculator.js +143 -0
- package/dist/_internal/shared/listing-types/art/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/classified/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/live/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/standard/config.d.ts +0 -6
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +0 -6
- package/dist/_internal/shared/templating/placeholders.d.ts +70 -0
- package/dist/_internal/shared/templating/placeholders.js +73 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/api-endpoints.d.ts +42 -0
- package/dist/constants/api-endpoints.js +14 -0
- package/dist/features/admin/actions/admin-actions.d.ts +1 -1
- package/dist/features/admin/actions/admin-actions.js +1 -1
- package/dist/features/admin/components/AdminCategoryEditorView.js +269 -3
- package/dist/features/admin/components/AdminProductEditorView.js +8 -1
- package/dist/features/admin/components/AdminProductsView.js +78 -7
- package/dist/features/admin/components/AdminSiteSettingsView.js +20 -1
- package/dist/features/admin/components/AdminTaxCodeEditorView.d.ts +10 -0
- package/dist/features/admin/components/AdminTaxCodeEditorView.js +160 -0
- package/dist/features/admin/components/AdminTaxCodesView.d.ts +13 -0
- package/dist/features/admin/components/AdminTaxCodesView.js +86 -0
- package/dist/features/admin/components/AdminTesterChecklistView.js +1 -1
- package/dist/features/admin/components/analytics/AdminTopProductsTable.js +1 -1
- package/dist/features/auth/schemas/firestore.d.ts +0 -1
- package/dist/features/blog/schemas/firestore.d.ts +2 -2
- package/dist/features/catalogue/components/CatalogueItemEditorView.js +14 -3
- package/dist/features/categories/api/[id]/description-template/route.d.ts +45 -0
- package/dist/features/categories/api/[id]/description-template/route.js +252 -0
- package/dist/features/categories/api/route.js +156 -11
- package/dist/features/categories/components/CategoryDetailPageView.js +43 -10
- package/dist/features/categories/components/CategoryTemplatePicker.d.ts +65 -0
- package/dist/features/categories/components/CategoryTemplatePicker.js +170 -0
- package/dist/features/categories/constants/listing-categories.d.ts +17 -0
- package/dist/features/categories/constants/listing-categories.js +53 -0
- package/dist/features/categories/hooks/useCategoryTree.js +28 -2
- package/dist/features/categories/repository/categories.repository.d.ts +62 -11
- package/dist/features/categories/repository/categories.repository.js +178 -35
- package/dist/features/categories/repository/price-index.repository.d.ts +115 -0
- package/dist/features/categories/repository/price-index.repository.js +85 -0
- package/dist/features/categories/schemas/category-content.d.ts +206 -0
- package/dist/features/categories/schemas/category-content.js +28 -0
- package/dist/features/categories/schemas/firestore.d.ts +74 -5
- package/dist/features/categories/schemas/firestore.js +74 -3
- package/dist/features/categories/schemas/index.d.ts +1047 -0
- package/dist/features/categories/schemas/index.js +96 -0
- package/dist/features/categories/server.d.ts +1 -0
- package/dist/features/categories/server.js +7 -0
- package/dist/features/categories/types/index.d.ts +9 -0
- package/dist/features/events/schemas/firestore.d.ts +2 -2
- package/dist/features/faq/schemas/firestore.d.ts +2 -1
- package/dist/features/faq/schemas/firestore.js +20 -8
- package/dist/features/orders/schemas/firestore.d.ts +70 -0
- package/dist/features/orders/types/index.d.ts +55 -0
- package/dist/features/products/columns/index.d.ts +0 -1
- package/dist/features/products/columns/index.js +5 -1
- package/dist/features/products/components/ProductFilters.d.ts +0 -9
- package/dist/features/products/components/ProductForm.d.ts +27 -1
- package/dist/features/products/components/ProductForm.js +26 -12
- package/dist/features/products/constants/sieve.d.ts +0 -6
- package/dist/features/products/constants/sieve.js +35 -2
- package/dist/features/products/repository/products.repository.d.ts +115 -1
- package/dist/features/products/repository/products.repository.js +491 -39
- package/dist/features/products/schemas/firestore.d.ts +65 -5
- package/dist/features/products/schemas/firestore.js +9 -0
- package/dist/features/products/schemas/product-features.d.ts +140 -2
- package/dist/features/products/schemas/product-features.js +62 -1
- package/dist/features/promotions/repository/coupons.repository.js +6 -1
- package/dist/features/promotions/schemas/firestore.d.ts +44 -1
- package/dist/features/promotions/schemas/firestore.js +76 -2
- package/dist/features/seller/actions/seller-actions.js +17 -2
- package/dist/features/seller/components/PrintCenterView.d.ts +3 -2
- package/dist/features/seller/components/PrintCenterView.js +3 -2
- package/dist/features/seller/components/SellerAnalyticsAlertsView.js +21 -11
- package/dist/features/seller/components/SellerOrdersView.js +6 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +19 -3
- package/dist/features/seller/components/SellerProductShell.d.ts +50 -2
- package/dist/features/seller/components/SellerProductShell.js +101 -9
- package/dist/features/seller/components/SellerProductsFilterDrawer.d.ts +13 -1
- package/dist/features/seller/components/SellerProductsFilterDrawer.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +108 -3
- package/dist/features/seller/types/index.d.ts +17 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +13 -0
- package/dist/features/seller/utils/product-draft-mapping.js +17 -0
- package/dist/features/store-extensions/constants/alert-metrics.d.ts +86 -0
- package/dist/features/store-extensions/constants/alert-metrics.js +142 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +19 -1
- package/dist/features/stores/schemas/firestore.d.ts +31 -2
- package/dist/features/tax-codes/repository/tax-codes.repository.d.ts +55 -0
- package/dist/features/tax-codes/repository/tax-codes.repository.js +131 -0
- package/dist/features/tax-codes/schemas/firestore.d.ts +125 -0
- package/dist/features/tax-codes/schemas/firestore.js +82 -0
- package/dist/features/tax-codes/schemas/tax-code-form.d.ts +63 -0
- package/dist/features/tax-codes/schemas/tax-code-form.js +152 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.d.ts +107 -0
- package/dist/features/tax-codes/schemas/tax-codes.validators.js +85 -0
- package/dist/features/tester/actions/index.d.ts +0 -1
- package/dist/features/tester/actions/index.js +0 -1
- package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +1 -1
- package/dist/features/tester/components/AdminTesterFeedbackListView.js +1 -1
- package/dist/features/tester/components/index.d.ts +0 -4
- package/dist/features/tester/components/index.js +0 -2
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +1 -20
- package/dist/features/tester/repository/tester-checklist-item.repository.js +0 -52
- package/dist/features/tester/schemas/firestore.d.ts +0 -6
- package/dist/features/tester/seed-data/authored/admin__admin-detail-round-trips.js +3 -3
- package/dist/features/tester/seed-data/authored/admin__site-system.js +4 -5
- package/dist/features/tester/seed-data/authored/content-discovery__category-counts-and-rollup.js +2 -2
- package/dist/features/tester/seed-data/authored/index.js +0 -2
- package/dist/features/tester/seed-data/authored/page-wiring__reachability.js +0 -19
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +8 -27
- package/dist/index.d.ts +21 -7
- package/dist/index.js +53 -7
- package/dist/next/routing/route-map.d.ts +2 -4
- package/dist/next/routing/route-map.js +1 -2
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +1 -0
- package/dist/schemas/registry.d.ts +506 -0
- package/dist/seed/_helpers/brand-rows.d.ts +35 -0
- package/dist/seed/_helpers/brand-rows.js +576 -0
- package/dist/seed/_helpers/category-content.d.ts +39 -0
- package/dist/seed/_helpers/category-content.js +634 -0
- package/dist/seed/_helpers/category-forest.d.ts +2 -0
- package/dist/seed/_helpers/category-forest.js +585 -243
- package/dist/seed/_helpers/category-models.d.ts +5 -0
- package/dist/seed/_helpers/category-models.js +1364 -0
- package/dist/seed/_helpers/category-tree.js +17 -1
- package/dist/seed/_helpers/character-features.d.ts +10 -0
- package/dist/seed/_helpers/character-features.js +118 -0
- package/dist/seed/_helpers/search-txt-wrappers.d.ts +10 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +12 -1
- package/dist/seed/categories-seed-data.js +147 -169
- package/dist/seed/index.d.ts +1 -0
- package/dist/seed/index.js +1 -0
- package/dist/seed/manifest.d.ts +1 -1
- package/dist/seed/manifest.js +5 -0
- package/dist/seed/product-features-seed-data.d.ts +1 -8
- package/dist/seed/product-features-seed-data.js +358 -154
- package/dist/seed/products-art-seed-data.js +16 -4
- package/dist/seed/products-auctions-seed-data.js +6 -6
- package/dist/seed/products-classifieds-seed-data.js +17 -7
- package/dist/seed/products-digital-codes-seed-data.js +12 -12
- package/dist/seed/products-preorders-seed-data.js +8 -8
- package/dist/seed/products-prize-draws-seed-data.js +25 -8
- package/dist/seed/products-standard-seed-data.js +10 -60
- package/dist/seed/products-stickers-seed-data.js +6 -6
- package/dist/seed/site-settings-seed-data.js +24 -0
- package/dist/seed/store-extensions-seed-data.js +10 -4
- package/dist/seed/tax-codes-seed-data.d.ts +34 -0
- package/dist/seed/tax-codes-seed-data.js +100 -0
- package/dist/seed/test-data-ttl.d.ts +29 -0
- package/dist/seed/test-data-ttl.js +31 -0
- package/dist/server-entry.d.ts +1 -1
- package/dist/server-entry.js +6 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +5 -2
- package/dist/styles.css +40 -0
- package/dist/ui/components/PaginatedSelect.d.ts +14 -0
- package/dist/ui/components/PaginatedSelect.js +0 -0
- package/dist/ui/components/PaginatedSelect.style.css +40 -0
- package/dist/utils/physical-location.d.ts +23 -0
- package/dist/utils/physical-location.js +52 -0
- package/dist/utils/search-txt-builders.d.ts +20 -0
- package/dist/utils/search-txt-builders.js +36 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +4 -1
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.d.ts +0 -2
- package/dist/_internal/server/jobs/handlers/testerSandboxCleanup.js +0 -2
- package/dist/features/tester/actions/leaderboard-actions.d.ts +0 -3
- package/dist/features/tester/actions/leaderboard-actions.js +0 -5
- package/dist/features/tester/components/BugHunterLeaderboardView.d.ts +0 -23
- package/dist/features/tester/components/BugHunterLeaderboardView.js +0 -18
- package/dist/features/tester/components/TesterHubView.d.ts +0 -5
- package/dist/features/tester/components/TesterHubView.js +0 -110
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.d.ts +0 -2
- package/dist/features/tester/seed-data/authored/public-pages__bug-hunters.js +0 -78
- package/dist/features/tester/seed-data/tester-ttl.d.ts +0 -4
- package/dist/features/tester/seed-data/tester-ttl.js +0 -6
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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: "
|
|
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) =>
|
|
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
|
|
125
|
-
*
|
|
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
|
-
|
|
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" }))
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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;
|