Vitron-Front/app/routes/store.add.manual.$storeId.tsx
fazli 83b93fe523 feat(seller-editor): 9x9 color grid, mandatory size, two-step category, robust image + discount save
- 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>
2026-07-18 13:19:13 +00:00

1150 lines
39 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};