@mohasinac/appkit 4.55.0 → 4.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) 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 +39 -3
  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/listing-types/route-gating.d.ts +56 -0
  8. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  9. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  10. package/dist/_internal/shared/shipping/quote.js +124 -0
  11. package/dist/client.d.ts +1 -0
  12. package/dist/client.js +8 -0
  13. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  14. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  15. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  16. package/dist/features/homepage/components/HeroCarousel.js +4 -2
  17. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  18. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  19. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  20. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  21. package/dist/features/homepage/lib/section-renderer.js +22 -5
  22. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  23. package/dist/features/homepage/lib/slide-gating.js +98 -0
  24. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  25. package/dist/features/media/MediaAvatar.js +1 -1
  26. package/dist/features/media/MediaImage.d.ts +18 -1
  27. package/dist/features/media/MediaImage.js +1 -1
  28. package/dist/features/orders/schemas/firestore.d.ts +38 -0
  29. package/dist/features/points/repository/points.repository.d.ts +81 -0
  30. package/dist/features/points/repository/points.repository.js +155 -0
  31. package/dist/features/points/schemas/firestore.d.ts +102 -0
  32. package/dist/features/points/schemas/firestore.js +99 -0
  33. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  34. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  35. package/dist/features/products/components/ProductDetailPageView.js +3 -1
  36. package/dist/features/seller/actions/seller-actions.d.ts +17 -0
  37. package/dist/features/seller/actions/seller-actions.js +33 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +8 -0
  40. package/dist/seed/carousel-slides-seed-data.js +48 -7
  41. package/dist/styles.css +38 -6
  42. package/dist/tailwind-utilities.css +1 -1
  43. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  44. package/dist/ui/components/ImageMosaic.js +28 -5
  45. package/dist/ui/components/ImageMosaic.style.css +37 -5
  46. package/package.json +1 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * What the buyer is quoted for shipping, what the seller actually spent, and
