@mohasinac/appkit 4.55.0-next.0 → 4.55.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 (47) hide show
  1. package/dist/.build-stamp.json +5 -5
  2. package/dist/_internal/server/features/checkout/actions.js +61 -2
  3. package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
  4. package/dist/_internal/shared/features/checkout/config.js +1 -1
  5. package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
  6. package/dist/_internal/shared/features/site-settings/fees.js +1 -1
  7. package/dist/_internal/shared/fees/calculator.d.ts +35 -3
  8. package/dist/_internal/shared/fees/calculator.js +34 -4
  9. package/dist/errors/error-codes.d.ts +15 -0
  10. package/dist/errors/error-codes.js +15 -0
  11. package/dist/errors/index.d.ts +1 -1
  12. package/dist/errors/index.js +1 -1
  13. package/dist/features/account/components/UserBidsView.js +4 -2
  14. package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
  15. package/dist/features/admin/schemas/firestore.d.ts +37 -2
  16. package/dist/features/admin/schemas/firestore.js +12 -1
  17. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  18. package/dist/features/checkout/schemas/firestore.d.ts +12 -1
  19. package/dist/features/layout/NavbarLayout.js +139 -32
  20. package/dist/features/media/messages/en.json +67 -67
  21. package/dist/features/orders/schemas/firestore.d.ts +1 -1
  22. package/dist/features/products/components/ProductDetailPageView.js +2 -25
  23. package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
  24. package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
  25. package/dist/features/products/components/ProductsIndexListing.js +35 -5
  26. package/dist/features/products/components/ProductsIndexPageView.js +40 -7
  27. package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
  28. package/dist/providers/shipping-shiprocket/index.js +34 -10
  29. package/dist/security/csp.d.ts +1 -16
  30. package/dist/security/csp.js +3 -5
  31. package/dist/seed/faq-seed-data.js +1 -1
  32. package/dist/seed/site-settings-seed-data.js +1 -1
  33. package/dist/seo/json-ld.d.ts +8 -1
  34. package/dist/seo/json-ld.js +39 -2
  35. package/dist/styles.css +382 -537
  36. package/dist/tailwind-utilities.css +1 -1
  37. package/dist/tokens/themes/index.d.ts +1 -3
  38. package/dist/tokens/themes/index.js +1 -11
  39. package/dist/tokens/tokens.css +0 -155
  40. package/dist/ui/components/GatedPrice.style.css +43 -43
  41. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  42. package/dist/ui/forms/FormShell.style.css +260 -260
  43. package/package.json +1 -1
  44. package/dist/tokens/themes/technical-dark.d.ts +0 -30
  45. package/dist/tokens/themes/technical-dark.js +0 -103
  46. package/dist/tokens/themes/technical-light.d.ts +0 -22
  47. package/dist/tokens/themes/technical-light.js +0 -89
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.55.0-next.0",
4
- "head": "549abc780f1aa0e87661e86d9c76718bfe3d6cb0",
5
- "branch": "feat/mohasinac-site",
6
- "dirty": true,
7
- "builtAt": "2026-10-20T12:44:38.195Z"
3
+ "version": "4.55.0",
4
+ "head": "e683e7b08996d66d9b6bef65cf4712b8e63454ea",
5
+ "branch": "main",
6
+ "dirty": false,
7
+ "builtAt": "2026-10-20T15:42:52.181Z"
8
8
  }
@@ -15,6 +15,8 @@ import { serverLogger } from "../../../../monitoring/index.js";
15
15
  import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories/index.js";
16
16
  import { isIntraStateSupply } from "../../../shared/fees/calculator.js";
17
17
  import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
18
+ import { COD_MAX_ORDER_VALUE_FALLBACK } from "../../../../features/admin/schemas/firestore.js";
19
+ import { CHECKOUT_ERROR_CODES } from "../../../../errors/error-codes.js";
18
20
  import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository.js";
19
21
  import { sendOrderConfirmationEmail } from "../../../../features/contact/server.js";
20
22
  import { splitCartIntoOrderGroups } from "../../../../features/orders/index.js";
@@ -870,6 +872,39 @@ export async function createCheckoutOrderAction(input) {
870
872
  }
871
873
  }
872
874
  }
875
+ /*
876
+ * COD value cap — the SERVER half of the gate.
877
+ *
878
+ * 🛑 The checkout UI already disables the COD option above the cap and says
879
+ * why. That is an affordance, not enforcement: this action is reachable by
880
+ * POST with `paymentMethod: "cod"` regardless of what the page rendered, and
881
+ * the whole point of the cap is the cash exposure on a refusal at the door.
882
+ * A limit enforced only in the browser is not a limit (Root Cause #46 — a
883
+ * field the schema accepts but the handler never verifies).
884
+ *
885
+ * Measured against the WHOLE checkout batch for the same reason the OTP gate
886
+ * above is: per-seller orders are a split of one cart, so capping each one
887
+ * would let a buyer clear a ₹3,000 limit with a ₹9,000 cart across three
888
+ * sellers. Same `cartItems` reduction, deliberately not shared with the OTP
889
+ * block — that one skips COD entirely, so the two can never be one test.
890
+ *
891
+ * `>` not `>=`: the setting is a maximum, so a cart exactly at the cap is
892
+ * allowed. The client comparison is spelled the same way.
893
+ */
894
+ if (!adminBypass && paymentMethod === "cod") {
895
+ const codMax = siteSettings?.payment?.codMaxOrderValue;
896
+ const effectiveCodMax = typeof codMax === "number" ? codMax : COD_MAX_ORDER_VALUE_FALLBACK;
897
+ const cartTotalForCodCheck = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
898
+ if (cartTotalForCodCheck > effectiveCodMax) {
899
+ failedCheckoutRepository
900
+ .logCheckout(uid, "cod_over_limit", "Cart exceeds the COD value cap", {
901
+ addressId: addressId ?? "",
902
+ paymentMethod,
903
+ })
904
+ .catch(() => undefined);
905
+ throw new ApiError(400, CHECKOUT_ERROR_CODES.COD_LIMIT_EXCEEDED);
906
+ }
907
+ }
873
908
  const isDigitalCart = cartIsDigitalOnly(cartItems);
874
909
  let shippingAddress;
875
910
  let resolvedAddress = null;
