586 lines
21 KiB
TypeScript
586 lines
21 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
DialogOverlay,
|
||
} from "./ui/dialog";
|
||
import { Button } from "./ui/button";
|
||
import {
|
||
Sparkles,
|
||
Upload,
|
||
ImageIcon,
|
||
X,
|
||
Download,
|
||
RotateCcw,
|
||
CheckCircle,
|
||
Share2,
|
||
} from "lucide-react";
|
||
import { useRootData, useAuthToken } from "../hooks/use-root-data";
|
||
import { LoginRequiredDialog } from "./LoginRequiredDialog";
|
||
import { useMutation } from "@tanstack/react-query";
|
||
import { getApiBaseUrl } from "~/utils/api-config";
|
||
|
||
interface AIPromoteModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
collectionId?: string;
|
||
productId?: string;
|
||
}
|
||
|
||
export default function AIPromoteModal({
|
||
isOpen,
|
||
onClose,
|
||
collectionId,
|
||
productId,
|
||
}: AIPromoteModalProps) {
|
||
const isProductMode = !!productId;
|
||
const { user } = useRootData();
|
||
const token = useAuthToken();
|
||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||
const [resultImage, setResultImage] = useState<string | null>(null);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false);
|
||
const [loadingStep, setLoadingStep] = useState(0);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Loading steps messages
|
||
const loadingSteps = [
|
||
{ text: "در حال آمادهسازی لباس...", icon: "👗" },
|
||
{ text: "در حال اندازهگیری سایز شما...", icon: "📏" },
|
||
{ text: "در حال دوختن لباس به اندازه شما...", icon: "✂️" },
|
||
{ text: "در حال اتو کردن و مرتب کردن...", icon: "✨" },
|
||
{ text: "در حال پرو کردن روی شما...", icon: "🎯" },
|
||
{ text: "تقریباً آماده شده یخورده هم صبر کن...", icon: "⏳" },
|
||
];
|
||
|
||
// Cycle through loading steps
|
||
useEffect(() => {
|
||
if (!isUploading) {
|
||
setLoadingStep(0);
|
||
return;
|
||
}
|
||
|
||
const interval = setInterval(() => {
|
||
setLoadingStep((prev) => {
|
||
// Stay on last step if we've gone through all
|
||
if (prev >= loadingSteps.length - 1) return prev;
|
||
return prev + 1;
|
||
});
|
||
}, 4000);
|
||
|
||
return () => clearInterval(interval);
|
||
}, [isUploading, loadingSteps.length]);
|
||
|
||
// AI Try-on mutation for collection
|
||
const tryOnCollectionMutation = useMutation({
|
||
mutationFn: async ({
|
||
image,
|
||
collectionId,
|
||
}: {
|
||
image: Blob;
|
||
collectionId: string;
|
||
}) => {
|
||
if (!token) throw new Error("Authentication required");
|
||
const baseUrl = getApiBaseUrl();
|
||
|
||
const formData = new FormData();
|
||
formData.append("collection_id", collectionId);
|
||
formData.append("image", image);
|
||
|
||
const response = await fetch(`${baseUrl}/api/aifit/v1/try-on/`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: formData,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API error: ${response.status}`);
|
||
}
|
||
|
||
// Response is binary PNG, convert to blob URL
|
||
const blob = await response.blob();
|
||
return URL.createObjectURL(blob);
|
||
},
|
||
});
|
||
|
||
// AI Try-on mutation for product
|
||
const tryOnProductMutation = useMutation({
|
||
mutationFn: async ({
|
||
image,
|
||
productId,
|
||
}: {
|
||
image: Blob;
|
||
productId: string;
|
||
}) => {
|
||
if (!token) throw new Error("Authentication required");
|
||
const baseUrl = getApiBaseUrl();
|
||
|
||
const formData = new FormData();
|
||
formData.append("product_id", productId);
|
||
formData.append("image", image);
|
||
|
||
// AI processing can take a while, set 2 minute timeout
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
||
|
||
try {
|
||
const response = await fetch(
|
||
`${baseUrl}/api/aifit/v1/try-on/product/`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: formData,
|
||
signal: controller.signal,
|
||
}
|
||
);
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text().catch(() => "");
|
||
console.error("API error response:", errorText);
|
||
throw new Error(`API error: ${response.status}`);
|
||
}
|
||
|
||
// Response is binary PNG, convert to blob URL
|
||
const blob = await response.blob();
|
||
console.log("Received blob:", blob.type, blob.size);
|
||
return URL.createObjectURL(blob);
|
||
} catch (error) {
|
||
clearTimeout(timeoutId);
|
||
if (error instanceof Error && error.name === "AbortError") {
|
||
throw new Error(
|
||
"درخواست زمان زیادی طول کشید. لطفا دوباره تلاش کنید."
|
||
);
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
});
|
||
|
||
// Image Upload Handler
|
||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
// Validate file type
|
||
if (!file.type.startsWith("image/")) {
|
||
alert("لطفا یک فایل تصویری انتخاب کنید");
|
||
return;
|
||
}
|
||
|
||
// Validate file size (max 10MB)
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
alert("حجم فایل نباید بیشتر از 10 مگابایت باشد");
|
||
return;
|
||
}
|
||
|
||
setSelectedImage(file);
|
||
|
||
// Create preview
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
setImagePreview(reader.result as string);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
// Handle login required - close AI modal first, then show login
|
||
const handleLoginRequired = () => {
|
||
onClose(); // Close AI modal first
|
||
// Small delay to let the modal close animation complete
|
||
setTimeout(() => {
|
||
setIsLoginDialogOpen(true);
|
||
}, 150);
|
||
};
|
||
|
||
// Handle AI Promote Submit
|
||
const handleAIPromote = async () => {
|
||
// Check if user is logged in
|
||
if (!user) {
|
||
handleLoginRequired();
|
||
return;
|
||
}
|
||
|
||
if (!selectedImage) {
|
||
alert("لطفا ابتدا عکس خود را آپلود کنید");
|
||
return;
|
||
}
|
||
|
||
setIsUploading(true);
|
||
|
||
try {
|
||
console.log("Sending AI try-on request...");
|
||
|
||
let result;
|
||
|
||
if (isProductMode && productId) {
|
||
// Product try-on
|
||
result = await tryOnProductMutation.mutateAsync({
|
||
image: selectedImage,
|
||
productId,
|
||
});
|
||
} else if (collectionId) {
|
||
// Collection try-on
|
||
result = await tryOnCollectionMutation.mutateAsync({
|
||
image: selectedImage,
|
||
collectionId,
|
||
});
|
||
} else {
|
||
throw new Error("No product or collection ID provided");
|
||
}
|
||
|
||
console.log("AI try-on completed successfully", result);
|
||
|
||
// Result is already a blob URL from the mutation
|
||
setResultImage(result);
|
||
|
||
// Reset state
|
||
// handleModalClose();
|
||
} catch (error) {
|
||
console.error("Error in AI try-on:", error);
|
||
alert("متاسفانه خطایی رخ داد. لطفا دوباره تلاش کنید.");
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
};
|
||
|
||
// Reset modal state when closed
|
||
const handleModalClose = () => {
|
||
setSelectedImage(null);
|
||
setImagePreview(null);
|
||
setResultImage(null);
|
||
setIsUploading(false);
|
||
onClose();
|
||
};
|
||
|
||
// Reset to try again
|
||
const handleTryAgain = () => {
|
||
setResultImage(null);
|
||
setSelectedImage(null);
|
||
setImagePreview(null);
|
||
};
|
||
|
||
// Download result image
|
||
const handleDownload = () => {
|
||
if (!resultImage) return;
|
||
const link = document.createElement("a");
|
||
link.href = resultImage;
|
||
link.download = `ai-tryon-${Date.now()}.png`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
};
|
||
|
||
// Share result image
|
||
const handleShare = async () => {
|
||
if (!resultImage) return;
|
||
|
||
try {
|
||
// Convert blob URL to actual blob for sharing
|
||
const response = await fetch(resultImage);
|
||
const blob = await response.blob();
|
||
const file = new File([blob], `ai-tryon-${Date.now()}.png`, {
|
||
type: "image/png",
|
||
});
|
||
|
||
// Build product URL for sharing
|
||
const siteUrl =
|
||
typeof window !== "undefined"
|
||
? window.location.origin
|
||
: "https://vitrown.com";
|
||
const productUrl = productId
|
||
? `${siteUrl}/product/${productId}`
|
||
: siteUrl;
|
||
const shareText = `ببین این لباس رو تن من چطور شده! 👗✨\n\nاین محصول رو از ویترون ببین:\n${productUrl}`;
|
||
|
||
// Check if Web Share API is available and supports sharing files
|
||
if (navigator.share && navigator.canShare?.({ files: [file] })) {
|
||
await navigator.share({
|
||
title: "پرو کردن با هوش مصنوعی - ویترون",
|
||
text: shareText,
|
||
files: [file],
|
||
});
|
||
} else {
|
||
// Fallback: Copy image to clipboard or show message
|
||
try {
|
||
await navigator.clipboard.write([
|
||
new ClipboardItem({ "image/png": blob }),
|
||
]);
|
||
alert(
|
||
"تصویر در کلیپبورد کپی شد! حالا میتونی توی هر اپلیکیشنی Paste کنی."
|
||
);
|
||
} catch {
|
||
// If clipboard also fails, just download
|
||
handleDownload();
|
||
alert("تصویر دانلود شد. میتونی از گالری به اشتراک بذاری.");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error sharing:", error);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* Login Required Dialog - outside of main dialog to avoid overlap */}
|
||
<LoginRequiredDialog
|
||
isOpen={isLoginDialogOpen}
|
||
onOpenChange={setIsLoginDialogOpen}
|
||
loginTitle="برای استفاده از قابلیت پرو کردن با AI ابتدا باید وارد حساب کاربری خود شوید."
|
||
/>
|
||
|
||
<Dialog open={isOpen} onOpenChange={handleModalClose}>
|
||
<DialogOverlay />
|
||
<DialogContent className="bg-WHITE rounded-2xl max-w-md w-[calc(100%-32px)] sm:w-full p-4 border border-inner-border">
|
||
{/* Close Button */}
|
||
<button
|
||
onClick={handleModalClose}
|
||
disabled={isUploading}
|
||
className="absolute top-4 right-4 text-GRAY hover:text-BLACK transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
aria-label="بستن"
|
||
>
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
|
||
<DialogHeader>
|
||
<DialogTitle className="text-B16 mt-8 font-bold text-right flex items-center gap-2 pl-8">
|
||
<span className="w-full">
|
||
{isProductMode
|
||
? "پرو کردن محصول با هوش مصنوعی"
|
||
: "پرو کردن کالکشن با هوش مصنوعی"}
|
||
</span>
|
||
<Sparkles className="w-6 h-6 text-BLACK" />
|
||
</DialogTitle>
|
||
<DialogDescription className="text-R14 text-GRAY text-right">
|
||
{isProductMode
|
||
? "برای اینکه AI بتواند این محصول رو روی بدن شما نمایش بده، لطفا یک عکس قدی از خود را آپلود کنید."
|
||
: "برای اینکه AI بتواد این کالکشن رو روی بدن شما نمایش بده، لطفا یک عکس قدی از خود را آپلود کنید."}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="flex flex-col gap-4 mt-4">
|
||
{resultImage ? (
|
||
// Result View
|
||
<div className="flex flex-col gap-4">
|
||
{/* Success Badge */}
|
||
<div className="flex items-center justify-center gap-2 p-3 bg-GREEN/10 rounded-xl border border-GREEN/30">
|
||
<CheckCircle className="w-5 h-5 text-GREEN" />
|
||
<p className="text-B14 font-bold text-GREEN">
|
||
پرو کردن با موفقیت انجام شد!
|
||
</p>
|
||
</div>
|
||
|
||
{/* Result Image */}
|
||
<div className="relative w-full h-80 rounded-xl overflow-hidden bg-WHITE2 border-2 border-GREEN/30 shadow-lg">
|
||
<img
|
||
src={resultImage}
|
||
alt="AI Try-on Result"
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex flex-col gap-3">
|
||
{/* Share Button - Full Width */}
|
||
<Button
|
||
variant="dark"
|
||
size="lg"
|
||
onClick={handleShare}
|
||
className="w-full"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Share2 className="w-4 h-4" />
|
||
<span>ارسال به دوستان</span>
|
||
</div>
|
||
</Button>
|
||
|
||
{/* Download & Try Again */}
|
||
<div className="flex gap-3">
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
onClick={handleTryAgain}
|
||
className="flex-1"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<RotateCcw className="w-4 h-4" />
|
||
<span>امتحان مجدد</span>
|
||
</div>
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
onClick={handleDownload}
|
||
className="flex-1"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Download className="w-4 h-4" />
|
||
<span>دانلود</span>
|
||
</div>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// Upload View
|
||
<>
|
||
{/* Image Upload Area */}
|
||
<div className="flex flex-col gap-3">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageSelect}
|
||
className="hidden"
|
||
id="ai-image-upload"
|
||
/>
|
||
|
||
{imagePreview ? (
|
||
// Image Preview
|
||
<div className="relative w-full h-64 rounded-xl overflow-hidden bg-WHITE2 border-2 border-BLACK/10">
|
||
<img
|
||
src={imagePreview}
|
||
alt="Preview"
|
||
className="w-full h-full object-contain"
|
||
/>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedImage(null);
|
||
setImagePreview(null);
|
||
}}
|
||
className="absolute top-2 right-2 bg-RED text-WHITE rounded-full p-2 hover:bg-RED/80 transition-colors shadow-lg"
|
||
aria-label="حذف عکس"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
// Upload Button
|
||
<label
|
||
htmlFor="ai-image-upload"
|
||
className="w-full h-64 rounded-xl border-2 border-dashed border-GRAY/30
|
||
hover:border-BLACK transition-colors cursor-pointer
|
||
flex flex-col items-center justify-center gap-3 bg-WHITE2 hover:bg-WHITE3"
|
||
>
|
||
<div className="w-16 h-16 rounded-full bg-BLACK/5 flex items-center justify-center">
|
||
<Upload className="w-8 h-8 text-BLACK" />
|
||
</div>
|
||
<div className="flex flex-col items-center gap-1">
|
||
<p className="text-B16 font-bold text-BLACK">
|
||
آپلود عکس
|
||
</p>
|
||
<p className="text-R12 text-GRAY text-center px-4">
|
||
عکس باید تمام قد باشه تا AI بتونه بهتر کالکشن رو روی
|
||
بدن شما نمایش بده
|
||
</p>
|
||
<p className="text-R10 text-GRAY mt-2">
|
||
حداکثر حجم: 10MB
|
||
</p>
|
||
</div>
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
{/* Action Button or Loading State */}
|
||
{isUploading ? (
|
||
// Loading State - replaces button and info boxes
|
||
<div className="flex flex-col items-center gap-4 py-6 px-4 bg-gradient-to-b from-BLACK/5 to-BLACK/10 rounded-xl border border-BLACK/10">
|
||
{/* Animated Icon */}
|
||
<div className="text-5xl animate-bounce">
|
||
{loadingSteps[loadingStep].icon}
|
||
</div>
|
||
|
||
{/* Loading Text */}
|
||
<p className="text-B16 font-bold text-BLACK text-center">
|
||
{loadingSteps[loadingStep].text}
|
||
</p>
|
||
|
||
{/* Progress Dots */}
|
||
<div className="flex items-center gap-2">
|
||
{loadingSteps.map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
|
||
index <= loadingStep
|
||
? "bg-BLACK scale-100"
|
||
: "bg-BLACK/20 scale-75"
|
||
}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Step Counter */}
|
||
<p className="text-R12 text-GRAY">
|
||
مرحله {loadingStep + 1} از {loadingSteps.length}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
// Normal State - Button and Info boxes
|
||
<>
|
||
<Button
|
||
variant="dark"
|
||
size="xl"
|
||
onClick={handleAIPromote}
|
||
disabled={!selectedImage}
|
||
className="w-full"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Sparkles className="w-4 h-4" />
|
||
<span>شروع پرو کردن</span>
|
||
</div>
|
||
</Button>
|
||
|
||
{/* Info Box */}
|
||
<div className="flex items-start gap-2 p-3 bg-BLUE/5 rounded-lg border border-BLUE/20">
|
||
<ImageIcon className="w-5 h-5 text-BLUE mt-0.5 flex-shrink-0" />
|
||
<p className="text-R12 text-GRAY text-right">
|
||
<span className="font-bold text-BLACK">نکته:</span> برای
|
||
بهترین نتیجه، عکس باید در نورپردازی مناسب و با زمینه
|
||
ساده باشه. AI با استفاده از این عکس،{" "}
|
||
{isProductMode ? "این محصول" : "محصولات کالکشن"} رو روی
|
||
بدن شما نمایش میده.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Privacy Notice */}
|
||
<div className="flex items-center justify-center gap-1.5 p-2 bg-GREEN/5 rounded-lg border border-GREEN/20">
|
||
<svg
|
||
className="w-4 h-4 text-GREEN flex-shrink-0"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||
/>
|
||
</svg>
|
||
<p className="text-R11 text-GRAY text-center">
|
||
<span className="font-bold text-GREEN">
|
||
حریم خصوصی شما مهمه:
|
||
</span>{" "}
|
||
عکسهای شما هیچجا ذخیره نمیشه
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|