3
+ * who wears the difference.
4
+ *
5
+ * Pure and synchronous — no I/O, no Firestore, no provider. Every surface that
6
+ * touches a shipping figure resolves through here so the quote shown at
7
+ * checkout, the amount charged, the order record and the reconciliation can
8
+ * never disagree (Root Cause #75: a rule reachable by copy-paste WILL be
9
+ * copied, and the copy never learns about the branch added later).
10
+ *
11
+ * ## The model
12
+ *
13
+ * 1. Shiprocket returns per-courier rates for the buyer's pincode.
14
+ * 2. Each rate is ROUNDED UP to the nearest ₹50 and that is what the buyer
15
+ * sees and is charged. The raw rate is never shown and never billed.
16
+ * 3. The seller ships. If their courier is dearer than the charged figure they
17
+ * absorb the difference; if it is cheaper the buyer is credited the
18
+ * difference in points, 1 point = ₹1.
19
+ *
20
+ * ## 🛑 Reconciliation measures against the CHARGED figure, not the raw rate
21
+ *
22
+ * This is the decision that makes the whole thing coherent, and it is worth
23
+ * stating because the obvious alternative is defensible and wrong.
24
+ *
25
+ * Measuring against the raw rate would let the platform keep the round-up
26
+ * (₹70 raw → ₹100 charged → a silent ₹30 margin on every parcel) while still
27
+ * calling the ₹50 step a "rounding". Measuring against the charged figure
28
+ * means the buyer is made whole for every rupee they handed over that did not
29
+ * become postage: ₹100 charged, shipped at ₹47, 53 points back. The round-up
30
+ * stops being a margin and becomes a BUFFER that absorbs ordinary courier
31
+ * variance before the seller has to.
32
+ *
33
+ * It also makes the rule explainable in one sentence to both sides — "you pay
34
+ * the rounded quote and get back whatever we did not spend", "you keep the
35
+ * quote and cover any overage" — and a rule a seller can predict is a rule
36
+ * they do not dispute.
37
+ */
38
+ /**
39
+ * Shipping is always billed in whole ₹50 steps.
40
+ *
41
+ * 🛑 Not a formatting choice. A courier rate is a volatile per-parcel number
42
+ * (fuel, zone, weight slab, surcharge) and quoting it to the rupee invites a
43
+ * dispute every time the actual differs by ₹3. A coarse step makes the quote
44
+ * stable, makes it look like a price rather than a machine reading, and buys
45
+ * the headroom that keeps most orders off the reconciliation path entirely.
46
+ */
47
+ export const SHIPPING_ROUND_UP_TO = 50;
48
+ /**
49
+ * Round a raw carrier rate up to the next ₹50.
50
+ *
51
+ * `Math.ceil`, so ₹70 → ₹100 and ₹100 → ₹100 — a rate exactly on a step is NOT
52
+ * pushed to the next one. ₹0 stays ₹0: free shipping must survive the rounding
53
+ * or every free-shipping order silently acquires a ₹50 charge.
54
+ *
55
+ * Negative input is clamped to 0 rather than rounded "up" toward zero, which
56
+ * would otherwise turn a bad provider response into a credit.
57
+ */
58
+ export function roundShippingUp(rawRate) {
59
+ if (!Number.isFinite(rawRate) || rawRate <= 0)
60
+ return 0;
61
+ return Math.ceil(rawRate / SHIPPING_ROUND_UP_TO) * SHIPPING_ROUND_UP_TO;
62
+ }
63
+ /**
64
+ * Compare the charged quote against the real spend.
65
+ *
66
+ * 🛑 Works on the CHARGED figure (see the header). The worked examples from the
67
+ * spec, with the ₹70 Ekart quote billed at ₹100:
68
+ *
69
+ * shipped Delhivery ₹100 → sellerAbsorbs 0, buyerPoints 0
70
+ * shipped Delhivery ₹120 → sellerAbsorbs 20, buyerPoints 0
71
+ * shipped India Post ₹47 → sellerAbsorbs 0, buyerPoints 53
72
+ *
73
+ * Points are the EXACT rupee difference and are deliberately not rounded to a
74
+ * ₹50 step. The round-up exists to make a *quote* stable; applying it to a
75
+ * credit would either invent money (rounding up) or quietly keep some of the
76
+ * buyer's (rounding down).
77
+ */
78
+ export function reconcileShipping(quote, actual) {
79
+ const charged = Number.isFinite(quote.chargedRate) ? Math.max(0, quote.chargedRate) : 0;
80
+ const spent = Number.isFinite(actual.actualRate) ? Math.max(0, actual.actualRate) : 0;
81
+ const delta = roundPaise(charged - spent);
82
+ return {
83
+ sellerAbsorbs: delta < 0 ? Math.abs(delta) : 0,
84
+ buyerPoints: delta > 0 ? delta : 0,
85
+ courierChanged: quote.courierId !== actual.courierId,
86
+ };
87
+ }
88
+ /**
89
+ * Two decimal places.
90
+ *
91
+ * Money here is decimal rupees (the paise→rupees migration, Root Cause #31), so
92
+ * a subtraction can leave a float artefact — `100 - 47.1` is `52.900000000000006`,
93
+ * and that would become a points credit with eleven decimal places on a user's
94
+ * balance.
95
+ */
96
+ function roundPaise(value) {
97
+ return Math.round(value * 100) / 100;
98
+ }
99
+ /**
100
+ * Turn carrier serviceability results into offerable options.
101
+ *
102
+ * 🛑 Drops any courier whose rate is not a usable number instead of charging
103
+ * ₹0 for it. `resolveShippingCost` already carries this lesson in its
104
+ * `shippingUnavailable` flag: a zero there "would ship every parcel free at
105
+ * our expense, silently, until somebody noticed". A malformed row from a
106
+ * provider is missing data, never free postage.
107
+ *
108
+ * Sorted cheapest-first, then fastest — the buyer is choosing, so the default
109
+ * reading order should be the one most of them want.
110
+ */
111
+ export function toQuoteOptions(couriers) {
112
+ return couriers
113
+ .filter((c) => Number.isFinite(c.rate) && c.rate > 0 && Boolean(c.name))
114
+ .map((c) => ({
115
+ // Providers do not all return an id. Falling back to the name keeps the
116
+ // pick round-trippable; it is only ever compared against itself.
117
+ courierId: c.courierId ?? c.name,
118
+ courierName: c.name,
119
+ rawRate: c.rate,
120
+ chargedRate: roundShippingUp(c.rate),
121
+ estimatedDays: c.estimatedDays,
122
+ }))
123
+ .sort((a, b) => a.chargedRate - b.chargedRate || a.estimatedDays - b.estimatedDays);
124
+ }
package/dist/client.d.ts CHANGED
@@ -534,6 +534,7 @@ export { SELLER_LISTING_TABS, type SellerListingTabId, } from "./features/produc
534
534
  export { ALL_TAB, EMPTY_TAB, ADMIN_PRODUCT_STATUS_TABS, ADMIN_PRODUCT_LISTING_TYPE_TABS, ADMIN_BLOG_STATUS_TABS, ADMIN_USER_STATUS_TABS, ADMIN_USER_ROLE_TABS, ADMIN_STORE_STATUS_TABS, ADMIN_PAYOUT_STATUS_TABS, ADMIN_ORDER_STATUS_TABS, ADMIN_REVIEW_STATUS_TABS, ADMIN_REVIEW_RATING_TABS, ADMIN_BID_STATUS_TABS, ADMIN_CONTACT_STATUS_TABS, ADMIN_NEWSLETTER_STATUS_TABS, ADMIN_EVENT_ENTRY_STATUS_TABS, ADMIN_EVENT_STATUS_TABS, ADMIN_CART_OWNERSHIP_TABS, ADMIN_COUPON_TYPE_TABS, SELLER_PRODUCT_STATUS_TABS, SELLER_AUCTION_STATUS_TABS, SELLER_ORDER_STATUS_TABS, SELLER_OFFER_STATUS_TABS, SELLER_BID_STATUS_TABS, } from "./features/admin/constants/filter-tabs";
