@mohasinac/appkit 4.55.0 → 4.56.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 +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +22 -0
- package/dist/_internal/server/features/checkout/actions.js +39 -3
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +124 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +8 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/homepage/components/HeroCarousel.js +4 -2
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +22 -5
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/orders/schemas/firestore.d.ts +38 -0
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +102 -0
- package/dist/features/points/schemas/firestore.js +99 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +3 -1
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/styles.css +38 -6
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- 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.56.0",
|
|
4
|
+
"head": "a2d85e72b5cac1f1a55ae7386f661b293eccd704",
|
|
5
5
|
"branch": "main",
|
|
6
|
-
"dirty":
|
|
7
|
-
"builtAt": "2026-10-
|
|
6
|
+
"dirty": true,
|
|
7
|
+
"builtAt": "2026-10-20T16:58:51.945Z"
|
|
8
8
|
}
|
|
@@ -105,6 +105,18 @@ export interface CheckoutPricingPreviewInput {
|
|
|
105
105
|
* is currently gated off.
|
|
106
106
|
*/
|
|
107
107
|
lane?: CartLane;
|
|
108
|
+
/**
|
|
109
|
+
* The chosen EMI tenure, when `paymentMethod` is `"emi"`.
|
|
110
|
+
*
|
|
111
|
+
* 🛑 REQUIRED for an EMI preview to be truthful. The surcharge is
|
|
112
|
+
* `principalRemaining × surchargePercentPerMonth × tenureMonths`, so it
|
|
113
|
+
* cannot be derived from the cart alone — and without it this function
|
|
114
|
+
* returned a total that `createOrderForGroup` then exceeded by exactly that
|
|
115
|
+
* amount. Unlike the add-ons below, the tenure is NOT on the cart document:
|
|
116
|
+
* it is picked on the payment step and belongs to the order, so it has to
|
|
117
|
+
* travel with the request.
|
|
118
|
+
*/
|
|
119
|
+
emiTenureMonths?: number;
|
|
108
120
|
}
|
|
109
121
|
/** One store's slice of the preview — what that seller's order will cost. */
|
|
110
122
|
export interface CheckoutPricingPreviewStore {
|
|
@@ -116,6 +128,14 @@ export interface CheckoutPricingPreviewStore {
|
|
|
116
128
|
giftWrapFee: number;
|
|
117
129
|
shipmentProtectionFee: number;
|
|
118
130
|
saleProtectionFee: number;
|
|
131
|
+
/**
|
|
132
|
+
* EMI surcharge for THIS seller, when the buyer is previewing an EMI order.
|
|
133
|
+
*
|
|
134
|
+
* Per store because `computeEmiSchedule` takes a SELLER subtotal — the
|
|
135
|
+
* financed principal is per order, not per cart, so a cart-wide figure
|
|
136
|
+
* would not match any order written.
|
|
137
|
+
*/
|
|
138
|
+
emiSurcharge: number;
|
|
119
139
|
gstAmount: number;
|
|
120
140
|
couponDiscount: number;
|
|
121
141
|
total: number;
|
|
@@ -147,6 +167,8 @@ export interface CheckoutPricingPreview {
|
|
|
147
167
|
giftWrapFee: number;
|
|
148
168
|
shipmentProtectionFee: number;
|
|
149
169
|
saleProtectionFee: number;
|
|
170
|
+
/** Total EMI surcharge across sellers. Zero unless previewing an EMI order. */
|
|
171
|
+
emiSurcharge: number;
|
|
150
172
|
gstAmount: number;
|
|
151
173
|
couponDiscount: number;
|
|
152
174
|
/** Buyer-facing platform commission, capped, charged once for this checkout. */
|
|
@@ -44,7 +44,7 @@ import { verifyPaymentSignature as verifyRazorpayPaymentSignature } from "../../
|
|
|
44
44
|
import { resolveKeys } from "../../../../core/integration-keys.js";
|
|
45
45
|
import { paiseToRupees } from "../../../../core/money.js";
|
|
46
46
|
import { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_DEFAULT_EMI_SETTINGS } from "../../../shared/features/checkout/config.js";
|
|
47
|
-
import { checkEmiEligibility, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
|
|
47
|
+
import { checkEmiEligibility, computeBuyerEmiQuote, computeEmiSchedule } from "../../../shared/features/emi/schedule.js";
|
|
48
48
|
import { formatShippingAddress } from "./data.js";
|
|
49
49
|
import { enforceMaxPerUserForCart } from "./prize-bundle-gates.js";
|
|
50
50
|
import { getListingRule, runSyncPreflight, assertPaymentMethodAllowed, } from "../../../shared/checkout/rules/index.js";
|
|
@@ -1172,6 +1172,7 @@ const EMPTY_PRICING_PREVIEW = {
|
|
|
1172
1172
|
giftWrapFee: 0,
|
|
1173
1173
|
shipmentProtectionFee: 0,
|
|
1174
1174
|
saleProtectionFee: 0,
|
|
1175
|
+
emiSurcharge: 0,
|
|
1175
1176
|
gstAmount: 0,
|
|
1176
1177
|
couponDiscount: 0,
|
|
1177
1178
|
platformFee: 0,
|
|
@@ -1189,7 +1190,7 @@ const EMPTY_PRICING_PREVIEW = {
|
|
|
1189
1190
|
* stock, creates an order, or writes coupon usage.
|
|
1190
1191
|
*/
|
|
1191
1192
|
export async function previewCheckoutPricing(input) {
|
|
1192
|
-
const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, } = input;
|
|
1193
|
+
const { userId: uid, addressId, paymentMethod, excludedProductIds = [], lane, emiTenureMonths, } = input;
|
|
1193
1194
|
const siteSettings = await siteSettingsRepository.getSingleton();
|
|
1194
1195
|
const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
|
|
1195
1196
|
const cart = await unitOfWork.carts.getOrCreate(uid);
|
|
@@ -1242,7 +1243,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1242
1243
|
for (const { items: group } of orderGroups) {
|
|
1243
1244
|
const firstItem = group[0].item;
|
|
1244
1245
|
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
1245
|
-
const { shippingFee: groupShippingFee, storeState, storeProtections } = await resolveShippingCost(firstItem.storeId);
|
|
1246
|
+
const { shippingFee: groupShippingFee, storeState, storeProtections, storeEmiEnabled } = await resolveShippingCost(firstItem.storeId);
|
|
1246
1247
|
// Add-ons are per store, read off the cart document — the same entry
|
|
1247
1248
|
// createOrderForGroup will read when the order is actually placed.
|
|
1248
1249
|
const addons = cart.storeAddons?.[firstItem.storeId] ?? {};
|
|
@@ -1250,6 +1251,36 @@ export async function previewCheckoutPricing(input) {
|
|
|
1250
1251
|
// ever charged for literal "cod" (pay the courier), never upi_manual or
|
|
1251
1252
|
// cash (both pre-paid via UPI/bank transfer before dispatch).
|
|
1252
1253
|
const groupCodHandlingFee = paymentMethod === "cod" ? computeCodHandlingFee(groupTotal, commissions) : 0;
|
|
1254
|
+
/*
|
|
1255
|
+
* 🛑 The EMI surcharge is a TERM OF THE TOTAL, and it was missing here
|
|
1256
|
+
* while `createOrderForGroup` added it — a shown-vs-charged drift of
|
|
1257
|
+
* exactly the kind this function exists to prevent.
|
|
1258
|
+
*
|
|
1259
|
+
* Measured on the live settings (tokenPercent 10, surchargePercentPerMonth
|
|
1260
|
+
* 1) against the ₹20,000 listing: token ₹2,000, financed ₹18,000, so the
|
|
1261
|
+
* surcharge is ₹360 at the default 2-month tenure and ₹1,080 at 6. The
|
|
1262
|
+
* Order Summary showed ₹20,011.80 either way, and the order recorded up to
|
|
1263
|
+
* ₹1,080 more. Worse, the EMI panel beside it already displayed
|
|
1264
|
+
* instalments computed WITH the surcharge, so the page disagreed with
|
|
1265
|
+
* itself.
|
|
1266
|
+
*
|
|
1267
|
+
* `computeBuyerEmiQuote`, never `computeEmiSchedule`: the latter also
|
|
1268
|
+
* returns the platform/seller split of the surcharge, which is our
|
|
1269
|
+
* commercial arrangement with the seller and has no business reaching a
|
|
1270
|
+
* browser (see § "Buyer-facing fees").
|
|
1271
|
+
*
|
|
1272
|
+
* Eligibility is deliberately NOT enforced here. A preview that threw
|
|
1273
|
+
* would blank the Order Summary; `createOrderForGroup` still refuses an
|
|
1274
|
+
* ineligible EMI order, which is where refusing belongs. An ineligible
|
|
1275
|
+
* cart simply previews no surcharge, because the tenure never applies.
|
|
1276
|
+
*/
|
|
1277
|
+
let groupEmiSurcharge = 0;
|
|
1278
|
+
if (paymentMethod === "emi" && emiTenureMonths && siteSettings?.emi) {
|
|
1279
|
+
const emiSettings = siteSettings.emi;
|
|
1280
|
+
if (checkEmiEligibility(groupTotal, storeEmiEnabled, emiSettings).eligible) {
|
|
1281
|
+
groupEmiSurcharge = computeBuyerEmiQuote(groupTotal, emiTenureMonths, emiSettings).surchargeAmount;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1253
1284
|
const groupGiftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissions);
|
|
1254
1285
|
/*
|
|
1255
1286
|
* C5e — the same three tiers `createOrderForGroup` enforces, so the figure
|
|
@@ -1302,6 +1333,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1302
1333
|
subtotal: groupTotal,
|
|
1303
1334
|
shippingFee: groupShippingFee,
|
|
1304
1335
|
codHandlingFee: groupCodHandlingFee,
|
|
1336
|
+
emiSurcharge: groupEmiSurcharge,
|
|
1305
1337
|
giftWrapFee: groupGiftWrapFee,
|
|
1306
1338
|
shipmentProtectionFee: groupShipmentProtectionFee,
|
|
1307
1339
|
saleProtectionFee: groupSaleProtectionFee,
|
|
@@ -1319,6 +1351,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1319
1351
|
total: Math.max(0, groupTotal - groupCouponDiscount) +
|
|
1320
1352
|
groupShippingFee +
|
|
1321
1353
|
groupCodHandlingFee +
|
|
1354
|
+
groupEmiSurcharge +
|
|
1322
1355
|
groupGiftWrapFee +
|
|
1323
1356
|
groupShipmentProtectionFee +
|
|
1324
1357
|
groupSaleProtectionFee,
|
|
@@ -1330,6 +1363,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1330
1363
|
const sum = (pick) => stores.reduce((acc, s) => acc + pick(s), 0);
|
|
1331
1364
|
const shippingFee = sum((s) => s.shippingFee);
|
|
1332
1365
|
const codHandlingFee = sum((s) => s.codHandlingFee);
|
|
1366
|
+
const emiSurcharge = sum((s) => s.emiSurcharge);
|
|
1333
1367
|
const giftWrapFeeTotal = sum((s) => s.giftWrapFee);
|
|
1334
1368
|
const shipmentProtectionFeeTotal = sum((s) => s.shipmentProtectionFee);
|
|
1335
1369
|
const saleProtectionFeeTotal = sum((s) => s.saleProtectionFee);
|
|
@@ -1366,6 +1400,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1366
1400
|
const total = Math.max(0, cartSubtotal - couponDiscount) +
|
|
1367
1401
|
shippingFee +
|
|
1368
1402
|
codHandlingFee +
|
|
1403
|
+
emiSurcharge +
|
|
1369
1404
|
giftWrapFeeTotal +
|
|
1370
1405
|
shipmentProtectionFeeTotal +
|
|
1371
1406
|
saleProtectionFeeTotal +
|
|
@@ -1375,6 +1410,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
1375
1410
|
subtotal: cartSubtotal,
|
|
1376
1411
|
shippingFee,
|
|
1377
1412
|
codHandlingFee,
|
|
1413
|
+
emiSurcharge,
|
|
1378
1414
|
giftWrapFee: giftWrapFeeTotal,
|
|
1379
1415
|
shipmentProtectionFee: shipmentProtectionFeeTotal,
|
|
1380
1416
|
saleProtectionFee: saleProtectionFeeTotal,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ActualShipmentCost } from "../../../shared/shipping/quote";
|
|
2
|
+
export interface ReconcileShippingResult {
|
|
3
|
+
/** `null` when there was nothing to reconcile — see the reasons below. */
|
|
4
|
+
outcome: "credited" | "seller-absorbed" | "exact" | null;
|
|
5
|
+
sellerAbsorbs: number;
|
|
6
|
+
buyerPoints: number;
|
|
7
|
+
/** Why nothing happened, when `outcome` is null. For logs and the UI. */
|
|
8
|
+
skipped?: "no-quote" | "already-reconciled" | "no-buyer";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 🛑 IDEMPOTENT, and the guard is `order.shippingReconciliation` being unset.
|
|
12
|
+
*
|
|
13
|
+
* Crediting points is an APPEND to a ledger. Appends do not deduplicate
|
|
14
|
+
* themselves, so a dispatch action that fired twice — a double-clicked button,
|
|
15
|
+
* a retried job, a webhook delivered again — would pay the buyer twice, and
|
|
16
|
+
* nothing downstream could distinguish that from two genuine savings. The
|
|
17
|
+
* reconciliation record is written in the same step as the credit precisely so
|
|
18
|
+
* it can be the key.
|
|
19
|
+
*
|
|
20
|
+
* It deliberately does NOT use a transaction spanning the order and the
|
|
21
|
+
* ledger: `pointsRepository.append` already runs its own transaction over the
|
|
22
|
+
* ledger and the balance mirror, and nesting would mean reading the order
|
|
23
|
+
* inside it for no added safety. The residual risk is a crash between the
|
|
24
|
+
* credit and the order write, which leaves a credited buyer and an order that
|
|
25
|
+
* looks unreconciled — recoverable, and strictly better than the alternative
|
|
26
|
+
* ordering, which would mark the order done and then fail to pay.
|
|
27
|
+
*/
|
|
28
|
+
export declare function reconcileOrderShipping(orderId: string, actual: ActualShipmentCost): Promise<ReconcileShippingResult>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Close the loop on one order's shipping: compare what the buyer was charged
|
|
3
|
+
* with what the seller actually spent, and credit the difference as points.
|
|
4
|
+
*
|
|
5
|
+
* Called from the seller's dispatch path, once the real postage is known.
|
|
6
|
+
*/
|
|
7
|
+
import { orderRepository } from "../../../../features/orders/repository/orders.repository.js";
|
|
8
|
+
import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
|
|
9
|
+
import { reconcileShipping, } from "../../../shared/shipping/quote.js";
|
|
10
|
+
import { serverLogger } from "../../../../monitoring/index.js";
|
|
11
|
+
/**
|
|
12
|
+
* 🛑 IDEMPOTENT, and the guard is `order.shippingReconciliation` being unset.
|
|
13
|
+
*
|
|
14
|
+
* Crediting points is an APPEND to a ledger. Appends do not deduplicate
|
|
15
|
+
* themselves, so a dispatch action that fired twice — a double-clicked button,
|
|
16
|
+
* a retried job, a webhook delivered again — would pay the buyer twice, and
|
|
17
|
+
* nothing downstream could distinguish that from two genuine savings. The
|
|
18
|
+
* reconciliation record is written in the same step as the credit precisely so
|
|
19
|
+
* it can be the key.
|
|
20
|
+
*
|
|
21
|
+
* It deliberately does NOT use a transaction spanning the order and the
|
|
22
|
+
* ledger: `pointsRepository.append` already runs its own transaction over the
|
|
23
|
+
* ledger and the balance mirror, and nesting would mean reading the order
|
|
24
|
+
* inside it for no added safety. The residual risk is a crash between the
|
|
25
|
+
* credit and the order write, which leaves a credited buyer and an order that
|
|
26
|
+
* looks unreconciled — recoverable, and strictly better than the alternative
|
|
27
|
+
* ordering, which would mark the order done and then fail to pay.
|
|
28
|
+
*/
|
|
29
|
+
export async function reconcileOrderShipping(orderId, actual) {
|
|
30
|
+
const order = await orderRepository.findById(orderId);
|
|
31
|
+
if (!order) {
|
|
32
|
+
return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-quote" };
|
|
33
|
+
}
|
|
34
|
+
/*
|
|
35
|
+
* No quote means this order never went through the courier picker — a
|
|
36
|
+
* manually-shipped seller, or an order placed before the feature. There is
|
|
37
|
+
* no charged figure to measure against, so there is nothing to reconcile.
|
|
38
|
+
* Inventing one from the flat `shippingFee` would compare the buyer's spend
|
|
39
|
+
* against a number that was never a courier quote.
|
|
40
|
+
*/
|
|
41
|
+
if (!order.selectedShippingQuote) {
|
|
42
|
+
return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-quote" };
|
|
43
|
+
}
|
|
44
|
+
if (order.shippingReconciliation) {
|
|
45
|
+
return {
|
|
46
|
+
outcome: null,
|
|
47
|
+
sellerAbsorbs: order.shippingReconciliation.sellerAbsorbs,
|
|
48
|
+
buyerPoints: order.shippingReconciliation.buyerPoints,
|
|
49
|
+
skipped: "already-reconciled",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const result = reconcileShipping(order.selectedShippingQuote, actual);
|
|
53
|
+
const buyerUid = order.userId;
|
|
54
|
+
if (result.buyerPoints > 0) {
|
|
55
|
+
if (!buyerUid) {
|
|
56
|
+
/*
|
|
57
|
+
* A credit with nobody to credit. Recorded rather than thrown: the
|
|
58
|
+
* parcel has shipped and the seller's dispatch must not fail because we
|
|
59
|
+
* cannot find the buyer — but this is a real data fault and has to be
|
|
60
|
+
* loud enough to find.
|
|
61
|
+
*/
|
|
62
|
+
serverLogger.error("shipping reconciliation has points but no buyer", {
|
|
63
|
+
orderId,
|
|
64
|
+
buyerPoints: result.buyerPoints,
|
|
65
|
+
});
|
|
66
|
+
return { outcome: null, sellerAbsorbs: 0, buyerPoints: 0, skipped: "no-buyer" };
|
|
67
|
+
}
|
|
68
|
+
await pointsRepository.append({
|
|
69
|
+
userId: buyerUid,
|
|
70
|
+
kind: "shipping_saving",
|
|
71
|
+
amount: result.buyerPoints,
|
|
72
|
+
orderId,
|
|
73
|
+
/*
|
|
74
|
+
* Names both couriers and both figures, because "53 points" on its own
|
|
75
|
+
* is unexplainable six weeks later — and this string is written once and
|
|
76
|
+
* never re-derived, so it stays true of the moment it describes even if
|
|
77
|
+
* the order is edited afterwards.
|
|
78
|
+
*/
|
|
79
|
+
description: `Shipping saving on ${orderId}: charged ₹${order.selectedShippingQuote.chargedRate} ` +
|
|
80
|
+
`for ${order.selectedShippingQuote.courierName}, shipped ₹${actual.actualRate} ` +
|
|
81
|
+
`via ${actual.courierName}.`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
await orderRepository.update(orderId, {
|
|
85
|
+
actualShipmentCost: actual,
|
|
86
|
+
shippingReconciliation: { ...result, reconciledAt: new Date() },
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
outcome: result.buyerPoints > 0 ? "credited" : result.sellerAbsorbs > 0 ? "seller-absorbed" : "exact",
|
|
90
|
+
sellerAbsorbs: result.sellerAbsorbs,
|
|
91
|
+
buyerPoints: result.buyerPoints,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -89,6 +89,12 @@ export interface AppkitBrandConfig {
|
|
|
89
89
|
name: string;
|
|
90
90
|
/** Short / abbreviated name, e.g. "LT" */
|
|
91
91
|
shortName?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Standalone lockup line under the homepage hero logo, e.g. "Let It Rip
|
|
94
|
+
* India". Not `name`: that one is a sentence ingredient ("Why Buyers Trust
|
|
95
|
+
* {name}") and this one stands alone, so they are spelled differently.
|
|
96
|
+
*/
|
|
97
|
+
heroCaption?: string;
|
|
92
98
|
/** One-line brand description used in meta tags and footers */
|
|
93
99
|
description?: string;
|
|
94
100
|
/** Tagline shown in footer / about sections */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Does this href lead somewhere the site has switched off?"
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `listings.listingTypes` already hides a disabled type everywhere the type is
|
|
7
|
+
* named — the nav, the `/products` chips, the admin tabs, the homepage
|
|
8
|
+
* SECTIONS (`HOMEPAGE_SECTION_LISTING_TYPE` in `MarketplaceHomepageView`). But
|
|
9
|
+
* a lot of this site's merchandising is free-text content that names a
|
|
10
|
+
* destination by URL and never mentions a listing type at all: a carousel
|
|
11
|
+
* slide's overlay button, a card's CTA, a promo-grid tile.
|
|
12
|
+
*
|
|
13
|
+
* Measured 2026-10-20 with `auction: false` live: the homepage hero carousel
|
|
14
|
+
* still shipped a "Live auctions / Place a bid before the timer runs out"
|
|
15
|
+
* CARD linking to `/auctions`, and a whole `slide-beyblade-auction` slide
|
|
16
|
+
* whose overlay read "Win it at your price". Both are authored content, so no
|
|
17
|
+
* type-keyed gate could see them — the only thing that identifies them is
|
|
18
|
+
* where they point.
|
|
19
|
+
*
|
|
20
|
+
* ## The rule, and the one case that makes it non-obvious
|
|
21
|
+
*
|
|
22
|
+
* A href is disabled when EVERY listing type that claims its route is
|
|
23
|
+
* disabled. Not "any" — `art` and `stickers` share `/art`, so a site that
|
|
24
|
+
* sells art but not stickers must keep `/art` reachable. Treating one disabled
|
|
25
|
+
* claimant as enough would silently delete a working destination.
|
|
26
|
+
*
|
|
27
|
+
* ## What it deliberately does NOT match
|
|
28
|
+
*
|
|
29
|
+
* Editorial pages that merely have a type's name in the path — `/help/auctions`,
|
|
30
|
+
* `/how-auctions-work`, `/seller-guide/prize-draws`. None of them starts with
|
|
31
|
+
* `/auctions`, so prefix matching excludes them for free, and that is correct:
|
|
32
|
+
* an explainer about auctions is still honest reading when auctions are off.
|
|
33
|
+
* A substring test would have eaten all three.
|
|
34
|
+
*
|
|
35
|
+
* Store tabs (`/stores/{slug}/auctions`) are also out of scope — those are
|
|
36
|
+
* resolved by `resolveStoreTabs`, which owns its own visibility, and a second
|
|
37
|
+
* owner is how a mirror drifts (Root Cause #42).
|
|
38
|
+
*/
|
|
39
|
+
import type { ListingType } from "../../../features/products/types/index";
|
|
40
|
+
interface ListingFlagSnapshot {
|
|
41
|
+
listings?: {
|
|
42
|
+
listingTypes?: Partial<Record<ListingType, boolean>>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `true` when `href` points at a listing type's browse page (or any page
|
|
47
|
+
* beneath it) and every type that browses there is disabled.
|
|
48
|
+
*
|
|
49
|
+
* Fails OPEN on anything it cannot classify: an unknown path, an external URL
|
|
50
|
+
* or a missing settings document all return `false`. A settings read that
|
|
51
|
+
* errored must not strip working destinations out of the homepage — the same
|
|
52
|
+
* reasoning as the suggestions endpoint's TTL cache and `src/proxy.ts`'s
|
|
53
|
+
* disabled-routes gate.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isDisabledListingHref(href: string | undefined | null, settings: ListingFlagSnapshot | null | undefined): boolean;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Does this href lead somewhere the site has switched off?"
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `listings.listingTypes` already hides a disabled type everywhere the type is
|
|
7
|
+
* named — the nav, the `/products` chips, the admin tabs, the homepage
|
|
8
|
+
* SECTIONS (`HOMEPAGE_SECTION_LISTING_TYPE` in `MarketplaceHomepageView`). But
|
|
9
|
+
* a lot of this site's merchandising is free-text content that names a
|
|
10
|
+
* destination by URL and never mentions a listing type at all: a carousel
|
|
11
|
+
* slide's overlay button, a card's CTA, a promo-grid tile.
|
|
12
|
+
*
|
|
13
|
+
* Measured 2026-10-20 with `auction: false` live: the homepage hero carousel
|
|
14
|
+
* still shipped a "Live auctions / Place a bid before the timer runs out"
|
|
15
|
+
* CARD linking to `/auctions`, and a whole `slide-beyblade-auction` slide
|
|
16
|
+
* whose overlay read "Win it at your price". Both are authored content, so no
|
|
17
|
+
* type-keyed gate could see them — the only thing that identifies them is
|
|
18
|
+
* where they point.
|
|
19
|
+
*
|
|
20
|
+
* ## The rule, and the one case that makes it non-obvious
|
|
21
|
+
*
|
|
22
|
+
* A href is disabled when EVERY listing type that claims its route is
|
|
23
|
+
* disabled. Not "any" — `art` and `stickers` share `/art`, so a site that
|
|
24
|
+
* sells art but not stickers must keep `/art` reachable. Treating one disabled
|
|
25
|
+
* claimant as enough would silently delete a working destination.
|
|
26
|
+
*
|
|
27
|
+
* ## What it deliberately does NOT match
|
|
28
|
+
*
|
|
29
|
+
* Editorial pages that merely have a type's name in the path — `/help/auctions`,
|
|
30
|
+
* `/how-auctions-work`, `/seller-guide/prize-draws`. None of them starts with
|
|
31
|
+
* `/auctions`, so prefix matching excludes them for free, and that is correct:
|
|
32
|
+
* an explainer about auctions is still honest reading when auctions are off.
|
|
33
|
+
* A substring test would have eaten all three.
|
|
34
|
+
*
|
|
35
|
+
* Store tabs (`/stores/{slug}/auctions`) are also out of scope — those are
|
|
36
|
+
* resolved by `resolveStoreTabs`, which owns its own visibility, and a second
|
|
37
|
+
* owner is how a mirror drifts (Root Cause #42).
|
|
38
|
+
*/
|
|
39
|
+
import { LISTING_TYPE_REGISTRY } from "./_registry.js";
|
|
40
|
+
import { ALL_LISTING_TYPES, isListingTypeEnabled } from "./feature-flags.js";
|
|
41
|
+
/**
|
|
42
|
+
* browse route → every listing type that browses there.
|
|
43
|
+
*
|
|
44
|
+
* Built from the registry rather than hand-listed, so a tenth type joins it by
|
|
45
|
+
* existing. `art` and `stickers` both resolve to `/art`, which is the entire
|
|
46
|
+
* reason the value is an array.
|
|
47
|
+
*/
|
|
48
|
+
const TYPES_BY_BROWSE_ROUTE = (() => {
|
|
49
|
+
const map = new Map();
|
|
50
|
+
for (const type of ALL_LISTING_TYPES) {
|
|
51
|
+
const route = normalisePath(LISTING_TYPE_REGISTRY[type].browseRoute);
|
|
52
|
+
if (!route)
|
|
53
|
+
continue;
|
|
54
|
+
const existing = map.get(route);
|
|
55
|
+
if (existing)
|
|
56
|
+
existing.push(type);
|
|
57
|
+
else
|
|
58
|
+
map.set(route, [type]);
|
|
59
|
+
}
|
|
60
|
+
return map;
|
|
61
|
+
})();
|
|
62
|
+
/**
|
|
63
|
+
* Reduce an authored href to a comparable path.
|
|
64
|
+
*
|
|
65
|
+
* Returns `""` for anything that is not an in-app path — an absolute URL to
|
|
66
|
+
* another origin, a `mailto:`, an anchor. Those can never be a listing browse
|
|
67
|
+
* route, and a caller must not filter them out.
|
|
68
|
+
*
|
|
69
|
+
* 🛑 The locale prefix is stripped. `ROUTES` values are unprefixed
|
|
70
|
+
* (`/auctions`) and `next-intl` adds `/en` at navigation time, but an admin
|
|
71
|
+
* pasting a URL out of the address bar stores the prefixed form — so a
|
|
72
|
+
* comparison against the raw value matches the seed and misses the hand-typed
|
|
73
|
+
* one, which is the half most likely to be wrong.
|
|
74
|
+
*/
|
|
75
|
+
function normalisePath(href) {
|
|
76
|
+
if (typeof href !== "string")
|
|
77
|
+
return "";
|
|
78
|
+
const trimmed = href.trim();
|
|
79
|
+
if (!trimmed || !trimmed.startsWith("/"))
|
|
80
|
+
return "";
|
|
81
|
+
// Drop query + hash: `/auctions?sort=ending` is still /auctions.
|
|
82
|
+
const path = trimmed.split(/[?#]/)[0] ?? "";
|
|
83
|
+
// `/en/auctions` → `/auctions`. Two-letter segment only, so a real route
|
|
84
|
+
// whose first segment is short (there are none today, but `/ar` would be a
|
|
85
|
+
// plausible future one) is the only thing this could over-match — and it
|
|
86
|
+
// would have to collide with a locale code to do it.
|
|
87
|
+
const delocalised = path.replace(/^\/[a-z]{2}(?=\/)/, "");
|
|
88
|
+
// Trailing slash off, but never reduce "/" to "".
|
|
89
|
+
return delocalised.length > 1 ? delocalised.replace(/\/+$/, "") : delocalised;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `true` when `href` points at a listing type's browse page (or any page
|
|
93
|
+
* beneath it) and every type that browses there is disabled.
|
|
94
|
+
*
|
|
95
|
+
* Fails OPEN on anything it cannot classify: an unknown path, an external URL
|
|
96
|
+
* or a missing settings document all return `false`. A settings read that
|
|
97
|
+
* errored must not strip working destinations out of the homepage — the same
|
|
98
|
+
* reasoning as the suggestions endpoint's TTL cache and `src/proxy.ts`'s
|
|
99
|
+
* disabled-routes gate.
|
|
100
|
+
*/
|
|
101
|
+
export function isDisabledListingHref(href, settings) {
|
|
102
|
+
const path = normalisePath(href);
|
|
103
|
+
if (!path)
|
|
104
|
+
return false;
|
|
105
|
+
for (const [route, types] of TYPES_BY_BROWSE_ROUTE) {
|
|
106
|
+
// The browse page itself, or a detail page under it. `startsWith(route)`
|
|
107
|
+
// alone would match `/live` against `/livestream`, so the boundary is
|
|
108
|
+
// explicit.
|
|
109
|
+
if (path !== route && !path.startsWith(`${route}/`))
|
|
110
|
+
continue;
|
|
111
|
+
return types.every((type) => !isListingTypeEnabled(type, settings));
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the buyer is quoted for shipping, what the seller actually spent, and
|
|
3
|
+
* who wears the difference.
|
|
4
|
+
*
|
|
5
|
+
* Pure and synchronous — no I/O, no Firestore, no provider. Every surface that
|
|
6
|
+
* touches a shipping figure resolves through here so the quote shown at
|
|
7
|
+
* checkout, the amount charged, the order record and the reconciliation can
|
|
8
|
+
* never disagree (Root Cause #75: a rule reachable by copy-paste WILL be
|
|
9
|
+
* copied, and the copy never learns about the branch added later).
|
|
10
|
+
*
|
|
11
|
+
* ## The model
|
|
12
|
+
*
|
|
13
|
+
* 1. Shiprocket returns per-courier rates for the buyer's pincode.
|
|
14
|
+
* 2. Each rate is ROUNDED UP to the nearest ₹50 and that is what the buyer
|
|
15
|
+
* sees and is charged. The raw rate is never shown and never billed.
|
|
16
|
+
* 3. The seller ships. If their courier is dearer than the charged figure they
|
|
17
|
+
* absorb the difference; if it is cheaper the buyer is credited the
|
|
18
|
+
* difference in points, 1 point = ₹1.
|
|
19
|
+
*
|
|
20
|
+
* ## 🛑 Reconciliation measures against the CHARGED figure, not the raw rate
|
|
21
|
+
*
|
|
22
|
+
* This is the decision that makes the whole thing coherent, and it is worth
|
|
23
|
+
* stating because the obvious alternative is defensible and wrong.
|
|
24
|
+
*
|
|
25
|
+
* Measuring against the raw rate would let the platform keep the round-up
|
|
26
|
+
* (₹70 raw → ₹100 charged → a silent ₹30 margin on every parcel) while still
|
|
27
|
+
* calling the ₹50 step a "rounding". Measuring against the charged figure
|
|
28
|
+
* means the buyer is made whole for every rupee they handed over that did not
|
|
29
|
+
* become postage: ₹100 charged, shipped at ₹47, 53 points back. The round-up
|
|
30
|
+
* stops being a margin and becomes a BUFFER that absorbs ordinary courier
|
|
31
|
+
* variance before the seller has to.
|
|
32
|
+
*
|
|
33
|
+
* It also makes the rule explainable in one sentence to both sides — "you pay
|
|
34
|
+
* the rounded quote and get back whatever we did not spend", "you keep the
|
|
35
|
+
* quote and cover any overage" — and a rule a seller can predict is a rule
|
|
36
|
+
* they do not dispute.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Shipping is always billed in whole ₹50 steps.
|
|
40
|
+
*
|
|
41
|
+
* 🛑 Not a formatting choice. A courier rate is a volatile per-parcel number
|
|
42
|
+
* (fuel, zone, weight slab, surcharge) and quoting it to the rupee invites a
|
|
43
|
+
* dispute every time the actual differs by ₹3. A coarse step makes the quote
|
|
44
|
+
* stable, makes it look like a price rather than a machine reading, and buys
|
|
45
|
+
* the headroom that keeps most orders off the reconciliation path entirely.
|
|
46
|
+
*/
|
|
47
|
+
export declare const SHIPPING_ROUND_UP_TO = 50;
|
|
48
|
+
/**
|
|
49
|
+
* Round a raw carrier rate up to the next ₹50.
|
|
50
|
+
*
|
|
51
|
+
* `Math.ceil`, so ₹70 → ₹100 and ₹100 → ₹100 — a rate exactly on a step is NOT
|
|
52
|
+
* pushed to the next one. ₹0 stays ₹0: free shipping must survive the rounding
|
|
53
|
+
* or every free-shipping order silently acquires a ₹50 charge.
|
|
54
|
+
*
|
|
55
|
+
* Negative input is clamped to 0 rather than rounded "up" toward zero, which
|
|
56
|
+
* would otherwise turn a bad provider response into a credit.
|
|
57
|
+
*/
|
|
58
|
+
export declare function roundShippingUp(rawRate: number): number;
|
|
59
|
+
/** One courier the buyer may pick, as offered at checkout. */
|
|
60
|
+
export interface ShippingQuoteOption {
|
|
61
|
+
/** Stable id from the carrier, so a pick survives a re-quote. */
|
|
62
|
+
courierId: string;
|
|
63
|
+
/** Display name — "Ekart", "Delhivery", "India Post". */
|
|
64
|
+
courierName: string;
|
|
65
|
+
/**
|
|
66
|
+
* The carrier's own rate, kept for the audit trail and NEVER rendered.
|
|
67
|
+
*
|
|
68
|
+
* 🛑 Showing both numbers would invite "why am I paying ₹100 for a ₹70
|
|
69
|
+
* courier" — a question whose honest answer (the difference comes back to
|
|
70
|
+
* you as points if it is not spent) is not something a buyer should have to
|
|
71
|
+
* reconstruct from two figures on a checkout page.
|
|
72
|
+
*/
|
|
73
|
+
rawRate: number;
|
|
74
|
+
/** What the buyer sees and is charged. `roundShippingUp(rawRate)`. */
|
|
75
|
+
chargedRate: number;
|
|
76
|
+
estimatedDays: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The option the buyer committed to, frozen onto the order.
|
|
80
|
+
*
|
|
81
|
+
* 🛑 Stored, never recomputed. Carrier rates move hourly; re-quoting at
|
|
82
|
+
* reconciliation time would compare the seller's actual spend against a price
|
|
83
|
+
* nobody was ever charged, and the buyer's points would change depending on
|
|
84
|
+
* when the job happened to run. Same reasoning as `OrderSourceContext` being
|
|
85
|
+
* written once at creation.
|
|
86
|
+
*/
|
|
87
|
+
export interface SelectedShippingQuote {
|
|
88
|
+
courierId: string;
|
|
89
|
+
courierName: string;
|
|
90
|
+
rawRate: number;
|
|
91
|
+
/** The amount actually added to the order total. */
|
|
92
|
+
chargedRate: number;
|
|
93
|
+
estimatedDays: number;
|
|
94
|
+
/** When the quote was taken, so a stale pick is recognisable. */
|
|
95
|
+
quotedAt: Date;
|
|
96
|
+
}
|
|
97
|
+
/** What the seller really shipped with, recorded when they dispatch. */
|
|
98
|
+
export interface ActualShipmentCost {
|
|
99
|
+
courierId: string;
|
|
100
|
+
courierName: string;
|
|
101
|
+
/** The seller's real postage, to the rupee. Not rounded. */
|
|
102
|
+
actualRate: number;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The outcome of comparing what was charged with what was spent.
|
|
106
|
+
*
|
|
107
|
+
* Exactly one of `sellerAbsorbs` / `buyerPoints` is non-zero, and both are
|
|
108
|
+
* non-negative — a signed "difference" field would make every consumer decide
|
|
109
|
+
* what its sign meant, and the two directions have completely different
|
|
110
|
+
* consequences (one is a cost to a seller, one is a credit to a buyer).
|
|
111
|
+
*/
|
|
112
|
+
export interface ShippingReconciliation {
|
|
113
|
+
/** Rupees the seller eats because their courier cost more than quoted. */
|
|
114
|
+
sellerAbsorbs: number;
|
|
115
|
+
/** Points credited to the buyer, 1 point = ₹1. */
|
|
116
|
+
buyerPoints: number;
|
|
117
|
+
/** True when the seller shipped with a courier the buyer did not choose. */
|
|
118
|
+
courierChanged: boolean;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Compare the charged quote against the real spend.
|
|
122
|
+
*
|
|
123
|
+
* 🛑 Works on the CHARGED figure (see the header). The worked examples from the
|
|
124
|
+
* spec, with the ₹70 Ekart quote billed at ₹100:
|
|
125
|
+
*
|
|
126
|
+
* shipped Delhivery ₹100 → sellerAbsorbs 0, buyerPoints 0
|
|
127
|
+
* shipped Delhivery ₹120 → sellerAbsorbs 20, buyerPoints 0
|
|
128
|
+
* shipped India Post ₹47 → sellerAbsorbs 0, buyerPoints 53
|
|
129
|
+
*
|
|
130
|
+
* Points are the EXACT rupee difference and are deliberately not rounded to a
|
|
131
|
+
* ₹50 step. The round-up exists to make a *quote* stable; applying it to a
|
|
132
|
+
* credit would either invent money (rounding up) or quietly keep some of the
|
|
133
|
+
* buyer's (rounding down).
|
|
134
|
+
*/
|
|
135
|
+
export declare function reconcileShipping(quote: Pick<SelectedShippingQuote, "chargedRate" | "courierId">, actual: ActualShipmentCost): ShippingReconciliation;
|
|
136
|
+
/**
|
|
137
|
+
* Turn carrier serviceability results into offerable options.
|
|
138
|
+
*
|
|
139
|
+
* 🛑 Drops any courier whose rate is not a usable number instead of charging
|
|
140
|
+
* ₹0 for it. `resolveShippingCost` already carries this lesson in its
|
|
141
|
+
* `shippingUnavailable` flag: a zero there "would ship every parcel free at
|
|
142
|
+
* our expense, silently, until somebody noticed". A malformed row from a
|
|
143
|
+
* provider is missing data, never free postage.
|
|
144
|
+
*
|
|
145
|
+
* Sorted cheapest-first, then fastest — the buyer is choosing, so the default
|
|
146
|
+
* reading order should be the one most of them want.
|
|
147
|
+
*/
|
|
148
|
+
export declare function toQuoteOptions(couriers: readonly {
|
|
149
|
+
courierId?: string;
|
|
150
|
+
name: string;
|
|
151
|
+
rate: number;
|
|
152
|
+
estimatedDays: number;
|
|
153
|
+
}[]): ShippingQuoteOption[];
|