@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.
Files changed (86) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/actions.d.ts +22 -0
  3. package/dist/_internal/server/features/checkout/actions.js +100 -5
  4. package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
  5. package/dist/_internal/server/features/shipping/reconcile.js +93 -0
  6. package/dist/_internal/shared/config/schema.d.ts +6 -0
  7. package/dist/_internal/shared/features/checkout/config.d.ts +1 -1
  8. package/dist/_internal/shared/features/checkout/config.js +1 -1
  9. package/dist/_internal/shared/features/site-settings/fees.d.ts +1 -1
  10. package/dist/_internal/shared/features/site-settings/fees.js +1 -1
  11. package/dist/_internal/shared/fees/calculator.d.ts +35 -3
  12. package/dist/_internal/shared/fees/calculator.js +34 -4
  13. package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
  14. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  15. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  16. package/dist/_internal/shared/shipping/quote.js +124 -0
  17. package/dist/client.d.ts +1 -0
  18. package/dist/client.js +8 -0
  19. package/dist/errors/error-codes.d.ts +15 -0
  20. package/dist/errors/error-codes.js +15 -0
  21. package/dist/errors/index.d.ts +1 -1
  22. package/dist/errors/index.js +1 -1
  23. package/dist/features/account/components/UserBidsView.js +4 -2
  24. package/dist/features/admin/components/AdminSiteSettingsView.js +18 -2
  25. package/dist/features/admin/schemas/firestore.d.ts +37 -2
  26. package/dist/features/admin/schemas/firestore.js +12 -1
  27. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  28. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  29. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  30. package/dist/features/checkout/schemas/firestore.d.ts +12 -1
  31. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  32. package/dist/features/homepage/components/HeroCarousel.js +4 -2
  33. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  34. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  35. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  36. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  37. package/dist/features/homepage/lib/section-renderer.js +22 -5
  38. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  39. package/dist/features/homepage/lib/slide-gating.js +98 -0
  40. package/dist/features/layout/NavbarLayout.js +139 -32
  41. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  42. package/dist/features/media/MediaAvatar.js +1 -1
  43. package/dist/features/media/MediaImage.d.ts +18 -1
  44. package/dist/features/media/MediaImage.js +1 -1
  45. package/dist/features/media/messages/en.json +67 -67
  46. package/dist/features/orders/schemas/firestore.d.ts +39 -1
  47. package/dist/features/points/repository/points.repository.d.ts +81 -0
  48. package/dist/features/points/repository/points.repository.js +155 -0
  49. package/dist/features/points/schemas/firestore.d.ts +102 -0
  50. package/dist/features/points/schemas/firestore.js +99 -0
  51. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  52. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  53. package/dist/features/products/components/ProductDetailPageView.js +5 -26
  54. package/dist/features/products/components/ProductOwnerQuickActions.d.ts +11 -0
  55. package/dist/features/products/components/ProductOwnerQuickActions.js +120 -0
  56. package/dist/features/products/components/ProductsIndexListing.js +35 -5
  57. package/dist/features/products/components/ProductsIndexPageView.js +40 -7
  58. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  59. package/dist/features/seller/actions/seller-actions.js +33 -0
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +8 -0
  62. package/dist/providers/shipping-shiprocket/index.d.ts +13 -4
  63. package/dist/providers/shipping-shiprocket/index.js +34 -10
  64. package/dist/security/csp.d.ts +1 -16
  65. package/dist/security/csp.js +3 -5
  66. package/dist/seed/carousel-slides-seed-data.js +48 -7
  67. package/dist/seed/faq-seed-data.js +1 -1
  68. package/dist/seed/site-settings-seed-data.js +1 -1
  69. package/dist/seo/json-ld.d.ts +8 -1
  70. package/dist/seo/json-ld.js +39 -2
  71. package/dist/styles.css +419 -542
  72. package/dist/tailwind-utilities.css +1 -1
  73. package/dist/tokens/themes/index.d.ts +1 -3
  74. package/dist/tokens/themes/index.js +1 -11
  75. package/dist/tokens/tokens.css +0 -155
  76. package/dist/ui/components/GatedPrice.style.css +43 -43
  77. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  78. package/dist/ui/components/ImageMosaic.js +28 -5
  79. package/dist/ui/components/ImageMosaic.style.css +37 -5
  80. package/dist/ui/components/QuickCreateModal.style.css +78 -78
  81. package/dist/ui/forms/FormShell.style.css +260 -260
  82. package/package.json +1 -1
  83. package/dist/tokens/themes/technical-dark.d.ts +0 -30
  84. package/dist/tokens/themes/technical-dark.js +0 -103
  85. package/dist/tokens/themes/technical-light.d.ts +0 -22
  86. 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
- /** COD handling fee charged to the buyer: max(codHandlingFeeMin, subtotal × codHandlingFeePercent / 100). */
415
- codHandlingFeeMin: number;
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
- codHandlingFeeMin: 200,
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) }), (preview.gstAmount > 0 || preview.gstOnFee > 0) && (_jsx(Line, { label: "GST", amount: money(preview.gstAmount + 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 }) }), errorNote ? (_jsx(Text, { size: "xs", color: "error", children: errorNote })) : (isLoading && (_jsx(Text, { size: "xs", color: "muted", children: "Updating\u2026" })))] }));
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" | "validation_error" | "unknown";
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
- return _jsx(MediaImage, { src: bg.url, alt: "", size: "card" });
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
- const carouselSlides = toCarouselSlides(slides);
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, MOST IMPORTANT FIRST — the first takes the large
23
- * mosaic cell. Below `MOSAIC_MIN` (3) the brand-mark panel is used instead.
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, MOSAIC_MIN, Row, Section, SiteLogo, SiteMark, Span, Stack, Text, TextLink } from "../../../ui/index.js";
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: heroImages && heroImages.length >= MOSAIC_MIN ? (_jsx(ImageMosaic, { size: "hero", images: heroImages, alt: `${brandLogoText || "Marketplace"} collection highlights` })) : (_jsxs(Div, { className: `relative overflow-hidden aspect-[4/3] bg-[image:var(--appkit-gradient-section-mesh)]`, shadow: "2xl", border: "default", rounded: "3xl", children: [_jsx(Div, { className: "absolute inset-0 bg-[image:var(--appkit-gradient-glass)]" }), _jsxs(Stack, { centered: true, gap: "sm", className: `absolute inset-0 ${flex.center} p-[2.5rem]`, children: [_jsx(SiteMark, { title: brandLogoText || "LetItRip.in", size: "xl" }), _jsx(SiteLogo, { title: brandLogoText || "LetItRip.in", size: "xl" })] })] })) })] }) })] }));
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 five in CONFIGURED order, never shuffled, so
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
- * falls back to the brand panel and nothing says why.
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, 5);
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
+ }