@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.
Files changed (212) hide show
  1. package/dist/_internal/server/features/checkout/actions.d.ts +4 -0
  2. package/dist/_internal/server/features/checkout/actions.js +138 -27
  3. package/dist/_internal/server/features/checkout/data.d.ts +10 -0
  4. package/dist/_internal/server/features/checkout/locked-lines.d.ts +30 -0
  5. package/dist/_internal/server/features/checkout/locked-lines.js +116 -0
  6. package/dist/_internal/server/features/orders/adapters.js +7 -0
  7. package/dist/_internal/server/features/products/data.d.ts +12 -3
  8. package/dist/_internal/server/features/products/data.js +55 -4
  9. package/dist/_internal/server/features/products/index.d.ts +2 -1
  10. package/dist/_internal/server/features/products/index.js +2 -1
  11. package/dist/_internal/server/features/products/list-public.d.ts +130 -0
  12. package/dist/_internal/server/features/products/list-public.js +375 -0
  13. package/dist/_internal/server/features/reviews/data.d.ts +1 -1
  14. package/dist/_internal/server/features/reviews/data.js +3 -2
  15. package/dist/_internal/server/features/tester/visibility.js +3 -3
  16. package/dist/_internal/server/jobs/core/auctionSettlement.js +89 -16
  17. package/dist/_internal/server/jobs/core/offerExpiry.js +116 -4
  18. package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +1 -1
  19. package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +4 -1
  20. package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +2 -0
  21. package/dist/_internal/server/jobs/handlers/_helpers.js +4 -1
  22. package/dist/_internal/server/jobs/handlers/messages.d.ts +5 -0
  23. package/dist/_internal/server/jobs/handlers/messages.js +5 -0
  24. package/dist/_internal/shared/checkout/lanes.d.ts +41 -0
  25. package/dist/_internal/shared/checkout/lanes.js +106 -0
  26. package/dist/_internal/shared/checkout/order-math.d.ts +19 -0
  27. package/dist/_internal/shared/checkout/order-math.js +30 -6
  28. package/dist/_internal/shared/features/promotions/schema.d.ts +0 -16
  29. package/dist/_internal/shared/features/promotions/schema.js +0 -2
  30. package/dist/_internal/shared/features/reviews/config.d.ts +8 -1
  31. package/dist/_internal/shared/features/reviews/config.js +8 -1
  32. package/dist/_internal/shared/listing-types/_registry.d.ts +88 -1
  33. package/dist/_internal/shared/listing-types/_registry.js +63 -1
  34. package/dist/_internal/shared/listing-types/art/config.d.ts +59 -0
  35. package/dist/_internal/shared/listing-types/art/config.js +12 -0
  36. package/dist/_internal/shared/listing-types/auction/config.d.ts +53 -0
  37. package/dist/_internal/shared/listing-types/auction/config.js +17 -0
  38. package/dist/_internal/shared/listing-types/classified/config.d.ts +59 -0
  39. package/dist/_internal/shared/listing-types/classified/config.js +14 -0
  40. package/dist/_internal/shared/listing-types/digital-code/config.d.ts +59 -0
  41. package/dist/_internal/shared/listing-types/digital-code/config.js +12 -0
  42. package/dist/_internal/shared/listing-types/feature-flags.d.ts +1 -0
  43. package/dist/_internal/shared/listing-types/feature-flags.js +24 -10
  44. package/dist/_internal/shared/listing-types/live/config.d.ts +59 -0
  45. package/dist/_internal/shared/listing-types/live/config.js +14 -0
  46. package/dist/_internal/shared/listing-types/pre-order/config.d.ts +110 -0
  47. package/dist/_internal/shared/listing-types/pre-order/config.js +14 -0
  48. package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +44 -0
  49. package/dist/_internal/shared/listing-types/prize-draw/config.js +10 -0
  50. package/dist/_internal/shared/listing-types/standard/config.d.ts +59 -0
  51. package/dist/_internal/shared/listing-types/standard/config.js +9 -0
  52. package/dist/_internal/shared/listing-types/stickers/config.d.ts +59 -0
  53. package/dist/_internal/shared/listing-types/stickers/config.js +12 -0
  54. package/dist/client.d.ts +7 -2
  55. package/dist/client.js +8 -2
  56. package/dist/constants/field-names.d.ts +12 -0
  57. package/dist/constants/field-names.js +12 -0
  58. package/dist/constants/table-keys.d.ts +2 -0
  59. package/dist/constants/table-keys.js +2 -0
  60. package/dist/features/account/components/NotificationBell.js +1 -0
  61. package/dist/features/account/components/UserOffersPanel.js +8 -1
  62. package/dist/features/account/components/UserOrdersView.js +27 -5
  63. package/dist/features/admin/actions/notification-actions.js +1 -1
  64. package/dist/features/admin/components/AdminAddressesView.js +3 -1
  65. package/dist/features/admin/components/AdminCouponEditorView.js +1 -4
  66. package/dist/features/admin/components/AdminPaymentMethodsView.js +3 -1
  67. package/dist/features/admin/components/AdminProductsView.js +34 -18
  68. package/dist/features/admin/constants/filter-tabs.d.ts +26 -9
  69. package/dist/features/admin/constants/filter-tabs.js +22 -9
  70. package/dist/features/admin/schemas/firestore.d.ts +2 -1
  71. package/dist/features/admin/schemas/firestore.js +1 -0
  72. package/dist/features/auctions/actions/bid-actions.d.ts +11 -1
  73. package/dist/features/auctions/actions/bid-actions.js +29 -15
  74. package/dist/features/auctions/components/AuctionBidsTable.js +6 -2
  75. package/dist/features/auctions/components/AuctionBottomActions.js +1 -0
  76. package/dist/features/auctions/components/AuctionDetailPageView.js +13 -11
  77. package/dist/features/auctions/components/AuctionsListView.js +12 -53
  78. package/dist/features/auctions/components/PlaceBidFormClient.js +10 -1
  79. package/dist/features/auctions/repository/bid.repository.d.ts +15 -0
  80. package/dist/features/auctions/repository/bid.repository.js +19 -0
  81. package/dist/features/auctions/schemas/firestore.d.ts +11 -1
  82. package/dist/features/auctions/schemas/firestore.js +1 -0
  83. package/dist/features/cart/actions/cart-actions.d.ts +11 -0
  84. package/dist/features/cart/actions/cart-actions.js +24 -0
  85. package/dist/features/cart/repository/cart.repository.d.ts +24 -1
  86. package/dist/features/cart/repository/cart.repository.js +71 -6
  87. package/dist/features/cart/schemas/firestore.d.ts +23 -4
  88. package/dist/features/categories/components/BrandDetailTabs.js +31 -11
  89. package/dist/features/categories/components/CategoryDetailTabs.js +27 -7
  90. package/dist/features/categories/components/CategoryProductsListing.d.ts +9 -1
  91. package/dist/features/categories/components/CategoryProductsListing.js +5 -2
  92. package/dist/features/classified/components/ClassifiedDetailPageView.js +2 -1
  93. package/dist/features/contact/email.js +56 -1
  94. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +17 -16
  95. package/dist/features/homepage/components/FeaturedResultsSection.js +1 -1
  96. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  97. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  98. package/dist/features/layout/AppLayoutShell.js +9 -2
  99. package/dist/features/layout/BottomActions.js +26 -3
  100. package/dist/features/layout/BottomActionsContext.d.ts +14 -0
  101. package/dist/features/layout/BottomActionsContext.js +13 -1
  102. package/dist/features/layout/hooks/useBottomActions.d.ts +6 -1
  103. package/dist/features/layout/hooks/useBottomActions.js +4 -1
  104. package/dist/features/layout/index.d.ts +1 -1
  105. package/dist/features/live/components/LiveItemDetailPageView.js +7 -6
  106. package/dist/features/orders/repository/orders.repository.d.ts +4 -15
  107. package/dist/features/orders/repository/orders.repository.js +4 -27
  108. package/dist/features/orders/types/index.d.ts +16 -0
  109. package/dist/features/orders/utils/order-splitter.js +14 -2
  110. package/dist/features/pre-orders/components/PreOrderBottomActions.d.ts +1 -1
  111. package/dist/features/pre-orders/components/PreOrderBottomActions.js +3 -19
  112. package/dist/features/pre-orders/components/PreOrdersListView.js +11 -39
  113. package/dist/features/products/components/ArtStickersListView.js +10 -49
  114. package/dist/features/products/components/AuctionsIndexListing.js +1 -2
  115. package/dist/features/products/components/ListingBottomActions.d.ts +28 -0
  116. package/dist/features/products/components/ListingBottomActions.js +25 -0
  117. package/dist/features/products/components/PrizeDrawBottomActions.d.ts +1 -1
  118. package/dist/features/products/components/PrizeDrawBottomActions.js +3 -21
  119. package/dist/features/products/components/ProductDetailActions.js +4 -0
  120. package/dist/features/products/components/ProductDetailPageView.js +7 -7
  121. package/dist/features/products/components/ProductsIndexPageView.js +9 -62
  122. package/dist/features/products/constants/listing-tabs.d.ts +43 -198
  123. package/dist/features/products/constants/listing-tabs.js +71 -58
  124. package/dist/features/products/constants/sieve.d.ts +104 -1
  125. package/dist/features/products/constants/sieve.js +27 -7
  126. package/dist/features/products/repository/products.repository.d.ts +45 -1
  127. package/dist/features/products/repository/products.repository.js +67 -8
  128. package/dist/features/products/utils/listing-badge-variant.d.ts +20 -1
  129. package/dist/features/products/utils/listing-badge-variant.js +26 -1
  130. package/dist/features/products/utils/listing-type.d.ts +24 -0
  131. package/dist/features/products/utils/listing-type.js +48 -0
  132. package/dist/features/promotions/actions/coupon-actions.d.ts +7 -0
  133. package/dist/features/promotions/actions/coupon-actions.js +12 -1
  134. package/dist/features/promotions/actions/coupon-stacking.d.ts +24 -0
  135. package/dist/features/promotions/actions/coupon-stacking.js +21 -0
  136. package/dist/features/promotions/actions/index.d.ts +1 -0
  137. package/dist/features/promotions/actions/index.js +1 -0
  138. package/dist/features/promotions/actions/seller-coupon-actions.d.ts +0 -1
  139. package/dist/features/promotions/actions/seller-coupon-actions.js +0 -1
  140. package/dist/features/promotions/components/CouponHelpDetails.d.ts +20 -0
  141. package/dist/features/promotions/components/CouponHelpDetails.js +17 -0
  142. package/dist/features/promotions/components/CouponsIndexListing.js +2 -1
  143. package/dist/features/promotions/components/index.d.ts +2 -0
  144. package/dist/features/promotions/components/index.js +1 -0
  145. package/dist/features/promotions/constants/coupon-help.d.ts +27 -0
  146. package/dist/features/promotions/constants/coupon-help.js +42 -0
  147. package/dist/features/promotions/repository/coupons.repository.d.ts +10 -1
  148. package/dist/features/promotions/repository/coupons.repository.js +22 -8
  149. package/dist/features/promotions/schemas/firestore.d.ts +0 -1
  150. package/dist/features/promotions/schemas/firestore.js +1 -1
  151. package/dist/features/promotions/schemas/index.d.ts +0 -20
  152. package/dist/features/promotions/schemas/index.js +0 -1
  153. package/dist/features/reviews/actions/review-actions.js +5 -1
  154. package/dist/features/reviews/components/ReviewDetailPageView.js +2 -1
  155. package/dist/features/reviews/components/ReviewFilters.d.ts +7 -1
  156. package/dist/features/reviews/components/ReviewFilters.js +4 -3
  157. package/dist/features/reviews/components/ReviewsIndexListing.d.ts +6 -0
  158. package/dist/features/reviews/components/ReviewsIndexListing.js +9 -109
  159. package/dist/features/reviews/components/ReviewsListingPanel.d.ts +41 -0
  160. package/dist/features/reviews/components/ReviewsListingPanel.js +226 -0
  161. package/dist/features/reviews/components/index.d.ts +2 -0
  162. package/dist/features/reviews/components/index.js +1 -0
  163. package/dist/features/reviews/hooks/useReviews.d.ts +8 -0
  164. package/dist/features/reviews/hooks/useReviews.js +14 -6
  165. package/dist/features/reviews/repository/reviews.repository.d.ts +29 -1
  166. package/dist/features/reviews/repository/reviews.repository.js +77 -41
  167. package/dist/features/reviews/types/index.d.ts +8 -1
  168. package/dist/features/seller/actions/offer-actions.d.ts +6 -0
  169. package/dist/features/seller/actions/offer-actions.js +8 -9
  170. package/dist/features/seller/components/SellerOffersView.d.ts +6 -1
  171. package/dist/features/seller/components/SellerOffersView.js +59 -8
  172. package/dist/features/seller/components/SellerProductsCards.js +2 -2
  173. package/dist/features/seller/components/SellerProductsView.js +21 -18
  174. package/dist/features/seller/repository/offer.repository.d.ts +18 -2
  175. package/dist/features/seller/repository/offer.repository.js +38 -2
  176. package/dist/features/seller/schemas/firestore.d.ts +7 -2
  177. package/dist/features/seller/schemas/firestore.js +3 -0
  178. package/dist/features/seller/schemas/offer-forms.d.ts +12 -0
  179. package/dist/features/seller/schemas/offer-forms.js +28 -0
  180. package/dist/features/stores/actions/store-query-actions.js +1 -1
  181. package/dist/features/stores/components/StoreAuctionsListing.js +5 -11
  182. package/dist/features/stores/components/StorePreOrdersListing.js +6 -11
  183. package/dist/features/stores/components/StoreReviewsListing.d.ts +4 -0
  184. package/dist/features/stores/components/StoreReviewsListing.js +7 -108
  185. package/dist/features/stores/schemas/index.d.ts +2 -2
  186. package/dist/features/tester/seed-data/coupons-tester-seed-data.js +3 -3
  187. package/dist/features/tester/seed-data/index.d.ts +1 -0
  188. package/dist/features/tester/seed-data/index.js +1 -0
  189. package/dist/features/tester/seed-data/offers-tester-seed-data.d.ts +2 -0
  190. package/dist/features/tester/seed-data/offers-tester-seed-data.js +61 -0
  191. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +337 -1
  192. package/dist/index.d.ts +11 -4
  193. package/dist/index.js +15 -3
  194. package/dist/react/hooks/useListingTypeFlags.d.ts +12 -19
  195. package/dist/react/hooks/useListingTypeFlags.js +4 -9
  196. package/dist/schemas/registry.d.ts +0 -12
  197. package/dist/seed/claimed-coupons-seed-data.js +3 -3
  198. package/dist/seed/coupons-seed-data.js +29 -10
  199. package/dist/seed/faq-seed-data.js +2 -2
  200. package/dist/seed/manifest.js +2 -2
  201. package/dist/server-entry.d.ts +1 -0
  202. package/dist/server-entry.js +1 -0
  203. package/dist/server.d.ts +1 -0
  204. package/dist/server.js +3 -0
  205. package/dist/styles.css +44 -39
  206. package/dist/tailwind-utilities.css +1 -1
  207. package/dist/ui/components/FilterChipGroup.d.ts +21 -3
  208. package/dist/ui/components/FilterChipGroup.js +33 -4
  209. package/dist/ui/components/HorizontalScroller.d.ts +1 -2
  210. package/dist/ui/components/HorizontalScroller.js +9 -5
  211. package/dist/ui/components/HorizontalScroller.style.css +43 -38
  212. 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 cartItems = (cart.items ?? []).filter((item) => !excludedSet.has(item.productId) && (!selectedSet || selectedSet.has(item.itemId)));
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
- const appliedCoupons = cart.appliedCoupons ?? [];
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.findApprovedByProduct(productId).catch(() => []);
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";