@mohasinac/appkit 4.55.0 → 4.57.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 +53 -0
  3. package/dist/_internal/server/features/checkout/actions.js +156 -8
  4. package/dist/_internal/server/features/orders/actions.d.ts +30 -0
  5. package/dist/_internal/server/features/orders/actions.js +103 -0
  6. package/dist/_internal/server/features/orders/adapters.js +7 -0
  7. package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
  8. package/dist/_internal/server/features/shipping/reconcile.js +93 -0
  9. package/dist/_internal/shared/actions/action-registry.js +53 -0
  10. package/dist/_internal/shared/config/schema.d.ts +6 -0
  11. package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
  12. package/dist/_internal/shared/listing-types/route-gating.js +114 -0
  13. package/dist/_internal/shared/shipping/quote.d.ts +153 -0
  14. package/dist/_internal/shared/shipping/quote.js +123 -0
  15. package/dist/client.d.ts +22 -0
  16. package/dist/client.js +10 -0
  17. package/dist/features/about/components/PublicProfileView.js +2 -1
  18. package/dist/features/account/components/UserOrdersView.js +25 -3
  19. package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
  20. package/dist/features/admin/components/OwnerQuickActions.js +53 -6
  21. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
  22. package/dist/features/auth/schemas/firestore.d.ts +15 -0
  23. package/dist/features/blog/components/BlogPostView.js +2 -1
  24. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  25. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  26. package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
  27. package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
  28. package/dist/features/faq/components/FAQPageContent.js +17 -4
  29. package/dist/features/faq/constants/help-guides.d.ts +36 -0
  30. package/dist/features/faq/constants/help-guides.js +64 -0
  31. package/dist/features/homepage/components/HeroCarousel.js +7 -3
  32. package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
  33. package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
  34. package/dist/features/homepage/components/WelcomeSection.js +3 -3
  35. package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
  36. package/dist/features/homepage/lib/section-renderer.js +24 -6
  37. package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
  38. package/dist/features/homepage/lib/slide-gating.js +98 -0
  39. package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
  40. package/dist/features/media/MediaAvatar.js +1 -1
  41. package/dist/features/media/MediaImage.d.ts +18 -1
  42. package/dist/features/media/MediaImage.js +1 -1
  43. package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
  44. package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
  45. package/dist/features/orders/components/index.d.ts +1 -0
  46. package/dist/features/orders/components/index.js +1 -0
  47. package/dist/features/orders/schemas/firestore.d.ts +38 -0
  48. package/dist/features/orders/schemas/index.d.ts +2 -2
  49. package/dist/features/orders/types/index.d.ts +15 -0
  50. package/dist/features/points/repository/points.repository.d.ts +81 -0
  51. package/dist/features/points/repository/points.repository.js +155 -0
  52. package/dist/features/points/schemas/firestore.d.ts +118 -0
  53. package/dist/features/points/schemas/firestore.js +116 -0
  54. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
  55. package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
  56. package/dist/features/products/components/ProductDetailPageView.js +3 -1
  57. package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
  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/features/stores/components/StoreHeader.js +3 -1
  61. package/dist/index.d.ts +1 -0
  62. package/dist/index.js +8 -0
  63. package/dist/next/routing/route-map.d.ts +4 -0
  64. package/dist/next/routing/route-map.js +2 -0
  65. package/dist/schemas/registry.d.ts +2 -2
  66. package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
  67. package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
  68. package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
  69. package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
  70. package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
  71. package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
  72. package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
  73. package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
  74. package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
  75. package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
  76. package/dist/seed/_helpers/category-models.d.ts +9 -2
  77. package/dist/seed/_helpers/category-models.js +19 -1368
  78. package/dist/seed/carousel-slides-seed-data.js +48 -7
  79. package/dist/server.d.ts +3 -1
  80. package/dist/server.js +11 -0
  81. package/dist/styles.css +38 -6
  82. package/dist/tailwind-utilities.css +1 -1
  83. package/dist/ui/components/ImageMosaic.d.ts +17 -1
  84. package/dist/ui/components/ImageMosaic.js +28 -5
  85. package/dist/ui/components/ImageMosaic.style.css +37 -5
  86. package/package.json +1 -1
@@ -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";
@@ -43,6 +48,7 @@ import { CollectionCardsSection } from "../components/CollectionCardsSection.js"
43
48
  import { HowItWorksSection } from "../components/HowItWorksSection.js";
44
49
  import { TestimonialsCarousel } from "../components/TestimonialsCarousel.js";
45
50
  import { PromoGrid } from "../components/PromoGrid.js";
