- 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>
1150 lines
39 KiB
TypeScript
1150 lines
39 KiB
TypeScript
import type { MetaFunction } from "@remix-run/node";
|
||
import { X, Loader } from "lucide-react";
|
||
import HeaderWithSupport from "../components/HeaderWithSupport";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import { safeGoBack } from "~/utils/helpers";
|
||
import { convertHeicFiles } from "~/utils/convert-heic";
|
||
import { useRef, useState, useEffect } from "react";
|
||
import { Button } from "../components/ui/button";
|
||
import { ImageUploadDrawer } from "~/components/store/ImageUploadDrawer";
|
||
import { ImageEditor } from "~/components/store/ImageEditor";
|
||
import { ImageUploadSection } from "~/components/store/ImageUploadSection";
|
||
import { ProductDetailsForm } from "~/components/store/ProductDetailsForm";
|
||
import { ProductVariantsSection } from "~/components/store/ProductVariantsSection";
|
||
import { SizeChartSection } from "~/components/store/SizeChartSection";
|
||
import { PricingForm } from "~/components/store/PricingForm";
|
||
import {
|
||
useCreateFullProduct,
|
||
useProductCategories,
|
||
useProductDetails,
|
||
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 {
|
||
useGetInstagramPostDetails,
|
||
useGenerateProductFromPost,
|
||
} from "~/requestHandler/use-instagram-hooks";
|
||
import {
|
||
FullProductCreate,
|
||
ProductVariant as ApiProductVariant,
|
||
ProductAttributeItem,
|
||
MediaUploadImageUsageEnum,
|
||
SizeChart,
|
||
} from "../../src/api/types";
|
||
import { Dialog, DialogContent, DialogOverlay } from "../components/ui/dialog";
|
||
import { useNavigate, useParams } from "@remix-run/react";
|
||
import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
||
|
||
export const meta: MetaFunction = () => {
|
||
return [
|
||
{ title: "افزودن محصول - ویترون" },
|
||
{
|
||
name: "description",
|
||
content:
|
||
"افزودن محصول جدید به فروشگاه شما در ویترون. آپلود تصاویر، تعیین قیمت، ویژگیها و تنظیمات محصول.",
|
||
},
|
||
{
|
||
name: "keywords",
|
||
content: "افزودن محصول، جدید محصول، فروشگاه، قیمت گذاری، ویترون",
|
||
},
|
||
{ name: "robots", content: "noindex, follow" },
|
||
{ property: "og:title", content: "افزودن محصول - ویترون" },
|
||
{
|
||
property: "og:description",
|
||
content:
|
||
"افزودن محصول جدید به فروشگاه شما در ویترون. آپلود تصاویر، تعیین قیمت، ویژگیها و تنظیمات محصول.",
|
||
},
|
||
{ property: "og:type", content: "website" },
|
||
{ name: "twitter:card", content: "summary" },
|
||
{ name: "twitter:title", content: "افزودن محصول - ویترون" },
|
||
{
|
||
name: "twitter:description",
|
||
content:
|
||
"افزودن محصول جدید به فروشگاه شما در ویترون. آپلود تصاویر، تعیین قیمت، ویژگیها و تنظیمات محصول.",
|
||
},
|
||
];
|
||
};
|
||
|
||
// Local interface for the component's variant structure
|
||
interface ProductVariant {
|
||
color: string;
|
||
size: string;
|
||
quantity: number;
|
||
}
|
||
|
||
export default function AddProduct() {
|
||
const [searchParams] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
const { storeId } = useParams();
|
||
|
||
// Get URL parameters
|
||
const postId = searchParams.get("id");
|
||
const isAI = searchParams.get("ai") === "true";
|
||
const editProductId = searchParams.get("edit");
|
||
|
||
// Check store ownership - this will redirect if user doesn't own the store
|
||
const { isLoading: isOwnershipLoading } = useStoreOwnership(storeId);
|
||
|
||
// Step management
|
||
const [currentStep, setCurrentStep] = useState(1);
|
||
|
||
// Step 1 - Product Info
|
||
const [productTitle, setProductTitle] = useState("");
|
||
const [productDescription, setProductDescription] = useState("");
|
||
const [selectedCategory, setSelectedCategory] = useState("");
|
||
// Step 2 - Pricing
|
||
const [productPrice, setProductPrice] = useState("");
|
||
const [finalPrice, setFinalPrice] = useState("");
|
||
|
||
// Loading states
|
||
const [isSubmittingProduct, setIsSubmittingProduct] = useState(false);
|
||
const [imageUploadProgress, setImageUploadProgress] = useState({
|
||
current: 0,
|
||
total: 0,
|
||
isUploading: false,
|
||
});
|
||
|
||
// API hooks
|
||
const createProductMutation = useCreateFullProduct();
|
||
const updateProductMutation = useUpdateProduct();
|
||
const imageUploadMutation = useImageUpload();
|
||
|
||
// Product details for editing
|
||
const { data: existingProduct } = useProductDetails(editProductId || "");
|
||
|
||
// Instagram post hooks - only fetch if we have postId and AI is false
|
||
const { data: instagramPostData, isLoading: isLoadingPostData } =
|
||
useGetInstagramPostDetails(postId || "", {
|
||
enabled: !!postId && !isAI,
|
||
});
|
||
|
||
// Generate product hook - for AI generation
|
||
const {
|
||
data: aiGeneratedData,
|
||
isLoading: isAiGenerating,
|
||
error: aiGenerationError,
|
||
} = useGenerateProductFromPost(postId || "", {
|
||
enabled: !!postId && isAI,
|
||
});
|
||
|
||
// Fetch categories
|
||
const { data: categoriesData, isLoading: categoriesLoading } =
|
||
useProductCategories();
|
||
|
||
// 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,
|
||
}));
|
||
|
||
// Track if form has been initialized and previous AI state
|
||
const [formInitialized, setFormInitialized] = useState(false);
|
||
const [previousIsAI, setPreviousIsAI] = useState<boolean | null>(null);
|
||
|
||
// Determine if we're in edit mode
|
||
const isEditMode = !!editProductId;
|
||
|
||
// Effect to fill form with existing product data for editing
|
||
useEffect(() => {
|
||
if (existingProduct && isEditMode && !formInitialized) {
|
||
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
|
||
? parseInt(existingProduct.basePrice).toString()
|
||
: ""
|
||
);
|
||
|
||
// Set final price if available from first variant
|
||
if (existingProduct.variants && existingProduct.variants.length > 0) {
|
||
const firstVariant = existingProduct.variants[0];
|
||
const discountPrice = firstVariant.discountPrice
|
||
? parseInt(firstVariant.discountPrice).toString()
|
||
: "";
|
||
const regularPrice = firstVariant.price
|
||
? parseInt(firstVariant.price).toString()
|
||
: "";
|
||
setFinalPrice(discountPrice || regularPrice);
|
||
}
|
||
|
||
// Set images from existing product
|
||
if (existingProduct.imageUrls && existingProduct.imageUrls.length > 0) {
|
||
const existingImages = existingProduct.imageUrls.map((url, index) => ({
|
||
file: new File([], `existing-image-${index + 1}.jpg`, {
|
||
type: "image/jpeg",
|
||
}),
|
||
url: url,
|
||
isEdited: false,
|
||
originalUrl: url,
|
||
uploadedUrl: undefined,
|
||
}));
|
||
setSelectedImages(existingImages);
|
||
}
|
||
|
||
// Set variants from existing product
|
||
if (existingProduct.variants && existingProduct.variants.length > 0) {
|
||
const existingVariants = existingProduct.variants
|
||
.filter((variant) => variant.stock && variant.stock > 0) // Only include variants with stock
|
||
.map((variant) => ({
|
||
color:
|
||
variant.resolvedAttributes?.COLOR ||
|
||
variant.attributes?.COLOR ||
|
||
"unselected",
|
||
size:
|
||
variant.resolvedAttributes?.SIZE ||
|
||
variant.attributes?.SIZE ||
|
||
"unselected",
|
||
quantity: variant.stock || 0,
|
||
}));
|
||
|
||
// If no variants with stock, create a default variant
|
||
if (existingVariants.length === 0) {
|
||
existingVariants.push({
|
||
color: "unselected",
|
||
size: "unselected",
|
||
quantity: 1,
|
||
});
|
||
}
|
||
|
||
setProductVariants(existingVariants);
|
||
} else {
|
||
// If no variants exist, create a default variant
|
||
setProductVariants([
|
||
{
|
||
color: "unselected",
|
||
size: "unselected",
|
||
quantity: 1,
|
||
},
|
||
]);
|
||
}
|
||
|
||
setFormInitialized(true);
|
||
}
|
||
}, [existingProduct, isEditMode, formInitialized]);
|
||
|
||
// Effect to fill form when AI generation is complete
|
||
useEffect(() => {
|
||
if (aiGeneratedData && isAI && !formInitialized && !isEditMode) {
|
||
setProductTitle(aiGeneratedData.product.title || "");
|
||
setProductDescription(aiGeneratedData.product.description || "");
|
||
setSelectedImages(
|
||
aiGeneratedData.product.imageUrls.map((url) => ({
|
||
file: new File([], url),
|
||
url: url,
|
||
isEdited: false,
|
||
originalUrl: url,
|
||
uploadedUrl: undefined,
|
||
}))
|
||
);
|
||
|
||
// Set pricing
|
||
if (aiGeneratedData.product.basePrice) {
|
||
setProductPrice(
|
||
parseInt(aiGeneratedData.product.basePrice.toString()).toString()
|
||
);
|
||
}
|
||
|
||
// Set final price from first variant if available
|
||
if (
|
||
aiGeneratedData.product.variants &&
|
||
aiGeneratedData.product.variants.length > 0
|
||
) {
|
||
const firstVariant = aiGeneratedData.product.variants[0];
|
||
const variantPrice = firstVariant.price
|
||
? parseInt(firstVariant.price.toString()).toString()
|
||
: "";
|
||
const basePrice = aiGeneratedData.product.basePrice
|
||
? parseInt(aiGeneratedData.product.basePrice.toString()).toString()
|
||
: "";
|
||
setFinalPrice(variantPrice || basePrice);
|
||
}
|
||
|
||
// Set category - we need to find the category ID from the category name
|
||
if (aiGeneratedData.product.category && categoriesData) {
|
||
// Don't set category if it's UNSELECTED or similar values
|
||
const categoryName = aiGeneratedData.product.category;
|
||
if (
|
||
categoryName.toLowerCase() !== "unselected" &&
|
||
categoryName.toLowerCase() !== "none"
|
||
) {
|
||
const categoryMatch = categoriesData.find(
|
||
(cat) => cat.name === categoryName
|
||
);
|
||
if (categoryMatch && categoryMatch.id) {
|
||
setSelectedCategory(categoryMatch.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Set variants - convert from API format to component format
|
||
if (
|
||
aiGeneratedData.product.variants &&
|
||
aiGeneratedData.product.variants.length > 0
|
||
) {
|
||
const convertedVariants = aiGeneratedData.product.variants.map(
|
||
(variant) => ({
|
||
color:
|
||
variant.attributes?.COLOR?.toLowerCase() === "unselected"
|
||
? "unselected"
|
||
: variant.attributes?.COLOR || "unselected",
|
||
size:
|
||
variant.attributes?.SIZE?.toLowerCase() === "unselected"
|
||
? "unselected"
|
||
: variant.attributes?.SIZE || "unselected",
|
||
quantity: variant.stock || 0,
|
||
})
|
||
);
|
||
setProductVariants(convertedVariants);
|
||
} else {
|
||
// Default variant if none provided
|
||
setProductVariants([
|
||
{
|
||
color: "unselected",
|
||
size: "unselected",
|
||
quantity: 1,
|
||
},
|
||
]);
|
||
}
|
||
|
||
setFormInitialized(true);
|
||
}
|
||
}, [aiGeneratedData, isAI, formInitialized, isEditMode, categoriesData]);
|
||
|
||
// Effect to set category when categoriesData loads after AI generation
|
||
useEffect(() => {
|
||
if (
|
||
aiGeneratedData &&
|
||
isAI &&
|
||
formInitialized &&
|
||
!isEditMode &&
|
||
categoriesData &&
|
||
!selectedCategory &&
|
||
aiGeneratedData.product.category
|
||
) {
|
||
const categoryName = aiGeneratedData.product.category;
|
||
// Don't set category if it's UNSELECTED or similar values
|
||
if (
|
||
categoryName.toLowerCase() !== "unselected" &&
|
||
categoryName.toLowerCase() !== "none"
|
||
) {
|
||
const categoryMatch = categoriesData.find(
|
||
(cat) => cat.name === categoryName
|
||
);
|
||
if (categoryMatch && categoryMatch.id) {
|
||
setSelectedCategory(categoryMatch.id);
|
||
}
|
||
}
|
||
}
|
||
}, [
|
||
categoriesData,
|
||
aiGeneratedData,
|
||
isAI,
|
||
formInitialized,
|
||
isEditMode,
|
||
selectedCategory,
|
||
]);
|
||
|
||
// Effect to handle initial data loading from Instagram post (only for manual mode)
|
||
useEffect(() => {
|
||
if (instagramPostData && !isAI && !formInitialized && !isEditMode) {
|
||
// If AI is false and we have post data, populate initial values
|
||
setProductTitle(instagramPostData.caption?.split("\n")[0] || "");
|
||
setProductDescription(instagramPostData.caption || "");
|
||
|
||
// Convert Instagram URLs to File-like objects
|
||
if (
|
||
instagramPostData.localMedia &&
|
||
instagramPostData.localMedia.length > 0
|
||
) {
|
||
const imageFiles = instagramPostData.localMedia.map(
|
||
(url: string, index: number) => {
|
||
// Create a dummy File object for Instagram URLs
|
||
const dummyFile = new File([], `instagram-image-${index + 1}.jpg`, {
|
||
type: "image/jpeg",
|
||
});
|
||
|
||
return {
|
||
file: dummyFile,
|
||
url: url,
|
||
isEdited: false,
|
||
originalUrl: url,
|
||
uploadedUrl: undefined,
|
||
};
|
||
}
|
||
);
|
||
|
||
setSelectedImages(imageFiles);
|
||
}
|
||
setFormInitialized(true);
|
||
}
|
||
}, [instagramPostData, isAI, formInitialized, isEditMode]);
|
||
|
||
// Reset form only when actually switching between AI and manual modes
|
||
useEffect(() => {
|
||
// Only reset if we're switching from one mode to another (not on initial load)
|
||
if (previousIsAI !== null && previousIsAI !== isAI && postId) {
|
||
setFormInitialized(false);
|
||
setProductTitle("");
|
||
setProductDescription("");
|
||
setSelectedImages([]);
|
||
setSelectedCategory("");
|
||
setProductVariants([]);
|
||
setProductPrice("");
|
||
setFinalPrice("");
|
||
}
|
||
setPreviousIsAI(isAI);
|
||
}, [isAI, postId, previousIsAI]);
|
||
|
||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||
const [isImageEditorOpen, setIsImageEditorOpen] = useState(false);
|
||
const [currentEditingIndex, setCurrentEditingIndex] = useState<number | null>(
|
||
null
|
||
);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [selectedImages, setSelectedImages] = useState<
|
||
Array<{
|
||
file: File;
|
||
url: string;
|
||
isEdited: boolean;
|
||
originalUrl?: string;
|
||
uploadedUrl?: string;
|
||
}>
|
||
>([]);
|
||
const [productVariants, setProductVariants] = useState<ProductVariant[]>([]);
|
||
const [sizeChart, setSizeChart] = useState<SizeChart | null>(null);
|
||
|
||
// Distinct sizes this product offers (drives the size-chart rows & requirement).
|
||
const productSizes = [
|
||
...new Set(
|
||
productVariants
|
||
.map((v) => v.size)
|
||
.filter((s) => s && s !== "unselected" && s.trim() !== "")
|
||
),
|
||
];
|
||
const hasSizes = productSizes.length > 0;
|
||
const selectedCategoryName =
|
||
categoryOptions.find((o) => o.value === selectedCategory)?.label || "";
|
||
|
||
// Complete = has columns and every offered size has a row with ≥1 filled value.
|
||
const isSizeChartComplete = () => {
|
||
if (!hasSizes) return true; // only required when the product has sizes
|
||
if (!sizeChart || sizeChart.columns.length === 0) return false;
|
||
return productSizes.every((s) => {
|
||
const row = sizeChart.rows.find((r) => r.label.trim() === s.trim());
|
||
return !!row && row.values.some((v) => v && v.trim() !== "");
|
||
});
|
||
};
|
||
|
||
// Function to update variant quantity
|
||
const updateVariantQuantity = (index: number, newQuantity: number) => {
|
||
setProductVariants((prev) =>
|
||
prev.map((variant, i) =>
|
||
i === index ? { ...variant, quantity: newQuantity } : variant
|
||
)
|
||
);
|
||
};
|
||
|
||
// 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 !== "" &&
|
||
allVariantsHaveRealSize() &&
|
||
isSizeChartComplete()
|
||
);
|
||
};
|
||
|
||
const isStep2Valid = () => {
|
||
return productPrice.trim() !== "";
|
||
};
|
||
|
||
// Helper function to determine which images need to be uploaded
|
||
const getImagesToUpload = () => {
|
||
return selectedImages.filter((img) => img.isEdited || !img.originalUrl);
|
||
};
|
||
|
||
// 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 totalImages = selectedImages.length;
|
||
setImageUploadProgress({
|
||
current: 0,
|
||
total: totalImages,
|
||
isUploading: true,
|
||
});
|
||
|
||
let completed = 0;
|
||
const bumpProgress = () => {
|
||
completed += 1;
|
||
setImageUploadProgress({
|
||
current: completed,
|
||
total: totalImages,
|
||
isUploading: true,
|
||
});
|
||
};
|
||
|
||
const CONCURRENCY = 3;
|
||
const isValidUrl = (u: unknown): u is string =>
|
||
typeof u === "string" && /^https?:\/\//i.test(u);
|
||
|
||
const uploadOne = async (index: number): Promise<string | null> => {
|
||
const image = selectedImages[index];
|
||
|
||
// Already-uploaded or unedited-existing paths bypass the network.
|
||
if (image.uploadedUrl && isValidUrl(image.uploadedUrl)) {
|
||
bumpProgress();
|
||
return image.uploadedUrl;
|
||
}
|
||
if (!image.isEdited && image.originalUrl && isValidUrl(image.originalUrl)) {
|
||
bumpProgress();
|
||
return image.originalUrl;
|
||
}
|
||
|
||
// Needs an upload — try twice.
|
||
for (let attempt = 0; attempt < 2; attempt++) {
|
||
try {
|
||
const result = await imageUploadMutation.mutateAsync({
|
||
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 });
|
||
|
||
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
|
||
const prepareProductAttributes = (
|
||
variants: ProductVariant[]
|
||
): ProductAttributeItem[] => {
|
||
const attributes: ProductAttributeItem[] = [];
|
||
|
||
// Add colors (excluding unselected)
|
||
const colors = [
|
||
...new Set(
|
||
variants.map((v) => v.color).filter((c) => c !== "unselected")
|
||
),
|
||
];
|
||
if (colors.length > 0) {
|
||
colors.forEach((color) => {
|
||
attributes.push({
|
||
attribute: "COLOR",
|
||
value: color, // Use raw color value instead of persian translation
|
||
});
|
||
});
|
||
}
|
||
|
||
// Add sizes (excluding unselected)
|
||
const sizes = [
|
||
...new Set(variants.map((v) => v.size).filter((s) => s !== "unselected")),
|
||
];
|
||
if (sizes.length > 0) {
|
||
sizes.forEach((size) => {
|
||
attributes.push({
|
||
attribute: "SIZE",
|
||
value: size,
|
||
});
|
||
});
|
||
}
|
||
|
||
return attributes;
|
||
};
|
||
|
||
// Helper function to prepare product variants
|
||
const prepareProductVariants = (
|
||
variants: ProductVariant[]
|
||
): ApiProductVariant[] => {
|
||
return variants.map((variant) => {
|
||
// Create variant name based on color and size
|
||
let variantName = "";
|
||
if (variant.color === "unselected" && variant.size === "unselected") {
|
||
variantName = "Default";
|
||
} else if (variant.color === "unselected") {
|
||
variantName = variant.size;
|
||
} else if (variant.size === "unselected") {
|
||
variantName = variant.color;
|
||
} else {
|
||
variantName = `${variant.color}-${variant.size}`;
|
||
}
|
||
|
||
// Prepare attributes object
|
||
const attributes: Record<string, string> = {};
|
||
if (variant.color !== "unselected") {
|
||
attributes.COLOR = variant.color;
|
||
}
|
||
if (variant.size !== "unselected" && 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 {
|
||
variantName: variantName,
|
||
stock: variant.quantity,
|
||
price: productPrice || "0",
|
||
discountPrice: hasRealDiscount ? finalPrice : null,
|
||
attributes: attributes,
|
||
};
|
||
});
|
||
};
|
||
|
||
// Main product submission handler
|
||
const handleProductSubmission = async () => {
|
||
try {
|
||
setIsSubmittingProduct(true);
|
||
|
||
// 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: validImageUrls,
|
||
category: selectedCategory,
|
||
attributes: prepareProductAttributes(productVariants),
|
||
variants: prepareProductVariants(productVariants),
|
||
sizeChart: sizeChartPayload,
|
||
};
|
||
await updateProductMutation.mutateAsync({
|
||
productId: editProductId,
|
||
productData: updateData,
|
||
});
|
||
navigate(`/store/${storeId}/product/${editProductId}`);
|
||
} else {
|
||
// Create new product
|
||
const productData: FullProductCreate = {
|
||
title: productTitle,
|
||
description: productDescription,
|
||
basePrice: productPrice || "",
|
||
imageUrls: validImageUrls,
|
||
category: selectedCategory,
|
||
attributes: prepareProductAttributes(productVariants),
|
||
variants: prepareProductVariants(productVariants),
|
||
instagramPostId: postId ? parseInt(postId) : undefined,
|
||
sizeChart: sizeChartPayload,
|
||
};
|
||
|
||
const product = await createProductMutation.mutateAsync(productData);
|
||
navigate(`/store/${product.seller?.username}`);
|
||
}
|
||
} catch (error) {
|
||
console.error(
|
||
`Failed to ${isEditMode ? "update" : "create"} product:`,
|
||
error
|
||
);
|
||
} finally {
|
||
setIsSubmittingProduct(false);
|
||
}
|
||
};
|
||
|
||
const handleContinue = () => {
|
||
if (currentStep === 1 && isStep1Valid()) {
|
||
setCurrentStep(2);
|
||
} else if (currentStep === 2 && isStep2Valid()) {
|
||
handleProductSubmission();
|
||
}
|
||
};
|
||
|
||
const handleBack = () => {
|
||
if (currentStep === 2) {
|
||
setCurrentStep(1);
|
||
} else {
|
||
safeGoBack(navigate);
|
||
}
|
||
};
|
||
|
||
// Handle drawer actions
|
||
const handleUploadClick = () => {
|
||
setIsDrawerOpen(true);
|
||
};
|
||
|
||
const handleCameraSelect = () => {
|
||
setIsDrawerOpen(false);
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.setAttribute("capture", "environment");
|
||
fileInputRef.current.click();
|
||
}
|
||
};
|
||
|
||
const handleGallerySelect = () => {
|
||
setIsDrawerOpen(false);
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.removeAttribute("capture");
|
||
fileInputRef.current.click();
|
||
}
|
||
};
|
||
|
||
const handleRemoveImage = (indexToRemove: number) => {
|
||
setSelectedImages((prev) => {
|
||
const imageToRemove = prev[indexToRemove];
|
||
const newImages = prev.filter((_, index) => index !== indexToRemove);
|
||
if (imageToRemove) {
|
||
setTimeout(() => {
|
||
URL.revokeObjectURL(imageToRemove.url);
|
||
}, 1000);
|
||
}
|
||
return newImages;
|
||
});
|
||
};
|
||
|
||
const handleScaleImage = (index: number) => {
|
||
setCurrentEditingIndex(index);
|
||
setIsImageEditorOpen(true);
|
||
};
|
||
|
||
const handleImageEditorSave = (editedImageBlob: Blob) => {
|
||
if (currentEditingIndex === null) return;
|
||
|
||
// Clean up old URL
|
||
const oldImage = selectedImages[currentEditingIndex];
|
||
if (oldImage) {
|
||
URL.revokeObjectURL(oldImage.url);
|
||
}
|
||
|
||
// Create new file and URL
|
||
const newFile = new File(
|
||
[editedImageBlob],
|
||
oldImage?.file.name || "edited-image.jpg",
|
||
{
|
||
type: editedImageBlob.type,
|
||
}
|
||
);
|
||
const newUrl = URL.createObjectURL(editedImageBlob);
|
||
|
||
// Update the image in state
|
||
setSelectedImages((prev) =>
|
||
prev.map((image, index) =>
|
||
index === currentEditingIndex
|
||
? {
|
||
file: newFile,
|
||
url: newUrl,
|
||
isEdited: true,
|
||
originalUrl: oldImage?.originalUrl || oldImage?.url,
|
||
uploadedUrl: undefined, // Clear uploaded URL since image is edited
|
||
}
|
||
: image
|
||
)
|
||
);
|
||
|
||
setCurrentEditingIndex(null);
|
||
};
|
||
|
||
// Handle image selection
|
||
const handleImageSelect = async (
|
||
event: React.ChangeEvent<HTMLInputElement>
|
||
) => {
|
||
const files = event.target.files;
|
||
if (files && files.length > 0) {
|
||
// iPhone photos are HEIC, which most browsers can't preview/crop.
|
||
// Convert to JPEG up front so preview, the crop editor, and upload work.
|
||
const converted = await convertHeicFiles(Array.from(files));
|
||
|
||
const newImages = converted.map((file) => ({
|
||
file,
|
||
url: URL.createObjectURL(file),
|
||
isEdited: false,
|
||
uploadedUrl: undefined,
|
||
}));
|
||
|
||
setSelectedImages((prev) => [...prev, ...newImages]);
|
||
|
||
// Reset the input value so the same files can be selected again if needed
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = "";
|
||
}
|
||
}
|
||
};
|
||
|
||
// Handle pricing changes
|
||
const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setProductPrice(e.target.value.replace(/[^0-9]/g, ""));
|
||
};
|
||
|
||
const onFinalPriceChange = (finalPrice: string) => {
|
||
setFinalPrice(finalPrice);
|
||
};
|
||
|
||
// Show loading while checking ownership
|
||
if (isOwnershipLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-0 w-full lg:max-w-[760px] lg:mx-auto lg:pt-6">
|
||
<HeaderWithSupport
|
||
title={
|
||
isEditMode
|
||
? "ویرایش محصول"
|
||
: postId && isAI
|
||
? "ایجاد محصول با هوش مصنوعی"
|
||
: postId && !isAI
|
||
? "ایجاد محصول از پست اینستاگرام"
|
||
: "افزودن محصول جدید"
|
||
}
|
||
onBack={handleBack}
|
||
/>
|
||
|
||
{/* Show form only if not in AI generation mode or AI generation is complete */}
|
||
{(!postId || !isAI || !isAiGenerating) && (
|
||
<>
|
||
{currentStep === 1 && (
|
||
<>
|
||
{/* Image Upload Section */}
|
||
<ImageUploadSection
|
||
selectedImages={selectedImages}
|
||
onUploadClick={handleUploadClick}
|
||
onRemoveImage={handleRemoveImage}
|
||
onScaleImage={handleScaleImage}
|
||
onImageSelect={handleImageSelect}
|
||
fileInputRef={fileInputRef}
|
||
/>
|
||
|
||
{/* Product Details Form */}
|
||
<ProductDetailsForm
|
||
productTitle={productTitle}
|
||
productDescription={productDescription}
|
||
onTitleChange={(e) => setProductTitle(e.target.value)}
|
||
onDescriptionChange={(e) =>
|
||
setProductDescription(e.target.value)
|
||
}
|
||
/>
|
||
|
||
{/* Category Selection — two-step: parent then sub */}
|
||
<div className="px-4 mt-4 space-y-3">
|
||
<FormField label="دستهبندی اصلی" required>
|
||
<AppSelectBox
|
||
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}
|
||
setValue={setSelectedCategory}
|
||
placeholder={
|
||
selectedParent
|
||
? "یک زیردسته انتخاب کنید"
|
||
: "ابتدا دسته اصلی را انتخاب کنید"
|
||
}
|
||
loading={categoriesLoading}
|
||
haveSearchbar={true}
|
||
width="w-full"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
|
||
{/* Product Variants Section */}
|
||
<ProductVariantsSection
|
||
setProductVariants={setProductVariants}
|
||
productVariants={productVariants}
|
||
onUpdateVariantQuantity={updateVariantQuantity}
|
||
/>
|
||
|
||
{/* Size Chart (راهنمای سایز) */}
|
||
<div className="px-4 mt-4">
|
||
<SizeChartSection
|
||
value={sizeChart}
|
||
onChange={setSizeChart}
|
||
categoryName={selectedCategoryName}
|
||
productSizes={productSizes}
|
||
required={hasSizes}
|
||
showError={hasSizes && !isSizeChartComplete()}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{currentStep === 2 && (
|
||
<PricingForm
|
||
productPrice={productPrice}
|
||
finalPrice={finalPrice}
|
||
onPriceChange={handlePriceChange}
|
||
onFinalPriceChange={onFinalPriceChange}
|
||
/>
|
||
)}
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex flex-col gap-3 px-4 pb-20 mt-2">
|
||
<Button
|
||
className="w-full"
|
||
size="xl"
|
||
variant="dark"
|
||
onClick={handleContinue}
|
||
disabled={
|
||
isSubmittingProduct ||
|
||
imageUploadProgress.isUploading ||
|
||
(currentStep === 1 ? !isStep1Valid() : !isStep2Valid())
|
||
}
|
||
>
|
||
{currentStep === 1
|
||
? "ادامه"
|
||
: isEditMode
|
||
? "بهروزرسانی محصول"
|
||
: "ثبت محصول"}
|
||
</Button>
|
||
|
||
{/* Validation Messages */}
|
||
{currentStep === 1 && !isStep1Valid() && (
|
||
<div className="text-xs text-red-500 text-center space-y-1">
|
||
{selectedImages.length === 0 && (
|
||
<div>•حداقل یک تصویر اضافه کنید</div>
|
||
)}
|
||
{!productTitle.trim() && <div>•عنوان محصول را وارد کنید</div>}
|
||
{!productDescription.trim() && (
|
||
<div>•توضیحات محصول را وارد کنید</div>
|
||
)}
|
||
{!selectedCategory && (
|
||
<div>•دستهبندی محصول را انتخاب کنید</div>
|
||
)}
|
||
{productVariants.length === 0 && (
|
||
<div>•حداقل یک ویژگی اضافه کنید</div>
|
||
)}
|
||
{hasSizes && !isSizeChartComplete() && (
|
||
<div>•راهنمای سایز را برای همه سایزها کامل کنید</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{currentStep === 2 && !isStep2Valid() && (
|
||
<div className="text-xs text-red-500 text-center">
|
||
• لطفاً قیمت محصول را وارد کنید
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Show a message when AI is generating */}
|
||
{postId && isAI && isAiGenerating && (
|
||
<div className="flex flex-col items-center justify-center p-8 mt-20">
|
||
<Loader className="animate-spin mb-4" size={48} />
|
||
<h2 className="text-lg font-bold mb-2">
|
||
در حال ایجاد محصول با هوش مصنوعی
|
||
</h2>
|
||
<p className="text-BLACK2 text-center">
|
||
لطفا صبر کنید تا محصول شما از روی پست اینستاگرام ایجاد شود...
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{postId && isAI && aiGenerationError && (
|
||
<div className="flex flex-col items-center justify-center p-8 mt-20">
|
||
<div className="text-red-500 mb-4">
|
||
<X size={48} />
|
||
</div>
|
||
<h2 className="text-lg font-bold mb-2 text-red-600">
|
||
خطا در ایجاد محصول
|
||
</h2>
|
||
<p className="text-BLACK2 text-center mb-4">
|
||
متأسفانه نتوانستیم محصول را از روی پست اینستاگرام شما ایجاد کنیم
|
||
</p>
|
||
<Button
|
||
onClick={() => safeGoBack(navigate)}
|
||
variant="outline"
|
||
className="mb-2"
|
||
>
|
||
بازگشت
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Image Editor */}
|
||
<ImageEditor
|
||
isOpen={isImageEditorOpen}
|
||
onClose={() => setIsImageEditorOpen(false)}
|
||
imageUrl={
|
||
currentEditingIndex !== null
|
||
? selectedImages[currentEditingIndex]?.url || ""
|
||
: ""
|
||
}
|
||
onSave={handleImageEditorSave}
|
||
/>
|
||
|
||
{/* Image Upload Drawer */}
|
||
<ImageUploadDrawer
|
||
isOpen={isDrawerOpen}
|
||
onOpenChange={setIsDrawerOpen}
|
||
onCameraSelect={handleCameraSelect}
|
||
onGallerySelect={handleGallerySelect}
|
||
/>
|
||
|
||
{/* Loading Modals */}
|
||
<LoadingModal
|
||
open={imageUploadProgress.isUploading}
|
||
setOpen={() => {}}
|
||
title={`در حال آپلود ${imageUploadProgress.current}/${imageUploadProgress.total} عکس...`}
|
||
/>
|
||
|
||
<LoadingModal
|
||
open={isSubmittingProduct}
|
||
setOpen={() => {}}
|
||
title={
|
||
isEditMode ? "در حال بهروزرسانی محصول..." : "در حال ایجاد محصول..."
|
||
}
|
||
/>
|
||
|
||
<LoadingModal
|
||
open={isLoadingPostData}
|
||
setOpen={() => {}}
|
||
title="در حال بارگذاری اطلاعات پست..."
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface LoadingModalProps {
|
||
open: boolean;
|
||
setOpen: (open: boolean) => void;
|
||
title?: string;
|
||
description?: string;
|
||
}
|
||
|
||
export const LoadingModal = ({
|
||
open,
|
||
setOpen,
|
||
title = "در حال بارگذاری...",
|
||
description,
|
||
}: LoadingModalProps) => {
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogOverlay className="bg-BLACK/50" />
|
||
<DialogContent className="p-6 bg-WHITE rounded-[15px] w-[calc(100%-40px)] max-w-sm">
|
||
<div className="flex flex-col items-center justify-center gap-4 py-4">
|
||
<Loader className="animate-spin" />
|
||
<p className="text-B14 font-bold text-center">لطفا منتظر بمانید...</p>
|
||
<p className="text-R12 text-center">{title}</p>
|
||
{description && <p className="text-R12 text-center">{description}</p>}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|