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:
fazli 2026-07-18 13:19:13 +00:00
parent 6a300b8760
commit 83b93fe523
4 changed files with 285 additions and 211 deletions

View File

@ -1,4 +1,5 @@
import { Check, X } from "lucide-react"; import { Check, X } from "lucide-react";
import { useMemo } from "react";
import { useColorAttributes } from "~/requestHandler/use-product-hooks"; import { useColorAttributes } from "~/requestHandler/use-product-hooks";
interface ColorSelectorProps { interface ColorSelectorProps {
@ -9,6 +10,15 @@ interface ColorSelectorProps {
onToggleUnselectedMode: (enabled: boolean) => void; 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 = ({ export const ColorSelector = ({
selectedColors, selectedColors,
onToggleColor, onToggleColor,
@ -17,15 +27,34 @@ export const ColorSelector = ({
onToggleUnselectedMode, onToggleUnselectedMode,
}: ColorSelectorProps) => { }: ColorSelectorProps) => {
const { data: colors, isLoading } = useColorAttributes(); 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) { if (isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4> <h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
<div className="grid grid-cols-7 gap-3"> <div className="grid grid-cols-9 gap-1.5">
{[...Array(20)].map((_, i) => ( {[...Array(81)].map((_, i) => (
<div <div
key={i} 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> </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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4> <h4 className="font-medium text-right">انتخاب رنگ های موجود:</h4>
@ -54,59 +97,41 @@ export const ColorSelector = ({
</label> </label>
</div> </div>
{/* Color Selection Grid - Disabled when unselected mode is on */} {/* 9×9 palette: columns are hue families, rows go light -> dark. */}
<div <div
className={`${unselectedMode ? "opacity-50 pointer-events-none" : ""}`} className={`${unselectedMode ? "opacity-50 pointer-events-none" : ""}`}
> >
<div className="grid grid-cols-7 gap-1 flex-wrap"> <div className="grid grid-cols-9 gap-1.5">
{colors.map((color) => ( {sortedColors.map((color) => {
<> const isSelected = selectedColors.includes(color.value);
{color.value !== "UNSELECTED" && ( const hex = color.info?.detail || "#000000";
<div const dark = isDarkHex(hex);
key={color.id} return (
className="flex flex-col items-center gap-1"
>
<button <button
onClick={() => key={color.id ?? color.value}
!unselectedMode && onToggleColor(color.value) type="button"
} onClick={() => !unselectedMode && onToggleColor(color.value)}
disabled={unselectedMode} disabled={unselectedMode}
className={`relative w-12 h-12 rounded-lg border-2 transition-all ${ title={color.info?.persian || color.value}
selectedColors.includes(color.value) aria-label={color.info?.persian || color.value}
? "border-blue-500" aria-pressed={isSelected}
className={`relative aspect-square rounded-md border-2 transition-all ${
isSelected
? "border-VITROWN_BLUE shadow-md"
: "border-WHITE2 hover:border-GRAY3" : "border-WHITE2 hover:border-GRAY3"
}`} }`}
style={{ backgroundColor: color.info.detail }} style={{ backgroundColor: hex }}
title={color.info?.persian}
> >
{selectedColors.includes(color.value) && ( {isSelected && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<Check <Check
className={`w-4 h-4 ${ className={`w-3.5 h-3.5 ${dark ? "text-WHITE" : "text-BLACK"}`}
color.info.detail === "#FFFFFF" ||
color.info.detail === "#F5F5DC" ||
color.info.detail === "#FAFAFA" ||
color.info.detail === "#F0F0F0"
? "text-BLACK"
: "text-WHITE"
}`}
/> />
</div> </div>
)} )}
</button> </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>
</div> </div>
@ -114,15 +139,16 @@ export const ColorSelector = ({
{selectedColors.length > 0 && !unselectedMode && ( {selectedColors.length > 0 && !unselectedMode && (
<div className="flex flex-wrap gap-2 mt-3"> <div className="flex flex-wrap gap-2 mt-3">
{selectedColors.map((colorValue) => { {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 ( return (
<div <div
key={colorValue} 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 <div
className="w-3 h-3 rounded-full border border-GRAY3" className="w-3 h-3 rounded-full border border-GRAY3"
style={{ backgroundColor: colorValue }} style={{ backgroundColor: hex }}
/> />
<span>{colorInfo?.info?.persian || colorValue}</span> <span>{colorInfo?.info?.persian || colorValue}</span>
<X <X

View File

@ -41,7 +41,6 @@ export const ProductVariantDrawer = ({
Record<string, number> Record<string, number>
>({}); >({});
const [colorUnselectedMode, setColorUnselectedMode] = useState(false); const [colorUnselectedMode, setColorUnselectedMode] = useState(false);
const [sizeUnselectedMode, setSizeUnselectedMode] = useState(false);
const initializationDoneRef = useRef<boolean>(false); const initializationDoneRef = useRef<boolean>(false);
const prevIsOpenRef = 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) // Only initialize when drawer opens for the first time (not on re-renders)
if (isOpen && !prevIsOpenRef.current) { if (isOpen && !prevIsOpenRef.current) {
if (initialVariants.length > 0) { if (initialVariants.length > 0) {
// Check if any variant has unselected values // Color still supports the "no color" escape hatch.
const hasUnselectedColor = initialVariants.some( const hasUnselectedColor = initialVariants.some(
(v) => v.color === "unselected" (v) => v.color === "unselected"
); );
const hasUnselectedSize = initialVariants.some(
(v) => v.size === "unselected"
);
// Set unselected modes
setColorUnselectedMode(hasUnselectedColor); 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 = [ const colors = [
...new Set( ...new Set(
initialVariants initialVariants
@ -101,7 +96,6 @@ export const ProductVariantDrawer = ({
setBulkQuantity("1"); setBulkQuantity("1");
setCustomQuantities({}); setCustomQuantities({});
setColorUnselectedMode(false); setColorUnselectedMode(false);
setSizeUnselectedMode(false);
} }
initializationDoneRef.current = true; initializationDoneRef.current = true;
} }
@ -114,7 +108,6 @@ export const ProductVariantDrawer = ({
setBulkQuantity("1"); setBulkQuantity("1");
setCustomQuantities({}); setCustomQuantities({});
setColorUnselectedMode(false); setColorUnselectedMode(false);
setSizeUnselectedMode(false);
initializationDoneRef.current = false; initializationDoneRef.current = false;
} }
@ -136,19 +129,14 @@ export const ProductVariantDrawer = ({
if ( if (
JSON.stringify(currentVariantKeys) !== JSON.stringify(newVariantKeys) 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( const hasUnselectedColor = initialVariants.some(
(v) => v.color === "unselected" (v) => v.color === "unselected"
); );
const hasUnselectedSize = initialVariants.some(
(v) => v.size === "unselected"
);
// Set unselected modes
setColorUnselectedMode(hasUnselectedColor); 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 = [ const colors = [
...new Set( ...new Set(
initialVariants initialVariants
@ -247,19 +235,12 @@ export const ProductVariantDrawer = ({
setCustomQuantities((prev) => ({ ...prev, ...newQuantities })); 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 getQuantityVariants = () => {
const variants: { color: string; size: string; key: string }[] = []; const variants: { color: string; size: string; key: string }[] = [];
if (colorUnselectedMode && sizeUnselectedMode) { if (colorUnselectedMode) {
// 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
selectedSizes.forEach((size) => { selectedSizes.forEach((size) => {
variants.push({ variants.push({
color: "unselected", color: "unselected",
@ -267,17 +248,7 @@ export const ProductVariantDrawer = ({
key: `unselected-${size}`, 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 { } else {
// Normal mode - show all combinations
selectedColors.forEach((color) => { selectedColors.forEach((color) => {
selectedSizes.forEach((size) => { selectedSizes.forEach((size) => {
variants.push({ variants.push({
@ -293,37 +264,22 @@ export const ProductVariantDrawer = ({
}; };
const handleNext = () => { const handleNext = () => {
// Check if at least one variant can be created // Size is always required; use "فری سایز" for one-size products.
if (colorUnselectedMode && sizeUnselectedMode) {
// Both unselected - always valid
} else if (colorUnselectedMode && !sizeUnselectedMode) {
// Color unselected, need at least one size
if (selectedSizes.length === 0) { 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({ toast({
title: "خطا", title: "خطا",
description: description:
"لطفاً حداقل یک رنگ و یک سایز انتخاب کنید یا حالت بدون رنگ/سایز را فعال کنید", "لطفاً حداقل یک سایز انتخاب کنید. اگر محصول تک‌سایز است، «فری سایز» را انتخاب کنید.",
}); });
return; return;
} }
// Color is optional (colorUnselectedMode covers no-color products).
if (!colorUnselectedMode && selectedColors.length === 0) {
toast({
title: "خطا",
description: "لطفاً حداقل یک رنگ انتخاب کنید یا حالت بدون رنگ را فعال کنید",
});
return;
} }
setStep("quantities"); setStep("quantities");
}; };
@ -364,8 +320,6 @@ export const ProductVariantDrawer = ({
selectedSizes={selectedSizes} selectedSizes={selectedSizes}
onToggleSize={toggleSize} onToggleSize={toggleSize}
onRemoveSize={removeSize} onRemoveSize={removeSize}
unselectedMode={sizeUnselectedMode}
onToggleUnselectedMode={setSizeUnselectedMode}
/> />
</div> </div>
) : ( ) : (
@ -424,17 +378,14 @@ export const ProductVariantDrawer = ({
(c) => c.value === color (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 = () => { const getDisplayText = () => {
if (colorUnselectedMode && sizeUnselectedMode) { if (colorUnselectedMode) {
return "کل موجودی محصول";
} else if (colorUnselectedMode && !sizeUnselectedMode) {
return `موجودی سایز ${size}`; 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(); const displayText = getDisplayText();

View File

@ -5,16 +5,12 @@ interface SizeSelectorProps {
selectedSizes: string[]; selectedSizes: string[];
onToggleSize: (size: string) => void; onToggleSize: (size: string) => void;
onRemoveSize: (size: string) => void; onRemoveSize: (size: string) => void;
unselectedMode: boolean;
onToggleUnselectedMode: (enabled: boolean) => void;
} }
export const SizeSelector: React.FC<SizeSelectorProps> = ({ export const SizeSelector: React.FC<SizeSelectorProps> = ({
selectedSizes, selectedSizes,
onToggleSize, onToggleSize,
onRemoveSize, onRemoveSize,
unselectedMode,
onToggleUnselectedMode,
}: SizeSelectorProps) => { }: SizeSelectorProps) => {
const { data: sizes, isLoading } = useSizeAttributes(); const { data: sizes, isLoading } = useSizeAttributes();
if (isLoading) { if (isLoading) {
@ -36,35 +32,17 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-medium text-right">انتخاب سایز های موجود:</h4> <h4 className="font-medium text-right">انتخاب سایز های موجود:</h4>
<p className="text-xs text-GRAY text-right">
اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.
</p>
{/* Unselected Mode Switch */} <div className="grid grid-cols-4 gap-2">
<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" : ""}`}
>
{sizes.map((size) => ( {sizes.map((size) => (
<> <>
{size.value !== "UNSELECTED" && ( {size.value !== "UNSELECTED" && (
<button <button
key={size.id} key={size.id}
onClick={() => !unselectedMode && onToggleSize(size.value)} onClick={() => onToggleSize(size.value)}
disabled={unselectedMode}
className={`py-3 px-4 rounded-lg border-2 text-sm font-medium transition-all ${ className={`py-3 px-4 rounded-lg border-2 text-sm font-medium transition-all ${
selectedSizes.includes(size.value) selectedSizes.includes(size.value)
? "border-blue-500 bg-blue-50 text-blue-700" ? "border-blue-500 bg-blue-50 text-blue-700"
@ -79,7 +57,7 @@ export const SizeSelector: React.FC<SizeSelectorProps> = ({
</div> </div>
{/* Selected Sizes */} {/* Selected Sizes */}
{selectedSizes.length > 0 && !unselectedMode && ( {selectedSizes.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedSizes.map((sizeValue) => { {selectedSizes.map((sizeValue) => {
const sizeInfo = sizes.find((s) => s.value === sizeValue); const sizeInfo = sizes.find((s) => s.value === sizeValue);

View File

@ -20,6 +20,7 @@ import {
useUpdateProduct, useUpdateProduct,
} from "~/requestHandler/use-product-hooks"; } from "~/requestHandler/use-product-hooks";
import { useImageUpload } from "~/requestHandler/use-media-hooks"; import { useImageUpload } from "~/requestHandler/use-media-hooks";
import { toast } from "~/hooks/use-toast";
import AppSelectBox from "~/components/AppSelectBox"; import AppSelectBox from "~/components/AppSelectBox";
import { FormField } from "~/components/ui/FormField"; import { FormField } from "~/components/ui/FormField";
import { import {
@ -133,7 +134,20 @@ export default function AddProduct() {
const { data: categoriesData, isLoading: categoriesLoading } = const { data: categoriesData, isLoading: categoriesLoading } =
useProductCategories(); 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) => ({ const categoryOptions = (categoriesData || []).map((cat) => ({
value: cat.id || "", value: cat.id || "",
label: cat.name, label: cat.name,
@ -152,6 +166,12 @@ export default function AddProduct() {
setProductTitle(existingProduct.title || ""); setProductTitle(existingProduct.title || "");
setProductDescription(existingProduct.description || ""); setProductDescription(existingProduct.description || "");
setSelectedCategory(existingProduct.category || ""); 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); setSizeChart(existingProduct.sizeChart || null);
setProductPrice( setProductPrice(
existingProduct.basePrice existingProduct.basePrice
@ -449,13 +469,19 @@ export default function AddProduct() {
}; };
// Validation functions // Validation functions
const allVariantsHaveRealSize = () =>
productVariants.length > 0 &&
productVariants.every(
(v) => v.size && v.size !== "unselected" && v.size.trim() !== ""
);
const isStep1Valid = () => { const isStep1Valid = () => {
return ( return (
selectedImages.length > 0 && selectedImages.length > 0 &&
productTitle.trim() !== "" && productTitle.trim() !== "" &&
productDescription.trim() !== "" && productDescription.trim() !== "" &&
selectedCategory !== "" && selectedCategory !== "" &&
productVariants.length > 0 && allVariantsHaveRealSize() &&
isSizeChartComplete() isSizeChartComplete()
); );
}; };
@ -469,56 +495,100 @@ export default function AddProduct() {
return selectedImages.filter((img) => img.isEdited || !img.originalUrl); 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 getFinalImageUrls = async (): Promise<string[]> => {
const imagesToUpload = getImagesToUpload(); const totalImages = selectedImages.length;
const totalImages = imagesToUpload.length;
setImageUploadProgress({ setImageUploadProgress({
current: 0, current: 0,
total: totalImages, total: totalImages,
isUploading: true, isUploading: true,
}); });
const finalUrls: string[] = []; let completed = 0;
const bumpProgress = () => {
// Process each image completed += 1;
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
setImageUploadProgress({ setImageUploadProgress({
current: finalUrls.length + 1, current: completed,
total: totalImages, total: totalImages,
isUploading: true, 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({ const result = await imageUploadMutation.mutateAsync({
file: image.file, file: image.file,
usageType: MediaUploadImageUsageEnum.Product, usageType: MediaUploadImageUsageEnum.Product,
}); });
const uploadedUrl = result.originalUrl;
const uploadedUrl = result.originalUrl || ""; if (isValidUrl(uploadedUrl)) {
finalUrls.push(uploadedUrl); // Persist success so a subsequent retry-submit doesn't re-upload.
// Store the uploaded URL for future use
setSelectedImages((prev) => setSelectedImages((prev) =>
prev.map((img, index) => prev.map((img, i) => (i === index ? { ...img, uploadedUrl } : img))
index === i ? { ...img, uploadedUrl } : img
)
); );
} else { bumpProgress();
// Use original URL for unedited images return uploadedUrl;
finalUrls.push(image.originalUrl); }
} 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 }); 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 // Helper function to prepare product attributes
@ -584,11 +654,25 @@ export default function AddProduct() {
attributes.SIZE = variant.size; 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 { return {
variantName: variantName, variantName: variantName,
stock: variant.quantity, stock: variant.quantity,
price: productPrice || "0", price: productPrice || "0",
discountPrice: finalPrice || productPrice || "0", discountPrice: hasRealDiscount ? finalPrice : null,
attributes: attributes, attributes: attributes,
}; };
}); });
@ -602,18 +686,33 @@ export default function AddProduct() {
// Get final image URLs (only upload edited/new images) // Get final image URLs (only upload edited/new images)
const imageUrls = await getFinalImageUrls(); 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 = const sizeChartPayload =
sizeChart && sizeChart.columns.length > 0 && sizeChart.rows.length > 0 sizeChart && sizeChart.columns.length > 0 && sizeChart.rows.length > 0
? sizeChart ? sizeChart
: null; : null;
const validImageUrls = imageUrls;
if (isEditMode && editProductId) { if (isEditMode && editProductId) {
// Update existing product // Update existing product
const updateData = { const updateData = {
title: productTitle, title: productTitle,
description: productDescription, description: productDescription,
basePrice: productPrice || "", basePrice: productPrice || "",
imageUrls: imageUrls.filter(Boolean), // Remove empty URLs imageUrls: validImageUrls,
category: selectedCategory, category: selectedCategory,
attributes: prepareProductAttributes(productVariants), attributes: prepareProductAttributes(productVariants),
variants: prepareProductVariants(productVariants), variants: prepareProductVariants(productVariants),
@ -630,7 +729,7 @@ export default function AddProduct() {
title: productTitle, title: productTitle,
description: productDescription, description: productDescription,
basePrice: productPrice || "", basePrice: productPrice || "",
imageUrls: imageUrls.filter(Boolean), // Remove empty URLs imageUrls: validImageUrls,
category: selectedCategory, category: selectedCategory,
attributes: prepareProductAttributes(productVariants), attributes: prepareProductAttributes(productVariants),
variants: prepareProductVariants(productVariants), variants: prepareProductVariants(productVariants),
@ -827,14 +926,34 @@ export default function AddProduct() {
} }
/> />
{/* Category Selection */} {/* Category Selection — two-step: parent then sub */}
<div className="px-4 mt-4"> <div className="px-4 mt-4 space-y-3">
<FormField label="دسته‌بندی" required> <FormField label="دسته‌بندی اصلی" required>
<AppSelectBox <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} value={selectedCategory}
setValue={setSelectedCategory} setValue={setSelectedCategory}
placeholder="انتخاب دسته‌بندی" placeholder={
selectedParent
? "یک زیر‌دسته انتخاب کنید"
: "ابتدا دسته اصلی را انتخاب کنید"
}
loading={categoriesLoading} loading={categoriesLoading}
haveSearchbar={true} haveSearchbar={true}
width="w-full" width="w-full"