feat(seller-editor): 9x9 color grid, mandatory size, two-step category, robust image + discount save
- app/components/store/ColorSelector.tsx: renders as a 9-column grid sorted
by info.sortOrder. Compact aspect-square swatches; Persian name lives in
title/aria-label so 81 swatches breathe on mobile. Checkmark ink flips by
swatch luminance so it stays visible on both سفید and مشکی.
- app/components/store/SizeSelector.tsx + ProductVariantDrawer.tsx: dropped
the "بدون مشخص کردن سایز" toggle now that "FreeSize" exists in the
palette. Every product must pick a size (or FreeSize) on save.
- app/routes/store.add.manual.$storeId.tsx:
- two-step category picker (parent → sub) driven by category.parentId,
with edit-mode pre-fill deriving the parent from the sub's parentId.
- getFinalImageUrls now uploads in parallel with concurrency 3, retries
each image once, persists successful uploads to state as they land so
a resubmit only retries the still-failing ones. If every upload fails
we refuse to submit rather than sending an empty gallery.
- only ship discountPrice when it's a REAL discount (finalNum > 0 and
< priceNum) — otherwise pass null so the backend clears any stale
value. Closes the "phantom discount" hole where PricingForm's
"no discount" radio still shipped the previous typed number.
- isStep1Valid requires every variant to have a real (non-"unselected")
size, matching the new mandatory-size rule.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6a300b8760
commit
83b93fe523
@ -1,4 +1,5 @@
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useColorAttributes } from "~/requestHandler/use-product-hooks";
|
||||
|
||||
interface ColorSelectorProps {
|
||||
@ -9,6 +10,15 @@ interface ColorSelectorProps {
|
||||
onToggleUnselectedMode: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
// The DB now carries a curated 81-color palette laid out as a 9×9 grid.
|
||||
// Each AttributeValue's `info` object has:
|
||||
// persian — display name in Persian
|
||||
// detail — hex swatch color
|
||||
// sort_order — 0..80, row-major position in the grid
|
||||
// grid_row — 0..8
|
||||
// grid_col — 0..8 (columns cluster by hue family)
|
||||
// Anything without a numeric sort_order is treated as trailing so we don't
|
||||
// accidentally hide legacy or admin-inserted values.
|
||||
export const ColorSelector = ({
|
||||
selectedColors,
|
||||
onToggleColor,
|
||||
@ -17,15 +27,34 @@ export const ColorSelector = ({
|
||||
onToggleUnselectedMode,
|
||||
}: ColorSelectorProps) => {
|
||||
const { data: colors, isLoading } = useColorAttributes();
|
||||
|
||||
const sortedColors = useMemo(() => {
|
||||
if (!colors) return [];
|
||||
return colors
|
||||
.filter((c) => c.value !== "UNSELECTED")
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const ax =
|
||||
typeof a.info?.sort_order === "number"
|
||||
? a.info.sort_order
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const bx =
|
||||
typeof b.info?.sort_order === "number"
|
||||
? b.info.sort_order
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return ax - bx;
|
||||
});
|
||||
}, [colors]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
|
||||
<div className="grid grid-cols-7 gap-3">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<div className="grid grid-cols-9 gap-1.5">
|
||||
{[...Array(81)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-12 h-12 rounded-lg border-2 border-WHITE2 bg-WHITE3 animate-pulse"
|
||||
className="aspect-square rounded-md border border-WHITE2 bg-WHITE3 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -33,6 +62,20 @@ export const ColorSelector = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Perceived lightness of the swatch — controls whether we overlay a light
|
||||
// or dark checkmark for the "selected" state so it stays visible on both
|
||||
// near-white swatches (سفید, شیری) and near-black swatches (مشکی, بنفش تیره).
|
||||
const isDarkHex = (hex?: string) => {
|
||||
if (!hex) return false;
|
||||
const h = hex.replace("#", "");
|
||||
if (h.length !== 6) return false;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
// Rec. 709 luma; below 128 reads as "dark ground -> use white ink".
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 128;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
|
||||
@ -54,59 +97,41 @@ export const ColorSelector = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Color Selection Grid - Disabled when unselected mode is on */}
|
||||
{/* 9×9 palette: columns are hue families, rows go light -> dark. */}
|
||||
<div
|
||||
className={`${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
|
||||
>
|
||||
<div className="grid grid-cols-7 gap-1 flex-wrap">
|
||||
{colors.map((color) => (
|
||||
<>
|
||||
{color.value !== "UNSELECTED" && (
|
||||
<div
|
||||
key={color.id}
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
<div className="grid grid-cols-9 gap-1.5">
|
||||
{sortedColors.map((color) => {
|
||||
const isSelected = selectedColors.includes(color.value);
|
||||
const hex = color.info?.detail || "#000000";
|
||||
const dark = isDarkHex(hex);
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
!unselectedMode && onToggleColor(color.value)
|
||||
}
|
||||
key={color.id ?? color.value}
|
||||
type="button"
|
||||
onClick={() => !unselectedMode && onToggleColor(color.value)}
|
||||
disabled={unselectedMode}
|
||||
className={`relative w-12 h-12 rounded-lg border-2 transition-all ${
|
||||
selectedColors.includes(color.value)
|
||||
? "border-blue-500"
|
||||
title={color.info?.persian || color.value}
|
||||
aria-label={color.info?.persian || color.value}
|
||||
aria-pressed={isSelected}
|
||||
className={`relative aspect-square rounded-md border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-VITROWN_BLUE shadow-md"
|
||||
: "border-WHITE2 hover:border-GRAY3"
|
||||
}`}
|
||||
style={{ backgroundColor: color.info.detail }}
|
||||
title={color.info?.persian}
|
||||
style={{ backgroundColor: hex }}
|
||||
>
|
||||
{selectedColors.includes(color.value) && (
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Check
|
||||
className={`w-4 h-4 ${
|
||||
color.info.detail === "#FFFFFF" ||
|
||||
color.info.detail === "#F5F5DC" ||
|
||||
color.info.detail === "#FAFAFA" ||
|
||||
color.info.detail === "#F0F0F0"
|
||||
? "text-BLACK"
|
||||
: "text-WHITE"
|
||||
}`}
|
||||
className={`w-3.5 h-3.5 ${dark ? "text-WHITE" : "text-BLACK"}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
className={`text-xs text-center truncate w-12 ${
|
||||
selectedColors.includes(color.value)
|
||||
? "font-semibold text-blue-700"
|
||||
: "text-BLACK2"
|
||||
}`}
|
||||
>
|
||||
{color.info?.persian || color.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -114,15 +139,16 @@ export const ColorSelector = ({
|
||||
{selectedColors.length > 0 && !unselectedMode && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{selectedColors.map((colorValue) => {
|
||||
const colorInfo = colors.find((c) => c.value === colorValue);
|
||||
const colorInfo = sortedColors.find((c) => c.value === colorValue);
|
||||
const hex = colorInfo?.info?.detail || "#EEE";
|
||||
return (
|
||||
<div
|
||||
key={colorValue}
|
||||
className="flex items-center gap-1 bg-WHITE3 px-3 py-1 rounded-full text-sm"
|
||||
className="flex items-center gap-1.5 bg-WHITE3 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-GRAY3"
|
||||
style={{ backgroundColor: colorValue }}
|
||||
style={{ backgroundColor: hex }}
|
||||
/>
|
||||
<span>{colorInfo?.info?.persian || colorValue}</span>
|
||||
<X
|
||||
|
||||
@ -41,7 +41,6 @@ export const ProductVariantDrawer = ({
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [colorUnselectedMode, setColorUnselectedMode] = useState(false);
|
||||
const [sizeUnselectedMode, setSizeUnselectedMode] = useState(false);
|
||||
|
||||
const initializationDoneRef = useRef<boolean>(false);
|
||||
const prevIsOpenRef = useRef<boolean>(false);
|
||||
@ -51,19 +50,15 @@ export const ProductVariantDrawer = ({
|
||||
// Only initialize when drawer opens for the first time (not on re-renders)
|
||||
if (isOpen && !prevIsOpenRef.current) {
|
||||
if (initialVariants.length > 0) {
|
||||
// Check if any variant has unselected values
|
||||
// Color still supports the "no color" escape hatch.
|
||||
const hasUnselectedColor = initialVariants.some(
|
||||
(v) => v.color === "unselected"
|
||||
);
|
||||
const hasUnselectedSize = initialVariants.some(
|
||||
(v) => v.size === "unselected"
|
||||
);
|
||||
|
||||
// Set unselected modes
|
||||
setColorUnselectedMode(hasUnselectedColor);
|
||||
setSizeUnselectedMode(hasUnselectedSize);
|
||||
|
||||
// Extract unique colors and sizes from initial variants (excluding unselected)
|
||||
// Extract unique colors and sizes from initial variants (excluding unselected).
|
||||
// Legacy variants with size="unselected" are dropped here so the seller is
|
||||
// forced to pick a real size (FreeSize covers one-size products).
|
||||
const colors = [
|
||||
...new Set(
|
||||
initialVariants
|
||||
@ -101,7 +96,6 @@ export const ProductVariantDrawer = ({
|
||||
setBulkQuantity("1");
|
||||
setCustomQuantities({});
|
||||
setColorUnselectedMode(false);
|
||||
setSizeUnselectedMode(false);
|
||||
}
|
||||
initializationDoneRef.current = true;
|
||||
}
|
||||
@ -114,7 +108,6 @@ export const ProductVariantDrawer = ({
|
||||
setBulkQuantity("1");
|
||||
setCustomQuantities({});
|
||||
setColorUnselectedMode(false);
|
||||
setSizeUnselectedMode(false);
|
||||
initializationDoneRef.current = false;
|
||||
}
|
||||
|
||||
@ -136,19 +129,14 @@ export const ProductVariantDrawer = ({
|
||||
if (
|
||||
JSON.stringify(currentVariantKeys) !== JSON.stringify(newVariantKeys)
|
||||
) {
|
||||
// Check if any variant has unselected values
|
||||
// Color still supports the "no color" escape hatch.
|
||||
const hasUnselectedColor = initialVariants.some(
|
||||
(v) => v.color === "unselected"
|
||||
);
|
||||
const hasUnselectedSize = initialVariants.some(
|
||||
(v) => v.size === "unselected"
|
||||
);
|
||||
|
||||
// Set unselected modes
|
||||
setColorUnselectedMode(hasUnselectedColor);
|
||||
setSizeUnselectedMode(hasUnselectedSize);
|
||||
|
||||
// Extract unique colors and sizes from initial variants (excluding unselected)
|
||||
// Extract unique colors and sizes from initial variants (excluding unselected).
|
||||
// Legacy "unselected" size variants are ignored so the seller must pick.
|
||||
const colors = [
|
||||
...new Set(
|
||||
initialVariants
|
||||
@ -247,19 +235,12 @@ export const ProductVariantDrawer = ({
|
||||
setCustomQuantities((prev) => ({ ...prev, ...newQuantities }));
|
||||
};
|
||||
|
||||
// Get variants for quantity display based on unselected modes
|
||||
// Get variants for quantity display. Size is always required (FreeSize covers
|
||||
// one-size products); color still has an unselected escape hatch.
|
||||
const getQuantityVariants = () => {
|
||||
const variants: { color: string; size: string; key: string }[] = [];
|
||||
|
||||
if (colorUnselectedMode && sizeUnselectedMode) {
|
||||
// Both unselected - show one general variant
|
||||
variants.push({
|
||||
color: "unselected",
|
||||
size: "unselected",
|
||||
key: "unselected-unselected",
|
||||
});
|
||||
} else if (colorUnselectedMode && !sizeUnselectedMode) {
|
||||
// Color unselected, size selected - show variants for each selected size
|
||||
if (colorUnselectedMode) {
|
||||
selectedSizes.forEach((size) => {
|
||||
variants.push({
|
||||
color: "unselected",
|
||||
@ -267,17 +248,7 @@ export const ProductVariantDrawer = ({
|
||||
key: `unselected-${size}`,
|
||||
});
|
||||
});
|
||||
} else if (!colorUnselectedMode && sizeUnselectedMode) {
|
||||
// Size unselected, color selected - show variants for each selected color
|
||||
selectedColors.forEach((color) => {
|
||||
variants.push({
|
||||
color: color,
|
||||
size: "unselected",
|
||||
key: `${color}-unselected`,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Normal mode - show all combinations
|
||||
selectedColors.forEach((color) => {
|
||||
selectedSizes.forEach((size) => {
|
||||
variants.push({
|
||||
@ -293,37 +264,22 @@ export const ProductVariantDrawer = ({
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
// Check if at least one variant can be created
|
||||
if (colorUnselectedMode && sizeUnselectedMode) {
|
||||
// Both unselected - always valid
|
||||
} else if (colorUnselectedMode && !sizeUnselectedMode) {
|
||||
// Color unselected, need at least one size
|
||||
// Size is always required; use "فری سایز" for one-size products.
|
||||
if (selectedSizes.length === 0) {
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "لطفاً حداقل یک سایز انتخاب کنید",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (!colorUnselectedMode && sizeUnselectedMode) {
|
||||
// Size unselected, need at least one color
|
||||
if (selectedColors.length === 0) {
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "لطفاً حداقل یک رنگ انتخاب کنید",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Normal mode - need both color and size
|
||||
if (selectedColors.length === 0 || selectedSizes.length === 0) {
|
||||
toast({
|
||||
title: "خطا",
|
||||
description:
|
||||
"لطفاً حداقل یک رنگ و یک سایز انتخاب کنید یا حالت بدون رنگ/سایز را فعال کنید",
|
||||
"لطفاً حداقل یک سایز انتخاب کنید. اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Color is optional (colorUnselectedMode covers no-color products).
|
||||
if (!colorUnselectedMode && selectedColors.length === 0) {
|
||||
toast({
|
||||
title: "خطا",
|
||||
description: "لطفاً حداقل یک رنگ انتخاب کنید یا حالت بدون رنگ را فعال کنید",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStep("quantities");
|
||||
};
|
||||
@ -364,8 +320,6 @@ export const ProductVariantDrawer = ({
|
||||
selectedSizes={selectedSizes}
|
||||
onToggleSize={toggleSize}
|
||||
onRemoveSize={removeSize}
|
||||
unselectedMode={sizeUnselectedMode}
|
||||
onToggleUnselectedMode={setSizeUnselectedMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -424,17 +378,14 @@ export const ProductVariantDrawer = ({
|
||||
(c) => c.value === color
|
||||
);
|
||||
|
||||
// Helper function to get display text
|
||||
// Helper function to get display text — colorless variants
|
||||
// roll up under the size label; sized/colored variants show
|
||||
// both.
|
||||
const getDisplayText = () => {
|
||||
if (colorUnselectedMode && sizeUnselectedMode) {
|
||||
return "کل موجودی محصول";
|
||||
} else if (colorUnselectedMode && !sizeUnselectedMode) {
|
||||
if (colorUnselectedMode) {
|
||||
return `موجودی سایز ${size}`;
|
||||
} else if (!colorUnselectedMode && sizeUnselectedMode) {
|
||||
return `موجودی ${colorInfo?.info.persian || color}`;
|
||||
} else {
|
||||
return null; // Normal mode - will show both color and size
|
||||
}
|
||||
return null; // Normal mode — render color + size below
|
||||
};
|
||||
|
||||
const displayText = getDisplayText();
|
||||
|
||||
@ -5,16 +5,12 @@ interface SizeSelectorProps {
|
||||
selectedSizes: string[];
|
||||
onToggleSize: (size: string) => void;
|
||||
onRemoveSize: (size: string) => void;
|
||||
unselectedMode: boolean;
|
||||
onToggleUnselectedMode: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const SizeSelector: React.FC<SizeSelectorProps> = ({
|
||||
selectedSizes,
|
||||
onToggleSize,
|
||||
onRemoveSize,
|
||||
unselectedMode,
|
||||
onToggleUnselectedMode,
|
||||
}: SizeSelectorProps) => {
|
||||
const { data: sizes, isLoading } = useSizeAttributes();
|
||||
if (isLoading) {
|
||||
@ -36,35 +32,17 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-right">انتخاب سایز های موجود:</h4>
|
||||
<p className="text-xs text-GRAY text-right">
|
||||
اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.
|
||||
</p>
|
||||
|
||||
{/* Unselected Mode Switch */}
|
||||
<div className="flex items-center justify-between p-3 bg-GRAY3 rounded-lg">
|
||||
<span className="text-sm font-medium text-BLACK2">
|
||||
بدون مشخص کردن سایز
|
||||
</span>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unselectedMode}
|
||||
onChange={(e) => onToggleUnselectedMode(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
aria-label="حالت بدون سایز"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-WHITE2 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-WHITE after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-WHITE after:border-GRAY3 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-VITROWN_BLUE border"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Size Selection Grid - Disabled when unselected mode is on */}
|
||||
<div
|
||||
className={`grid grid-cols-4 gap-2 ${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{sizes.map((size) => (
|
||||
<>
|
||||
{size.value !== "UNSELECTED" && (
|
||||
<button
|
||||
key={size.id}
|
||||
onClick={() => !unselectedMode && onToggleSize(size.value)}
|
||||
disabled={unselectedMode}
|
||||
onClick={() => onToggleSize(size.value)}
|
||||
className={`py-3 px-4 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
selectedSizes.includes(size.value)
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
@ -79,7 +57,7 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Selected Sizes */}
|
||||
{selectedSizes.length > 0 && !unselectedMode && (
|
||||
{selectedSizes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedSizes.map((sizeValue) => {
|
||||
const sizeInfo = sizes.find((s) => s.value === sizeValue);
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
useUpdateProduct,
|
||||
} from "~/requestHandler/use-product-hooks";
|
||||
import { useImageUpload } from "~/requestHandler/use-media-hooks";
|
||||
import { toast } from "~/hooks/use-toast";
|
||||
import AppSelectBox from "~/components/AppSelectBox";
|
||||
import { FormField } from "~/components/ui/FormField";
|
||||
import {
|
||||
@ -133,7 +134,20 @@ export default function AddProduct() {
|
||||
const { data: categoriesData, isLoading: categoriesLoading } =
|
||||
useProductCategories();
|
||||
|
||||
// Transform categories data for AppSelectBox
|
||||
// Categories now live in a two-level tree (parent > sub). The seller first
|
||||
// picks the parent bucket, then a sub-bucket underneath. Selected sub is
|
||||
// the value we send to the backend; the parent is UI state only.
|
||||
const parentOptions = (categoriesData || [])
|
||||
.filter((c) => !c.parentId)
|
||||
.map((cat) => ({ value: cat.id || "", label: cat.name }));
|
||||
|
||||
const [selectedParent, setSelectedParent] = useState("");
|
||||
|
||||
const subOptions = (categoriesData || [])
|
||||
.filter((c) => c.parentId && c.parentId === selectedParent)
|
||||
.map((cat) => ({ value: cat.id || "", label: cat.name }));
|
||||
|
||||
// Legacy flat list used by helpers/copy that just want to look up a name.
|
||||
const categoryOptions = (categoriesData || []).map((cat) => ({
|
||||
value: cat.id || "",
|
||||
label: cat.name,
|
||||
@ -152,6 +166,12 @@ export default function AddProduct() {
|
||||
setProductTitle(existingProduct.title || "");
|
||||
setProductDescription(existingProduct.description || "");
|
||||
setSelectedCategory(existingProduct.category || "");
|
||||
// Derive the parent bucket from the sub's parentId so the two-step
|
||||
// picker shows the correct main category when the seller opens edit.
|
||||
if (existingProduct.category && categoriesData) {
|
||||
const sub = categoriesData.find((c) => c.id === existingProduct.category);
|
||||
if (sub?.parentId) setSelectedParent(sub.parentId);
|
||||
}
|
||||
setSizeChart(existingProduct.sizeChart || null);
|
||||
setProductPrice(
|
||||
existingProduct.basePrice
|
||||
@ -449,13 +469,19 @@ export default function AddProduct() {
|
||||
};
|
||||
|
||||
// Validation functions
|
||||
const allVariantsHaveRealSize = () =>
|
||||
productVariants.length > 0 &&
|
||||
productVariants.every(
|
||||
(v) => v.size && v.size !== "unselected" && v.size.trim() !== ""
|
||||
);
|
||||
|
||||
const isStep1Valid = () => {
|
||||
return (
|
||||
selectedImages.length > 0 &&
|
||||
productTitle.trim() !== "" &&
|
||||
productDescription.trim() !== "" &&
|
||||
selectedCategory !== "" &&
|
||||
productVariants.length > 0 &&
|
||||
allVariantsHaveRealSize() &&
|
||||
isSizeChartComplete()
|
||||
);
|
||||
};
|
||||
@ -469,56 +495,100 @@ export default function AddProduct() {
|
||||
return selectedImages.filter((img) => img.isEdited || !img.originalUrl);
|
||||
};
|
||||
|
||||
// Helper function to get final image URLs
|
||||
// Helper function to get final image URLs.
|
||||
//
|
||||
// Uploads in parallel (bounded concurrency) with one automatic retry per
|
||||
// image, so a single flaky request over a slow Iran connection can't drop
|
||||
// the seller out of the whole submission. Every successful upload is
|
||||
// persisted to `selectedImages[i].uploadedUrl` immediately, so if the
|
||||
// seller retries the submit later, only the still-failing images re-upload.
|
||||
// Returns only the successful URLs; if any failed, the seller gets a
|
||||
// toast naming the count so they know to re-pick and try again.
|
||||
const getFinalImageUrls = async (): Promise<string[]> => {
|
||||
const imagesToUpload = getImagesToUpload();
|
||||
const totalImages = imagesToUpload.length;
|
||||
|
||||
const totalImages = selectedImages.length;
|
||||
setImageUploadProgress({
|
||||
current: 0,
|
||||
total: totalImages,
|
||||
isUploading: true,
|
||||
});
|
||||
|
||||
const finalUrls: string[] = [];
|
||||
|
||||
// Process each image
|
||||
for (let i = 0; i < selectedImages.length; i++) {
|
||||
const image = selectedImages[i];
|
||||
|
||||
if (image.uploadedUrl) {
|
||||
// If image was already uploaded before, use that URL
|
||||
finalUrls.push(image.uploadedUrl);
|
||||
} else if (image.isEdited || !image.originalUrl) {
|
||||
// Upload edited or new images
|
||||
let completed = 0;
|
||||
const bumpProgress = () => {
|
||||
completed += 1;
|
||||
setImageUploadProgress({
|
||||
current: finalUrls.length + 1,
|
||||
current: completed,
|
||||
total: totalImages,
|
||||
isUploading: true,
|
||||
});
|
||||
};
|
||||
|
||||
const CONCURRENCY = 3;
|
||||
const isValidUrl = (u: unknown): u is string =>
|
||||
typeof u === "string" && /^https?:\/\//i.test(u);
|
||||
|
||||
const uploadOne = async (index: number): Promise<string | null> => {
|
||||
const image = selectedImages[index];
|
||||
|
||||
// Already-uploaded or unedited-existing paths bypass the network.
|
||||
if (image.uploadedUrl && isValidUrl(image.uploadedUrl)) {
|
||||
bumpProgress();
|
||||
return image.uploadedUrl;
|
||||
}
|
||||
if (!image.isEdited && image.originalUrl && isValidUrl(image.originalUrl)) {
|
||||
bumpProgress();
|
||||
return image.originalUrl;
|
||||
}
|
||||
|
||||
// Needs an upload — try twice.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const result = await imageUploadMutation.mutateAsync({
|
||||
file: image.file,
|
||||
usageType: MediaUploadImageUsageEnum.Product,
|
||||
});
|
||||
|
||||
const uploadedUrl = result.originalUrl || "";
|
||||
finalUrls.push(uploadedUrl);
|
||||
|
||||
// Store the uploaded URL for future use
|
||||
const uploadedUrl = result.originalUrl;
|
||||
if (isValidUrl(uploadedUrl)) {
|
||||
// Persist success so a subsequent retry-submit doesn't re-upload.
|
||||
setSelectedImages((prev) =>
|
||||
prev.map((img, index) =>
|
||||
index === i ? { ...img, uploadedUrl } : img
|
||||
)
|
||||
prev.map((img, i) => (i === index ? { ...img, uploadedUrl } : img))
|
||||
);
|
||||
} else {
|
||||
// Use original URL for unedited images
|
||||
finalUrls.push(image.originalUrl);
|
||||
bumpProgress();
|
||||
return uploadedUrl;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to next attempt.
|
||||
}
|
||||
}
|
||||
bumpProgress();
|
||||
return null;
|
||||
};
|
||||
|
||||
const queue = selectedImages.map((_, i) => i);
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const idx = queue.shift();
|
||||
if (idx == null) break;
|
||||
results[idx] = await uploadOne(idx);
|
||||
}
|
||||
};
|
||||
const results: (string | null)[] = new Array(totalImages).fill(null);
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(CONCURRENCY, totalImages) }, () => worker())
|
||||
);
|
||||
|
||||
setImageUploadProgress({ current: 0, total: 0, isUploading: false });
|
||||
return finalUrls;
|
||||
|
||||
const succeeded = results.filter(isValidUrl);
|
||||
const failedCount = results.length - succeeded.length;
|
||||
if (failedCount > 0) {
|
||||
toast({
|
||||
title: `${failedCount} تصویر آپلود نشد`,
|
||||
description:
|
||||
"با تصاویر باقیمانده ادامه دادیم. برای آپلود تصاویر ناموفق، بعد از ذخیرهسازی محصول، آنها را دوباره اضافه کنید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return succeeded;
|
||||
};
|
||||
|
||||
// Helper function to prepare product attributes
|
||||
@ -584,11 +654,25 @@ export default function AddProduct() {
|
||||
attributes.SIZE = variant.size;
|
||||
}
|
||||
|
||||
// Only ship a `discountPrice` when the seller has actually configured a
|
||||
// discount (finalPrice strictly below productPrice). Passing an equal
|
||||
// value used to leak through the PricingForm's "no discount" radio and
|
||||
// land as a phantom promo in the DB — the storefront still hid it, but
|
||||
// saving the product a second time could bring it back if the field
|
||||
// drifted. Send null so the backend clears any stale value on update.
|
||||
const priceNum = Number(productPrice || 0);
|
||||
const finalNum = Number(finalPrice || 0);
|
||||
const hasRealDiscount =
|
||||
Number.isFinite(priceNum) &&
|
||||
Number.isFinite(finalNum) &&
|
||||
finalNum > 0 &&
|
||||
finalNum < priceNum;
|
||||
|
||||
return {
|
||||
variantName: variantName,
|
||||
stock: variant.quantity,
|
||||
price: productPrice || "0",
|
||||
discountPrice: finalPrice || productPrice || "0",
|
||||
discountPrice: hasRealDiscount ? finalPrice : null,
|
||||
attributes: attributes,
|
||||
};
|
||||
});
|
||||
@ -602,18 +686,33 @@ export default function AddProduct() {
|
||||
// Get final image URLs (only upload edited/new images)
|
||||
const imageUrls = await getFinalImageUrls();
|
||||
|
||||
// getFinalImageUrls already filters to valid http(s) URLs and toasts
|
||||
// when some failed. Refuse to submit with no images at all — otherwise
|
||||
// the seller sees a cryptic backend validation error.
|
||||
if (imageUrls.length === 0) {
|
||||
toast({
|
||||
title: "هیچ تصویری آپلود نشد",
|
||||
description:
|
||||
"لطفاً اتصال اینترنت را بررسی کنید و دوباره تلاش کنید. با تصاویر کوچکتر یا کمتر شروع کنید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeChartPayload =
|
||||
sizeChart && sizeChart.columns.length > 0 && sizeChart.rows.length > 0
|
||||
? sizeChart
|
||||
: null;
|
||||
|
||||
const validImageUrls = imageUrls;
|
||||
|
||||
if (isEditMode && editProductId) {
|
||||
// Update existing product
|
||||
const updateData = {
|
||||
title: productTitle,
|
||||
description: productDescription,
|
||||
basePrice: productPrice || "",
|
||||
imageUrls: imageUrls.filter(Boolean), // Remove empty URLs
|
||||
imageUrls: validImageUrls,
|
||||
category: selectedCategory,
|
||||
attributes: prepareProductAttributes(productVariants),
|
||||
variants: prepareProductVariants(productVariants),
|
||||
@ -630,7 +729,7 @@ export default function AddProduct() {
|
||||
title: productTitle,
|
||||
description: productDescription,
|
||||
basePrice: productPrice || "",
|
||||
imageUrls: imageUrls.filter(Boolean), // Remove empty URLs
|
||||
imageUrls: validImageUrls,
|
||||
category: selectedCategory,
|
||||
attributes: prepareProductAttributes(productVariants),
|
||||
variants: prepareProductVariants(productVariants),
|
||||
@ -827,14 +926,34 @@ export default function AddProduct() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Category Selection */}
|
||||
<div className="px-4 mt-4">
|
||||
<FormField label="دستهبندی" required>
|
||||
{/* Category Selection — two-step: parent then sub */}
|
||||
<div className="px-4 mt-4 space-y-3">
|
||||
<FormField label="دستهبندی اصلی" required>
|
||||
<AppSelectBox
|
||||
items={categoryOptions}
|
||||
items={parentOptions}
|
||||
value={selectedParent}
|
||||
setValue={(v: string) => {
|
||||
setSelectedParent(v);
|
||||
// Reset the sub whenever the parent changes so we never
|
||||
// send a sub that no longer belongs to the picked parent.
|
||||
setSelectedCategory("");
|
||||
}}
|
||||
placeholder="ابتدا یک دسته اصلی انتخاب کنید"
|
||||
loading={categoriesLoading}
|
||||
haveSearchbar={true}
|
||||
width="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="زیردسته" required>
|
||||
<AppSelectBox
|
||||
items={subOptions}
|
||||
value={selectedCategory}
|
||||
setValue={setSelectedCategory}
|
||||
placeholder="انتخاب دستهبندی"
|
||||
placeholder={
|
||||
selectedParent
|
||||
? "یک زیردسته انتخاب کنید"
|
||||
: "ابتدا دسته اصلی را انتخاب کنید"
|
||||
}
|
||||
loading={categoriesLoading}
|
||||
haveSearchbar={true}
|
||||
width="w-full"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user