@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.
Files changed (104) hide show
  1. package/dist/_internal/server/features/checkout/actions.js +9 -1
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
  3. package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
  4. package/dist/_internal/server/features/media/contextGuards.js +19 -5
  5. package/dist/_internal/server/features/orders/actions.d.ts +9 -0
  6. package/dist/_internal/server/features/orders/actions.js +47 -4
  7. package/dist/_internal/server/features/orders/adapters.js +10 -0
  8. package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
  9. package/dist/_internal/server/features/refunds/actions.js +36 -3
  10. package/dist/_internal/server/features/seo/sitemap.js +2 -2
  11. package/dist/_internal/server/features/site-settings/adapters.js +1 -1
  12. package/dist/_internal/server/functions/scheduled.d.ts +1 -2
  13. package/dist/_internal/server/functions/scheduled.js +8 -9
  14. package/dist/_internal/server/jobs/core/testerSandboxCleanup.d.ts +15 -0
  15. package/dist/_internal/server/jobs/core/testerSandboxCleanup.js +15 -6
  16. package/dist/_internal/server/jobs/handlers/index.d.ts +0 -1
  17. package/dist/_internal/server/jobs/handlers/index.js +0 -1
  18. package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
  19. package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
  20. package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
  21. package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
  22. package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
  23. package/dist/_internal/shared/features/orders/schema.js +20 -0
  24. package/dist/_internal/shared/features/products/schema.d.ts +12 -0
  25. package/dist/_internal/shared/features/products/schema.js +4 -1
  26. package/dist/_internal/shared/media/limits.d.ts +14 -0
  27. package/dist/_internal/shared/media/limits.js +29 -0
  28. package/dist/client.d.ts +9 -1
  29. package/dist/client.js +19 -1
  30. package/dist/features/admin/components/AdminProductEditorView.js +6 -1
  31. package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
  32. package/dist/features/admin/components/index.d.ts +0 -2
  33. package/dist/features/admin/components/index.js +0 -1
  34. package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
  35. package/dist/features/admin/schemas/firestore.d.ts +0 -2
  36. package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
  37. package/dist/features/auth/schemas/firestore.d.ts +1 -0
  38. package/dist/features/contact/components/ContactForm.js +46 -31
  39. package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
  40. package/dist/features/media/upload/MediaUploadList.js +58 -8
  41. package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
  42. package/dist/features/orders/components/RefundRequestView.js +108 -24
  43. package/dist/features/orders/schemas/firestore.d.ts +42 -0
  44. package/dist/features/orders/types/index.d.ts +24 -0
  45. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
  46. package/dist/features/products/components/ProductDetailPageView.js +14 -2
  47. package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
  48. package/dist/features/products/components/ProductFeatureBadges.js +17 -1
  49. package/dist/features/products/components/ProductForm.js +46 -10
  50. package/dist/features/products/components/ProductGrid.js +10 -1
  51. package/dist/features/products/constants/final-sale.d.ts +40 -0
  52. package/dist/features/products/constants/final-sale.js +40 -0
  53. package/dist/features/products/index.d.ts +1 -0
  54. package/dist/features/products/index.js +1 -0
  55. package/dist/features/products/schemas/firestore.d.ts +19 -2
  56. package/dist/features/products/schemas/firestore.js +4 -0
  57. package/dist/features/products/types/index.d.ts +2 -0
  58. package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
  59. package/dist/features/seller/components/SellerProductShell.js +82 -11
  60. package/dist/features/seller/index.d.ts +1 -0
  61. package/dist/features/seller/index.js +1 -0
  62. package/dist/features/seller/schemas/product-form.d.ts +304 -0
  63. package/dist/features/seller/schemas/product-form.js +132 -0
  64. package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
  65. package/dist/features/seller/utils/product-draft-mapping.js +147 -0
  66. package/dist/features/shell/SectionForm.js +62 -7
  67. package/dist/features/shell/form-sections.js +4 -0
  68. package/dist/features/tester/repository/tester-checklist-item.repository.d.ts +16 -1
  69. package/dist/features/tester/repository/tester-checklist-item.repository.js +32 -6
  70. package/dist/features/tester/seed-data/events-tester-seed-data.js +77 -5
  71. package/dist/features/tester/seed-data/offers-tester-seed-data.js +23 -9
  72. package/dist/features/tester/seed-data/products-tester-seed-data.js +65 -49
  73. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
  74. package/dist/features/tester/seed-data/tester-window.d.ts +25 -0
  75. package/dist/features/tester/seed-data/tester-window.js +66 -0
  76. package/dist/index.d.ts +6 -3
  77. package/dist/index.js +12 -3
  78. package/dist/next/routing/route-map.d.ts +2 -0
  79. package/dist/next/routing/route-map.js +1 -0
  80. package/dist/schemas/registry.d.ts +271 -0
  81. package/dist/schemas/registry.js +9 -0
  82. package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
  83. package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
  84. package/dist/seed/products-art-seed-data.js +9 -1
  85. package/dist/seed/products-auctions-seed-data.js +2 -1
  86. package/dist/seed/products-classifieds-seed-data.js +2 -1
  87. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  88. package/dist/seed/products-live-items-seed-data.js +2 -1
  89. package/dist/seed/products-preorders-seed-data.d.ts +2 -138
  90. package/dist/seed/products-preorders-seed-data.js +2 -1
  91. package/dist/seed/products-prize-draws-seed-data.js +2 -1
  92. package/dist/seed/products-standard-seed-data.js +2 -1
  93. package/dist/seed/products-stickers-seed-data.js +9 -1
  94. package/dist/seed/users-seed-data.js +107 -4
  95. package/dist/server.d.ts +6 -2
  96. package/dist/server.js +15 -2
  97. package/dist/styles.css +1 -1
  98. package/dist/tailwind-utilities.css +1 -1
  99. package/dist/tokens/motion.d.ts +12 -0
  100. package/dist/tokens/motion.js +12 -0
  101. package/dist/ui/components/CollapsibleSection.d.ts +18 -1
  102. package/dist/ui/components/CollapsibleSection.js +20 -3
  103. package/dist/ui/components/Motion.js +17 -1
  104. 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) }))
