@mohasinac/appkit 4.55.0 → 4.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +22 -0
- package/dist/_internal/server/features/checkout/actions.js +39 -3
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +124 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +8 -0
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/homepage/components/HeroCarousel.js +4 -2
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +22 -5
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/orders/schemas/firestore.d.ts +38 -0
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +102 -0
- package/dist/features/points/schemas/firestore.js +99 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +3 -1
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/styles.css +38 -6
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- package/package.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the buyer is quoted for shipping, what the seller actually spent, and
|
|
3
|
+
* who wears the difference.
|
|
4
|
+
*
|
|
5
|
+
* Pure and synchronous — no I/O, no Firestore, no provider. Every surface that
|
|
6
|
+
* touches a shipping figure resolves through here so the quote shown at
|
|
7
|
+
* checkout, the amount charged, the order record and the reconciliation can
|
|
8
|
+
* never disagree (Root Cause #75: a rule reachable by copy-paste WILL be
|
|
9
|
+
* copied, and the copy never learns about the branch added later).
|
|
10
|
+
*
|
|
11
|
+
* ## The model
|
|
12
|
+
*
|
|
13
|
+
* 1. Shiprocket returns per-courier rates for the buyer's pincode.
|
|
14
|
+
* 2. Each rate is ROUNDED UP to the nearest ₹50 and that is what the buyer
|
|
15
|
+
* sees and is charged. The raw rate is never shown and never billed.
|
|
16
|
+
* 3. The seller ships. If their courier is dearer than the charged figure they
|
|
17
|
+
* absorb the difference; if it is cheaper the buyer is credited the
|
|
18
|
+
* difference in points, 1 point = ₹1.
|
|
19
|
+
*
|
|
20
|
+
* ## 🛑 Reconciliation measures against the CHARGED figure, not the raw rate
|
|
21
|
+
*
|
|
22
|
+
* This is the decision that makes the whole thing coherent, and it is worth
|
|
23
|
+
* stating because the obvious alternative is defensible and wrong.
|
|
24
|
+
*
|
|
25
|
+
* Measuring against the raw rate would let the platform keep the round-up
|
|
26
|
+
* (₹70 raw → ₹100 charged → a silent ₹30 margin on every parcel) while still
|
|
27
|
+
* calling the ₹50 step a "rounding". Measuring against the charged figure
|
|
28
|
+
* means the buyer is made whole for every rupee they handed over that did not
|
|
29
|
+
* become postage: ₹100 charged, shipped at ₹47, 53 points back. The round-up
|
|
30
|
+
* stops being a margin and becomes a BUFFER that absorbs ordinary courier
|
|
31
|
+
* variance before the seller has to.
|
|
32
|
+
*
|
|
33
|
+
* It also makes the rule explainable in one sentence to both sides — "you pay
|
|
34
|
+
* the rounded quote and get back whatever we did not spend", "you keep the
|
|
35
|
+
* quote and cover any overage" — and a rule a seller can predict is a rule
|
|
36
|
+
* they do not dispute.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Shipping is always billed in whole ₹50 steps.
|
|
40
|
+
*
|
|
41
|
+
* 🛑 Not a formatting choice. A courier rate is a volatile per-parcel number
|
|
42
|
+
* (fuel, zone, weight slab, surcharge) and quoting it to the rupee invites a
|
|
43
|
+
* dispute every time the actual differs by ₹3. A coarse step makes the quote
|
|
44
|
+
* stable, makes it look like a price rather than a machine reading, and buys
|
|
45
|
+
* the headroom that keeps most orders off the reconciliation path entirely.
|
|
46
|
+
*/
|
|
47
|
+
export const SHIPPING_ROUND_UP_TO = 50;
|
|
48
|
+
/**
|
|
49
|
+
* Round a raw carrier rate up to the next ₹50.
|
|
50
|
+
*
|
|
51
|
+
* `Math.ceil`, so ₹70 → ₹100 and ₹100 → ₹100 — a rate exactly on a step is NOT
|
|
52
|
+
* pushed to the next one. ₹0 stays ₹0: free shipping must survive the rounding
|
|
53
|
+
* or every free-shipping order silently acquires a ₹50 charge.
|
|
54
|
+
*
|
|
55
|
+
* Negative input is clamped to 0 rather than rounded "up" toward zero, which
|
|
56
|
+
* would otherwise turn a bad provider response into a credit.
|
|
57
|
+
*/
|
|
58
|
+
export function roundShippingUp(rawRate) {
|
|
59
|
+
if (!Number.isFinite(rawRate) || rawRate <= 0)
|
|
60
|
+
return 0;
|
|
61
|
+
return Math.ceil(rawRate / SHIPPING_ROUND_UP_TO) * SHIPPING_ROUND_UP_TO;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Compare the charged quote against the real spend.
|
|
65
|
+
*
|
|
66
|
+
* 🛑 Works on the CHARGED figure (see the header). The worked examples from the
|
|
67
|
+
* spec, with the ₹70 Ekart quote billed at ₹100:
|
|
68
|
+
*
|
|
69
|
+
* shipped Delhivery ₹100 → sellerAbsorbs 0, buyerPoints 0
|
|
70
|
+
* shipped Delhivery ₹120 → sellerAbsorbs 20, buyerPoints 0
|
|
71
|
+
* shipped India Post ₹47 → sellerAbsorbs 0, buyerPoints 53
|
|
72
|
+
*
|
|
73
|
+
* Points are the EXACT rupee difference and are deliberately not rounded to a
|
|
74
|
+
* ₹50 step. The round-up exists to make a *quote* stable; applying it to a
|
|
75
|
+
* credit would either invent money (rounding up) or quietly keep some of the
|
|
76
|
+
* buyer's (rounding down).
|
|
77
|
+
*/
|
|
78
|
+
export function reconcileShipping(quote, actual) {
|
|
79
|
+
const charged = Number.isFinite(quote.chargedRate) ? Math.max(0, quote.chargedRate) : 0;
|
|
80
|
+
const spent = Number.isFinite(actual.actualRate) ? Math.max(0, actual.actualRate) : 0;
|
|
81
|
+
const delta = roundPaise(charged - spent);
|
|
82
|
+
return {
|
|
83
|
+
sellerAbsorbs: delta < 0 ? Math.abs(delta) : 0,
|
|
84
|
+
buyerPoints: delta > 0 ? delta : 0,
|
|
85
|
+
courierChanged: quote.courierId !== actual.courierId,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Two decimal places.
|
|
90
|
+
*
|
|
91
|
+
* Money here is decimal rupees (the paise→rupees migration, Root Cause #31), so
|
|
92
|
+
* a subtraction can leave a float artefact — `100 - 47.1` is `52.900000000000006`,
|
|
93
|
+
* and that would become a points credit with eleven decimal places on a user's
|
|
94
|
+
* balance.
|
|
95
|
+
*/
|
|
96
|
+
function roundPaise(value) {
|
|
97
|
+
return Math.round(value * 100) / 100;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Turn carrier serviceability results into offerable options.
|
|
101
|
+
*
|
|
102
|
+
* 🛑 Drops any courier whose rate is not a usable number instead of charging
|
|
103
|
+
* ₹0 for it. `resolveShippingCost` already carries this lesson in its
|
|
104
|
+
* `shippingUnavailable` flag: a zero there "would ship every parcel free at
|
|
105
|
+
* our expense, silently, until somebody noticed". A malformed row from a
|
|
106
|
+
* provider is missing data, never free postage.
|
|
107
|
+
*
|
|
108
|
+
* Sorted cheapest-first, then fastest — the buyer is choosing, so the default
|
|
109
|
+
* reading order should be the one most of them want.
|
|
110
|
+
*/
|
|
111
|
+
export function toQuoteOptions(couriers) {
|
|
112
|
+
return couriers
|
|
113
|
+
.filter((c) => Number.isFinite(c.rate) && c.rate > 0 && Boolean(c.name))
|
|
114
|
+
.map((c) => ({
|
|
115
|
+
// Providers do not all return an id. Falling back to the name keeps the
|
|
116
|
+
// pick round-trippable; it is only ever compared against itself.
|
|
117
|
+
courierId: c.courierId ?? c.name,
|
|
118
|
+
courierName: c.name,
|
|
119
|
+
rawRate: c.rate,
|
|
120
|
+
chargedRate: roundShippingUp(c.rate),
|
|
121
|
+
estimatedDays: c.estimatedDays,
|
|
122
|
+
}))
|
|
123
|
+
.sort((a, b) => a.chargedRate - b.chargedRate || a.estimatedDays - b.estimatedDays);
|
|
124
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -534,6 +534,7 @@ export { SELLER_LISTING_TABS, type SellerListingTabId, } from "./features/produc
|
|
|
534
534
|
export { ALL_TAB, EMPTY_TAB, ADMIN_PRODUCT_STATUS_TABS, ADMIN_PRODUCT_LISTING_TYPE_TABS, ADMIN_BLOG_STATUS_TABS, ADMIN_USER_STATUS_TABS, ADMIN_USER_ROLE_TABS, ADMIN_STORE_STATUS_TABS, ADMIN_PAYOUT_STATUS_TABS, ADMIN_ORDER_STATUS_TABS, ADMIN_REVIEW_STATUS_TABS, ADMIN_REVIEW_RATING_TABS, ADMIN_BID_STATUS_TABS, ADMIN_CONTACT_STATUS_TABS, ADMIN_NEWSLETTER_STATUS_TABS, ADMIN_EVENT_ENTRY_STATUS_TABS, ADMIN_EVENT_STATUS_TABS, ADMIN_CART_OWNERSHIP_TABS, ADMIN_COUPON_TYPE_TABS, SELLER_PRODUCT_STATUS_TABS, SELLER_AUCTION_STATUS_TABS, SELLER_ORDER_STATUS_TABS, SELLER_OFFER_STATUS_TABS, SELLER_BID_STATUS_TABS, } from "./features/admin/constants/filter-tabs";
|
|
535
535
|
export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS, BID_FIELDS, AD_FIELDS, EVENT_FIELDS, EVENT_ENTRY_FIELDS, PAYOUT_FIELDS, STORE_FIELDS, CATEGORY_FIELDS, BLOG_FIELDS, USER_FIELDS, ADDRESS_FIELDS, BRAND_FIELDS, CART_FIELDS, WISHLIST_FIELDS, HISTORY_FIELDS, NOTIFICATION_FIELDS, SESSION_FIELDS, COUPON_USAGE_FIELDS, SCAMMER_FIELDS, SUPPORT_TICKET_FIELDS, CAROUSEL_FIELDS, COUPON_FIELDS, FAQ_FIELDS, HOMEPAGE_SECTION_FIELDS, SITE_SETTINGS_FIELDS, COMMON_FIELDS, OAUTH_STATE_VALUES, SCHEMA_DEFAULTS, } from "./constants/field-names";
|
|
536
536
|
export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, type CartLane, type LaneAssignable, } from "./_internal/shared/checkout/lanes";
|
|
537
|
+
export { roundShippingUp, toQuoteOptions, reconcileShipping, SHIPPING_ROUND_UP_TO, type ShippingQuoteOption, type SelectedShippingQuote, type ActualShipmentCost, type ShippingReconciliation, } from "./_internal/shared/shipping/quote";
|
|
537
538
|
export { getCartLineMembers, isMultiMemberLine, } from "./_internal/shared/checkout/line-members";
|
|
538
539
|
export { newsletterSubscribeSchema, quickCreateTaxonomySchema, payoutMarkPaidSchema, } from "./features/admin/schemas/small-forms";
|
|
539
540
|
export { raffleEntrySchema } from "./features/contact/schemas/inquiry-forms";
|
package/dist/client.js
CHANGED
|
@@ -546,6 +546,14 @@ export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS
|
|
|
546
546
|
// Checkout lanes — the derived auction > offer > standard partition of the
|
|
547
547
|
// cart, and the priority rule that decides which one may be checked out.
|
|
548
548
|
export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, } from "./_internal/shared/checkout/lanes.js";
|
|
549
|
+
/*
|
|
550
|
+
* Shipping quote model. Public because the CONSUMER owns the route that asks
|
|
551
|
+
* the carrier for rates (`/api/shipping/quote`) and the checkout UI that
|
|
552
|
+
* renders them — the rounding rule has to be reachable from both, and it must
|
|
553
|
+
* be the same rule the server-side reconciliation uses, or the buyer is
|
|
554
|
+
* credited against a figure they were never charged.
|
|
555
|
+
*/
|
|
556
|
+
export { roundShippingUp, toQuoteOptions, reconcileShipping, SHIPPING_ROUND_UP_TO, } from "./_internal/shared/shipping/quote.js";
|
|
549
557
|
/*
|
|
550
558
|
* The cart page needs these to tell a multi-member line from an ordinary one: a
|
|
551
559
|
* group/bundle line's `productId` is a group or category id, not a product id,
|
|
@@ -43,6 +43,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
|
|
|
43
43
|
import { SublistingCarouselSection } from "../../products/components/SublistingCarouselSection.js";
|
|
44
44
|
import { isFinalSale } from "../../products/constants/final-sale.js";
|
|
45
45
|
import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
|
|
46
|
+
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
46
47
|
function toDescriptionHtml(raw) {
|
|
47
48
|
if (!raw)
|
|
48
49
|
return "";
|
|
@@ -235,6 +236,6 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
|
|
|
235
236
|
return (_jsx(CollapsibleBidHistory, { productId: String(product.id), initialData: bidsResult, currency: currency }));
|
|
236
237
|
}, renderRelated: () => {
|
|
237
238
|
const related = toRelatedAuctionCards(relatedDocs, product.id, new Date());
|
|
238
|
-
return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
|
|
239
|
+
return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
|
|
239
240
|
} }), renderAuctionStoreReviews(storeId), onPlaceBid && (_jsx(AuctionBottomActions, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow }))] })] }));
|
|
240
241
|
}
|
|
@@ -84,6 +84,21 @@ export interface UserDocument extends BaseDocument {
|
|
|
84
84
|
storeId?: string;
|
|
85
85
|
storeSlug?: string;
|
|
86
86
|
storeStatus?: "pending" | "approved" | "rejected";
|
|
87
|
+
/**
|
|
88
|
+
* Denormalised sum of this user's `pointsLedger` entries. 1 point = ₹1.
|
|
89
|
+
*
|
|
90
|
+
* 🛑 A MIRROR, and `pointsRepository` is the only thing allowed to write it —
|
|
91
|
+
* always in the same transaction as the ledger entry that moved it. Root
|
|
92
|
+
* Cause #42: a mirror maintained by more than one write path drifts, and this
|
|
93
|
+
* one guards money-shaped value, so drift either invents points or swallows
|
|
94
|
+
* earned ones.
|
|
95
|
+
*
|
|
96
|
+
* 🛑 Absence means "never computed", NOT zero. `getBalance` falls back to
|
|
97
|
+
* summing the ledger rather than answering 0, because every user document
|
|
98
|
+
* written before this field existed has no value here and answering zero
|
|
99
|
+
* would quietly deny people credits they hold.
|
|
100
|
+
*/
|
|
101
|
+
pointsBalance?: number;
|
|
87
102
|
isTester?: boolean;
|
|
88
103
|
canTestAdmin?: boolean;
|
|
89
104
|
publicProfile?: {
|
|
@@ -23,6 +23,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
|
|
|
23
23
|
import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
|
|
24
24
|
import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
|
|
25
25
|
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
26
|
+
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
26
27
|
function toDescriptionHtml(raw) {
|
|
27
28
|
if (!raw)
|
|
28
29
|
return "";
|
|
@@ -84,5 +85,5 @@ export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPr
|
|
|
84
85
|
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
85
86
|
storeName: storeName ?? undefined,
|
|
86
87
|
inStock: codesLeft > 0,
|
|
87
|
-
}) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
|
|
88
|
+
}) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
|
|
88
89
|
}
|
|
@@ -88,7 +88,7 @@ function SlideBackground({ bg, legacy, mobileLegacy, isMobile, priority, }) {
|
|
|
88
88
|
if (effectiveBg.type === "video") {
|
|
89
89
|
return (_jsxs(_Fragment, { children: [_jsx(MediaVideo, { src: src, thumbnailUrl: effectiveBg.thumbnail ?? legacy?.thumbnail, alt: "", autoPlayMuted: true, loop: true, controls: false }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
|
|
90
90
|
}
|
|
91
|
-
return (_jsxs(_Fragment, { children: [_jsx(MediaImage, { src: src, alt: "", size: "hero", priority: priority, fetchPriority: priority ? "high" : "low" }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
|
|
91
|
+
return (_jsxs(_Fragment, { children: [_jsx(MediaImage, { src: src, alt: "", size: "hero", objectFit: "cover", priority: priority, fetchPriority: priority ? "high" : "low" }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
|
|
92
92
|
}
|
|
93
93
|
/** Renders the background of a card (image, video, color, gradient). */
|
|
94
94
|
function CardBackground({ bg }) {
|
|
@@ -102,7 +102,9 @@ function CardBackground({ bg }) {
|
|
|
102
102
|
return (_jsx(Div, { className: POSITION_FILL, style: { background: `linear-gradient(${angle}deg, ${from}, ${to})` } }));
|
|
103
103
|
}
|
|
104
104
|
if (bg.type === "image" && bg.url) {
|
|
105
|
-
|
|
105
|
+
// `cover` for the same reason as the slide backdrop above — this sits
|
|
106
|
+
// behind the card's eyebrow/title/description, not beside them.
|
|
107
|
+
return _jsx(MediaImage, { src: bg.url, alt: "", size: "card", objectFit: "cover" });
|
|
106
108
|
}
|
|
107
109
|
return null;
|
|
108
110
|
}
|
|
@@ -17,6 +17,7 @@ import { getHomepageFaqsCached } from "../../faq/utils/getHomepageFaqsCached.js"
|
|
|
17
17
|
import { safeRead } from "../../../errors/safe-read.js";
|
|
18
18
|
import { fetchLiveStats } from "../lib/live-stats.js";
|
|
19
19
|
import { renderSection, AnnouncementBar } from "../lib/section-renderer.js";
|
|
20
|
+
import { gateCarouselSlides } from "../lib/slide-gating.js";
|
|
20
21
|
import { SECTION_COPY } from "../constants/section-copy.js";
|
|
21
22
|
import { homepageSectionsRepository } from "../repository/homepage-sections.repository.js";
|
|
22
23
|
import { isListingTypeEnabled, isCategoryTypeEnabled, } from "../../../_internal/shared/listing-types/feature-flags.js";
|
|
@@ -215,6 +216,20 @@ export async function MarketplaceHomepageView({ adSlots, newsletterFormSlot, onB
|
|
|
215
216
|
? eventsResult.items
|
|
216
217
|
: undefined,
|
|
217
218
|
};
|
|
218
|
-
|
|
219
|
+
/*
|
|
220
|
+
* The same "do not advertise what we do not sell" rule the section filter
|
|
221
|
+
* above applies, one level deeper.
|
|
222
|
+
*
|
|
223
|
+
* `enabledSections` is keyed on `SectionType`, so it can drop the auctions
|
|
224
|
+
* RAIL but is structurally blind to a carousel — a carousel is one section
|
|
225
|
+
* of type `carousel` whatever its slides happen to be about. With
|
|
226
|
+
* `auction: false` live, the hero still shipped a "Live auctions" card and a
|
|
227
|
+
* full "Win it at your price" slide, both pointing at `/auctions`.
|
|
228
|
+
*
|
|
229
|
+
* Gated on the LINK rather than the copy: "Win it at your price" names no
|
|
230
|
+
* type, so a keyword scan would miss the worse of the two offenders while
|
|
231
|
+
* eating innocent ones. See `slide-gating.ts`.
|
|
232
|
+
*/
|
|
233
|
+
const carouselSlides = gateCarouselSlides(toCarouselSlides(slides), siteSettings);
|
|
219
234
|
return (_jsx(Main, { children: _jsxs(Div, { className: "relative", children: [showAnnouncement ? (_jsx(TopBannerPortal, { children: _jsx(AnnouncementBar, { message: announcementMessage, link: siteSettings?.announcementBar?.link, onDismiss: onBannerDismiss }) })) : null, orderedSections.map((section) => renderSection(section, adSlots, newsletterFormSlot ?? null, faqItems, carouselSlides, liveStats, sectionData, brand))] }) }));
|
|
220
235
|
}
|
|
@@ -19,8 +19,13 @@ export interface WelcomeSectionProps {
|
|
|
19
19
|
onSecondaryCtaClick?: () => void;
|
|
20
20
|
trustChips?: WelcomeSectionChip[];
|
|
21
21
|
/**
|
|
22
|
-
* Decorative hero imagery
|
|
23
|
-
*
|
|
22
|
+
* Decorative hero imagery for the mosaic's SMALL cells — the large cell is
|
|
23
|
+
* always the brand panel now, so these no longer compete for it.
|
|
24
|
+
*
|
|
25
|
+
* 🛑 Pass REAL images only. A caller sourcing these from
|
|
26
|
+
* `display.coverImage` must drop `SEED_TILE_PREFIX` values first: those are
|
|
27
|
+
* six flat generated SVGs, and a hero built from them is four coloured
|
|
28
|
+
* rectangles claiming to be a product preview.
|
|
24
29
|
*
|
|
25
30
|
* Must be deterministic: `ImageMosaic` is explicit that `Math.random()` here
|
|
26
31
|
* would break SSR/hydration agreement, so the caller passes a stable
|
|
@@ -29,6 +34,14 @@ export interface WelcomeSectionProps {
|
|
|
29
34
|
heroImages?: readonly string[];
|
|
30
35
|
isLoading?: boolean;
|
|
31
36
|
brandLogoText?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Wordmark caption under the hero logo, e.g. "Let It Rip India".
|
|
39
|
+
*
|
|
40
|
+
* Consumer-supplied, never defaulted to a literal here: appkit is a library
|
|
41
|
+
* and a brand string baked into it is the encapsulation break
|
|
42
|
+
* `audit-ssr-in-appkit` Rule 3 exists to prevent. Omitted = no caption.
|
|
43
|
+
*/
|
|
44
|
+
brandCaption?: string;
|
|
32
45
|
className?: string;
|
|
33
46
|
}
|
|
34
|
-
export declare function WelcomeSection({ title, subtitle, pillLabel, showCTA, ctaLabel, ctaHref, onCtaClick, secondaryCtaLabel, secondaryCtaHref, onSecondaryCtaClick, trustChips, heroImages, isLoading, brandLogoText, className, }: WelcomeSectionProps): React.JSX.Element;
|
|
47
|
+
export declare function WelcomeSection({ title, subtitle, pillLabel, showCTA, ctaLabel, ctaHref, onCtaClick, secondaryCtaLabel, secondaryCtaHref, onSecondaryCtaClick, trustChips, heroImages, isLoading, brandLogoText, brandCaption, className, }: WelcomeSectionProps): React.JSX.Element;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { THEMED_TEXT_SECONDARY, FLEX_CENTER } from "../../../_internal/shared/styles/themed.js";
|
|
4
|
-
import { Button, Div, Grid, Heading, Icon, ImageMosaic,
|
|
4
|
+
import { Button, Div, Grid, Heading, Icon, ImageMosaic, Row, Section, SiteLogo, SiteMark, Span, Stack, Text, TextLink } from "../../../ui/index.js";
|
|
5
5
|
// --- Component ---------------------------------------------------------------
|
|
6
|
-
export function WelcomeSection({ title, subtitle, pillLabel, showCTA = true, ctaLabel = "Shop now", ctaHref, onCtaClick, secondaryCtaLabel = "Browse products", secondaryCtaHref, onSecondaryCtaClick, trustChips = [], heroImages, isLoading = false, brandLogoText = "", className = "", }) {
|
|
6
|
+
export function WelcomeSection({ title, subtitle, pillLabel, showCTA = true, ctaLabel = "Shop now", ctaHref, onCtaClick, secondaryCtaLabel = "Browse products", secondaryCtaHref, onSecondaryCtaClick, trustChips = [], heroImages, isLoading = false, brandLogoText = "", brandCaption, className = "", }) {
|
|
7
7
|
const themed = { textSecondary: THEMED_TEXT_SECONDARY };
|
|
8
8
|
const flex = { center: FLEX_CENTER };
|
|
9
9
|
if (isLoading) {
|
|
10
10
|
return (_jsx(Section, { className: `relative overflow-hidden pt-[0px] pb-[var(--appkit-space-16)] md:pt-[0px] md:pb-[6rem] ${className}`, paddingX: "x-md", children: _jsxs(Div, { className: "animate-pulse max-w-4xl mx-auto text-left", children: [_jsx(Div, { className: "h-6 w-52 mb-6", surface: "subtle", rounded: "full" }), _jsx(Div, { className: "h-20 mb-4 max-w-2xl", surface: "subtle", rounded: "lg" }), _jsx(Div, { className: "h-6 mb-8 max-w-lg", surface: "subtle", rounded: "lg" }), _jsxs(Row, { justify: "start", gap: "md", children: [_jsx(Div, { className: "h-12 w-36", surface: "subtle", rounded: "xl" }), _jsx(Div, { className: "h-12 w-36", surface: "subtle", rounded: "xl" })] })] }) }));
|
|
11
11
|
}
|
|
12
|
-
return (_jsxs(Section, { className: `relative overflow-hidden pt-[0px] pb-[var(--appkit-space-20)] md:pt-[0px] md:pb-[7rem] ${className}`, paddingX: "x-md", children: [_jsx(Div, { className: "pointer-events-none absolute -top-32 -left-32 w-[28rem] h-[28rem] bg-primary/10 blur-3xl", rounded: "full", "aria-hidden": "true" }), _jsx(Div, { className: "pointer-events-none absolute -bottom-40 -right-40 w-[36rem] h-[36rem] bg-secondary/10 dark:bg-secondary/15 blur-3xl", rounded: "full", "aria-hidden": "true" }), _jsx(Div, { className: "relative z-10 max-w-7xl mx-auto", children: _jsxs(Grid, { align: "center", gap: "2xl", className: "grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2", children: [_jsxs(Div, { className: "text-left", children: [pillLabel && (_jsx(Div, { children: _jsxs(Span, { layout: "inline-flex", gap: "md", size: "xs", weight: "medium", className: /* audit-variant-ok: colour+alpha border (primary-500/30); the `border` variant takes a token name, not an rgba step */ "border border-primary-500/30 bg-primary-500/10 px-[1.25rem] py-[0.375rem] tracking-[0.2em] text-primary-700 dark:text-primary-400 backdrop-blur-sm", rounded: "full", transform: "uppercase", children: [_jsx(Span, { className: "w-1.5 h-1.5 bg-primary-500 inline-block", rounded: "full", "aria-hidden": "true" }), pillLabel, _jsx(Span, { className: "w-1.5 h-1.5 bg-primary-500 inline-block", rounded: "full", "aria-hidden": "true" })] }) })), _jsx(Heading, { level: 1, variant: "none", gradient: "brand-tri", className: "mt-4 font-display leading-[1.1] tracking-tight break-words", size: "5xl", mdSize: "6xl", lgSize: "7xl", xlSize: "7xl", children: title }), subtitle && (_jsx(Text, { className: `mt-4 ${themed.textSecondary} max-w-xl leading-relaxed`, size: "xl", children: subtitle })), showCTA && (_jsxs(Row, { wrap: true, gap: "md", className: "mt-8", justify: "start", children: [ctaHref ? (_jsx(TextLink, { rounded: "xl", href: ctaHref, layout: "inline-flex", align: "center", justify: "center", className: "px-[2rem] py-[0.875rem] !bg-primary hover:!bg-primary-600 text-white dark:!bg-primary dark:hover:!bg-primary-600 dark:text-white btn-glow transition-all hover:scale-[1.02]", size: "base", weight: "bold", children: ctaLabel })) : (_jsx(Button, { variant: "primary", size: "lg", onClick: onCtaClick, children: ctaLabel })), secondaryCtaHref ? (_jsx(TextLink, { rounded: "xl", href: secondaryCtaHref, layout: "inline-flex", align: "center", justify: "center", className: "border-2 border-cobalt/40 dark:border-cobalt-400/40 px-[2rem] py-[0.875rem] text-cobalt-700 dark:text-cobalt-300 hover:bg-primary-surface transition-all hover:scale-[1.02]", size: "base", weight: "semibold", children: secondaryCtaLabel })) : (_jsx(Button, { variant: "outline", size: "lg", onClick: onSecondaryCtaClick, children: secondaryCtaLabel }))] })), trustChips.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mt-6", justify: "start", children: trustChips.map((chip) => (_jsxs(Row, { align: "center", gap: "xs", surface: "default", border: "subtle", rounded: "full", shadow: "sm", className: "px-[0.875rem] py-[0.375rem]", children: [_jsx(Icon, { name: chip.iconName, size: "sm", tone: "success" }), _jsx(Span, { size: "xs", weight: "medium", color: "muted", children: chip.label })] }, chip.key))) }))] }), _jsx(Div, { className: "hidden lg:block", children:
|
|
12
|
+
return (_jsxs(Section, { className: `relative overflow-hidden pt-[0px] pb-[var(--appkit-space-20)] md:pt-[0px] md:pb-[7rem] ${className}`, paddingX: "x-md", children: [_jsx(Div, { className: "pointer-events-none absolute -top-32 -left-32 w-[28rem] h-[28rem] bg-primary/10 blur-3xl", rounded: "full", "aria-hidden": "true" }), _jsx(Div, { className: "pointer-events-none absolute -bottom-40 -right-40 w-[36rem] h-[36rem] bg-secondary/10 dark:bg-secondary/15 blur-3xl", rounded: "full", "aria-hidden": "true" }), _jsx(Div, { className: "relative z-10 max-w-7xl mx-auto", children: _jsxs(Grid, { align: "center", gap: "2xl", className: "grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2", children: [_jsxs(Div, { className: "text-left", children: [pillLabel && (_jsx(Div, { children: _jsxs(Span, { layout: "inline-flex", gap: "md", size: "xs", weight: "medium", className: /* audit-variant-ok: colour+alpha border (primary-500/30); the `border` variant takes a token name, not an rgba step */ "border border-primary-500/30 bg-primary-500/10 px-[1.25rem] py-[0.375rem] tracking-[0.2em] text-primary-700 dark:text-primary-400 backdrop-blur-sm", rounded: "full", transform: "uppercase", children: [_jsx(Span, { className: "w-1.5 h-1.5 bg-primary-500 inline-block", rounded: "full", "aria-hidden": "true" }), pillLabel, _jsx(Span, { className: "w-1.5 h-1.5 bg-primary-500 inline-block", rounded: "full", "aria-hidden": "true" })] }) })), _jsx(Heading, { level: 1, variant: "none", gradient: "brand-tri", className: "mt-4 font-display leading-[1.1] tracking-tight break-words", size: "5xl", mdSize: "6xl", lgSize: "7xl", xlSize: "7xl", children: title }), subtitle && (_jsx(Text, { className: `mt-4 ${themed.textSecondary} max-w-xl leading-relaxed`, size: "xl", children: subtitle })), showCTA && (_jsxs(Row, { wrap: true, gap: "md", className: "mt-8", justify: "start", children: [ctaHref ? (_jsx(TextLink, { rounded: "xl", href: ctaHref, layout: "inline-flex", align: "center", justify: "center", className: "px-[2rem] py-[0.875rem] !bg-primary hover:!bg-primary-600 text-white dark:!bg-primary dark:hover:!bg-primary-600 dark:text-white btn-glow transition-all hover:scale-[1.02]", size: "base", weight: "bold", children: ctaLabel })) : (_jsx(Button, { variant: "primary", size: "lg", onClick: onCtaClick, children: ctaLabel })), secondaryCtaHref ? (_jsx(TextLink, { rounded: "xl", href: secondaryCtaHref, layout: "inline-flex", align: "center", justify: "center", className: "border-2 border-cobalt/40 dark:border-cobalt-400/40 px-[2rem] py-[0.875rem] text-cobalt-700 dark:text-cobalt-300 hover:bg-primary-surface transition-all hover:scale-[1.02]", size: "base", weight: "semibold", children: secondaryCtaLabel })) : (_jsx(Button, { variant: "outline", size: "lg", onClick: onSecondaryCtaClick, children: secondaryCtaLabel }))] })), trustChips.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mt-6", justify: "start", children: trustChips.map((chip) => (_jsxs(Row, { align: "center", gap: "xs", surface: "default", border: "subtle", rounded: "full", shadow: "sm", className: "px-[0.875rem] py-[0.375rem]", children: [_jsx(Icon, { name: chip.iconName, size: "sm", tone: "success" }), _jsx(Span, { size: "xs", weight: "medium", color: "muted", children: chip.label })] }, chip.key))) }))] }), _jsx(Div, { className: "hidden lg:block", children: _jsx(ImageMosaic, { size: "hero", images: heroImages ?? [], alt: `${brandLogoText || "Marketplace"} collection highlights`, leadSlot: _jsxs(Div, { className: "absolute inset-0 bg-[image:var(--appkit-gradient-section-mesh)]", children: [_jsx(Div, { className: "absolute inset-0 bg-[image:var(--appkit-gradient-glass)]" }), _jsxs(Stack, { centered: true, gap: "sm", padding: "xl", className: `absolute inset-0 ${flex.center}`, children: [_jsx(SiteMark, { title: brandLogoText || "LetItRip.in", size: "xl" }), _jsx(SiteLogo, { title: brandLogoText || "LetItRip.in", size: "xl" }), brandCaption && (_jsx(Text, { align: "center", size: "sm", weight: "semibold", transform: "uppercase", className: "tracking-widest text-primary-700 dark:text-primary-300", children: brandCaption }))] })] }) }) })] }) })] }));
|
|
13
13
|
}
|
|
@@ -46,6 +46,15 @@ export interface HomepageBrand {
|
|
|
46
46
|
name?: string;
|
|
47
47
|
/** Short mark shown in the hero panel, e.g. "LT". */
|
|
48
48
|
shortName?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Caption under the hero brand panel's logo, e.g. "Let It Rip India".
|
|
51
|
+
*
|
|
52
|
+
* Separate from `name` because that one is a sentence ingredient ("Why
|
|
53
|
+
* Buyers Trust LetItRip") and this one is a standalone lockup line — they
|
|
54
|
+
* are spelled differently on purpose and collapsing them would force one of
|
|
55
|
+
* the two to read wrong.
|
|
56
|
+
*/
|
|
57
|
+
heroCaption?: string;
|
|
49
58
|
}
|
|
50
59
|
/**
|
|
51
60
|
* Every surface a `SectionDef` may be mounted on.
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
4
|
+
/* Defining module, not the `ui`/root barrel — Root Cause #18. This is the one
|
|
5
|
+
owner of "what a generated placeholder tile looks like"; the seed helper's
|
|
6
|
+
`isSeedPlaceholderImage` reads the same prefix from its own copy, and both
|
|
7
|
+
are documented as mirrors of `public/images/seed-tiles/`. */
|
|
8
|
+
import { SEED_TILE_PREFIX } from "../../../utils/media-url.js";
|
|
4
9
|
import { cleanTitle, parseWelcomeDescription } from "./section-helpers.js";
|
|
5
10
|
import { DEFAULT_TRUST_FEATURES, DEFAULT_SECURITY_ITEMS } from "./section-defaults.js";
|
|
6
11
|
import { SECTION_TITLE, SECTION_COPY, VIEW_MORE_LABEL, WELCOME_TRUST_CHIPS, } from "../constants/section-copy.js";
|
|
@@ -76,21 +81,33 @@ const SECTION_REGISTRY = {
|
|
|
76
81
|
/*
|
|
77
82
|
* Hero imagery, taken from the featured categories' own covers.
|
|
78
83
|
*
|
|
79
|
-
* Deterministic — the first
|
|
84
|
+
* Deterministic — the first few in CONFIGURED order, never shuffled, so
|
|
80
85
|
* the server and client render the same mosaic (ImageMosaic's header is
|
|
81
86
|
* explicit about why `Math.random()` is wrong here).
|
|
82
87
|
*
|
|
88
|
+
* 🛑 PLACEHOLDERS ARE DROPPED, and on this install that is most of them.
|
|
89
|
+
* `seedPhoto()` returns one of six flat SVG tiles under
|
|
90
|
+
* `SEED_TILE_PREFIX` whenever the seed has no real artwork, and
|
|
91
|
+
* `listTopLevelCategories` returns exactly the rows that have none —
|
|
92
|
+
* measured against production, 4 of the first 12 covers were tiles and
|
|
93
|
+
* the hero's large cell was `/images/seed-tiles/312e81.svg`. Those are
|
|
94
|
+
* not photographs of anything; passing them through meant the hero
|
|
95
|
+
* advertised the catalogue with coloured rectangles.
|
|
96
|
+
*
|
|
97
|
+
* Four, not five: the brand panel now owns the large cell, so the mosaic
|
|
98
|
+
* has one fewer slot to fill (see `ImageMosaic.leadSlot`).
|
|
99
|
+
*
|
|
83
100
|
* 🛑 `sectionData.categories` is only populated when the page also has a
|
|
84
101
|
* `categories` section enabled, because MarketplaceHomepageView gates
|
|
85
102
|
* each fetch on `activeTypes`. `welcome` is registered as a trigger for
|
|
86
103
|
* that fetch for exactly this reason; without it the mosaic silently
|
|
87
|
-
*
|
|
104
|
+
* collapses to the brand panel alone and nothing says why.
|
|
88
105
|
*/
|
|
89
106
|
const heroImages = (sectionData.categories ?? [])
|
|
90
107
|
.map((c) => c.display?.coverImage)
|
|
91
|
-
.filter((src) => Boolean(src))
|
|
92
|
-
.slice(0,
|
|
93
|
-
return (_jsx(WelcomeSection, { heroImages: heroImages, title: cleanTitle(cfg?.h1) || SECTION_TITLE.welcome,
|
|
108
|
+
.filter((src) => Boolean(src) && !src.startsWith(SEED_TILE_PREFIX))
|
|
109
|
+
.slice(0, 4);
|
|
110
|
+
return (_jsx(WelcomeSection, { heroImages: heroImages, brandCaption: brand?.heroCaption, title: cleanTitle(cfg?.h1) || SECTION_TITLE.welcome,
|
|
94
111
|
// A real `subtitle` wins; `description` is the legacy source and is
|
|
95
112
|
// still parsed for documents that only carry it.
|
|
96
113
|
subtitle: cfg?.subtitle?.trim() || parseWelcomeDescription(cfg?.description), pillLabel: cfg?.pillLabel || SECTION_COPY.welcomePill, showCTA: cfg?.showCTA ?? true, ctaLabel: cfg?.ctaText || SECTION_COPY.welcomePrimaryCta, ctaHref: cfg?.ctaLink || ROUTES.PUBLIC.PRODUCTS, secondaryCtaLabel: cfg?.secondaryCtaText || SECTION_COPY.welcomeSecondaryCta, secondaryCtaHref: cfg?.secondaryCtaLink || ROUTES.PUBLIC.PRODUCTS, trustChips: [...WELCOME_TRUST_CHIPS], brandLogoText: brand?.shortName }));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carousel gating — strip slides and cards that advertise a disabled listing
|
|
3
|
+
* type.
|
|
4
|
+
*
|
|
5
|
+
* ## The gap this closes
|
|
6
|
+
*
|
|
7
|
+
* `MarketplaceHomepageView` already drops whole SECTIONS whose type maps to a
|
|
8
|
+
* disabled listing type (`HOMEPAGE_SECTION_LISTING_TYPE`). That gate is keyed
|
|
9
|
+
* on `SectionType`, so it can only see sections that ARE a listing type — the
|
|
10
|
+
* auctions rail, the pre-orders rail.
|
|
11
|
+
*
|
|
12
|
+
* A carousel is one section of type `carousel` whatever it contains, and what
|
|
13
|
+
* it contains is free-text authored content. With `auction: false` live, the
|
|
14
|
+
* hero still shipped a "Live auctions — place a bid before the timer runs out"
|
|
15
|
+
* card and a full-screen "Win it at your price" slide, both linking to
|
|
16
|
+
* `/auctions`, which renders nothing. The section gate could not see either:
|
|
17
|
+
* the only thing identifying them is where they point.
|
|
18
|
+
*
|
|
19
|
+
* ## Deliberately link-based, not keyword-based
|
|
20
|
+
*
|
|
21
|
+
* Nothing here reads the copy. "Win it at your price" names no type, and a
|
|
22
|
+
* word search would be both incomplete (it misses that slide) and
|
|
23
|
+
* over-eager (it would eat the authenticity slide for saying "bid" in passing).
|
|
24
|
+
* A destination is checkable; prose is not.
|
|
25
|
+
*
|
|
26
|
+
* ## What survives
|
|
27
|
+
*
|
|
28
|
+
* A slide keeps its authored `zone` values when a card is removed. Re-packing
|
|
29
|
+
* the survivors is NOT done here: the zone grid is a fixed `repeat(3, 1fr)`,
|
|
30
|
+
* so there is no arrangement of two cards that fills it, and silently moving
|
|
31
|
+
* an admin's card is a worse surprise than the gap. The seeded hero therefore
|
|
32
|
+
* replaces its auction card with a real enabled type rather than relying on
|
|
33
|
+
* this filter to tidy up after it — this is the safety net for content an
|
|
34
|
+
* admin authors later, where the gap is a visible prompt to go and fix it.
|
|
35
|
+
*/
|
|
36
|
+
import type { CarouselSlide } from "../types/index";
|
|
37
|
+
import type { ListingType } from "../../products/types/index";
|
|
38
|
+
interface ListingFlagSnapshot {
|
|
39
|
+
listings?: {
|
|
40
|
+
listingTypes?: Partial<Record<ListingType, boolean>>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Filter a slide list for the current listing-type flags.
|
|
45
|
+
*
|
|
46
|
+
* Returns a new array; input is not mutated. Fails OPEN — with no settings
|
|
47
|
+
* document every `isListingTypeEnabled` call returns `true` and nothing is
|
|
48
|
+
* removed, which is the right failure for a read that errored (Root Cause
|
|
49
|
+
* #72: an unknown must never be read as "zero").
|
|
50
|
+
*/
|
|
51
|
+
export declare function gateCarouselSlides<T extends CarouselSlide>(slides: readonly T[], settings: ListingFlagSnapshot | null | undefined): T[];
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carousel gating — strip slides and cards that advertise a disabled listing
|
|
3
|
+
* type.
|
|
4
|
+
*
|
|
5
|
+
* ## The gap this closes
|
|
6
|
+
*
|
|
7
|
+
* `MarketplaceHomepageView` already drops whole SECTIONS whose type maps to a
|
|
8
|
+
* disabled listing type (`HOMEPAGE_SECTION_LISTING_TYPE`). That gate is keyed
|
|
9
|
+
* on `SectionType`, so it can only see sections that ARE a listing type — the
|
|
10
|
+
* auctions rail, the pre-orders rail.
|
|
11
|
+
*
|
|
12
|
+
* A carousel is one section of type `carousel` whatever it contains, and what
|
|
13
|
+
* it contains is free-text authored content. With `auction: false` live, the
|
|
14
|
+
* hero still shipped a "Live auctions — place a bid before the timer runs out"
|
|
15
|
+
* card and a full-screen "Win it at your price" slide, both linking to
|
|
16
|
+
* `/auctions`, which renders nothing. The section gate could not see either:
|
|
17
|
+
* the only thing identifying them is where they point.
|
|
18
|
+
*
|
|
19
|
+
* ## Deliberately link-based, not keyword-based
|
|
20
|
+
*
|
|
21
|
+
* Nothing here reads the copy. "Win it at your price" names no type, and a
|
|
22
|
+
* word search would be both incomplete (it misses that slide) and
|
|
23
|
+
* over-eager (it would eat the authenticity slide for saying "bid" in passing).
|
|
24
|
+
* A destination is checkable; prose is not.
|
|
25
|
+
*
|
|
26
|
+
* ## What survives
|
|
27
|
+
*
|
|
28
|
+
* A slide keeps its authored `zone` values when a card is removed. Re-packing
|
|
29
|
+
* the survivors is NOT done here: the zone grid is a fixed `repeat(3, 1fr)`,
|
|
30
|
+
* so there is no arrangement of two cards that fills it, and silently moving
|
|
31
|
+
* an admin's card is a worse surprise than the gap. The seeded hero therefore
|
|
32
|
+
* replaces its auction card with a real enabled type rather than relying on
|
|
33
|
+
* this filter to tidy up after it — this is the safety net for content an
|
|
34
|
+
* admin authors later, where the gap is a visible prompt to go and fix it.
|
|
35
|
+
*/
|
|
36
|
+
import { isDisabledListingHref } from "../../../_internal/shared/listing-types/route-gating.js";
|
|
37
|
+
/**
|
|
38
|
+
* Every destination a card offers. A card with no buttons is still clickable
|
|
39
|
+
* through nothing — it has no link of its own — so it is never gated.
|
|
40
|
+
*/
|
|
41
|
+
function cardHrefs(card) {
|
|
42
|
+
return (card.buttons ?? []).map((b) => b.href).filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A card is dropped when it HAS destinations and every one of them is
|
|
46
|
+
* disabled.
|
|
47
|
+
*
|
|
48
|
+
* 🛑 "every", not "any", and the empty case matters: `[].every()` is `true`,
|
|
49
|
+
* so a card with no buttons at all would be dropped by the predicate alone.
|
|
50
|
+
* That is why the length check is separate rather than folded in — an
|
|
51
|
+
* image-only card advertises no type and must survive.
|
|
52
|
+
*/
|
|
53
|
+
function isCardDisabled(card, settings) {
|
|
54
|
+
const hrefs = cardHrefs(card);
|
|
55
|
+
if (hrefs.length === 0)
|
|
56
|
+
return false;
|
|
57
|
+
return hrefs.every((href) => isDisabledListingHref(href, settings));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A slide is dropped when the thing it is ABOUT is disabled — its overlay CTA
|
|
61
|
+
* or its own whole-slide link.
|
|
62
|
+
*
|
|
63
|
+
* Both are checked because they are set independently: the seeded auction
|
|
64
|
+
* slide carries `overlay.button.link` AND `link.url`, while a slide can
|
|
65
|
+
* legitimately have only one.
|
|
66
|
+
*/
|
|
67
|
+
function isSlideDisabled(slide, settings) {
|
|
68
|
+
if (isDisabledListingHref(slide.overlay?.button?.link, settings))
|
|
69
|
+
return true;
|
|
70
|
+
if (isDisabledListingHref(slide.link?.url, settings))
|
|
71
|
+
return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Filter a slide list for the current listing-type flags.
|
|
76
|
+
*
|
|
77
|
+
* Returns a new array; input is not mutated. Fails OPEN — with no settings
|
|
78
|
+
* document every `isListingTypeEnabled` call returns `true` and nothing is
|
|
79
|
+
* removed, which is the right failure for a read that errored (Root Cause
|
|
80
|
+
* #72: an unknown must never be read as "zero").
|
|
81
|
+
*/
|
|
82
|
+
export function gateCarouselSlides(slides, settings) {
|
|
83
|
+
const kept = [];
|
|
84
|
+
for (const slide of slides) {
|
|
85
|
+
if (isSlideDisabled(slide, settings))
|
|
86
|
+
continue;
|
|
87
|
+
const cards = (slide.cards ?? []).filter((card) => !isCardDisabled(card, settings));
|
|
88
|
+
const hadCards = (slide.cards ?? []).length > 0;
|
|
89
|
+
// A cards-slide that lost ALL its cards renders as a bare background with
|
|
90
|
+
// no copy and no call to action — the exact "renders blank" failure the
|
|
91
|
+
// carousel seed file's own header warns about. Drop it rather than ship an
|
|
92
|
+
// empty frame the visitor can swipe to.
|
|
93
|
+
if (hadCards && cards.length === 0 && !slide.overlay)
|
|
94
|
+
continue;
|
|
95
|
+
kept.push(cards.length === (slide.cards ?? []).length ? slide : { ...slide, cards });
|
|
96
|
+
}
|
|
97
|
+
return kept;
|
|
98
|
+
}
|
|
@@ -26,6 +26,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
|
|
|
26
26
|
import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
|
|
27
27
|
import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
|
|
28
28
|
import { productImageUrls } from "../../products/schemas/firestore.js";
|
|
29
|
+
import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
|
|
29
30
|
function toDescriptionHtml(raw) {
|
|
30
31
|
if (!raw)
|
|
31
32
|
return "";
|
|
@@ -79,5 +80,5 @@ export async function LiveItemDetailPageView({ slug, initialProduct, renderActio
|
|
|
79
80
|
price: price ?? undefined,
|
|
80
81
|
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
81
82
|
storeName: storeName ?? undefined,
|
|
82
|
-
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-text-muted)]", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: toCustomSectionTabs(p.customSections) })), renderBuyBar: () => _jsx(Div, { id: "live-item-buy-bar", children: renderActions?.(product) }), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "live", anchorId: "live-item-buy-bar", price: price, currency: currency })] })] }));
|
|
83
|
+
} }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-text-muted)]", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: toCustomSectionTabs(p.customSections) })), renderBuyBar: () => _jsx(Div, { id: "live-item-buy-bar", children: renderActions?.(product) }), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "live", anchorId: "live-item-buy-bar", price: price, currency: currency })] })] }));
|
|
83
84
|
}
|
|
@@ -8,6 +8,6 @@ const sizeClasses = {
|
|
|
8
8
|
xl: "w-20 h-20",
|
|
9
9
|
};
|
|
10
10
|
export function MediaAvatar({ src, alt, size = "md", className = "", }) {
|
|
11
|
-
return (_jsx(Div, { className: `relative ${sizeClasses[size]} overflow-hidden flex-shrink-0 ${className}`, rounded: "full", children: _jsx(MediaImage, { src: src, alt: alt, size: "avatar" }) }));
|
|
11
|
+
return (_jsx(Div, { className: `relative ${sizeClasses[size]} overflow-hidden flex-shrink-0 ${className}`, rounded: "full", children: _jsx(MediaImage, { src: src, alt: alt, size: "avatar", objectFit: "cover" }) }));
|
|
12
12
|
}
|
|
13
13
|
export default MediaAvatar;
|