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 { 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
|
||||||
>
|
key={color.id ?? color.value}
|
||||||
<button
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => !unselectedMode && onToggleColor(color.value)}
|
||||||
!unselectedMode && onToggleColor(color.value)
|
disabled={unselectedMode}
|
||||||
}
|
title={color.info?.persian || color.value}
|
||||||
disabled={unselectedMode}
|
aria-label={color.info?.persian || color.value}
|
||||||
className={`relative w-12 h-12 rounded-lg border-2 transition-all ${
|
aria-pressed={isSelected}
|
||||||
selectedColors.includes(color.value)
|
className={`relative aspect-square rounded-md border-2 transition-all ${
|
||||||
? "border-blue-500"
|
isSelected
|
||||||
: "border-WHITE2 hover:border-GRAY3"
|
? "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">
|
<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" ||
|
</div>
|
||||||
color.info.detail === "#FAFAFA" ||
|
)}
|
||||||
color.info.detail === "#F0F0F0"
|
</button>
|
||||||
? "text-BLACK"
|
);
|
||||||
: "text-WHITE"
|
})}
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
</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
|
||||||
|
|||||||
@ -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) {
|
if (selectedSizes.length === 0) {
|
||||||
// Both unselected - always valid
|
toast({
|
||||||
} else if (colorUnselectedMode && !sizeUnselectedMode) {
|
title: "خطا",
|
||||||
// Color unselected, need at least one size
|
description:
|
||||||
if (selectedSizes.length === 0) {
|
"لطفاً حداقل یک سایز انتخاب کنید. اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.",
|
||||||
toast({
|
});
|
||||||
title: "خطا",
|
return;
|
||||||
description: "لطفاً حداقل یک سایز انتخاب کنید",
|
}
|
||||||
});
|
// Color is optional (colorUnselectedMode covers no-color products).
|
||||||
return;
|
if (!colorUnselectedMode && selectedColors.length === 0) {
|
||||||
}
|
toast({
|
||||||
} else if (!colorUnselectedMode && sizeUnselectedMode) {
|
title: "خطا",
|
||||||
// Size unselected, need at least one color
|
description: "لطفاً حداقل یک رنگ انتخاب کنید یا حالت بدون رنگ را فعال کنید",
|
||||||
if (selectedColors.length === 0) {
|
});
|
||||||
toast({
|
return;
|
||||||
title: "خطا",
|
|
||||||
description: "لطفاً حداقل یک رنگ انتخاب کنید",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal mode - need both color and size
|
|
||||||
if (selectedColors.length === 0 || selectedSizes.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();
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
completed += 1;
|
||||||
|
setImageUploadProgress({
|
||||||
|
current: completed,
|
||||||
|
total: totalImages,
|
||||||
|
isUploading: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Process each image
|
const CONCURRENCY = 3;
|
||||||
for (let i = 0; i < selectedImages.length; i++) {
|
const isValidUrl = (u: unknown): u is string =>
|
||||||
const image = selectedImages[i];
|
typeof u === "string" && /^https?:\/\//i.test(u);
|
||||||
|
|
||||||
if (image.uploadedUrl) {
|
const uploadOne = async (index: number): Promise<string | null> => {
|
||||||
// If image was already uploaded before, use that URL
|
const image = selectedImages[index];
|
||||||
finalUrls.push(image.uploadedUrl);
|
|
||||||
} else if (image.isEdited || !image.originalUrl) {
|
|
||||||
// Upload edited or new images
|
|
||||||
setImageUploadProgress({
|
|
||||||
current: finalUrls.length + 1,
|
|
||||||
total: totalImages,
|
|
||||||
isUploading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await imageUploadMutation.mutateAsync({
|
// Already-uploaded or unedited-existing paths bypass the network.
|
||||||
file: image.file,
|
if (image.uploadedUrl && isValidUrl(image.uploadedUrl)) {
|
||||||
usageType: MediaUploadImageUsageEnum.Product,
|
bumpProgress();
|
||||||
});
|
return image.uploadedUrl;
|
||||||
|
|
||||||
const uploadedUrl = result.originalUrl || "";
|
|
||||||
finalUrls.push(uploadedUrl);
|
|
||||||
|
|
||||||
// Store the uploaded URL for future use
|
|
||||||
setSelectedImages((prev) =>
|
|
||||||
prev.map((img, index) =>
|
|
||||||
index === i ? { ...img, uploadedUrl } : img
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Use original URL for unedited images
|
|
||||||
finalUrls.push(image.originalUrl);
|
|
||||||
}
|
}
|
||||||
}
|
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;
|
||||||
|
if (isValidUrl(uploadedUrl)) {
|
||||||
|
// Persist success so a subsequent retry-submit doesn't re-upload.
|
||||||
|
setSelectedImages((prev) =>
|
||||||
|
prev.map((img, i) => (i === index ? { ...img, uploadedUrl } : img))
|
||||||
|
);
|
||||||
|
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 });
|
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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user