@mohasinac/appkit 4.33.1 → 4.34.1
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/_internal/server/features/checkout/actions.js +9 -1
- package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
- package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
- package/dist/_internal/server/features/media/contextGuards.js +19 -5
- package/dist/_internal/server/features/orders/actions.d.ts +9 -0
- package/dist/_internal/server/features/orders/actions.js +47 -4
- package/dist/_internal/server/features/orders/adapters.js +10 -0
- package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
- package/dist/_internal/server/features/refunds/actions.js +36 -3
- package/dist/_internal/server/features/seo/sitemap.js +2 -2
- package/dist/_internal/server/features/site-settings/adapters.js +1 -1
- package/dist/_internal/server/functions/scheduled.d.ts +1 -2
- package/dist/_internal/server/functions/scheduled.js +8 -9
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +15 -0
- package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +15 -6
- package/dist/_internal/server/jobs/handlers/index.d.ts +0 -1
- package/dist/_internal/server/jobs/handlers/index.js +0 -1
- package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
- package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
- package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
- package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
- package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
- package/dist/_internal/shared/features/orders/schema.js +20 -0
- package/dist/_internal/shared/features/products/schema.d.ts +12 -0
- package/dist/_internal/shared/features/products/schema.js +4 -1
- package/dist/_internal/shared/media/limits.d.ts +14 -0
- package/dist/_internal/shared/media/limits.js +29 -0
- package/dist/client.d.ts +9 -1
- package/dist/client.js +19 -1
- package/dist/features/admin/components/AdminProductEditorView.js +6 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
- package/dist/features/admin/components/index.d.ts +0 -2
- package/dist/features/admin/components/index.js +0 -1
- package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
- package/dist/features/admin/schemas/firestore.d.ts +0 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
- package/dist/features/auth/schemas/firestore.d.ts +1 -0
- package/dist/features/contact/components/ContactForm.js +46 -31
- package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
- package/dist/features/media/upload/MediaUploadList.js +58 -8
- package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
- package/dist/features/orders/components/RefundRequestView.js +108 -24
- package/dist/features/orders/schemas/firestore.d.ts +42 -0
- package/dist/features/orders/types/index.d.ts +24 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
- package/dist/features/products/components/ProductDetailPageView.js +14 -2
- package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
- package/dist/features/products/components/ProductFeatureBadges.js +17 -1
- package/dist/features/products/components/ProductForm.js +46 -10
- package/dist/features/products/components/ProductGrid.js +10 -1
- package/dist/features/products/constants/final-sale.d.ts +40 -0
- package/dist/features/products/constants/final-sale.js +40 -0
- package/dist/features/products/index.d.ts +1 -0
- package/dist/features/products/index.js +1 -0
- package/dist/features/products/schemas/firestore.d.ts +19 -2
- package/dist/features/products/schemas/firestore.js +4 -0
- package/dist/features/products/types/index.d.ts +2 -0
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
- package/dist/features/seller/components/SellerProductShell.js +82 -11
- package/dist/features/seller/index.d.ts +1 -0
- package/dist/features/seller/index.js +1 -0
- package/dist/features/seller/schemas/product-form.d.ts +304 -0
- package/dist/features/seller/schemas/product-form.js +132 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
- package/dist/features/seller/utils/product-draft-mapping.js +147 -0
- package/dist/features/shell/SectionForm.js +62 -7
- package/dist/features/shell/form-sections.js +4 -0
- package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +16 -1
- package/dist/features/tester/repository/tester-checklist-item.repository.js +32 -6
- package/dist/features/tester/seed-data/events-tester-seed-data.js +77 -5
- package/dist/features/tester/seed-data/offers-tester-seed-data.js +23 -9
- package/dist/features/tester/seed-data/products-tester-seed-data.js +65 -49
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
- package/dist/features/tester/seed-data/tester-window.d.ts +25 -0
- package/dist/features/tester/seed-data/tester-window.js +66 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +12 -3
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/schemas/registry.d.ts +271 -0
- package/dist/schemas/registry.js +9 -0
- package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
- package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
- package/dist/seed/products-art-seed-data.js +9 -1
- package/dist/seed/products-auctions-seed-data.js +2 -1
- package/dist/seed/products-classifieds-seed-data.js +2 -1
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.d.ts +2 -138
- package/dist/seed/products-preorders-seed-data.js +2 -1
- package/dist/seed/products-prize-draws-seed-data.js +2 -1
- package/dist/seed/products-standard-seed-data.js +2 -1
- package/dist/seed/products-stickers-seed-data.js +9 -1
- package/dist/seed/users-seed-data.js +107 -4
- package/dist/server.d.ts +6 -2
- package/dist/server.js +15 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/motion.d.ts +12 -0
- package/dist/tokens/motion.js +12 -0
- package/dist/ui/components/CollapsibleSection.d.ts +18 -1
- package/dist/ui/components/CollapsibleSection.js +20 -3
- package/dist/ui/components/Motion.js +17 -1
- package/package.json +1 -1
|
@@ -39,6 +39,7 @@ import { computeRelatedItems } from "../../../_internal/server/features/products
|
|
|
39
39
|
import { GroupedListingsCarousel } from "../../grouped/components/GroupedListingsCarousel";
|
|
40
40
|
import { getGroupsWithItemsForProduct } from "../../../_internal/server/features/grouped/data";
|
|
41
41
|
import { SublistingCarouselSection } from "../../products/components/SublistingCarouselSection";
|
|
42
|
+
import { isFinalSale } from "../../products/constants/final-sale";
|
|
42
43
|
function toDescriptionHtml(raw) {
|
|
43
44
|
if (!raw)
|
|
44
45
|
return "";
|
|
@@ -46,8 +47,8 @@ function toDescriptionHtml(raw) {
|
|
|
46
47
|
return normalizeRichTextHtml(s);
|
|
47
48
|
}
|
|
48
49
|
function renderAuctionInfoPanel(props) {
|
|
49
|
-
const { productId, title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref } = props;
|
|
50
|
-
return (_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_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: formatCurrency(buyNowPrice, currency) }), _jsx(Span, { size: "xs", color: "faint", children: "\u2014 in \u201CPlace a bid\u201D" })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), brand && brandSlug && _jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand }), brand && !brandSlug && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), 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, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", 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: "Listed 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" })] }) }))] }));
|
|
50
|
+
const { productId, title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, finalSale, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref } = props;
|
|
51
|
+
return (_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_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: formatCurrency(buyNowPrice, currency) }), _jsx(Span, { size: "xs", color: "faint", children: "\u2014 in \u201CPlace a bid\u201D" })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, finalSale: finalSale, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", finalSale: "Final Sale", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), brand && brandSlug && _jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand }), brand && !brandSlug && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), 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, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", 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: "Listed 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" })] }) }))] }));
|
|
51
52
|
}
|
|
52
53
|
/**
|
|
53
54
|
* Store reviews for the auction's seller.
|
|
@@ -215,7 +216,7 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
|
|
|
215
216
|
price: typeof p.price === "number" ? p.price : undefined,
|
|
216
217
|
storeId: typeof p.storeId === "string" ? p.storeId : undefined,
|
|
217
218
|
storeName: typeof p.storeName === "string" ? p.storeName : undefined,
|
|
218
|
-
} }), _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.AUCTIONS), className: CLS_BREADCRUMB_LINK, children: "Auctions" }), 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(AuctionDetailView, { renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title })), renderInfo: () => renderAuctionInfoPanel({ productId: String(product.id), title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref }), renderBidForm: () => onPlaceBid ? (_jsx(Stack, { id: "auction-bid-form", gap: "3", children: _jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "xl", badgeSize: "xs" }), !isEnded && (_jsx(LiveMinIncrement, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride }))] }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] }) })) : (
|
|
219
|
+
} }), _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.AUCTIONS), className: CLS_BREADCRUMB_LINK, children: "Auctions" }), 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(AuctionDetailView, { renderGallery: () => (_jsx(ProductGalleryClient, { images: images, video: productVideo, productName: title })), renderInfo: () => renderAuctionInfoPanel({ productId: String(product.id), title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, finalSale: isFinalSale(p), category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref }), renderBidForm: () => onPlaceBid ? (_jsx(Stack, { id: "auction-bid-form", gap: "3", children: _jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsxs(Row, { justify: "between", align: "baseline", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "xl", badgeSize: "xs" }), !isEnded && (_jsx(LiveMinIncrement, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride }))] }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] }) })) : (
|
|
219
220
|
/* Read-only bid panel — shown when no bid action is wired (preview/demo) */
|
|
220
221
|
_jsxs(Stack, { className: `${__P.p5}`, border: "subtle", gap: "md", rounded: "xl", surface: "muted", children: [_jsx(Stack, { gap: "xs", children: _jsxs(Text, { size: "xs", color: "muted", children: ["Starting bid: ", formatCurrency(startingBid, currency), !isEnded && _jsxs(_Fragment, { children: [" \u00B7 min increment ", formatCurrency(initialMinBidIncrement, currency)] })] }) }), _jsxs(Stack, { gap: "sm", children: [_jsx(Input, { type: "number", placeholder: `At least ${formatCurrency(initialMinBid, currency)}`, min: initialMinBid, "aria-label": "Your bid amount", disabled: isEnded }), !isEnded && endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", disabled: isEnded, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Buy Now available at ", formatCurrency(buyNowPrice, currency)] }))] }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: tag }, tag))) }) }))] })), renderMobileBidForm: () => !isEnded && onPlaceBid ? (_jsxs(Stack, { className: `lg:hidden ${__P.p4}`, border: "subtle", gap: "3", rounded: "xl", surface: "muted", children: [_jsx(LiveBidPrice, { productId: String(product.id), currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "base", badgeSize: "xs" }), endDate && (_jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(PlaceBidModalButton, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow, triggerClassName: "w-full" })] })) : !isEnded ? (_jsxs(Div, { className: `${__P.p4} lg:hidden`, border: "subtle", rounded: "xl", surface: "muted", children: [_jsxs(Row, { align: "center", gap: "sm", className: "mb-3", children: [_jsx(Span, { size: "base", weight: "bold", className: "text-primary-600 dark:text-primary-400", children: formatCurrency(currentBid, currency) }), _jsxs(Span, { size: "xs", color: "muted", children: [bidCount, " bids"] })] }), endDate && (_jsxs(Text, { align: "center", className: "mb-3", size: "xs", color: "muted", children: ["Ends in ", _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" })] })), _jsx(Button, { variant: "primary", size: "md", className: "w-full", children: "Place Bid" })] })) : null, renderSublistingSection: sublistingCategoryId
|
|
221
222
|
? () => (_jsx(SublistingCarouselSection, { sublistingCategoryId: sublistingCategoryId, currentListingId: String(product.id) }))
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
|
-
import {
|
|
4
|
+
import { useCallback, useMemo, useState } from "react";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { Button, Div,
|
|
7
|
-
import { useFormShellState, FormShellContext, FormErrorSummary } from "../../../ui/forms";
|
|
6
|
+
import { Button, Div, Heading, Stack, Text } from "../../../ui";
|
|
7
|
+
import { useFormShellState, FormShellContext, FormErrorSummary, FieldInput, FieldTextarea, } from "../../../ui/forms";
|
|
8
|
+
import { SectionForm, useSectionFormNav } from "../../shell";
|
|
8
9
|
const contactFormSchema = z.object({
|
|
9
10
|
name: z.string().min(1, "Name is required"),
|
|
10
11
|
email: z.string().email("Enter a valid email address"),
|
|
11
12
|
subject: z.string().min(1, "Subject is required"),
|
|
12
13
|
message: z.string().min(10, "Message must be at least 10 characters"),
|
|
13
14
|
});
|
|
14
|
-
const CLS_FIELD_ERROR = "text-error";
|
|
15
15
|
const CLS_ERROR_BANNER = "rounded-lg bg-error-surface dark:bg-error-surface border border-error dark:border-error px-[var(--appkit-space-4)] py-[var(--appkit-space-3)] text-[length:var(--appkit-text-sm)] text-error dark:text-error";
|
|
16
16
|
const INITIAL_FORM = {
|
|
17
17
|
name: "",
|
|
@@ -23,30 +23,39 @@ export function ContactForm({ onSubmit, labels = {}, className = "", }) {
|
|
|
23
23
|
const [form, setForm] = useState(INITIAL_FORM);
|
|
24
24
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
25
25
|
const [submitted, setSubmitted] = useState(false);
|
|
26
|
-
const [errors, setErrors] = useState({});
|
|
27
26
|
const [submitError, setSubmitError] = useState(null);
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
27
|
+
const update = useCallback((partial) => setForm((f) => ({ ...f, ...partial })), []);
|
|
28
|
+
/*
|
|
29
|
+
* One section, `required` — so after the 2026-09-04 collapse fix it renders
|
|
30
|
+
* as a plain heading with no chevron and cannot be hidden. A contact form
|
|
31
|
+
* has nothing optional to fold away; the section exists to give the fields
|
|
32
|
+
* an owner for error attribution, not to be collapsible.
|
|
33
|
+
*/
|
|
34
|
+
const sections = useMemo(() => [
|
|
35
|
+
{
|
|
36
|
+
id: "message",
|
|
37
|
+
label: labels.title ?? "Send us a message",
|
|
38
|
+
required: true,
|
|
39
|
+
fields: ["name", "email", "subject", "message"],
|
|
40
|
+
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "name", label: labels.nameLabel ?? "Your Name", required: true, value: values.name, onChange: (v) => onChange({ name: v }), placeholder: labels.namePlaceholder ?? "Enter your name" }), _jsx(FieldInput, { name: "email", type: "email", label: labels.emailLabel ?? "Email", required: true, value: values.email, onChange: (v) => onChange({ email: v }), placeholder: labels.emailPlaceholder ?? "your@email.com" }), _jsx(FieldInput, { name: "subject", label: labels.subjectLabel ?? "Subject", required: true, value: values.subject, onChange: (v) => onChange({ subject: v }), placeholder: labels.subjectPlaceholder ?? "How can we help?" }), _jsx(FieldTextarea, { name: "message", label: labels.messageLabel ?? "Message", required: true, rows: 5, value: values.message, onChange: (v) => onChange({ message: v }), placeholder: labels.messagePlaceholder ?? "Tell us more…" })] })),
|
|
41
|
+
},
|
|
42
|
+
], [labels]);
|
|
43
|
+
const nav = useSectionFormNav(sections, form);
|
|
44
|
+
const { shellCtx, validate: validateSchema } = useFormShellState(contactFormSchema, {
|
|
45
|
+
sections: nav.sectionMeta,
|
|
46
|
+
onGoToSection: nav.goToSection,
|
|
47
|
+
fieldToSectionIndex: nav.fieldToSectionIndex,
|
|
48
|
+
});
|
|
49
|
+
const handleSubmit = useCallback(async () => {
|
|
50
|
+
/*
|
|
51
|
+
* The hand-rolled `validate()` this replaces re-stated every rule the
|
|
52
|
+
* schema already had — required-ness, the email regex, the 10-character
|
|
53
|
+
* minimum — in a second place that could drift from it, and wrote the
|
|
54
|
+
* results into a `useState` errors map instead of the form context
|
|
55
|
+
* (Rule #9.2 and #9.4). `validateSchema` parses and pipes the issues to
|
|
56
|
+
* the fields, which is the same check the submit gate now uses.
|
|
57
|
+
*/
|
|
58
|
+
if (!validateSchema(form))
|
|
50
59
|
return;
|
|
51
60
|
setIsSubmitting(true);
|
|
52
61
|
setSubmitError(null);
|
|
@@ -64,12 +73,18 @@ export function ContactForm({ onSubmit, labels = {}, className = "", }) {
|
|
|
64
73
|
}
|
|
65
74
|
},
|
|
66
75
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
-
[form, onSubmit, labels]);
|
|
76
|
+
[form, onSubmit, labels, validateSchema]);
|
|
68
77
|
if (submitted) {
|
|
69
78
|
return (_jsxs(Div, { className: `text-center ${className}`, padding: "y-3xl", children: [_jsx(Div, { textSize: "5xl", className: "mb-4", children: "\u2705" }), _jsx(Heading, { level: 2, className: "mb-2", children: labels.successTitle ?? "Message Sent!" }), _jsx(Text, { variant: "secondary", className: "mb-6", children: labels.successDescription ?? "We'll get back to you shortly." }), _jsx(Button, { type: "button", variant: "outline", onClick: () => setSubmitted(false), children: labels.sendAnotherButton ?? "Send another message" })] }));
|
|
70
79
|
}
|
|
71
|
-
|
|
72
|
-
return (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Form, { onSubmit: handleSubmit, noValidate: true, className: className, spacing: "md", children: [_jsx(Heading, { level: 2, className: "mb-6", children: labels.title ?? "Send us a message" }), submitError && (_jsx(Div, { className: CLS_ERROR_BANNER, children: submitError })), field("name", labels.nameLabel ?? "Your Name", labels.namePlaceholder ?? "Enter your name"), field("email", labels.emailLabel ?? "Email", labels.emailPlaceholder ?? "your@email.com"), field("subject", labels.subjectLabel ?? "Subject", labels.subjectPlaceholder ?? "How can we help?"), field("message", labels.messageLabel ?? "Message", labels.messagePlaceholder ?? "Tell us more…", true), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "submit", variant: "primary", disabled: isSubmitting, className: "w-full sm:w-auto", children: isSubmitting
|
|
80
|
+
return (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "md", className: className, children: [submitError && _jsx(Div, { className: CLS_ERROR_BANNER, children: submitError }), _jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: form, onChange: update, onSubmit: handleSubmit, schema: contactFormSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, isLoading: isSubmitting, submitLabel: isSubmitting
|
|
73
81
|
? (labels.submittingButton ?? "Sending…")
|
|
74
|
-
: (labels.submitButton ?? "Send Message")
|
|
82
|
+
: (labels.submitButton ?? "Send Message"),
|
|
83
|
+
/*
|
|
84
|
+
* No pinned mobile bar. This form is embedded in a marketing page
|
|
85
|
+
* beside other content rather than owning the screen, so a Save bar
|
|
86
|
+
* fixed to the bottom of the viewport would follow the visitor
|
|
87
|
+
* around the rest of the page.
|
|
88
|
+
*/
|
|
89
|
+
bottomBar: false })] }) }));
|
|
75
90
|
}
|
|
@@ -7,6 +7,21 @@ export interface MediaUploadListProps {
|
|
|
7
7
|
accept?: string;
|
|
8
8
|
maxSizeMB?: number;
|
|
9
9
|
maxItems?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Per-TYPE ceilings, counted separately.
|
|
12
|
+
*
|
|
13
|
+
* `maxItems` alone cannot express "10 images and 1 video" — it is one
|
|
14
|
+
* number over a mixed list, so a buyer-facing rule of that shape had to be
|
|
15
|
+
* enforced by splitting the control in two, which is why product forms
|
|
16
|
+
* carried a gallery AND a separate single-video field that wrote a
|
|
17
|
+
* different part of the document.
|
|
18
|
+
*
|
|
19
|
+
* When either is set the matching type is counted on its own and `maxItems`
|
|
20
|
+
* becomes the overall backstop. Existing callers that pass neither are
|
|
21
|
+
* completely unaffected.
|
|
22
|
+
*/
|
|
23
|
+
maxImages?: number;
|
|
24
|
+
maxVideos?: number;
|
|
10
25
|
disabled?: boolean;
|
|
11
26
|
helperText?: string;
|
|
12
27
|
/**
|
|
@@ -23,4 +38,4 @@ export interface MediaUploadListProps {
|
|
|
23
38
|
*/
|
|
24
39
|
isPersisted?: boolean;
|
|
25
40
|
}
|
|
26
|
-
export declare function MediaUploadList({ label, value, onChange, onUpload, accept, maxSizeMB, maxItems, disabled, helperText, onAbort, onStagedUrlsChange, isPersisted, }: MediaUploadListProps): import("react").JSX.Element;
|
|
41
|
+
export declare function MediaUploadList({ label, value, onChange, onUpload, accept, maxSizeMB, maxItems, maxImages, maxVideos, disabled, helperText, onAbort, onStagedUrlsChange, isPersisted, }: MediaUploadListProps): import("react").JSX.Element;
|
|
@@ -6,6 +6,7 @@ import { MediaImage } from "../MediaImage";
|
|
|
6
6
|
import { MediaVideo } from "../MediaVideo";
|
|
7
7
|
import { inferMediaTypeFromMime } from "../types/index";
|
|
8
8
|
import { normalizeError } from "../../../errors/normalize";
|
|
9
|
+
import { toUserMessage } from "../../../errors/error-display-map";
|
|
9
10
|
const __O = {
|
|
10
11
|
hidden: "overflow-hidden",
|
|
11
12
|
};
|
|
@@ -20,7 +21,7 @@ function fileNameFromUrl(url) {
|
|
|
20
21
|
return url;
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
|
-
export function MediaUploadList({ label, value, onChange, onUpload, accept = "image/*,video/*,application/pdf", maxSizeMB = 50, maxItems = 12, disabled = false, helperText, onAbort, onStagedUrlsChange, isPersisted = false, }) {
|
|
24
|
+
export function MediaUploadList({ label, value, onChange, onUpload, accept = "image/*,video/*,application/pdf", maxSizeMB = 50, maxItems = 12, maxImages, maxVideos, disabled = false, helperText, onAbort, onStagedUrlsChange, isPersisted = false, }) {
|
|
24
25
|
const inputRef = useRef(null);
|
|
25
26
|
const [isLoading, setIsLoading] = useState(false);
|
|
26
27
|
const [error, setError] = useState(null);
|
|
@@ -69,15 +70,51 @@ export function MediaUploadList({ label, value, onChange, onUpload, accept = "im
|
|
|
69
70
|
e.currentTarget.value = "";
|
|
70
71
|
return;
|
|
71
72
|
}
|
|
73
|
+
/*
|
|
74
|
+
* Per-type ceilings. Counted from the INCOMING batch as well as what is
|
|
75
|
+
* already there, so selecting five videos at once is refused up front
|
|
76
|
+
* rather than after four of them have uploaded.
|
|
77
|
+
*
|
|
78
|
+
* `inferMediaTypeFromMime` is the same classifier used to tag the item
|
|
79
|
+
* below, so the count and the stored `type` can never disagree.
|
|
80
|
+
*/
|
|
81
|
+
if (maxImages != null || maxVideos != null) {
|
|
82
|
+
const incoming = files.map((f) => inferMediaTypeFromMime(f.type, f.name));
|
|
83
|
+
const countOf = (t) => value.filter((v) => v.type === t).length + incoming.filter((v) => v === t).length;
|
|
84
|
+
if (maxImages != null && countOf("image") > maxImages) {
|
|
85
|
+
setError(`You can upload up to ${maxImages} image${maxImages === 1 ? "" : "s"}.`);
|
|
86
|
+
e.currentTarget.value = "";
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (maxVideos != null && countOf("video") > maxVideos) {
|
|
90
|
+
setError(maxVideos === 1
|
|
91
|
+
? "Only one video is allowed. Remove the current one to replace it."
|
|
92
|
+
: `You can upload up to ${maxVideos} videos.`);
|
|
93
|
+
e.currentTarget.value = "";
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
72
97
|
setError(null);
|
|
98
|
+
/*
|
|
99
|
+
* Size is checked BEFORE the upload loop, and reported directly rather
|
|
100
|
+
* than thrown.
|
|
101
|
+
*
|
|
102
|
+
* It used to throw into the shared catch, which then rendered
|
|
103
|
+
* `err.message` — so one branch of that catch was authored copy worth
|
|
104
|
+
* showing while the other was whatever the network layer happened to
|
|
105
|
+
* throw. Splitting them lets the useful message survive and the arbitrary
|
|
106
|
+
* one resolve through `toUserMessage` (Rule #9.6 / Root Cause #86).
|
|
107
|
+
*/
|
|
108
|
+
const tooBig = files.find((f) => f.size / 1024 / 1024 > maxSizeMB);
|
|
109
|
+
if (tooBig) {
|
|
110
|
+
setError(`${tooBig.name} exceeds ${maxSizeMB}MB`);
|
|
111
|
+
e.currentTarget.value = "";
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
73
114
|
setIsLoading(true);
|
|
74
115
|
try {
|
|
75
116
|
const uploaded = [];
|
|
76
117
|
for (const file of files) {
|
|
77
|
-
const sizeMb = file.size / 1024 / 1024;
|
|
78
|
-
if (sizeMb > maxSizeMB) {
|
|
79
|
-
throw new Error(`${file.name} exceeds ${maxSizeMB}MB`);
|
|
80
|
-
}
|
|
81
118
|
const url = await onUpload(file);
|
|
82
119
|
stageUrl(url);
|
|
83
120
|
uploaded.push({
|
|
@@ -88,8 +125,12 @@ export function MediaUploadList({ label, value, onChange, onUpload, accept = "im
|
|
|
88
125
|
onChange([...value, ...uploaded]);
|
|
89
126
|
}
|
|
90
127
|
catch (err) {
|
|
91
|
-
|
|
92
|
-
|
|
128
|
+
const normalized = normalizeError(err);
|
|
129
|
+
// Never the thrown value's own text: `onUpload` reaches the signed-URL
|
|
130
|
+
// endpoint, so this is where a server sentence would surface.
|
|
131
|
+
setError(toUserMessage(normalized.code, undefined, {
|
|
132
|
+
fallback: "Upload failed. Please try again.",
|
|
133
|
+
}));
|
|
93
134
|
}
|
|
94
135
|
finally {
|
|
95
136
|
setIsLoading(false);
|
|
@@ -107,5 +148,14 @@ export function MediaUploadList({ label, value, onChange, onUpload, accept = "im
|
|
|
107
148
|
unstageUrl(target.url);
|
|
108
149
|
}
|
|
109
150
|
};
|
|
110
|
-
return (_jsxs(Stack, { gap: "sm", children: [_jsx(Label, { className: "block", color: "muted", size: "sm", weight: "medium", children: label }), value.length > 0 && (_jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-1 sm:grid-cols-2", children: value.map((item, index) => (_jsxs(Div, { rounded: "lg", padding: "sm", border: "default", surface: "muted", children: [item.type === "video" ? (_jsx(Div, { className: `relative aspect-square max-h-24 ${__O.hidden}`, rounded: "lg", children: _jsx(MediaVideo, { src: item.url, alt: item.alt || `Media ${index + 1}`, controls: true }) })) : item.type === "image" ? (_jsx(Div, { className: `relative aspect-square max-h-24 ${__O.hidden}`, rounded: "lg", children: _jsx(MediaImage, { src: item.url, alt: item.alt || `Media ${index + 1}`, size: "card", objectFit: "contain" }) })) : (_jsx(Anchor, { href: item.url, tone: "none", underline: "none", className: CLS_PDF_LINK, children: fileNameFromUrl(item.url) })), !disabled && (_jsx(Div, { className: "mt-2", children: _jsx(Button, { type: "button", size: "sm", variant: "danger", onClick: () => removeAt(index), children: "Remove" }) }))] }, `${item.url}-${index}`))) })), !disabled && value.length < maxItems && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => inputRef.current?.click(), disabled: isLoading, children: isLoading ? "Uploading..." : `+ Add Files (${value.length}/${maxItems})` })), isLoading && (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Spinner, { size: "sm" }), _jsx(Text, { size: "sm", variant: "secondary", children: "Uploading" })] })), _jsx("input", { ref: inputRef, type: "file", multiple: true, accept: accept, onChange: handleFileChange, className: "hidden", "aria-hidden": "true", "data-testid": "media-upload-list-input" }),
|
|
151
|
+
return (_jsxs(Stack, { gap: "sm", children: [_jsx(Label, { className: "block", color: "muted", size: "sm", weight: "medium", children: label }), value.length > 0 && (_jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-1 sm:grid-cols-2", children: value.map((item, index) => (_jsxs(Div, { rounded: "lg", padding: "sm", border: "default", surface: "muted", children: [item.type === "video" ? (_jsx(Div, { className: `relative aspect-square max-h-24 ${__O.hidden}`, rounded: "lg", children: _jsx(MediaVideo, { src: item.url, alt: item.alt || `Media ${index + 1}`, controls: true }) })) : item.type === "image" ? (_jsx(Div, { className: `relative aspect-square max-h-24 ${__O.hidden}`, rounded: "lg", children: _jsx(MediaImage, { src: item.url, alt: item.alt || `Media ${index + 1}`, size: "card", objectFit: "contain" }) })) : (_jsx(Anchor, { href: item.url, tone: "none", underline: "none", className: CLS_PDF_LINK, children: fileNameFromUrl(item.url) })), !disabled && (_jsx(Div, { className: "mt-2", children: _jsx(Button, { type: "button", size: "sm", variant: "danger", onClick: () => removeAt(index), children: "Remove" }) }))] }, `${item.url}-${index}`))) })), !disabled && value.length < maxItems && (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => inputRef.current?.click(), disabled: isLoading, children: isLoading ? "Uploading..." : `+ Add Files (${value.length}/${maxItems})` })), isLoading && (_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Spinner, { size: "sm" }), _jsx(Text, { size: "sm", variant: "secondary", children: "Uploading" })] })), _jsx("input", { ref: inputRef, type: "file", multiple: true, accept: accept, onChange: handleFileChange, className: "hidden", "aria-hidden": "true", "data-testid": "media-upload-list-input" }), (maxImages != null || maxVideos != null) && (_jsx(Text, { variant: "secondary", size: "xs", children: [
|
|
152
|
+
maxImages != null
|
|
153
|
+
? `${value.filter((v) => v.type === "image").length}/${maxImages} images`
|
|
154
|
+
: null,
|
|
155
|
+
maxVideos != null
|
|
156
|
+
? `${value.filter((v) => v.type === "video").length}/${maxVideos} video${maxVideos === 1 ? "" : "s"}`
|
|
157
|
+
: null,
|
|
158
|
+
]
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.join(" · ") })), helperText && !error && (_jsx(Text, { variant: "secondary", size: "xs", children: helperText })), error && _jsx(Alert, { variant: "error", children: error })] }));
|
|
111
161
|
}
|
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RefundRequestView — buyer-facing refund
|
|
2
|
+
* RefundRequestView — buyer-facing return / refund request form.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the buyer must tick before the submit button becomes active. On submit the
|
|
6
|
-
* parent's `onSubmitRequest` is called with the reason string; the parent
|
|
7
|
-
* (page or modal) is responsible for calling the API.
|
|
4
|
+
* ## What changed and why
|
|
8
5
|
*
|
|
9
|
-
* This component
|
|
10
|
-
*
|
|
6
|
+
* This component had **zero call sites** and was grandfathered in
|
|
7
|
+
* `audit-orphan-view-component`. It also predated three project rules:
|
|
8
|
+
* it hand-rolled `useState<string | null>` for its error (Rule #9.4), took a
|
|
9
|
+
* free-text reason with no schema (Rule #9.2), and had no `<FormErrorSummary>`.
|
|
10
|
+
*
|
|
11
|
+
* The free-text reason is the substantive change. "Final sale, unless the
|
|
12
|
+
* buyer didn't get what they paid for" cannot be enforced against prose — the
|
|
13
|
+
* server needs a coded reason to decide, so the buyer picks one here and the
|
|
14
|
+
* note is the optional elaboration beside it.
|
|
15
|
+
*
|
|
16
|
+
* ## Final sale is a filter, not a wall
|
|
17
|
+
*
|
|
18
|
+
* When a line in scope is final sale, this does NOT refuse to render. It says
|
|
19
|
+
* so, and narrows the picker to the reasons that remain valid. Blocking the
|
|
20
|
+
* page outright would hide the two claims a final sale can never refuse —
|
|
21
|
+
* which are exactly the ones a buyer in trouble needs.
|
|
11
22
|
*/
|
|
12
23
|
import React from "react";
|
|
13
|
-
import type
|
|
24
|
+
import { type ReturnReason } from "../../../_internal/shared/features/orders/return-reasons";
|
|
25
|
+
import type { Order } from "../types";
|
|
26
|
+
export interface RefundRequestSubmission {
|
|
27
|
+
reasonCode: ReturnReason;
|
|
28
|
+
reasonNote?: string;
|
|
29
|
+
}
|
|
14
30
|
export interface RefundRequestViewProps {
|
|
15
|
-
order: Pick<
|
|
16
|
-
onSubmitRequest: (
|
|
31
|
+
order: Pick<Order, "id" | "total" | "currency" | "isNonRefundable" | "contestable" | "items">;
|
|
32
|
+
onSubmitRequest: (submission: RefundRequestSubmission) => Promise<void>;
|
|
17
33
|
isLoading?: boolean;
|
|
18
34
|
className?: string;
|
|
19
35
|
}
|
|
@@ -1,48 +1,132 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
/**
|
|
4
|
-
* RefundRequestView — buyer-facing refund
|
|
4
|
+
* RefundRequestView — buyer-facing return / refund request form.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* the buyer must tick before the submit button becomes active. On submit the
|
|
8
|
-
* parent's `onSubmitRequest` is called with the reason string; the parent
|
|
9
|
-
* (page or modal) is responsible for calling the API.
|
|
6
|
+
* ## What changed and why
|
|
10
7
|
*
|
|
11
|
-
* This component
|
|
12
|
-
*
|
|
8
|
+
* This component had **zero call sites** and was grandfathered in
|
|
9
|
+
* `audit-orphan-view-component`. It also predated three project rules:
|
|
10
|
+
* it hand-rolled `useState<string | null>` for its error (Rule #9.4), took a
|
|
11
|
+
* free-text reason with no schema (Rule #9.2), and had no `<FormErrorSummary>`.
|
|
12
|
+
*
|
|
13
|
+
* The free-text reason is the substantive change. "Final sale, unless the
|
|
14
|
+
* buyer didn't get what they paid for" cannot be enforced against prose — the
|
|
15
|
+
* server needs a coded reason to decide, so the buyer picks one here and the
|
|
16
|
+
* note is the optional elaboration beside it.
|
|
17
|
+
*
|
|
18
|
+
* ## Final sale is a filter, not a wall
|
|
19
|
+
*
|
|
20
|
+
* When a line in scope is final sale, this does NOT refuse to render. It says
|
|
21
|
+
* so, and narrows the picker to the reasons that remain valid. Blocking the
|
|
22
|
+
* page outright would hide the two claims a final sale can never refuse —
|
|
23
|
+
* which are exactly the ones a buyer in trouble needs.
|
|
13
24
|
*/
|
|
14
|
-
import { useState } from "react";
|
|
15
|
-
import {
|
|
25
|
+
import { useMemo, useState } from "react";
|
|
26
|
+
import { Alert, Div, Span, Stack, Text, Checkbox, PaginatedSelect } from "../../../ui";
|
|
27
|
+
import { FormShellContext, useFormShellState, FormErrorSummary, FieldTextarea, } from "../../../ui/forms";
|
|
28
|
+
import { SectionForm, useSectionFormNav } from "../../shell";
|
|
29
|
+
import { z } from "zod";
|
|
16
30
|
import { REFUND_COPY } from "../../../_internal/shared/features/orders/refund-copy";
|
|
31
|
+
import { RETURN_REASON_LABEL, selectableReturnReasons, } from "../../../_internal/shared/features/orders/return-reasons";
|
|
17
32
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
18
33
|
import { normalizeError } from "../../../errors/normalize";
|
|
19
34
|
import { toUserMessage } from "../../../errors/error-display-map";
|
|
20
35
|
const ACK_COUNT = REFUND_COPY.request.acknowledgments.length;
|
|
21
|
-
const CLS_WARN_PANEL = "rounded-xl border border-warning bg-warning-surface p-[var(--appkit-space-4)] dark:border-warning";
|
|
22
36
|
export function RefundRequestView({ order, onSubmitRequest, isLoading = false, className = "", }) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
/*
|
|
38
|
+
* Does anything in this order carry the final-sale term? Read from the
|
|
39
|
+
* ORDER's own per-line snapshot — the product may have been re-listed on
|
|
40
|
+
* different terms since, and the buyer agreed to the terms at checkout.
|
|
41
|
+
*/
|
|
42
|
+
const hasFinalSaleLine = useMemo(() => (order.items ?? []).some((i) => i.finalSale === true), [order.items]);
|
|
43
|
+
const allowedReasons = useMemo(() => selectableReturnReasons(hasFinalSaleLine), [hasFinalSaleLine]);
|
|
44
|
+
/*
|
|
45
|
+
* The schema mirrors the server's `returnRequestSchema`, plus the
|
|
46
|
+
* acknowledgements, which are a client-side consent gate and are not stored.
|
|
47
|
+
* `reasonCode` is constrained to the ALLOWED set rather than the full enum,
|
|
48
|
+
* so a change-of-mind reason cannot be submitted on a final-sale order even
|
|
49
|
+
* if the option list were bypassed.
|
|
50
|
+
*/
|
|
51
|
+
const schema = useMemo(() => z.object({
|
|
52
|
+
reasonCode: z.enum(allowedReasons, {
|
|
53
|
+
errorMap: () => ({ message: "Choose why you're returning this order." }),
|
|
54
|
+
}),
|
|
55
|
+
reasonNote: z.string().max(500).optional(),
|
|
56
|
+
acknowledged: z
|
|
57
|
+
.array(z.boolean())
|
|
58
|
+
.refine((a) => a.every(Boolean), {
|
|
59
|
+
message: "Please acknowledge all three statements before submitting.",
|
|
60
|
+
}),
|
|
61
|
+
}), [allowedReasons]);
|
|
62
|
+
const [draft, setDraft] = useState({
|
|
63
|
+
reasonCode: "",
|
|
64
|
+
reasonNote: "",
|
|
65
|
+
acknowledged: Array(ACK_COUNT).fill(false),
|
|
66
|
+
});
|
|
67
|
+
const update = (partial) => setDraft((prev) => ({ ...prev, ...partial }));
|
|
68
|
+
const sections = useMemo(() => [
|
|
69
|
+
{
|
|
70
|
+
id: "declare",
|
|
71
|
+
label: "Why are you returning this?",
|
|
72
|
+
required: true,
|
|
73
|
+
fields: ["reasonCode", "reasonNote"],
|
|
74
|
+
render: ({ values, onChange }) => (_jsxs(Stack, { gap: "md", children: [_jsxs(Text, { size: "sm", color: "muted", children: [REFUND_COPY.request.orderTotalLabel, " ", _jsx(Span, { weight: "bold", children: formatCurrency(order.total, order.currency ?? "INR") })] }), hasFinalSaleLine && (_jsx(Alert, { variant: "warning", title: REFUND_COPY.request.finalSaleNoticeHeading, children: "You can\u2019t return it for a change of mind, but you can still claim if it never arrived, arrived damaged, was the wrong item, wasn\u2019t as described, or is counterfeit. Only those reasons are listed below." })), _jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: [REFUND_COPY.request.reasonSelectLabel, " ", _jsx(Span, { "aria-hidden": true, children: "*" })] }), _jsx(PaginatedSelect, { value: values.reasonCode === "" ? null : values.reasonCode, onChange: (v) => onChange({ reasonCode: v ?? "" }), options: allowedReasons.map((r) => ({
|
|
75
|
+
value: r,
|
|
76
|
+
label: RETURN_REASON_LABEL[r],
|
|
77
|
+
})), placeholder: REFUND_COPY.request.reasonSelectPlaceholder, ariaLabel: REFUND_COPY.request.reasonSelectLabel })] }), _jsx(FieldTextarea, { name: "reasonNote", label: REFUND_COPY.request.reasonNoteLabel, value: values.reasonNote, onChange: (v) => onChange({ reasonNote: v }), rows: 3, maxLength: 500, showCharCount: true })] })),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "declare-acknowledgements",
|
|
81
|
+
label: REFUND_COPY.request.acknowledgeHeading,
|
|
82
|
+
required: true,
|
|
83
|
+
fields: ["acknowledged"],
|
|
84
|
+
render: ({ values, onChange }) => (_jsx(Stack, { gap: "sm", children: REFUND_COPY.request.acknowledgments.map((text, i) => (_jsx(Checkbox, { checked: values.acknowledged[i] ?? false, onChange: () => onChange({
|
|
85
|
+
acknowledged: values.acknowledged.map((v, idx) => idx === i ? !v : v),
|
|
86
|
+
}), label: _jsx(Text, { size: "sm", className: "leading-snug", children: text }), "aria-label": `Acknowledgment ${i + 1}` }, i))) })),
|
|
87
|
+
},
|
|
88
|
+
], [order.total, order.currency, hasFinalSaleLine, allowedReasons]);
|
|
89
|
+
const nav = useSectionFormNav(sections, draft);
|
|
90
|
+
const { shellCtx, setFieldError, clearErrors } = useFormShellState(schema, {
|
|
91
|
+
sections: nav.sectionMeta,
|
|
92
|
+
onGoToSection: nav.goToSection,
|
|
93
|
+
fieldToSectionIndex: nav.fieldToSectionIndex,
|
|
94
|
+
});
|
|
95
|
+
// ── Early returns: states in which no request can be made at all ──────────
|
|
28
96
|
if (order.isNonRefundable) {
|
|
29
|
-
return (_jsx(Div, { className:
|
|
97
|
+
return (_jsx(Div, { className: className, rounded: "xl", padding: "md", surface: "muted", border: "default", children: _jsx(Text, { size: "sm", color: "muted", children: REFUND_COPY.request.nonRefundableMessage }) }));
|
|
30
98
|
}
|
|
31
99
|
if (order.contestable === false) {
|
|
32
|
-
return (_jsx(
|
|
100
|
+
return (_jsx(Alert, { variant: "warning", className: className, children: REFUND_COPY.request.alreadyRefundedMessage }));
|
|
33
101
|
}
|
|
34
|
-
const handleToggle = (i) => setAcknowledged((prev) => prev.map((v, idx) => (idx === i ? !v : v)));
|
|
35
102
|
const handleSubmit = async () => {
|
|
36
|
-
|
|
103
|
+
clearErrors();
|
|
104
|
+
const parsed = schema.safeParse(draft);
|
|
105
|
+
if (!parsed.success) {
|
|
106
|
+
for (const issue of parsed.error.issues) {
|
|
107
|
+
setFieldError(issue.path.map(String).join("."), issue.message);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
37
111
|
try {
|
|
38
|
-
await onSubmitRequest(
|
|
112
|
+
await onSubmitRequest({
|
|
113
|
+
reasonCode: parsed.data.reasonCode,
|
|
114
|
+
...(parsed.data.reasonNote?.trim()
|
|
115
|
+
? { reasonNote: parsed.data.reasonNote.trim() }
|
|
116
|
+
: {}),
|
|
117
|
+
});
|
|
39
118
|
}
|
|
40
119
|
catch (err) {
|
|
41
120
|
const e = normalizeError(err);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
121
|
+
/*
|
|
122
|
+
* Surfaced on the field the server actually rejected. A final-sale
|
|
123
|
+
* refusal is always about the chosen reason, so putting it on
|
|
124
|
+
* `reasonCode` puts the message next to the control that fixes it.
|
|
125
|
+
*
|
|
126
|
+
* `toUserMessage` and never the raw server text — Rule #9.6.
|
|
127
|
+
*/
|
|
128
|
+
setFieldError("reasonCode", toUserMessage(e.code, undefined, { fallback: REFUND_COPY.request.errorFallback }));
|
|
45
129
|
}
|
|
46
130
|
};
|
|
47
|
-
return (
|
|
131
|
+
return (_jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "md", className: className, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: update, onSubmit: handleSubmit, schema: schema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: REFUND_COPY.request.submitLabel, isLoading: isLoading })] }) }));
|
|
48
132
|
}
|