51
+ import { OwnerQuickActions } from "../../admin/components/OwnerQuickActions.js";
46
52
  const AD_SLOT_MAP = {
47
53
  carousel: "afterHero",
48
54
  products: "afterFeaturedProducts",
@@ -76,21 +82,33 @@ const SECTION_REGISTRY = {
76
82
  /*
77
83
  * Hero imagery, taken from the featured categories' own covers.
78
84
  *
79
- * Deterministic — the first five in CONFIGURED order, never shuffled, so
85
+ * Deterministic — the first few in CONFIGURED order, never shuffled, so
80
86
  * the server and client render the same mosaic (ImageMosaic's header is
81
87
  * explicit about why `Math.random()` is wrong here).
82
88
  *
89
+ * 🛑 PLACEHOLDERS ARE DROPPED, and on this install that is most of them.
90
+ * `seedPhoto()` returns one of six flat SVG tiles under
91
+ * `SEED_TILE_PREFIX` whenever the seed has no real artwork, and
92
+ * `listTopLevelCategories` returns exactly the rows that have none —
93
+ * measured against production, 4 of the first 12 covers were tiles and
94
+ * the hero's large cell was `/images/seed-tiles/312e81.svg`. Those are
95
+ * not photographs of anything; passing them through meant the hero
96
+ * advertised the catalogue with coloured rectangles.
97
+ *
98
+ * Four, not five: the brand panel now owns the large cell, so the mosaic
99
+ * has one fewer slot to fill (see `ImageMosaic.leadSlot`).
100
+ *
83
101
  * 🛑 `sectionData.categories` is only populated when the page also has a
84
102
  * `categories` section enabled, because MarketplaceHomepageView gates
85
103
  * each fetch on `activeTypes`. `welcome` is registered as a trigger for
86
104
  * that fetch for exactly this reason; without it the mosaic silently
87
- * falls back to the brand panel and nothing says why.
105
+ * collapses to the brand panel alone and nothing says why.
88
106
  */
89
107
  const heroImages = (sectionData.categories ?? [])
90
108
  .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,
109
+ .filter((src) => Boolean(src) && !src.startsWith(SEED_TILE_PREFIX))
110
+ .slice(0, 4);
111
+ return (_jsx(WelcomeSection, { heroImages: heroImages, brandCaption: brand?.heroCaption, title: cleanTitle(cfg?.h1) || SECTION_TITLE.welcome,
94
112
  // A real `subtitle` wins; `description` is the legacy source and is
95
113
  // still parsed for documents that only carry it.
96
114
  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 }));
@@ -416,6 +434,6 @@ export function renderSection(section, adSlots, newsletterFormSlot, faqItems, sl
416
434
  if (!sectionElement)
417
435
  return null;
418
436
  const adSlotKey = AD_SLOT_MAP[section.type];
419
- return (_jsxs(React.Fragment, { children: [_jsx(HomepageSectionBoundary, { sectionId: section.id, sectionType: section.type, children: sectionElement }), adSlots && adSlotKey !== undefined && adSlotKey in adSlots && adSlots[adSlotKey]] }, section.id));
437
+ return (_jsxs(React.Fragment, { children: [_jsx(OwnerQuickActions, { resource: "SECTION", placement: "detail", className: "justify-end", recordLabel: section.type, editHref: String(ROUTES.ADMIN.SECTIONS) }), _jsx(HomepageSectionBoundary, { sectionId: section.id, sectionType: section.type, children: sectionElement }), adSlots && adSlotKey !== undefined && adSlotKey in adSlots && adSlots[adSlotKey]] }, section.id));
420
438
  }
421
439
  export { AnnouncementBar, hashBannerMessage };
@@ -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;
@@ -24,7 +24,24 @@ export interface MediaImageProps {
24
24
  * one slide the visitor can actually see.
25
25
  */
26
26
  fetchPriority?: "high" | "low" | "auto";
27
- /** CSS object-fit applied to the underlying img element. Defaults to `'cover'`. */
27
+ /**
28
+ * CSS object-fit applied to the underlying img element. Defaults to
29
+ * `'contain'`.
30
+ *
31
+ * 🛑 THE DEFAULT CHANGED on 2026-10-20, from `'cover'`. `cover` fills the
32
+ * box by cropping whatever does not fit, which is right for a photograph
33
+ * that continues past its frame and wrong for this catalogue: most of its
34
+ * imagery is a product shot centred on white, where the crop takes the
35
+ * product and leaves the background. `contain` fits the whole subject
36
+ * inside the shape and letterboxes the remainder against the tile's own
37
+ * surface colour.
38
+ *
39
+ * Pass `'cover'` explicitly where a FILLED box is the component's contract
40
+ * rather than a preference — a full-bleed backdrop with text over it, or a
41
+ * circular avatar. Three call sites do: `MediaAvatar`, and the slide and
42
+ * card backgrounds in `HeroCarousel`. A letterboxed backdrop there would put
43
+ * the dim overlay and the headline on bare surface.
44
+ */
28
45
  objectFit?: "cover" | "contain";
29
46
  /**
30
47
  * Emoji or text to show when `src` is undefined.
@@ -67,7 +67,7 @@ function resolveSrcSet(input) {
67
67
  .filter(Boolean)
68
68
  .join(", ");
69
69
  }
70
- export function MediaImage({ src, alt, size = "card", priority = false, loading, fetchPriority, objectFit = "cover", fallback, className, rounded, shadow, style, sources, onError, }) {
70
+ export function MediaImage({ src, alt, size = "card", priority = false, loading, fetchPriority, objectFit = "contain", fallback, className, rounded, shadow, style, sources, onError, }) {
71
71
  const [hasError, setHasError] = useState(false);
72
72
  const [isLoaded, setIsLoaded] = useState(false);
73
73
  const [retryCount, setRetryCount] = useState(0);
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import type { EmiInstallment } from "../schemas/firestore";
3
+ export interface EmiInstallmentsPanelProps {
4
+ orderId: string;
5
+ installments: readonly EmiInstallment[];
6
+ /** Sum of unpaid instalments, as recorded on the order. */
7
+ remainingBalance?: number;
8
+ currency?: string;
9
+ /**
10
+ * Submits one instalment. Supplied by the consumer so this component does
11
+ * no fetching of its own — the same slot shape the rest of the orders
12
+ * feature uses.
13
+ */
14
+ onSubmit: (input: {
15
+ installmentIndex: number;
16
+ transactionId: string;
17
+ }) => Promise<void>;
18
+ }
19
+ export declare function EmiInstallmentsPanel({ orderId, installments, remainingBalance, currency, onSubmit, }: EmiInstallmentsPanelProps): React.JSX.Element;
@@ -0,0 +1,57 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { Badge, Button, Div, Form, Row, Stack, Text, useToast } from "../../../ui/index.js";
5
+ import { FieldInput } from "../../../ui/forms/index.js";
6
+ import { formatCurrency } from "../../../utils/number.formatter.js";
7
+ import { normalizeError } from "../../../errors/normalize.js";
8
+ function stateOf(i) {
9
+ if (i.status === "paid")
10
+ return "paid";
11
+ // A reference the buyer submitted but nobody has confirmed. The status
12
+ // field cannot express this: an instalment is created "pending" and stays
13
+ // "pending" until it is paid, so the presence of a reference is the signal.
14
+ if (i.transactionId)
15
+ return "awaiting-review";
16
+ return "payable";
17
+ }
18
+ const STATE_LABEL = {
19
+ paid: "Paid",
20
+ "awaiting-review": "Awaiting confirmation",
21
+ payable: "Due",
22
+ };
23
+ export function EmiInstallmentsPanel({ orderId, installments, remainingBalance, currency = "INR", onSubmit, }) {
24
+ const { showToast } = useToast();
25
+ const [openIndex, setOpenIndex] = useState(null);
26
+ const [reference, setReference] = useState("");
27
+ const [isSubmitting, setIsSubmitting] = useState(false);
28
+ const money = (value) => formatCurrency(value, currency);
29
+ const ordered = [...installments].sort((a, b) => a.index - b.index);
30
+ const outstanding = ordered.filter((i) => i.status !== "paid").length;
31
+ const submit = async (index) => {
32
+ setIsSubmitting(true);
33
+ try {
34
+ await onSubmit({ installmentIndex: index, transactionId: reference.trim() });
35
+ showToast("Payment reference submitted — the seller will confirm it.", "success");
36
+ setOpenIndex(null);
37
+ setReference("");
38
+ }
39
+ catch (err) {
40
+ void normalizeError(err);
41
+ // The action's own message is surfaced by the caller's error mapping;
42
+ // this only has to stop the panel pretending the submission landed.
43
+ showToast("Couldn't submit that payment. Please try again.", "error");
44
+ }
45
+ finally {
46
+ setIsSubmitting(false);
47
+ }
48
+ };
49
+ return (_jsx(Div, { surface: "card", border: "default", rounded: "lg", padding: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", children: [_jsx(Text, { weight: "semibold", children: "Your EMI plan" }), remainingBalance !== undefined && remainingBalance > 0 && (_jsxs(Text, { size: "sm", color: "muted", children: [money(remainingBalance), " outstanding across ", outstanding, " instalment", outstanding === 1 ? "" : "s"] }))] }), _jsx(Text, { size: "xs", color: "muted", children: "You can pay any instalment whenever you like, including ahead of its due date \u2014 paying early settles the plan sooner and does not cost more." }), _jsx(Stack, { gap: "xs", children: ordered.map((inst) => {
50
+ const state = stateOf(inst);
51
+ const due = inst.dueDate ? new Date(inst.dueDate) : null;
52
+ return (_jsxs(Div, { border: "default", rounded: "md", padding: "sm", children: [_jsxs(Row, { align: "center", justify: "between", gap: "sm", className: "min-w-0", children: [_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsxs(Text, { size: "sm", weight: "medium", children: ["Instalment ", inst.index, " \u00B7 ", money(inst.amount)] }), _jsx(Text, { size: "xs", color: "muted", children: due ? `Due ${due.toLocaleDateString()}` : "No due date recorded" })] }), _jsxs(Row, { align: "center", gap: "xs", className: "flex-shrink-0", children: [_jsx(Badge, { variant: state === "paid" ? "success" : state === "awaiting-review" ? "warning" : "default", children: STATE_LABEL[state] }), state === "payable" && (_jsx(Button, { type: "button", size: "sm", variant: "secondary", onClick: () => {
53
+ setOpenIndex(openIndex === inst.index ? null : inst.index);
54
+ setReference("");
55
+ }, children: openIndex === inst.index ? "Cancel" : "Pay now" }))] })] }), openIndex === inst.index && (_jsx(Div, { className: "mt-3", children: _jsx(Form, { onSubmit: (e) => e.preventDefault(), children: () => (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Transfer ", money(inst.amount), " to the seller, then enter the UTR or reference number so they can confirm it."] }), _jsx(FieldInput, { name: `emi-reference-${orderId}-${inst.index}`, label: "UTR / reference number", required: true, value: reference, onChange: setReference }), _jsx(Button, { type: "submit", size: "sm", isLoading: isSubmitting, disabled: reference.trim().length === 0 || isSubmitting, onClick: () => { void submit(inst.index); }, children: "Submit payment reference" })] })) }) }))] }, inst.index));
56
+ }) })] }) }));
57
+ }
@@ -14,3 +14,4 @@ export { RefundRequestView } from "./RefundRequestView";
14
14
  export type { RefundRequestViewProps } from "./RefundRequestView";
15
15
  export { YourPurchaseStrip } from "./YourPurchaseStrip";
16
16
  export type { YourPurchaseStripProps, PurchaseContext } from "./YourPurchaseStrip";
17
+ export { EmiInstallmentsPanel, type EmiInstallmentsPanelProps, } from "./EmiInstallmentsPanel";
@@ -7,3 +7,4 @@ export { OrderStatusTimeline } from "./OrderStatusTimeline.js";
7
7
  export { RefundHistoryTable } from "./RefundHistoryTable.js";
8
8
  export { RefundRequestView } from "./RefundRequestView.js";
9
9
  export { YourPurchaseStrip } from "./YourPurchaseStrip.js";
10
+ export { EmiInstallmentsPanel, } from "./EmiInstallmentsPanel.js";
@@ -8,6 +8,7 @@ import type { ListingType } from "../../products/types";
8
8
  import type { StatusChangeEntry } from "../../../_internal/shared/history/index";
9
9
  import type { ReturnReason } from "../../../_internal/shared/features/orders/return-reasons";
10
10
  import type { ReturnCustodyState } from "../../../_internal/shared/features/returns/custody";
11
+ import type { ActualShipmentCost, SelectedShippingQuote, ShippingReconciliation } from "../../../_internal/shared/shipping/quote";
11
12
  import type { Encrypted, Plaintext } from "../../../security/encrypted";
12
13
  export interface OrderSourceStandard {
13
14
  path: "standard";
@@ -550,6 +551,43 @@ export interface OrderDocument extends BaseDocument {
550
551
  shippingMethod?: ShippingMethod;
551
552
  shippingCarrier?: string;
552
553
  trackingUrl?: string;
554
+ /**
555
+ * The courier the BUYER picked at checkout, and what they were charged for
556
+ * it, frozen at order creation.
557
+ *
558
+ * 🛑 Stored, never recomputed. Carrier rates move hourly, so re-quoting at
559
+ * reconciliation time would compare the seller's real spend against a price
560
+ * nobody was ever charged, and the buyer's refund would depend on when a job
561
+ * happened to run. Same reasoning as `sourceContext`.
562
+ *
563
+ * `chargedRate` is the ₹50-rounded figure and is the ONLY one that is
564
+ * billed; `rawRate` is kept for the audit trail and is never rendered.
565
+ * Absent on orders placed before the picker shipped, and on any order whose
566
+ * seller ships manually — so every reader must treat absence as "no quote
567
+ * was taken", never as zero.
568
+ */
569
+ selectedShippingQuote?: SelectedShippingQuote;
570
+ /**
571
+ * What the seller actually spent on postage, recorded at dispatch.
572
+ *
573
+ * Written by the seller's ship action. Its presence is what makes an order
574
+ * eligible for reconciliation; its absence means the parcel has not been
575
+ * dispatched or the seller has not told us what it cost.
576
+ */
577
+ actualShipmentCost?: ActualShipmentCost;
578
+ /**
579
+ * The outcome of comparing the two above — computed ONCE, when the seller
580
+ * dispatches, and then left alone.
581
+ *
582
+ * 🛑 Its presence is the idempotency key for the points credit. Crediting is
583
+ * an append to a ledger, so a job that ran twice would pay the buyer twice
584
+ * and nothing downstream could tell the difference between that and two
585
+ * genuine savings. Every writer must check this field is unset before
586
+ * writing a `shipping_saving` entry.
587
+ */
588
+ shippingReconciliation?: ShippingReconciliation & {
589
+ reconciledAt: Date;
590
+ };
553
591
  /**
554
592
  * The carrier's label PDF, once a shipment has been booked.
555
593
  *
@@ -614,6 +614,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
614
614
  } | undefined;
615
615
  offerId?: string | undefined;
616
616
  orderType?: string | undefined;
617
+ isNonRefundable?: boolean | undefined;
617
618
  imageUrls?: string[] | undefined;
618
619
  refundFeeDeducted?: number | undefined;
619
620
  refundNetAmount?: number | undefined;
@@ -650,7 +651,6 @@ export declare const orderFirestoreSchema: z.ZodObject<{
650
651
  }[] | undefined;
651
652
  emiRemainingBalance?: number | undefined;
652
653
  emiComplete?: boolean | undefined;
653
- isNonRefundable?: boolean | undefined;
654
654
  paymentBatchId?: string | undefined;
655
655
  refunds?: {
656
656
  type: "full" | "partial";
@@ -762,6 +762,7 @@ export declare const orderFirestoreSchema: z.ZodObject<{
762
762
  } | undefined;
763
763
  offerId?: string | undefined;
764
764
  orderType?: string | undefined;
765
+ isNonRefundable?: boolean | undefined;
765
766
  imageUrls?: string[] | undefined;
766
767
  refundFeeDeducted?: number | undefined;
767
768
  refundNetAmount?: number | undefined;
@@ -798,7 +799,6 @@ export declare const orderFirestoreSchema: z.ZodObject<{
798
799
  }[] | undefined;
799
800
  emiRemainingBalance?: number | undefined;
800
801
  emiComplete?: boolean | undefined;
801
- isNonRefundable?: boolean | undefined;
802
802
  paymentBatchId?: string | undefined;
803
803
  refunds?: {
804
804
  type: "full" | "partial";
@@ -1,3 +1,4 @@
1
+ import type { EmiInstallment } from "../schemas/firestore";
1
2
  import type { ListingType } from "../../products/types/index";
2
3
  import type { OrderType } from "../utils/order-splitter";
3
4
  import type { AppliedOrderDiscount, OrderSourceContext } from "../schemas/firestore";
@@ -183,6 +184,20 @@ export interface Order {
183
184
  * read it from site settings, because `gst` is in `PRIVATE_SITE_FIELDS`
184
185
  * after an earlier deny-list projection shipped it publicly (Root Cause #70).
185
186
  */
187
+ /**
188
+ * ── EMI, buyer-facing ─────────────────────────────────────────────────
189
+ *
190
+ * 🛑 Declared here because `EmiInstallmentsPanel` is useless without it,
191
+ * and the absence was Root Cause #57's exact shape: the whole EMI schedule
192
+ * sat on `OrderDocument` and `orderDocumentToOrder` mapped none of it, so
193
+ * every buyer surface would have rendered its "no EMI plan" fallback with
194
+ * a real plan on the record. That reads as "this order isn't on EMI"
195
+ * rather than as a bug, which is why it survives.
196
+ */
197
+ emiEnabled?: boolean;
198
+ emiInstallments?: EmiInstallment[];
199
+ /** Sum of unpaid instalments. 0 once the plan is settled. */
200
+ emiRemainingBalance?: number;
186
201
  supplierGstin?: string;
187
202
  supplierLegalName?: string;
188
203
  supplierAddress?: string;