@mohasinac/appkit 4.33.0 → 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.
- package/dist/_internal/server/features/checkout/actions.js +9 -1
- package/dist/_internal/server/features/checkout/expand-order-items.js +9 -0
- package/dist/_internal/server/features/media/contextGuards.d.ts +3 -3
- package/dist/_internal/server/features/media/contextGuards.js +19 -5
- package/dist/_internal/server/features/orders/actions.d.ts +9 -0
- package/dist/_internal/server/features/orders/actions.js +47 -4
- package/dist/_internal/server/features/orders/adapters.js +10 -0
- package/dist/_internal/server/features/refunds/actions.d.ts +9 -1
- package/dist/_internal/server/features/refunds/actions.js +36 -3
- package/dist/_internal/server/features/site-settings/adapters.js +1 -1
- package/dist/_internal/shared/features/orders/refund-copy.d.ts +12 -0
- package/dist/_internal/shared/features/orders/refund-copy.js +12 -0
- package/dist/_internal/shared/features/orders/return-reasons.d.ts +59 -0
- package/dist/_internal/shared/features/orders/return-reasons.js +96 -0
- package/dist/_internal/shared/features/orders/schema.d.ts +30 -0
- package/dist/_internal/shared/features/orders/schema.js +20 -0
- package/dist/_internal/shared/features/products/schema.d.ts +12 -0
- package/dist/_internal/shared/features/products/schema.js +4 -1
- package/dist/_internal/shared/media/limits.d.ts +14 -0
- package/dist/_internal/shared/media/limits.js +29 -0
- package/dist/client.d.ts +9 -1
- package/dist/client.js +19 -1
- package/dist/features/about/components/PolicyPageView.js +1 -1
- package/dist/features/admin/components/AdminProductEditorView.js +6 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +2 -6
- package/dist/features/admin/components/index.d.ts +0 -2
- package/dist/features/admin/components/index.js +0 -1
- package/dist/features/admin/schemas/admin-editor-forms.js +1 -1
- package/dist/features/admin/schemas/firestore.d.ts +0 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +4 -3
- package/dist/features/contact/components/ContactForm.js +46 -31
- package/dist/features/media/upload/MediaUploadList.d.ts +16 -1
- package/dist/features/media/upload/MediaUploadList.js +58 -8
- package/dist/features/orders/components/RefundRequestView.d.ts +26 -10
- package/dist/features/orders/components/RefundRequestView.js +108 -24
- package/dist/features/orders/schemas/firestore.d.ts +42 -0
- package/dist/features/orders/types/index.d.ts +24 -0
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +5 -3
- package/dist/features/products/components/ProductDetailPageView.js +14 -2
- package/dist/features/products/components/ProductFeatureBadges.d.ts +9 -1
- package/dist/features/products/components/ProductFeatureBadges.js +17 -1
- package/dist/features/products/components/ProductForm.js +46 -10
- package/dist/features/products/components/ProductGrid.js +10 -1
- package/dist/features/products/constants/final-sale.d.ts +40 -0
- package/dist/features/products/constants/final-sale.js +40 -0
- package/dist/features/products/index.d.ts +1 -0
- package/dist/features/products/index.js +1 -0
- package/dist/features/products/schemas/firestore.d.ts +19 -2
- package/dist/features/products/schemas/firestore.js +4 -0
- package/dist/features/products/types/index.d.ts +2 -0
- package/dist/features/seller/components/SellerProductShell.d.ts +7 -0
- package/dist/features/seller/components/SellerProductShell.js +82 -11
- package/dist/features/seller/index.d.ts +1 -0
- package/dist/features/seller/index.js +1 -0
- package/dist/features/seller/schemas/product-form.d.ts +304 -0
- package/dist/features/seller/schemas/product-form.js +132 -0
- package/dist/features/seller/utils/product-draft-mapping.d.ts +117 -0
- package/dist/features/seller/utils/product-draft-mapping.js +147 -0
- package/dist/features/shell/SectionForm.js +62 -7
- package/dist/features/shell/form-sections.js +4 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +200 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +12 -3
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/schemas/registry.d.ts +271 -0
- package/dist/schemas/registry.js +9 -0
- package/dist/seed/_helpers/final-sale-fixtures.d.ts +35 -0
- package/dist/seed/_helpers/final-sale-fixtures.js +61 -0
- package/dist/seed/products-art-seed-data.js +9 -1
- package/dist/seed/products-auctions-seed-data.js +2 -1
- package/dist/seed/products-classifieds-seed-data.js +2 -1
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.d.ts +2 -138
- package/dist/seed/products-preorders-seed-data.js +2 -1
- package/dist/seed/products-prize-draws-seed-data.js +2 -1
- package/dist/seed/products-standard-seed-data.js +2 -1
- package/dist/seed/products-stickers-seed-data.js +9 -1
- package/dist/server.d.ts +6 -2
- package/dist/server.js +15 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/motion.d.ts +12 -0
- package/dist/tokens/motion.js +12 -0
- package/dist/ui/components/CollapsibleSection.d.ts +18 -1
- package/dist/ui/components/CollapsibleSection.js +20 -3
- package/dist/ui/components/Motion.js +17 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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,
|
|
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..." }),
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
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:
|
|
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: "
|
|
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
|
}
|