Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { Product, AppView } from './types'; | |
| import { PRODUCTS } from './constants'; | |
| import { generateTryOn } from './services/geminiService'; | |
| import Navigation from './components/Navigation'; | |
| import ProductCard from './components/ProductCard'; | |
| import CameraView from './components/CameraView'; | |
| import ResultView from './components/ResultView'; | |
| import { Sparkles, Timer } from 'lucide-react'; | |
| const STORAGE_KEY = 'stylegenie_last_req_v2'; | |
| const COOLDOWN_SECONDS = 120; // 2 minutes is safe for public shared keys | |
| function App() { | |
| const [currentView, setCurrentView] = useState<AppView>(AppView.CATALOG); | |
| const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); | |
| const [userImage, setUserImage] = useState<string | null>(null); | |
| const [generatedResult, setGeneratedResult] = useState<string | null>(null); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| const [generationError, setGenerationError] = useState<string | null>(null); | |
| const [retryAfter, setRetryAfter] = useState<number | null>(null); | |
| const [globalCooldown, setGlobalCooldown] = useState(0); | |
| // Sync cooldown from localStorage to survive refreshes | |
| useEffect(() => { | |
| const updateCooldown = () => { | |
| const last = localStorage.getItem(STORAGE_KEY); | |
| if (last) { | |
| const diff = (Date.now() - parseInt(last)) / 1000; | |
| if (diff < COOLDOWN_SECONDS) { | |
| setGlobalCooldown(Math.ceil(COOLDOWN_SECONDS - diff)); | |
| } else { | |
| setGlobalCooldown(0); | |
| } | |
| } | |
| }; | |
| updateCooldown(); | |
| const interval = setInterval(updateCooldown, 1000); | |
| return () => clearInterval(interval); | |
| }, []); | |
| const handleSelectProduct = (product: Product) => { | |
| if (globalCooldown > 0) return; | |
| setSelectedProduct(product); | |
| setCurrentView(AppView.CAMERA); | |
| }; | |
| const handleBackToCatalog = () => { | |
| setCurrentView(AppView.CATALOG); | |
| setSelectedProduct(null); | |
| setUserImage(null); | |
| setGeneratedResult(null); | |
| setGenerationError(null); | |
| setRetryAfter(null); | |
| }; | |
| const handleCapture = async (imageSrc: string) => { | |
| setUserImage(imageSrc); | |
| setCurrentView(AppView.RESULT); | |
| if (selectedProduct) { | |
| await processTryOn(imageSrc, selectedProduct); | |
| } | |
| }; | |
| const processTryOn = async (imageSrc: string, product: Product) => { | |
| // Final check for cooldown | |
| if (globalCooldown > 0) { | |
| setRetryAfter(globalCooldown); | |
| return; | |
| } | |
| setIsGenerating(true); | |
| setGenerationError(null); | |
| setRetryAfter(null); | |
| // Set lock immediately to prevent race conditions | |
| localStorage.setItem(STORAGE_KEY, Date.now().toString()); | |
| try { | |
| const resultImage = await generateTryOn(imageSrc, product); | |
| setGeneratedResult(resultImage); | |
| } catch (error: any) { | |
| const msg = error.message || ""; | |
| if (msg.includes("FREE_LIMIT_HIT") || msg.includes("429")) { | |
| setRetryAfter(COOLDOWN_SECONDS); | |
| } else { | |
| setGenerationError(msg); | |
| } | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }; | |
| const handleRetake = () => { | |
| setCurrentView(AppView.CAMERA); | |
| setGeneratedResult(null); | |
| setGenerationError(null); | |
| setRetryAfter(null); | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-slate-50 font-sans text-slate-900"> | |
| <Navigation | |
| showBack={currentView !== AppView.CATALOG} | |
| onBack={handleBackToCatalog} | |
| cooldown={globalCooldown} | |
| /> | |
| {currentView === AppView.CATALOG && ( | |
| <main className="max-w-7xl mx-auto p-4 md:p-6 lg:p-8"> | |
| <div className="mb-8 text-center max-w-2xl mx-auto"> | |
| <div className="inline-flex items-center gap-2 px-3 py-1 bg-brand-50 rounded-full border border-brand-100 mb-4"> | |
| <Sparkles className="w-4 h-4 text-brand-500" /> | |
| <span className="text-[10px] font-bold text-brand-600 uppercase tracking-widest">Optimized Free Mode</span> | |
| </div> | |
| <h1 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">StyleGenie Try-On</h1> | |
| {globalCooldown > 0 ? ( | |
| <div className="flex flex-col items-center gap-2 mt-4 p-4 bg-amber-50 rounded-2xl border border-amber-100 animate-in fade-in zoom-in-95"> | |
| <Timer className="w-6 h-6 text-amber-500 animate-pulse" /> | |
| <p className="text-amber-700 font-bold">AI is Resting: {globalCooldown}s</p> | |
| <p className="text-amber-600 text-xs">Free tier allows 1 generation every 2 minutes.</p> | |
| </div> | |
| ) : ( | |
| <p className="text-gray-500 text-lg">Select an item below to see the magic happen.</p> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6"> | |
| {PRODUCTS.map(product => ( | |
| <ProductCard | |
| key={product.id} | |
| product={product} | |
| onTryOn={handleSelectProduct} | |
| disabled={globalCooldown > 0} | |
| /> | |
| ))} | |
| </div> | |
| </main> | |
| )} | |
| {currentView === AppView.CAMERA && selectedProduct && ( | |
| <CameraView | |
| product={selectedProduct} | |
| onCapture={handleCapture} | |
| onClose={handleBackToCatalog} | |
| /> | |
| )} | |
| {currentView === AppView.RESULT && selectedProduct && userImage && ( | |
| <ResultView | |
| product={selectedProduct} | |
| originalImage={userImage} | |
| generatedImage={generatedResult} | |
| loading={isGenerating} | |
| error={generationError} | |
| retryAfter={retryAfter} | |
| onRetake={handleRetake} | |
| onRetry={() => processTryOn(userImage, selectedProduct)} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| export default App; |