diff --git a/app/components/store/ColorSelector.tsx b/app/components/store/ColorSelector.tsx index 5499640..a33c1f8 100644 --- a/app/components/store/ColorSelector.tsx +++ b/app/components/store/ColorSelector.tsx @@ -1,4 +1,5 @@ import { Check, X } from "lucide-react"; +import { useMemo } from "react"; import { useColorAttributes } from "~/requestHandler/use-product-hooks"; interface ColorSelectorProps { @@ -9,6 +10,15 @@ interface ColorSelectorProps { onToggleUnselectedMode: (enabled: boolean) => void; } +// The DB now carries a curated 81-color palette laid out as a 9×9 grid. +// Each AttributeValue's `info` object has: +// persian — display name in Persian +// detail — hex swatch color +// sort_order — 0..80, row-major position in the grid +// grid_row — 0..8 +// grid_col — 0..8 (columns cluster by hue family) +// Anything without a numeric sort_order is treated as trailing so we don't +// accidentally hide legacy or admin-inserted values. export const ColorSelector = ({ selectedColors, onToggleColor, @@ -17,15 +27,34 @@ export const ColorSelector = ({ onToggleUnselectedMode, }: ColorSelectorProps) => { const { data: colors, isLoading } = useColorAttributes(); + + const sortedColors = useMemo(() => { + if (!colors) return []; + return colors + .filter((c) => c.value !== "UNSELECTED") + .slice() + .sort((a, b) => { + const ax = + typeof a.info?.sort_order === "number" + ? a.info.sort_order + : Number.MAX_SAFE_INTEGER; + const bx = + typeof b.info?.sort_order === "number" + ? b.info.sort_order + : Number.MAX_SAFE_INTEGER; + return ax - bx; + }); + }, [colors]); + if (isLoading) { return (

انتخاب رنگ های موجود:

-
- {[...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" + /> + + +