@mohasinac/appkit 4.54.1 → 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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/checkout/actions.js +61 -2
- package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
- package/dist/_internal/shared/features/checkout/config.js +1 -1
- package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
- package/dist/_internal/shared/features/site-settings/fees.js +1 -1
- package/dist/_internal/shared/fees/calculator.d.ts +35 -3
- package/dist/_internal/shared/fees/calculator.js +34 -4
- package/dist/errors/error-codes.d.ts +15 -0
- package/dist/errors/error-codes.js +15 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/features/account/components/UserBidsView.js +4 -2
- package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
- package/dist/features/admin/schemas/firestore.d.ts +37 -2
- package/dist/features/admin/schemas/firestore.js +12 -1
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/checkout/schemas/firestore.d.ts +12 -1
- package/dist/features/layout/NavbarLayout.js +139 -32
- package/dist/features/orders/schemas/firestore.d.ts +1 -1
- package/dist/features/products/components/ProductDetailPageView.js +2 -25
- package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
- package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
- package/dist/features/products/components/ProductsIndexListing.js +35 -5
- package/dist/features/products/components/ProductsIndexPageView.js +40 -7
- package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
- package/dist/providers/shipping-shiprocket/index.js +34 -10
- package/dist/seed/faq-seed-data.js +1 -1
- package/dist/seed/site-settings-seed-data.js +1 -1
- package/dist/seo/json-ld.d.ts +8 -1
- package/dist/seo/json-ld.js +39 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.55.0",
|
|
4
|
+
"head": "e683e7b08996d66d9b6bef65cf4712b8e63454ea",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": false,
|
|
7
|
-
"builtAt": "2026-10-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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",
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/errors/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
415
|
-
|
|
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
|
-
|
|
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) }),
|
|
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"
|
|
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;
|