- {[...Array(20)].map((_, i) => (
+
+ {[...Array(81)].map((_, i) => (
))}
@@ -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 (
انتخاب رنگ های موجود:
@@ -54,59 +97,41 @@ export const ColorSelector = ({
- {/* Color Selection Grid - Disabled when unselected mode is on */}
+ {/* 9×9 palette: columns are hue families, rows go light -> dark. */}
-
- {colors.map((color) => (
- <>
- {color.value !== "UNSELECTED" && (
-
-
-
- {color.info?.persian || color.value}
-
-
- )}
- >
- ))}
+
+ {sortedColors.map((color) => {
+ const isSelected = selectedColors.includes(color.value);
+ const hex = color.info?.detail || "#000000";
+ const dark = isDarkHex(hex);
+ return (
+
+ );
+ })}
@@ -114,15 +139,16 @@ export const ColorSelector = ({
{selectedColors.length > 0 && !unselectedMode && (
{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 (
{colorInfo?.info?.persian || colorValue}
>({});
const [colorUnselectedMode, setColorUnselectedMode] = useState(false);
- const [sizeUnselectedMode, setSizeUnselectedMode] = useState(false);
const initializationDoneRef = useRef(false);
const prevIsOpenRef = useRef(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
- 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;
- }
+ // 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");
};
@@ -364,8 +320,6 @@ export const ProductVariantDrawer = ({
selectedSizes={selectedSizes}
onToggleSize={toggleSize}
onRemoveSize={removeSize}
- unselectedMode={sizeUnselectedMode}
- onToggleUnselectedMode={setSizeUnselectedMode}
/>
) : (
@@ -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();
diff --git a/app/components/store/SizeSelector.tsx b/app/components/store/SizeSelector.tsx
index ea026e9..dd98545 100644
--- a/app/components/store/SizeSelector.tsx
+++ b/app/components/store/SizeSelector.tsx
@@ -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
= ({
selectedSizes,
onToggleSize,
onRemoveSize,
- unselectedMode,
- onToggleUnselectedMode,
}: SizeSelectorProps) => {
const { data: sizes, isLoading } = useSizeAttributes();
if (isLoading) {
@@ -36,35 +32,17 @@ export const SizeSelector: React.FC = ({
return (
انتخاب سایز های موجود:
+
+ اگر محصول تکسایز است، «فری سایز» را انتخاب کنید.
+
- {/* Unselected Mode Switch */}
-
-
- بدون مشخص کردن سایز
-
-
-
-
- {/* Size Selection Grid - Disabled when unselected mode is on */}
-
+
{sizes.map((size) => (
<>
{size.value !== "UNSELECTED" && (
{/* Selected Sizes */}
- {selectedSizes.length > 0 && !unselectedMode && (
+ {selectedSizes.length > 0 && (
{selectedSizes.map((sizeValue) => {
const sizeInfo = sizes.find((s) => s.value === sizeValue);
diff --git a/app/routes/store.add.manual.$storeId.tsx b/app/routes/store.add.manual.$storeId.tsx
index f3a3e2c..85a9f93 100644
--- a/app/routes/store.add.manual.$storeId.tsx
+++ b/app/routes/store.add.manual.$storeId.tsx
@@ -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
=> {
- const imagesToUpload = getImagesToUpload();
- const totalImages = imagesToUpload.length;
-
+ const totalImages = selectedImages.length;
setImageUploadProgress({
current: 0,
total: totalImages,
isUploading: true,
});
- const finalUrls: string[] = [];
+ let completed = 0;
+ const bumpProgress = () => {
+ completed += 1;
+ setImageUploadProgress({
+ current: completed,
+ total: totalImages,
+ isUploading: true,
+ });
+ };
- // Process each image
- for (let i = 0; i < selectedImages.length; i++) {
- const image = selectedImages[i];
+ const CONCURRENCY = 3;
+ const isValidUrl = (u: unknown): u is string =>
+ typeof u === "string" && /^https?:\/\//i.test(u);
- 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({
- current: finalUrls.length + 1,
- total: totalImages,
- isUploading: true,
- });
+ const uploadOne = async (index: number): Promise => {
+ const image = selectedImages[index];
- 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
- setSelectedImages((prev) =>
- prev.map((img, index) =>
- index === i ? { ...img, uploadedUrl } : img
- )
- );
- } else {
- // Use original URL for unedited images
- finalUrls.push(image.originalUrl);
+ // 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;
+ 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 });
- 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 */}
-
-
+ {/* Category Selection — two-step: parent then sub */}
+
+
{
+ 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"
+ />
+
+
+