@@ -69,6 +69,7 @@ export interface UserDocument extends BaseDocument {
69
69
  storeStatus?: "pending" | "approved" | "rejected";
70
70
  isTester?: boolean;
71
71
  canTestAdmin?: boolean;
72
+ isBot?: boolean;
72
73
  publicProfile?: {
73
74
  isPublic: boolean;
74
75
  showEmail: boolean;
@@ -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 { useEffect, useState, useCallback } from "react";
4
+ import { useCallback, useMemo, useState } from "react";
5
5
  import { z } from "zod";
6
- import { Button, Div, Form, Heading, Input, Span, Stack, Text, Textarea } from "../../../ui";
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 { shellCtx, validate: validateSchema } = useFormShellState(contactFormSchema);
29
- useEffect(() => {
30
- validateSchema(form);
31
- // eslint-disable-next-line react-hooks/exhaustive-deps
32
- }, [form, validateSchema]);
33
- const validate = () => {
34
- const newErrors = {};
35
- if (!form.name.trim())
36
- newErrors.name = labels.validationRequired ?? "Required";
37
- if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
38
- newErrors.email = labels.validationEmail ?? "Valid email required";
39
- if (!form.subject.trim())
40
- newErrors.subject = labels.validationRequired ?? "Required";
41
- if (!form.message.trim() || form.message.length < 10)
42
- newErrors.message =
43
- labels.validationMessageTooShort ?? "Message too short";
44
- setErrors(newErrors);
45
- return Object.keys(newErrors).length === 0;
46
- };
47
- const handleSubmit = useCallback(async (e) => {
48
- e.preventDefault();
49
- if (!validate())
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
- const field = (id, label, placeholder, multiline) => (_jsxs(Stack, { gap: "xs", children: [_jsx("label", { htmlFor: id, className: "text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)]", children: label }), multiline ? (_jsx(Textarea, { id: id, value: form[id], onChange: (e) => setForm((f) => ({ ...f, [id]: e.target.value })), placeholder: placeholder, rows: 5, className: "w-full rounded-lg border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] outline-none focus:ring-2 ring-primary/20 transition" })) : (_jsx(Input, { id: id, type: id === "email" ? "email" : "text", value: form[id], onChange: (e) => setForm((f) => ({ ...f, [id]: e.target.value })), placeholder: placeholder, className: "h-10" })), errors[id] && _jsx(Span, { size: "xs", className: CLS_FIELD_ERROR, children: errors[id] })] }));
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
- void normalizeError(err);
92
- setError(err instanceof Error ? err.message : "Upload failed");
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" }), helperText && !error && (_jsx(Text, { variant: "secondary", size: "xs", children: helperText })), error && _jsx(Alert, { variant: "error", children: error })] }));
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 submission form.
2
+ * RefundRequestView — buyer-facing return / refund request form.
3
3
  *
4
- * Shows 3 acknowledgment checkboxes (via the appkit Checkbox primitive) that
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 is presentational: it has no server-action imports and
10
- * does not talk to the API directly.
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 { OrderDocument } from "../schemas";
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<OrderDocument, "id" | "totalPrice" | "currency" | "isNonRefundable" | "refunds" | "contestable">;
16
- onSubmitRequest: (reason: string) => Promise<void>;
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 submission form.
4
+ * RefundRequestView — buyer-facing return / refund request form.
5
5
  *
6
- * Shows 3 acknowledgment checkboxes (via the appkit Checkbox primitive) that
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 is presentational: it has no server-action imports and
12
- * does not talk to the API directly.
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 { Button, Checkbox, Div, Span, Stack, Text, Textarea } from "../../../ui";
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
- const [acknowledged, setAcknowledged] = useState(Array(ACK_COUNT).fill(false));
24
- const [reason, setReason] = useState("");
25
- const [error, setError] = useState(null);
26
- const allChecked = acknowledged.every(Boolean);
27
- const canSubmit = allChecked && reason.trim().length >= 10 && !isLoading;
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: `${className}`, rounded: "xl", padding: "md", surface: "muted", border: "default", children: _jsx(Text, { size: "sm", color: "muted", children: REFUND_COPY.request.nonRefundableMessage }) }));
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(Div, { className: `${CLS_WARN_PANEL} ${className}`, children: _jsx(Text, { size: "sm", className: "text-warning", children: REFUND_COPY.request.alreadyRefundedMessage }) }));
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
- setError(null);
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(reason.trim());
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
- setError(toUserMessage(e.code, undefined, {
43
- fallback: REFUND_COPY.request.errorFallback,
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 (_jsxs(Stack, { gap: "md", className: className, children: [_jsxs(Text, { size: "sm", color: "muted", children: [REFUND_COPY.request.orderTotalLabel, " ", _jsx(Span, { weight: "bold", children: formatCurrency(order.totalPrice, order.currency ?? "INR") })] }), _jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", weight: "semibold", children: [REFUND_COPY.request.reasonLabel, " ", _jsx(Span, { "aria-hidden": true, children: "*" })] }), _jsx(Textarea, { value: reason, onChange: (e) => setReason(e.target.value), placeholder: REFUND_COPY.request.reasonPlaceholder, rows: 3, "aria-required": "true", "aria-label": REFUND_COPY.request.reasonLabel })] }), _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", weight: "semibold", children: REFUND_COPY.request.acknowledgeHeading }), REFUND_COPY.request.acknowledgments.map((text, i) => (_jsx(Checkbox, { checked: acknowledged[i], onChange: () => handleToggle(i), label: _jsx(Text, { size: "sm", className: "leading-snug", children: text }), "aria-label": `Acknowledgment ${i + 1}` }, i)))] }), error && (_jsx(Text, { size: "sm", color: "danger", role: "alert", children: error })), _jsx(Button, { variant: "danger", onClick: handleSubmit, disabled: !canSubmit, "aria-busy": isLoading, children: isLoading ? REFUND_COPY.request.submittingLabel : REFUND_COPY.request.submitLabel })] }));
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
  }