@mohasinac/appkit 4.33.1 → 4.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) 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/site-settings/adapters.js +1 -1
  11. package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
  12. package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
  13. package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
  14. package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
  15. package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
  16. package/dist/_internal/shared/features/orders/schema.js +20 -0
  17. package/dist/_internal/shared/features/products/schema.d.ts +12 -0
  18. package/dist/_internal/shared/features/products/schema.js +4 -1
  19. package/dist/_internal/shared/media/limits.d.ts +14 -0
  20. package/dist/_internal/shared/media/limits.js +29 -0
  21. package/dist/client.d.ts +9 -1
  22. package/dist/client.js +19 -1
  23. package/dist/features/admin/components/AdminProductEditorView.js +6 -1
  24. package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
  25. package/dist/features/admin/components/index.d.ts +0 -2
  26. package/dist/features/admin/components/index.js +0 -1
  27. package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
  28. package/dist/features/admin/schemas/firestore.d.ts +0 -2
  29. package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
  30. package/dist/features/contact/components/ContactForm.js +46 -31
  31. package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
  32. package/dist/features/media/upload/MediaUploadList.js +58 -8
  33. package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
  34. package/dist/features/orders/components/RefundRequestView.js +108 -24
  35. package/dist/features/orders/schemas/firestore.d.ts +42 -0
  36. package/dist/features/orders/types/index.d.ts +24 -0
  37. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
  38. package/dist/features/products/components/ProductDetailPageView.js +14 -2
  39. package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
  40. package/dist/features/products/components/ProductFeatureBadges.js +17 -1
  41. package/dist/features/products/components/ProductForm.js +46 -10
  42. package/dist/features/products/components/ProductGrid.js +10 -1
  43. package/dist/features/products/constants/final-sale.d.ts +40 -0
  44. package/dist/features/products/constants/final-sale.js +40 -0
  45. package/dist/features/products/index.d.ts +1 -0
  46. package/dist/features/products/index.js +1 -0
  47. package/dist/features/products/schemas/firestore.d.ts +19 -2
  48. package/dist/features/products/schemas/firestore.js +4 -0
  49. package/dist/features/products/types/index.d.ts +2 -0
  50. package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
  51. package/dist/features/seller/components/SellerProductShell.js +82 -11
  52. package/dist/features/seller/index.d.ts +1 -0
  53. package/dist/features/seller/index.js +1 -0
  54. package/dist/features/seller/schemas/product-form.d.ts +304 -0
  55. package/dist/features/seller/schemas/product-form.js +132 -0
  56. package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
  57. package/dist/features/seller/utils/product-draft-mapping.js +147 -0
  58. package/dist/features/shell/SectionForm.js +62 -7
  59. package/dist/features/shell/form-sections.js +4 -0
  60. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +182 -0
  61. package/dist/index.d.ts +6 -3
  62. package/dist/index.js +12 -3
  63. package/dist/next/routing/route-map.d.ts +2 -0
  64. package/dist/next/routing/route-map.js +1 -0
  65. package/dist/schemas/registry.d.ts +271 -0
  66. package/dist/schemas/registry.js +9 -0
  67. package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
  68. package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
  69. package/dist/seed/products-art-seed-data.js +9 -1
  70. package/dist/seed/products-auctions-seed-data.js +2 -1
  71. package/dist/seed/products-classifieds-seed-data.js +2 -1
  72. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  73. package/dist/seed/products-live-items-seed-data.js +2 -1
  74. package/dist/seed/products-preorders-seed-data.d.ts +2 -138
  75. package/dist/seed/products-preorders-seed-data.js +2 -1
  76. package/dist/seed/products-prize-draws-seed-data.js +2 -1
  77. package/dist/seed/products-standard-seed-data.js +2 -1
  78. package/dist/seed/products-stickers-seed-data.js +9 -1
  79. package/dist/server.d.ts +6 -2
  80. package/dist/server.js +15 -2
  81. package/dist/styles.css +1 -1
  82. package/dist/tailwind-utilities.css +1 -1
  83. package/dist/tokens/motion.d.ts +12 -0
  84. package/dist/tokens/motion.js +12 -0
  85. package/dist/ui/components/CollapsibleSection.d.ts +18 -1
  86. package/dist/ui/components/CollapsibleSection.js +20 -3
  87. package/dist/ui/components/Motion.js +17 -1
  88. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment } from "react";
3
3
  import { Row, Span, TextLink, Icon } from "../../../ui";