535
535
  export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS, BID_FIELDS, AD_FIELDS, EVENT_FIELDS, EVENT_ENTRY_FIELDS, PAYOUT_FIELDS, STORE_FIELDS, CATEGORY_FIELDS, BLOG_FIELDS, USER_FIELDS, ADDRESS_FIELDS, BRAND_FIELDS, CART_FIELDS, WISHLIST_FIELDS, HISTORY_FIELDS, NOTIFICATION_FIELDS, SESSION_FIELDS, COUPON_USAGE_FIELDS, SCAMMER_FIELDS, SUPPORT_TICKET_FIELDS, CAROUSEL_FIELDS, COUPON_FIELDS, FAQ_FIELDS, HOMEPAGE_SECTION_FIELDS, SITE_SETTINGS_FIELDS, COMMON_FIELDS, OAUTH_STATE_VALUES, SCHEMA_DEFAULTS, } from "./constants/field-names";
536
536
  export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, type CartLane, type LaneAssignable, } from "./_internal/shared/checkout/lanes";
537
+ export { roundShippingUp, toQuoteOptions, reconcileShipping, SHIPPING_ROUND_UP_TO, type ShippingQuoteOption, type SelectedShippingQuote, type ActualShipmentCost, type ShippingReconciliation, } from "./_internal/shared/shipping/quote";
537
538
  export { getCartLineMembers, isMultiMemberLine, } from "./_internal/shared/checkout/line-members";
538
539
  export { newsletterSubscribeSchema, quickCreateTaxonomySchema, payoutMarkPaidSchema, } from "./features/admin/schemas/small-forms";
539
540
  export { raffleEntrySchema } from "./features/contact/schemas/inquiry-forms";