@@ -1303,6 +1338,31 @@ export async function previewCheckoutPricing(input) {
1303
1338
  // One capped commission for the whole checkout, on every payment method —
1304
1339
  // deliberately not per store, so it is not added to the per-store rows above.
1305
1340
  const { platformFee, gstOnFee } = computeCheckoutFees(cartSubtotal, commissions);
1341
+ /*
1342
+ * 🛑 `gstAmount` is NOT a term, and leaving it in was a shown-vs-charged
1343
+ * drift waiting on a settings flag.
1344
+ *
1345
+ * The per-store `total` above already excludes it, and its own field comment
1346
+ * says so in capitals ("Deliberately NOT added to `total` below"). The
1347
+ * CART-level total here added it anyway, so the two disagreed with each
1348
+ * other and the cart-level one disagreed with `createOrderForGroup`, whose
1349
+ * `orderTotal` excludes product GST for the reason set out there: prices are
1350
+ * tax-INCLUSIVE, so the tax is already inside `cartSubtotal` and adding it
1351
+ * charges it twice.
1352
+ *
1353
+ * It has never fired, which is the only reason it survived: `gstAmount` is
1354
+ * computed solely when `siteSettings.gst.enabled` is true, and that is
1355
+ * deliberately false until a real GSTIN is registered (see the seed's note —
1356
+ * `/api/admin/site` refuses to enable it without a valid check digit). So on
1357
+ * the day someone enters a registration, every cart would have quietly
1358
+ * quoted ~5% more than the order it previews. Measured on the live ₹20,000
1359
+ * listing (gstRate 5, HSN 95030020): the preview would have read ₹952.38
1360
+ * over the amount charged.
1361
+ *
1362
+ * `gstOnFee` IS a term, and the asymmetry is the same one `createOrderForGroup`
1363
+ * documents: that is GST on OUR commission, billed on top of the fee, not
1364
+ * tax on the goods.
1365
+ */
1306
1366
  const total = Math.max(0, cartSubtotal - couponDiscount) +
1307
1367
  shippingFee +
1308
1368
  codHandlingFee +
@@ -1310,8 +1370,7 @@ export async function previewCheckoutPricing(input) {
1310
1370
  shipmentProtectionFeeTotal +
1311
1371
  saleProtectionFeeTotal +
1312
1372
  platformFee +
1313
- gstOnFee +
1314
- gstAmount;
1373
+ gstOnFee;
1315
1374
  return {
1316
1375
  subtotal: cartSubtotal,
1317
1376
  shippingFee,
@@ -4,7 +4,7 @@ export declare const CHECKOUT_DEFAULT_COMMISSIONS: {
4
4
  readonly minimumTransactionFee: 0;
5
5
  readonly platformFeeMax: 10;
6
6
  readonly codDepositPercent: 10;
7
- readonly codHandlingFeeMin: 200;
7
+ readonly codHandlingFeeCap: 200;
8
8
  readonly codHandlingFeePercent: 10;
9
9
  readonly giftWrapFeeEnabled: true;
10
10
  readonly giftWrapFee: 49;
@@ -8,7 +8,7 @@ export const CHECKOUT_DEFAULT_COMMISSIONS = {
8
8
  minimumTransactionFee: 0,
9
9
  platformFeeMax: 10,
10
10
  codDepositPercent: 10,
11
- codHandlingFeeMin: 200,
11
+ codHandlingFeeCap: 200,
12
12
  codHandlingFeePercent: 10,
13
13
  // Mirrors the SiteSettingsDocument defaults, which enable every add-on.
14
14
  // Consequence of the mirror: if the settings read fails, an add-on already
@@ -35,7 +35,7 @@ export interface BuyerFacingFees {
35
35
  saleProtectionFeeEnabled?: boolean;
36
36
  saleProtectionFeePercent?: number;
37
37
  saleProtectionFeeMin?: number;
38
- codHandlingFeeMin?: number;
38
+ codHandlingFeeCap?: number;
39
39
  codHandlingFeePercent?: number;
40
40
  codDepositPercent?: number;
41
41
  }
@@ -21,7 +21,7 @@ export function toBuyerFacingFees(commissions) {
21
21
  saleProtectionFeeEnabled: commissions.saleProtectionFeeEnabled,
22
22
  saleProtectionFeePercent: commissions.saleProtectionFeePercent,
23
23
  saleProtectionFeeMin: commissions.saleProtectionFeeMin,
24
- codHandlingFeeMin: commissions.codHandlingFeeMin,
24
+ codHandlingFeeCap: commissions.codHandlingFeeCap,
25
25
  codHandlingFeePercent: commissions.codHandlingFeePercent,
26
26
  codDepositPercent: commissions.codDepositPercent,
27
27
  };
@@ -75,12 +75,44 @@ export interface PayoutDeduction {
75
75
  }
76
76
  export declare function computePayoutDeduction(grossAmount: number, commissions: FeeCommissionRates): PayoutDeduction;
77
77
  export interface CodHandlingFeeRates {
78
- /** Optional so existing `siteSettings` documents saved before this field existed don't produce NaN — falls back to ₹200. */
79
- codHandlingFeeMin?: number;
78
+ /**
79
+ * Rupee CEILING on the COD handling fee. Optional so a `siteSettings`
80
+ * document saved before this field existed does not produce NaN — falls
81
+ * back to ₹200.
82
+ */ codHandlingFeeCap?: number;
80
83
  /** Optional for the same reason — falls back to 10%. */
81
84
  codHandlingFeePercent?: number;
82
85
  }
83
- /** COD handling fee charged to the buyer: max(fixed floor, subtotal × percent). */
86
+ /**
87
+ * COD handling fee charged to the buyer: **min(subtotal × percent, cap)**.
88
+ *
89
+ * 🛑 The ₹200 is a CEILING, and it used to be applied as a FLOOR
90
+ * (`Math.max(min, percentFee)`) by a field called `codHandlingFeeMin`. The
91
+ * percentage then ran away on anything expensive: a ₹20,000 listing was
92
+ * charged **₹2,000** of "handling", which is not what handling a parcel costs
93
+ * — it is a tenth of the order. Measured on the live settings document
94
+ * (`codHandlingFeeMin: 200`, `codHandlingFeePercent: 10`) in the cart, and the
95
+ * figure was real: the buyer was quoted ₹22,011.80 on a ₹20,000 item.
96
+ *
97
+ * 🛑 What the fee IS, which is what makes the cap the correct shape: it is a
98
+ * TOKEN covering our cost of having shipped a parcel for free when the buyer
99
+ * does not pay on delivery. That cost is a courier's return leg — it scales
100
+ * with the parcel, not with the price of what is inside it. A ₹20,000 top and
101
+ * a ₹2,000 one cost the same to send back, so a fee that is 10× larger on the
102
+ * first is not recovering a cost, it is a surcharge on being expensive.
103
+ *
104
+ * The percentage survives because it is right at the small end — ₹150 of
105
+ * handling on a ₹300 order would exceed half the order — so the rule is "10%,
106
+ * but never more than ₹200". Below ₹2,000 the percentage binds; above it the
107
+ * cap does.
108
+ *
109
+ * The field was RENAMED rather than reinterpreted. A `…Min` read as a maximum
110
+ * is Root Cause #51's shape — a label describing the reverse of what the code
111
+ * does — and the next person to read the settings document would have had no
112
+ * way to tell. A live document carrying the old key now falls through to
113
+ * `DEFAULT_COD_HANDLING_FEE_CAP`, which is the same ₹200, so the rename
114
+ * changes no number on its own.
115
+ */
84
116
  export declare function computeCodHandlingFee(subtotal: number, rates: CodHandlingFeeRates): number;
85
117
  export interface GiftWrapFeeRates {
86
118
  /** Admin master toggle — the addon isn't charged (even if the buyer selected it) unless this is true. */
@@ -73,14 +73,44 @@ export function computePayoutDeduction(grossAmount, commissions) {
73
73
  const netAmount = Math.max(0, roundRupees(grossAmount - totalDeduction));
74
74
  return { platformFee, gatewayFee, gstOnFee, totalDeduction, netAmount };
75
75
  }
76
- const DEFAULT_COD_HANDLING_FEE_MIN = 200;
76
+ /** Buyer-facing COD handling ceiling, in decimal rupees. */
77
+ const DEFAULT_COD_HANDLING_FEE_CAP = 200;
77
78
  const DEFAULT_COD_HANDLING_FEE_PERCENT = 10;
78
- /** COD handling fee charged to the buyer: max(fixed floor, subtotal × percent). */
79
+ /**
80
+ * COD handling fee charged to the buyer: **min(subtotal × percent, cap)**.
81
+ *
82
+ * 🛑 The ₹200 is a CEILING, and it used to be applied as a FLOOR
83
+ * (`Math.max(min, percentFee)`) by a field called `codHandlingFeeMin`. The
84
+ * percentage then ran away on anything expensive: a ₹20,000 listing was
85
+ * charged **₹2,000** of "handling", which is not what handling a parcel costs
86
+ * — it is a tenth of the order. Measured on the live settings document
87
+ * (`codHandlingFeeMin: 200`, `codHandlingFeePercent: 10`) in the cart, and the
88
+ * figure was real: the buyer was quoted ₹22,011.80 on a ₹20,000 item.
89
+ *
90
+ * 🛑 What the fee IS, which is what makes the cap the correct shape: it is a
91
+ * TOKEN covering our cost of having shipped a parcel for free when the buyer
92
+ * does not pay on delivery. That cost is a courier's return leg — it scales
93
+ * with the parcel, not with the price of what is inside it. A ₹20,000 top and
94
+ * a ₹2,000 one cost the same to send back, so a fee that is 10× larger on the
95
+ * first is not recovering a cost, it is a surcharge on being expensive.
96
+ *
97
+ * The percentage survives because it is right at the small end — ₹150 of
98
+ * handling on a ₹300 order would exceed half the order — so the rule is "10%,
99
+ * but never more than ₹200". Below ₹2,000 the percentage binds; above it the
100
+ * cap does.
101
+ *
102
+ * The field was RENAMED rather than reinterpreted. A `…Min` read as a maximum
103
+ * is Root Cause #51's shape — a label describing the reverse of what the code
104
+ * does — and the next person to read the settings document would have had no
105
+ * way to tell. A live document carrying the old key now falls through to
106
+ * `DEFAULT_COD_HANDLING_FEE_CAP`, which is the same ₹200, so the rename
107
+ * changes no number on its own.
108
+ */
79
109
  export function computeCodHandlingFee(subtotal, rates) {
80
- const min = rates.codHandlingFeeMin ?? DEFAULT_COD_HANDLING_FEE_MIN;
110
+ const cap = rates.codHandlingFeeCap ?? DEFAULT_COD_HANDLING_FEE_CAP;
81
111
  const percent = rates.codHandlingFeePercent ?? DEFAULT_COD_HANDLING_FEE_PERCENT;
82
112
  const percentFee = roundRupees(subtotal * (percent / 100));
83
- return Math.max(min, percentFee);
113
+ return Math.min(percentFee, cap);
84
114
  }
85
115
  const DEFAULT_GIFT_WRAP_FEE = 49;
86
116
  /** Flat gift-wrap addon fee — charged only when the buyer opted in AND the admin has the addon enabled. */
@@ -22,6 +22,21 @@ export declare const BID_ERROR_CODES: {
22
22
  /** The auction's clock ran out mid-checkout, so it settled to a real bidder. */
23
23
  readonly AUCTION_ENDED_BEFORE_CHECKOUT: "BID_AUCTION_ENDED_BEFORE_CHECKOUT";
24
24
  };
25
+ /**
26
+ * Checkout refusals that are BUSINESS rules rather than malformed requests.
27
+ *
28
+ * 🛑 Registered here, not left as a bare string at the throw site. The code is
29
+ * what `toUserMessage` resolves through `errors.codes.*`, and an unregistered
30
+ * one terminates at `errors.codes.UNKNOWN` — so a buyer refused for a reason
31
+ * the site can explain in one sentence would instead read "Something went
32
+ * wrong. Please try again." and retry the same cart forever.
33
+ * `audit-error-display-i18n` holds both ends: an enum value with no
34
+ * `messages/en.json` key fails, and so does a key with no enum value.
35
+ */
36
+ export declare const CHECKOUT_ERROR_CODES: {
37
+ /** Cart total exceeds `siteSettings.payment.codMaxOrderValue`. */
38
+ readonly COD_LIMIT_EXCEEDED: "COD_LIMIT_EXCEEDED";
39
+ };
25
40
  export declare const OFFER_ERROR_CODES: {
26
41
  readonly DISABLED: "OFFER_DISABLED";
27
42
  readonly PRICE_TOO_LOW: "OFFER_PRICE_TOO_LOW";
@@ -22,6 +22,21 @@ export const BID_ERROR_CODES = {
22
22
  /** The auction's clock ran out mid-checkout, so it settled to a real bidder. */
23
23
  AUCTION_ENDED_BEFORE_CHECKOUT: "BID_AUCTION_ENDED_BEFORE_CHECKOUT",
24
24
  };
25
+ /**
26
+ * Checkout refusals that are BUSINESS rules rather than malformed requests.
27
+ *
28
+ * 🛑 Registered here, not left as a bare string at the throw site. The code is
29
+ * what `toUserMessage` resolves through `errors.codes.*`, and an unregistered
30
+ * one terminates at `errors.codes.UNKNOWN` — so a buyer refused for a reason
31
+ * the site can explain in one sentence would instead read "Something went
32
+ * wrong. Please try again." and retry the same cart forever.
33
+ * `audit-error-display-i18n` holds both ends: an enum value with no
34
+ * `messages/en.json` key fails, and so does a key with no enum value.
35
+ */
36
+ export const CHECKOUT_ERROR_CODES = {
37
+ /** Cart total exceeds `siteSettings.payment.codMaxOrderValue`. */
38
+ COD_LIMIT_EXCEEDED: "COD_LIMIT_EXCEEDED",
39
+ };
25
40
  export const OFFER_ERROR_CODES = {
26
41
  DISABLED: "OFFER_DISABLED",
27
42
  PRICE_TOO_LOW: "OFFER_PRICE_TOO_LOW",
@@ -1,5 +1,5 @@
1
1
  export { AppError } from "./base-error";
2
- export { ERROR_CODES, BID_ERROR_CODES, OFFER_ERROR_CODES } from "./error-codes";
2
+ export { ERROR_CODES, BID_ERROR_CODES, OFFER_ERROR_CODES, CHECKOUT_ERROR_CODES } from "./error-codes";
3
3
  export type { ErrorCode } from "./error-codes";
4
4
  export { ERROR_MESSAGES } from "./messages";
5
5
  export { ApiError } from "./api-error";
@@ -1,5 +1,5 @@
1
1
  export { AppError } from "./base-error.js";
2
- export { ERROR_CODES, BID_ERROR_CODES, OFFER_ERROR_CODES } from "./error-codes.js";
2
+ export { ERROR_CODES, BID_ERROR_CODES, OFFER_ERROR_CODES, CHECKOUT_ERROR_CODES } from "./error-codes.js";
3
3
  export { ERROR_MESSAGES } from "./messages.js";
4
4
  // Note: appkit/src/errors has two ERROR_MESSAGE patterns:
5
5
  // - ERROR_CODES.AUTH_001 → "Invalid email or password" (code-based)
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { sortBy, sieveFilter, SIEVE_OP } from "../../../utils/sieve-builder.js";
4
4
  import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints.js";
5
5
  import { ROUTES } from "../../../constants/index.js";
@@ -9,6 +9,7 @@ import { buildBidDetailFields, bidStatusBadge } from "../../auctions/components/
9
9
  import { FieldSelect } from "../../../ui/forms/index.js";
10
10
  import { AuctionBidsTable } from "../../auctions/components/AuctionBidsTable.js";
11
11
  import { DataListingView } from "../../admin/components/DataListingView.js";
12
+ import { useListingTypeFlags } from "../../../react/hooks/useListingTypeFlags.js";
12
13
  const SORT_OPTIONS = [
13
14
  { value: sortBy("bidDate", "DESC"), label: "Newest first" },
14
15
  { value: "bidDate", label: "Oldest first" },
@@ -28,6 +29,7 @@ export function UserBidsView() {
28
29
  // not an affordance: it is not row-scoped, and it only shows when there are
29
30
  // no bids at all.
30
31
  const [detail, setDetail] = useState(null);
32
+ const auctionsEnabled = useListingTypeFlags().auction;
31
33
  const config = {
32
34
  portal: "user",
33
35
  title: "My Bids",
@@ -52,7 +54,7 @@ export function UserBidsView() {
52
54
  if (isLoading) {
53
55
  return (_jsx(Stack, { gap: "md", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Div, { className: "h-16 animate-pulse border border-[var(--appkit-color-border)]", rounded: "xl" }, i))) }));
54
56
  }
55
- return (_jsx(AuctionBidsTable, { bids: rows, portal: "buyer", onRowClick: (bid) => setDetail(bid), totalPages: 1, currentPage: 1, onPageChange: () => { }, emptyLabel: _jsxs(Span, { children: ["You haven't placed any bids yet.", " ", _jsx(TextLink, { href: String(ROUTES.PUBLIC.AUCTIONS), children: "Browse auctions" })] }) }));
57
+ return (_jsx(AuctionBidsTable, { bids: rows, portal: "buyer", onRowClick: (bid) => setDetail(bid), totalPages: 1, currentPage: 1, onPageChange: () => { }, emptyLabel: _jsxs(Span, { children: ["You haven't placed any bids yet.", auctionsEnabled && (_jsxs(_Fragment, { children: [" ", _jsx(TextLink, { href: String(ROUTES.PUBLIC.AUCTIONS), children: "Browse auctions" })] }))] }) }));
56
58
  },
57
59
  };
58
60
  const badge = detail ? bidStatusBadge(detail.status) : null;
@@ -18,6 +18,12 @@ import { ThemeManagerView, } from "../../site-settings/components/ThemeManagerVi
18
18
  import { DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME } from "../../../tokens/themes/index.js";
19
19
  import { DEFAULT_AUCTION_BID_INCREMENT_TIERS, } from "../../../_internal/shared/features/auctions/config.js";
20
20
  import { validateGstEnablement } from "../../../_internal/shared/features/site-settings/gst-registration.js";
21
+ /*
22
+ * 🛑 The SAME constant the checkout page and the server-side gate read. An
23
+ * admin screen that defaulted its own input to a different number would show
24
+ * a cap that is not the cap being enforced until somebody pressed Save.
25
+ */
26
+ import { COD_MAX_ORDER_VALUE_FALLBACK } from "../schemas/firestore.js";
21
27
  import { siteSettingsFormSchema } from "../schemas/site-settings-form.js";
22
28
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary.js";
23
29
  import { SectionForm } from "../../shell/index.js";
@@ -279,6 +285,7 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
279
285
  const [upiManualEnabled, setUpiManualEnabled] = React.useState(true);
280
286
  const [codEnabled, setCodEnabled] = React.useState(true);
281
287
  const [otpCheckoutThreshold, setOtpCheckoutThreshold] = React.useState(5000);
288
+ const [codMaxOrderValue, setCodMaxOrderValue] = React.useState(COD_MAX_ORDER_VALUE_FALLBACK);
282
289
  // Per-listing-type / per-category-type visibility flags.
283
290
  const [listingTypeFlags, setListingTypeFlags] = React.useState({});
284
291
  const [categoryTypeFlags, setCategoryTypeFlags] = React.useState({});
@@ -472,6 +479,7 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
472
479
  setUpiManualEnabled(s.payment?.upiManualEnabled ?? true);
473
480
  setCodEnabled(s.payment?.codEnabled ?? true);
474
481
  setOtpCheckoutThreshold(s.payment?.otpCheckoutThreshold ?? 5000);
482
+ setCodMaxOrderValue(s.payment?.codMaxOrderValue ?? COD_MAX_ORDER_VALUE_FALLBACK);
475
483
  setListingTypeFlags(s.listings?.listingTypes ?? {});
476
484
  setCategoryTypeFlags(s.listings?.categoryTypes ?? {});
477
485
  setNotifEmailTypes(s.notificationChannels?.email?.types ?? []);
@@ -667,7 +675,7 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
667
675
  },
668
676
  integrations: { googleAnalyticsId: gaMeasurementId, facebookPixelId: fbPixelId, gtmContainerId, clarityProjectId },
669
677
  shipping: { freeShippingThreshold, defaultCarrier, maxDeliveryRadius },
670
- payment: { razorpayEnabled, phonepeEnabled, upiManualEnabled, codEnabled, otpCheckoutThreshold },
678
+ payment: { razorpayEnabled, phonepeEnabled, upiManualEnabled, codEnabled, codMaxOrderValue, otpCheckoutThreshold },
671
679
  auctionConfig: { bidIncrementTiers: bidIncrementTiers, autoExtendWindowMinutes: autoExtendWindow, settlementGracePeriodHours: settlementGrace },
672
680
  platformLimits: { maxProductsPerStore, maxCustomFieldsPerProduct: maxCustomFields, orderCancellationWindowHours: orderCancelWindow },
673
681
  legalPages: { terms: termsHtml, privacy: privacyHtml, refundPolicy: refundHtml, shipping: shippingPolicyHtml, cookies: cookieHtml, ethics: ethicsHtml, codeOfConduct: conductHtml },
@@ -791,7 +799,15 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
791
799
  // The plugin's own display label, not the raw key — the
792
800
  // literal version rendered "pre-order" and
793
801
  // "digital-code" at an admin.
794
- label: pluginFor(key).typeLabel, checked: listingTypeFlags[key] ?? true, onChange: (v) => setListingTypeFlags((prev) => ({ ...prev, [key]: v })) }, key))) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Category types" }), _jsx(Grid, { cols: 2, gap: "sm", children: CATEGORY_TYPE_KEYS.map((key) => (_jsx(Toggle, { label: key, checked: categoryTypeFlags[key] ?? true, onChange: (v) => setCategoryTypeFlags((prev) => ({ ...prev, [key]: v })) }, key))) })] })] }) }), _jsx(TabsContent, { value: "integrations", children: _jsxs(SettingsTabForm, { id: "integrations", label: "Integrations", children: [_jsx(Text, { size: "xs", color: "muted", children: "Keys are masked in transit and stored encrypted. Click Reveal to view." }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Razorpay" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Razorpay Key ID", value: razorpayKeyId, onChange: setRazorpayKeyId, placeholder: "rzp_test_\u2026 / rzp_live_\u2026" }), _jsx(MaskedInput, { label: "Razorpay Key Secret", value: razorpayKeySecret, onChange: setRazorpayKeySecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "Razorpay Webhook Secret", value: razorpayWebhookSecret, onChange: setRazorpayWebhookSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "PhonePe" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "PhonePe Client ID", value: phonepeClientId, onChange: setPhonepeClientId, placeholder: "MERCHANTID_\u2026" }), _jsx(MaskedInput, { label: "PhonePe Client Secret", value: phonepeClientSecret, onChange: setPhonepeClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(Input, { label: "PhonePe Client Version", value: phonepeClientVersion, onChange: (e) => setPhonepeClientVersion(e.target.value), placeholder: "1" }), _jsx(Select, { label: "PhonePe Environment", options: PHONEPE_ENVIRONMENT_OPTIONS, value: phonepeEnvironment, onValueChange: setPhonepeEnvironment }), _jsx(MaskedInput, { label: "PhonePe Webhook Username", value: phonepeWebhookUsername, onChange: setPhonepeWebhookUsername, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "PhonePe Webhook Password", value: phonepeWebhookPassword, onChange: setPhonepeWebhookPassword, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "SMTP / Email" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "SMTP host", value: smtpHost, onChange: (e) => setSmtpHost(e.target.value), placeholder: "smtp.sendgrid.net" }), _jsx(Input, { label: "SMTP port", value: smtpPort, onChange: (e) => setSmtpPort(e.target.value), type: "number", placeholder: "587" }), _jsx(Input, { label: "SMTP user", value: smtpUser, onChange: (e) => setSmtpUser(e.target.value), placeholder: "apikey" })] }), _jsx(Input, { label: "From address", value: smtpFrom, onChange: (e) => setSmtpFrom(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Text, { size: "xs", color: "muted", children: "Transactional email is delivered through Resend \u2014 set the Resend API key below. These SMTP fields are stored but not currently used for delivery." })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Google Places (store reviews)" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Google Maps API key", value: googleMapsApiKey, onChange: setGoogleMapsApiKey, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(Input, { label: "Default Google Place ID", value: googlePlaceId, onChange: (e) => setGooglePlaceId(e.target.value), placeholder: "ChIJ\u2026" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Analytics & Tracking" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Google Analytics ID", value: gaMeasurementId, onChange: (e) => setGaMeasurementId(e.target.value), placeholder: "G-XXXXXXXXXX" }), _jsx(Input, { label: "Facebook Pixel ID", value: fbPixelId, onChange: (e) => setFbPixelId(e.target.value), placeholder: "XXXXXXXXXXXXXXXX" }), _jsx(Input, { label: "GTM Container ID", value: gtmContainerId, onChange: (e) => setGtmContainerId(e.target.value), placeholder: "GTM-XXXXXXX" }), _jsx(Input, { label: "Microsoft Clarity Project ID", value: clarityProjectId, onChange: (e) => setClarityProjectId(e.target.value), placeholder: "abcdefghij", helperText: "Heatmaps and session replay. Recording is strictly masked \u2014 no text or input values are captured. Leave blank to keep Clarity off." })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Meta (Instagram & Facebook Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Used by the Social Feed section to fetch Instagram and Facebook posts via Meta Graph API v19." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Page Access Token", value: metaPageAccessToken, onChange: setMetaPageAccessToken, placeholder: "EAAxxxxxxx\u2026" }), _jsx(Input, { label: "Page ID (or handle)", value: metaPageId, onChange: (e) => setMetaPageId(e.target.value), placeholder: "letitrip" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "TikTok for Developers (Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Client credentials + long-lived access token from TikTok for Developers. Used to list your account's public videos." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Client Key", value: tiktokClientKey, onChange: setTiktokClientKey, placeholder: "aw\u2026" }), _jsx(MaskedInput, { label: "Client Secret", value: tiktokClientSecret, onChange: setTiktokClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "Access Token (long-lived)", value: tiktokAccessToken, onChange: setTiktokAccessToken, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "DeviantArt OAuth2 (Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Client credentials for DeviantArt gallery fetching (client-credentials OAuth2 flow \u2014 no user login required)." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Client ID", value: deviantartClientId, onChange: setDeviantartClientId, placeholder: "1234" }), _jsx(MaskedInput, { label: "Client Secret", value: deviantartClientSecret, onChange: setDeviantartClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] })] }) }), _jsx(TabsContent, { value: "shipping", children: _jsxs(SettingsTabForm, { id: "shipping", label: "Shipping", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Payment methods" }), _jsx(Div, { id: "setting-razorpay-enabled", children: _jsx(Toggle, { label: "Razorpay (online card/UPI/netbanking) enabled \u2014 needs Key ID + Key Secret under Credentials", checked: razorpayEnabled, onChange: setRazorpayEnabled }) }), _jsx(Div, { id: "setting-phonepe-enabled", children: _jsx(Toggle, { label: "PhonePe (online card/UPI) enabled \u2014 ignored while Razorpay is on; only one online gateway runs at a time", checked: phonepeEnabled, onChange: setPhonepeEnabled }) }), _jsx(Div, { id: "setting-manual-payment-enabled", children: _jsx(Toggle, { label: "Manual UPI/bank transfer enabled", checked: upiManualEnabled, onChange: setUpiManualEnabled }) }), _jsx(Div, { id: "setting-cod-enabled", children: _jsx(Toggle, { label: "Cash on delivery (COD) enabled", checked: codEnabled, onChange: setCodEnabled }) }), _jsx(Input, { label: "Free shipping threshold (\u20B9)", value: String(freeShippingThreshold), onChange: (e) => setFreeShippingThreshold(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Orders above this amount get free shipping." }), _jsx(Select, { label: "Default carrier", options: CARRIER_OPTIONS, value: defaultCarrier, onValueChange: setDefaultCarrier }), _jsx(Input, { label: "Max delivery radius (km, 0 = no limit)", value: String(maxDeliveryRadius), onChange: (e) => setMaxDeliveryRadius(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "High-value checkout OTP threshold (\u20B9)", helperText: "Carts at or above this subtotal require an extra OTP verification step before payment (0 = disabled).", value: String(otpCheckoutThreshold), onChange: (e) => setOtpCheckoutThreshold(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Text, { size: "sm", weight: "medium", color: "muted", className: "pt-[var(--appkit-space-2)]", children: "Seller shipping charge defaults" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Seller shipping fixed (\u20B9)", helperText: "Flat shipping charge collected on the seller's behalf.", value: String(sellerShippingFixed), onChange: (e) => setSellerShippingFixed(parseFloat(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Platform shipping (%)", helperText: "Platform's own shipping markup, as a % of order value.", value: String(platformShippingPercent), onChange: (e) => setPlatformShippingPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 }), _jsx(Input, { label: "Platform shipping minimum (\u20B9)", helperText: "Floor for the platform shipping markup.", value: String(platformShippingFixedMin), onChange: (e) => setPlatformShippingFixedMin(parseFloat(e.target.value) || 0), type: "number", min: 0 })] })] }) }), _jsx(TabsContent, { value: "auction", children: _jsxs(SettingsTabForm, { id: "auction", label: "Auction", children: [_jsxs(Stack, { gap: "sm", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Bid increment tiers" }), _jsx(Text, { size: "xs", color: "muted", children: "The minimum jump a new bid must clear, based on the current bid amount. A seller's per-listing override can require more than the matching tier, but can never undercut it." }), bidIncrementTiers.map((tier, i) => {
802
+ label: pluginFor(key).typeLabel, checked: listingTypeFlags[key] ?? true, onChange: (v) => setListingTypeFlags((prev) => ({ ...prev, [key]: v })) }, key))) })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Category types" }), _jsx(Grid, { cols: 2, gap: "sm", children: CATEGORY_TYPE_KEYS.map((key) => (_jsx(Toggle, { label: key, checked: categoryTypeFlags[key] ?? true, onChange: (v) => setCategoryTypeFlags((prev) => ({ ...prev, [key]: v })) }, key))) })] })] }) }), _jsx(TabsContent, { value: "integrations", children: _jsxs(SettingsTabForm, { id: "integrations", label: "Integrations", children: [_jsx(Text, { size: "xs", color: "muted", children: "Keys are masked in transit and stored encrypted. Click Reveal to view." }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Razorpay" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Razorpay Key ID", value: razorpayKeyId, onChange: setRazorpayKeyId, placeholder: "rzp_test_\u2026 / rzp_live_\u2026" }), _jsx(MaskedInput, { label: "Razorpay Key Secret", value: razorpayKeySecret, onChange: setRazorpayKeySecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "Razorpay Webhook Secret", value: razorpayWebhookSecret, onChange: setRazorpayWebhookSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "PhonePe" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "PhonePe Client ID", value: phonepeClientId, onChange: setPhonepeClientId, placeholder: "MERCHANTID_\u2026" }), _jsx(MaskedInput, { label: "PhonePe Client Secret", value: phonepeClientSecret, onChange: setPhonepeClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(Input, { label: "PhonePe Client Version", value: phonepeClientVersion, onChange: (e) => setPhonepeClientVersion(e.target.value), placeholder: "1" }), _jsx(Select, { label: "PhonePe Environment", options: PHONEPE_ENVIRONMENT_OPTIONS, value: phonepeEnvironment, onValueChange: setPhonepeEnvironment }), _jsx(MaskedInput, { label: "PhonePe Webhook Username", value: phonepeWebhookUsername, onChange: setPhonepeWebhookUsername, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "PhonePe Webhook Password", value: phonepeWebhookPassword, onChange: setPhonepeWebhookPassword, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "SMTP / Email" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "SMTP host", value: smtpHost, onChange: (e) => setSmtpHost(e.target.value), placeholder: "smtp.sendgrid.net" }), _jsx(Input, { label: "SMTP port", value: smtpPort, onChange: (e) => setSmtpPort(e.target.value), type: "number", placeholder: "587" }), _jsx(Input, { label: "SMTP user", value: smtpUser, onChange: (e) => setSmtpUser(e.target.value), placeholder: "apikey" })] }), _jsx(Input, { label: "From address", value: smtpFrom, onChange: (e) => setSmtpFrom(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Text, { size: "xs", color: "muted", children: "Transactional email is delivered through Resend \u2014 set the Resend API key below. These SMTP fields are stored but not currently used for delivery." })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Google Places (store reviews)" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Google Maps API key", value: googleMapsApiKey, onChange: setGoogleMapsApiKey, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(Input, { label: "Default Google Place ID", value: googlePlaceId, onChange: (e) => setGooglePlaceId(e.target.value), placeholder: "ChIJ\u2026" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Analytics & Tracking" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Google Analytics ID", value: gaMeasurementId, onChange: (e) => setGaMeasurementId(e.target.value), placeholder: "G-XXXXXXXXXX" }), _jsx(Input, { label: "Facebook Pixel ID", value: fbPixelId, onChange: (e) => setFbPixelId(e.target.value), placeholder: "XXXXXXXXXXXXXXXX" }), _jsx(Input, { label: "GTM Container ID", value: gtmContainerId, onChange: (e) => setGtmContainerId(e.target.value), placeholder: "GTM-XXXXXXX" }), _jsx(Input, { label: "Microsoft Clarity Project ID", value: clarityProjectId, onChange: (e) => setClarityProjectId(e.target.value), placeholder: "abcdefghij", helperText: "Heatmaps and session replay. Recording is strictly masked \u2014 no text or input values are captured. Leave blank to keep Clarity off." })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Meta (Instagram & Facebook Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Used by the Social Feed section to fetch Instagram and Facebook posts via Meta Graph API v19." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Page Access Token", value: metaPageAccessToken, onChange: setMetaPageAccessToken, placeholder: "EAAxxxxxxx\u2026" }), _jsx(Input, { label: "Page ID (or handle)", value: metaPageId, onChange: (e) => setMetaPageId(e.target.value), placeholder: "letitrip" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "TikTok for Developers (Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Client credentials + long-lived access token from TikTok for Developers. Used to list your account's public videos." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Client Key", value: tiktokClientKey, onChange: setTiktokClientKey, placeholder: "aw\u2026" }), _jsx(MaskedInput, { label: "Client Secret", value: tiktokClientSecret, onChange: setTiktokClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" }), _jsx(MaskedInput, { label: "Access Token (long-lived)", value: tiktokAccessToken, onChange: setTiktokAccessToken, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "DeviantArt OAuth2 (Social Feed)" }), _jsx(Text, { size: "xs", color: "muted", children: "Client credentials for DeviantArt gallery fetching (client-credentials OAuth2 flow \u2014 no user login required)." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(MaskedInput, { label: "Client ID", value: deviantartClientId, onChange: setDeviantartClientId, placeholder: "1234" }), _jsx(MaskedInput, { label: "Client Secret", value: deviantartClientSecret, onChange: setDeviantartClientSecret, placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" })] })] })] }) }), _jsx(TabsContent, { value: "shipping", children: _jsxs(SettingsTabForm, { id: "shipping", label: "Shipping", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "Payment methods" }), _jsx(Div, { id: "setting-razorpay-enabled", children: _jsx(Toggle, { label: "Razorpay (online card/UPI/netbanking) enabled \u2014 needs Key ID + Key Secret under Credentials", checked: razorpayEnabled, onChange: setRazorpayEnabled }) }), _jsx(Div, { id: "setting-phonepe-enabled", children: _jsx(Toggle, { label: "PhonePe (online card/UPI) enabled \u2014 ignored while Razorpay is on; only one online gateway runs at a time", checked: phonepeEnabled, onChange: setPhonepeEnabled }) }), _jsx(Div, { id: "setting-manual-payment-enabled", children: _jsx(Toggle, { label: "Manual UPI/bank transfer enabled", checked: upiManualEnabled, onChange: setUpiManualEnabled }) }), _jsx(Div, { id: "setting-cod-enabled", children: _jsx(Toggle, { label: "Cash on delivery (COD) enabled", checked: codEnabled, onChange: setCodEnabled }) }), _jsx(Input, { label: "Free shipping threshold (\u20B9)", value: String(freeShippingThreshold), onChange: (e) => setFreeShippingThreshold(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Orders above this amount get free shipping." }), _jsx(Select, { label: "Default carrier", options: CARRIER_OPTIONS, value: defaultCarrier, onValueChange: setDefaultCarrier }), _jsx(Input, { label: "Max delivery radius (km, 0 = no limit)", value: String(maxDeliveryRadius), onChange: (e) => setMaxDeliveryRadius(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "COD maximum order value (\u20B9)",
803
+ /*
804
+ 🛑 A MAXIMUM, sitting directly above a THRESHOLD that is a
805
+ minimum. The helper text says which way each one runs,
806
+ because the two fields look identical and mean opposite
807
+ things — and getting it backwards here either refuses every
808
+ COD order or refuses none.
809
+ */
810
+ helperText: "Carts ABOVE this subtotal cannot pay by cash on delivery; the option is shown disabled with the reason. 0 = COD off for every cart.", value: String(codMaxOrderValue), onChange: (e) => setCodMaxOrderValue(parseInt(e.target.value) || 0), type: "number", min: 0, disabled: !codEnabled }), _jsx(Input, { label: "High-value checkout OTP threshold (\u20B9)", helperText: "Carts at or above this subtotal require an extra OTP verification step before payment (0 = disabled).", value: String(otpCheckoutThreshold), onChange: (e) => setOtpCheckoutThreshold(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Text, { size: "sm", weight: "medium", color: "muted", className: "pt-[var(--appkit-space-2)]", children: "Seller shipping charge defaults" }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Seller shipping fixed (\u20B9)", helperText: "Flat shipping charge collected on the seller's behalf.", value: String(sellerShippingFixed), onChange: (e) => setSellerShippingFixed(parseFloat(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Platform shipping (%)", helperText: "Platform's own shipping markup, as a % of order value.", value: String(platformShippingPercent), onChange: (e) => setPlatformShippingPercent(parseFloat(e.target.value) || 0), type: "number", min: 0, max: 100, step: 0.1 }), _jsx(Input, { label: "Platform shipping minimum (\u20B9)", helperText: "Floor for the platform shipping markup.", value: String(platformShippingFixedMin), onChange: (e) => setPlatformShippingFixedMin(parseFloat(e.target.value) || 0), type: "number", min: 0 })] })] }) }), _jsx(TabsContent, { value: "auction", children: _jsxs(SettingsTabForm, { id: "auction", label: "Auction", children: [_jsxs(Stack, { gap: "sm", rounded: "lg", border: "default", padding: "sm", children: [_jsx(Text, { size: "xs", weight: "medium", color: "muted", children: "Bid increment tiers" }), _jsx(Text, { size: "xs", color: "muted", children: "The minimum jump a new bid must clear, based on the current bid amount. A seller's per-listing override can require more than the matching tier, but can never undercut it." }), bidIncrementTiers.map((tier, i) => {
795
811
  const isLast = i === bidIncrementTiers.length - 1;
796
812
  return (_jsxs(Grid, { align: "end", gap: "xs", className: "grid-cols-12", children: [_jsx(Div, { className: "col-span-5", children: isLast ? (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", weight: "medium", className: "mb-1", children: "Up to (\u20B9)" }), _jsx(Text, { size: "sm", color: "muted", children: "and above" })] })) : (_jsx(Input, { label: "Up to (\u20B9)", type: "number", min: 1, value: String(tier.upTo ?? ""), onChange: (e) => setBidIncrementTiers(bidIncrementTiers.map((t, j) => j === i ? { ...t, upTo: parseInt(e.target.value) || 0 } : t)) })) }), _jsx(Div, { className: "col-span-5", children: _jsx(Input, { label: "Increment (\u20B9)", type: "number", min: 1, value: String(tier.increment), onChange: (e) => setBidIncrementTiers(bidIncrementTiers.map((t, j) => j === i ? { ...t, increment: parseInt(e.target.value) || 0 } : t)) }) }), _jsx(Row, { centered: true, className: "col-span-2", padding: "b-xs", children: _jsx(Button, { variant: "ghost", type: "button", disabled: bidIncrementTiers.length <= 1, onClick: () => setBidIncrementTiers(bidIncrementTiers.filter((_, j) => j !== i)), "aria-label": "Remove tier", children: "\u00D7" }) })] }, i));
797
813
  }), _jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setBidIncrementTiers([
@@ -352,6 +352,26 @@ export interface SiteSettingsDocument extends BaseDocument {
352
352
  phonepeEnabled: boolean;
353
353
  upiManualEnabled: boolean;
354
354
  codEnabled: boolean;
355
+ /**
356
+ * Cart total (rupees) ABOVE which cash on delivery is refused. Default
357
+ * 3000.
358
+ *
359
+ * 🛑 Measured against the CART TOTAL, not an item price. COD risk is the
360
+ * cash a courier carries and the value at stake on a refusal at the door,
361
+ * and neither cares how many lines the order has — a ₹2,000 item ordered
362
+ * three times is a ₹6,000 exposure exactly like one ₹6,000 item. Gating
363
+ * per item would pass that order straight through.
364
+ *
365
+ * 🛑 It is a MAXIMUM, so the comparison is `subtotal > value` — strictly
366
+ * greater. `otpCheckoutThreshold` beside it is a MINIMUM and fires
367
+ * at/above its value. Two money thresholds on one settings block with
368
+ * opposite senses is a genuine trap; the names carry it (`Max` vs
369
+ * `Threshold`) and both comparisons are spelled out at their call sites.
370
+ *
371
+ * `0` disables COD outright at any value, which is a real configuration
372
+ * rather than a bug — `undefined` is what means "no cap".
373
+ */
374
+ codMaxOrderValue?: number;
355
375
  /** Tier PP — cart total (rupees) at/above which checkout requires OTP verification. Applies to all payment methods except COD. Optional — falls back to no OTP gate when unset. */
356
376
  otpCheckoutThreshold?: number;
357
377
  /**
@@ -411,8 +431,13 @@ export interface SiteSettingsDocument extends BaseDocument {
411
431
  /** Razorpay gateway cost % (absorbed by platform, not passed through separately). */
412
432
  gatewayFeePercent: number;
413
433
  codDepositPercent: number;
414
- /** COD handling fee charged to the buyer: max(codHandlingFeeMin, subtotal × codHandlingFeePercent / 100). */
415
- codHandlingFeeMin: number;
434
+ /**
435
+ * COD handling fee charged to the buyer:
436
+ * min(subtotal × codHandlingFeePercent / 100, codHandlingFeeCap).
437
+ *
438
+ * 🛑 A CEILING. It was `codHandlingFeeMin` applied as a floor, which billed
439
+ * ₹2,000 of "handling" on a ₹20,000 order — see `computeCodHandlingFee`.
440
+ */ codHandlingFeeCap: number;
416
441
  codHandlingFeePercent: number;
417
442
  /** Admin master toggle — whether the gift-wrap addon is offered at checkout at all. */
418
443
  giftWrapFeeEnabled: boolean;
@@ -793,6 +818,16 @@ export declare const SITE_SETTINGS_INDEXED_FIELDS: readonly [];
793
818
  * and switch the active default with `defaultLightThemeId` / `defaultDarkThemeId`.
794
819
  */
795
820
  export declare const DEFAULT_SITE_SETTINGS_THEME: SiteSettingsTheme;
821
+ /**
822
+ * Cart total (rupees) above which cash on delivery is refused, when the
823
+ * settings document does not say.
824
+ *
825
+ * 🛑 ONE number, consumed by both the default document below and every reader
826
+ * that has to cope with a settings document written before the field existed.
827
+ * Two spellings of a money limit is how a cap ends up enforced at one value
828
+ * and displayed at another.
829
+ */
830
+ export declare const COD_MAX_ORDER_VALUE_FALLBACK = 3000;
796
831
  export declare const DEFAULT_SITE_SETTINGS_DATA: Partial<SiteSettingsDocument>;
797
832
  /**
798
833
  * 🛑 DEAD, AND IT DISAGREES WITH THE REAL PROJECTION. Do not add to it.
@@ -224,6 +224,16 @@ export const DEFAULT_SITE_SETTINGS_THEME = {
224
224
  defaultLightThemeId: "default-light",
225
225
  defaultDarkThemeId: "default-dark",
226
226
  };
227
+ /**
228
+ * Cart total (rupees) above which cash on delivery is refused, when the
229
+ * settings document does not say.
230
+ *
231
+ * 🛑 ONE number, consumed by both the default document below and every reader
232
+ * that has to cope with a settings document written before the field existed.
233
+ * Two spellings of a money limit is how a cap ends up enforced at one value
234
+ * and displayed at another.
235
+ */
236
+ export const COD_MAX_ORDER_VALUE_FALLBACK = 3000;
227
237
  export const DEFAULT_SITE_SETTINGS_DATA = {
228
238
  id: "global",
229
239
  siteName: "My Store",
@@ -234,6 +244,7 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
234
244
  phonepeEnabled: false,
235
245
  upiManualEnabled: true,
236
246
  codEnabled: true,
247
+ codMaxOrderValue: COD_MAX_ORDER_VALUE_FALLBACK,
237
248
  otpCheckoutThreshold: 5000,
238
249
  smsVerification: false,
239
250
  adminCheckoutBypass: false,
@@ -261,7 +272,7 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
261
272
  platformFeeMax: 10,
262
273
  gatewayFeePercent: 2,
263
274
  codDepositPercent: 10,
264
- codHandlingFeeMin: 200,
275
+ codHandlingFeeCap: 200,
265
276
  codHandlingFeePercent: 10,
266
277
  // The buyer add-ons ship ENABLED. They were `false`, which made
267
278
  // StoreAddonsPicker return null on both the cart and checkout, so a fresh
@@ -21,5 +21,5 @@ export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unava
21
21
  const subtotalLabel = itemCount !== undefined
22
22
  ? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
23
23
  : "Subtotal";
24
- return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }), (preview.gstAmount > 0 || preview.gstOnFee > 0) && (_jsx(Line, { label: "GST", amount: money(preview.gstAmount + preview.gstOnFee) })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: true }) }), errorNote ? (_jsx(Text, { size: "xs", color: "error", children: errorNote })) : (isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" })))] }));
24
+ return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee) })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: true }) }), preview.gstAmount > 0 && (_jsxs(Text, { size: "xs", color: "muted", children: ["Includes GST of ", money(preview.gstAmount)] })), errorNote ? (_jsx(Text, { size: "xs", color: "error", children: errorNote })) : (isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" })))] }));
25
25
  }
@@ -2,7 +2,18 @@
2
2
  * Checkout Feature Firestore Document Types & Constants
3
3
  * Covers: failed checkouts, failed payments
4
4
  */
5
- export type FailedCheckoutReason = "stock_failed" | "address_not_found" | "cart_empty" | "validation_error" | "unknown";
5
+ export type FailedCheckoutReason = "stock_failed" | "address_not_found" | "cart_empty"
6
+ /**
7
+ * The cart exceeded `siteSettings.payment.codMaxOrderValue` and COD was
8
+ * refused server-side.
9
+ *
10
+ * 🛑 Its own reason rather than `validation_error`, because it is the only
11
+ * one here that is a BUSINESS rule an admin can change rather than a
12
+ * malformed request. Folded into `validation_error` it would be invisible,
13
+ * and "how often does the COD cap turn buyers away" is the question that
14
+ * decides whether ₹3,000 is the right number.
15
+ */
16
+ | "cod_over_limit" | "validation_error" | "unknown";
6
17
  export type FailedPaymentReason = "signature_mismatch" | "payment_cancelled" | "amount_mismatch" | "product_unavailable" | "stock_insufficient" | "unknown";
7
18
  export type FailedCheckoutDocument = {
8
19
  id: string;