4
- export function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, condition, returnable, freeShipping, wishlistCount, categoryProductCount, categoryName, codAvailable, codHref, emiAvailable, emiHref, labels, formatCount, categoryBadgeClassName, }) {
4
+ export function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, condition, returnable, finalSale, freeShipping, wishlistCount, categoryProductCount, categoryName, codAvailable, codHref, emiAvailable, emiHref, labels, formatCount, categoryBadgeClassName, }) {
5
5
  const badges = [];
6
6
  if (featured) {
7
7
  badges.push({
@@ -48,6 +48,22 @@ export function ProductFeatureBadges({ featured, fasterDelivery, ratedSeller, co
48
48
  bgClass: "bg-primary/5 dark:bg-primary/10 border-primary/20 dark:border-primary/30",
49
49
  });
50
50
  }
51
+ /*
52
+ * Final sale is the platform DEFAULT, so unlike every other badge here this
53
+ * one is the common case rather than the exception — it is rendered on
54
+ * warning tokens, not error, because it is a term of the sale and not a
55
+ * fault. `-surface` + matching ink per Root Cause #67: never `text-white`,
56
+ * which is invisible against the light-theme tint.
57
+ */
58
+ if (finalSale) {
59
+ badges.push({
60
+ key: "finalSale",
61
+ icon: _jsx(Span, { size: "xs", children: "\u2298" }),
62
+ label: labels.finalSale,
63
+ colorClass: "text-warning dark:text-warning",
64
+ bgClass: "bg-warning-surface dark:bg-warning-surface border-warning dark:border-warning",
65
+ });
66
+ }
51
67
  if (returnable && condition === "new") {
52
68
  badges.push({
53
69
  key: "returnable",
@@ -3,7 +3,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { Row } from "@mohasinac/appkit/ui";
4
4
  import { useRef } from "react";
5
5
  import { useTranslations } from "next-intl";
6
- import { Alert, Checkbox, Div, FormField, FormGroup, Heading, Label, RichTextEditor, Stack, TagInput, Text, } from "../../../ui";
6
+ import { Alert, Checkbox, Div, FormField, FormGroup, Heading, Label, RichTextEditor, Stack, TagInput, Text, Toggle, } from "../../../ui";
7
+ import { isFinalSale } from "../constants/final-sale";
8
+ import { PRODUCT_MAX_IMAGES, PRODUCT_MAX_VIDEOS, PRODUCT_IMAGE_INDEX_MAX, } from "../../../_internal/shared/media/limits";
7
9
  import { CustomSectionsEditor } from "./CustomSectionsEditor";
8
10
  import { SublistingCategorySelect } from "./SublistingCategorySelect";
9
11
  import { ProductFeaturesSelector } from "./ProductFeaturesSelector";
@@ -56,15 +58,42 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
56
58
  const update = (partial) => {
57
59
  onChange({ ...product, ...partial });
58
60
  };
59
- const galleryImages = (product.images ?? []).map((url) => ({
60
- url,
61
- type: "image",
62
- }));
61
+ /*
62
+ * One control for both media kinds — see the matching comment in
63
+ * SellerProductShell's StepMedia. The document keeps `images: string[]` plus
64
+ * a single `video`; this seeds from both and splits again on write.
65
+ */
66
+ const galleryImages = [
67
+ ...(product.images ?? []).map((url) => ({ url, type: "image" })),
68
+ ...(product.video?.url
69
+ ? [{
70
+ url: product.video.url,
71
+ type: "video",
72
+ ...(product.video.thumbnailUrl ? { thumbnailUrl: product.video.thumbnailUrl } : {}),
73
+ }]
74
+ : []),
75
+ ];
76
+ /** Split a mixed gallery back into the document's two fields. */
77
+ const applyGallery = (fields) => {
78
+ const video = fields.find((f) => f.type === "video");
79
+ update({
80
+ images: fields.filter((f) => f.type === "image").map((f) => f.url),
81
+ video: video
82
+ ? { url: video.url, thumbnailUrl: video.thumbnailUrl ?? product.video?.thumbnailUrl }
83
+ : undefined,
84
+ });
85
+ };
63
86
  const handleGalleryUpload = async (file) => {
87
+ /*
88
+ * The gallery occupies indices 2..N — index 1 belongs to the main image,
89
+ * and this ref started at 0 producing index 1, so the first gallery image
90
+ * generated the same filename as the main image and overwrote it in
91
+ * Storage (content-derived slugs, no timestamp).
92
+ */
64
93
  galleryIndexRef.current += 1;
65
94
  return upload(file, "products", true, {
66
- type: "product-image",
67
- index: galleryIndexRef.current,
95
+ type: file.type.startsWith("video/") ? "product-video" : "product-image",
96
+ index: Math.min(galleryIndexRef.current + 1, PRODUCT_IMAGE_INDEX_MAX),
68
97
  name: product.title || "product",
69
98
  category: (product.categorySlugs?.[0] ?? product.category) || "uncategorized",
70
99
  store: product.storeName || "store",
@@ -113,7 +142,7 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
113
142
  name: product.title || "product",
114
143
  category: (product.categorySlugs?.[0] ?? product.category) || "uncategorized",
115
144
  store: product.storeName || "store",
116
- }), onChange: (url) => update({ mainImage: url }), label: t("formMainImage"), helperText: "Recommended: 800x800px (1:1)" })), isReadonly && product.mainImage && (_jsx(FormField, { name: "mainImage", label: t("formMainImage"), type: "text", value: product.mainImage, onChange: () => { }, disabled: true })), !isReadonly && (_jsx(MediaUploadList, { label: t("formGalleryImages"), value: galleryImages, onChange: (fields) => update({ images: fields.map((f) => f.url) }), onUpload: handleGalleryUpload, accept: "image/*,video/*", maxItems: 5, maxSizeMB: 10, helperText: t("formGalleryImagesHelper"), onAbort: onMediaAbort })), !isReadonly && (_jsx(MediaUploadField, { label: product.listingType === "live" ? `${t("formVideo")} *` : t("formVideo"), value: product.video?.url || "", onChange: (url) => update({
145
+ }), onChange: (url) => update({ mainImage: url }), label: t("formMainImage"), helperText: "Recommended: 800x800px (1:1)" })), isReadonly && product.mainImage && (_jsx(FormField, { name: "mainImage", label: t("formMainImage"), type: "text", value: product.mainImage, onChange: () => { }, disabled: true })), !isReadonly && (_jsx(MediaUploadList, { label: t("formGalleryImages"), value: galleryImages, onChange: applyGallery, onUpload: handleGalleryUpload, accept: "image/*,video/*", maxItems: PRODUCT_MAX_IMAGES + PRODUCT_MAX_VIDEOS, maxImages: PRODUCT_MAX_IMAGES, maxVideos: PRODUCT_MAX_VIDEOS, maxSizeMB: 50, helperText: t("formGalleryImagesHelper"), onAbort: onMediaAbort })), !isReadonly && (_jsx(MediaUploadField, { label: product.listingType === "live" ? `${t("formVideo")} *` : t("formVideo"), value: product.video?.url || "", onChange: (url) => update({
117
146
  video: url
118
147
  ? {
119
148
  url,
@@ -138,7 +167,12 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
138
167
  thumbnailUrl: media.thumbnailUrl,
139
168
  },
140
169
  });
141
- }, onUpload: handleVideoUpload, accept: "video/*", maxSizeMB: 50, helperText: product.listingType === "live"
170
+ }, onUpload: handleVideoUpload,
171
+ // `auto` (image+video+pdf), not `video/*`: this control is also the
172
+ // only place a YouTube/external URL can be attached, and refusing
173
+ // images here served no purpose. The live-listing REQUIREMENT below
174
+ // is unchanged — that is a requiredness rule, not a count rule.
175
+ kind: "auto", maxSizeMB: 50, helperText: product.listingType === "live"
142
176
  ? "Required for live listings — buyers must see the actual animal/plant moving before purchase."
143
177
  : t("formVideoHelper"), onAbort: onMediaAbort })), _jsx(TagInput, { label: t("formTags"), value: product.tags || [], onChange: (tags) => update({ tags }), disabled: isReadonly, placeholder: t("formTagsPlaceholder") }), _jsxs(FormGroup, { columns: 2, children: [_jsx(Checkbox, { label: t("formFeatured"), checked: !!product.featured, onChange: (e) => update({ featured: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formIsPromoted"), checked: !!product.isPromoted, onChange: (e) => update({ isPromoted: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formAllowShipBeforeEmiComplete"), checked: !!product.allowShipBeforeEmiComplete, onChange: (e) => update({ allowShipBeforeEmiComplete: e.target.checked }), disabled: isReadonly }), _jsx(Checkbox, { label: t("formAllowOffers"), checked: !!product.allowOffers, onChange: (e) => update({
144
178
  allowOffers: e.target.checked,
@@ -220,7 +254,9 @@ export function ProductForm({ product, onChange, isReadonly = false, renderDescr
220
254
  value: product.groupId,
221
255
  onChange: (id) => update({ groupId: id || undefined }),
222
256
  disabled: isReadonly,
223
- }), !isAuctionListing(product) && renderGroupSettings?.(product), _jsx(ProductFeaturesSelector, { value: product.features ?? [], onChange: (features) => update({ features }), productType: LISTING_TYPE_TO_FEATURE_PRODUCT_TYPE[normalizeListingType(product)], storeId: product.storeId, disabled: isReadonly }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Custom Sections" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "Add up to 3 custom tabs to your product page \u2014 e.g. \"Box Contents\", \"Compatibility\", \"Grading Details\"." }), _jsx(CustomSectionsEditor, { sections: product.customSections ?? [], onChange: (sections) => update({ customSections: sections }) })] }), _jsx(FormField, { name: "shippingInfo", label: t("formShipping"), type: "textarea", value: product.shippingInfo || "", onChange: (value) => update({ shippingInfo: value }), disabled: isReadonly, placeholder: "Shipping information..." }), _jsx(FormField, { name: "returnPolicy", label: t("formReturnPolicy"), type: "textarea", value: product.returnPolicy || "", onChange: (value) => update({ returnPolicy: value }), disabled: isReadonly, placeholder: "Return policy details..." }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Physical Inventory" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "Assign a storage location for warehouse or shelf organisation. Used by the Print & Label Center." }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { name: "physicalLocation.zone", label: "Zone", type: "text", placeholder: "e.g. A", value: product.physicalLocation?.zone ?? "", onChange: (v) => update({
257
+ }), !isAuctionListing(product) && renderGroupSettings?.(product), _jsx(ProductFeaturesSelector, { value: product.features ?? [], onChange: (features) => update({ features }), productType: LISTING_TYPE_TO_FEATURE_PRODUCT_TYPE[normalizeListingType(product)], storeId: product.storeId, disabled: isReadonly }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Custom Sections" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "Add up to 3 custom tabs to your product page \u2014 e.g. \"Box Contents\", \"Compatibility\", \"Grading Details\"." }), _jsx(CustomSectionsEditor, { sections: product.customSections ?? [], onChange: (sections) => update({ customSections: sections }) })] }), _jsx(FormField, { name: "shippingInfo", label: t("formShipping"), type: "textarea", value: product.shippingInfo || "", onChange: (value) => update({ shippingInfo: value }), disabled: isReadonly, placeholder: "Shipping information..." }), _jsxs(Div, { children: [_jsx(Toggle, { checked: !isFinalSale(product), onChange: (checked) => update({ finalSale: !checked }), disabled: isReadonly, label: "Accept change-of-mind returns" }), _jsx(Text, { className: "mt-1", size: "xs", color: "muted", children: isFinalSale(product)
258
+ ? "Final sale (default). Not-received, damaged, wrong-item, not-as-described and counterfeit claims are still accepted."
259
+ : "Buyers can return this listing within the platform return window for any reason." })] }), _jsx(FormField, { name: "returnPolicy", label: t("formReturnPolicy"), type: "textarea", value: product.returnPolicy || "", onChange: (value) => update({ returnPolicy: value }), disabled: isReadonly, placeholder: "Return policy details..." }), _jsxs(Div, { children: [_jsx(Heading, { level: 3, className: "mb-1", color: "muted", size: "sm", weight: "semibold", children: "Physical Inventory" }), _jsx(Text, { className: "mb-3", color: "muted", size: "xs", children: "Assign a storage location for warehouse or shelf organisation. Used by the Print & Label Center." }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { name: "physicalLocation.zone", label: "Zone", type: "text", placeholder: "e.g. A", value: product.physicalLocation?.zone ?? "", onChange: (v) => update({
224
260
  physicalLocation: {
225
261
  zone: v,
226
262
  shelf: product.physicalLocation?.shelf ?? "",
@@ -10,6 +10,7 @@ import { formatCurrency } from "../../../utils/number.formatter";
10
10
  import { getDefaultCurrency } from "../../../core/baseline-resolver";
11
11
  import { useLongPress } from "../../../react/hooks/useLongPress";
12
12
  import { CardStoreLine } from "./CardStoreLine";
13
+ import { isFinalSale } from "../constants/final-sale";
13
14
  import { FeatureBadgeList } from "./FeatureBadge";
14
15
  import { useProductFeatures } from "./ProductFeaturesContext";
15
16
  import { PRODUCT_FEATURE_CARD_MAX_VISIBLE } from "../constants/product-features.constants";
@@ -31,6 +32,14 @@ const CLS_BADGE_LIMITED = "rounded-full bg-info-solid px-[var(--appkit-space-2)]
31
32
  // inverts with the theme. `warning` because success/info/error are each already
32
33
  // taken by a sibling pill in this same stack.
33
34
  const CLS_BADGE_OFFERABLE = "rounded-full bg-warning-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[10px] font-bold text-warning-on-solid shadow-sm";
35
+ /*
36
+ * Final sale. A fixed dark scrim with white ink, following CLS_BADGE_TRENDING's
37
+ * precedent rather than a `-surface` token: this sits on top of the product
38
+ * PHOTO, where a theme-relative tint is a near-white fill that vanishes against
39
+ * a light image (Root Cause #67). Not `warning-solid`, which "Taking Offers"
40
+ * already owns — two amber pills stacked would read as one control.
41
+ */
42
+ const CLS_BADGE_FINAL_SALE = "rounded-full bg-zinc-900/90 px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[10px] font-bold text-white shadow-sm";
34
43
  const CLS_STAR = "text-[11px] text-warning";
35
44
  const CLS_DISCOUNT_TEXT = "mt-1 text-[11px] font-semibold text-error";
36
45
  const CLS_DISCOUNT_TEXT_BARE = "text-[10px] text-error";
@@ -70,7 +79,7 @@ export function ProductCard({ product, href, onClick, onAddToWishlist, isWishlis
70
79
  .filter(Boolean)
71
80
  .join(" "), children: [_jsxs(Div, { className: `relative ${__O.hidden} aspect-square`, surface: "subtle", children: [product.mainImage ? (_jsx(MediaImage, { src: product.mainImage, alt: product.title, size: "card", className: "transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Row, { surface: "muted", className: "h-full w-full", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-30", size: "4xl", children: "\uD83D\uDECD\uFE0F" }) })), onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => { e.preventDefault(); onSelect(product.id); }, label: isSelected ? "Deselect" : "Select", position: "top-2 left-2", className: selectionMode || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsx(Stack, { className: `absolute top-2 ${onSelect ? "left-10" : "left-2"}`, gap: "xs", children: !selectionMode && !isSelected && (_jsxs(_Fragment, { children: [discount && (_jsxs(Span, { weight: "bold", className: CLS_BADGE_NEW, children: ["-", discount, "%"] })), typeBadge && (_jsx(Span, { weight: "bold", className: `text-[10px] ${typeBadge.cls}`, rounded: "full", padding: "pill-xs", shadow: "sm", children: typeBadge.label })), product.partOfBundleIds && product.partOfBundleIds.length > 0 && (_jsx(Span, { className: CLS_BADGE_SALE, title: product.partOfBundleTitles && product.partOfBundleTitles.length > 0
72
81
  ? `In bundle: ${product.partOfBundleTitles[0]}`
73
- : "In a bundle", children: "Bundled" })), product.groupId && (_jsx(Span, { className: CLS_BADGE_TRENDING, title: product.groupTitle ? `Part of set: ${product.groupTitle}` : "Part of a set", children: product.isGroupParent ? "Set Parent" : "In Set" })), product.allowOffers && (_jsx(Span, { className: CLS_BADGE_OFFERABLE, title: "The seller accepts offers on this listing", children: "Taking Offers" })), product.sublistingCategoryId && (_jsx(Span, { className: CLS_BADGE_LIMITED, title: "Has variants or sub-listings", children: "Has Variants" }))] })) }), _jsx(BaseListingCard.IconOverlay, { groupIcon: product.groupIcon, sublistingIcon: product.sublistingIcon, position: "bottom-2 right-2" }), onAddToWishlist && (_jsx(WishlistHeartButton, { size: "card", variant: "overlay", inWishlist: !!isWishlisted, addLabel: "Add to wishlist", removeLabel: "Remove from wishlist", className: "absolute right-2 top-2", onToggle: (e) => {
82
+ : "In a bundle", children: "Bundled" })), product.groupId && (_jsx(Span, { className: CLS_BADGE_TRENDING, title: product.groupTitle ? `Part of set: ${product.groupTitle}` : "Part of a set", children: product.isGroupParent ? "Set Parent" : "In Set" })), product.allowOffers && (_jsx(Span, { className: CLS_BADGE_OFFERABLE, title: "The seller accepts offers on this listing", children: "Taking Offers" })), isFinalSale(product) && (_jsx(Span, { className: CLS_BADGE_FINAL_SALE, title: "Final sale \u2014 no change-of-mind returns. Not-received, damaged, wrong-item, not-as-described and counterfeit claims are still accepted.", children: "Final Sale" })), product.sublistingCategoryId && (_jsx(Span, { className: CLS_BADGE_LIMITED, title: "Has variants or sub-listings", children: "Has Variants" }))] })) }), _jsx(BaseListingCard.IconOverlay, { groupIcon: product.groupIcon, sublistingIcon: product.sublistingIcon, position: "bottom-2 right-2" }), onAddToWishlist && (_jsx(WishlistHeartButton, { size: "card", variant: "overlay", inWishlist: !!isWishlisted, addLabel: "Add to wishlist", removeLabel: "Remove from wishlist", className: "absolute right-2 top-2", onToggle: (e) => {
74
83
  e.stopPropagation();
75
84
  e.preventDefault();
76
85
  onAddToWishlist(product.id);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Final sale — whether a listing accepts change-of-mind returns.
3
+ *
4
+ * 🛑 ABSENT MEANS FINAL SALE. `finalSale` is optional on `ProductDocument`
5
+ * and the platform default is TRUE, so `undefined` and `true` mean the same
6
+ * thing and only an explicit `false` opts a listing out.
7
+ *
8
+ * That inversion is why this helper exists and why it is the ONLY legal way
9
+ * to read the field. The obvious spellings are all wrong for `undefined`:
10
+ *
11
+ * !product.finalSale // true for undefined -> WRONG, opts out
12
+ * product.finalSale === true // false for undefined -> WRONG, opts out
13
+ * product.finalSale ?? true // correct, but re-derived at each call site
14
+ *
15
+ * Every one of those reads "not final sale" for the ~69 seeded products and
16
+ * every listing created before the field existed — i.e. it fails open, in the
17
+ * direction that silently grants returns the seller never agreed to.
18
+ *
19
+ * The default is deliberately NOT baked into `DEFAULT_PRODUCT_DATA`: writing
20
+ * `finalSale: true` there would make `undefined` unreachable in new documents
21
+ * and hide the rule from anyone reading the schema, while the millions of
22
+ * already-stored documents would still arrive without it. One helper that
23
+ * handles absence is honest; a default that only covers new rows is not.
24
+ *
25
+ * What final sale actually blocks is a REASON, not a refund — see
26
+ * `_internal/shared/features/orders/return-reasons.ts`. Seller- and
27
+ * carrier-fault claims (not received, not as described, damaged, wrong item,
28
+ * counterfeit) are always accepted regardless of this flag.
29
+ */
30
+ export declare function isFinalSale(listing: {
31
+ finalSale?: boolean;
32
+ } | null | undefined): boolean;
33
+ /**
34
+ * The inverse, for reading as a positive at a call site that renders a
35
+ * "Returnable" affordance. Exists so no one writes `!isFinalSale(...)` and
36
+ * then has to reason about the double negative at a glance.
37
+ */
38
+ export declare function acceptsChangeOfMindReturns(listing: {
39
+ finalSale?: boolean;
40
+ } | null | undefined): boolean;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Final sale — whether a listing accepts change-of-mind returns.
3
+ *
4
+ * 🛑 ABSENT MEANS FINAL SALE. `finalSale` is optional on `ProductDocument`
5
+ * and the platform default is TRUE, so `undefined` and `true` mean the same
6
+ * thing and only an explicit `false` opts a listing out.
7
+ *
8
+ * That inversion is why this helper exists and why it is the ONLY legal way
9
+ * to read the field. The obvious spellings are all wrong for `undefined`:
10
+ *
11
+ * !product.finalSale // true for undefined -> WRONG, opts out
12
+ * product.finalSale === true // false for undefined -> WRONG, opts out
13
+ * product.finalSale ?? true // correct, but re-derived at each call site
14
+ *
15
+ * Every one of those reads "not final sale" for the ~69 seeded products and
16
+ * every listing created before the field existed — i.e. it fails open, in the
17
+ * direction that silently grants returns the seller never agreed to.
18
+ *
19
+ * The default is deliberately NOT baked into `DEFAULT_PRODUCT_DATA`: writing
20
+ * `finalSale: true` there would make `undefined` unreachable in new documents
21
+ * and hide the rule from anyone reading the schema, while the millions of
22
+ * already-stored documents would still arrive without it. One helper that
23
+ * handles absence is honest; a default that only covers new rows is not.
24
+ *
25
+ * What final sale actually blocks is a REASON, not a refund — see
26
+ * `_internal/shared/features/orders/return-reasons.ts`. Seller- and
27
+ * carrier-fault claims (not received, not as described, damaged, wrong item,
28
+ * counterfeit) are always accepted regardless of this flag.
29
+ */
30
+ export function isFinalSale(listing) {
31
+ return listing?.finalSale !== false;
32
+ }
33
+ /**
34
+ * The inverse, for reading as a positive at a call site that renders a
35
+ * "Returnable" affordance. Exists so no one writes `!isFinalSale(...)` and
36
+ * then has to reason about the double negative at a glance.
37
+ */
38
+ export function acceptsChangeOfMindReturns(listing) {
39
+ return !isFinalSale(listing);
40
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./types";
2
2
  export * from "./constants/action-defs";
3
+ export * from "./constants/final-sale";
3
4
  export * from "./constants/listing-tabs";
4
5
  export * from "./hooks/useProducts";
5
6
  export * from "./hooks/useProductDetail";
@@ -1,5 +1,6 @@
1
1
  export * from "./types";
2
2
  export * from "./constants/action-defs";
3
+ export * from "./constants/final-sale";
3
4
  export * from "./constants/listing-tabs";
4
5
  export * from "./hooks/useProducts";
5
6
  export * from "./hooks/useProductDetail";
@@ -183,6 +183,23 @@ export interface ProductDocument extends BaseDocument {
183
183
  features?: string[];
184
184
  shippingInfo?: string;
185
185
  returnPolicy?: string;
186
+ /**
187
+ * 🛑 ABSENT MEANS FINAL SALE. The platform default is `true`, so `undefined`
188
+ * and `true` are the same thing and only an explicit `false` opts a listing
189
+ * into change-of-mind returns.
190
+ *
191
+ * Never read this field directly — `!p.finalSale` and `p.finalSale === true`
192
+ * both get `undefined` wrong, in the direction that silently grants returns.
193
+ * Use `isFinalSale(p)` from `features/products/constants/final-sale`.
194
+ *
195
+ * Deliberately NOT in `DEFAULT_PRODUCT_DATA` (a default there would make
196
+ * `undefined` unreachable in new rows while every existing row still lacks
197
+ * it) and NOT in `PRODUCT_INDEXED_FIELDS` (nothing queries it).
198
+ *
199
+ * This blocks a return REASON, not a refund: seller/carrier-fault claims are
200
+ * always accepted. See `_internal/shared/features/orders/return-reasons`.
201
+ */
202
+ finalSale?: boolean;
186
203
  condition?: "new" | "like_new" | "good" | "fair" | "poor" | "used" | "refurbished" | "broken" | "graded";
187
204
  insurance?: boolean;
188
205
  insuranceCost?: number;
@@ -405,8 +422,8 @@ export declare const ProductStatusValues: {
405
422
  export declare const PRODUCT_COLLECTION: "products";
406
423
  export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId"];
407
424
  export declare const DEFAULT_PRODUCT_DATA: Partial<ProductDocument>;
408
- export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
409
- export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
425
+ export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
426
+ export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "finalSale", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
410
427
  export type ProductCreateInput = Omit<ProductDocument, "id" | "createdAt" | "updatedAt" | "availableQuantity" | "bidCount" | "currentBid" | "auctionOriginalEndDate">;
411
428
  export type ProductUpdateInput = Partial<Pick<ProductDocument, (typeof PRODUCT_UPDATABLE_FIELDS)[number]>>;
412
429
  export type ProductAdminUpdateInput = Partial<Omit<ProductDocument, "id" | "createdAt">>;
@@ -72,6 +72,9 @@ export const PRODUCT_PUBLIC_FIELDS = [
72
72
  "features",
73
73
  "shippingInfo",
74
74
  "returnPolicy",
75
+ // Buyer-facing: drives the "Final Sale" card pill and the detail-page
76
+ // Delivery & Returns line. Absent means final sale — read via isFinalSale().
77
+ "finalSale",
75
78
  "listingType",
76
79
  "auctionEndDate",
77
80
  "startingBid",
@@ -138,6 +141,7 @@ export const PRODUCT_UPDATABLE_FIELDS = [
138
141
  "features",
139
142
  "shippingInfo",
140
143
  "returnPolicy",
144
+ "finalSale",
141
145
  "pickupAddressId",
142
146
  "condition",
143
147
  "insurance",
@@ -108,6 +108,8 @@ export interface ProductItem {
108
108
  features?: string[];
109
109
  shippingInfo?: string;
110
110
  returnPolicy?: string;
111
+ /** Absent means final sale — read via `isFinalSale()`, never directly. */
112
+ finalSale?: boolean;
111
113
  ingredients?: string[];
112
114
  howToUse?: string[];
113
115
  allowOffers?: boolean;
@@ -28,6 +28,13 @@ export interface SellerProductDraft {
28
28
  insuranceCost?: number;
29
29
  gstRate?: 0 | 5 | 12 | 18 | 28;
30
30
  hsnCode?: string;
31
+ /**
32
+ * Absent means FINAL SALE — the form seeds it explicitly so the toggle has
33
+ * a value to render, but a draft that never touched the section still saves
34
+ * `undefined`, which the platform reads as final sale. Read via isFinalSale.
35
+ */
36
+ finalSale?: boolean;
37
+ returnPolicy?: string;
31
38
  status?: "draft" | "published";
32
39
  seoTitle?: string;
33
40
  seoDescription?: string;
@@ -12,6 +12,8 @@ import { QuickProductForm } from "./QuickProductForm";
12
12
  import { BarcodeField } from "./BarcodeField";
13
13
  import { pluginFor } from "../../../_internal/shared/listing-types/_registry";
14
14
  import { DEFAULT_MIN_OFFER_PERCENT } from "../../../_internal/shared/features/offers/config";
15
+ import { PRODUCT_MAX_IMAGES, PRODUCT_MAX_VIDEOS, PRODUCT_IMAGE_INDEX_MAX, } from "../../../_internal/shared/media/limits";
16
+ import { isFinalSale } from "../../products/constants/final-sale";
15
17
  import { normalizeError } from "../../../errors/normalize";
16
18
  import { toUserMessage } from "../../../errors/error-display-map";
17
19
  /**
@@ -46,8 +48,13 @@ const sellerProductSchema = z.object({
46
48
  category: z.string().optional(),
47
49
  brand: z.string().optional(),
48
50
  isActive: z.boolean().optional(),
49
- images: z.array(z.string()).max(5, "Up to 5 gallery images allowed").optional(),
51
+ images: z
52
+ .array(z.string())
53
+ .max(PRODUCT_MAX_IMAGES, `Up to ${PRODUCT_MAX_IMAGES} gallery images allowed`)
54
+ .optional(),
50
55
  video: productVideoSchema.optional(),
56
+ finalSale: z.boolean().optional(),
57
+ returnPolicy: z.string().max(1000).optional(),
51
58
  }).passthrough();
52
59
  const CONDITION_OPTIONS = [
53
60
  { value: "new", label: "New" },
@@ -76,26 +83,64 @@ function StepBasic({ values, onChange, renderCategorySelector, renderBrandSelect
76
83
  function StepMedia({ values, onChange, storeSlug = "store", }) {
77
84
  const { upload } = useMediaUpload();
78
85
  const idxRef = useRef(0);
79
- const galleryFields = (values.images ?? []).map((url) => ({
80
- url,
81
- type: "image",
82
- }));
86
+ /*
87
+ * One control for both media kinds.
88
+ *
89
+ * The document keeps `images: string[]` and a single `video` — unchanged —
90
+ * so this seeds from both and splits again on write. Collapsing the two
91
+ * into one stored `media[]` would be a Firestore migration touching cards,
92
+ * detail, cart, orders and search; the input surface is what needed fixing.
93
+ */
94
+ const galleryFields = [
95
+ ...(values.images ?? []).map((url) => ({ url, type: "image" })),
96
+ ...(values.video?.url
97
+ ? [{
98
+ url: values.video.url,
99
+ type: "video",
100
+ ...(values.video.thumbnailUrl ? { thumbnailUrl: values.video.thumbnailUrl } : {}),
101
+ }]
102
+ : []),
103
+ ];
104
+ /** Split a mixed gallery back into the document's two fields. */
105
+ const applyGallery = (fields) => {
106
+ const video = fields.find((f) => f.type === "video");
107
+ onChange({
108
+ images: fields.filter((f) => f.type === "image").map((f) => f.url),
109
+ video: video
110
+ ? {
111
+ ...(values.video ?? { type: "video", source: "upload" }),
112
+ ...video,
113
+ type: "video",
114
+ }
115
+ : undefined,
116
+ });
117
+ };
83
118
  return (_jsxs(Stack, { gap: "md", children: [_jsx(ImageUpload, { currentImage: values.mainImage, onUpload: (file) => upload(file, "products", true, {
84
119
  type: "product-image",
85
120
  index: 1,
86
121
  name: values.title ?? "product",
87
122
  store: storeSlug,
88
123
  category: values.category ?? "uncategorized",
89
- }), onChange: (url) => onChange({ mainImage: url }), label: "Main Image", helperText: "Recommended: 800\u00D7800px square (JPG, PNG, WebP \u2014 max 10 MB)", enableAdvancedCrop: true, cropAspectRatio: 1 }), _jsx(MediaUploadList, { label: "Gallery Images (up to 5)", value: galleryFields, onChange: (fields) => onChange({ images: fields.map((f) => f.url) }), onUpload: (file) => {
124
+ }), onChange: (url) => onChange({ mainImage: url }), label: "Main Image", helperText: "Recommended: 800\u00D7800px square (JPG, PNG, WebP \u2014 max 10 MB)", enableAdvancedCrop: true, cropAspectRatio: 1 }), _jsx(MediaUploadList, { label: `Gallery (up to ${PRODUCT_MAX_IMAGES} images + ${PRODUCT_MAX_VIDEOS} video)`, value: galleryFields, onChange: applyGallery, onUpload: (file) => {
125
+ /*
126
+ * The gallery occupies indices 2..N — index 1 is the main image, and
127
+ * `generateMediaFilename` bakes the index into the filename that the
128
+ * server's `indexGuard` then checks.
129
+ *
130
+ * This was `idxRef.current + 1` off a ref starting at 0, so the FIRST
131
+ * gallery image was index 2 and the fifth was index 6 — one past the
132
+ * server's ceiling of 5. The last slot of every product form returned
133
+ * a 400.
134
+ */
90
135
  idxRef.current += 1;
91
136
  return upload(file, "products", true, {
92
- type: "product-image",
93
- index: idxRef.current + 1,
137
+ type: file.type.startsWith("video/") ? "product-video" : "product-image",
138
+ index: Math.min(idxRef.current + 1, PRODUCT_IMAGE_INDEX_MAX),
94
139
  name: values.title ?? "product",
95
140
  store: storeSlug,
96
141
  category: values.category ?? "uncategorized",
97
142
  });
98
- }, accept: "image/*,video/*", maxItems: 5, maxSizeMB: 10, helperText: "Show multiple angles, grading details, or box contents." }), _jsx(MediaUploadField, { label: "Product Video (optional)", value: values.video?.url ?? "", onChange: (url) => onChange({
143
+ }, accept: "image/*,video/*", maxItems: PRODUCT_MAX_IMAGES + PRODUCT_MAX_VIDEOS, maxImages: PRODUCT_MAX_IMAGES, maxVideos: PRODUCT_MAX_VIDEOS, maxSizeMB: 50, helperText: "Show multiple angles, grading details, or box contents. A video can go here too." }), _jsx(MediaUploadField, { label: "Video options \u2014 YouTube, external URL, trim and poster", value: values.video?.url ?? "", onChange: (url) => onChange({
99
144
  video: url
100
145
  ? { ...(values.video ?? { type: "video", source: "upload" }), url }
101
146
  : undefined,
@@ -103,7 +148,7 @@ function StepMedia({ values, onChange, storeSlug = "store", }) {
103
148
  type: "product-video",
104
149
  name: values.title ?? "product",
105
150
  store: storeSlug,
106
- }), kind: "video", helperText: "MP4, WebM or QuickTime \u2014 max 50 MB" }), _jsx(FormField, { name: "externalVideoUrl", label: "3rd-party video URL (YouTube / Vimeo)", type: "text", value: values.externalVideoUrl ?? "", onChange: (v) => onChange({ externalVideoUrl: v }), placeholder: "https://www.youtube.com/watch?v=\u2026", hint: "External video links are queued for moderation and become visible to buyers after admin approval." }), _jsx(FormField, { name: "youtubeId", label: "YouTube Video ID (optional)", type: "text", value: values.youtubeId ?? "", onChange: (v) => onChange({ youtubeId: v }), placeholder: "e.g. dQw4w9WgXcQ", helpText: "Paste the 11-character ID from the YouTube URL" })] }));
151
+ }), kind: "auto", helperText: "The gallery above accepts a video file directly. Use this panel for a YouTube or external URL, or to trim and pick a poster frame." }), _jsx(FormField, { name: "externalVideoUrl", label: "3rd-party video URL (YouTube / Vimeo)", type: "text", value: values.externalVideoUrl ?? "", onChange: (v) => onChange({ externalVideoUrl: v }), placeholder: "https://www.youtube.com/watch?v=\u2026", hint: "External video links are queued for moderation and become visible to buyers after admin approval." }), _jsx(FormField, { name: "youtubeId", label: "YouTube Video ID (optional)", type: "text", value: values.youtubeId ?? "", onChange: (v) => onChange({ youtubeId: v }), placeholder: "e.g. dQw4w9WgXcQ", helpText: "Paste the 11-character ID from the YouTube URL" })] }));
107
152
  }
108
153
  // ── Step 3: Auction Settings ───────────────────────────────────────────────
109
154
  function StepAuctionSettings({ values, onChange, }) {
@@ -263,6 +308,25 @@ function StepShipping({ values, onChange, renderAddressSelector, }) {
263
308
  { value: "28", label: "28%" },
264
309
  ] }), _jsx(FormField, { name: "hsnCode", label: "HSN Code", value: values.hsnCode ?? "", onChange: (v) => onChange({ hsnCode: v }), placeholder: "e.g. 9503" })] }));
265
310
  }
311
+ // ── Step: Returns ─────────────────────────────────────────────────────────
312
+ /**
313
+ * Final sale + return policy.
314
+ *
315
+ * `returnPolicy` had no seller-facing editor at all before this section — the
316
+ * only place it could be authored was the admin field group, so a seller could
317
+ * never state their own terms.
318
+ *
319
+ * The toggle is phrased positively ("Accept returns") rather than as
320
+ * "Final sale", because a switch whose ON state means "fewer rights for the
321
+ * buyer" reads backwards and invites the seller to flip it by accident. The
322
+ * stored field is still `finalSale`, and OFF is its default.
323
+ */
324
+ function StepReturns({ values, onChange, }) {
325
+ const acceptsReturns = !isFinalSale(values);
326
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Toggle, { checked: acceptsReturns, onChange: (checked) => onChange({ finalSale: !checked }), label: "Accept change-of-mind returns" }), _jsx(Alert, { variant: acceptsReturns ? "info" : "warning", children: acceptsReturns
327
+ ? "Buyers can return this item within the platform return window for any reason, including simply changing their mind."
328
+ : "This is a FINAL SALE — the default. Buyers cannot return it for changing their mind. They can still claim if the item never arrived, arrived damaged, was the wrong item, was not as described, or was counterfeit." }), _jsx(FormField, { name: "returnPolicy", label: "Return policy (optional)", type: "textarea", value: values.returnPolicy ?? "", onChange: (v) => onChange({ returnPolicy: v }), placeholder: "Any extra detail buyers should know \u2014 condition on return, who pays return shipping, how long you take to refund.", hint: "Shown on the listing under Delivery & Returns. This is your own wording; it does not change what the platform allows above." })] }));
329
+ }
266
330
  // ── Step: Publish / SEO ───────────────────────────────────────────────────
