@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.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/actions.d.ts +53 -0
- package/dist/_internal/server/features/checkout/actions.js +156 -8
- package/dist/_internal/server/features/orders/actions.d.ts +30 -0
- package/dist/_internal/server/features/orders/actions.js +103 -0
- package/dist/_internal/server/features/orders/adapters.js +7 -0
- package/dist/_internal/server/features/shipping/reconcile.d.ts +28 -0
- package/dist/_internal/server/features/shipping/reconcile.js +93 -0
- package/dist/_internal/shared/actions/action-registry.js +53 -0
- package/dist/_internal/shared/config/schema.d.ts +6 -0
- package/dist/_internal/shared/listing-types/route-gating.d.ts +56 -0
- package/dist/_internal/shared/listing-types/route-gating.js +114 -0
- package/dist/_internal/shared/shipping/quote.d.ts +153 -0
- package/dist/_internal/shared/shipping/quote.js +123 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +10 -0
- package/dist/features/about/components/PublicProfileView.js +2 -1
- package/dist/features/account/components/UserOrdersView.js +25 -3
- package/dist/features/admin/components/OwnerQuickActions.d.ts +1 -1
- package/dist/features/admin/components/OwnerQuickActions.js +53 -6
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -1
- package/dist/features/auth/schemas/firestore.d.ts +15 -0
- package/dist/features/blog/components/BlogPostView.js +2 -1
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/categories/components/FranchiseDetailPageView.js +3 -1
- package/dist/features/digital-codes/components/DigitalCodeDetailPageView.js +2 -1
- package/dist/features/faq/components/FAQPageContent.js +17 -4
- package/dist/features/faq/constants/help-guides.d.ts +36 -0
- package/dist/features/faq/constants/help-guides.js +64 -0
- package/dist/features/homepage/components/HeroCarousel.js +7 -3
- package/dist/features/homepage/components/MarketplaceHomepageView.js +16 -1
- package/dist/features/homepage/components/WelcomeSection.d.ts +16 -3
- package/dist/features/homepage/components/WelcomeSection.js +3 -3
- package/dist/features/homepage/lib/section-renderer.d.ts +9 -0
- package/dist/features/homepage/lib/section-renderer.js +24 -6
- package/dist/features/homepage/lib/slide-gating.d.ts +52 -0
- package/dist/features/homepage/lib/slide-gating.js +98 -0
- package/dist/features/live/components/LiveItemDetailPageView.js +2 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.d.ts +18 -1
- package/dist/features/media/MediaImage.js +1 -1
- package/dist/features/orders/components/EmiInstallmentsPanel.d.ts +19 -0
- package/dist/features/orders/components/EmiInstallmentsPanel.js +57 -0
- package/dist/features/orders/components/index.d.ts +1 -0
- package/dist/features/orders/components/index.js +1 -0
- package/dist/features/orders/schemas/firestore.d.ts +38 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +15 -0
- package/dist/features/points/repository/points.repository.d.ts +81 -0
- package/dist/features/points/repository/points.repository.js +155 -0
- package/dist/features/points/schemas/firestore.d.ts +118 -0
- package/dist/features/points/schemas/firestore.js +116 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +2 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +2 -1
- package/dist/features/products/components/ProductDetailPageView.js +3 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +3 -2
- package/dist/features/seller/actions/seller-actions.d.ts +17 -0
- package/dist/features/seller/actions/seller-actions.js +33 -0
- package/dist/features/stores/components/StoreHeader.js +3 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/next/routing/route-map.d.ts +4 -0
- package/dist/next/routing/route-map.js +2 -0
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/seed/_helpers/category-models/chunk0.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk0.js +2648 -0
- package/dist/seed/_helpers/category-models/chunk1.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk1.js +2318 -0
- package/dist/seed/_helpers/category-models/chunk2.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk2.js +2560 -0
- package/dist/seed/_helpers/category-models/chunk3.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk3.js +2196 -0
- package/dist/seed/_helpers/category-models/chunk4.d.ts +2 -0
- package/dist/seed/_helpers/category-models/chunk4.js +1732 -0
- package/dist/seed/_helpers/category-models.d.ts +9 -2
- package/dist/seed/_helpers/category-models.js +19 -1368
- package/dist/seed/carousel-slides-seed-data.js +48 -7
- package/dist/server.d.ts +3 -1
- package/dist/server.js +11 -0
- package/dist/styles.css +38 -6
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/ImageMosaic.d.ts +17 -1
- package/dist/ui/components/ImageMosaic.js +28 -5
- package/dist/ui/components/ImageMosaic.style.css +37 -5
- package/package.json +1 -1
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { THEMED_TEXT_SECONDARY, FLEX_CENTER } from "../../../_internal/shared/styles/themed.js";
|
|
4
|
-
import { Button, Div, Grid, Heading, Icon, ImageMosaic,
|
|
4
|
+
import { Button, Div, Grid, Heading, Icon, ImageMosaic, Row, Section, SiteLogo, SiteMark, Span, Stack, Text, TextLink } from "../../../ui/index.js";
|
|
5
5
|
// --- Component ---------------------------------------------------------------
|
|
6
|
-
export function WelcomeSection({ title, subtitle, pillLabel, showCTA = true, ctaLabel = "Shop now", ctaHref, onCtaClick, secondaryCtaLabel = "Browse products", secondaryCtaHref, onSecondaryCtaClick, trustChips = [], heroImages, isLoading = false, brandLogoText = "", className = "", }) {
|
|
6
|
+
export function WelcomeSection({ title, subtitle, pillLabel, showCTA = true, ctaLabel = "Shop now", ctaHref, onCtaClick, secondaryCtaLabel = "Browse products", secondaryCtaHref, onSecondaryCtaClick, trustChips = [], heroImages, isLoading = false, brandLogoText = "", brandCaption, className = "", }) {
|
|
7
7
|
const themed = { textSecondary: THEMED_TEXT_SECONDARY };
|
|
8
8
|
const flex = { center: FLEX_CENTER };
|
|
9
9
|
if (isLoading) {
|
|
10
10
|
return (_jsx(Section, { className: `relative overflow-hidden pt-[0px] pb-[var(--appkit-space-16)] md:pt-[0px] md:pb-[6rem] ${className}`, paddingX: "x-md", children: _jsxs(Div, { className: "animate-pulse max-w-4xl mx-auto text-left", children: [_jsx(Div, { className: "h-6 w-52 mb-6", surface: "subtle", rounded: "full" }), _jsx(Div, { className: "h-20 mb-4 max-w-2xl", surface: "subtle", rounded: "lg" }), _jsx(Div, { className: "h-6 mb-8 max-w-lg", surface: "subtle", rounded: "lg" }), _jsxs(Row, { justify: "start", gap: "md", children: [_jsx(Div, { className: "h-12 w-36", surface: "subtle", rounded: "xl" }), _jsx(Div, { className: "h-12 w-36", surface: "subtle", rounded: "xl" })] })] }) }));
|
|
11
11
|
}
|
|
12
|
-
return (_jsxs(Section, { className: `relative overflow-hidden pt-[0px] pb-[var(--appkit-space-20)] md:pt-[0px] md:pb-[7rem] ${className}`, paddingX: "x-md", children: [_jsx(Div, { className: "pointer-events-none absolute -top-32 -left-32 w-[28rem] h-[28rem] bg-primary/10 blur-3xl", rounded: "full", "aria-hidden": "true" }), _jsx(Div, { className: "pointer-events-none absolute -bottom-40 -right-40 w-[36rem] h-[36rem] bg-secondary/10 dark:bg-secondary/15 blur-3xl", rounded: "full", "aria-hidden": "true" }), _jsx(Div, { className: "relative z-10 max-w-7xl mx-auto", children: _jsxs(Grid, { align: "center", gap: "2xl", className: "grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2", children: [_jsxs(Div, { className: "text-left", children: [pillLabel && (_jsx(Div, { children: _jsxs(Span, { layout: "inline-flex", gap: "md", size: "xs", weight: "medium", className: /* audit-variant-ok: colour+alpha border (primary-500/30); the `border` variant takes a token name, not an rgba step */ "border border-primary-500/30 bg-primary-500/10 px-[1.25rem] py-[0.375rem] tracking-[0.2em] text-primary-700 dark:text-primary-400 backdrop-blur-sm", rounded: "full", transform: "uppercase", children: [_jsx(Span, { className: "w-1.5 h-1.5 bg-primary-500 inline-block", rounded: "full", "aria-hidden": "true" }), pillLabel, _jsx(Span, { className: "w-1.5 h-1.5 bg-primary-500 inline-block", rounded: "full", "aria-hidden": "true" })] }) })), _jsx(Heading, { level: 1, variant: "none", gradient: "brand-tri", className: "mt-4 font-display leading-[1.1] tracking-tight break-words", size: "5xl", mdSize: "6xl", lgSize: "7xl", xlSize: "7xl", children: title }), subtitle && (_jsx(Text, { className: `mt-4 ${themed.textSecondary} max-w-xl leading-relaxed`, size: "xl", children: subtitle })), showCTA && (_jsxs(Row, { wrap: true, gap: "md", className: "mt-8", justify: "start", children: [ctaHref ? (_jsx(TextLink, { rounded: "xl", href: ctaHref, layout: "inline-flex", align: "center", justify: "center", className: "px-[2rem] py-[0.875rem] !bg-primary hover:!bg-primary-600 text-white dark:!bg-primary dark:hover:!bg-primary-600 dark:text-white btn-glow transition-all hover:scale-[1.02]", size: "base", weight: "bold", children: ctaLabel })) : (_jsx(Button, { variant: "primary", size: "lg", onClick: onCtaClick, children: ctaLabel })), secondaryCtaHref ? (_jsx(TextLink, { rounded: "xl", href: secondaryCtaHref, layout: "inline-flex", align: "center", justify: "center", className: "border-2 border-cobalt/40 dark:border-cobalt-400/40 px-[2rem] py-[0.875rem] text-cobalt-700 dark:text-cobalt-300 hover:bg-primary-surface transition-all hover:scale-[1.02]", size: "base", weight: "semibold", children: secondaryCtaLabel })) : (_jsx(Button, { variant: "outline", size: "lg", onClick: onSecondaryCtaClick, children: secondaryCtaLabel }))] })), trustChips.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mt-6", justify: "start", children: trustChips.map((chip) => (_jsxs(Row, { align: "center", gap: "xs", surface: "default", border: "subtle", rounded: "full", shadow: "sm", className: "px-[0.875rem] py-[0.375rem]", children: [_jsx(Icon, { name: chip.iconName, size: "sm", tone: "success" }), _jsx(Span, { size: "xs", weight: "medium", color: "muted", children: chip.label })] }, chip.key))) }))] }), _jsx(Div, { className: "hidden lg:block", children:
|
|
12
|
+
return (_jsxs(Section, { className: `relative overflow-hidden pt-[0px] pb-[var(--appkit-space-20)] md:pt-[0px] md:pb-[7rem] ${className}`, paddingX: "x-md", children: [_jsx(Div, { className: "pointer-events-none absolute -top-32 -left-32 w-[28rem] h-[28rem] bg-primary/10 blur-3xl", rounded: "full", "aria-hidden": "true" }), _jsx(Div, { className: "pointer-events-none absolute -bottom-40 -right-40 w-[36rem] h-[36rem] bg-secondary/10 dark:bg-secondary/15 blur-3xl", rounded: "full", "aria-hidden": "true" }), _jsx(Div, { className: "relative z-10 max-w-7xl mx-auto", children: _jsxs(Grid, { align: "center", gap: "2xl", className: "grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2", children: [_jsxs(Div, { className: "text-left", children: [pillLabel && (_jsx(Div, { children: _jsxs(Span, { layout: "inline-flex", gap: "md", size: "xs", weight: "medium", className: /* audit-variant-ok: colour+alpha border (primary-500/30); the `border` variant takes a token name, not an rgba step */ "border border-primary-500/30 bg-primary-500/10 px-[1.25rem] py-[0.375rem] tracking-[0.2em] text-primary-700 dark:text-primary-400 backdrop-blur-sm", rounded: "full", transform: "uppercase", children: [_jsx(Span, { className: "w-1.5 h-1.5 bg-primary-500 inline-block", rounded: "full", "aria-hidden": "true" }), pillLabel, _jsx(Span, { className: "w-1.5 h-1.5 bg-primary-500 inline-block", rounded: "full", "aria-hidden": "true" })] }) })), _jsx(Heading, { level: 1, variant: "none", gradient: "brand-tri", className: "mt-4 font-display leading-[1.1] tracking-tight break-words", size: "5xl", mdSize: "6xl", lgSize: "7xl", xlSize: "7xl", children: title }), subtitle && (_jsx(Text, { className: `mt-4 ${themed.textSecondary} max-w-xl leading-relaxed`, size: "xl", children: subtitle })), showCTA && (_jsxs(Row, { wrap: true, gap: "md", className: "mt-8", justify: "start", children: [ctaHref ? (_jsx(TextLink, { rounded: "xl", href: ctaHref, layout: "inline-flex", align: "center", justify: "center", className: "px-[2rem] py-[0.875rem] !bg-primary hover:!bg-primary-600 text-white dark:!bg-primary dark:hover:!bg-primary-600 dark:text-white btn-glow transition-all hover:scale-[1.02]", size: "base", weight: "bold", children: ctaLabel })) : (_jsx(Button, { variant: "primary", size: "lg", onClick: onCtaClick, children: ctaLabel })), secondaryCtaHref ? (_jsx(TextLink, { rounded: "xl", href: secondaryCtaHref, layout: "inline-flex", align: "center", justify: "center", className: "border-2 border-cobalt/40 dark:border-cobalt-400/40 px-[2rem] py-[0.875rem] text-cobalt-700 dark:text-cobalt-300 hover:bg-primary-surface transition-all hover:scale-[1.02]", size: "base", weight: "semibold", children: secondaryCtaLabel })) : (_jsx(Button, { variant: "outline", size: "lg", onClick: onSecondaryCtaClick, children: secondaryCtaLabel }))] })), trustChips.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", className: "mt-6", justify: "start", children: trustChips.map((chip) => (_jsxs(Row, { align: "center", gap: "xs", surface: "default", border: "subtle", rounded: "full", shadow: "sm", className: "px-[0.875rem] py-[0.375rem]", children: [_jsx(Icon, { name: chip.iconName, size: "sm", tone: "success" }), _jsx(Span, { size: "xs", weight: "medium", color: "muted", children: chip.label })] }, chip.key))) }))] }), _jsx(Div, { className: "hidden lg:block", children: _jsx(ImageMosaic, { size: "hero", images: heroImages ?? [], alt: `${brandLogoText || "Marketplace"} collection highlights`, leadSlot: _jsxs(Div, { className: "absolute inset-0 bg-[image:var(--appkit-gradient-section-mesh)]", children: [_jsx(Div, { className: "absolute inset-0 bg-[image:var(--appkit-gradient-glass)]" }), _jsxs(Stack, { centered: true, gap: "sm", padding: "xl", className: `absolute inset-0 ${flex.center}`, children: [_jsx(SiteMark, { title: brandLogoText || "LetItRip.in", size: "xl" }), _jsx(SiteLogo, { title: brandLogoText || "LetItRip.in", size: "xl" }), brandCaption && (_jsx(Text, { align: "center", size: "sm", weight: "semibold", transform: "uppercase", className: "tracking-widest text-primary-700 dark:text-primary-300", children: brandCaption }))] })] }) }) })] }) })] }));
|
|
13
13
|
}
|
|
@@ -46,6 +46,15 @@ export interface HomepageBrand {
|
|
|
46
46
|
name?: string;
|
|
47
47
|
/** Short mark shown in the hero panel, e.g. "LT". */
|
|
48
48
|
shortName?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Caption under the hero brand panel's logo, e.g. "Let It Rip India".
|
|
51
|
+
*
|
|
52
|
+
* Separate from `name` because that one is a sentence ingredient ("Why
|
|
53
|
+
* Buyers Trust LetItRip") and this one is a standalone lockup line — they
|
|
54
|
+
* are spelled differently on purpose and collapsing them would force one of
|
|
55
|
+
* the two to read wrong.
|
|
56
|
+
*/
|
|
57
|
+
heroCaption?: string;
|
|
49
58
|
}
|
|
50
59
|
/**
|
|
51
60
|
* Every surface a `SectionDef` may be mounted on.
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { ROUTES } from "../../../next/routing/route-map.js";
|
|
4
|
+
/* Defining module, not the `ui`/root barrel — Root Cause #18. This is the one
|
|
5
|
+
owner of "what a generated placeholder tile looks like"; the seed helper's
|
|
6
|
+
`isSeedPlaceholderImage` reads the same prefix from its own copy, and both
|
|
7
|
+
are documented as mirrors of `public/images/seed-tiles/`. */
|
|
8
|
+
import { SEED_TILE_PREFIX } from "../../../utils/media-url.js";
|
|
4
9
|
import { cleanTitle, parseWelcomeDescription } from "./section-helpers.js";
|
|
5
10
|
import { DEFAULT_TRUST_FEATURES, DEFAULT_SECURITY_ITEMS } from "./section-defaults.js";
|
|
6
11
|
import { SECTION_TITLE, SECTION_COPY, VIEW_MORE_LABEL, WELCOME_TRUST_CHIPS, } from "../constants/section-copy.js";
|
|
@@ -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
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
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 = "
|
|
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;
|