- HeaderWithSupport is now lg:hidden (all its callers are desktop-full-width store/profile pages), removing the stray mobile back+support bar on desktop. - Constrain + title the previously mobile-only pages so their content doesn't stretch edge-to-edge on desktop: store edit/create form (StoreForm), add product, shipping method, financial sub-pages (cash-funds/reports/ transactions), order detail, instagram auth; and profile add/edit address, set detail, and payment result. Forms use a narrow centered column, lists/ details a wider one. Transactions keeps its filter button (header restyled, not hidden). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
982 lines
32 KiB
TypeScript
982 lines
32 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 { 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,
|
||
} 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<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 || "");
|
||
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[]>([]);
|
||
|
||
// 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
|
||
);
|
||
};
|
||
|
||
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<string[]> => {
|
||
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<string, string> = {};
|
||
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();
|
||
|
||
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),
|
||
};
|
||
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,
|
||
};
|
||
|
||
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 */}
|
||
<div className="px-4 mt-4">
|
||
<FormField label="دستهبندی" required>
|
||
<AppSelectBox
|
||
items={categoryOptions}
|
||
value={selectedCategory}
|
||
setValue={setSelectedCategory}
|
||
placeholder="انتخاب دستهبندی"
|
||
loading={categoriesLoading}
|
||
haveSearchbar={true}
|
||
width="w-full"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
|
||
{/* Product Variants Section */}
|
||
<ProductVariantsSection
|
||
setProductVariants={setProductVariants}
|
||
productVariants={productVariants}
|
||
onUpdateVariantQuantity={updateVariantQuantity}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{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>
|
||
)}
|
||
</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>
|
||
);
|
||
};
|