@mohasinac/appkit 4.23.0 → 4.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/bundles/actions.js +7 -0
- package/dist/_internal/server/features/checkout/actions.js +24 -16
- package/dist/_internal/shared/actions/action-registry.js +41 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
- package/dist/_internal/shared/checkout/order-math.js +29 -6
- package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
- package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
- package/dist/_internal/shared/features/cart/config.d.ts +2 -1
- package/dist/_internal/shared/features/cart/config.js +2 -1
- package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
- package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
- package/dist/_internal/shared/features/notifications/action-url.js +111 -0
- package/dist/constants/notification-types.d.ts +38 -10
- package/dist/constants/notification-types.js +39 -28
- package/dist/features/account/types/index.d.ts +1 -10
- package/dist/features/admin/actions/notification-actions.d.ts +12 -0
- package/dist/features/admin/actions/notification-actions.js +149 -4
- package/dist/features/admin/schemas/firestore.d.ts +14 -3
- package/dist/features/admin/schemas/firestore.js +79 -0
- package/dist/features/cart/actions/cart-actions.d.ts +16 -0
- package/dist/features/cart/actions/cart-actions.js +26 -12
- package/dist/features/cart/components/CartDrawer.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +132 -8
- package/dist/features/cart/schemas/firestore.js +22 -0
- package/dist/features/email/index.d.ts +1 -1
- package/dist/features/email/index.js +5 -1
- package/dist/features/email/notification-templates.d.ts +35 -0
- package/dist/features/email/notification-templates.js +96 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +30 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -1
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/seed/factories/notification.factory.d.ts +2 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +5 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +10 -1
- package/dist/styles.css +58 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/QuantityStepper.d.ts +38 -0
- package/dist/ui/components/QuantityStepper.js +20 -0
- package/dist/ui/components/QuantityStepper.style.css +56 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { NotFoundError, ValidationError } from "../../../../errors";
|
|
16
16
|
import { categoriesRepository } from "../../../../repositories";
|
|
17
17
|
import { cartRepository } from "../../../../features/cart/repository/cart.repository";
|
|
18
|
+
import { assertCanAddNewItems } from "../../../../features/cart/actions/cart-actions";
|
|
18
19
|
import { getDefaultCurrency } from "../../../../core/baseline-resolver";
|
|
19
20
|
export async function addBundleToCartAction(userId, bundleSlug) {
|
|
20
21
|
if (!userId)
|
|
@@ -32,6 +33,12 @@ export async function addBundleToCartAction(userId, bundleSlug) {
|
|
|
32
33
|
if (!bundle.bundlePrice || bundle.bundlePrice < 1) {
|
|
33
34
|
throw new ValidationError("Bundle price is not configured");
|
|
34
35
|
}
|
|
36
|
+
// Bundles bypass `addItemToCart`'s capability gate deliberately (a bundle's
|
|
37
|
+
// `listingType: "standard"` is a lie told to satisfy it — `bundleRule` is
|
|
38
|
+
// `cartEligible: false`). The LANE gate is not optional though, and this path
|
|
39
|
+
// silently skipped it: a buyer with an unpaid auction win could keep shopping
|
|
40
|
+
// by adding bundles.
|
|
41
|
+
await assertCanAddNewItems(userId);
|
|
35
42
|
await cartRepository.addItem(userId, {
|
|
36
43
|
productId: bundle.id,
|
|
37
44
|
productTitle: bundle.name,
|
|
@@ -14,7 +14,7 @@ import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
|
14
14
|
import { serverLogger } from "../../../../monitoring";
|
|
15
15
|
import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
|
|
16
16
|
import { calculateGst } from "../../../shared/fees/calculator";
|
|
17
|
-
import { computePreOrderDepositAmount, unitPriceFor } from "../../../shared/checkout/order-math";
|
|
17
|
+
import { computePreOrderDepositAmount, lineTotalFor, unitPriceFor } from "../../../shared/checkout/order-math";
|
|
18
18
|
import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
|
|
19
19
|
import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
|
|
20
20
|
import { splitCartIntoOrderGroups } from "../../../../features/orders/index";
|
|
@@ -374,7 +374,7 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
374
374
|
// Add-ons are this store's choice, not the cart's — see OrderGroupContext.
|
|
375
375
|
const { whatsappNotifyAddon = false, giftWrapAddon = false, giftWrapMessage, shipmentProtectionAddon = false, } = storeAddons?.[firstItem.storeId] ?? {};
|
|
376
376
|
const { platformFee = 0, gstOnFee: platformFeeGst = 0 } = platformFeeByStore.get(firstItem.storeId) ?? {};
|
|
377
|
-
const groupTotal = group.reduce((sum, { item, product }) => sum +
|
|
377
|
+
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
378
378
|
// S-SBUNI-RULES 2026-05-13 — order-item decoration via rule registry.
|
|
379
379
|
const orderItems = group.map(({ item, product }) => {
|
|
380
380
|
const lt = (product.listingType ?? "standard");
|
|
@@ -415,7 +415,7 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
415
415
|
let taxableAmount = 0;
|
|
416
416
|
let gstAmount = 0;
|
|
417
417
|
for (const { item, product } of group) {
|
|
418
|
-
const lineTotal =
|
|
418
|
+
const lineTotal = lineTotalFor(item, product);
|
|
419
419
|
const rate = product.gstRate ?? 0;
|
|
420
420
|
if (rate > 0) {
|
|
421
421
|
taxableAmount += lineTotal;
|
|
@@ -774,8 +774,16 @@ export async function createCheckoutOrderAction(input) {
|
|
|
774
774
|
}
|
|
775
775
|
}
|
|
776
776
|
if (availableItems.length > 0) {
|
|
777
|
-
|
|
778
|
-
|
|
777
|
+
// Clear by LINE identity, not by productId. `itemId` is what uniquely
|
|
778
|
+
// identifies a line; `productId` is not unique across lines and is not
|
|
779
|
+
// even a product id on a bundle/group line (it points at the bundle
|
|
780
|
+
// category, or at the group parent — which is itself purchasable). So
|
|
781
|
+
// matching on it deleted every OTHER line that happened to share the
|
|
782
|
+
// value: a loose "Set" parent sitting beside a group line built from
|
|
783
|
+
// that same group, or two lines of one product, both vanished when only
|
|
784
|
+
// one was ordered.
|
|
785
|
+
const orderedItemIds = new Set(availableItems.map((a) => a.item.itemId));
|
|
786
|
+
const remainingCartItems = cart.items.filter((ci) => !orderedItemIds.has(ci.itemId));
|
|
779
787
|
const cartRef = db.collection(CART_COLLECTION).doc(uid);
|
|
780
788
|
tx.set(cartRef, {
|
|
781
789
|
items: remainingCartItems,
|
|
@@ -818,13 +826,13 @@ export async function createCheckoutOrderAction(input) {
|
|
|
818
826
|
const orderIds = [];
|
|
819
827
|
let total = 0;
|
|
820
828
|
const emailsToSend = [];
|
|
821
|
-
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs +
|
|
829
|
+
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
|
|
822
830
|
const couponUsageAccumulator = new Map();
|
|
823
831
|
// One commission on one transaction, capped, then apportioned across the
|
|
824
832
|
// orders this cart splits into — see allocateCheckoutFees.
|
|
825
833
|
const platformFeeByStore = allocateCheckoutFees(orderGroups.map(({ items: g }) => [
|
|
826
834
|
g[0].item.storeId,
|
|
827
|
-
g.reduce((sum, { item, product }) => sum +
|
|
835
|
+
g.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
|
|
828
836
|
]), computeCheckoutFees(cartSubtotal, commissions));
|
|
829
837
|
const groupCtx = {
|
|
830
838
|
paymentMethod,
|
|
@@ -922,7 +930,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
922
930
|
}
|
|
923
931
|
const checks = cartItems.map((item) => ({ item, product: productById.get(item.productId) ?? null }));
|
|
924
932
|
const orderGroups = splitCartIntoOrderGroups(checks);
|
|
925
|
-
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs +
|
|
933
|
+
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
|
|
926
934
|
// Same re-validation the real order placement runs, so the preview never
|
|
927
935
|
// shows a discount the order won't actually honour.
|
|
928
936
|
const { coupons: appliedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], checks, uid);
|
|
@@ -932,7 +940,7 @@ export async function previewCheckoutPricing(input) {
|
|
|
932
940
|
const stores = [];
|
|
933
941
|
for (const { items: group } of orderGroups) {
|
|
934
942
|
const firstItem = group[0].item;
|
|
935
|
-
const groupTotal = group.reduce((sum, { item, product }) => sum +
|
|
943
|
+
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
936
944
|
const { shippingFee: groupShippingFee, storeState } = await resolveShippingCost(firstItem.storeId);
|
|
937
945
|
// Add-ons are per store, read off the cart document — the same entry
|
|
938
946
|
// createOrderForGroup will read when the order is actually placed.
|
|
@@ -948,13 +956,13 @@ export async function previewCheckoutPricing(input) {
|
|
|
948
956
|
if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
|
|
949
957
|
const intraState = !!storeState && storeState === resolvedAddress.state;
|
|
950
958
|
for (const { item, product } of group) {
|
|
951
|
-
const lineTotal =
|
|
959
|
+
const lineTotal = lineTotalFor(item, product);
|
|
952
960
|
const rate = product?.gstRate ?? 0;
|
|
953
961
|
if (rate > 0)
|
|
954
962
|
groupGstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
|
|
955
963
|
}
|
|
956
964
|
}
|
|
957
|
-
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal:
|
|
965
|
+
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
|
|
958
966
|
const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
959
967
|
stores.push({
|
|
960
968
|
storeId: firstItem.storeId,
|
|
@@ -1131,12 +1139,12 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
|
|
|
1131
1139
|
async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
1132
1140
|
const { appliedCoupons, cartSubtotal, storeAddons, platformFeeByStore, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, } = ctx;
|
|
1133
1141
|
const firstItem = group[0].item;
|
|
1134
|
-
const groupTotal = group.reduce((sum, { item, product }) => sum +
|
|
1142
|
+
const groupTotal = group.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0);
|
|
1135
1143
|
// Reuses the same store/seller lookup as the COD/UPI path above instead
|
|
1136
1144
|
// of re-implementing it inline — was two sequential findById calls per
|
|
1137
1145
|
// seller group here, duplicated from resolveShippingCost.
|
|
1138
1146
|
const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
|
|
1139
|
-
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal:
|
|
1147
|
+
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: lineTotalFor(item, product) }));
|
|
1140
1148
|
const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
1141
1149
|
// Mirrors createOrderForGroup — without this the online-payment path wrote
|
|
1142
1150
|
// the discount onto the order but never incremented usage.currentUsage or
|
|
@@ -1469,7 +1477,7 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1469
1477
|
const expectedAddonFees = expectedShippingGroups.reduce((sum, g) => {
|
|
1470
1478
|
const storeId = g.items[0].item.storeId;
|
|
1471
1479
|
const addons = cart.storeAddons?.[storeId] ?? {};
|
|
1472
|
-
const groupSubtotal = g.items.reduce((gs, { item, product }) => gs +
|
|
1480
|
+
const groupSubtotal = g.items.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0);
|
|
1473
1481
|
return (sum +
|
|
1474
1482
|
computeWhatsAppNotifyFee(addons.whatsappNotifyAddon ?? false, commissionRates) +
|
|
1475
1483
|
computeGiftWrapFee(addons.giftWrapAddon ?? false, commissionRates) +
|
|
@@ -1501,12 +1509,12 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1501
1509
|
let total = 0;
|
|
1502
1510
|
const emailsToSend = [];
|
|
1503
1511
|
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s +
|
|
1504
|
-
g.reduce((gs, { item, product }) => gs +
|
|
1512
|
+
g.reduce((gs, { item, product }) => gs + lineTotalFor(item, product), 0), 0);
|
|
1505
1513
|
const couponUsageAccumulator = new Map();
|
|
1506
1514
|
// Same single capped commission + pro-rata split as the COD/UPI path.
|
|
1507
1515
|
const platformFeeByStore = allocateCheckoutFees(orderGroups.map(({ items: g }) => [
|
|
1508
1516
|
g[0].item.storeId,
|
|
1509
|
-
g.reduce((sum, { item, product }) => sum +
|
|
1517
|
+
g.reduce((sum, { item, product }) => sum + lineTotalFor(item, product), 0),
|
|
1510
1518
|
]), computeCheckoutFees(cartSubtotal, siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS));
|
|
1511
1519
|
for (const { items: group, orderType } of orderGroups) {
|
|
1512
1520
|
total += await createRazorpayGroupOrder(group, orderType, {
|
|
@@ -2412,6 +2412,47 @@ export const ACTIONS = {
|
|
|
2412
2412
|
description: "Navigate back from the cart to the product catalogue.",
|
|
2413
2413
|
kind: "ghost",
|
|
2414
2414
|
},
|
|
2415
|
+
"add-group-to-cart": {
|
|
2416
|
+
iconKey: "cart",
|
|
2417
|
+
id: "cart.add-group-to-cart",
|
|
2418
|
+
label: "Add selected to cart",
|
|
2419
|
+
ariaLabel: "Add the selected group items to the cart as one line",
|
|
2420
|
+
description: "Add a buyer-assembled selection of group members to the cart as a SINGLE line, rather than one line per product.",
|
|
2421
|
+
kind: "primary",
|
|
2422
|
+
},
|
|
2423
|
+
"add-bundle-to-cart": {
|
|
2424
|
+
iconKey: "cart",
|
|
2425
|
+
id: "cart.add-bundle-to-cart",
|
|
2426
|
+
label: "Add to cart",
|
|
2427
|
+
ariaLabel: "Add this bundle to the cart",
|
|
2428
|
+
description: "Add a whole bundle to the cart. Bundles are all-or-nothing — the quantity is the number of COPIES of the bundle.",
|
|
2429
|
+
kind: "primary",
|
|
2430
|
+
},
|
|
2431
|
+
"remove-group-member": {
|
|
2432
|
+
iconKey: "delete",
|
|
2433
|
+
id: "cart.remove-group-member",
|
|
2434
|
+
label: "Remove",
|
|
2435
|
+
ariaLabel: "Remove this item from the group line",
|
|
2436
|
+
description: "Drop one member out of a grouped cart line. Ghost rather than danger on purpose: it reuses the cart's existing undo affordance, and Rule #7.4's mandatory-confirmation requirement applies to destructive actions.",
|
|
2437
|
+
kind: "ghost",
|
|
2438
|
+
},
|
|
2439
|
+
// No iconKey — these two exist only to keep the stepper's accessible names
|
|
2440
|
+
// in the registry rather than hard-coded in the primitive. The glyphs are
|
|
2441
|
+
// the literal "+" / "−" characters.
|
|
2442
|
+
"increase-quantity": {
|
|
2443
|
+
id: "cart.increase-quantity",
|
|
2444
|
+
label: "+",
|
|
2445
|
+
ariaLabel: "Increase quantity",
|
|
2446
|
+
description: "Default accessible name for QuantityStepper's increment control.",
|
|
2447
|
+
kind: "ghost",
|
|
2448
|
+
},
|
|
2449
|
+
"decrease-quantity": {
|
|
2450
|
+
id: "cart.decrease-quantity",
|
|
2451
|
+
label: "−",
|
|
2452
|
+
ariaLabel: "Decrease quantity",
|
|
2453
|
+
description: "Default accessible name for QuantityStepper's decrement control.",
|
|
2454
|
+
kind: "ghost",
|
|
2455
|
+
},
|
|
2415
2456
|
},
|
|
2416
2457
|
CHECKOUT: {
|
|
2417
2458
|
"place-order": {
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
import type { CartItemDocument } from "../../../features/cart/schemas/firestore";
|
|
8
8
|
import type { ProductDocument } from "../../../features/products/schemas/firestore";
|
|
9
9
|
/**
|
|
10
|
-
* The single source of truth for "what do we charge for this line".
|
|
10
|
+
* The single source of truth for "what do we charge for ONE COPY of this line".
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Four cases, in priority order:
|
|
13
13
|
*
|
|
14
14
|
* 1. **A locked price** — an accepted offer or a won auction. `lockedPrice` is
|
|
15
15
|
* the negotiated/won amount and it OVERRIDES the current listing price, which
|
|
@@ -17,15 +17,33 @@ import type { ProductDocument } from "../../../features/products/schemas/firesto
|
|
|
17
17
|
* copies of this function looked at `lockedPrice`, so an accepted offer was
|
|
18
18
|
* billed at the seller's listed price while the cart UI displayed the agreed
|
|
19
19
|
* one (`item.lockedPrice ?? item.price`) — the buyer saw ₹X and paid ₹Y.
|
|
20
|
-
* 2. **A
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* 2. **A multi-member line** — the sum of its members' own snapshotted prices,
|
|
21
|
+
* weighted by each member's per-copy quantity. A `"group"` line pins
|
|
22
|
+
* `item.quantity` to 1, so this is both the unit price and the line total;
|
|
23
|
+
* see the invariant on `CartLineMember`.
|
|
24
|
+
* 3. **A legacy bundle line** (SB-UNI-5 2026-05-13) — written before
|
|
25
|
+
* `groupMembers` existed. `item.price` is the bundle price locked at
|
|
26
|
+
* add-time.
|
|
27
|
+
* 4. **Everything else** — the live Firestore price, so a stale cart-cached
|
|
23
28
|
* price is never charged on a COD/UPI order.
|
|
24
29
|
*
|
|
25
30
|
* `product` may be null for a locked line whose listing has since been archived;
|
|
26
31
|
* the locked price is still owed, so that case must not dereference `product`.
|
|
32
|
+
*
|
|
33
|
+
* 🛑 Do not hand-roll this rule at a call site. It has been re-derived by hand
|
|
34
|
+
* six times, and the two copies that lived in the consumer repo
|
|
35
|
+
* (`src/app/api/payment/create-order/route.ts`) each reproduced the bundle
|
|
36
|
+
* branch while silently omitting case 1 — so Razorpay captured the LIST price
|
|
37
|
+
* for an accepted offer while the cart displayed the negotiated one. Call
|
|
38
|
+
* `lineTotalFor` and let this function stay the only definition.
|
|
27
39
|
*/
|
|
28
40
|
export declare function unitPriceFor(item: CartItemDocument, product: ProductDocument | null): number;
|
|
41
|
+
/**
|
|
42
|
+
* What this line contributes to a subtotal: one copy's price × the number of
|
|
43
|
+
* copies. THE call site should use this rather than multiplying `unitPriceFor`
|
|
44
|
+
* itself — that multiplication is where the six hand-rolled copies diverged.
|
|
45
|
+
*/
|
|
46
|
+
export declare function lineTotalFor(item: CartItemDocument, product: ProductDocument | null): number;
|
|
29
47
|
/**
|
|
30
48
|
* P-6 — pre-order groups charge each product's own `preOrderDepositPercent`
|
|
31
49
|
* (falling back to the generic COD deposit % when a product doesn't have one
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { roundRupees } from "../../../utils/number.formatter";
|
|
2
2
|
/**
|
|
3
|
-
* The single source of truth for "what do we charge for this line".
|
|
3
|
+
* The single source of truth for "what do we charge for ONE COPY of this line".
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Four cases, in priority order:
|
|
6
6
|
*
|
|
7
7
|
* 1. **A locked price** — an accepted offer or a won auction. `lockedPrice` is
|
|
8
8
|
* the negotiated/won amount and it OVERRIDES the current listing price, which
|
|
@@ -10,18 +10,33 @@ import { roundRupees } from "../../../utils/number.formatter";
|
|
|
10
10
|
* copies of this function looked at `lockedPrice`, so an accepted offer was
|
|
11
11
|
* billed at the seller's listed price while the cart UI displayed the agreed
|
|
12
12
|
* one (`item.lockedPrice ?? item.price`) — the buyer saw ₹X and paid ₹Y.
|
|
13
|
-
* 2. **A
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* 2. **A multi-member line** — the sum of its members' own snapshotted prices,
|
|
14
|
+
* weighted by each member's per-copy quantity. A `"group"` line pins
|
|
15
|
+
* `item.quantity` to 1, so this is both the unit price and the line total;
|
|
16
|
+
* see the invariant on `CartLineMember`.
|
|
17
|
+
* 3. **A legacy bundle line** (SB-UNI-5 2026-05-13) — written before
|
|
18
|
+
* `groupMembers` existed. `item.price` is the bundle price locked at
|
|
19
|
+
* add-time.
|
|
20
|
+
* 4. **Everything else** — the live Firestore price, so a stale cart-cached
|
|
16
21
|
* price is never charged on a COD/UPI order.
|
|
17
22
|
*
|
|
18
23
|
* `product` may be null for a locked line whose listing has since been archived;
|
|
19
24
|
* the locked price is still owed, so that case must not dereference `product`.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 Do not hand-roll this rule at a call site. It has been re-derived by hand
|
|
27
|
+
* six times, and the two copies that lived in the consumer repo
|
|
28
|
+
* (`src/app/api/payment/create-order/route.ts`) each reproduced the bundle
|
|
29
|
+
* branch while silently omitting case 1 — so Razorpay captured the LIST price
|
|
30
|
+
* for an accepted offer while the cart displayed the negotiated one. Call
|
|
31
|
+
* `lineTotalFor` and let this function stay the only definition.
|
|
20
32
|
*/
|
|
21
33
|
export function unitPriceFor(item, product) {
|
|
22
34
|
if (typeof item.lockedPrice === "number" && item.lockedPrice > 0) {
|
|
23
35
|
return item.lockedPrice;
|
|
24
36
|
}
|
|
37
|
+
if (item.groupMembers?.length) {
|
|
38
|
+
return roundRupees(item.groupMembers.reduce((sum, m) => sum + m.unitPrice * m.quantity, 0));
|
|
39
|
+
}
|
|
25
40
|
if (item.bundleCategorySlug && item.bundleProductIds?.length) {
|
|
26
41
|
return item.price;
|
|
27
42
|
}
|
|
@@ -31,6 +46,14 @@ export function unitPriceFor(item, product) {
|
|
|
31
46
|
// crash whenever a cart line outlives its listing.
|
|
32
47
|
return product?.price ?? item.price;
|
|
33
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* What this line contributes to a subtotal: one copy's price × the number of
|
|
51
|
+
* copies. THE call site should use this rather than multiplying `unitPriceFor`
|
|
52
|
+
* itself — that multiplication is where the six hand-rolled copies diverged.
|
|
53
|
+
*/
|
|
54
|
+
export function lineTotalFor(item, product) {
|
|
55
|
+
return unitPriceFor(item, product) * item.quantity;
|
|
56
|
+
}
|
|
34
57
|
/**
|
|
35
58
|
* P-6 — pre-order groups charge each product's own `preOrderDepositPercent`
|
|
36
59
|
* (falling back to the generic COD deposit % when a product doesn't have one
|
|
@@ -41,7 +64,7 @@ export function unitPriceFor(item, product) {
|
|
|
41
64
|
*/
|
|
42
65
|
export function computePreOrderDepositAmount(group, defaultDepositPercent) {
|
|
43
66
|
const raw = group.reduce((sum, { item, product }) => {
|
|
44
|
-
const lineTotal =
|
|
67
|
+
const lineTotal = lineTotalFor(item, product);
|
|
45
68
|
const pct = product.preOrderDepositPercent ?? defaultDepositPercent;
|
|
46
69
|
return sum + lineTotal * (pct / 100); // audit-money-units-ok: percentage divisor, not paise
|
|
47
70
|
}, 0);
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/** Checkout window / capacity constants — single source of truth. */
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Maximum distinct line items in one cart (hard cap, enforced at every
|
|
4
|
+
* add-to-cart entry point).
|
|
5
|
+
*
|
|
6
|
+
* Re-exported, not re-declared. This value existed as three independent `= 50`
|
|
7
|
+
* literals — here, in `constants/limits.ts`, and in
|
|
8
|
+
* `_internal/shared/features/cart/config.ts` — with nothing tying them
|
|
9
|
+
* together, so raising the cap in one place would have left two silently
|
|
10
|
+
* disagreeing. `constants/limits.ts` is a dependency-free leaf module, so this
|
|
11
|
+
* costs no import chain.
|
|
12
|
+
*/
|
|
13
|
+
export { CART_MAX_ITEMS } from "../../../../constants/limits";
|
|
4
14
|
/**
|
|
5
15
|
* Maximum number of order documents that can be created in one checkout
|
|
6
16
|
* transaction. If the cart produces more groups than this the checkout is
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
/** Checkout window / capacity constants — single source of truth. */
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Maximum distinct line items in one cart (hard cap, enforced at every
|
|
4
|
+
* add-to-cart entry point).
|
|
5
|
+
*
|
|
6
|
+
* Re-exported, not re-declared. This value existed as three independent `= 50`
|
|
7
|
+
* literals — here, in `constants/limits.ts`, and in
|
|
8
|
+
* `_internal/shared/features/cart/config.ts` — with nothing tying them
|
|
9
|
+
* together, so raising the cap in one place would have left two silently
|
|
10
|
+
* disagreeing. `constants/limits.ts` is a dependency-free leaf module, so this
|
|
11
|
+
* costs no import chain.
|
|
12
|
+
*/
|
|
13
|
+
export { CART_MAX_ITEMS } from "../../../../constants/limits";
|
|
4
14
|
/**
|
|
5
15
|
* Maximum number of order documents that can be created in one checkout
|
|
6
16
|
* transaction. If the cart produces more groups than this the checkout is
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
/** Re-exported from the single declaration — see `constants/limits.ts`. */
|
|
2
|
+
export { CART_MAX_ITEMS } from "../../../../constants/limits";
|
|
2
3
|
export declare const CART_SESSION_TTL_DAYS = 30;
|
|
3
4
|
export declare const CART_GUEST_STORAGE_KEY = "appkit:cart";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
/** Re-exported from the single declaration — see `constants/limits.ts`. */
|
|
2
|
+
export { CART_MAX_ITEMS } from "../../../../constants/limits";
|
|
2
3
|
export const CART_SESSION_TTL_DAYS = 30;
|
|
3
4
|
export const CART_GUEST_STORAGE_KEY = "appkit:cart";
|
|
@@ -22,9 +22,9 @@ export declare const addToCartSchema: z.ZodObject<{
|
|
|
22
22
|
productId: string;
|
|
23
23
|
productTitle: string;
|
|
24
24
|
productImage: string;
|
|
25
|
+
isOffer?: boolean | undefined;
|
|
25
26
|
offerId?: string | undefined;
|
|
26
27
|
lockedPrice?: number | undefined;
|
|
27
|
-
isOffer?: boolean | undefined;
|
|
28
28
|
}, {
|
|
29
29
|
storeId: string;
|
|
30
30
|
price: number;
|
|
@@ -35,9 +35,9 @@ export declare const addToCartSchema: z.ZodObject<{
|
|
|
35
35
|
storeName?: string | undefined;
|
|
36
36
|
quantity?: number | undefined;
|
|
37
37
|
productImage?: string | undefined;
|
|
38
|
+
isOffer?: boolean | undefined;
|
|
38
39
|
offerId?: string | undefined;
|
|
39
40
|
lockedPrice?: number | undefined;
|
|
40
|
-
isOffer?: boolean | undefined;
|
|
41
41
|
}>;
|
|
42
42
|
export declare const updateCartItemSchema: z.ZodObject<{
|
|
43
43
|
productId: z.ZodString;
|
|
@@ -80,9 +80,9 @@ export declare const mergeGuestCartSchema: z.ZodObject<{
|
|
|
80
80
|
productId: string;
|
|
81
81
|
productTitle: string;
|
|
82
82
|
productImage: string;
|
|
83
|
+
isOffer?: boolean | undefined;
|
|
83
84
|
offerId?: string | undefined;
|
|
84
85
|
lockedPrice?: number | undefined;
|
|
85
|
-
isOffer?: boolean | undefined;
|
|
86
86
|
}, {
|
|
87
87
|
storeId: string;
|
|
88
88
|
price: number;
|
|
@@ -93,9 +93,9 @@ export declare const mergeGuestCartSchema: z.ZodObject<{
|
|
|
93
93
|
storeName?: string | undefined;
|
|
94
94
|
quantity?: number | undefined;
|
|
95
95
|
productImage?: string | undefined;
|
|
96
|
+
isOffer?: boolean | undefined;
|
|
96
97
|
offerId?: string | undefined;
|
|
97
98
|
lockedPrice?: number | undefined;
|
|
98
|
-
isOffer?: boolean | undefined;
|
|
99
99
|
}>, "many">;
|
|
100
100
|
}, "strip", z.ZodTypeAny, {
|
|
101
101
|
guestItems: {
|
|
@@ -108,9 +108,9 @@ export declare const mergeGuestCartSchema: z.ZodObject<{
|
|
|
108
108
|
productId: string;
|
|
109
109
|
productTitle: string;
|
|
110
110
|
productImage: string;
|
|
111
|
+
isOffer?: boolean | undefined;
|
|
111
112
|
offerId?: string | undefined;
|
|
112
113
|
lockedPrice?: number | undefined;
|
|
113
|
-
isOffer?: boolean | undefined;
|
|
114
114
|
}[];
|
|
115
115
|
}, {
|
|
116
116
|
guestItems: {
|
|
@@ -123,9 +123,9 @@ export declare const mergeGuestCartSchema: z.ZodObject<{
|
|
|
123
123
|
storeName?: string | undefined;
|
|
124
124
|
quantity?: number | undefined;
|
|
125
125
|
productImage?: string | undefined;
|
|
126
|
+
isOffer?: boolean | undefined;
|
|
126
127
|
offerId?: string | undefined;
|
|
127
128
|
lockedPrice?: number | undefined;
|
|
128
|
-
isOffer?: boolean | undefined;
|
|
129
129
|
}[];
|
|
130
130
|
}>;
|
|
131
131
|
/**
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where does a notification LAND?
|
|
3
|
+
*
|
|
4
|
+
* ## The problem this solves
|
|
5
|
+
*
|
|
6
|
+
* `actionUrl` was set by **5 of 40** `sendNotification()` call sites, so the
|
|
7
|
+
* other 35 arrived in the bell as text with nothing to click. Every writer
|
|
8
|
+
* that did set one hand-wrote the path, which is the same drift the ROUTES
|
|
9
|
+
* map exists to prevent.
|
|
10
|
+
*
|
|
11
|
+
* Worse, the destination is **role-dependent**: an offer notification sent to
|
|
12
|
+
* the buyer belongs at `/user/offers`, the same event sent to the seller at
|
|
13
|
+
* `/store/offers`, and an admin escalation at `/admin/offers`. A per-writer
|
|
14
|
+
* literal cannot express that, so writers that guessed a single path sent
|
|
15
|
+
* half their recipients somewhere they have no permission to be.
|
|
16
|
+
*
|
|
17
|
+
* ## Honest fallbacks, not invented pages
|
|
18
|
+
*
|
|
19
|
+
* `bid`, `offer` and `review` still have **no per-record page in any role** —
|
|
20
|
+
* W8 fixed those dead-end listings with a detail MODAL, not a route. So this
|
|
21
|
+
* resolver points them at the list the record lives in. That is a real,
|
|
22
|
+
* permitted, useful destination; fabricating `/user/bids/{id}` would produce
|
|
23
|
+
* a 404 and `audit-nav-page-wiring` would (correctly) fail on the ROUTES key.
|
|
24
|
+
*
|
|
25
|
+
* When those pages are built, change the entry here and all 40 call sites
|
|
26
|
+
* follow. That is the whole point of the indirection.
|
|
27
|
+
*/
|
|
28
|
+
import type { NotificationRelatedType } from "../../../../features/admin/schemas/firestore";
|
|
29
|
+
/**
|
|
30
|
+
* Which portal the recipient reads this in.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately NOT the `role` string off the session: a seller is also a
|
|
33
|
+
* buyer, and which portal a notification belongs to is a property of the
|
|
34
|
+
* EVENT, not of the person. The order-shipped notification goes to the buyer
|
|
35
|
+
* in `/user`, and the same shipment's payout notification goes to the same
|
|
36
|
+
* human in `/store`.
|
|
37
|
+
*/
|
|
38
|
+
export type NotificationAudience = "buyer" | "seller" | "admin";
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the page a notification should open.
|
|
41
|
+
*
|
|
42
|
+
* Returns `undefined` when there is genuinely nowhere to go — a `promotion`
|
|
43
|
+
* or a `welcome` has no record behind it — so the caller renders a plain,
|
|
44
|
+
* non-clickable row rather than a link to the dashboard the user is already
|
|
45
|
+
* looking at.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveNotificationActionUrl(relatedType: NotificationRelatedType | undefined, relatedId: string | undefined, audience: NotificationAudience): string | undefined;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where does a notification LAND?
|
|
3
|
+
*
|
|
4
|
+
* ## The problem this solves
|
|
5
|
+
*
|
|
6
|
+
* `actionUrl` was set by **5 of 40** `sendNotification()` call sites, so the
|
|
7
|
+
* other 35 arrived in the bell as text with nothing to click. Every writer
|
|
8
|
+
* that did set one hand-wrote the path, which is the same drift the ROUTES
|
|
9
|
+
* map exists to prevent.
|
|
10
|
+
*
|
|
11
|
+
* Worse, the destination is **role-dependent**: an offer notification sent to
|
|
12
|
+
* the buyer belongs at `/user/offers`, the same event sent to the seller at
|
|
13
|
+
* `/store/offers`, and an admin escalation at `/admin/offers`. A per-writer
|
|
14
|
+
* literal cannot express that, so writers that guessed a single path sent
|
|
15
|
+
* half their recipients somewhere they have no permission to be.
|
|
16
|
+
*
|
|
17
|
+
* ## Honest fallbacks, not invented pages
|
|
18
|
+
*
|
|
19
|
+
* `bid`, `offer` and `review` still have **no per-record page in any role** —
|
|
20
|
+
* W8 fixed those dead-end listings with a detail MODAL, not a route. So this
|
|
21
|
+
* resolver points them at the list the record lives in. That is a real,
|
|
22
|
+
* permitted, useful destination; fabricating `/user/bids/{id}` would produce
|
|
23
|
+
* a 404 and `audit-nav-page-wiring` would (correctly) fail on the ROUTES key.
|
|
24
|
+
*
|
|
25
|
+
* When those pages are built, change the entry here and all 40 call sites
|
|
26
|
+
* follow. That is the whole point of the indirection.
|
|
27
|
+
*/
|
|
28
|
+
import { ROUTES } from "../../../../next/routing/route-map";
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the page a notification should open.
|
|
31
|
+
*
|
|
32
|
+
* Returns `undefined` when there is genuinely nowhere to go — a `promotion`
|
|
33
|
+
* or a `welcome` has no record behind it — so the caller renders a plain,
|
|
34
|
+
* non-clickable row rather than a link to the dashboard the user is already
|
|
35
|
+
* looking at.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveNotificationActionUrl(relatedType, relatedId, audience) {
|
|
38
|
+
if (!relatedType || !relatedId)
|
|
39
|
+
return undefined;
|
|
40
|
+
switch (relatedType) {
|
|
41
|
+
case "order":
|
|
42
|
+
return audience === "admin"
|
|
43
|
+
? String(ROUTES.ADMIN.ORDER_DETAIL(relatedId))
|
|
44
|
+
: audience === "seller"
|
|
45
|
+
? String(ROUTES.STORE.ORDER_DETAIL(relatedId))
|
|
46
|
+
: String(ROUTES.USER.ORDER_DETAIL(relatedId));
|
|
47
|
+
case "product":
|
|
48
|
+
// The public listing page — the one destination that is the same for
|
|
49
|
+
// everyone, because a product page is public.
|
|
50
|
+
return String(ROUTES.PUBLIC.PRODUCT_DETAIL(relatedId));
|
|
51
|
+
// ── List-level, because no per-record page exists in ANY role ─────────
|
|
52
|
+
// W8 gave these listings a detail modal rather than a route. Linking the
|
|
53
|
+
// list is honest; a fabricated `/…/{id}` would 404.
|
|
54
|
+
case "bid":
|
|
55
|
+
return audience === "admin"
|
|
56
|
+
? String(ROUTES.ADMIN.BIDS)
|
|
57
|
+
: audience === "seller"
|
|
58
|
+
? String(ROUTES.STORE.BIDS)
|
|
59
|
+
: String(ROUTES.USER.BIDS);
|
|
60
|
+
case "offer":
|
|
61
|
+
return audience === "admin"
|
|
62
|
+
? String(ROUTES.ADMIN.OFFERS)
|
|
63
|
+
: audience === "seller"
|
|
64
|
+
? String(ROUTES.STORE.OFFERS)
|
|
65
|
+
: String(ROUTES.USER.OFFERS);
|
|
66
|
+
case "review":
|
|
67
|
+
// The public review page exists; the admin/seller sides are modals.
|
|
68
|
+
return String(ROUTES.PUBLIC.REVIEW_DETAIL(relatedId));
|
|
69
|
+
case "blog":
|
|
70
|
+
return String(ROUTES.BLOG.ARTICLE(relatedId));
|
|
71
|
+
case "user":
|
|
72
|
+
// Only an admin has a per-user page. A user notified ABOUT themselves
|
|
73
|
+
// (a ban, a role change) belongs on their own account hub, not on a
|
|
74
|
+
// page they cannot open.
|
|
75
|
+
return audience === "admin"
|
|
76
|
+
? String(ROUTES.ADMIN.USER_DETAIL(relatedId))
|
|
77
|
+
: String(ROUTES.USER.PROFILE);
|
|
78
|
+
case "support_ticket":
|
|
79
|
+
return audience === "admin"
|
|
80
|
+
? String(ROUTES.ADMIN.SUPPORT_TICKET_BY_ID(relatedId))
|
|
81
|
+
: String(ROUTES.USER.SUPPORT_TICKET(relatedId));
|
|
82
|
+
case "scammer":
|
|
83
|
+
// Admin-only surface — the public scam registry is browsed, not
|
|
84
|
+
// notified about.
|
|
85
|
+
return audience === "admin" ? String(ROUTES.ADMIN.SCAMMER_BY_ID(relatedId)) : undefined;
|
|
86
|
+
case "catalogueItem":
|
|
87
|
+
// The owner edits it; an admin reviews it from the approvals queue,
|
|
88
|
+
// which is a list because approval is a queue-shaped job.
|
|
89
|
+
return audience === "admin"
|
|
90
|
+
? String(ROUTES.ADMIN.CATALOGUE_APPROVALS)
|
|
91
|
+
: String(ROUTES.USER.CATALOGUE_EDIT(relatedId));
|
|
92
|
+
case "payout":
|
|
93
|
+
return audience === "admin"
|
|
94
|
+
? String(ROUTES.ADMIN.PAYOUT_DETAIL(relatedId))
|
|
95
|
+
: String(ROUTES.STORE.PAYOUT_DETAIL(relatedId));
|
|
96
|
+
case "store":
|
|
97
|
+
return audience === "admin"
|
|
98
|
+
? String(ROUTES.ADMIN.STORE_DETAIL(relatedId))
|
|
99
|
+
: String(ROUTES.STORE.DASHBOARD);
|
|
100
|
+
default: {
|
|
101
|
+
/*
|
|
102
|
+
* Exhaustiveness. A new `relatedType` cannot compile without an answer
|
|
103
|
+
* to "where does this land", which is precisely the check that was
|
|
104
|
+
* missing when `catalogueItem` was smuggled in behind an `as never`.
|
|
105
|
+
*/
|
|
106
|
+
const _exhaustive = relatedType;
|
|
107
|
+
void _exhaustive;
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|