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 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(); // Transform categories data for AppSelectBox 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(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 || ""); 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( null ); const fileInputRef = useRef(null); const [selectedImages, setSelectedImages] = useState< Array<{ file: File; url: string; isEdited: boolean; originalUrl?: string; uploadedUrl?: string; }> >([]); const [productVariants, setProductVariants] = useState([]); const [sizeChart, setSizeChart] = useState(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 isStep1Valid = () => { return ( selectedImages.length > 0 && productTitle.trim() !== "" && productDescription.trim() !== "" && selectedCategory !== "" && productVariants.length > 0 && 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 const getFinalImageUrls = async (): Promise => { const imagesToUpload = getImagesToUpload(); const totalImages = imagesToUpload.length; setImageUploadProgress({ current: 0, total: totalImages, isUploading: true, }); const finalUrls: string[] = []; // Process each image for (let i = 0; i < selectedImages.length; i++) { const image = selectedImages[i]; 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 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); } } setImageUploadProgress({ current: 0, total: 0, isUploading: false }); return finalUrls; }; // 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 = {}; if (variant.color !== "unselected") { attributes.COLOR = variant.color; } if (variant.size !== "unselected" && variant.size !== "") { attributes.SIZE = variant.size; } return { variantName: variantName, stock: variant.quantity, price: productPrice || "0", discountPrice: finalPrice || productPrice || "0", 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(); const sizeChartPayload = sizeChart && sizeChart.columns.length > 0 && sizeChart.rows.length > 0 ? sizeChart : null; if (isEditMode && editProductId) { // Update existing product const updateData = { title: productTitle, description: productDescription, basePrice: productPrice || "", imageUrls: imageUrls.filter(Boolean), // Remove empty URLs 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: imageUrls.filter(Boolean), // Remove empty URLs 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 ) => { 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) => { setProductPrice(e.target.value.replace(/[^0-9]/g, "")); }; const onFinalPriceChange = (finalPrice: string) => { setFinalPrice(finalPrice); }; // Show loading while checking ownership if (isOwnershipLoading) { return (
); } return (
{/* Show form only if not in AI generation mode or AI generation is complete */} {(!postId || !isAI || !isAiGenerating) && ( <> {currentStep === 1 && ( <> {/* Image Upload Section */} {/* Product Details Form */} setProductTitle(e.target.value)} onDescriptionChange={(e) => setProductDescription(e.target.value) } /> {/* Category Selection */}
{/* Product Variants Section */} {/* Size Chart (راهنمای سایز) */}
)} {currentStep === 2 && ( )} {/* Action Buttons */}
{/* Validation Messages */} {currentStep === 1 && !isStep1Valid() && (
{selectedImages.length === 0 && (
•حداقل یک تصویر اضافه کنید
)} {!productTitle.trim() &&
•عنوان محصول را وارد کنید
} {!productDescription.trim() && (
•توضیحات محصول را وارد کنید
)} {!selectedCategory && (
•دسته‌بندی محصول را انتخاب کنید
)} {productVariants.length === 0 && (
•حداقل یک ویژگی اضافه کنید
)} {hasSizes && !isSizeChartComplete() && (
•راهنمای سایز را برای همه سایزها کامل کنید
)}
)} {currentStep === 2 && !isStep2Valid() && (
• لطفاً قیمت محصول را وارد کنید
)}
)} {/* Show a message when AI is generating */} {postId && isAI && isAiGenerating && (

در حال ایجاد محصول با هوش مصنوعی

لطفا صبر کنید تا محصول شما از روی پست اینستاگرام ایجاد شود...

)} {postId && isAI && aiGenerationError && (

خطا در ایجاد محصول

متأسفانه نتوانستیم محصول را از روی پست اینستاگرام شما ایجاد کنیم

)} {/* Image Editor */} setIsImageEditorOpen(false)} imageUrl={ currentEditingIndex !== null ? selectedImages[currentEditingIndex]?.url || "" : "" } onSave={handleImageEditorSave} /> {/* Image Upload Drawer */} {/* Loading Modals */} {}} title={`در حال آپلود ${imageUploadProgress.current}/${imageUploadProgress.total} عکس...`} /> {}} title={ isEditMode ? "در حال به‌روزرسانی محصول..." : "در حال ایجاد محصول..." } /> {}} title="در حال بارگذاری اطلاعات پست..." />
); } interface LoadingModalProps { open: boolean; setOpen: (open: boolean) => void; title?: string; description?: string; } export const LoadingModal = ({ open, setOpen, title = "در حال بارگذاری...", description, }: LoadingModalProps) => { return (

لطفا منتظر بمانید...

{title}

{description &&

{description}

}
); };