@mohasinac/appkit 4.11.1 → 4.11.3
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/checkout/actions.d.ts +4 -0
- package/dist/_internal/server/features/checkout/actions.js +138 -27
- package/dist/_internal/server/features/checkout/data.d.ts +10 -0
- package/dist/_internal/server/features/checkout/locked-lines.d.ts +30 -0
- package/dist/_internal/server/features/checkout/locked-lines.js +116 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/server/features/products/data.d.ts +12 -3
- package/dist/_internal/server/features/products/data.js +55 -4
- package/dist/_internal/server/features/products/index.d.ts +2 -1
- package/dist/_internal/server/features/products/index.js +2 -1
- package/dist/_internal/server/features/products/list-public.d.ts +130 -0
- package/dist/_internal/server/features/products/list-public.js +375 -0
- package/dist/_internal/server/features/reviews/data.d.ts +1 -1
- package/dist/_internal/server/features/reviews/data.js +3 -2
- package/dist/_internal/server/features/tester/visibility.js +3 -3
- package/dist/_internal/server/jobs/core/auctionSettlement.js +89 -16
- package/dist/_internal/server/jobs/core/offerExpiry.js +116 -4
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +1 -1
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +4 -1
- package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +2 -0
- package/dist/_internal/server/jobs/handlers/_helpers.js +4 -1
- package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -0
- package/dist/_internal/server/jobs/handlers/messages.js +5 -0
- package/dist/_internal/shared/checkout/lanes.d.ts +41 -0
- package/dist/_internal/shared/checkout/lanes.js +106 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +19 -0
- package/dist/_internal/shared/checkout/order-math.js +30 -6
- package/dist/_internal/shared/features/promotions/schema.d.ts +0 -16
- package/dist/_internal/shared/features/promotions/schema.js +0 -2
- package/dist/_internal/shared/features/reviews/config.d.ts +8 -1
- package/dist/_internal/shared/features/reviews/config.js +8 -1
- package/dist/_internal/shared/listing-types/_registry.d.ts +88 -1
- package/dist/_internal/shared/listing-types/_registry.js +63 -1
- package/dist/_internal/shared/listing-types/art/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/art/config.js +12 -0
- package/dist/_internal/shared/listing-types/auction/config.d.ts +53 -0
- package/dist/_internal/shared/listing-types/auction/config.js +17 -0
- package/dist/_internal/shared/listing-types/classified/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/classified/config.js +14 -0
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/digital-code/config.js +12 -0
- package/dist/_internal/shared/listing-types/feature-flags.d.ts +1 -0
- package/dist/_internal/shared/listing-types/feature-flags.js +24 -10
- package/dist/_internal/shared/listing-types/live/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/live/config.js +14 -0
- package/dist/_internal/shared/listing-types/pre-order/config.d.ts +110 -0
- package/dist/_internal/shared/listing-types/pre-order/config.js +14 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +44 -0
- package/dist/_internal/shared/listing-types/prize-draw/config.js +10 -0
- package/dist/_internal/shared/listing-types/standard/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/standard/config.js +9 -0
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +59 -0
- package/dist/_internal/shared/listing-types/stickers/config.js +12 -0
- package/dist/client.d.ts +7 -2
- package/dist/client.js +8 -2
- package/dist/constants/field-names.d.ts +12 -0
- package/dist/constants/field-names.js +12 -0
- package/dist/constants/table-keys.d.ts +2 -0
- package/dist/constants/table-keys.js +2 -0
- package/dist/features/account/components/NotificationBell.js +1 -0
- package/dist/features/account/components/UserOffersPanel.js +8 -1
- package/dist/features/account/components/UserOrdersView.js +27 -5
- package/dist/features/admin/actions/notification-actions.js +1 -1
- package/dist/features/admin/components/AdminAddressesView.js +3 -1
- package/dist/features/admin/components/AdminCouponEditorView.js +1 -4
- package/dist/features/admin/components/AdminPaymentMethodsView.js +3 -1
- package/dist/features/admin/components/AdminProductsView.js +34 -18
- package/dist/features/admin/constants/filter-tabs.d.ts +26 -9
- package/dist/features/admin/constants/filter-tabs.js +22 -9
- package/dist/features/admin/schemas/firestore.d.ts +2 -1
- package/dist/features/admin/schemas/firestore.js +1 -0
- package/dist/features/auctions/actions/bid-actions.d.ts +11 -1
- package/dist/features/auctions/actions/bid-actions.js +29 -15
- package/dist/features/auctions/components/AuctionBidsTable.js +6 -2
- package/dist/features/auctions/components/AuctionBottomActions.js +1 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +13 -11
- package/dist/features/auctions/components/AuctionsListView.js +12 -53
- package/dist/features/auctions/components/PlaceBidFormClient.js +10 -1
- package/dist/features/auctions/repository/bid.repository.d.ts +15 -0
- package/dist/features/auctions/repository/bid.repository.js +19 -0
- package/dist/features/auctions/schemas/firestore.d.ts +11 -1
- package/dist/features/auctions/schemas/firestore.js +1 -0
- package/dist/features/cart/actions/cart-actions.d.ts +11 -0
- package/dist/features/cart/actions/cart-actions.js +24 -0
- package/dist/features/cart/repository/cart.repository.d.ts +24 -1
- package/dist/features/cart/repository/cart.repository.js +71 -6
- package/dist/features/cart/schemas/firestore.d.ts +23 -4
- package/dist/features/categories/components/BrandDetailTabs.js +31 -11
- package/dist/features/categories/components/CategoryDetailTabs.js +27 -7
- package/dist/features/categories/components/CategoryProductsListing.d.ts +9 -1
- package/dist/features/categories/components/CategoryProductsListing.js +5 -2
- package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -1
- package/dist/features/contact/email.js +56 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +17 -16
- package/dist/features/homepage/components/FeaturedResultsSection.js +1 -1
- package/dist/features/homepage/components/SectionCarousel.js +1 -1
- package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
- package/dist/features/layout/AppLayoutShell.js +9 -2
- package/dist/features/layout/BottomActions.js +26 -3
- package/dist/features/layout/BottomActionsContext.d.ts +14 -0
- package/dist/features/layout/BottomActionsContext.js +13 -1
- package/dist/features/layout/hooks/useBottomActions.d.ts +6 -1
- package/dist/features/layout/hooks/useBottomActions.js +4 -1
- package/dist/features/layout/index.d.ts +1 -1
- package/dist/features/live/components/LiveItemDetailPageView.js +7 -6
- package/dist/features/orders/repository/orders.repository.d.ts +4 -15
- package/dist/features/orders/repository/orders.repository.js +4 -27
- package/dist/features/orders/types/index.d.ts +16 -0
- package/dist/features/orders/utils/order-splitter.js +14 -2
- package/dist/features/pre-orders/components/PreOrderBottomActions.d.ts +1 -1
- package/dist/features/pre-orders/components/PreOrderBottomActions.js +3 -19
- package/dist/features/pre-orders/components/PreOrdersListView.js +11 -39
- package/dist/features/products/components/ArtStickersListView.js +10 -49
- package/dist/features/products/components/AuctionsIndexListing.js +1 -2
- package/dist/features/products/components/ListingBottomActions.d.ts +28 -0
- package/dist/features/products/components/ListingBottomActions.js +25 -0
- package/dist/features/products/components/PrizeDrawBottomActions.d.ts +1 -1
- package/dist/features/products/components/PrizeDrawBottomActions.js +3 -21
- package/dist/features/products/components/ProductDetailActions.js +4 -0
- package/dist/features/products/components/ProductDetailPageView.js +7 -7
- package/dist/features/products/components/ProductsIndexPageView.js +9 -62
- package/dist/features/products/constants/listing-tabs.d.ts +43 -198
- package/dist/features/products/constants/listing-tabs.js +71 -58
- package/dist/features/products/constants/sieve.d.ts +104 -1
- package/dist/features/products/constants/sieve.js +27 -7
- package/dist/features/products/repository/products.repository.d.ts +45 -1
- package/dist/features/products/repository/products.repository.js +67 -8
- package/dist/features/products/utils/listing-badge-variant.d.ts +20 -1
- package/dist/features/products/utils/listing-badge-variant.js +26 -1
- package/dist/features/products/utils/listing-type.d.ts +24 -0
- package/dist/features/products/utils/listing-type.js +48 -0
- package/dist/features/promotions/actions/coupon-actions.d.ts +7 -0
- package/dist/features/promotions/actions/coupon-actions.js +12 -1
- package/dist/features/promotions/actions/coupon-stacking.d.ts +24 -0
- package/dist/features/promotions/actions/coupon-stacking.js +21 -0
- package/dist/features/promotions/actions/index.d.ts +1 -0
- package/dist/features/promotions/actions/index.js +1 -0
- package/dist/features/promotions/actions/seller-coupon-actions.d.ts +0 -1
- package/dist/features/promotions/actions/seller-coupon-actions.js +0 -1
- package/dist/features/promotions/components/CouponHelpDetails.d.ts +20 -0
- package/dist/features/promotions/components/CouponHelpDetails.js +17 -0
- package/dist/features/promotions/components/CouponsIndexListing.js +2 -1
- package/dist/features/promotions/components/index.d.ts +2 -0
- package/dist/features/promotions/components/index.js +1 -0
- package/dist/features/promotions/constants/coupon-help.d.ts +27 -0
- package/dist/features/promotions/constants/coupon-help.js +42 -0
- package/dist/features/promotions/repository/coupons.repository.d.ts +10 -1
- package/dist/features/promotions/repository/coupons.repository.js +22 -8
- package/dist/features/promotions/schemas/firestore.d.ts +0 -1
- package/dist/features/promotions/schemas/firestore.js +1 -1
- package/dist/features/promotions/schemas/index.d.ts +0 -20
- package/dist/features/promotions/schemas/index.js +0 -1
- package/dist/features/reviews/actions/review-actions.js +5 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +2 -1
- package/dist/features/reviews/components/ReviewFilters.d.ts +7 -1
- package/dist/features/reviews/components/ReviewFilters.js +4 -3
- package/dist/features/reviews/components/ReviewsIndexListing.d.ts +6 -0
- package/dist/features/reviews/components/ReviewsIndexListing.js +9 -109
- package/dist/features/reviews/components/ReviewsListingPanel.d.ts +41 -0
- package/dist/features/reviews/components/ReviewsListingPanel.js +226 -0
- package/dist/features/reviews/components/index.d.ts +2 -0
- package/dist/features/reviews/components/index.js +1 -0
- package/dist/features/reviews/hooks/useReviews.d.ts +8 -0
- package/dist/features/reviews/hooks/useReviews.js +14 -6
- package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
- package/dist/features/reviews/repository/reviews.repository.js +77 -41
- package/dist/features/reviews/types/index.d.ts +8 -1
- package/dist/features/seller/actions/offer-actions.d.ts +6 -0
- package/dist/features/seller/actions/offer-actions.js +8 -9
- package/dist/features/seller/components/SellerOffersView.d.ts +6 -1
- package/dist/features/seller/components/SellerOffersView.js +59 -8
- package/dist/features/seller/components/SellerProductsCards.js +2 -2
- package/dist/features/seller/components/SellerProductsView.js +21 -18
- package/dist/features/seller/repository/offer.repository.d.ts +18 -2
- package/dist/features/seller/repository/offer.repository.js +38 -2
- package/dist/features/seller/schemas/firestore.d.ts +7 -2
- package/dist/features/seller/schemas/firestore.js +3 -0
- package/dist/features/seller/schemas/offer-forms.d.ts +12 -0
- package/dist/features/seller/schemas/offer-forms.js +28 -0
- package/dist/features/stores/actions/store-query-actions.js +1 -1
- package/dist/features/stores/components/StoreAuctionsListing.js +5 -11
- package/dist/features/stores/components/StorePreOrdersListing.js +6 -11
- package/dist/features/stores/components/StoreReviewsListing.d.ts +4 -0
- package/dist/features/stores/components/StoreReviewsListing.js +7 -108
- package/dist/features/stores/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/coupons-tester-seed-data.js +3 -3
- package/dist/features/tester/seed-data/index.d.ts +1 -0
- package/dist/features/tester/seed-data/index.js +1 -0
- package/dist/features/tester/seed-data/offers-tester-seed-data.d.ts +2 -0
- package/dist/features/tester/seed-data/offers-tester-seed-data.js +61 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +337 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.js +15 -3
- package/dist/react/hooks/useListingTypeFlags.d.ts +12 -19
- package/dist/react/hooks/useListingTypeFlags.js +4 -9
- package/dist/schemas/registry.d.ts +0 -12
- package/dist/seed/claimed-coupons-seed-data.js +3 -3
- package/dist/seed/coupons-seed-data.js +29 -10
- package/dist/seed/faq-seed-data.js +2 -2
- package/dist/seed/manifest.js +2 -2
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +3 -0
- package/dist/styles.css +44 -39
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/FilterChipGroup.d.ts +21 -3
- package/dist/ui/components/FilterChipGroup.js +33 -4
- package/dist/ui/components/HorizontalScroller.d.ts +1 -2
- package/dist/ui/components/HorizontalScroller.js +9 -5
- package/dist/ui/components/HorizontalScroller.style.css +43 -38
- package/package.json +1 -1
|
@@ -44,6 +44,10 @@ export declare function resolveShippingCost(storeId: string | undefined): Promis
|
|
|
44
44
|
storeEmiEnabled: boolean;
|
|
45
45
|
storeState: string | undefined;
|
|
46
46
|
}>;
|
|
47
|
+
export interface DroppedCoupon {
|
|
48
|
+
code: string;
|
|
49
|
+
reason: string;
|
|
50
|
+
}
|
|
47
51
|
/**
|
|
48
52
|
* Place order(s) from the user's cart in a single Firestore transaction.
|
|
49
53
|
*
|
|
@@ -13,10 +13,13 @@ import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
|
13
13
|
import { serverLogger } from "../../../../monitoring";
|
|
14
14
|
import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
|
|
15
15
|
import { calculateGst } from "../../../shared/fees/calculator";
|
|
16
|
-
import { computePreOrderDepositAmount } from "../../../shared/checkout/order-math";
|
|
16
|
+
import { computePreOrderDepositAmount, unitPriceFor } from "../../../shared/checkout/order-math";
|
|
17
17
|
import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
|
|
18
18
|
import { sendOrderConfirmationEmail } from "../../../../features/contact/server";
|
|
19
19
|
import { splitCartIntoOrderGroups } from "../../../../features/orders/index";
|
|
20
|
+
import { resolveCouponCategorySlugs } from "../../../../features/promotions/actions/coupon-actions";
|
|
21
|
+
import { assertCheckoutLane, assertLockedLinesStillValid, finalizeLockedLines, } from "./locked-lines";
|
|
22
|
+
import { activeLane, laneOf } from "../../../shared/checkout/lanes";
|
|
20
23
|
import { computeCodHandlingFee, computeWhatsAppNotifyFee, computeGiftWrapFee, computeShipmentProtectionFee, } from "../../../shared/fees/calculator";
|
|
21
24
|
import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase";
|
|
22
25
|
import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore";
|
|
@@ -236,6 +239,90 @@ function buildStockUpdatePayload(product, qtyDelta) {
|
|
|
236
239
|
...rule.stockDecrementExtras(product, qtyDelta),
|
|
237
240
|
};
|
|
238
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* Re-checks every coupon persisted on the cart against the items actually
|
|
244
|
+
* being ordered, immediately before pricing.
|
|
245
|
+
*
|
|
246
|
+
* A cart can sit for days, so the `discountAmount` frozen onto
|
|
247
|
+
* `cart.appliedCoupons` at apply-time may since have become wrong or
|
|
248
|
+
* unearned — the coupon may have expired, hit its total/per-user limit, been
|
|
249
|
+
* deactivated, or had its eligible items removed from the cart. Trusting the
|
|
250
|
+
* stored amount let all of those redeem anyway.
|
|
251
|
+
*
|
|
252
|
+
* Invalid coupons are DROPPED (and reported back to the caller) rather than
|
|
253
|
+
* failing the whole checkout: the buyer is on the payment step and cannot fix
|
|
254
|
+
* a lapsed coupon from there, so killing an otherwise-valid order is hostile.
|
|
255
|
+
*
|
|
256
|
+
* One parallel round of reads for at most a handful of coupons (the stacking
|
|
257
|
+
* rule caps it at one per store plus one platform-wide), so this stays inside
|
|
258
|
+
* the Vercel Hobby round-trip budget.
|
|
259
|
+
*/
|
|
260
|
+
async function revalidateAppliedCoupons(appliedCoupons, available, uid) {
|
|
261
|
+
if (appliedCoupons.length === 0)
|
|
262
|
+
return { coupons: [], dropped: [] };
|
|
263
|
+
const cartItems = available.map(({ item, product }) => ({
|
|
264
|
+
productId: item.productId,
|
|
265
|
+
storeId: item.storeId,
|
|
266
|
+
price: unitPriceFor(item, product),
|
|
267
|
+
quantity: item.quantity,
|
|
268
|
+
listingType: (item.listingType ?? "standard"),
|
|
269
|
+
}));
|
|
270
|
+
const results = await Promise.all(appliedCoupons.map(async (coupon) => {
|
|
271
|
+
try {
|
|
272
|
+
const check = await couponsRepository.validateCouponForCart(coupon.code, uid, cartItems, { resolveCategorySlugs: resolveCouponCategorySlugs });
|
|
273
|
+
if (!check.valid) {
|
|
274
|
+
return {
|
|
275
|
+
dropped: {
|
|
276
|
+
code: coupon.code,
|
|
277
|
+
reason: check.message ?? "This coupon is no longer valid",
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
// Re-derive the amount and the eligible lines from the CURRENT cart —
|
|
282
|
+
// the stored values were computed against whatever was in the cart at
|
|
283
|
+
// apply-time.
|
|
284
|
+
const applicableItemIds = check.eligibleProductIds
|
|
285
|
+
? available
|
|
286
|
+
.filter(({ item }) => check.eligibleProductIds.includes(item.productId))
|
|
287
|
+
.map(({ item }) => item.itemId)
|
|
288
|
+
: undefined;
|
|
289
|
+
return {
|
|
290
|
+
coupon: {
|
|
291
|
+
...coupon,
|
|
292
|
+
discountAmount: check.discountAmount ?? 0,
|
|
293
|
+
scope: check.scope ?? coupon.scope,
|
|
294
|
+
storeId: check.storeId ?? coupon.storeId,
|
|
295
|
+
applicableItemIds,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
void normalizeError(err);
|
|
301
|
+
// A lookup failure must not silently grant the discount.
|
|
302
|
+
return {
|
|
303
|
+
dropped: {
|
|
304
|
+
code: coupon.code,
|
|
305
|
+
reason: "This coupon could not be verified",
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}));
|
|
310
|
+
const coupons = [];
|
|
311
|
+
const dropped = [];
|
|
312
|
+
for (const r of results) {
|
|
313
|
+
if (r.coupon)
|
|
314
|
+
coupons.push(r.coupon);
|
|
315
|
+
else if (r.dropped)
|
|
316
|
+
dropped.push(r.dropped);
|
|
317
|
+
}
|
|
318
|
+
if (dropped.length > 0) {
|
|
319
|
+
serverLogger.info("Dropped invalid coupons at checkout", {
|
|
320
|
+
uid,
|
|
321
|
+
codes: dropped.map((d) => d.code).join(","),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return { coupons, dropped };
|
|
325
|
+
}
|
|
239
326
|
async function flushCouponUsageAccumulator(accumulator, uid) {
|
|
240
327
|
if (accumulator.size === 0)
|
|
241
328
|
return;
|
|
@@ -272,14 +359,6 @@ function dispatchOrderConfirmationEmails(emailsToSend) {
|
|
|
272
359
|
return;
|
|
273
360
|
Promise.all(emailsToSend.map((e) => sendOrderConfirmationEmail(e))).catch((err) => serverLogger.error("Order confirmation email error:", err));
|
|
274
361
|
}
|
|
275
|
-
// SB-UNI-5 2026-05-13 — bundle cart-lines use item.price (locked bundle price
|
|
276
|
-
// at add-time); regular lines use product.price (current Firestore). Prevents
|
|
277
|
-
// stale cart-cached prices from being charged on COD/UPI orders.
|
|
278
|
-
function unitPriceFor(item, product) {
|
|
279
|
-
return item.bundleCategorySlug && item.bundleProductIds?.length
|
|
280
|
-
? item.price
|
|
281
|
-
: product.price;
|
|
282
|
-
}
|
|
283
362
|
/**
|
|
284
363
|
* Places one order for one seller-group of the checkout cart — fee math
|
|
285
364
|
* (COD deposit/handling, EMI schedule), coupon apportionment, order-doc
|
|
@@ -461,6 +540,11 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
461
540
|
...extraOrderFields,
|
|
462
541
|
});
|
|
463
542
|
orderIds.push(order.id);
|
|
543
|
+
// Close the loop on any locked line this order settled: flip the offer to
|
|
544
|
+
// "paid" (its terminal status had NO server-side writer before 2026-08-21,
|
|
545
|
+
// so an accepted offer could be re-added and re-ordered) and back-link the
|
|
546
|
+
// winning bid to the order it became.
|
|
547
|
+
await finalizeLockedLines(group.map(({ item }) => item), order.id);
|
|
464
548
|
// SB-UNI-N — claim a digital code for digital-code orders (fire-and-forget,
|
|
465
549
|
// order is already persisted so a claim failure is logged, not thrown).
|
|
466
550
|
if ((group[0]?.product?.listingType ?? "standard") === "digital-code") {
|
|
@@ -541,6 +625,9 @@ export async function createCheckoutOrderAction(input) {
|
|
|
541
625
|
if (cartItems.length === 0) {
|
|
542
626
|
throw new ValidationError(ERROR_MESSAGES.CHECKOUT.CART_EMPTY);
|
|
543
627
|
}
|
|
628
|
+
// Lane priority + locked-line revalidation, before any money math runs.
|
|
629
|
+
assertCheckoutLane(cart.items, cartItems);
|
|
630
|
+
await assertLockedLinesStillValid(cartItems, uid);
|
|
544
631
|
// Tier PP — OTP gate for high-value checkouts. Evaluated against the
|
|
545
632
|
// WHOLE checkout batch's total (not each resulting per-seller order),
|
|
546
633
|
// otherwise a buyer could dodge it by splitting a big cart across
|
|
@@ -699,7 +786,7 @@ export async function createCheckoutOrderAction(input) {
|
|
|
699
786
|
.catch((logErr) => { void normalizeError(logErr); serverLogger.warn(AUDIT_LOG_FAIL_MSG, { error: logErr instanceof Error ? logErr.message : String(logErr) }); });
|
|
700
787
|
throw new ValidationError(ERROR_MESSAGES.CHECKOUT.INSUFFICIENT_STOCK);
|
|
701
788
|
}
|
|
702
|
-
const appliedCoupons = cart.appliedCoupons ?? [];
|
|
789
|
+
const { coupons: appliedCoupons, dropped: droppedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], available, uid);
|
|
703
790
|
const orderGroups = splitCartIntoOrderGroups(available);
|
|
704
791
|
// Admin-bypass orders are immediately paid and processing; generate a shared
|
|
705
792
|
// transaction reference for all orders in this checkout batch.
|
|
@@ -751,6 +838,7 @@ export async function createCheckoutOrderAction(input) {
|
|
|
751
838
|
total,
|
|
752
839
|
itemCount: orderIds.length,
|
|
753
840
|
...(unavailable.length > 0 ? { unavailableItems: unavailable } : {}),
|
|
841
|
+
...(droppedCoupons.length > 0 ? { droppedCoupons } : {}),
|
|
754
842
|
};
|
|
755
843
|
}
|
|
756
844
|
const EMPTY_PRICING_PREVIEW = {
|
|
@@ -780,7 +868,12 @@ export async function previewCheckoutPricing(input) {
|
|
|
780
868
|
const cart = await unitOfWork.carts.getOrCreate(uid);
|
|
781
869
|
const excludedSet = new Set(excludedProductIds);
|
|
782
870
|
const selectedSet = cart.selectedItemIds?.length ? new Set(cart.selectedItemIds) : null;
|
|
783
|
-
const
|
|
871
|
+
const previewLane = activeLane(cart.items ?? []);
|
|
872
|
+
const cartItems = (cart.items ?? []).filter((item) => !excludedSet.has(item.productId) &&
|
|
873
|
+
(!selectedSet || selectedSet.has(item.itemId)) &&
|
|
874
|
+
// A preview never throws — it just shows the lane the buyer can actually
|
|
875
|
+
// check out, matching what the checkout page's active tab displays.
|
|
876
|
+
(!previewLane || laneOf(item) === previewLane));
|
|
784
877
|
if (cartItems.length === 0)
|
|
785
878
|
return EMPTY_PRICING_PREVIEW;
|
|
786
879
|
// Best-effort product lookup — a preview never throws on a since-unpublished
|
|
@@ -795,11 +888,12 @@ export async function previewCheckoutPricing(input) {
|
|
|
795
888
|
resolvedAddress =
|
|
796
889
|
addressDoc && addressDoc.ownerType === "user" && addressDoc.ownerId === uid ? addressDoc : null;
|
|
797
890
|
}
|
|
798
|
-
const unitPriceFor = (item, product) => item.bundleCategorySlug && item.bundleProductIds?.length ? item.price : (product?.price ?? item.price);
|
|
799
891
|
const checks = cartItems.map((item) => ({ item, product: productById.get(item.productId) ?? null }));
|
|
800
892
|
const orderGroups = splitCartIntoOrderGroups(checks);
|
|
801
893
|
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s + g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
|
|
802
|
-
|
|
894
|
+
// Same re-validation the real order placement runs, so the preview never
|
|
895
|
+
// shows a discount the order won't actually honour.
|
|
896
|
+
const { coupons: appliedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], checks, uid);
|
|
803
897
|
let shippingFee = 0;
|
|
804
898
|
let codHandlingFee = 0;
|
|
805
899
|
let whatsappNotifyFeeTotal = 0;
|
|
@@ -970,13 +1064,7 @@ async function refundDroppedItemsForRazorpayCheckout(input) {
|
|
|
970
1064
|
* from the inline version this replaced.
|
|
971
1065
|
*/
|
|
972
1066
|
async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
973
|
-
const { appliedCoupons, cartSubtotal, platformFeePercent, gstPercent, whatsappNotifyFee, giftWrapFee, giftWrapMessage, shipmentProtectionAddon, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, } = ctx;
|
|
974
|
-
// SB-UNI-5 2026-05-13 — bundle cart-lines use item.price (locked bundle
|
|
975
|
-
// price); regular lines use product.price. The helper keeps the math
|
|
976
|
-
// local to this block.
|
|
977
|
-
const unitPriceFor = (item, product) => item.bundleCategorySlug && item.bundleProductIds?.length
|
|
978
|
-
? item.price
|
|
979
|
-
: product.price;
|
|
1067
|
+
const { appliedCoupons, cartSubtotal, platformFeePercent, gstPercent, whatsappNotifyFee, giftWrapFee, giftWrapMessage, shipmentProtectionAddon, siteSettings, uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, shippingAddress, notes, outOfStockPolicy, unavailablePaid, orderIds, emailsToSend, couponUsageAccumulator, } = ctx;
|
|
980
1068
|
const firstItem = group[0].item;
|
|
981
1069
|
const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
|
|
982
1070
|
// Reuses the same store/seller lookup as the COD/UPI path above instead
|
|
@@ -985,6 +1073,16 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
|
985
1073
|
const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
|
|
986
1074
|
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
|
|
987
1075
|
const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
1076
|
+
// Mirrors createOrderForGroup — without this the online-payment path wrote
|
|
1077
|
+
// the discount onto the order but never incremented usage.currentUsage or
|
|
1078
|
+
// the per-user counter, so limits were unenforceable for Razorpay orders.
|
|
1079
|
+
const groupCouponCodes = new Set();
|
|
1080
|
+
for (const discount of appliedDiscounts) {
|
|
1081
|
+
if (discount.couponId) {
|
|
1082
|
+
accumulateCouponUsage(couponUsageAccumulator, discount.code, discount.couponId, discount.discountAmount);
|
|
1083
|
+
groupCouponCodes.add(discount.code);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
988
1086
|
const rawPlatformFee = roundRupees(groupTotal * (platformFeePercent / 100));
|
|
989
1087
|
const gstOnFee = roundRupees(rawPlatformFee * (gstPercent / 100));
|
|
990
1088
|
const platformFee = rawPlatformFee + gstOnFee;
|
|
@@ -1089,6 +1187,11 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
|
1089
1187
|
...extraOrderFields,
|
|
1090
1188
|
});
|
|
1091
1189
|
orderIds.push(order.id);
|
|
1190
|
+
// Close the loop on any locked line this order settled: flip the offer to
|
|
1191
|
+
// "paid" (its terminal status had NO server-side writer before 2026-08-21,
|
|
1192
|
+
// so an accepted offer could be re-added and re-ordered) and back-link the
|
|
1193
|
+
// winning bid to the order it became.
|
|
1194
|
+
await finalizeLockedLines(group.map(({ item }) => item), order.id);
|
|
1092
1195
|
// SB-UNI-N — claim a digital code for digital-code orders (fire-and-forget,
|
|
1093
1196
|
// order is already persisted so a claim failure is logged, not thrown).
|
|
1094
1197
|
if ((group[0]?.product?.listingType ?? "standard") === "digital-code") {
|
|
@@ -1098,6 +1201,11 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
|
1098
1201
|
productTitle: firstItem.productTitle,
|
|
1099
1202
|
}).catch((e) => serverLogger.error("claimDigitalCode", e));
|
|
1100
1203
|
}
|
|
1204
|
+
for (const code of groupCouponCodes) {
|
|
1205
|
+
const entry = couponUsageAccumulator.get(code);
|
|
1206
|
+
if (entry)
|
|
1207
|
+
entry.orderIds.push(order.id);
|
|
1208
|
+
}
|
|
1101
1209
|
if (userEmail) {
|
|
1102
1210
|
emailsToSend.push({
|
|
1103
1211
|
to: userEmail,
|
|
@@ -1150,6 +1258,11 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1150
1258
|
if (!cart.items || cart.items.length === 0) {
|
|
1151
1259
|
throw new ValidationError(ERROR_MESSAGES.CHECKOUT.CART_EMPTY);
|
|
1152
1260
|
}
|
|
1261
|
+
// Same lane priority + locked-line revalidation the manual/COD path runs.
|
|
1262
|
+
// The Razorpay path settles the whole cart (no selectedItemIds), so the
|
|
1263
|
+
// "selected" set is the cart itself.
|
|
1264
|
+
assertCheckoutLane(cart.items, cart.items);
|
|
1265
|
+
await assertLockedLinesStillValid(cart.items, uid);
|
|
1153
1266
|
const isDigitalCartRazorpay = cartIsDigitalOnly(cart.items);
|
|
1154
1267
|
let shippingAddress;
|
|
1155
1268
|
let resolvedAddressRzp = null;
|
|
@@ -1272,7 +1385,7 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1272
1385
|
throw new ValidationError(ERROR_MESSAGES.CHECKOUT.PAYMENT_FAILED);
|
|
1273
1386
|
}
|
|
1274
1387
|
}
|
|
1275
|
-
const appliedCoupons = cart.appliedCoupons ?? [];
|
|
1388
|
+
const { coupons: appliedCoupons, dropped: droppedCoupons } = await revalidateAppliedCoupons(cart.appliedCoupons ?? [], bucketedPaid.available, uid);
|
|
1276
1389
|
// "skip_items" (or an all-available cart under any policy) — build order
|
|
1277
1390
|
// groups from the available bucket only. Dropped items are never ordered;
|
|
1278
1391
|
// their value is refunded below since Razorpay already captured payment
|
|
@@ -1281,14 +1394,9 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1281
1394
|
const orderIds = [];
|
|
1282
1395
|
let total = 0;
|
|
1283
1396
|
const emailsToSend = [];
|
|
1284
|
-
// SB-UNI-5 2026-05-13 — bundle cart-lines use item.price (locked bundle
|
|
1285
|
-
// price); regular lines use product.price. The helper keeps the math
|
|
1286
|
-
// local to this block.
|
|
1287
|
-
const unitPriceFor = (item, product) => item.bundleCategorySlug && item.bundleProductIds?.length
|
|
1288
|
-
? item.price
|
|
1289
|
-
: product.price;
|
|
1290
1397
|
const cartSubtotal = orderGroups.reduce((s, { items: g }) => s +
|
|
1291
1398
|
g.reduce((gs, { item, product }) => gs + unitPriceFor(item, product) * item.quantity, 0), 0);
|
|
1399
|
+
const couponUsageAccumulator = new Map();
|
|
1292
1400
|
for (const { items: group, orderType } of orderGroups) {
|
|
1293
1401
|
total += await createRazorpayGroupOrder(group, orderType, {
|
|
1294
1402
|
appliedCoupons,
|
|
@@ -1312,8 +1420,10 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1312
1420
|
unavailablePaid,
|
|
1313
1421
|
orderIds,
|
|
1314
1422
|
emailsToSend,
|
|
1423
|
+
couponUsageAccumulator,
|
|
1315
1424
|
});
|
|
1316
1425
|
}
|
|
1426
|
+
await flushCouponUsageAccumulator(couponUsageAccumulator, uid);
|
|
1317
1427
|
// SB-UNI-5 2026-05-13 — bundle-aware batch decrement. We iterate the
|
|
1318
1428
|
// cumulative `decrements` map (one entry per unique member product) so a
|
|
1319
1429
|
// bundle's members + any sibling cart line touching the same product
|
|
@@ -1362,5 +1472,6 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1362
1472
|
total,
|
|
1363
1473
|
itemCount: orderIds.length,
|
|
1364
1474
|
...(unavailablePaid.length > 0 ? { unavailableItems: unavailablePaid } : {}),
|
|
1475
|
+
...(droppedCoupons.length > 0 ? { droppedCoupons } : {}),
|
|
1365
1476
|
};
|
|
1366
1477
|
}
|
|
@@ -10,4 +10,14 @@ export interface CheckoutOrderResult {
|
|
|
10
10
|
requestedQty: number;
|
|
11
11
|
availableQty: number;
|
|
12
12
|
}[];
|
|
13
|
+
/**
|
|
14
|
+
* Coupons that were on the cart but failed re-validation at placement time
|
|
15
|
+
* (expired, limit reached, no longer any eligible items). They were removed
|
|
16
|
+
* and the order priced without them — surfaced so the UI can tell the buyer
|
|
17
|
+
* rather than silently charging more than the cart showed.
|
|
18
|
+
*/
|
|
19
|
+
droppedCoupons?: {
|
|
20
|
+
code: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
}[];
|
|
13
23
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CartItemDocument } from "../../../../features/cart/schemas/firestore";
|
|
2
|
+
/**
|
|
3
|
+
* Refuse a checkout that isn't the cart's active lane.
|
|
4
|
+
*
|
|
5
|
+
* The client scopes its selection to one lane's tab, but a selection is just a
|
|
6
|
+
* list of item ids in a request body — nothing stopped a caller from checking
|
|
7
|
+
* out their ₹200 standard items while a ₹30,000 auction win they already
|
|
8
|
+
* committed to sat unpaid. Priority is enforced here, on the server, over the
|
|
9
|
+
* WHOLE cart, not over the caller-supplied subset.
|
|
10
|
+
*/
|
|
11
|
+
export declare function assertCheckoutLane(allCartItems: readonly CartItemDocument[], selectedItems: readonly CartItemDocument[]): void;
|
|
12
|
+
/** Error code the client maps to a lane-aware message. */
|
|
13
|
+
export declare const CHECKOUT_LANE_BLOCKED = "CHECKOUT_LANE_BLOCKED";
|
|
14
|
+
/**
|
|
15
|
+
* Re-validate every locked line immediately before the order is written.
|
|
16
|
+
*
|
|
17
|
+
* `checkoutOffer()` already checks status/deadline/stock when the line is ADDED
|
|
18
|
+
* to the cart, but nothing re-checked at order time — so a buyer could add an
|
|
19
|
+
* accepted offer, wait out the 48h window, and still be charged the locked
|
|
20
|
+
* price. Throws on the first invalid line; the caller's transaction aborts.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assertLockedLinesStillValid(items: readonly CartItemDocument[], buyerUid: string): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Close the loop after the order exists.
|
|
25
|
+
*
|
|
26
|
+
* Best-effort by design: the order is already persisted and paid-for, so a
|
|
27
|
+
* failure here must be logged rather than thrown — losing the back-link is a
|
|
28
|
+
* bookkeeping problem, refusing a completed order is a customer-facing one.
|
|
29
|
+
*/
|
|
30
|
+
export declare function finalizeLockedLines(items: readonly CartItemDocument[], orderId: string): Promise<void>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: A "locked line" is a cart line whose price was fixed outside the listing —
|
|
3
|
+
* an accepted offer or a won auction. Two things were missing before
|
|
4
|
+
* 2026-08-21: the offer/bid was never re-checked at ORDER time (only at
|
|
5
|
+
* add-to-cart time, so a line could sit in the cart past its deadline and
|
|
6
|
+
* still check out), and nothing ever closed the loop afterwards — the
|
|
7
|
+
* offer stayed "accepted" forever and could be re-added and re-ordered,
|
|
8
|
+
* and a won bid had no link to the order it became.
|
|
9
|
+
* WHAT: `assertLockedLinesStillValid` (pre-order-creation guard) and
|
|
10
|
+
* `finalizeLockedLines` (post-order-creation close-out). Both are called
|
|
11
|
+
* from every order-creation path so the manual/COD and Razorpay flows
|
|
12
|
+
* cannot diverge.
|
|
13
|
+
*
|
|
14
|
+
* EXPORTS:
|
|
15
|
+
* assertLockedLinesStillValid, finalizeLockedLines
|
|
16
|
+
*
|
|
17
|
+
* @tag domain:checkout,offers,auctions
|
|
18
|
+
* @tag layer:server-action
|
|
19
|
+
* @tag pattern:none
|
|
20
|
+
* @tag access:server-only
|
|
21
|
+
* @tag consumers:checkout/actions.ts
|
|
22
|
+
* @tag sideEffects:firestore-write
|
|
23
|
+
*/
|
|
24
|
+
import { offerRepository } from "../../../../features/seller/repository/offer.repository";
|
|
25
|
+
import { bidRepository } from "../../../../repositories";
|
|
26
|
+
import { OfferStatusValues } from "../../../../features/seller/schemas";
|
|
27
|
+
import { ValidationError } from "../../../../errors";
|
|
28
|
+
import { serverLogger } from "../../../../monitoring/server-logger";
|
|
29
|
+
import { normalizeError } from "../../../../errors/normalize";
|
|
30
|
+
import { activeLane, laneOf, laneBlockReason } from "../../../shared/checkout/lanes";
|
|
31
|
+
/**
|
|
32
|
+
* Refuse a checkout that isn't the cart's active lane.
|
|
33
|
+
*
|
|
34
|
+
* The client scopes its selection to one lane's tab, but a selection is just a
|
|
35
|
+
* list of item ids in a request body — nothing stopped a caller from checking
|
|
36
|
+
* out their ₹200 standard items while a ₹30,000 auction win they already
|
|
37
|
+
* committed to sat unpaid. Priority is enforced here, on the server, over the
|
|
38
|
+
* WHOLE cart, not over the caller-supplied subset.
|
|
39
|
+
*/
|
|
40
|
+
export function assertCheckoutLane(allCartItems, selectedItems) {
|
|
41
|
+
const active = activeLane(allCartItems);
|
|
42
|
+
if (!active)
|
|
43
|
+
return;
|
|
44
|
+
const offLane = selectedItems.filter((item) => laneOf(item) !== active);
|
|
45
|
+
if (offLane.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
throw new ValidationError(laneBlockReason(allCartItems, laneOf(offLane[0])) ??
|
|
48
|
+
"Please complete your outstanding items first.", { code: CHECKOUT_LANE_BLOCKED });
|
|
49
|
+
}
|
|
50
|
+
/** Error code the client maps to a lane-aware message. */
|
|
51
|
+
export const CHECKOUT_LANE_BLOCKED = "CHECKOUT_LANE_BLOCKED";
|
|
52
|
+
/**
|
|
53
|
+
* Re-validate every locked line immediately before the order is written.
|
|
54
|
+
*
|
|
55
|
+
* `checkoutOffer()` already checks status/deadline/stock when the line is ADDED
|
|
56
|
+
* to the cart, but nothing re-checked at order time — so a buyer could add an
|
|
57
|
+
* accepted offer, wait out the 48h window, and still be charged the locked
|
|
58
|
+
* price. Throws on the first invalid line; the caller's transaction aborts.
|
|
59
|
+
*/
|
|
60
|
+
export async function assertLockedLinesStillValid(items, buyerUid) {
|
|
61
|
+
const now = new Date();
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
if (!item.offerId)
|
|
64
|
+
continue;
|
|
65
|
+
const offer = await offerRepository.findById(item.offerId);
|
|
66
|
+
if (!offer) {
|
|
67
|
+
throw new ValidationError("The offer for one of your items no longer exists. Remove it and try again.");
|
|
68
|
+
}
|
|
69
|
+
if (offer.buyerUid !== buyerUid) {
|
|
70
|
+
throw new ValidationError("This offer belongs to a different account.");
|
|
71
|
+
}
|
|
72
|
+
if (offer.status !== OfferStatusValues.ACCEPTED) {
|
|
73
|
+
throw new ValidationError(offer.status === OfferStatusValues.PAID
|
|
74
|
+
? "This offer has already been used for an order."
|
|
75
|
+
: "This offer is no longer accepted. Remove it from your cart and make a new offer.");
|
|
76
|
+
}
|
|
77
|
+
if (offer.checkoutDeadline && now > offer.checkoutDeadline) {
|
|
78
|
+
throw new ValidationError("The checkout window for this accepted offer has expired. Please make a new offer.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Close the loop after the order exists.
|
|
84
|
+
*
|
|
85
|
+
* Best-effort by design: the order is already persisted and paid-for, so a
|
|
86
|
+
* failure here must be logged rather than thrown — losing the back-link is a
|
|
87
|
+
* bookkeeping problem, refusing a completed order is a customer-facing one.
|
|
88
|
+
*/
|
|
89
|
+
export async function finalizeLockedLines(items, orderId) {
|
|
90
|
+
for (const item of items) {
|
|
91
|
+
if (item.offerId) {
|
|
92
|
+
try {
|
|
93
|
+
await offerRepository.markPaid(item.offerId, orderId);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
void normalizeError(err);
|
|
97
|
+
serverLogger.error("finalizeLockedLines: markPaid failed", {
|
|
98
|
+
offerId: item.offerId,
|
|
99
|
+
orderId,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (item.bidId) {
|
|
104
|
+
try {
|
|
105
|
+
await bidRepository.attachOrder(item.bidId, orderId);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
void normalizeError(err);
|
|
109
|
+
serverLogger.error("finalizeLockedLines: attachOrder failed", {
|
|
110
|
+
bidId: item.bidId,
|
|
111
|
+
orderId,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -50,12 +50,19 @@ export function orderDocumentToOrder(doc) {
|
|
|
50
50
|
address,
|
|
51
51
|
orderStatus: doc.status,
|
|
52
52
|
paymentStatus: doc.paymentStatus,
|
|
53
|
+
// Which checkout lane produced this order (standard / auction / offer /
|
|
54
|
+
// preorder / prize-draw). Drives the lane tabs on /user/orders. Absent on
|
|
55
|
+
// orders written before `orderType` existed — every reader must treat a
|
|
56
|
+
// missing value as "standard" rather than filtering it out.
|
|
57
|
+
orderType: doc.orderType,
|
|
58
|
+
offerId: doc.offerId,
|
|
53
59
|
subtotal,
|
|
54
60
|
shippingCost: shippingCost || undefined,
|
|
55
61
|
discount: discount || undefined,
|
|
56
62
|
total: doc.totalPrice,
|
|
57
63
|
currency: doc.currency,
|
|
58
64
|
couponCode: doc.couponCode,
|
|
65
|
+
appliedDiscounts: doc.appliedDiscounts,
|
|
59
66
|
trackingNumber: doc.trackingNumber,
|
|
60
67
|
shippingCarrier: doc.shippingCarrier,
|
|
61
68
|
trackingUrl: doc.trackingUrl,
|
|
@@ -4,15 +4,24 @@ import type { ProductDocument } from "../../../../features/products/schemas/fire
|
|
|
4
4
|
import type { ListingType } from "../../../../features/products/types";
|
|
5
5
|
import type { ProductItem } from "../../../../features/products/types";
|
|
6
6
|
import type { FirestoreDocument } from "../../../../schemas/types";
|
|
7
|
-
import type { Review } from "../../../../features/reviews/types";
|
|
7
|
+
import type { Review, ReviewListResponse } from "../../../../features/reviews/types";
|
|
8
8
|
/** Fetch a single product by slug or id, deduped per request. */
|
|
9
9
|
export declare const getProductForDetail: (slugOrId: string) => Promise<ProductDocument | null>;
|
|
10
10
|
/** Fetch the first page of approved reviews for a product, deduped per request. */
|
|
11
|
-
export declare const getReviewsForProduct: (productId: string) => Promise<unknown[]>;
|
|
11
|
+
export declare const getReviewsForProduct: (productId: string, limit?: number) => Promise<unknown[]>;
|
|
12
12
|
/** Firestore review doc → client Review shape. Shared by every listing-type detail page's reviews tab. */
|
|
13
13
|
export declare function toReview(doc: FirestoreDocument): Review;
|
|
14
14
|
/** Fetch + map approved reviews for a product straight to the client Review shape, deduped per request. */
|
|
15
|
-
export declare const getReviewItemsForProduct: (productId: string) => Promise<Review[]>;
|
|
15
|
+
export declare const getReviewItemsForProduct: (productId: string, limit?: number) => Promise<Review[]>;
|
|
16
|
+
/**
|
|
17
|
+
* Page 1 of a product's approved reviews, shaped exactly like `GET /api/reviews?productId=`
|
|
18
|
+
* so it can seed `<ReviewsListingPanel initialData>` without a client round-trip.
|
|
19
|
+
*
|
|
20
|
+
* The defaults here MUST match the panel's bare (unfiltered, page-1) query defaults —
|
|
21
|
+
* `staleTime: Infinity` freezes whatever `initialData` is handed over, so a mismatch would
|
|
22
|
+
* pin the wrong rows into the cache (Recurrent Root Cause #30).
|
|
23
|
+
*/
|
|
24
|
+
export declare const getReviewPageForProduct: (productId: string, pageSize?: number) => Promise<ReviewListResponse>;
|
|
16
25
|
/** Minimal product fields needed for sitemap generation. */
|
|
17
26
|
export interface SitemapProduct {
|
|
18
27
|
slugOrId: string;
|
|
@@ -13,6 +13,8 @@ import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firest
|
|
|
13
13
|
import { PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|
|
14
14
|
import { PRODUCT_FIELDS } from "../../../../constants/field-names";
|
|
15
15
|
import { SIEVE_OP, sieveAnd, sieveFilter, sortBy } from "../../../../utils/sieve-builder";
|
|
16
|
+
import { REVIEW_FIELDS } from "../../../../constants/field-names";
|
|
17
|
+
import { REVIEWS_DETAIL_PAGE_SIZE } from "../../../shared/features/reviews/config";
|
|
16
18
|
// ---------------------------------------------------------------------------
|
|
17
19
|
// Request-scoped cache — each function is deduplicated per RSC render tree.
|
|
18
20
|
// Both generateMetadata() and the page component get the same promise.
|
|
@@ -26,8 +28,8 @@ export const getProductForDetail = cache(async (slugOrId) => {
|
|
|
26
28
|
return product;
|
|
27
29
|
});
|
|
28
30
|
/** Fetch the first page of approved reviews for a product, deduped per request. */
|
|
29
|
-
export const getReviewsForProduct = cache(async (productId) => {
|
|
30
|
-
return reviewRepository.findApprovedByProduct(productId).catch(() => []);
|
|
31
|
+
export const getReviewsForProduct = cache(async (productId, limit = REVIEWS_DETAIL_PAGE_SIZE) => {
|
|
32
|
+
return reviewRepository.findApprovedByProduct(productId, limit).catch(() => []);
|
|
31
33
|
});
|
|
32
34
|
/** Firestore review doc → client Review shape. Shared by every listing-type detail page's reviews tab. */
|
|
33
35
|
export function toReview(doc) {
|
|
@@ -57,10 +59,59 @@ export function toReview(doc) {
|
|
|
57
59
|
};
|
|
58
60
|
}
|
|
59
61
|
/** Fetch + map approved reviews for a product straight to the client Review shape, deduped per request. */
|
|
60
|
-
export const getReviewItemsForProduct = cache(async (productId) => {
|
|
61
|
-
const docs = await reviewRepository
|
|
62
|
+
export const getReviewItemsForProduct = cache(async (productId, limit = REVIEWS_DETAIL_PAGE_SIZE) => {
|
|
63
|
+
const docs = await reviewRepository
|
|
64
|
+
.findApprovedByProduct(productId, limit)
|
|
65
|
+
.catch(() => []);
|
|
62
66
|
return docs.map(toReview);
|
|
63
67
|
});
|
|
68
|
+
/**
|
|
69
|
+
* Page 1 of a product's approved reviews, shaped exactly like `GET /api/reviews?productId=`
|
|
70
|
+
* so it can seed `<ReviewsListingPanel initialData>` without a client round-trip.
|
|
71
|
+
*
|
|
72
|
+
* The defaults here MUST match the panel's bare (unfiltered, page-1) query defaults —
|
|
73
|
+
* `staleTime: Infinity` freezes whatever `initialData` is handed over, so a mismatch would
|
|
74
|
+
* pin the wrong rows into the cache (Recurrent Root Cause #30).
|
|
75
|
+
*/
|
|
76
|
+
export const getReviewPageForProduct = cache(async (productId, pageSize = REVIEWS_DETAIL_PAGE_SIZE) => {
|
|
77
|
+
const empty = {
|
|
78
|
+
items: [],
|
|
79
|
+
total: 0,
|
|
80
|
+
page: 1,
|
|
81
|
+
pageSize,
|
|
82
|
+
totalPages: 1,
|
|
83
|
+
hasMore: false,
|
|
84
|
+
averageRating: 0,
|
|
85
|
+
ratingDistribution: {},
|
|
86
|
+
totalApproved: 0,
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
const [summary, result] = await Promise.all([
|
|
90
|
+
reviewRepository.getApprovedRatingSummary(productId),
|
|
91
|
+
reviewRepository.listForProduct(productId, {
|
|
92
|
+
filters: sieveFilter(REVIEW_FIELDS.STATUS, SIEVE_OP.EQ, "approved"),
|
|
93
|
+
sorts: sortBy(REVIEW_FIELDS.CREATED_AT),
|
|
94
|
+
page: 1,
|
|
95
|
+
pageSize,
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
return {
|
|
99
|
+
items: result.items.map(toReview),
|
|
100
|
+
total: result.total,
|
|
101
|
+
page: result.page,
|
|
102
|
+
pageSize: result.pageSize,
|
|
103
|
+
totalPages: result.totalPages,
|
|
104
|
+
hasMore: result.hasMore,
|
|
105
|
+
averageRating: summary.averageRating,
|
|
106
|
+
ratingDistribution: summary.ratingDistribution,
|
|
107
|
+
totalApproved: summary.total,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
void normalizeError(err);
|
|
112
|
+
return empty;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
64
115
|
/** List all published products for sitemap generation. */
|
|
65
116
|
export async function listSitemapProducts() {
|
|
66
117
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, type SitemapProduct, computeRelatedItems, toProductItem, toReview, getReviewItemsForProduct, type RelatedItemsResult, } from "./data";
|
|
1
|
+
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, type SitemapProduct, computeRelatedItems, toProductItem, toReview, getReviewItemsForProduct, getReviewPageForProduct, type RelatedItemsResult, } from "./data";
|
|
2
|
+
export { listPublicProducts, parsePublicProductParams, PUBLIC_PRODUCT_MAX_PAGE_SIZE, type PublicProductListInput, type PublicProductListResult, type PublicProductListOptions, type PublicProductExecutor, type PublicProductQuery, type ExecutorResult, } from "./list-public";
|
|
2
3
|
export { assertProductOwnership, assertStatusTransition, assertInStock, effectivePrice, isAvailableForPurchase, } from "./service";
|
|
3
4
|
export { createProductAction, createAuctionAction, createPreOrderAction, updateProductAction, deleteProductAction, setProductStatusAction, setProductFeaturedAction, } from "./actions";
|
|
4
5
|
export { PRODUCTS_PAGE_SIZE, PRODUCTS_FEATURED_LIMIT, PRODUCTS_RELATED_LIMIT, PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, computeRelatedItems, toProductItem, toReview, getReviewItemsForProduct, } from "./data";
|
|
1
|
+
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, computeRelatedItems, toProductItem, toReview, getReviewItemsForProduct, getReviewPageForProduct, } from "./data";
|
|
2
|
+
export { listPublicProducts, parsePublicProductParams, PUBLIC_PRODUCT_MAX_PAGE_SIZE, } from "./list-public";
|
|
2
3
|
export { assertProductOwnership, assertStatusTransition, assertInStock, effectivePrice, isAvailableForPurchase, } from "./service";
|
|
3
4
|
export { createProductAction, createAuctionAction, createPreOrderAction, updateProductAction, deleteProductAction, setProductStatusAction, setProductFeaturedAction, } from "./actions";
|
|
4
5
|
export { PRODUCTS_PAGE_SIZE, PRODUCTS_FEATURED_LIMIT, PRODUCTS_RELATED_LIMIT, PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|