package/dist/client.js CHANGED
@@ -546,6 +546,14 @@ export { PRODUCT_FIELDS, PRODUCT_STATUS_TRANSITIONS, ORDER_FIELDS, REVIEW_FIELDS
546
546
  // Checkout lanes — the derived auction > offer > standard partition of the
547
547
  // cart, and the priority rule that decides which one may be checked out.
548
548
  export { CART_LANE, CART_LANE_PRIORITY, CART_LANE_LABELS, laneOf, activeLane, laneItems, laneCounts, isLaneCheckoutable, laneBlockReason, isLockedLane, canAddNewItems, } from "./_internal/shared/checkout/lanes.js";
549
+ /*
550
+ * Shipping quote model. Public because the CONSUMER owns the route that asks
551
+ * the carrier for rates (`/api/shipping/quote`) and the checkout UI that
552
+ * renders them — the rounding rule has to be reachable from both, and it must
553
+ * be the same rule the server-side reconciliation uses, or the buyer is
554
+ * credited against a figure they were never charged.
555
+ */
556
+ export { roundShippingUp, toQuoteOptions, reconcileShipping, SHIPPING_ROUND_UP_TO, } from "./_internal/shared/shipping/quote.js";
549
557
  /*
550
558
  * The cart page needs these to tell a multi-member line from an ordinary one: a
551
559
  * group/bundle line's `productId` is a group or category id, not a product id,
@@ -43,6 +43,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
43
43
  import { SublistingCarouselSection } from "../../products/components/SublistingCarouselSection.js";
44
44
  import { isFinalSale } from "../../products/constants/final-sale.js";
45
45
  import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
46
+ import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
46
47
  function toDescriptionHtml(raw) {
47
48
  if (!raw)
48
49
  return "";
@@ -235,6 +236,6 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
235
236
  return (_jsx(CollapsibleBidHistory, { productId: String(product.id), initialData: bidsResult, currency: currency }));
236
237
  }, renderRelated: () => {
237
238
  const related = toRelatedAuctionCards(relatedDocs, product.id, new Date());
238
- return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
239
+ return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related })) })), _jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
239
240
  } }), renderAuctionStoreReviews(storeId), onPlaceBid && (_jsx(AuctionBottomActions, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow }))] })] }));
240
241
  }
@@ -84,6 +84,21 @@ export interface UserDocument extends BaseDocument {
84
84
  storeId?: string;
85
85
  storeSlug?: string;
86
86
  storeStatus?: "pending" | "approved" | "rejected";
87
+ /**
88
+ * Denormalised sum of this user's `pointsLedger` entries. 1 point = ₹1.
89
+ *
90
+ * 🛑 A MIRROR, and `pointsRepository` is the only thing allowed to write it —
91
+ * always in the same transaction as the ledger entry that moved it. Root
92
+ * Cause #42: a mirror maintained by more than one write path drifts, and this
93
+ * one guards money-shaped value, so drift either invents points or swallows
94
+ * earned ones.
95
+ *
96
+ * 🛑 Absence means "never computed", NOT zero. `getBalance` falls back to
97
+ * summing the ledger rather than answering 0, because every user document
98
+ * written before this field existed has no value here and answering zero
99
+ * would quietly deny people credits they hold.
100
+ */
101
+ pointsBalance?: number;
87
102
  isTester?: boolean;
88
103
  canTestAdmin?: boolean;
89
104
  publicProfile?: {
@@ -23,6 +23,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
23
23
  import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
24
24
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
25
25
  import { productImageUrls } from "../../products/schemas/firestore.js";
26
+ import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
26
27
  function toDescriptionHtml(raw) {
27
28
  if (!raw)
28
29
  return "";
@@ -84,5 +85,5 @@ export async function DigitalCodeDetailPageView({ slug, initialProduct, renderPr
84
85
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
85
86
  storeName: storeName ?? undefined,
86
87
  inStock: codesLeft > 0,
87
- }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
88
+ }) })), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "digital-code", anchorId: "digital-code-buy-bar", price: price, currency: currency, unavailable: codesLeft <= 0 })] })] }));
88
89
  }
