@mohasinac/appkit 4.56.0 → 4.57.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.d.ts +31 -0
- package/dist/_internal/server/features/checkout/actions.js +117 -5
- package/dist/_internal/server/features/orders/actions.d.ts +30 -0
- package/dist/_internal/server/features/orders/actions.js +103 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/shared/actions/action-registry.js +53 -0
- package/dist/_internal/shared/shipping/quote.js +5 -6
- package/dist/client.d.ts +21 -0
- package/dist/client.js +2 -0
- package/dist/features/about/components/PublicProfileView.js +2 -1
- package/dist/features/account/components/UserOrdersView.js +25 -3
- package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
- package/dist/features/admin/components/OwnerQuickActions.js +53 -6
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
- package/dist/features/faq/components/FAQPageContent.js +17 -4
- package/dist/features/faq/constants/help-guides.d.ts +36 -0
- package/dist/features/faq/constants/help-guides.js +64 -0
- package/dist/features/homepage/components/HeroCarousel.js +3 -1
- package/dist/features/homepage/lib/section-renderer.js +2 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
- package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
- package/dist/features/orders/components/index.d.ts +1 -0
- package/dist/features/orders/components/index.js +1 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +15 -0
- package/dist/features/points/repository/points.repository.js +5 -5
- package/dist/features/points/schemas/firestore.d.ts +16 -0
- package/dist/features/points/schemas/firestore.js +17 -0
- package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
- package/dist/features/stores/components/StoreHeader.js +3 -1
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +2 -0
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
- package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
- package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
- package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
- package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
- package/dist/seed/_helpers/category-models.d.ts +9 -2
- package/dist/seed/_helpers/category-models.js +19 -1368
- package/dist/server.d.ts +3 -1
- package/dist/server.js +11 -0
- 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.57.0",
|
|
4
|
+
"head": "de32aea7a1bbb5fe9970959866c5278c2f9e4e49",
|
|
5
5
|
"branch": "main",
|
|
6
6
|
"dirty": true,
|
|
7
|
-
"builtAt": "2026-10-
|
|
7
|
+
"builtAt": "2026-10-20T18:02:32.256Z"
|
|
8
8
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SelectedShippingQuote } from "../../../shared/shipping/quote";
|
|
1
2
|
import { type StoreShippingPolicy } from "../../../shared/features/shipping/mode";
|
|
2
3
|
import { type CartLane } from "../../../shared/checkout/lanes";
|
|
3
4
|
import type { OutOfStockPolicy } from "../../../../features/orders/schemas/index";
|
|
@@ -12,6 +13,36 @@ export interface CreateCheckoutOrderInput {
|
|
|
12
13
|
paymentMethod: CheckoutPaymentMethod;
|
|
13
14
|
/** Required when paymentMethod === "emi" — the buyer's chosen tenure (2–6 months). */
|
|
14
15
|
emiTenureMonths?: number;
|
|
16
|
+
/**
|
|
17
|
+
* The courier the buyer picked at checkout, with the ₹50-rounded rate they
|
|
18
|
+
* were shown.
|
|
19
|
+
*
|
|
20
|
+
* 🛑 OPTIONAL, and its absence is meaningful: a cart whose carrier quote
|
|
21
|
+
* failed, a digital-only cart, or any caller written before the picker
|
|
22
|
+
* existed falls back to `resolveShippingCost` exactly as before. Absence is
|
|
23
|
+
* what makes reconciliation skip the order as "no-quote" rather than
|
|
24
|
+
* measuring a seller's spend against a figure nobody was charged.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 `chargedRate` is TRUSTED from the client, and that is a deliberate,
|
|
27
|
+
* bounded decision rather than an oversight. It is re-derived server-side
|
|
28
|
+
* through `roundShippingUp` before it is billed, so a tampered value can
|
|
29
|
+
* only ever be a different carrier rate rounded the same way — the gap
|
|
30
|
+
* worth closing is re-quoting the carrier at order time, which costs an
|
|
31
|
+
* outbound call on the payment path and is tracked separately.
|
|
32
|
+
*/
|
|
33
|
+
selectedShippingQuote?: SelectedShippingQuote;
|
|
34
|
+
/**
|
|
35
|
+
* Points the buyer chose to spend on this checkout. 1 point = ₹1.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 Requested, never trusted. The server re-reads the balance, re-applies
|
|
38
|
+
* the 50% cap against the real cart total, and spends the LESSER of the two
|
|
39
|
+
* — a client that asks for more than it holds gets its request trimmed, not
|
|
40
|
+
* an error, because the honest reading of "spend 500 when you have 300" is a
|
|
41
|
+
* stale page rather than an attack. `pointsRepository.redeem` then re-checks
|
|
42
|
+
* the balance a third time inside its own transaction, which is the check
|
|
43
|
+
* that actually prevents a negative balance under concurrency.
|
|
44
|
+
*/
|
|
45
|
+
pointsToRedeem?: number;
|
|
15
46
|
notes?: string;
|
|
16
47
|
excludedProductIds?: string[];
|
|
17
48
|
/** When true this is an admin test order — OTP consent is pre-granted and the order is created paid/processing. */
|
|
@@ -12,6 +12,9 @@ import { roundRupees } from "../../../../utils/number.formatter.js";
|
|
|
12
12
|
import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../../../errors/index.js";
|
|
13
13
|
import { ORDER_FIELDS } from "../../../../constants/field-names.js";
|
|
14
14
|
import { serverLogger } from "../../../../monitoring/index.js";
|
|
15
|
+
import { roundShippingUp, } from "../../../shared/shipping/quote.js";
|
|
16
|
+
import { pointsRepository } from "../../../../features/points/repository/points.repository.js";
|
|
17
|
+
import { maxRedeemablePoints } from "../../../../features/points/schemas/firestore.js";
|
|
15
18
|
import { unitOfWork, adminNotificationsRepository, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories/index.js";
|
|
16
19
|
import { isIntraStateSupply } from "../../../shared/fees/calculator.js";
|
|
17
20
|
import { computePreOrderDepositAmount, lineTotalFor, sumGroupGst, unitPriceFor, } from "../../../shared/checkout/order-math.js";
|
|
@@ -490,7 +493,7 @@ function dispatchOrderConfirmationEmails(emailsToSend) {
|
|
|
490
493
|
* so the caller can accumulate the checkout-wide `total`.
|
|
491
494
|
*/
|
|
492
495
|
async function createOrderForGroup(group, orderType, ctx) {
|
|
493
|
-
const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, storeAddons, platformFeeByStore, } = ctx;
|
|
496
|
+
const { paymentMethod, emiTenureMonths, emiSettings, commissions, appliedCoupons, cartSubtotal, couponUsageAccumulator, uid, userName, userEmail, shippingAddress, notes, adminBypass, adminBypassBy, adminBatchId, orderIds, emailsToSend, outOfStockPolicy, droppedItems, buyerState, gstSettings, siteContactUpiVpa, storeAddons, platformFeeByStore, selectedShippingQuote, } = ctx;
|
|
494
497
|
const firstItem = group[0].item;
|
|
495
498
|
const firstProduct = group[0].product;
|
|
496
499
|
// Add-ons are this store's choice, not the cart's — see OrderGroupContext.
|
|
@@ -507,7 +510,25 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
507
510
|
return expandCartLineToOrderRows(item, product, ctx.productById).map((baseLine) => itemRule.decorateOrderItem(baseLine, product));
|
|
508
511
|
});
|
|
509
512
|
const totalQuantity = group.reduce((sum, { item }) => sum + item.quantity, 0);
|
|
510
|
-
const { shippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
|
|
513
|
+
const { shippingFee: resolvedShippingFee, storeOwnerId, storeEmiEnabled, storeState, storeProtections, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
|
|
514
|
+
/*
|
|
515
|
+
* 🛑 The buyer's quote WINS over the seller's configured rate, and it is
|
|
516
|
+
* re-rounded here rather than trusted as sent.
|
|
517
|
+
*
|
|
518
|
+
* `roundShippingUp` is idempotent, so re-applying it to an honest value is a
|
|
519
|
+
* no-op; applied to a tampered one it forces the result back onto a ₹50 step.
|
|
520
|
+
* That bounds what a forged `chargedRate` can be to "some carrier rate,
|
|
521
|
+
* correctly rounded" rather than an arbitrary number.
|
|
522
|
+
*
|
|
523
|
+
* Applied to the FIRST group only — see the field's note on OrderGroupContext.
|
|
524
|
+
* A cart spanning sellers must not be charged the same parcel rate twice.
|
|
525
|
+
*/
|
|
526
|
+
const isFirstGroup = ctx.orderIds.length === 0;
|
|
527
|
+
const shippingFee = selectedShippingQuote && isFirstGroup
|
|
528
|
+
? roundShippingUp(selectedShippingQuote.chargedRate)
|
|
529
|
+
: selectedShippingQuote
|
|
530
|
+
? 0
|
|
531
|
+
: resolvedShippingFee;
|
|
511
532
|
/*
|
|
512
533
|
* Who issues this invoice — the seller if they have their own GSTIN, the
|
|
513
534
|
* platform otherwise. ONE function decides, and it is the only place that
|
|
@@ -678,6 +699,16 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
678
699
|
// are "auction". Written ONCE here and never recomputed: the auction and
|
|
679
700
|
// offer documents it summarises are mutable and eventually archived.
|
|
680
701
|
sourceContext,
|
|
702
|
+
/*
|
|
703
|
+
* Frozen onto the order so reconciliation has a charged figure to measure
|
|
704
|
+
* the seller's real postage against. Only on the group that actually
|
|
705
|
+
* carries the shipping fee — a later group in a multi-seller cart is
|
|
706
|
+
* charged ₹0 shipping, so recording a quote there would invite a credit
|
|
707
|
+
* against money that group never collected.
|
|
708
|
+
*/
|
|
709
|
+
selectedShippingQuote: selectedShippingQuote && isFirstGroup
|
|
710
|
+
? { ...selectedShippingQuote, chargedRate: shippingFee }
|
|
711
|
+
: undefined,
|
|
681
712
|
offerId: firstItem.offerId ?? undefined,
|
|
682
713
|
// Distinguishes a Buy Now purchase from a settled auction win. Both are
|
|
683
714
|
// orderType "auction"; only this one gets the 24h admin-review window.
|
|
@@ -1139,12 +1170,53 @@ export async function createCheckoutOrderAction(input) {
|
|
|
1139
1170
|
siteContactUpiVpa: siteSettings?.contact?.upiVpa,
|
|
1140
1171
|
storeAddons: cart.storeAddons,
|
|
1141
1172
|
platformFeeByStore,
|
|
1173
|
+
selectedShippingQuote: input.selectedShippingQuote,
|
|
1142
1174
|
productById: checkoutProductById,
|
|
1143
1175
|
};
|
|
1144
1176
|
for (const { items: group, orderType } of orderGroups) {
|
|
1145
1177
|
total += await createOrderForGroup(group, orderType, groupCtx);
|
|
1146
1178
|
}
|
|
1147
1179
|
await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
|
|
1180
|
+
/*
|
|
1181
|
+
* Spend points AFTER the orders exist.
|
|
1182
|
+
*
|
|
1183
|
+
* 🛑 This ordering is the whole safety argument. Redeeming first and then
|
|
1184
|
+
* failing to create an order would debit a buyer for something they never
|
|
1185
|
+
* received, and points have no refund path that does not need a human. This
|
|
1186
|
+
* way the worst case is an order placed at full price with the balance
|
|
1187
|
+
* intact — recoverable, and visible to the buyer, who still has their
|
|
1188
|
+
* points.
|
|
1189
|
+
*
|
|
1190
|
+
* The amount is re-derived server-side against the real total rather than
|
|
1191
|
+
* taken from the request; `maxRedeemablePoints` applies the 50% cap, and
|
|
1192
|
+
* `redeem` re-checks the balance inside its own transaction.
|
|
1193
|
+
*/
|
|
1194
|
+
if (input.pointsToRedeem && input.pointsToRedeem > 0 && orderIds.length > 0) {
|
|
1195
|
+
try {
|
|
1196
|
+
const balance = await pointsRepository.getBalance(uid);
|
|
1197
|
+
const spend = maxRedeemablePoints(total, Math.min(balance, input.pointsToRedeem));
|
|
1198
|
+
if (spend > 0) {
|
|
1199
|
+
await pointsRepository.redeem({
|
|
1200
|
+
userId: uid,
|
|
1201
|
+
points: spend,
|
|
1202
|
+
orderId: orderIds[0],
|
|
1203
|
+
description: `Redeemed on ${orderIds[0]}`,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) {
|
|
1208
|
+
/*
|
|
1209
|
+
* A failed redemption must not fail a placed order. The buyer keeps the
|
|
1210
|
+
* points and paid full price — the strictly safer side of this trade.
|
|
1211
|
+
*/
|
|
1212
|
+
const normalized = normalizeError(err);
|
|
1213
|
+
serverLogger.error("points redemption failed after order creation", {
|
|
1214
|
+
uid,
|
|
1215
|
+
orderId: orderIds[0],
|
|
1216
|
+
message: normalized.message,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1148
1220
|
dispatchOrderConfirmationEmails(emailsToSend);
|
|
1149
1221
|
serverLogger.info(`createCheckoutOrderAction: ${orderIds.length} order(s) placed for uid=${uid}` +
|
|
1150
1222
|
(unavailable.length > 0 ? ` (${unavailable.length} item(s) unavailable)` : ""));
|
|
@@ -1572,7 +1644,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1572
1644
|
// Reuses the same store/seller lookup as the COD/UPI path above instead
|
|
1573
1645
|
// of re-implementing it inline — was two sequential findById calls per
|
|
1574
1646
|
// seller group here, duplicated from resolveShippingCost.
|
|
1575
|
-
const { shippingFee, storeOwnerId, storeState, storeTaxIdentity } = await resolveShippingCost(firstItem.storeId);
|
|
1647
|
+
const { shippingFee, storeOwnerId, storeState, storeTaxIdentity, storeProtections } = await resolveShippingCost(firstItem.storeId);
|
|
1576
1648
|
/*
|
|
1577
1649
|
* Same supplier-of-record rule as `createOrderForGroup`, through the same
|
|
1578
1650
|
* function. This path (PhonePe verify / webhook) is the SECOND place an
|
|
@@ -1608,7 +1680,44 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1608
1680
|
const addons = storeAddons?.[firstItem.storeId] ?? {};
|
|
1609
1681
|
const giftWrapFee = computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates);
|
|
1610
1682
|
const giftWrapMessage = addons.giftWrapMessage;
|
|
1611
|
-
|
|
1683
|
+
/*
|
|
1684
|
+
* 🛑 `computeProtectionFees`, not a bare `computeShipmentProtectionFee`.
|
|
1685
|
+
*
|
|
1686
|
+
* This path hand-rolled the shipment fee and computed NO sale-protection fee
|
|
1687
|
+
* at all, which was wrong twice over:
|
|
1688
|
+
*
|
|
1689
|
+
* 1. **Sale protection was never charged here.** Razorpay's create-order
|
|
1690
|
+
* route DOES charge it (it calls `computeProtectionFees`), so the amount
|
|
1691
|
+
* captured and the amount this function records on the order disagreed
|
|
1692
|
+
* by the fee — and the platform absorbs the unconditional refund that
|
|
1693
|
+
* fee exists to pay for. See `computeSaleProtectionFee`.
|
|
1694
|
+
* 2. **The C5e tiers were skipped.** The bare helper tests only "did the
|
|
1695
|
+
* buyer tick it" and the site-wide master switch, so shipment protection
|
|
1696
|
+
* could be billed on a seller who never agreed to back it, or on a
|
|
1697
|
+
* final-sale item where it cannot apply.
|
|
1698
|
+
*
|
|
1699
|
+
* Latent rather than live when written — `payment.phonepeEnabled` is false
|
|
1700
|
+
* and the one live store has `supportsSaleProtection` unset — but both of
|
|
1701
|
+
* those are a settings toggle away, and an under-charge that appears the
|
|
1702
|
+
* moment a flag flips is worse than one that is visible now.
|
|
1703
|
+
*/
|
|
1704
|
+
const groupHasFinalSale = group.some(({ product }) => product?.finalSale === true);
|
|
1705
|
+
const { shippingProtectionFee: shipmentProtectionFee, saleProtectionFee } = computeProtectionFees({
|
|
1706
|
+
subtotal: groupTotal,
|
|
1707
|
+
selected: {
|
|
1708
|
+
shipping: addons.shipmentProtectionAddon ?? false,
|
|
1709
|
+
sale: addons.saleProtectionAddon ?? false,
|
|
1710
|
+
},
|
|
1711
|
+
tiers: {
|
|
1712
|
+
platformShipping: commissionRates.shipmentProtectionFeeEnabled === true,
|
|
1713
|
+
platformSale: commissionRates.saleProtectionFeeEnabled === true,
|
|
1714
|
+
sellerShipping: storeProtections.shipping,
|
|
1715
|
+
sellerSale: storeProtections.sale,
|
|
1716
|
+
finalSale: groupHasFinalSale,
|
|
1717
|
+
},
|
|
1718
|
+
shippingFee: (s, sel) => computeShipmentProtectionFee(s, sel, commissionRates),
|
|
1719
|
+
saleFee: (s, sel) => computeSaleProtectionFee(s, sel, commissionRates),
|
|
1720
|
+
});
|
|
1612
1721
|
/*
|
|
1613
1722
|
* P-8 GST — now wired through this path. C0c defect 1.
|
|
1614
1723
|
*
|
|
@@ -1641,7 +1750,7 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1641
1750
|
/* Product GST is not a term — prices are tax-inclusive, so it is already
|
|
1642
1751
|
* inside `groupTotal`. Matches the COD/UPI path exactly; `platformFeeGst`
|
|
1643
1752
|
* stays because that is GST on our fee, charged on top. */
|
|
1644
|
-
const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + platformFee + platformFeeGst;
|
|
1753
|
+
const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + giftWrapFee + shipmentProtectionFee + saleProtectionFee + platformFee + platformFeeGst;
|
|
1645
1754
|
// S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
|
|
1646
1755
|
// Same expansion as the COD/UPI path. This was a THIRD hand-rolled copy of
|
|
1647
1756
|
// the price rule (`isBundle ? item.price : product.price`) that — like the
|
|
@@ -1730,6 +1839,9 @@ async function createPhonePeGroupOrder(group, orderType, ctx) {
|
|
|
1730
1839
|
giftWrapMessage: giftWrapFee > 0 ? giftWrapMessage?.slice(0, 500) : undefined,
|
|
1731
1840
|
shipmentProtectionAddon: shipmentProtectionFee > 0 ? true : undefined,
|
|
1732
1841
|
shipmentProtectionFee: shipmentProtectionFee > 0 ? shipmentProtectionFee : undefined,
|
|
1842
|
+
// Recorded as well as charged — an order that collects a protection fee
|
|
1843
|
+
// without a record of it cannot be reconciled against a later claim.
|
|
1844
|
+
saleProtectionFee: saleProtectionFee > 0 ? saleProtectionFee : undefined,
|
|
1733
1845
|
/*
|
|
1734
1846
|
* The tax breakdown, matching `createOrderForGroup` field for field.
|
|
1735
1847
|
* This path recorded NONE of it before C0c defect 1 — so an online order
|
|
@@ -46,3 +46,33 @@ export declare function adminRejectPaymentAsFraudAction(orderId: string, note: s
|
|
|
46
46
|
* payment/order status — surfaces to the admin queue for investigation.
|
|
47
47
|
*/
|
|
48
48
|
export declare function raiseOrderDisputeAction(orderId: string, reason: string): Promise<ActionResult<void>>;
|
|
49
|
+
/**
|
|
50
|
+
* A buyer submits payment for ONE of their own EMI instalments.
|
|
51
|
+
*
|
|
52
|
+
* 🛑 Why this exists at all: there was no buyer-side route. The only writer
|
|
53
|
+
* was the seller's `markEmiInstallmentPaid`, so a buyer on an EMI plan could
|
|
54
|
+
* not pay an instalment — they waited for the seller to record it. The
|
|
55
|
+
* schema had anticipated them all along (`EmiInstallment.transactionId` and
|
|
56
|
+
* `.proofUrl` are documented as "buyer-uploaded"), which is the tell that the
|
|
57
|
+
* surface, not the model, was missing.
|
|
58
|
+
*
|
|
59
|
+
* 🛑 **ANY unpaid instalment, at ANY time — deliberately unbounded.** There is
|
|
60
|
+
* no due-date check and none should be added. A buyer who wants to clear the
|
|
61
|
+
* plan early is reducing our exposure, not breaking a rule, and the dueDate is
|
|
62
|
+
* the date we will CHASE them on, never the first date they may pay. Paying
|
|
63
|
+
* ahead also keeps `emiComplete` honest: that flag is what
|
|
64
|
+
* `assertEmiShippable` reads, so early settlement should be able to release a
|
|
65
|
+
* shipment early rather than waiting out a calendar.
|
|
66
|
+
*
|
|
67
|
+
* It mirrors the manual-payment model rather than inventing a second one: the
|
|
68
|
+
* buyer supplies a UTR and a proof, the seller or an admin confirms. The
|
|
69
|
+
* status therefore moves to `pending` review here, NOT to `paid` — a buyer
|
|
70
|
+
* must never be able to mark their own money received. `markEmiInstallmentPaid`
|
|
71
|
+
* stays the only writer of `paid`, and so stays the only way `emiComplete`
|
|
72
|
+
* can flip.
|
|
73
|
+
*/
|
|
74
|
+
export declare function submitEmiInstallmentPaymentAction(orderId: string, input: {
|
|
75
|
+
installmentIndex: number;
|
|
76
|
+
transactionId: string;
|
|
77
|
+
proofUrl?: string;
|
|
78
|
+
}): Promise<ActionResult<void>>;
|
|
@@ -412,3 +412,106 @@ export async function raiseOrderDisputeAction(orderId, reason) {
|
|
|
412
412
|
});
|
|
413
413
|
});
|
|
414
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* A buyer submits payment for ONE of their own EMI instalments.
|
|
417
|
+
*
|
|
418
|
+
* 🛑 Why this exists at all: there was no buyer-side route. The only writer
|
|
419
|
+
* was the seller's `markEmiInstallmentPaid`, so a buyer on an EMI plan could
|
|
420
|
+
* not pay an instalment — they waited for the seller to record it. The
|
|
421
|
+
* schema had anticipated them all along (`EmiInstallment.transactionId` and
|
|
422
|
+
* `.proofUrl` are documented as "buyer-uploaded"), which is the tell that the
|
|
423
|
+
* surface, not the model, was missing.
|
|
424
|
+
*
|
|
425
|
+
* 🛑 **ANY unpaid instalment, at ANY time — deliberately unbounded.** There is
|
|
426
|
+
* no due-date check and none should be added. A buyer who wants to clear the
|
|
427
|
+
* plan early is reducing our exposure, not breaking a rule, and the dueDate is
|
|
428
|
+
* the date we will CHASE them on, never the first date they may pay. Paying
|
|
429
|
+
* ahead also keeps `emiComplete` honest: that flag is what
|
|
430
|
+
* `assertEmiShippable` reads, so early settlement should be able to release a
|
|
431
|
+
* shipment early rather than waiting out a calendar.
|
|
432
|
+
*
|
|
433
|
+
* It mirrors the manual-payment model rather than inventing a second one: the
|
|
434
|
+
* buyer supplies a UTR and a proof, the seller or an admin confirms. The
|
|
435
|
+
* status therefore moves to `pending` review here, NOT to `paid` — a buyer
|
|
436
|
+
* must never be able to mark their own money received. `markEmiInstallmentPaid`
|
|
437
|
+
* stays the only writer of `paid`, and so stays the only way `emiComplete`
|
|
438
|
+
* can flip.
|
|
439
|
+
*/
|
|
440
|
+
export async function submitEmiInstallmentPaymentAction(orderId, input) {
|
|
441
|
+
return wrapAction(async () => {
|
|
442
|
+
const user = await requireRoleUser(["user", "buyer", "seller", "admin"]);
|
|
443
|
+
const order = await orderRepository.findById(orderId);
|
|
444
|
+
if (!order)
|
|
445
|
+
throw new OrderNotFoundError(orderId);
|
|
446
|
+
if (!isAdminUser(user) && order.userId !== user.uid)
|
|
447
|
+
throw new OrderOwnershipError(orderId);
|
|
448
|
+
if (!order.emiEnabled)
|
|
449
|
+
throw new ValidationError("This order is not on an EMI plan");
|
|
450
|
+
const installments = order.emiInstallments ?? [];
|
|
451
|
+
const target = installments.find((i) => i.index === input.installmentIndex);
|
|
452
|
+
if (!target) {
|
|
453
|
+
throw new ValidationError(`Installment ${input.installmentIndex} not found on this order`);
|
|
454
|
+
}
|
|
455
|
+
if (target.status === "paid") {
|
|
456
|
+
throw new ValidationError("This installment is already paid");
|
|
457
|
+
}
|
|
458
|
+
// A second submission would overwrite the reference the reviewer is
|
|
459
|
+
// currently looking at, which reads to them as the buyer changing their
|
|
460
|
+
// story. Re-submitting needs the reviewer to reject the first one.
|
|
461
|
+
if (target.transactionId) {
|
|
462
|
+
throw new ValidationError("A payment reference is already under review for this installment");
|
|
463
|
+
}
|
|
464
|
+
/*
|
|
465
|
+
* 🛑 `status` is NOT written here, and writing it would be a lie dressed
|
|
466
|
+
* as a state change.
|
|
467
|
+
*
|
|
468
|
+
* A new instalment is created `status: "pending"` (see
|
|
469
|
+
* `createOrderForGroup`), so setting `"pending"` again changes nothing
|
|
470
|
+
* while reading like a transition — and it would leave "nobody has paid"
|
|
471
|
+
* and "the buyer has reported paying" indistinguishable, which is exactly
|
|
472
|
+
* the state a reviewer needs to tell apart.
|
|
473
|
+
*
|
|
474
|
+
* "Awaiting review" is DERIVED: `transactionId` set and `status !== "paid"`.
|
|
475
|
+
* Same rule as the manual-payment queue, which derives its four states
|
|
476
|
+
* from `paymentProofUrl` + `paymentReviewOutcome` rather than storing a
|
|
477
|
+
* mirror field that any write path could forget to update (Root Cause
|
|
478
|
+
* #42). Only `markEmiInstallmentPaid` writes `paid`, so a buyer can never
|
|
479
|
+
* mark their own money received, and `emiComplete` keeps its single writer.
|
|
480
|
+
*/
|
|
481
|
+
const updatedInstallments = installments.map((i) => i.index === input.installmentIndex
|
|
482
|
+
? { ...i, transactionId: input.transactionId, proofUrl: input.proofUrl }
|
|
483
|
+
: i);
|
|
484
|
+
await orderRepository.update(orderId, { emiInstallments: updatedInstallments });
|
|
485
|
+
/*
|
|
486
|
+
* Tell the seller there is something to confirm. Best-effort: a
|
|
487
|
+
* notification that fails must not lose the buyer's submitted reference,
|
|
488
|
+
* which is the only record that they paid.
|
|
489
|
+
*/
|
|
490
|
+
const store = order.storeId ? await storeRepository.findById(order.storeId) : null;
|
|
491
|
+
if (store?.ownerId) {
|
|
492
|
+
await sendNotification({
|
|
493
|
+
userId: store.ownerId,
|
|
494
|
+
type: "payment_review",
|
|
495
|
+
/*
|
|
496
|
+
* `high`, matching the other `payment_review` send in this file. The
|
|
497
|
+
* two differ in urgency — that one has a 15-minute re-upload window
|
|
498
|
+
* and this one has no deadline — but a single notification TYPE whose
|
|
499
|
+
* priority disagrees between its call sites is the drift this codebase
|
|
500
|
+
* keeps paying for, and unconfirmed money is a seller action item
|
|
501
|
+
* either way.
|
|
502
|
+
*/
|
|
503
|
+
priority: "high",
|
|
504
|
+
title: "EMI instalment payment reported",
|
|
505
|
+
message: `A buyer has submitted payment for instalment ${input.installmentIndex} on order ${orderId}.`,
|
|
506
|
+
relatedId: orderId,
|
|
507
|
+
relatedType: "order",
|
|
508
|
+
}).catch((err) => {
|
|
509
|
+
void normalizeError(err);
|
|
510
|
+
serverLogger.error("Failed to notify seller of EMI instalment payment (non-fatal)", {
|
|
511
|
+
orderId,
|
|
512
|
+
installmentIndex: input.installmentIndex,
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
@@ -170,6 +170,13 @@ export function orderDocumentToOrder(doc) {
|
|
|
170
170
|
// the packer needs giftWrapMessage in hand. An
|
|
171
171
|
// adapter that drops them makes every downstream surface render its
|
|
172
172
|
// "not applicable" fallback instead (Root Cause #57).
|
|
173
|
+
// EMI, so the buyer can see and pay their own schedule. Mapped with the
|
|
174
|
+
// add-ons above for the same reason recorded there: an adapter that drops
|
|
175
|
+
// a field makes every downstream surface render its not-applicable
|
|
176
|
+
// fallback (Root Cause #57).
|
|
177
|
+
emiEnabled: doc.emiEnabled,
|
|
178
|
+
emiInstallments: doc.emiInstallments,
|
|
179
|
+
emiRemainingBalance: doc.emiRemainingBalance,
|
|
173
180
|
giftWrapAddon: doc.giftWrapAddon,
|
|
174
181
|
giftWrapFee: doc.giftWrapFee,
|
|
175
182
|
giftWrapMessage: doc.giftWrapMessage,
|
|
@@ -1039,6 +1039,21 @@ export const ACTIONS = {
|
|
|
1039
1039
|
},
|
|
1040
1040
|
},
|
|
1041
1041
|
BLOG: {
|
|
1042
|
+
/*
|
|
1043
|
+
* Clone, admin-only, backed by POST /api/admin/blog/[id]/duplicate. A
|
|
1044
|
+
* templated post is the real use case: same structure and category, new
|
|
1045
|
+
* subject. The copy starts as a DRAFT with a freshly derived slug — blog is
|
|
1046
|
+
* a pure-slug resource, so reusing the source slug would overwrite it.
|
|
1047
|
+
*/
|
|
1048
|
+
"duplicate-post": {
|
|
1049
|
+
iconKey: "duplicate",
|
|
1050
|
+
id: "blog.duplicate-post",
|
|
1051
|
+
label: "Duplicate",
|
|
1052
|
+
ariaLabel: "Duplicate this post as a draft",
|
|
1053
|
+
description: "Copy this post into a new draft, without its views or featured flag.",
|
|
1054
|
+
kind: "secondary",
|
|
1055
|
+
permissions: ["admin"],
|
|
1056
|
+
},
|
|
1042
1057
|
"create-post": {
|
|
1043
1058
|
iconKey: "create",
|
|
1044
1059
|
id: "blog.create-post",
|
|
@@ -1113,6 +1128,23 @@ export const ACTIONS = {
|
|
|
1113
1128
|
},
|
|
1114
1129
|
},
|
|
1115
1130
|
EVENT: {
|
|
1131
|
+
/*
|
|
1132
|
+
* Clone, admin-only, backed by POST /api/admin/events/[id]/duplicate. A
|
|
1133
|
+
* recurring sale is the real use case: same copy, same prizes, new dates.
|
|
1134
|
+
*
|
|
1135
|
+
* 🛑 The endpoint rebuilds every lottery slot from its prize definition and
|
|
1136
|
+
* resets the booking half — a spread would attribute a real buyer’s
|
|
1137
|
+
* purchased slot to a brand-new event.
|
|
1138
|
+
*/
|
|
1139
|
+
duplicate: {
|
|
1140
|
+
iconKey: "duplicate",
|
|
1141
|
+
id: "event.duplicate",
|
|
1142
|
+
label: "Duplicate",
|
|
1143
|
+
ariaLabel: "Duplicate this event as a draft",
|
|
1144
|
+
description: "Copy this event into a new draft, without its entries, winner or slot bookings.",
|
|
1145
|
+
kind: "secondary",
|
|
1146
|
+
permissions: ["admin"],
|
|
1147
|
+
},
|
|
1116
1148
|
/*
|
|
1117
1149
|
* This bucket had only register / cancel-registration / draw-raffle-winner
|
|
1118
1150
|
* — no edit, no delete — so the owner affordance on an event card or detail
|
|
@@ -2208,6 +2240,27 @@ export const ACTIONS = {
|
|
|
2208
2240
|
kind: "ghost",
|
|
2209
2241
|
permissions: ["admin"],
|
|
2210
2242
|
},
|
|
2243
|
+
/*
|
|
2244
|
+
* Homepage sections have no bucket of their own, so their edit leaf lives
|
|
2245
|
+
* here beside the carousel pair for the same reason — both are homepage
|
|
2246
|
+
* chrome an admin edits from a list rather than from a detail page.
|
|
2247
|
+
*
|
|
2248
|
+
* 🛑 There is deliberately NO `delete-section` counterpart. Section `order`
|
|
2249
|
+
* is POSITIONAL, so removing one silently renumbers its neighbours — not
|
|
2250
|
+
* something to offer from the public homepage, where the only confirmation
|
|
2251
|
+
* a person gets is a dialog. `OwnerQuickActions` enforces the absence by
|
|
2252
|
+
* omitting `remove` from the SECTION mapping, so no call site can pass a
|
|
2253
|
+
* delete handler and have it render.
|
|
2254
|
+
*/
|
|
2255
|
+
"edit-section": {
|
|
2256
|
+
iconKey: "edit",
|
|
2257
|
+
id: "admin.edit-section",
|
|
2258
|
+
label: "Edit Section",
|
|
2259
|
+
ariaLabel: "Edit this homepage section",
|
|
2260
|
+
description: "Open the homepage sections list, which owns this section's config.",
|
|
2261
|
+
kind: "ghost",
|
|
2262
|
+
permissions: ["admin"],
|
|
2263
|
+
},
|
|
2211
2264
|
"delete-carousel": {
|
|
2212
2265
|
iconKey: "delete",
|
|
2213
2266
|
id: "admin.delete-carousel",
|
|
@@ -78,7 +78,7 @@ export function roundShippingUp(rawRate) {
|
|
|
78
78
|
export function reconcileShipping(quote, actual) {
|
|
79
79
|
const charged = Number.isFinite(quote.chargedRate) ? Math.max(0, quote.chargedRate) : 0;
|
|
80
80
|
const spent = Number.isFinite(actual.actualRate) ? Math.max(0, actual.actualRate) : 0;
|
|
81
|
-
const delta =
|
|
81
|
+
const delta = roundMoney(charged - spent);
|
|
82
82
|
return {
|
|
83
83
|
sellerAbsorbs: delta < 0 ? Math.abs(delta) : 0,
|
|
84
84
|
buyerPoints: delta > 0 ? delta : 0,
|
|
@@ -88,12 +88,11 @@ export function reconcileShipping(quote, actual) {
|
|
|
88
88
|
/**
|
|
89
89
|
* Two decimal places.
|
|
90
90
|
*
|
|
91
|
-
* Money
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* balance.
|
|
91
|
+
* Money is stored as decimal rupees (Root Cause #31), so a subtraction can
|
|
92
|
+
* leave a float artefact — `100 - 47.1` is `52.900000000000006`, and that would
|
|
93
|
+
* become a points credit with eleven decimal places on a user's balance.
|
|
95
94
|
*/
|
|
96
|
-
function
|
|
95
|
+
function roundMoney(value) {
|
|
97
96
|
return Math.round(value * 100) / 100;
|
|
98
97
|
}
|
|
99
98
|
/**
|
package/dist/client.d.ts
CHANGED
|
@@ -387,6 +387,8 @@ export type { UserAccountHubViewProps, UserAccountHubViewLabels, UserOrdersViewP
|
|
|
387
387
|
export { useOrders, useOrder, OrdersList } from "./features/orders/index";
|
|
388
388
|
export { OrderStatusTimeline } from "./features/orders/components/OrderStatusTimeline";
|
|
389
389
|
export type { OrderStatusTimelineProps } from "./features/orders/components/OrderStatusTimeline";
|
|
390
|
+
export { EmiInstallmentsPanel } from "./features/orders/components/EmiInstallmentsPanel";
|
|
391
|
+
export type { EmiInstallmentsPanelProps } from "./features/orders/components/EmiInstallmentsPanel";
|
|
390
392
|
export { CountdownDisplay } from "./ui/components/CountdownDisplay";
|
|
391
393
|
export type { CountdownDisplayProps } from "./ui/components/CountdownDisplay";
|
|
392
394
|
export { OrderAddonBadges } from "./features/orders/components/OrderAddonBadges";
|
|
@@ -584,3 +586,22 @@ export { PriceIndexToolView } from "./features/price-index/components/PriceIndex
|
|
|
584
586
|
export type { PriceIndexToolViewProps, PriceIndexToolResponse, PriceIndexResultRow, PriceIndexBandRow, PriceIndexConditionRow, } from "./features/price-index/components/PriceIndexToolView";
|
|
585
587
|
export { COURIER_RATING_MIN_CONFIDENT_SAMPLES, courierRatingDisplay, isValidCourierVote, courierVoteId, rollUpCourierVotes, } from "./_internal/shared/features/shipping/courier-rating";
|
|
586
588
|
export type { CourierRatingPair, CourierRatingDisplay, CourierRatingVote, CourierStats, } from "./_internal/shared/features/shipping/courier-rating";
|
|
589
|
+
/**
|
|
590
|
+
* The `/api/checkout/pricing-preview` RESPONSE CONTRACT.
|
|
591
|
+
*
|
|
592
|
+
* 🛑 `export type`, from a `_internal/server` module, deliberately. A type-only
|
|
593
|
+
* re-export is fully erased at compile time — `audit-client-server-only-leak`
|
|
594
|
+
* states that explicitly — so this pulls no server chain into the client
|
|
595
|
+
* bundle. It is the one exception worth making for this module.
|
|
596
|
+
*
|
|
597
|
+
* The consumer had been keeping a HAND-WRITTEN copy of both shapes in
|
|
598
|
+
* `src/lib/api/payment-client.ts`, under a comment reading "mirrors
|
|
599
|
+
* `CheckoutPricingPreviewStore` on the server". It drifted exactly as Root
|
|
600
|
+
* Cause #42 predicts: the server gained `emiSurcharge`, the mirror did not, and
|
|
601
|
+
* the preview the cart rendered could not be assigned to the breakdown
|
|
602
|
+
* component that displays it. Four `tsc` errors across the cart and checkout
|
|
603
|
+
* clients, all from one missing field in a second definition of one shape.
|
|
604
|
+
*
|
|
605
|
+
* A response contract has exactly one owner — the handler that produces it.
|
|
606
|
+
*/
|
|
607
|
+
export type { CheckoutPricingPreview, CheckoutPricingPreviewStore, } from "./_internal/server/features/checkout/actions";
|
package/dist/client.js
CHANGED
|
@@ -405,6 +405,8 @@ export { couponDraftToPayload } from "./features/seller/schemas/coupon-form.js";
|
|
|
405
405
|
export { UserAccountHubView, UserOrdersView, UserBidsView, OrderDetailView, UserReturnsView, useNotifications } from "./features/account/index.js";
|
|
406
406
|
export { useOrders, useOrder, OrdersList } from "./features/orders/index.js";
|
|
407
407
|
export { OrderStatusTimeline } from "./features/orders/components/OrderStatusTimeline.js";
|
|
408
|
+
// Buyer-facing EMI schedule with a pay-any-instalment action.
|
|
409
|
+
export { EmiInstallmentsPanel } from "./features/orders/components/EmiInstallmentsPanel.js";
|
|
408
410
|
export { CountdownDisplay } from "./ui/components/CountdownDisplay.js";
|
|
409
411
|
export { OrderAddonBadges } from "./features/orders/components/OrderAddonBadges.js";
|
|
410
412
|
// Manual-payment (cash / UPI / EMI) shared constants — pure data + predicates,
|
|
@@ -16,6 +16,7 @@ import { pluginFor } from "../../../_internal/shared/listing-types/_registry.js"
|
|
|
16
16
|
import { normalizeListingType } from "../../products/utils/listing-type.js";
|
|
17
17
|
import { isAdminUser, isSellerUser } from "../../auth/role-predicates.js";
|
|
18
18
|
import { User, Star, ShoppingBag, Package, Trophy, Globe, MapPin, ExternalLink } from "lucide-react";
|
|
19
|
+
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
19
20
|
const __P = {
|
|
20
21
|
p4: "p-[var(--appkit-space-4)]",
|
|
21
22
|
p6: "p-[var(--appkit-space-6)]",
|
|
@@ -120,7 +121,7 @@ export async function PublicProfileView({ userId, tab, }) {
|
|
|
120
121
|
const profileHeroCtx = { displayName, photoURL, memberSince, isSeller, storeSlug, flex, page };
|
|
121
122
|
const statItems = buildProfileStatItems(t, { listingCount, reviewCount, itemsSold, auctionsWon, totalOrders, isSeller });
|
|
122
123
|
const activeTab = tab === "catalogue" ? "catalogue" : "overview";
|
|
123
|
-
return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [renderProfileHero(t, profileHeroCtx), _jsxs(Stack, { gap: "2xl", className: `${page.container.md}`, paddingY: "y-3xl", children: [_jsxs(Row, { gap: "md", padding: "t-xs", border: "bottom", children: [_jsx(TextLink, { href: `/profile/${userId}/overview`, variant: activeTab === "overview" ? "default" : "muted", weight: activeTab === "overview" ? "semibold" : "normal", children: "Overview" }), _jsx(TextLink, { href: `/profile/${userId}/catalogue`, variant: activeTab === "catalogue" ? "default" : "muted", weight: activeTab === "catalogue" ? "semibold" : "normal", children: "Catalogue" })] }), activeTab === "catalogue" ? (_jsx(PublicCatalogueView, { ownerSlug: userId })) : (_jsxs(_Fragment, { children: [renderProfileStatsRow(flex, statItems), renderProfileBioSection(pub), renderStoreDescriptionSection(isSeller, storeSlug ?? null, storeDescription ?? null, storeName, t), renderProfileListingsSection(t, products, storeSlug ?? null), renderAuthoredReviewsSection(t, reviewsAuthored, displayName), reviewsReceivedAsBuyer.length > 0 && renderBuyerReceivedReviewsSection(t, reviewsReceivedAsBuyer)] })), _jsx(Row, { justify: "center", padding: "t-xs", children: _jsxs(Link, { href: String(ROUTES.HOME), className: "text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]", children: ["\u2190 ", t("backHome")] }) })] })] }));
|
|
124
|
+
return (_jsxs(Div, { className: "-mx-4 md:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8 lg:-mt-10", children: [renderProfileHero(t, profileHeroCtx), _jsxs(Stack, { gap: "2xl", className: `${page.container.md}`, paddingY: "y-3xl", children: [_jsx(Row, { justify: "end", children: _jsx(OwnerQuickActions, { resource: "USER", placement: "detail", recordLabel: displayName, editHref: String(ROUTES.ADMIN.USER_EDIT(userId)) }) }), _jsxs(Row, { gap: "md", padding: "t-xs", border: "bottom", children: [_jsx(TextLink, { href: `/profile/${userId}/overview`, variant: activeTab === "overview" ? "default" : "muted", weight: activeTab === "overview" ? "semibold" : "normal", children: "Overview" }), _jsx(TextLink, { href: `/profile/${userId}/catalogue`, variant: activeTab === "catalogue" ? "default" : "muted", weight: activeTab === "catalogue" ? "semibold" : "normal", children: "Catalogue" })] }), activeTab === "catalogue" ? (_jsx(PublicCatalogueView, { ownerSlug: userId })) : (_jsxs(_Fragment, { children: [renderProfileStatsRow(flex, statItems), renderProfileBioSection(pub), renderStoreDescriptionSection(isSeller, storeSlug ?? null, storeDescription ?? null, storeName, t), renderProfileListingsSection(t, products, storeSlug ?? null), renderAuthoredReviewsSection(t, reviewsAuthored, displayName), reviewsReceivedAsBuyer.length > 0 && renderBuyerReceivedReviewsSection(t, reviewsReceivedAsBuyer)] })), _jsx(Row, { justify: "center", padding: "t-xs", children: _jsxs(Link, { href: String(ROUTES.HOME), className: "text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]", children: ["\u2190 ", t("backHome")] }) })] })] }));
|
|
124
125
|
}
|
|
125
126
|
function buildProfileStatItems(t, ctx) {
|
|
126
127
|
const { listingCount, reviewCount, itemsSold, auctionsWon, totalOrders, isSeller } = ctx;
|
|
@@ -51,6 +51,27 @@ const ORDER_LANE_TABS = [
|
|
|
51
51
|
{ id: "auction", label: "Auction wins" },
|
|
52
52
|
{ id: "offer", label: "Offer wins" },
|
|
53
53
|
];
|
|
54
|
+
/**
|
|
55
|
+
* Payment-method tabs — a THIRD axis, independent of the lane tabs above and
|
|
56
|
+
* the lifecycle scope beside them.
|
|
57
|
+
*
|
|
58
|
+
* 🛑 EMI is not a lane and not a status, which is why it could not join
|
|
59
|
+
* either existing control: `orderType` records how the order was WON
|
|
60
|
+
* (standard / auction / offer) and the scope records where it IS. How it is
|
|
61
|
+
* being PAID is orthogonal to both — an auction win can be on an EMI plan.
|
|
62
|
+
*
|
|
63
|
+
* Every id is exactly a stored `OrderDocument.paymentMethod` value, and
|
|
64
|
+
* `paymentMethod` is `canFilter: true` in the orders SIEVE_FIELDS — checked
|
|
65
|
+
* before wiring this, because an unallowlisted field does not raise: the
|
|
66
|
+
* clause is silently dropped and the chip returns the ENTIRE order list while
|
|
67
|
+
* looking like a filter (Root Cause #62).
|
|
68
|
+
*/
|
|
69
|
+
const PAYMENT_METHOD_TABS = [
|
|
70
|
+
{ id: "", label: "Any" },
|
|
71
|
+
{ id: "emi", label: "EMI plans" },
|
|
72
|
+
{ id: "cod", label: "Cash on delivery" },
|
|
73
|
+
{ id: "online", label: "Paid online" },
|
|
74
|
+
];
|
|
54
75
|
const STATUS_OPTIONS = [
|
|
55
76
|
{ value: "pending", label: "Pending" },
|
|
56
77
|
{ value: "processing", label: "Processing" },
|
|
@@ -85,7 +106,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
85
106
|
* (CLAUDE.md, Root Cause #35).
|
|
86
107
|
*/
|
|
87
108
|
const searchParams = useSearchParams();
|
|
88
|
-
const hasNarrowingFilter = ["q", "search", "status", "orderType", "filters"].some((k) => Boolean(searchParams.get(k)));
|
|
109
|
+
const hasNarrowingFilter = ["q", "search", "status", "orderType", "paymentMethod", "filters"].some((k) => Boolean(searchParams.get(k)));
|
|
89
110
|
const emptyMessage = hasNarrowingFilter
|
|
90
111
|
? "No orders match your search or filters."
|
|
91
112
|
: "You haven't placed any orders yet.";
|
|
@@ -104,7 +125,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
104
125
|
fields: ["id", "productTitle"],
|
|
105
126
|
},
|
|
106
127
|
emptyLabel: emptyMessage,
|
|
107
|
-
filterKeys: ["status", "orderType"],
|
|
128
|
+
filterKeys: ["status", "orderType", "paymentMethod"],
|
|
108
129
|
defaultSort: sortBy("createdAt", "DESC"),
|
|
109
130
|
queryKey: ["user", "orders", "listing"],
|
|
110
131
|
endpoint: ACCOUNT_ENDPOINTS.ORDERS,
|
|
@@ -115,6 +136,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
115
136
|
buildFilters: (state) => [
|
|
116
137
|
state.status ? sieveFilter("status", SIEVE_OP.EQ, state.status) : null,
|
|
117
138
|
state.orderType ? sieveFilter("orderType", SIEVE_OP.EQ, state.orderType) : null,
|
|
139
|
+
state.paymentMethod ? sieveFilter("paymentMethod", SIEVE_OP.EQ, state.paymentMethod) : null,
|
|
118
140
|
]
|
|
119
141
|
.filter(Boolean)
|
|
120
142
|
.join(",") || undefined,
|
|
@@ -123,7 +145,7 @@ export function UserOrdersView({ onOrderClick }) {
|
|
|
123
145
|
// in flight" without those two questions fighting over one control.
|
|
124
146
|
buildExtraParams: () => orderScope.extraParams,
|
|
125
147
|
renderAboveContent: orderScope.renderAboveContent,
|
|
126
|
-
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
|
|
148
|
+
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FilterChipGroup, { label: "Type", tabs: ORDER_LANE_TABS, value: pendingFilters.orderType ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, orderType: id })) }), _jsx(FilterChipGroup, { label: "Payment", tabs: PAYMENT_METHOD_TABS, value: pendingFilters.paymentMethod ?? "", onChange: (id) => setPendingFilters((prev) => ({ ...prev, paymentMethod: id })) }), _jsx(Div, { children: _jsx(PaginatedSelect, { value: pendingFilters.status || null, onChange: (v) => setPendingFilters((p) => ({ ...p, status: v ?? "" })), options: STATUS_OPTIONS, placeholder: "All statuses", ariaLabel: "Filter by order status" }) })] })),
|
|
127
149
|
renderCards: (rows, _view, _selection, isLoading) => {
|
|
128
150
|
if (isLoading) {
|
|
129
151
|
return (_jsx(Stack, { gap: "md", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Div, { className: "h-24 animate-pulse border border-[var(--appkit-color-border)]", rounded: "xl" }, i))) }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Which registry bucket supplies the labels and the confirm copy. */
|
|
2
|
-
export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW";
|
|
2
|
+
export type OwnerQuickActionsResource = "PRODUCT" | "CATEGORY" | "BRAND" | "FRANCHISE" | "BUNDLE" | "GROUP" | "SUBLISTING" | "EVENT" | "BLOG" | "REVIEW" | "STORE" | "USER" | "CAROUSEL" | "SECTION";
|
|
3
3
|
export interface OwnerQuickActionsProps {
|
|
4
4
|
resource: OwnerQuickActionsResource;
|
|
5
5
|
/** Where the pencil goes — that record's dashboard editor. */
|