Vitron-Front/app/components/store/ProductVariantDrawer.tsx
fazli 83b93fe523 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>
2026-07-18 13:19:13 +00:00

487 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from "react";
import { Button } from "../ui/button";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerFooter,
} from "../ui/drawer";
import { toast } from "../../hooks/use-toast";
import { ColorSelector } from "./ColorSelector";
import { SizeSelector } from "./SizeSelector";
import { useColorAttributes } from "~/requestHandler/use-product-hooks";
import AppInput from "../AppInput";
interface ProductVariant {
color: string;
size: string;
quantity: number;
}
interface ProductVariantDrawerProps {
isOpen: boolean;
onClose: () => void;
onSave: (variants: ProductVariant[]) => void;
initialVariants?: ProductVariant[];
}
export const ProductVariantDrawer = ({
isOpen,
onClose,
onSave,
initialVariants = [],
}: ProductVariantDrawerProps) => {
const { data: colorAttributes } = useColorAttributes();
const [step, setStep] = useState<"attributes" | "quantities">("attributes");
const [selectedColors, setSelectedColors] = useState<string[]>([]);
const [selectedSizes, setSelectedSizes] = useState<string[]>([]);
const [bulkQuantity, setBulkQuantity] = useState("1");
const [customQuantities, setCustomQuantities] = useState<
Record<string, number>
>({});
const [colorUnselectedMode, setColorUnselectedMode] = useState(false);
const initializationDoneRef = useRef<boolean>(false);
const prevIsOpenRef = useRef<boolean>(false);
// Initialize state from initial variants when drawer opens
useEffect(() => {
// Only initialize when drawer opens for the first time (not on re-renders)
if (isOpen && !prevIsOpenRef.current) {
if (initialVariants.length > 0) {
// Color still supports the "no color" escape hatch.
const hasUnselectedColor = initialVariants.some(
(v) => v.color === "unselected"
);
setColorUnselectedMode(hasUnselectedColor);
// 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
.map((v) => v.color)
.filter((c) => c !== "unselected")
),
];
const sizes = [
...new Set(
initialVariants.map((v) => v.size).filter((s) => s !== "unselected")
),
];
setSelectedColors(colors);
setSelectedSizes(sizes);
// Set individual quantities
const quantities: Record<string, number> = {};
initialVariants.forEach((variant) => {
const key = `${variant.color}-${variant.size}`;
quantities[key] = variant.quantity;
});
setCustomQuantities(quantities);
// Set bulk quantity to the most common quantity or first quantity
const quantityValues = initialVariants.map((v) => v.quantity);
const mostCommonQuantity =
quantityValues.length > 0 ? quantityValues[0] : 1;
setBulkQuantity(mostCommonQuantity.toString());
} else {
// Reset state when opening without initial variants
setStep("attributes");
setSelectedColors([]);
setSelectedSizes([]);
setBulkQuantity("1");
setCustomQuantities({});
setColorUnselectedMode(false);
}
initializationDoneRef.current = true;
}
// Reset when drawer closes
if (!isOpen && prevIsOpenRef.current) {
setStep("attributes");
setSelectedColors([]);
setSelectedSizes([]);
setBulkQuantity("1");
setCustomQuantities({});
setColorUnselectedMode(false);
initializationDoneRef.current = false;
}
prevIsOpenRef.current = isOpen;
}, [isOpen]);
// Separate effect for initialVariants changes (when drawer is already open)
useEffect(() => {
if (isOpen && initializationDoneRef.current && initialVariants.length > 0) {
// Only update if variants actually changed
const currentVariantKeys = selectedColors
.flatMap((color) => selectedSizes.map((size) => `${color}-${size}`))
.sort();
const newVariantKeys = initialVariants
.map((v) => `${v.color}-${v.size}`)
.sort();
if (
JSON.stringify(currentVariantKeys) !== JSON.stringify(newVariantKeys)
) {
// Color still supports the "no color" escape hatch.
const hasUnselectedColor = initialVariants.some(
(v) => v.color === "unselected"
);
setColorUnselectedMode(hasUnselectedColor);
// 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
.map((v) => v.color)
.filter((c) => c !== "unselected")
),
];
const sizes = [
...new Set(
initialVariants.map((v) => v.size).filter((s) => s !== "unselected")
),
];
setSelectedColors(colors);
setSelectedSizes(sizes);
// Set individual quantities
const quantities: Record<string, number> = {};
initialVariants.forEach((variant) => {
const key = `${variant.color}-${variant.size}`;
quantities[key] = variant.quantity;
});
setCustomQuantities(quantities);
}
}
}, [initialVariants, isOpen]);
// Generate all possible variants
const generateVariants = (): ProductVariant[] => {
const variants: ProductVariant[] = [];
getQuantityVariants().forEach((variant) => {
const { color, size, key } = variant;
let quantity: number;
if (key in customQuantities) {
// Use custom quantity if exists
quantity = customQuantities[key];
} else {
// Use bulk quantity for variants without custom quantity
quantity = parseInt(bulkQuantity) || 0;
}
variants.push({ color, size, quantity });
});
return variants;
};
const toggleColor = (colorValue: string) => {
setSelectedColors((prev) =>
prev.includes(colorValue)
? prev.filter((c) => c !== colorValue)
: [...prev, colorValue]
);
};
const removeColor = (colorValue: string) => {
setSelectedColors((prev) => prev.filter((c) => c !== colorValue));
};
const toggleSize = (size: string) => {
setSelectedSizes((prev) =>
prev.includes(size) ? prev.filter((s) => s !== size) : [...prev, size]
);
};
const removeSize = (size: string) => {
setSelectedSizes((prev) => prev.filter((s) => s !== size));
};
const handleQuantityChange = (
color: string,
size: string,
quantity: string
) => {
const key = `${color}-${size}`;
// Allow empty string or parse to number (including 0)
const quantityNumber = quantity === "" ? 0 : parseInt(quantity);
setCustomQuantities((prev) => ({
...prev,
[key]: isNaN(quantityNumber) ? 0 : quantityNumber,
}));
};
const applyBulkQuantityToAll = () => {
const bulkQuantityNumber = parseInt(bulkQuantity) || 0;
const newQuantities: Record<string, number> = {};
getQuantityVariants().forEach((variant) => {
const { key } = variant;
newQuantities[key] = bulkQuantityNumber;
});
setCustomQuantities((prev) => ({ ...prev, ...newQuantities }));
};
// 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) {
selectedSizes.forEach((size) => {
variants.push({
color: "unselected",
size: size,
key: `unselected-${size}`,
});
});
} else {
selectedColors.forEach((color) => {
selectedSizes.forEach((size) => {
variants.push({
color: color,
size: size,
key: `${color}-${size}`,
});
});
});
}
return variants;
};
const handleNext = () => {
// Size is always required; use "فری سایز" for one-size products.
if (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");
};
const handleSave = () => {
const variants = generateVariants();
onSave(variants);
onClose();
};
const handleClose = () => {
onClose();
};
return (
<Drawer open={isOpen} onOpenChange={onClose}>
<DrawerContent className="h-[90vh]">
<DrawerHeader>
<DrawerTitle className="text-center">
{step === "attributes" ? "انتخاب رنگ و سایز" : "تعیین موجودی"}
</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4">
{step === "attributes" ? (
<div className="space-y-6">
{/* Color Selection */}
<ColorSelector
selectedColors={selectedColors}
onToggleColor={toggleColor}
onRemoveColor={removeColor}
unselectedMode={colorUnselectedMode}
onToggleUnselectedMode={setColorUnselectedMode}
/>
{/* Size Selection */}
<SizeSelector
selectedSizes={selectedSizes}
onToggleSize={toggleSize}
onRemoveSize={removeSize}
/>
</div>
) : (
<div className="space-y-6">
{/* Bulk Quantity Setting */}
<div className="relative overflow-hidden rounded-xl border border-primary/20 bg-gradient-to-br from-primary/5 via-primary/10 to-transparent p-6 shadow-sm">
<div className="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-primary/10 blur-2xl" />
<div className="relative space-y-4">
<div className="flex items-center gap-2 text-right">
<div className="flex-1">
<h4 className="font-semibold text-base text-foreground">تعیین موجودی عمومی</h4>
<p className="text-xs text-muted-foreground mt-1">این مقدار به تمام موارد اعمال میشود</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<svg className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
</div>
<div className="flex items-center gap-3">
<Button
size="sm"
onClick={applyBulkQuantityToAll}
className="flex-1 gap-2 shadow-md hover:shadow-lg transition-all duration-200"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
اعمال به همه
</Button>
<div className="w-24">
<AppInput
inputTitle=""
inputValue={bulkQuantity}
inputOnChange={(e) => setBulkQuantity(e.target.value)}
inputPlaceholder="0"
type="number"
className="text-center font-semibold text-lg"
/>
</div>
</div>
</div>
</div>
{/* Individual Variant Quantities */}
<div className="space-y-4">
<h4 className="font-medium text-right">موجودی اختصاصی:</h4>
<div className="space-y-3 overflow-y-auto">
{getQuantityVariants().map((variant) => {
const { color, size, key } = variant;
const quantity =
key in customQuantities
? customQuantities[key]
: parseInt(bulkQuantity) || 0;
const colorInfo = colorAttributes?.find(
(c) => c.value === color
);
// Helper function to get display text — colorless variants
// roll up under the size label; sized/colored variants show
// both.
const getDisplayText = () => {
if (colorUnselectedMode) {
return `موجودی سایز ${size}`;
}
return null; // Normal mode — render color + size below
};
const displayText = getDisplayText();
return (
<div
key={key}
className="flex items-center justify-between p-3 bg-WHITE3 border rounded-lg"
>
<div className="flex items-center gap-3">
{displayText ? (
// Special unselected modes
<span className="text-sm font-medium">
{displayText}
</span>
) : (
// Normal mode - show color and size
<>
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded border border-GRAY3"
style={{ backgroundColor: color }}
/>
<span className="text-sm">
{colorInfo?.info.persian || color}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-GRAY">
سایز:
</span>
<span className="text-sm font-medium">
{size}
</span>
</div>
</>
)}
</div>
<div className="w-16">
<AppInput
inputTitle=""
inputValue={quantity.toString()}
inputOnChange={(e) =>
handleQuantityChange(
color,
size,
e.target.value
)
}
inputPlaceholder="0"
type="number"
className="text-center text-sm"
/>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
<DrawerFooter>
<div className="flex gap-3 w-full">
<Button
variant="outline"
size="lg"
onClick={handleClose}
className="flex-1"
>
لغو
</Button>
{step === "attributes" ? (
<Button size="lg" onClick={handleNext} className="flex-1">
مرحله بعد
</Button>
) : (
<>
<Button
variant="outline"
size="lg"
onClick={() => setStep("attributes")}
className="flex-1"
>
بازگشت
</Button>
<Button size="lg" onClick={handleSave} className="flex-1">
ذخیره
</Button>
</>
)}
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};