@@ -88,7 +88,7 @@ function SlideBackground({ bg, legacy, mobileLegacy, isMobile, priority, }) {
88
88
  if (effectiveBg.type === "video") {
89
89
  return (_jsxs(_Fragment, { children: [_jsx(MediaVideo, { src: src, thumbnailUrl: effectiveBg.thumbnail ?? legacy?.thumbnail, alt: "", autoPlayMuted: true, loop: true, controls: false }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
90
90
  }
91
- return (_jsxs(_Fragment, { children: [_jsx(MediaImage, { src: src, alt: "", size: "hero", priority: priority, fetchPriority: priority ? "high" : "low" }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
91
+ return (_jsxs(_Fragment, { children: [_jsx(MediaImage, { src: src, alt: "", size: "hero", objectFit: "cover", priority: priority, fetchPriority: priority ? "high" : "low" }), effectiveBg.dimOverlay?.enabled && (_jsx(Div, { className: POSITION_FILL, style: { backgroundColor: `rgba(0,0,0,${(effectiveBg.dimOverlay.opacity ?? 0) / 100})` } }))] }));
92
92
  }
93
93
  /** Renders the background of a card (image, video, color, gradient). */
94
94
  function CardBackground({ bg }) {
@@ -102,7 +102,9 @@ function CardBackground({ bg }) {
102
102
  return (_jsx(Div, { className: POSITION_FILL, style: { background: `linear-gradient(${angle}deg, ${from}, ${to})` } }));
103
103
  }
104
104
  if (bg.type === "image" && bg.url) {
105
- 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
+ }
@@ -26,6 +26,7 @@ import { getGroupsWithItemsForProduct } from "../../../_internal/server/features
26
26
  import { ReviewsListingPanel } from "../../reviews/components/ReviewsListingPanel.js";
27
27
  import { ListingBottomActions } from "../../products/components/ListingBottomActions.js";
28
28
  import { productImageUrls } from "../../products/schemas/firestore.js";
29
+ import { PurchaseReviewAffordance } from "../../reviews/components/PurchaseReviewAffordance.js";
29
30
  function toDescriptionHtml(raw) {
30
31
  if (!raw)
31
32
  return "";
@@ -79,5 +80,5 @@ export async function LiveItemDetailPageView({ slug, initialProduct, renderActio
79
80
  price: price ?? undefined,
80
81
  storeId: typeof p.storeId === "string" ? p.storeId : undefined,
81
82
  storeName: storeName ?? undefined,
82
- } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-text-muted)]", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: toCustomSectionTabs(p.customSections) })), renderBuyBar: () => _jsx(Div, { id: "live-item-buy-bar", children: renderActions?.(product) }), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "live", anchorId: "live-item-buy-bar", price: price, currency: currency })] })] }));
83
+ } }), _jsxs(Container, { size: "xl", padding: "y-lg", children: [_jsxs(Row, { className: "mb-4", align: "center", justify: "between", gap: "sm", wrap: true, children: [_jsxs(Nav, { "aria-label": "Breadcrumb", layout: "flex-wrap", gap: "2xs", textSize: "xs", color: "muted", children: [_jsx(Link, { href: String(ROUTES.HOME), className: CLS_BREADCRUMB_LINK, children: "Home" }), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.LIVE), className: CLS_BREADCRUMB_LINK, children: "Live" }), category && (_jsxs(_Fragment, { children: [_jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: CLS_BREADCRUMB_LINK, children: categoryName || category })] })), _jsx(Span, { "aria-hidden": true, children: "/" }), _jsx(Span, { className: "truncate max-w-[200px]", color: "muted", children: title })] }), _jsx(ShareButton, { title: title })] }), _jsx(PreOrderDetailView, { renderGallery: () => _jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title }), renderInfo: () => (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_ITEM_BADGE, children: "Live Item" }), meta?.vendorVerified && (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Verified Seller" })), meta?.cites && (_jsxs(Span, { color: "warning", surface: "warning-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: ["CITES: ", meta.cites] }))] }), _jsx(OwnerQuickActions, { resource: "PRODUCT", placement: "detail", recordLabel: String(p.title ?? p.slug ?? p.id), storeId: p.storeId ? String(p.storeId) : undefined, editHref: String(ROUTES.STORE.PRODUCTS_EDIT(String(p.id))) }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title }), meta?.species && (_jsxs(Text, { className: "italic mt-1", color: "muted", size: "sm", children: [meta.species, meta.sex && meta.sex !== "n/a" && ` · ${meta.sex}`, meta.ageMonths !== undefined && ` · ${meta.ageMonths}mo`] }))] }), price !== null && (_jsx(Text, { size: "2xl", weight: "bold", color: "primary", children: _jsx(GatedPrice, { children: formatCurrency(price, currency) }) })), jurisdictions.length > 0 && (_jsxs(Div, { className: CLS_WARN_BOX, children: [_jsx(Text, { className: CLS_WARN_TITLE, children: "Delivery restrictions" }), _jsxs(Text, { className: CLS_WARN_BODY, children: ["This item can only be shipped to: ", jurisdictions.join(", ")] })] })), transport && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Transport" }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)]", children: ["Method: ", transport.method, transport.handlingFee !== undefined && (_jsx(PricesOnly, { children: ` · Handling: ${formatCurrency(transport.handlingFee, "INR")}` })), transport.insuranceIncluded && " · Insurance included"] })] })), meta?.careInfo && (_jsxs(Div, { textSize: "sm", border: "subtle", surface: "muted", padding: "md", rounded: "lg", children: [_jsx(Text, { weight: "medium", children: "Care information" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-text-muted)]", children: meta.careInfo })] })), descriptionHtml && (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] line-clamp-4" })), safeSeller && (_jsx(Div, { border: "subtle", rounded: "xl", surface: "muted", padding: "md", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Sold by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && (_jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" }))] }) }))] })), renderTabs: () => (_jsx(ProductTabsShell, { descriptionContent: descriptionHtml ? (_jsx(RichText, { html: descriptionHtml, proseClass: "prose prose-sm sm:prose max-w-none dark:prose-invert", className: "text-[var(--appkit-color-text-muted)]" })) : undefined, specsContent: specs.length > 0 ? (_jsx(SpecTable, { rows: specs })) : undefined, reviewsContent: _jsx(ReviewsListingPanel, { source: { kind: "product", productId: product.id }, stateMode: "local", context: "listing", initialData: initialReviews, emptyLabel: "No reviews yet \u2014 be the first to review this product." }), customTabs: toCustomSectionTabs(p.customSections) })), renderBuyBar: () => _jsx(Div, { id: "live-item-buy-bar", children: renderActions?.(product) }), renderRelated: () => (_jsxs(Stack, { gap: "xl", children: [_jsx(PurchaseReviewAffordance, { productId: String(product.id), storeId: product.storeId ? String(product.storeId) : undefined }), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand, storeLabel: storeName ?? undefined })] })) }), _jsx(ListingBottomActions, { listingType: "live", anchorId: "live-item-buy-bar", price: price, currency: currency })] })] }));
83
84
  }
@@ -8,6 +8,6 @@ const sizeClasses = {
8
8
  xl: "w-20 h-20",
9
9
  };
10
10
  export function MediaAvatar({ src, alt, size = "md", className = "", }) {
11
- return (_jsx(Div, { className: `relative ${sizeClasses[size]} overflow-hidden flex-shrink-0 ${className}`, rounded: "full", children: _jsx(MediaImage, { src: src, alt: alt, size: "avatar" }) }));
11
+ return (_jsx(Div, { className: `relative ${sizeClasses[size]} overflow-hidden flex-shrink-0 ${className}`, rounded: "full", children: _jsx(MediaImage, { src: src, alt: alt, size: "avatar", objectFit: "cover" }) }));
12
12
  }
13
13
  export default MediaAvatar;