267
331
  function StepPublish({ values, onChange, }) {
268
332
  return (_jsxs(Stack, { gap: "md", children: [_jsx(FormField, { name: "status", label: "Listing Status", type: "select", value: values.status ?? "draft", onChange: (v) => onChange({ status: v }), options: [
@@ -276,6 +340,7 @@ const EDIT_SECTIONS = [
276
340
  { id: "media", label: "Media" },
277
341
  { id: "pricing", label: "Pricing" },
278
342
  { id: "shipping", label: "Shipping" },
343
+ { id: "returns", label: "Returns" },
279
344
  { id: "publish", label: "Publish" },
280
345
  ];
281
346
  // ── Main SellerProductShell ───────────────────────────────────────────────
@@ -447,6 +512,12 @@ export function SellerProductShell({ mode, listingType = "standard", initialValu
447
512
  fields: ["shippingPaidBy", "pickupAddressId", "insurance", "insuranceCost"],
448
513
  render: ({ values, onChange }) => (_jsx(StepShipping, { values: values, onChange: onChange, renderAddressSelector: renderAddressSelector })),
449
514
  },
515
+ {
516
+ id: "returns",
517
+ label: "Returns",
518
+ fields: ["finalSale", "returnPolicy"],
519
+ render: ({ values, onChange }) => (_jsx(StepReturns, { values: values, onChange: onChange })),
520
+ },
450
521
  {
451
522
  id: "publish",
452
523
  label: "Publish",
@@ -505,5 +576,5 @@ export function SellerProductShell({ mode, listingType = "standard", initialValu
505
576
  ...EDIT_SECTIONS,
506
577
  ...(typeSpecificSection ? [{ id: typeSpecificSection.id, label: typeSpecificSection.label }] : []),
507
578
  ];
508
- return (_jsx(FormShell, { isOpen: true, onClose: handleDiscard, title: title, breadcrumb: breadcrumb, isDirty: isDirty, isLoading: isLoading, sections: editSections, onSaveDraft: handleSave, onPublish: handlePublish, saveLabel: "Save Changes", publishLabel: "Update", previewSlot: previewSlot, footerTopSlot: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(FormErrorSummary, {}) }), children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Section, { id: "basic", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Basic Info" }), _jsx(StepBasic, { values: draft, onChange: update, renderCategorySelector: renderCategorySelector, renderBrandSelector: renderBrandSelector, renderTemplateSelector: renderTemplateSelector })] }), _jsxs(Section, { id: "media", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Media" }), _jsx(StepMedia, { values: draft, onChange: update, storeSlug: storeSlug })] }), typeSpecificSection && (_jsxs(Section, { id: typeSpecificSection.id, children: [_jsx(Heading, { level: 3, className: "mb-4", children: typeSpecificSection.sectionHeading ?? typeSpecificSection.label }), typeSpecificSection.render({ values: draft, onChange: update, errors: {} })] })), _jsxs(Section, { id: "pricing", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Pricing" }), _jsx(StepPricing, { values: draft, onChange: update, listingType: listingType })] }), _jsxs(Section, { id: "shipping", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Shipping" }), _jsx(StepShipping, { values: draft, onChange: update, renderAddressSelector: renderAddressSelector })] }), _jsxs(Section, { id: "publish", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Publish" }), _jsx(StepPublish, { values: draft, onChange: update }), onSaveAsTemplate && (_jsxs(Div, { className: "mt-4 border-t border-[var(--appkit-color-border)]", padding: "t-md", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onSaveAsTemplate(draft), children: "Save as Template" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-secondary-text)]", size: "xs", children: "Save these settings as a reusable template for future listings." })] }))] })] }) }) }));
579
+ return (_jsx(FormShell, { isOpen: true, onClose: handleDiscard, title: title, breadcrumb: breadcrumb, isDirty: isDirty, isLoading: isLoading, sections: editSections, onSaveDraft: handleSave, onPublish: handlePublish, saveLabel: "Save Changes", publishLabel: "Update", previewSlot: previewSlot, footerTopSlot: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsx(FormErrorSummary, {}) }), children: _jsx(FormShellContext.Provider, { value: shellCtx, children: _jsxs(Stack, { gap: "lg", children: [_jsxs(Section, { id: "basic", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Basic Info" }), _jsx(StepBasic, { values: draft, onChange: update, renderCategorySelector: renderCategorySelector, renderBrandSelector: renderBrandSelector, renderTemplateSelector: renderTemplateSelector })] }), _jsxs(Section, { id: "media", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Media" }), _jsx(StepMedia, { values: draft, onChange: update, storeSlug: storeSlug })] }), typeSpecificSection && (_jsxs(Section, { id: typeSpecificSection.id, children: [_jsx(Heading, { level: 3, className: "mb-4", children: typeSpecificSection.sectionHeading ?? typeSpecificSection.label }), typeSpecificSection.render({ values: draft, onChange: update, errors: {} })] })), _jsxs(Section, { id: "pricing", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Pricing" }), _jsx(StepPricing, { values: draft, onChange: update, listingType: listingType })] }), _jsxs(Section, { id: "shipping", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Shipping" }), _jsx(StepShipping, { values: draft, onChange: update, renderAddressSelector: renderAddressSelector })] }), _jsxs(Section, { id: "returns", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Returns" }), _jsx(StepReturns, { values: draft, onChange: update })] }), _jsxs(Section, { id: "publish", children: [_jsx(Heading, { level: 3, className: "mb-4", children: "Publish" }), _jsx(StepPublish, { values: draft, onChange: update }), onSaveAsTemplate && (_jsxs(Div, { className: "mt-4 border-t border-[var(--appkit-color-border)]", padding: "t-md", children: [_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => onSaveAsTemplate(draft), children: "Save as Template" }), _jsx(Text, { className: "mt-1 text-[var(--appkit-color-secondary-text)]", size: "xs", children: "Save these settings as a reusable template for future listings." })] }))] })] }) }) }));
509
580
  }
@@ -8,3 +8,4 @@ export * from "./components";
8
8
  export * from "./schemas";
9
9
  export * from "./columns";
10
10
  export { manifest } from "./manifest";
11
+ export { draftToProductInput, productToDraft } from "./utils/product-draft-mapping";
@@ -8,3 +8,4 @@ export * from "./components";
8
8
  export * from "./schemas";
9
9
  export * from "./columns";
10
10
  export { manifest } from "./manifest";
11
+ export { draftToProductInput, productToDraft } from "./utils/product-draft-mapping";