@mohasinac/appkit 4.55.0-next.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 +100 -5
- 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/features/checkout/config.d.ts +1 -1
- package/dist/_internal/shared/features/checkout/config.js +1 -1
- package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
- package/dist/_internal/shared/features/site-settings/fees.js +1 -1
- package/dist/_internal/shared/fees/calculator.d.ts +35 -3
- package/dist/_internal/shared/fees/calculator.js +34 -4
- 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/errors/error-codes.d.ts +15 -0
- package/dist/errors/error-codes.js +15 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/features/account/components/UserBidsView.js +4 -2
- package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
- package/dist/features/admin/schemas/firestore.d.ts +37 -2
- package/dist/features/admin/schemas/firestore.js +12 -1
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/checkout/schemas/firestore.d.ts +12 -1
- 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/layout/NavbarLayout.js +139 -32
- 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/media/messages/en.json +67 -67
- package/dist/features/orders/schemas/firestore.d.ts +39 -1
- 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 +5 -26
- package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
- package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
- package/dist/features/products/components/ProductsIndexListing.js +35 -5
- package/dist/features/products/components/ProductsIndexPageView.js +40 -7
- 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/providers/shipping-shiprocket/index.d.ts +13 -4
- package/dist/providers/shipping-shiprocket/index.js +34 -10
- package/dist/security/csp.d.ts +1 -16
- package/dist/security/csp.js +3 -5
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/seed/faq-seed-data.js +1 -1
- package/dist/seed/site-settings-seed-data.js +1 -1
- package/dist/seo/json-ld.d.ts +8 -1
- package/dist/seo/json-ld.js +39 -2
- package/dist/styles.css +419 -542
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/index.d.ts +1 -3
- package/dist/tokens/themes/index.js +1 -11
- package/dist/tokens/tokens.css +0 -155
- package/dist/ui/components/GatedPrice.style.css +43 -43
- 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/dist/ui/components/QuickCreateModal.style.css +78 -78
- package/dist/ui/forms/FormShell.style.css +260 -260
- package/package.json +1 -1
- package/dist/tokens/themes/technical-dark.d.ts +0 -30
- package/dist/tokens/themes/technical-dark.js +0 -103
- package/dist/tokens/themes/technical-light.d.ts +0 -22
- package/dist/tokens/themes/technical-light.js +0 -89
|
@@ -352,6 +352,26 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
352
352
|
phonepeEnabled: boolean;
|
|
353
353
|
upiManualEnabled: boolean;
|
|
354
354
|
codEnabled: boolean;
|
|
355
|
+
/**
|
|
356
|
+
* Cart total (rupees) ABOVE which cash on delivery is refused. Default
|
|
357
|
+
* 3000.
|
|
358
|
+
*
|
|
359
|
+
* 🛑 Measured against the CART TOTAL, not an item price. COD risk is the
|
|
360
|
+
* cash a courier carries and the value at stake on a refusal at the door,
|
|
361
|
+
* and neither cares how many lines the order has — a ₹2,000 item ordered
|
|
362
|
+
* three times is a ₹6,000 exposure exactly like one ₹6,000 item. Gating
|
|
363
|
+
* per item would pass that order straight through.
|
|
364
|
+
*
|
|
365
|
+
* 🛑 It is a MAXIMUM, so the comparison is `subtotal > value` — strictly
|
|
366
|
+
* greater. `otpCheckoutThreshold` beside it is a MINIMUM and fires
|
|
367
|
+
* at/above its value. Two money thresholds on one settings block with
|
|
368
|
+
* opposite senses is a genuine trap; the names carry it (`Max` vs
|
|
369
|
+
* `Threshold`) and both comparisons are spelled out at their call sites.
|
|
370
|
+
*
|
|
371
|
+
* `0` disables COD outright at any value, which is a real configuration
|
|
372
|
+
* rather than a bug — `undefined` is what means "no cap".
|
|
373
|
+
*/
|
|
374
|
+
codMaxOrderValue?: number;
|
|
355
375
|
/** Tier PP — cart total (rupees) at/above which checkout requires OTP verification. Applies to all payment methods except COD. Optional — falls back to no OTP gate when unset. */
|
|
356
376
|
otpCheckoutThreshold?: number;
|
|
357
377
|
/**
|
|
@@ -411,8 +431,13 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
411
431
|
/** Razorpay gateway cost % (absorbed by platform, not passed through separately). */
|
|
412
432
|
gatewayFeePercent: number;
|
|
413
433
|
codDepositPercent: number;
|
|
414
|
-
/**
|
|
415
|
-
|
|
434
|
+
/**
|
|
435
|
+
* COD handling fee charged to the buyer:
|
|
436
|
+
* min(subtotal × codHandlingFeePercent / 100, codHandlingFeeCap).
|
|
437
|
+
*
|
|
438
|
+
* 🛑 A CEILING. It was `codHandlingFeeMin` applied as a floor, which billed
|
|
439
|
+
* ₹2,000 of "handling" on a ₹20,000 order — see `computeCodHandlingFee`.
|
|
440
|
+
*/ codHandlingFeeCap: number;
|
|
416
441
|
codHandlingFeePercent: number;
|
|
417
442
|
/** Admin master toggle — whether the gift-wrap addon is offered at checkout at all. */
|
|
418
443
|
giftWrapFeeEnabled: boolean;
|
|
@@ -793,6 +818,16 @@ export declare const SITE_SETTINGS_INDEXED_FIELDS: readonly [];
|
|
|
793
818
|
* and switch the active default with `defaultLightThemeId` / `defaultDarkThemeId`.
|
|
794
819
|
*/
|
|
795
820
|
export declare const DEFAULT_SITE_SETTINGS_THEME: SiteSettingsTheme;
|
|
821
|
+
/**
|
|
822
|
+
* Cart total (rupees) above which cash on delivery is refused, when the
|
|
823
|
+
* settings document does not say.
|
|
824
|
+
*
|
|
825
|
+
* 🛑 ONE number, consumed by both the default document below and every reader
|
|
826
|
+
* that has to cope with a settings document written before the field existed.
|
|
827
|
+
* Two spellings of a money limit is how a cap ends up enforced at one value
|
|
828
|
+
* and displayed at another.
|
|
829
|
+
*/
|
|
830
|
+
export declare const COD_MAX_ORDER_VALUE_FALLBACK = 3000;
|
|
796
831
|
export declare const DEFAULT_SITE_SETTINGS_DATA: Partial<SiteSettingsDocument>;
|
|
797
832
|
/**
|
|
798
833
|
* 🛑 DEAD, AND IT DISAGREES WITH THE REAL PROJECTION. Do not add to it.
|
|
@@ -224,6 +224,16 @@ export const DEFAULT_SITE_SETTINGS_THEME = {
|
|
|
224
224
|
defaultLightThemeId: "default-light",
|
|
225
225
|
defaultDarkThemeId: "default-dark",
|
|
226
226
|
};
|
|
227
|
+
/**
|
|
228
|
+
* Cart total (rupees) above which cash on delivery is refused, when the
|
|
229
|
+
* settings document does not say.
|
|
230
|
+
*
|
|
231
|
+
* 🛑 ONE number, consumed by both the default document below and every reader
|
|
232
|
+
* that has to cope with a settings document written before the field existed.
|
|
233
|
+
* Two spellings of a money limit is how a cap ends up enforced at one value
|
|
234
|
+
* and displayed at another.
|
|
235
|
+
*/
|
|
236
|
+
export const COD_MAX_ORDER_VALUE_FALLBACK = 3000;
|
|
227
237
|
export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
228
238
|
id: "global",
|
|
229
239
|
siteName: "My Store",
|
|
@@ -234,6 +244,7 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
234
244
|
phonepeEnabled: false,
|
|
235
245
|
upiManualEnabled: true,
|
|
236
246
|
codEnabled: true,
|
|
247
|
+
codMaxOrderValue: COD_MAX_ORDER_VALUE_FALLBACK,
|
|
237
248
|
otpCheckoutThreshold: 5000,
|
|
238
249
|
smsVerification: false,
|
|
239
250
|
adminCheckoutBypass: false,
|
|
@@ -261,7 +272,7 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
261
272
|
platformFeeMax: 10,
|
|
262
273
|
gatewayFeePercent: 2,
|
|
263
274
|
codDepositPercent: 10,
|
|
264
|
-
|
|
275
|
+
codHandlingFeeCap: 200,
|
|
265
276
|
codHandlingFeePercent: 10,
|
|
266
277
|
// The buyer add-ons ship ENABLED. They were `false`, which made
|
|
267
278
|
// StoreAddonsPicker return null on both the cart and checkout, so a fresh
|
|
@@ -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?: {
|
|
@@ -21,5 +21,5 @@ export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unava
|
|
|
21
21
|
const subtotalLabel = itemCount !== undefined
|
|
22
22
|
? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
|
|
23
23
|
: "Subtotal";
|
|
24
|
-
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }),
|
|
24
|
+
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.platformFee > 0 && _jsx(Line, { label: "Platform fee", amount: money(preview.platformFee) }), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee) })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: true }) }), preview.gstAmount > 0 && (_jsxs(Text, { size: "xs", color: "muted", children: ["Includes GST of ", money(preview.gstAmount)] })), errorNote ? (_jsx(Text, { size: "xs", color: "error", children: errorNote })) : (isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" })))] }));
|
|
25
25
|
}
|
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
* Checkout Feature Firestore Document Types & Constants
|
|
3
3
|
* Covers: failed checkouts, failed payments
|
|
4
4
|
*/
|
|
5
|
-
export type FailedCheckoutReason = "stock_failed" | "address_not_found" | "cart_empty"
|
|
5
|
+
export type FailedCheckoutReason = "stock_failed" | "address_not_found" | "cart_empty"
|
|
6
|
+
/**
|
|
7
|
+
* The cart exceeded `siteSettings.payment.codMaxOrderValue` and COD was
|
|
8
|
+
* refused server-side.
|
|
9
|
+
*
|
|
10
|
+
* 🛑 Its own reason rather than `validation_error`, because it is the only
|
|
11
|
+
* one here that is a BUSINESS rule an admin can change rather than a
|
|
12
|
+
* malformed request. Folded into `validation_error` it would be invisible,
|
|
13
|
+
* and "how often does the COD cap turn buyers away" is the question that
|
|
14
|
+
* decides whether ₹3,000 is the right number.
|
|
15
|
+
*/
|
|
16
|
+
| "cod_over_limit" | "validation_error" | "unknown";
|
|
6
17
|
export type FailedPaymentReason = "signature_mismatch" | "payment_cancelled" | "amount_mismatch" | "product_unavailable" | "stock_insufficient" | "unknown";
|
|
7
18
|
export type FailedCheckoutDocument = {
|
|
8
19
|
id: string;
|
|
@@ -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
|
+
}
|