@mohasinac/appkit 4.9.4 → 4.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +7 -2
- package/dist/_internal/client/features/layout/SidebarCollapseToggle.d.ts +5 -1
- package/dist/_internal/client/features/layout/SidebarCollapseToggle.js +8 -2
- package/dist/_internal/client/features/layout/sidebarPositionClasses.d.ts +20 -0
- package/dist/_internal/client/features/layout/sidebarPositionClasses.js +20 -0
- package/dist/_internal/client/hand-mode/HandModeProvider.d.ts +21 -0
- package/dist/_internal/client/hand-mode/HandModeProvider.js +81 -0
- package/dist/_internal/client/hand-mode/index.d.ts +2 -0
- package/dist/_internal/client/hand-mode/index.js +1 -0
- package/dist/_internal/server/features/auctions/actions.js +3 -2
- package/dist/_internal/server/features/auctions/service.d.ts +4 -3
- package/dist/_internal/server/features/auctions/service.js +6 -6
- package/dist/_internal/server/features/bundles/data.d.ts +9 -0
- package/dist/_internal/server/features/bundles/data.js +14 -0
- package/dist/_internal/server/features/bundles/index.d.ts +1 -1
- package/dist/_internal/server/features/bundles/index.js +1 -1
- package/dist/_internal/server/features/catalogue/og.d.ts +20 -0
- package/dist/_internal/server/features/catalogue/og.js +27 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +43 -0
- package/dist/_internal/server/features/checkout/actions.js +172 -81
- package/dist/_internal/server/features/checkout/index.d.ts +1 -1
- package/dist/_internal/server/features/checkout/index.js +1 -1
- package/dist/_internal/server/features/grouped/data.d.ts +12 -0
- package/dist/_internal/server/features/grouped/data.js +26 -1
- package/dist/_internal/server/features/grouped/index.d.ts +1 -1
- package/dist/_internal/server/features/grouped/index.js +1 -1
- package/dist/_internal/server/features/products/data.d.ts +23 -0
- package/dist/_internal/server/features/products/data.js +159 -0
- package/dist/_internal/server/features/products/index.d.ts +1 -1
- package/dist/_internal/server/features/products/index.js +1 -1
- package/dist/_internal/server/features/seo/robots.js +3 -2
- package/dist/_internal/server/features/seo/sitemap.js +70 -5
- package/dist/_internal/shared/config/schema.d.ts +1 -0
- package/dist/_internal/shared/features/auctions/config.d.ts +20 -0
- package/dist/_internal/shared/features/auctions/config.js +31 -1
- package/dist/_internal/shared/listing-types/art/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/art/config.js +1 -1
- package/dist/_internal/shared/listing-types/classified/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/classified/config.js +2 -2
- package/dist/_internal/shared/listing-types/digital-code/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/digital-code/config.js +2 -2
- package/dist/_internal/shared/listing-types/live/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/live/config.js +2 -2
- package/dist/_internal/shared/listing-types/prize-draw/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/prize-draw/config.js +1 -1
- package/dist/_internal/shared/listing-types/stickers/config.d.ts +1 -1
- package/dist/_internal/shared/listing-types/stickers/config.js +1 -1
- package/dist/client.d.ts +4 -6
- package/dist/client.js +10 -4
- package/dist/constants/api-endpoints.d.ts +6 -0
- package/dist/constants/api-endpoints.js +2 -0
- package/dist/features/about/components/AboutView.js +2 -2
- package/dist/features/about/components/DeveloperView.js +2 -2
- package/dist/features/about/schemas/firestore.d.ts +1 -0
- package/dist/features/account/components/UserSidebar.js +8 -2
- package/dist/features/account/hooks/useProfile.d.ts +2 -0
- package/dist/features/admin/components/AdminSidebar.js +8 -2
- package/dist/features/admin/components/AdminSiteSettingsView.js +12 -4
- package/dist/features/admin/schemas/firestore.d.ts +9 -0
- package/dist/features/admin/schemas/firestore.js +6 -0
- package/dist/features/auctions/actions/bid-actions.js +4 -1
- package/dist/features/auctions/components/AuctionBottomActions.d.ts +2 -1
- package/dist/features/auctions/components/AuctionBottomActions.js +14 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +21 -9
- package/dist/features/auctions/components/LiveMinIncrement.d.ts +18 -0
- package/dist/features/auctions/components/LiveMinIncrement.js +17 -0
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +5 -2
- package/dist/features/auctions/components/PlaceBidFormClient.js +7 -3
- package/dist/features/auth/schemas/firestore.d.ts +3 -0
- package/dist/features/cart/components/CartDrawer.js +4 -1
- package/dist/features/catalogue/components/PublicCatalogueItemDetailView.d.ts +11 -0
- package/dist/features/catalogue/components/PublicCatalogueItemDetailView.js +33 -0
- package/dist/features/catalogue/components/PublicCatalogueView.js +2 -1
- package/dist/features/catalogue/index.d.ts +2 -0
- package/dist/features/catalogue/index.js +1 -0
- package/dist/features/categories/components/BundleDetailView.d.ts +3 -1
- package/dist/features/categories/components/BundleDetailView.js +3 -2
- package/dist/features/classified/components/ClassifiedContactSellerPanel.d.ts +12 -0
- package/dist/features/classified/components/ClassifiedContactSellerPanel.js +49 -0
- package/dist/features/classified/components/ClassifiedDetailPageView.d.ts +7 -0
- package/dist/features/classified/components/ClassifiedDetailPageView.js +69 -0
- package/dist/features/contact/components/ContactPageView.js +8 -2
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.d.ts +19 -0
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +79 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.d.ts +13 -0
- package/dist/features/grouped/components/GroupedListingsCarousel.js +31 -0
- package/dist/features/grouped/repository/grouped-listings.repository.d.ts +8 -0
- package/dist/features/grouped/repository/grouped-listings.repository.js +16 -0
- package/dist/features/homepage/components/BrandsSection.js +3 -1
- package/dist/features/homepage/components/FeaturedBundlesSection.js +12 -1
- package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
- package/dist/features/homepage/components/HeroCarousel.js +3 -1
- package/dist/features/homepage/components/SectionCarousel.js +5 -2
- package/dist/features/homepage/components/SocialFeedSection.js +2 -2
- package/dist/features/homepage/components/WhatsAppCommunitySection.js +3 -1
- package/dist/features/layout/BackToTop.js +3 -1
- package/dist/features/layout/BottomActions.js +4 -3
- package/dist/features/layout/BottomActionsContext.d.ts +10 -0
- package/dist/features/layout/BottomActionsContext.js +5 -1
- package/dist/features/layout/SidebarLayout.js +7 -1
- package/dist/features/layout/hooks/useBottomActions.d.ts +2 -0
- package/dist/features/layout/hooks/useBottomActions.js +4 -1
- package/dist/features/live/components/LiveItemDetailPageView.d.ts +9 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +74 -0
- package/dist/features/media/MediaVideo.js +17 -1
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +9 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +11 -1
- package/dist/features/products/components/ProductDetailPageView.js +11 -116
- package/dist/features/products/components/RelatedItemsSection.d.ts +16 -0
- package/dist/features/products/components/RelatedItemsSection.js +17 -0
- package/dist/features/seller/components/SellerProductShell.js +1 -1
- package/dist/features/seller/components/SellerSidebar.js +8 -2
- package/dist/features/shell/QuickFormDrawer.js +3 -1
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +114 -3
- package/dist/hand-mode/index.d.ts +12 -0
- package/dist/hand-mode/index.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +7 -0
- package/dist/react/contexts/SessionContext.d.ts +5 -0
- package/dist/react/contexts/SessionContext.js +2 -0
- package/dist/seed/bids-seed-data.js +48 -3
- package/dist/seed/grouped-listings-seed-data.js +167 -0
- package/dist/seed/payouts-seed-data.js +4 -4
- package/dist/seed/products-art-seed-data.js +188 -5
- package/dist/seed/products-auctions-seed-data.js +215 -6
- package/dist/seed/products-classifieds-seed-data.js +255 -8
- package/dist/seed/products-digital-codes-seed-data.js +258 -19
- package/dist/seed/products-live-items-seed-data.js +143 -10
- package/dist/seed/products-preorders-seed-data.js +213 -8
- package/dist/seed/products-prize-draws-seed-data.js +198 -0
- package/dist/seed/products-standard-seed-data.js +141 -0
- package/dist/seed/products-stickers-seed-data.js +178 -5
- package/dist/seed/site-settings-seed-data.js +13 -31
- package/dist/seed/store-extensions-seed-data.js +1 -1
- package/dist/server-entry.d.ts +5 -2
- package/dist/server-entry.js +5 -2
- package/dist/server.d.ts +4 -2
- package/dist/server.js +4 -2
- package/dist/styles.css +30 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/Drawer.js +7 -4
- package/dist/ui/components/FilterDrawer.js +1 -1
- package/dist/ui/components/HandModeRow.d.ts +12 -0
- package/dist/ui/components/HandModeRow.js +18 -0
- package/dist/ui/components/ImageLightbox.js +37 -3
- package/dist/ui/components/ImageLightbox.style.css +28 -0
- package/dist/ui/components/Layout.d.ts +5 -1
- package/dist/ui/components/Layout.js +2 -1
- package/dist/ui/components/Layout.style.css +1 -0
- package/dist/ui/components/ListingFilterDrawer.js +3 -1
- package/dist/ui/components/Modal.js +3 -1
- package/dist/ui/components/SideDrawer.js +8 -5
- package/dist/ui/components/SideModal.js +7 -4
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/utils/media-url.d.ts +9 -0
- package/dist/utils/media-url.js +39 -0
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import { roundRupees } from "../../../../utils/number.formatter";
|
|
|
11
11
|
import { ApiError, ValidationError, NotFoundError, ERROR_MESSAGES } from "../../../../errors";
|
|
12
12
|
import { ORDER_FIELDS } from "../../../../constants/field-names";
|
|
13
13
|
import { serverLogger } from "../../../../monitoring";
|
|
14
|
-
import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, } from "../../../../repositories";
|
|
14
|
+
import { unitOfWork, siteSettingsRepository, userRepository, storeRepository, couponsRepository, claimedCouponsRepository, addressesRepository, productRepository, } from "../../../../repositories";
|
|
15
15
|
import { calculateGst } from "../../../shared/fees/calculator";
|
|
16
16
|
import { computePreOrderDepositAmount } from "../../../shared/checkout/order-math";
|
|
17
17
|
import { failedCheckoutRepository } from "../../../../features/checkout/repository/failed-checkout.repository";
|
|
@@ -151,7 +151,13 @@ function accumulateCouponUsage(accumulator, couponCode, couponId, discountAmount
|
|
|
151
151
|
entry.totalDiscount += discountAmount;
|
|
152
152
|
accumulator.set(couponCode, entry);
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Exported so `/api/payment/create-order` (pre-charge amount) and the
|
|
156
|
+
* read-only `previewCheckoutPricing` (Order Summary display) can compute the
|
|
157
|
+
* exact same per-seller shipping fee that order creation charges/records —
|
|
158
|
+
* a single source of truth instead of a third re-implementation.
|
|
159
|
+
*/
|
|
160
|
+
export async function resolveShippingCost(storeId) {
|
|
155
161
|
if (!storeId)
|
|
156
162
|
return { shippingFee: 0, storeOwnerId: undefined, storeEmiEnabled: false, storeState: undefined };
|
|
157
163
|
const store = await storeRepository.findById(storeId);
|
|
@@ -172,6 +178,55 @@ async function resolveStoreState(storeId) {
|
|
|
172
178
|
const pickup = addresses.find((a) => a.isDefault) ?? addresses[0];
|
|
173
179
|
return pickup?.state;
|
|
174
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Prorates every buyer-applied coupon against one seller-group's total. Pure
|
|
183
|
+
* — no side effects (usage-accumulator bookkeeping is layered on by callers
|
|
184
|
+
* that actually place an order). Shared by createOrderForGroup,
|
|
185
|
+
* createRazorpayGroupOrder, and the read-only previewCheckoutPricing so the
|
|
186
|
+
* three can never drift on how a coupon splits across sellers (was three
|
|
187
|
+
* near-identical copies before this extraction — Duplication Decision
|
|
188
|
+
* Framework's Rule of Three).
|
|
189
|
+
*/
|
|
190
|
+
function computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, storeId) {
|
|
191
|
+
let couponDiscount = 0;
|
|
192
|
+
const appliedDiscounts = [];
|
|
193
|
+
for (const coupon of appliedCoupons) {
|
|
194
|
+
let couponGroupDiscount = 0;
|
|
195
|
+
const isSellerScoped = coupon.scope === "seller" && coupon.storeId;
|
|
196
|
+
if (isSellerScoped) {
|
|
197
|
+
if (coupon.storeId !== storeId)
|
|
198
|
+
continue;
|
|
199
|
+
if (coupon.applicableItemIds?.length) {
|
|
200
|
+
const eligibleTotal = groupLines
|
|
201
|
+
.filter((l) => coupon.applicableItemIds.includes(l.itemId))
|
|
202
|
+
.reduce((s, l) => s + l.lineTotal, 0);
|
|
203
|
+
couponGroupDiscount =
|
|
204
|
+
eligibleTotal > 0
|
|
205
|
+
? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
|
|
206
|
+
: 0;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else if (cartSubtotal > 0) {
|
|
213
|
+
couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
|
|
214
|
+
}
|
|
215
|
+
if (couponGroupDiscount > 0) {
|
|
216
|
+
couponDiscount += couponGroupDiscount;
|
|
217
|
+
appliedDiscounts.push({
|
|
218
|
+
code: coupon.code,
|
|
219
|
+
couponId: coupon.couponId,
|
|
220
|
+
type: "coupon",
|
|
221
|
+
discountAmount: couponGroupDiscount,
|
|
222
|
+
scope: coupon.scope,
|
|
223
|
+
storeId: coupon.storeId,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
couponDiscount = Math.min(couponDiscount, groupTotal);
|
|
228
|
+
return { couponDiscount, appliedDiscounts };
|
|
229
|
+
}
|
|
175
230
|
function buildStockUpdatePayload(product, qtyDelta) {
|
|
176
231
|
const lt = (product.listingType ?? "standard");
|
|
177
232
|
const rule = getListingRule(lt);
|
|
@@ -313,50 +368,15 @@ async function createOrderForGroup(group, orderType, ctx) {
|
|
|
313
368
|
const whatsappNotifyFee = computeWhatsAppNotifyFee(whatsappNotifyAddon, commissions);
|
|
314
369
|
const giftWrapFee = computeGiftWrapFee(giftWrapAddon, commissions);
|
|
315
370
|
const shipmentProtectionFee = computeShipmentProtectionFee(groupTotal, shipmentProtectionAddon, commissions);
|
|
316
|
-
let couponDiscount = 0;
|
|
317
|
-
const appliedDiscounts = [];
|
|
318
371
|
const groupCouponCodes = new Set();
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (coupon.applicableItemIds?.length) {
|
|
326
|
-
const eligibleTotal = group
|
|
327
|
-
.filter(({ item }) => coupon.applicableItemIds.includes(item.itemId))
|
|
328
|
-
.reduce((s, { item }) => s + item.price * item.quantity, 0);
|
|
329
|
-
couponGroupDiscount =
|
|
330
|
-
eligibleTotal > 0
|
|
331
|
-
? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
|
|
332
|
-
: 0;
|
|
333
|
-
}
|
|
334
|
-
else {
|
|
335
|
-
couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
else {
|
|
339
|
-
if (cartSubtotal > 0) {
|
|
340
|
-
couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
if (couponGroupDiscount > 0) {
|
|
344
|
-
couponDiscount += couponGroupDiscount;
|
|
345
|
-
appliedDiscounts.push({
|
|
346
|
-
code: coupon.code,
|
|
347
|
-
couponId: coupon.couponId,
|
|
348
|
-
type: "coupon",
|
|
349
|
-
discountAmount: couponGroupDiscount,
|
|
350
|
-
scope: coupon.scope,
|
|
351
|
-
storeId: coupon.storeId,
|
|
352
|
-
});
|
|
353
|
-
if (coupon.couponId) {
|
|
354
|
-
accumulateCouponUsage(couponUsageAccumulator, coupon.code, coupon.couponId, couponGroupDiscount);
|
|
355
|
-
groupCouponCodes.add(coupon.code);
|
|
356
|
-
}
|
|
372
|
+
const groupLines = group.map(({ item }) => ({ itemId: item.itemId, lineTotal: item.price * item.quantity }));
|
|
373
|
+
const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
374
|
+
for (const discount of appliedDiscounts) {
|
|
375
|
+
if (discount.couponId) {
|
|
376
|
+
accumulateCouponUsage(couponUsageAccumulator, discount.code, discount.couponId, discount.discountAmount);
|
|
377
|
+
groupCouponCodes.add(discount.code);
|
|
357
378
|
}
|
|
358
379
|
}
|
|
359
|
-
couponDiscount = Math.min(couponDiscount, groupTotal);
|
|
360
380
|
const orderTotal = Math.max(0, groupTotal - couponDiscount) + shippingFee + codHandlingFee + whatsappNotifyFee + giftWrapFee + shipmentProtectionFee + (emiSchedule?.surchargeAmount ?? 0) + (gstBreakdown?.gstAmount ?? 0);
|
|
361
381
|
const imageUrls = [
|
|
362
382
|
...new Set(group
|
|
@@ -733,6 +753,105 @@ export async function createCheckoutOrderAction(input) {
|
|
|
733
753
|
...(unavailable.length > 0 ? { unavailableItems: unavailable } : {}),
|
|
734
754
|
};
|
|
735
755
|
}
|
|
756
|
+
const EMPTY_PRICING_PREVIEW = {
|
|
757
|
+
subtotal: 0,
|
|
758
|
+
shippingFee: 0,
|
|
759
|
+
codHandlingFee: 0,
|
|
760
|
+
whatsappNotifyFee: 0,
|
|
761
|
+
giftWrapFee: 0,
|
|
762
|
+
shipmentProtectionFee: 0,
|
|
763
|
+
gstAmount: 0,
|
|
764
|
+
couponDiscount: 0,
|
|
765
|
+
total: 0,
|
|
766
|
+
};
|
|
767
|
+
/**
|
|
768
|
+
* Read-only "what will I actually be charged" preview for the checkout
|
|
769
|
+
* Order Summary. Reuses the exact same building blocks
|
|
770
|
+
* createCheckoutOrderAction / verifyAndPlaceRazorpayOrderAction use when an
|
|
771
|
+
* order is actually placed — resolveShippingCost, the compute*Fee helpers,
|
|
772
|
+
* computeGroupCouponDiscount, and the GST calc — so the number shown to the
|
|
773
|
+
* buyer can never diverge from what gets charged/recorded. Never decrements
|
|
774
|
+
* stock, creates an order, or writes coupon usage.
|
|
775
|
+
*/
|
|
776
|
+
export async function previewCheckoutPricing(input) {
|
|
777
|
+
const { userId: uid, addressId, paymentMethod, excludedProductIds = [], whatsappNotifyAddon = false, giftWrapAddon = false, shipmentProtectionAddon = false, } = input;
|
|
778
|
+
const siteSettings = await siteSettingsRepository.getSingleton();
|
|
779
|
+
const commissions = siteSettings?.commissions ?? CHECKOUT_DEFAULT_COMMISSIONS;
|
|
780
|
+
const cart = await unitOfWork.carts.getOrCreate(uid);
|
|
781
|
+
const excludedSet = new Set(excludedProductIds);
|
|
782
|
+
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)));
|
|
784
|
+
if (cartItems.length === 0)
|
|
785
|
+
return EMPTY_PRICING_PREVIEW;
|
|
786
|
+
// Best-effort product lookup — a preview never throws on a since-unpublished
|
|
787
|
+
// product, it just falls back to the item's cart-snapshot price for that
|
|
788
|
+
// line (same fallback cartRepository.getSubtotal() implicitly relies on).
|
|
789
|
+
const uniqueProductIds = [...new Set(cartItems.map((item) => item.productId))];
|
|
790
|
+
const productDocs = await Promise.all(uniqueProductIds.map((pid) => productRepository.findById(pid)));
|
|
791
|
+
const productById = new Map(uniqueProductIds.map((pid, i) => [pid, productDocs[i]]));
|
|
792
|
+
let resolvedAddress = null;
|
|
793
|
+
if (addressId) {
|
|
794
|
+
const addressDoc = await unitOfWork.addresses.findById(addressId);
|
|
795
|
+
resolvedAddress =
|
|
796
|
+
addressDoc && addressDoc.ownerType === "user" && addressDoc.ownerId === uid ? addressDoc : null;
|
|
797
|
+
}
|
|
798
|
+
const unitPriceFor = (item, product) => item.bundleCategorySlug && item.bundleProductIds?.length ? item.price : (product?.price ?? item.price);
|
|
799
|
+
const checks = cartItems.map((item) => ({ item, product: productById.get(item.productId) ?? null }));
|
|
800
|
+
const orderGroups = splitCartIntoOrderGroups(checks);
|
|
801
|
+
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 ?? [];
|
|
803
|
+
let shippingFee = 0;
|
|
804
|
+
let codHandlingFee = 0;
|
|
805
|
+
let whatsappNotifyFeeTotal = 0;
|
|
806
|
+
let giftWrapFeeTotal = 0;
|
|
807
|
+
let shipmentProtectionFeeTotal = 0;
|
|
808
|
+
let gstAmount = 0;
|
|
809
|
+
let couponDiscount = 0;
|
|
810
|
+
for (const { items: group } of orderGroups) {
|
|
811
|
+
const firstItem = group[0].item;
|
|
812
|
+
const groupTotal = group.reduce((sum, { item, product }) => sum + unitPriceFor(item, product) * item.quantity, 0);
|
|
813
|
+
const { shippingFee: groupShippingFee, storeState } = await resolveShippingCost(firstItem.storeId);
|
|
814
|
+
shippingFee += groupShippingFee;
|
|
815
|
+
// Mirrors createOrderForGroup exactly — the COD handling fee is only
|
|
816
|
+
// ever charged for literal "cod" (pay the courier), never upi_manual or
|
|
817
|
+
// cash (both pre-paid via UPI/bank transfer before dispatch).
|
|
818
|
+
if (paymentMethod === "cod")
|
|
819
|
+
codHandlingFee += computeCodHandlingFee(groupTotal, commissions);
|
|
820
|
+
whatsappNotifyFeeTotal += computeWhatsAppNotifyFee(whatsappNotifyAddon, commissions);
|
|
821
|
+
giftWrapFeeTotal += computeGiftWrapFee(giftWrapAddon, commissions);
|
|
822
|
+
shipmentProtectionFeeTotal += computeShipmentProtectionFee(groupTotal, shipmentProtectionAddon, commissions);
|
|
823
|
+
if (siteSettings?.gst?.enabled && resolvedAddress?.state) {
|
|
824
|
+
const intraState = !!storeState && storeState === resolvedAddress.state;
|
|
825
|
+
for (const { item, product } of group) {
|
|
826
|
+
const lineTotal = unitPriceFor(item, product) * item.quantity;
|
|
827
|
+
const rate = product?.gstRate ?? 0;
|
|
828
|
+
if (rate > 0)
|
|
829
|
+
gstAmount += calculateGst(rate, intraState, lineTotal).gstAmount;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
|
|
833
|
+
const { couponDiscount: groupCouponDiscount } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
834
|
+
couponDiscount += groupCouponDiscount;
|
|
835
|
+
}
|
|
836
|
+
const total = Math.max(0, cartSubtotal - couponDiscount) +
|
|
837
|
+
shippingFee +
|
|
838
|
+
codHandlingFee +
|
|
839
|
+
whatsappNotifyFeeTotal +
|
|
840
|
+
giftWrapFeeTotal +
|
|
841
|
+
shipmentProtectionFeeTotal +
|
|
842
|
+
gstAmount;
|
|
843
|
+
return {
|
|
844
|
+
subtotal: cartSubtotal,
|
|
845
|
+
shippingFee,
|
|
846
|
+
codHandlingFee,
|
|
847
|
+
whatsappNotifyFee: whatsappNotifyFeeTotal,
|
|
848
|
+
giftWrapFee: giftWrapFeeTotal,
|
|
849
|
+
shipmentProtectionFee: shipmentProtectionFeeTotal,
|
|
850
|
+
gstAmount,
|
|
851
|
+
couponDiscount,
|
|
852
|
+
total,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
736
855
|
/**
|
|
737
856
|
* Mark an order as paid after a payment provider verifies the transaction.
|
|
738
857
|
* Consumers call this from /api/payment/verify or a payment webhook.
|
|
@@ -864,43 +983,8 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
|
|
|
864
983
|
// of re-implementing it inline — was two sequential findById calls per
|
|
865
984
|
// seller group here, duplicated from resolveShippingCost.
|
|
866
985
|
const { shippingFee, storeOwnerId } = await resolveShippingCost(firstItem.storeId);
|
|
867
|
-
|
|
868
|
-
const appliedDiscounts =
|
|
869
|
-
for (const coupon of appliedCoupons) {
|
|
870
|
-
let couponGroupDiscount = 0;
|
|
871
|
-
const isSellerScoped = coupon.scope === "seller" && coupon.storeId;
|
|
872
|
-
if (isSellerScoped) {
|
|
873
|
-
if (coupon.storeId !== firstItem.storeId)
|
|
874
|
-
continue;
|
|
875
|
-
if (coupon.applicableItemIds?.length) {
|
|
876
|
-
const eligibleTotal = group
|
|
877
|
-
.filter(({ item }) => coupon.applicableItemIds.includes(item.itemId))
|
|
878
|
-
.reduce((s, { item, product }) => s + unitPriceFor(item, product) * item.quantity, 0);
|
|
879
|
-
couponGroupDiscount =
|
|
880
|
-
eligibleTotal > 0
|
|
881
|
-
? Math.min(roundRupees((eligibleTotal / groupTotal) * coupon.discountAmount), eligibleTotal)
|
|
882
|
-
: 0;
|
|
883
|
-
}
|
|
884
|
-
else {
|
|
885
|
-
couponGroupDiscount = Math.min(coupon.discountAmount, groupTotal);
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
else if (cartSubtotal > 0) {
|
|
889
|
-
couponGroupDiscount = Math.min(roundRupees((groupTotal / cartSubtotal) * coupon.discountAmount), groupTotal);
|
|
890
|
-
}
|
|
891
|
-
if (couponGroupDiscount > 0) {
|
|
892
|
-
couponDiscount += couponGroupDiscount;
|
|
893
|
-
appliedDiscounts.push({
|
|
894
|
-
code: coupon.code,
|
|
895
|
-
couponId: coupon.couponId,
|
|
896
|
-
type: "coupon",
|
|
897
|
-
discountAmount: couponGroupDiscount,
|
|
898
|
-
scope: coupon.scope,
|
|
899
|
-
storeId: coupon.storeId,
|
|
900
|
-
});
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
couponDiscount = Math.min(couponDiscount, groupTotal);
|
|
986
|
+
const groupLines = group.map(({ item, product }) => ({ itemId: item.itemId, lineTotal: unitPriceFor(item, product) * item.quantity }));
|
|
987
|
+
const { couponDiscount, appliedDiscounts } = computeGroupCouponDiscount(appliedCoupons, groupLines, groupTotal, cartSubtotal, firstItem.storeId);
|
|
904
988
|
const rawPlatformFee = roundRupees(groupTotal * (platformFeePercent / 100));
|
|
905
989
|
const gstOnFee = roundRupees(rawPlatformFee * (gstPercent / 100));
|
|
906
990
|
const platformFee = rawPlatformFee + gstOnFee;
|
|
@@ -1165,7 +1249,14 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1165
1249
|
}, 0);
|
|
1166
1250
|
const expectedPlatformFee = roundRupees(cartSubtotalRs * (platformFeePercent / 100));
|
|
1167
1251
|
const expectedGstOnFee = roundRupees(expectedPlatformFee * (gstPercent / 100));
|
|
1168
|
-
|
|
1252
|
+
// Same per-seller-group shipping sum /api/payment/create-order charged
|
|
1253
|
+
// upfront (and the same resolveShippingCost createRazorpayGroupOrder
|
|
1254
|
+
// below will use to build each order's recorded totalPrice) — keeps this
|
|
1255
|
+
// floor check from passing an amount that's short by the shipping fee.
|
|
1256
|
+
const expectedShippingGroups = splitCartIntoOrderGroups(bucketedPaid.available);
|
|
1257
|
+
const expectedShippingFees = await Promise.all(expectedShippingGroups.map((g) => resolveShippingCost(g.items[0].item.storeId)));
|
|
1258
|
+
const expectedShippingFee = expectedShippingFees.reduce((sum, r) => sum + r.shippingFee, 0);
|
|
1259
|
+
const expectedPaymentAmountRs = cartSubtotalRs + expectedPlatformFee + expectedGstOnFee + expectedShippingFee;
|
|
1169
1260
|
const rzpOrderRecord = await fetchRazorpayOrder(razorpay_order_id);
|
|
1170
1261
|
const paidAmountRs = paiseToRupees(rzpOrderRecord.amount);
|
|
1171
1262
|
if (paidAmountRs < expectedPaymentAmountRs - 1) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { createCheckoutOrderAction, attachPaymentAction, verifyAndPlaceRazorpayOrderAction, type CreateCheckoutOrderInput, type VerifyAndPlaceRazorpayOrderInput, } from "./actions";
|
|
1
|
+
export { createCheckoutOrderAction, attachPaymentAction, verifyAndPlaceRazorpayOrderAction, previewCheckoutPricing, resolveShippingCost, type CreateCheckoutOrderInput, type VerifyAndPlaceRazorpayOrderInput, type CheckoutPricingPreviewInput, type CheckoutPricingPreview, } from "./actions";
|
|
2
2
|
export { formatShippingAddress, type CheckoutOrderResult, } from "./data";
|
|
3
3
|
export { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_PAYMENT_METHODS, type CheckoutPaymentMethod, } from "../../../shared/features/checkout/config";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { createCheckoutOrderAction, attachPaymentAction, verifyAndPlaceRazorpayOrderAction, } from "./actions";
|
|
1
|
+
export { createCheckoutOrderAction, attachPaymentAction, verifyAndPlaceRazorpayOrderAction, previewCheckoutPricing, resolveShippingCost, } from "./actions";
|
|
2
2
|
export { formatShippingAddress, } from "./data";
|
|
3
3
|
export { CHECKOUT_DEFAULT_COMMISSIONS, CHECKOUT_PAYMENT_METHODS, } from "../../../shared/features/checkout/config";
|
|
@@ -18,6 +18,18 @@ export interface ListGroupedListingsParams {
|
|
|
18
18
|
export declare function listGroupedListings(params?: ListGroupedListingsParams): Promise<GroupedListingDocument[]>;
|
|
19
19
|
/** Featured grouped listings for homepage strips. */
|
|
20
20
|
export declare const listFeaturedGroupedListings: (limit?: number) => Promise<GroupedListingDocument[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Groups a product belongs to that are eligible to render publicly —
|
|
23
|
+
* powers the "You might also like" / "Same character" / etc. carousel
|
|
24
|
+
* on product-based listing detail pages. Deduped per request.
|
|
25
|
+
*/
|
|
26
|
+
export declare const getGroupsForProduct: (productId: string) => Promise<GroupedListingDocument[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Groups a product belongs to, each hydrated with its OTHER member products
|
|
29
|
+
* (the current product is excluded from its own group's item list) — ready
|
|
30
|
+
* to render directly as a public GroupedListingsCarousel section.
|
|
31
|
+
*/
|
|
32
|
+
export declare const getGroupsWithItemsForProduct: (productId: string) => Promise<GroupedListingWithItems[]>;
|
|
21
33
|
export interface SitemapGroupedListing {
|
|
22
34
|
slug: string;
|
|
23
35
|
updatedAt: Date;
|
|
@@ -3,7 +3,7 @@ import { getAdminDb } from "../../../../providers/db-firebase";
|
|
|
3
3
|
import { normalizeError } from "../../../../errors/normalize";
|
|
4
4
|
import { serverLogger } from "../../../../monitoring/server-logger";
|
|
5
5
|
import { GROUPED_LISTINGS_COLLECTION, } from "../../../../features/grouped/schemas/firestore";
|
|
6
|
-
import { productRepository } from "../../../../repositories";
|
|
6
|
+
import { productRepository, groupedListingsRepository } from "../../../../repositories";
|
|
7
7
|
import { PRODUCT_FIELDS, CATEGORY_FIELDS } from "../../../../constants/field-names";
|
|
8
8
|
import { GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
|
|
9
9
|
function mapDoc(doc) {
|
|
@@ -107,6 +107,31 @@ export async function listGroupedListings(params = {}) {
|
|
|
107
107
|
export const listFeaturedGroupedListings = cache(async (limit = GROUPED_LISTINGS_FEATURED_LIMIT) => {
|
|
108
108
|
return listGroupedListings({ featuredOnly: true, limit });
|
|
109
109
|
});
|
|
110
|
+
/**
|
|
111
|
+
* Groups a product belongs to that are eligible to render publicly —
|
|
112
|
+
* powers the "You might also like" / "Same character" / etc. carousel
|
|
113
|
+
* on product-based listing detail pages. Deduped per request.
|
|
114
|
+
*/
|
|
115
|
+
export const getGroupsForProduct = cache(async (productId) => {
|
|
116
|
+
return groupedListingsRepository.findByProductId(productId).catch((err) => {
|
|
117
|
+
void normalizeError(err);
|
|
118
|
+
serverLogger.warn("grouped-data: getGroupsForProduct failed — returning empty", { productId, error: err instanceof Error ? err.message : String(err) });
|
|
119
|
+
return [];
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* Groups a product belongs to, each hydrated with its OTHER member products
|
|
124
|
+
* (the current product is excluded from its own group's item list) — ready
|
|
125
|
+
* to render directly as a public GroupedListingsCarousel section.
|
|
126
|
+
*/
|
|
127
|
+
export const getGroupsWithItemsForProduct = cache(async (productId) => {
|
|
128
|
+
const groups = await getGroupsForProduct(productId);
|
|
129
|
+
return Promise.all(groups.map(async (group) => {
|
|
130
|
+
const otherIds = group.productIds.filter((id) => id !== productId);
|
|
131
|
+
const items = await Promise.all(otherIds.map((id) => productRepository.findByIdOrSlug(id).catch(() => null)));
|
|
132
|
+
return { ...group, items: items.filter((p) => p !== null) };
|
|
133
|
+
}));
|
|
134
|
+
});
|
|
110
135
|
export async function listSitemapGroupedListings() {
|
|
111
136
|
try {
|
|
112
137
|
const db = getAdminDb();
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, type GroupedListingWithItems, type ListGroupedListingsParams, type SitemapGroupedListing, } from "./data";
|
|
1
|
+
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, getGroupsForProduct, getGroupsWithItemsForProduct, type GroupedListingWithItems, type ListGroupedListingsParams, type SitemapGroupedListing, } from "./data";
|
|
2
2
|
export { GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, } from "./data";
|
|
1
|
+
export { getGroupedListingForDetail, getGroupedListingWithItems, listGroupedListings, listFeaturedGroupedListings, listSitemapGroupedListings, getGroupsForProduct, getGroupsWithItemsForProduct, } from "./data";
|
|
2
2
|
export { GROUPED_LISTINGS_PAGE_SIZE, GROUPED_LISTINGS_FEATURED_LIMIT, GROUPED_LISTINGS_SITEMAP_LIMIT, } from "../../../shared/features/grouped/config";
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { ProductDocument } from "../../../../features/products/schemas/firestore";
|
|
2
2
|
import type { ListingType } from "../../../../features/products/types";
|
|
3
|
+
import type { ProductItem } from "../../../../features/products/types";
|
|
4
|
+
import type { FirestoreDocument } from "../../../../schemas/types";
|
|
5
|
+
import type { Review } from "../../../../features/reviews/types";
|
|
3
6
|
/** Fetch a single product by slug or id, deduped per request. */
|
|
4
7
|
export declare const getProductForDetail: (slugOrId: string) => Promise<ProductDocument | null>;
|
|
5
8
|
/** Fetch the first page of approved reviews for a product, deduped per request. */
|
|
6
9
|
export declare const getReviewsForProduct: (productId: string) => Promise<unknown[]>;
|
|
10
|
+
/** Firestore review doc → client Review shape. Shared by every listing-type detail page's reviews tab. */
|
|
11
|
+
export declare function toReview(doc: FirestoreDocument): Review;
|
|
12
|
+
/** Fetch + map approved reviews for a product straight to the client Review shape, deduped per request. */
|
|
13
|
+
export declare const getReviewItemsForProduct: (productId: string) => Promise<Review[]>;
|
|
7
14
|
/** Minimal product fields needed for sitemap generation. */
|
|
8
15
|
export interface SitemapProduct {
|
|
9
16
|
slugOrId: string;
|
|
@@ -13,3 +20,19 @@ export interface SitemapProduct {
|
|
|
13
20
|
}
|
|
14
21
|
/** List all published products for sitemap generation. */
|
|
15
22
|
export declare function listSitemapProducts(): Promise<SitemapProduct[]>;
|
|
23
|
+
export interface RelatedItemsResult {
|
|
24
|
+
relatedItems: ProductItem[];
|
|
25
|
+
relatedByBrand: ProductItem[];
|
|
26
|
+
relatedByTags: ProductItem[];
|
|
27
|
+
relatedByStore: ProductItem[];
|
|
28
|
+
}
|
|
29
|
+
/** Firestore doc → card-grid shape. Shared by every related/similar-items carousel. */
|
|
30
|
+
export declare function toProductItem(doc: FirestoreDocument): ProductItem;
|
|
31
|
+
/**
|
|
32
|
+
* Same 4-signal related-items computation used across every listing-type
|
|
33
|
+
* detail page (category / brand / tag-overlap / same-store), each excluding
|
|
34
|
+
* invalid states for that item's own listing type. Extracted from
|
|
35
|
+
* ProductDetailPageView so pre-order/prize-draw/classified/digital-code/live
|
|
36
|
+
* pages can reuse it instead of having no related section at all.
|
|
37
|
+
*/
|
|
38
|
+
export declare const computeRelatedItems: (product: ProductDocument) => Promise<RelatedItemsResult>;
|
|
@@ -7,6 +7,7 @@ import { serverLogger } from "../../../../monitoring/server-logger";
|
|
|
7
7
|
import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore";
|
|
8
8
|
import { PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|
|
9
9
|
import { PRODUCT_FIELDS } from "../../../../constants/field-names";
|
|
10
|
+
import { SIEVE_OP, sieveAnd, sieveFilter, sortBy } from "../../../../utils/sieve-builder";
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Request-scoped cache — each function is deduplicated per RSC render tree.
|
|
12
13
|
// Both generateMetadata() and the page component get the same promise.
|
|
@@ -23,6 +24,38 @@ export const getProductForDetail = cache(async (slugOrId) => {
|
|
|
23
24
|
export const getReviewsForProduct = cache(async (productId) => {
|
|
24
25
|
return reviewRepository.findApprovedByProduct(productId).catch(() => []);
|
|
25
26
|
});
|
|
27
|
+
/** Firestore review doc → client Review shape. Shared by every listing-type detail page's reviews tab. */
|
|
28
|
+
export function toReview(doc) {
|
|
29
|
+
const images = Array.isArray(doc.images) ? doc.images : undefined;
|
|
30
|
+
const createdAt = doc.createdAt instanceof Date
|
|
31
|
+
? doc.createdAt.toISOString()
|
|
32
|
+
: typeof doc.createdAt === "string"
|
|
33
|
+
? doc.createdAt
|
|
34
|
+
: undefined;
|
|
35
|
+
const rawRating = typeof doc.rating === "number" ? doc.rating : 3;
|
|
36
|
+
const rating = Math.min(5, Math.max(1, Math.round(rawRating)));
|
|
37
|
+
return {
|
|
38
|
+
id: String(doc.id ?? ""),
|
|
39
|
+
productId: String(doc.productId ?? ""),
|
|
40
|
+
userId: String(doc.userId ?? ""),
|
|
41
|
+
userName: String(doc.userName ?? "Anonymous"),
|
|
42
|
+
userAvatar: typeof doc.userAvatar === "string" ? doc.userAvatar : undefined,
|
|
43
|
+
rating,
|
|
44
|
+
title: typeof doc.title === "string" ? doc.title : undefined,
|
|
45
|
+
comment: typeof doc.comment === "string" ? doc.comment : undefined,
|
|
46
|
+
images,
|
|
47
|
+
status: doc.status ?? "approved",
|
|
48
|
+
helpfulCount: typeof doc.helpfulCount === "number" ? doc.helpfulCount : undefined,
|
|
49
|
+
verified: doc.verified === true,
|
|
50
|
+
featured: doc.featured === true,
|
|
51
|
+
createdAt,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Fetch + map approved reviews for a product straight to the client Review shape, deduped per request. */
|
|
55
|
+
export const getReviewItemsForProduct = cache(async (productId) => {
|
|
56
|
+
const docs = await reviewRepository.findApprovedByProduct(productId).catch(() => []);
|
|
57
|
+
return docs.map(toReview);
|
|
58
|
+
});
|
|
26
59
|
/** List all published products for sitemap generation. */
|
|
27
60
|
export async function listSitemapProducts() {
|
|
28
61
|
try {
|
|
@@ -54,3 +87,129 @@ export async function listSitemapProducts() {
|
|
|
54
87
|
return [];
|
|
55
88
|
}
|
|
56
89
|
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Related items — same 4-signal computation shared by every listing-type
|
|
92
|
+
// detail page (category / brand / tag-overlap / same-store).
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
const RELATED_QUERY_PAGE_SIZE = 9;
|
|
95
|
+
const RELATED_DISPLAY_LIMIT = 8;
|
|
96
|
+
/** Firestore doc → card-grid shape. Shared by every related/similar-items carousel. */
|
|
97
|
+
export function toProductItem(doc) {
|
|
98
|
+
return {
|
|
99
|
+
id: String(doc.id ?? ""),
|
|
100
|
+
title: String(doc.title ?? doc.name ?? ""),
|
|
101
|
+
price: typeof doc.price === "number" ? doc.price : 0,
|
|
102
|
+
originalPrice: typeof doc.originalPrice === "number" ? doc.originalPrice : undefined,
|
|
103
|
+
mainImage: Array.isArray(doc.images)
|
|
104
|
+
? doc.images[0]
|
|
105
|
+
: typeof doc.mainImage === "string"
|
|
106
|
+
? doc.mainImage
|
|
107
|
+
: undefined,
|
|
108
|
+
status: doc.status ?? "published",
|
|
109
|
+
slug: typeof doc.slug === "string" ? doc.slug : undefined,
|
|
110
|
+
storeName: typeof doc.storeName === "string" ? doc.storeName : undefined,
|
|
111
|
+
rating: typeof doc.rating === "number" ? doc.rating : undefined,
|
|
112
|
+
reviewCount: typeof doc.reviewCount === "number" ? doc.reviewCount : undefined,
|
|
113
|
+
// Without this, every related-item card silently linked to the standard
|
|
114
|
+
// PDP regardless of its real type (pluginFor() defaults to "standard"
|
|
115
|
+
// when listingType is missing) — same bug class as the Phase 1 routing
|
|
116
|
+
// fix, one hop further downstream in the related-items card link path.
|
|
117
|
+
listingType: doc.listingType ?? undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Excludes the current product plus any listing-type-specific "invalid to
|
|
122
|
+
* surface as related" state: sold/archived/out-of-stock/draft, ended
|
|
123
|
+
* auctions, closed prize-draws, sold-out pre-orders, depleted digital-code
|
|
124
|
+
* pools.
|
|
125
|
+
*/
|
|
126
|
+
function isValidRelatedItem(r, currentProductId, now) {
|
|
127
|
+
if (r.id === currentProductId)
|
|
128
|
+
return false;
|
|
129
|
+
const s = r.status;
|
|
130
|
+
if (s && ["sold", "out_of_stock", "archived", "discontinued", "draft"].includes(s))
|
|
131
|
+
return false;
|
|
132
|
+
if (r.isSold === true)
|
|
133
|
+
return false;
|
|
134
|
+
if (r.availableQuantity === 0)
|
|
135
|
+
return false;
|
|
136
|
+
if (r.listingType === "auction" && r.auctionEndDate) {
|
|
137
|
+
const end = r.auctionEndDate;
|
|
138
|
+
const endDate = typeof end.toDate === "function"
|
|
139
|
+
? end.toDate()
|
|
140
|
+
: end instanceof Date
|
|
141
|
+
? end
|
|
142
|
+
: new Date(String(end));
|
|
143
|
+
if (endDate <= now)
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (r.listingType === "prize-draw" && r.prizeRevealStatus === "closed")
|
|
147
|
+
return false;
|
|
148
|
+
if (r.listingType === "pre-order" &&
|
|
149
|
+
typeof r.preOrderMaxQuantity === "number" &&
|
|
150
|
+
typeof r.preOrderCurrentCount === "number" &&
|
|
151
|
+
r.preOrderCurrentCount >= r.preOrderMaxQuantity) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (r.listingType === "digital-code" && r.codesAvailable === 0)
|
|
155
|
+
return false;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
function toRelatedItems(docs, currentProductId) {
|
|
159
|
+
const now = new Date();
|
|
160
|
+
return docs
|
|
161
|
+
.filter((r) => isValidRelatedItem(r, currentProductId, now))
|
|
162
|
+
.slice(0, RELATED_DISPLAY_LIMIT)
|
|
163
|
+
.map(toProductItem);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Same 4-signal related-items computation used across every listing-type
|
|
167
|
+
* detail page (category / brand / tag-overlap / same-store), each excluding
|
|
168
|
+
* invalid states for that item's own listing type. Extracted from
|
|
169
|
+
* ProductDetailPageView so pre-order/prize-draw/classified/digital-code/live
|
|
170
|
+
* pages can reuse it instead of having no related section at all.
|
|
171
|
+
*/
|
|
172
|
+
export const computeRelatedItems = cache(async (product) => {
|
|
173
|
+
const p = product;
|
|
174
|
+
const primaryCategorySlug = Array.isArray(p.categorySlugs) ? p.categorySlugs[0] : undefined;
|
|
175
|
+
const brand = typeof p.brand === "string" ? p.brand : undefined;
|
|
176
|
+
const tags = Array.isArray(p.tags) ? p.tags : [];
|
|
177
|
+
const storeId = typeof p.storeId === "string" ? p.storeId : undefined;
|
|
178
|
+
const currentProductId = product.id;
|
|
179
|
+
const [sameCategoryDocs, sameBrandDocs, tagOverlapDocs, sameStoreDocs] = await Promise.all([
|
|
180
|
+
primaryCategorySlug
|
|
181
|
+
? productRepository.findByCategory(primaryCategorySlug).catch(() => [])
|
|
182
|
+
: Promise.resolve([]),
|
|
183
|
+
brand
|
|
184
|
+
? productRepository
|
|
185
|
+
.list({
|
|
186
|
+
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("brand", SIEVE_OP.EQ, brand)),
|
|
187
|
+
sorts: sortBy("createdAt", "DESC"),
|
|
188
|
+
page: 1,
|
|
189
|
+
pageSize: RELATED_QUERY_PAGE_SIZE,
|
|
190
|
+
})
|
|
191
|
+
.then((r) => r.items)
|
|
192
|
+
.catch(() => [])
|
|
193
|
+
: Promise.resolve([]),
|
|
194
|
+
tags.length > 0
|
|
195
|
+
? productRepository.findByTagsOverlap(tags, RELATED_QUERY_PAGE_SIZE).catch(() => [])
|
|
196
|
+
: Promise.resolve([]),
|
|
197
|
+
storeId
|
|
198
|
+
? productRepository
|
|
199
|
+
.list({
|
|
200
|
+
filters: sieveAnd(sieveFilter("status", SIEVE_OP.EQ, "published"), sieveFilter("storeId", SIEVE_OP.EQ, storeId)),
|
|
201
|
+
sorts: sortBy("createdAt", "DESC"),
|
|
202
|
+
page: 1,
|
|
203
|
+
pageSize: RELATED_QUERY_PAGE_SIZE,
|
|
204
|
+
})
|
|
205
|
+
.then((r) => r.items)
|
|
206
|
+
.catch(() => [])
|
|
207
|
+
: Promise.resolve([]),
|
|
208
|
+
]);
|
|
209
|
+
return {
|
|
210
|
+
relatedItems: toRelatedItems(sameCategoryDocs, currentProductId),
|
|
211
|
+
relatedByBrand: toRelatedItems(sameBrandDocs, currentProductId),
|
|
212
|
+
relatedByTags: toRelatedItems(tagOverlapDocs, currentProductId),
|
|
213
|
+
relatedByStore: toRelatedItems(sameStoreDocs, currentProductId),
|
|
214
|
+
};
|
|
215
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, type SitemapProduct, } from "./data";
|
|
1
|
+
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, type SitemapProduct, computeRelatedItems, toProductItem, toReview, getReviewItemsForProduct, type RelatedItemsResult, } from "./data";
|
|
2
2
|
export { assertProductOwnership, assertStatusTransition, assertInStock, effectivePrice, isAvailableForPurchase, } from "./service";
|
|
3
3
|
export { createProductAction, createAuctionAction, createPreOrderAction, updateProductAction, deleteProductAction, setProductStatusAction, setProductFeaturedAction, } from "./actions";
|
|
4
4
|
export { PRODUCTS_PAGE_SIZE, PRODUCTS_FEATURED_LIMIT, PRODUCTS_RELATED_LIMIT, PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, } from "./data";
|
|
1
|
+
export { getProductForDetail, getReviewsForProduct, listSitemapProducts, computeRelatedItems, toProductItem, toReview, getReviewItemsForProduct, } from "./data";
|
|
2
2
|
export { assertProductOwnership, assertStatusTransition, assertInStock, effectivePrice, isAvailableForPurchase, } from "./service";
|
|
3
3
|
export { createProductAction, createAuctionAction, createPreOrderAction, updateProductAction, deleteProductAction, setProductStatusAction, setProductFeaturedAction, } from "./actions";
|
|
4
4
|
export { PRODUCTS_PAGE_SIZE, PRODUCTS_FEATURED_LIMIT, PRODUCTS_RELATED_LIMIT, PRODUCTS_SITEMAP_LIMIT } from "../../../shared/features/products/config";
|
|
@@ -7,12 +7,13 @@ export function buildRobots({ siteUrl }) {
|
|
|
7
7
|
disallow: [
|
|
8
8
|
"/admin/",
|
|
9
9
|
"/api/", // audit-hardcoded-api-routes-ok: robots.txt disallow prefix, not an endpoint reference
|
|
10
|
-
"/
|
|
10
|
+
"/store/",
|
|
11
11
|
"/user/",
|
|
12
12
|
"/auth/",
|
|
13
13
|
"/checkout/",
|
|
14
14
|
"/cart/",
|
|
15
|
-
"/
|
|
15
|
+
"/wishlist",
|
|
16
|
+
"/preview/",
|
|
16
17
|
"/track/",
|
|
17
18
|
"/unauthorized/",
|
|
18
19
|
"/_next/",
|