Lianjx's picture
Upload 16 files
a122535 verified
import React, { useState, useRef, useEffect, Suspense } from 'react';
import { Header } from './components/Header';
import { ImageUploader } from './components/ImageUploader';
import { StyleSelector, STYLES } from './components/StyleSelector';
import { ResultViewer } from './components/ResultViewer';
import { FilterSelector, FILTERS } from './components/FilterSelector';
import { Features } from './components/Features';
import { Testimonials } from './components/Testimonials';
// Lazy load AdminDashboard
const AdminDashboard = React.lazy(() => import('./components/AdminDashboard').then(module => ({ default: module.AdminDashboard })));
import { UserCenter } from './components/UserCenter';
import { AuthModal } from './components/AuthModal';
import { AboutModal } from './components/AboutModal';
import { RedPacketModal } from './components/RedPacketModal';
import { SlashModal } from './components/SlashModal';
import { ShareModal } from './components/ShareModal';
import { AnalysisModal } from './components/AnalysisModal';
import { FeedbackModal } from './components/FeedbackModal';
import { generateStyledWeddingImage } from './services/geminiService';
import { crmService } from './services/crmService';
import { WeddingStyle, LeadData, UserAccount, FeedbackItem } from './types';
import { TRANSLATIONS } from './constants/translations';
import { Loader2, Wand2, AlertCircle, Layers, Sparkles, PenTool, Image as ImageIcon, Users, Calendar, X, CheckCircle, MapPin, Phone, MessageCircle, ScanFace, Timer, Bell, Lock, StopCircle, Maximize } from 'lucide-react';
// @ts-ignore
import confetti from 'canvas-confetti';
import { useUserStore, useUIStore, useGenerationStore } from './store';
import { FloatingConcierge } from './components/FloatingConcierge';
import { ExitIntentModal } from './components/ExitIntentModal';
import { ipService } from './services/ipService';
// Simple Toast Component
const Toast = ({ msg, onClose }: { msg: string, onClose: () => void }) => (
<div className="fixed top-24 left-1/2 -translate-x-1/2 z-[200] animate-fade-in-down pointer-events-none">
<div className="bg-gray-900/90 backdrop-blur text-white text-sm font-medium px-4 py-3 rounded-full shadow-2xl flex items-center gap-2 border border-white/10">
<CheckCircle className="w-4 h-4 text-green-400" />
{msg}
</div>
</div>
);
const App: React.FC = () => {
// --- ZUSTAND STORES ---
const {
currentUser, allUsers, leads, feedback, logs,
login, register, logout, updateUser, resetPassword, addPoints,
addLead, updateLeadStatus, deleteLead, addFeedback, addLog
} = useUserStore();
const {
language, adminConfig, modals, toastMsg,
setLanguage, setAdminConfig, toggleModal, showToast, closeAllModals
} = useUIStore();
const {
uploadedImages, selectedStyle, customStyleImage,
filter, blurAmount, compositionMode, resolution, subjectType, customPrompt,
results, status, progress, errorMsg,
isScanning, scanStep, recommendedStyleIds, analysisResult,
setUploadedImages, setSelectedStyle, setCustomStyleImage,
setGenerationConfig, setStatus, setProgress, setErrorMsg, addResult, setResults, resetGeneration,
startScanning, setScanStep, setAnalysisData, stopScanning
} = useGenerationStore();
const t = TRANSLATIONS[language];
// Lead Capture Local State (Ephemeral)
const [formState, setFormState] = useState<'idle'|'submitting'|'success'>('idle');
const [formData, setFormData] = useState({ name: '', phone: '', wechat: '', date: '', budget: '', service: '' });
// PDD Viral Local State
const [slashingStyle, setSlashingStyle] = useState<WeddingStyle | null>(null);
// Ticker State
const [tickerVisible, setTickerVisible] = useState(false);
const [tickerData, setTickerData] = useState({ name: '', style: '', time: '' });
// Countdown State
const [countdown, setCountdown] = useState("02:14:59");
const [rotatingTip, setRotatingTip] = useState(t.genTips[0]);
// Refs
const abortControllerRef = useRef<AbortController | null>(null);
const resultRef = useRef<HTMLDivElement>(null);
const adminTriggerCount = useRef(0);
// --- EFFECTS ---
// IP Tracking
useEffect(() => {
ipService.trackVisit();
}, []);
// Failsafe: Remove loading spinner when App mounts
useEffect(() => {
const spinner = document.getElementById('loading-spinner');
if (spinner) {
spinner.style.opacity = '0';
setTimeout(() => spinner.remove(), 500);
}
}, []);
useEffect(() => {
if (navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4) {
document.body.classList.add('low-power');
}
if (selectedStyle) {
document.title = `${t.title} - ${selectedStyle.name}`;
} else {
document.title = t.title + " - AI Fitting Room";
}
}, [selectedStyle, t.title]);
// Initial Red Packet check
useEffect(() => {
const hasSeenRP = localStorage.getItem('seen_rp_' + new Date().toDateString());
if (!hasSeenRP) {
setTimeout(() => {
if (!currentUser && !modals.auth) toggleModal('redPacket', true);
localStorage.setItem('seen_rp_' + new Date().toDateString(), 'true');
}, 2000);
}
}, []);
// Rotating Tips
useEffect(() => {
if (status === 'generating') {
let i = 0;
const interval = setInterval(() => {
i = (i + 1) % t.genTips.length;
setRotatingTip(t.genTips[i]);
}, 4000);
return () => clearInterval(interval);
}
}, [status, language]);
// Countdown
useEffect(() => {
const timer = setInterval(() => {
const now = new Date();
const targetTime = new Date(adminConfig.promoEnds).getTime() > now.getTime()
? new Date(adminConfig.promoEnds)
: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
const diff = targetTime.getTime() - now.getTime();
const hours = Math.floor((diff / (1000 * 60 * 60)));
const minutes = Math.floor((diff / (1000 * 60)) % 60);
const seconds = Math.floor((diff / 1000) % 60);
setCountdown(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
}, 1000);
return () => clearInterval(timer);
}, [adminConfig.promoEnds]);
// Ticker
useEffect(() => {
const names = ['李小姐', '王女士', 'Ms. Chen', 'Sarah', '张小姐', 'Jessica', '刘女士', 'Emily'];
const showTicker = () => {
const randomName = names[Math.floor(Math.random() * names.length)];
const randomStyle = STYLES[Math.floor(Math.random() * STYLES.length)];
const styleName = (TRANSLATIONS[language].styles as any)[randomStyle.id] || randomStyle.name;
const time = Math.floor(Math.random() * 5) + 1;
const timeStr = time === 1 ? TRANSLATIONS[language].tickerJustNow : `${time} ${TRANSLATIONS[language].tickerMinsAgo}`;
setTickerData({ name: randomName, style: styleName, time: timeStr });
setTickerVisible(true);
setTimeout(() => setTickerVisible(false), 5000);
};
const interval = setInterval(showTicker, 15000);
return () => clearInterval(interval);
}, [language]);
// --- HANDLERS ---
const handleLogin = (phone: string, password?: string) => {
const result = login(phone, password);
if (result.success) {
showToast(result.msg);
toggleModal('auth', false);
const user = allUsers.find(u => u.phone === phone);
if (user && (user.role === 'admin' || user.role === 'staff')) {
setTimeout(() => toggleModal('admin', true), 500);
}
} else {
alert(result.msg);
}
};
const handleRegister = (phone: string, name: string, pass: string) => {
const result = register(phone, name, pass);
if (result.success) {
showToast(result.msg);
} else {
showToast(result.msg);
}
};
const handlePasswordReset = async (phone: string, newPass: string) => {
return resetPassword(phone, newPass);
};
const handleAdminLoginSuccess = () => {
const adminUser: UserAccount = {
id: 'ADMIN_SUPER',
name: 'Administrator',
phone: '000-0000',
points: 999999,
isVip: true,
joinDate: Date.now(),
history: [],
role: 'admin'
};
// We hack the currentUser in store for session only
useUserStore.setState({ currentUser: adminUser });
showToast("Admin Mode Activated");
};
const trackStyleUsage = (styleId: string) => {
if (!currentUser) return;
const stats = currentUser.styleStats || {};
stats[styleId] = (stats[styleId] || 0) + 1;
updateUser(currentUser.id, { styleStats: stats });
// Log generation event
addLog({
styleId,
styleName: (TRANSLATIONS[language].styles as any)[styleId] || styleId,
userId: currentUser.id,
timestamp: Date.now(),
ip: ipService.currentIp,
location: ipService.currentLocation,
device: /Mobi|Android/i.test(navigator.userAgent) ? 'Mobile' : 'Desktop'
});
};
const handleBookClick = () => toggleModal('consult', true);
const handleAdminTrigger = () => {
adminTriggerCount.current += 1;
if (adminTriggerCount.current >= 5) {
toggleModal('admin', true);
adminTriggerCount.current = 0;
}
};
const handleSlashClick = (style: WeddingStyle) => {
setSlashingStyle(style);
toggleModal('slash', true);
};
const handleSlashUnlock = () => {
if (currentUser) {
updateUser(currentUser.id, { isVip: true });
showToast(t.pddSlashSuccess);
} else {
showToast("Please login first!");
toggleModal('auth', true);
}
};
const handleFeedbackSubmit = async (data: Omit<FeedbackItem, 'id' | 'timestamp'>) => {
const newItem: FeedbackItem = {
id: Date.now().toString(),
timestamp: Date.now(),
...data
};
addFeedback(newItem);
await new Promise(resolve => setTimeout(resolve, 800));
showToast(t.toastFeedback);
toggleModal('feedback', false);
};
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormState('submitting');
let preferredStyleName = 'Unknown';
let genCount = 0;
if (currentUser && currentUser.styleStats) {
const entries = Object.entries(currentUser.styleStats);
if (entries.length > 0) {
genCount = entries.reduce((sum, [_, count]) => sum + (count as number), 0);
const [topId, _] = entries.reduce((max, curr) => (curr[1] as number) > (max[1] as number) ? curr : max);
const topStyleObj = STYLES.find(s => s.id === topId);
if (topStyleObj) preferredStyleName = (TRANSLATIONS[language].styles as any)[topId] || topStyleObj.name;
}
}
const newLead: LeadData = {
id: Date.now().toString(),
userId: currentUser?.id,
name: formData.name,
phone: formData.phone,
wechat: formData.wechat,
service: formData.service,
budget: formData.budget,
date: formData.date,
timestamp: Date.now(),
status: 'new',
preferredStyle: preferredStyleName,
generationCount: genCount,
syncStatus: 'pending'
};
// CRM Sync Logic
try {
const syncResult = await crmService.syncLead(newLead, adminConfig);
if (syncResult.success) {
newLead.syncStatus = 'synced';
newLead.crmId = syncResult.crmId;
} else {
newLead.syncStatus = 'failed';
}
} catch (e) {
console.error("CRM Sync failed", e);
newLead.syncStatus = 'failed';
}
addLead(newLead);
setTimeout(() => {
setFormState('success');
if (currentUser && !currentUser.isVip) {
addPoints(adminConfig.pointsBook || 100, 'Booking Inquiry');
updateUser(currentUser.id, { isVip: true });
}
confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
setTimeout(() => {
toggleModal('consult', false);
setFormState('idle');
setFormData({ name: '', phone: '', wechat: '', date: '', budget: '', service: '' });
}, 3000);
}, 1500);
};
const handleImagesSelect = (base64Images: string[]) => {
setUploadedImages(base64Images);
setResults({});
setStatus('idle');
setErrorMsg(null);
setAnalysisData([], { faceShape: '', skinTone: '', bestVibe: '' });
if (base64Images.length > 0) {
startScanning();
setTimeout(() => setScanStep(1), 1000);
setTimeout(() => {
if (language === 'zh') {
setAnalysisData([], {
faceShape: "标准鹅蛋脸",
skinTone: "暖白皮 / 象牙色",
bestVibe: "法式浪漫 & 韩式唯美"
});
} else {
setAnalysisData([], {
faceShape: "Oval (Goose Egg)",
skinTone: "Warm Porcelain",
bestVibe: "Romantic & Elegant"
});
}
setScanStep(2);
}, 2500);
setTimeout(() => {
stopScanning();
const hotStyles = STYLES.filter(s => s.tags?.includes('hot'));
const otherStyles = STYLES.filter(s => !s.tags?.includes('hot'));
const randomHot = hotStyles.sort(() => 0.5 - Math.random()).slice(0, 1);
const randomOthers = otherStyles.sort(() => 0.5 - Math.random()).slice(0, 2);
const finalRecs = [...randomHot, ...randomOthers].map(s => s.id);
setAnalysisData(finalRecs, {
faceShape: language === 'zh' ? "标准鹅蛋脸" : "Oval",
skinTone: language === 'zh' ? "暖白皮" : "Warm",
bestVibe: language === 'zh' ? "法式浪漫" : "Romantic"
});
toggleModal('analysis', true);
}, 3500);
}
};
const handleStyleSelect = (style: WeddingStyle) => {
if (currentUser?.role === 'admin') {
setSelectedStyle(style);
setCustomStyleImage(null);
return;
}
if (style.isLocked && (!currentUser || !currentUser.isVip)) {
if (!currentUser) toggleModal('auth', true);
else toggleModal('consult', true);
return;
}
setSelectedStyle(style);
setCustomStyleImage(null);
};
const handleCustomStyleSelect = (base64Style: string) => {
setCustomStyleImage(base64Style);
const customStyle: WeddingStyle = {
id: 'custom',
name: t.customStyle,
prompt: 'Custom style from reference image',
description: 'Using your uploaded photo as style guide',
coverColor: 'bg-rose-100',
icon: <ImageIcon className="w-5 h-5 text-rose-500" />,
isCustom: true
};
setSelectedStyle(customStyle);
};
const handleLuckySelect = () => {
const isVip = currentUser?.isVip || currentUser?.role === 'admin' || false;
// Filter available styles: if not VIP, exclude locked styles
const availableStyles = STYLES.filter(s => !s.isLocked || isVip);
if (availableStyles.length > 0) {
const randomStyle = availableStyles[Math.floor(Math.random() * availableStyles.length)];
handleStyleSelect(randomStyle);
// Show Toast
const styleName = (TRANSLATIONS[language].styles as any)[randomStyle.id] || randomStyle.name;
showToast(`✨ Lucky Pick: ${styleName}!`);
// Trigger confetti for fun
confetti({
particleCount: 50,
spread: 60,
origin: { y: 0.7 },
colors: ['#fbbf24', '#f43f5e']
});
}
};
const handleReset = () => {
resetGeneration();
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const generateSingleStyle = async (images: string[], style: WeddingStyle, customPromptText: string) => {
try {
const referenceImage = style.id === 'custom' ? customStyleImage : null;
const generated = await generateStyledWeddingImage(images, style.prompt, customPromptText, referenceImage, compositionMode, resolution, subjectType);
if (style.id !== 'custom') trackStyleUsage(style.id);
const dateKey = new Date().toDateString();
const hasGenToday = localStorage.getItem('gen_today_' + dateKey);
if (!hasGenToday) {
addPoints(50, 'Daily First Look');
localStorage.setItem('gen_today_' + dateKey, 'true');
}
return {
styleId: style.id,
imageUrl: generated,
timestamp: Date.now(),
config: {
customInstruction: customPromptText,
filter: filter,
blurAmount: blurAmount,
compositionMode: compositionMode,
resolution: resolution,
subjectType: subjectType
}
};
} catch (e) {
console.error(`Failed to generate style ${style.name}`, e);
throw e;
}
};
const handleGenerateSingle = async () => {
if (uploadedImages.length === 0 || !selectedStyle) return;
setStatus('generating');
setErrorMsg(null);
setProgress({ current: 0, total: 1, statusMsg: "Initializing AI Designer..." });
setTimeout(() => { resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100);
try {
setTimeout(() => setProgress({ current: 0, total: 1, statusMsg: "Analyzing facial features..." }), 500);
setTimeout(() => setProgress({ current: 0, total: 1, statusMsg: "Applying wedding style..." }), 1500);
const result = await generateSingleStyle(uploadedImages, selectedStyle, customPrompt);
if (result) {
addResult(result);
setStatus('success');
setProgress(null);
} else {
throw new Error("Generation failed");
}
} catch (error) {
setStatus('error');
setErrorMsg("Failed to generate image. Please try again.");
setProgress(null);
}
};
const stopGeneration = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setStatus('idle');
setProgress(null);
showToast("Generation stopped.");
};
const handleGenerateAll = () => {
if (uploadedImages.length === 0) return;
setStatus('generating');
setErrorMsg(null);
showToast("Starting Batch Generation...");
setProgress({ current: 0, total: STYLES.length, statusMsg: "Initializing Batch Engine..." });
setTimeout(async () => {
try {
resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
const isSuperAdmin = currentUser?.role === 'admin';
const isVip = currentUser?.isVip || isSuperAdmin;
if (!isSuperAdmin && !isVip) {
const hasLocked = STYLES.some(s => s.isLocked);
if(hasLocked) {
setStatus('idle');
setProgress(null);
if(!currentUser) toggleModal('auth', true);
else toggleModal('consult', true);
return;
}
}
abortControllerRef.current = new AbortController();
const presetStyles = STYLES;
const total = presetStyles.length;
let successCount = 0;
const baseDelay = resolution === 'ultra' ? 6000 : resolution === 'high' ? 4000 : 2500;
for (let i = 0; i < presetStyles.length; i++) {
if (abortControllerRef.current?.signal.aborted) break;
const style = presetStyles[i];
const styleName = (TRANSLATIONS[language].styles as any)[style.id] || style.name;
if (results[style.id]) {
successCount++;
setProgress({ current: i + 1, total, statusMsg: `Skipping ${styleName} (Done)` });
continue;
}
let attempts = 0;
let success = false;
while (attempts < 3 && !success) {
if (abortControllerRef.current?.signal.aborted) break;
try {
setProgress({ current: i + 1, total, statusMsg: `Designing: ${styleName}...` });
const result = await generateSingleStyle(uploadedImages, style, customPrompt);
if (result) {
addResult(result);
successCount++;
success = true;
}
} catch (e: any) {
attempts++;
console.warn(`Failed ${styleName} attempt ${attempts}`, e);
const isRateLimit = e?.message?.includes('429');
const waitTime = isRateLimit ? 5000 * attempts : 1500;
setProgress({ current: i + 1, total, statusMsg: `Cooling down (Retrying)...` });
await new Promise(r => setTimeout(r, waitTime));
}
}
if (i < presetStyles.length - 1) {
setProgress({ current: i + 1, total, statusMsg: `Success! Preparing next style...` });
await new Promise(r => setTimeout(r, baseDelay));
}
}
setStatus(successCount > 0 ? 'success' : 'error');
if (successCount === 0) setErrorMsg("Batch generation failed. Please check network.");
} catch (err) {
console.error(err);
setStatus('error');
setErrorMsg("System error during batch.");
} finally {
abortControllerRef.current = null;
setProgress(null);
}
}, 100);
};
const hasResults = Object.keys(results).length > 0;
const activeFilterCss = FILTERS.find(f => f.id === filter)?.css || 'none';
const hasUploaded = uploadedImages.length > 0;
return (
<div className="min-h-screen bg-white font-sans text-gray-900 selection:bg-rose-100 selection:text-rose-900 flex flex-col relative overflow-x-hidden">
{toastMsg && <Toast msg={toastMsg} onClose={() => {}} />}
<FloatingConcierge language={language} config={adminConfig} />
<ExitIntentModal language={language} onClose={() => {}} />
<Suspense fallback={null}>
<AdminDashboard
isVisible={modals.admin}
onClose={() => toggleModal('admin', false)}
language={language}
leads={leads}
feedback={feedback}
config={adminConfig}
allUsers={allUsers}
currentUser={currentUser}
onUpdateConfig={setAdminConfig}
onUpdateLeadStatus={updateLeadStatus}
onDeleteLead={deleteLead}
onUpdateUser={updateUser}
showToast={showToast}
onAdminLoginSuccess={handleAdminLoginSuccess}
logs={logs}
/>
</Suspense>
{currentUser && (
<UserCenter
isVisible={modals.userCenter}
onClose={() => toggleModal('userCenter', false)}
language={language}
user={currentUser}
leads={leads}
config={adminConfig}
onRedeem={() => {
const cost = adminConfig.pointsVipCost || 100;
if (currentUser.points >= cost) {
updateUser(currentUser.id, { points: currentUser.points - cost, isVip: true });
showToast("VIP Unlocked!");
}
}}
showToast={showToast}
onAddPoints={addPoints}
/>
)}
<AuthModal
isVisible={modals.auth}
onClose={() => toggleModal('auth', false)}
language={language}
onLogin={handleLogin}
onRegister={handleRegister}
onResetPassword={handlePasswordReset}
/>
<AboutModal
isVisible={modals.about}
onClose={() => toggleModal('about', false)}
language={language}
config={adminConfig}
/>
<AnalysisModal
isVisible={modals.analysis}
onClose={() => toggleModal('analysis', false)}
language={language}
result={analysisResult}
onUnlock={() => toggleModal('analysis', false)}
/>
<RedPacketModal
isVisible={modals.redPacket}
onClose={() => toggleModal('redPacket', false)}
language={language}
adminConfig={adminConfig}
user={currentUser}
onUpdateUser={(bal) => currentUser && updateUser(currentUser.id, { redPacketBalance: bal })}
onOpenShare={() => toggleModal('share', true)}
/>
<SlashModal
isVisible={modals.slash}
onClose={() => toggleModal('slash', false)}
style={slashingStyle}
onUnlock={handleSlashUnlock}
language={language}
adminConfig={adminConfig}
user={currentUser}
onUpdateUser={(prog) => currentUser && updateUser(currentUser.id, { slashProgress: prog })}
onOpenShare={() => toggleModal('share', true)}
/>
<ShareModal
isVisible={modals.share}
onClose={() => toggleModal('share', false)}
language={language}
config={adminConfig.shareConfig}
showToast={showToast}
onShareSuccess={() => addPoints(adminConfig.pointsShare || 10, 'Viral Share')}
/>
<FeedbackModal
isVisible={modals.feedback}
onClose={() => toggleModal('feedback', false)}
language={language}
user={currentUser}
onSubmit={handleFeedbackSubmit}
/>
{isScanning && (
<div className="fixed inset-0 z-[70] bg-black/95 flex flex-col items-center justify-center text-white p-4">
<div className="relative w-64 h-64 sm:w-80 sm:h-80 border-2 border-rose-500/50 rounded-full flex items-center justify-center overflow-hidden mb-8 shadow-[0_0_50px_rgba(244,63,94,0.4)]">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-rose-500/20 to-transparent w-full h-full animate-scan" style={{animationDuration: '1.5s'}}></div>
<div className="absolute inset-0 border-4 border-rose-500 rounded-full animate-ping opacity-20"></div>
{uploadedImages[0] && <img src={uploadedImages[0]} className="w-full h-full object-cover opacity-50 grayscale" />}
<ScanFace className="absolute w-16 h-16 text-rose-500 animate-pulse" />
<div className="absolute top-10 left-10 w-2 h-2 bg-rose-400 rounded-full animate-ping"></div>
<div className="absolute bottom-10 right-10 w-2 h-2 bg-rose-400 rounded-full animate-ping" style={{animationDelay: '0.3s'}}></div>
</div>
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold font-serif text-rose-300 tracking-wider">
{scanStep === 0 ? t.aiScanning : scanStep === 1 ? t.aiAnalyzing : t.aiMatching}
</h2>
<p className="text-gray-400 text-sm font-mono tracking-widest">SYSTEM ANALYSIS v2.4.0</p>
</div>
<div className="mt-8 w-64 h-1 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-rose-500 transition-all duration-[3500ms] ease-linear w-full shadow-[0_0_10px_#f43f5e]"></div>
</div>
</div>
)}
<div className={`fixed bottom-24 left-4 z-40 bg-white/90 backdrop-blur border border-rose-100 shadow-xl rounded-full px-4 py-3 flex items-center gap-3 transition-all duration-500 transform ${tickerVisible ? 'translate-y-0 opacity-100' : 'translate-y-8 opacity-0 pointer-events-none'}`}>
<div className="w-8 h-8 rounded-full bg-rose-100 flex items-center justify-center">
<Bell className="w-4 h-4 text-rose-500 animate-swing" />
</div>
<div className="text-xs">
<p className="font-bold text-gray-800">
<span className="text-rose-600">{tickerData.name}</span> {t.tickerBooked}
</p>
<p className="text-gray-500 flex items-center gap-1">
{tickerData.style} • {tickerData.time}
</p>
</div>
</div>
{adminConfig.showBanner && (
<div className="bg-gradient-to-r from-rose-600 to-rose-500 text-white text-xs sm:text-sm py-2 px-4 flex items-center justify-center gap-2 sm:gap-4 font-medium relative animate-fade-in flex-wrap shadow-md z-[60]">
<span className="text-center drop-shadow-sm">{adminConfig.promoText}</span>
<div className="flex items-center gap-1.5 bg-rose-800/40 px-2 py-0.5 rounded-lg border border-rose-400/30">
<Timer className="w-3.5 h-3.5 text-rose-200" />
<span className="font-mono font-bold text-rose-100 tracking-wide">{t.promoEnds} {countdown}</span>
</div>
<button onClick={() => setAdminConfig({...adminConfig, showBanner: false})} className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-rose-700/50 rounded-full transition-colors">
<X className="w-3 h-3" />
</button>
</div>
)}
<Header
language={language}
onLanguageChange={setLanguage}
onBookClick={handleBookClick}
user={currentUser}
config={adminConfig}
onOpenUserCenter={() => toggleModal('userCenter', true)}
onLoginClick={() => toggleModal('auth', true)}
onOpenAdmin={() => toggleModal('admin', true)}
onOpenAbout={() => toggleModal('about', true)}
onOpenFeedback={() => toggleModal('feedback', true)}
/>
<button
onClick={handleBookClick}
className="fixed bottom-6 right-6 z-40 bg-gradient-to-r from-rose-500 to-rose-600 text-white rounded-full p-4 shadow-xl shadow-rose-300 transition-transform hover:scale-110 active:scale-95 animate-bounce-slow group"
>
<MessageCircle className="w-6 h-6" />
<span className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-gray-900 text-white text-xs font-bold px-3 py-1.5 rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
{t.floatConsult}
</span>
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
</button>
{modals.consult && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden relative">
<button onClick={() => toggleModal('consult', false)} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 p-1">
<X className="w-5 h-5" />
</button>
<div className="p-6">
{formState === 'success' ? (
<div className="flex flex-col items-center justify-center py-8 text-center space-y-4">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center text-green-600 mb-2">
<CheckCircle className="w-10 h-10" />
</div>
<h3 className="text-xl font-bold text-gray-800">{t.formSuccess}</h3>
<p className="text-gray-500 text-sm">We will contact you shortly.</p>
</div>
) : (
<>
<div className="text-center mb-6">
{(!currentUser?.isVip) ? (
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center text-amber-600 mx-auto mb-3 animate-pulse">
<Lock className="w-6 h-6" />
</div>
) : (
<div className="w-12 h-12 bg-rose-100 rounded-full flex items-center justify-center text-rose-600 mx-auto mb-3">
<Calendar className="w-6 h-6" />
</div>
)}
<h3 className="text-xl font-bold text-gray-900">{(!currentUser?.isVip) ? t.vipUnlockTitle : t.consultTitle}</h3>
<p className="text-sm text-gray-500 mt-1">{(!currentUser?.isVip) ? t.vipUnlockDesc : t.consultDesc}</p>
</div>
<form onSubmit={handleFormSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formName} *</label>
<input required type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 focus:border-rose-500 outline-none" />
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formPhone} *</label>
<input required type="tel" value={formData.phone} onChange={e => setFormData({...formData, phone: e.target.value})} className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 focus:border-rose-500 outline-none" />
</div>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formWeChat}</label>
<input type="text" value={formData.wechat} onChange={e => setFormData({...formData, wechat: e.target.value})} className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 focus:border-rose-500 outline-none" />
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formService}</label>
<select value={formData.service} onChange={e => setFormData({...formData, service: e.target.value})} className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 bg-white focus:border-rose-500 outline-none">
<option value="">- Select -</option>
<option value="wedding">{t.service1}</option>
<option value="art">{t.service2}</option>
<option value="family">{t.service3}</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formBudget}</label>
<select value={formData.budget} onChange={e => setFormData({...formData, budget: e.target.value})} className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 bg-white focus:border-rose-500 outline-none">
<option value="">- Select -</option>
<option value="1">{t.budget1}</option>
<option value="2">{t.budget2}</option>
<option value="3">{t.budget3}</option>
<option value="4">{t.budget4}</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formDate}</label>
<input type="date" value={formData.date} onChange={e => setFormData({...formData, date: e.target.value})} className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 focus:border-rose-500 outline-none" />
</div>
<button type="submit" disabled={formState === 'submitting'} className="w-full py-3 rounded-xl bg-gray-900 text-white font-bold shadow-lg hover:bg-black transition-all flex items-center justify-center gap-2 mt-4">
{formState === 'submitting' && <Loader2 className="w-4 h-4 animate-spin" />}
{t.formSubmit}
</button>
</form>
</>
)}
</div>
</div>
</div>
)}
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-8 sm:py-12 flex-grow w-full">
<div className="text-center mb-8 sm:mb-12 max-w-2xl mx-auto animate-fade-in">
<h2 className="font-serif text-3xl sm:text-5xl font-bold text-gray-900 mb-4 leading-tight">
{t.heroTitle}
</h2>
<p className="text-sm sm:text-lg text-gray-600">
{t.heroDesc}
</p>
</div>
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 items-start">
<div className="lg:col-span-5 space-y-8">
<section className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-rose-100 text-rose-600 font-bold text-sm">1</span>
<h3 className="text-lg sm:text-xl font-bold">{t.step1}</h3>
</div>
<ImageUploader onImagesSelect={handleImagesSelect} currentImages={uploadedImages} disabled={status === 'generating'} language={language} />
</section>
<section className={`space-y-4 transition-opacity duration-500 ${!hasUploaded ? 'opacity-40 pointer-events-none' : 'opacity-100'}`}>
<div className="flex items-center gap-3 mb-2 justify-between">
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-rose-100 text-rose-600 font-bold text-sm">2</span>
<h3 className="text-lg sm:text-xl font-bold">{t.step2}</h3>
</div>
{selectedStyle && <button onClick={() => { setSelectedStyle(null); setCustomStyleImage(null); }} className="text-xs text-rose-500 hover:underline">{t.clearSelection}</button>}
</div>
<div className="max-h-[350px] overflow-y-auto pr-2 custom-scrollbar">
<StyleSelector
selectedStyle={selectedStyle}
onSelect={handleStyleSelect}
onCustomSelect={handleCustomStyleSelect}
onLuckySelect={handleLuckySelect}
onSlashClick={handleSlashClick}
disabled={status === 'generating'}
language={language}
recommendedStyleIds={recommendedStyleIds}
isVipUnlocked={currentUser?.isVip}
resolution={resolution}
onResolutionChange={(res) => setGenerationConfig({ resolution: res })}
/>
</div>
{customStyleImage && (
<div className="flex items-center gap-3 p-3 bg-rose-50 rounded-xl border border-rose-100">
<div className="w-12 h-12 rounded-lg overflow-hidden border border-rose-200 shrink-0"><img src={customStyleImage} alt="Reference" className="w-full h-full object-cover" /></div>
<div className="flex-1"><p className="text-sm font-bold text-gray-800">{t.usingCustom}</p><p className="text-xs text-gray-500">{t.usingCustomDesc}</p></div>
<button onClick={() => { setCustomStyleImage(null); setSelectedStyle(null); }} className="p-2 hover:bg-rose-100 rounded-full text-rose-500"><Sparkles className="w-4 h-4" /></button>
</div>
)}
</section>
<section className={`space-y-4 transition-opacity duration-500 ${!hasUploaded ? 'opacity-40 pointer-events-none' : 'opacity-100'}`}>
<div className="flex items-center gap-3 mb-2">
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-rose-100 text-rose-600 font-bold text-sm">3</span>
<h3 className="text-lg sm:text-xl font-bold">{t.step3}</h3>
</div>
<div className="bg-gray-50 rounded-2xl border border-gray-200 p-5 space-y-5">
<div>
<div className="flex items-center gap-2 mb-2"><Users className="w-4 h-4 text-gray-500" /><label className="text-sm font-bold text-gray-700">{t.subjType}</label></div>
<div className="grid grid-cols-3 gap-2">
<button onClick={() => setGenerationConfig({ subjectType: 'female' })} className={`px-2 py-2 text-xs rounded-lg border font-medium transition-all ${subjectType === 'female' ? 'bg-rose-50 border-rose-500 text-rose-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}>{t.subjFemale}</button>
<button onClick={() => setGenerationConfig({ subjectType: 'male' })} className={`px-2 py-2 text-xs rounded-lg border font-medium transition-all ${subjectType === 'male' ? 'bg-rose-50 border-rose-500 text-rose-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}>{t.subjMale}</button>
<button onClick={() => setGenerationConfig({ subjectType: 'couple' })} className={`px-2 py-2 text-xs rounded-lg border font-medium transition-all ${subjectType === 'couple' ? 'bg-rose-50 border-rose-500 text-rose-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}>{t.subjCouple}</button>
<button onClick={() => setGenerationConfig({ subjectType: 'child' })} className={`px-2 py-2 text-xs rounded-lg border font-medium transition-all ${subjectType === 'child' ? 'bg-rose-50 border-rose-500 text-rose-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}>{t.subjChild}</button>
<button onClick={() => setGenerationConfig({ subjectType: 'family' })} className={`px-2 py-2 text-xs rounded-lg border font-medium transition-all ${subjectType === 'family' ? 'bg-rose-50 border-rose-500 text-rose-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}>{t.subjFamily}</button>
</div>
</div>
<div className="border-t border-gray-200 my-4"></div>
<div><FilterSelector selectedFilter={filter} onSelect={(f) => setGenerationConfig({ filter: f })} blurAmount={blurAmount} onBlurChange={(b) => setGenerationConfig({ blurAmount: b })} disabled={status === 'generating'} language={language} /></div>
<div className="border-t border-gray-200 my-4"></div>
{(uploadedImages.length > 1 || subjectType === 'family' || subjectType === 'couple') && (
<div className="animate-fade-in">
<div className="flex items-center gap-2 mb-3"><Users className="w-4 h-4 text-gray-500" /><label className="text-sm font-bold text-gray-700">{t.groupComp}</label></div>
<div className="grid grid-cols-3 gap-2">
<button onClick={() => setGenerationConfig({ compositionMode: 'classic' })} className={`px-2 py-2 text-xs rounded-lg border font-medium transition-all ${compositionMode === 'classic' ? 'bg-rose-50 border-rose-500 text-rose-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}>{t.compClassic}</button>
<button onClick={() => setGenerationConfig({ compositionMode: 'dynamic' })} className={`px-2 py-2 text-xs rounded-lg border font-medium transition-all ${compositionMode === 'dynamic' ? 'bg-rose-50 border-rose-500 text-rose-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}>{t.compDynamic}</button>
<button onClick={() => setGenerationConfig({ compositionMode: 'cinematic' })} className={`px-2 py-2 text-xs rounded-lg border font-medium transition-all ${compositionMode === 'cinematic' ? 'bg-rose-50 border-rose-500 text-rose-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'}`}>{t.compCinematic}</button>
</div>
<div className="border-t border-gray-200 my-4"></div>
</div>
)}
<div>
<div className="flex items-center gap-2 mb-2"><PenTool className="w-4 h-4 text-gray-500" /><label className="text-sm font-bold text-gray-700">{t.customInstruct}</label></div>
<textarea value={customPrompt} onChange={(e) => setGenerationConfig({ customPrompt: e.target.value })} placeholder={uploadedImages.length > 1 ? "Describe how you want the group to be arranged..." : t.customInstructPlaceholder} className="w-full px-4 py-3 text-sm rounded-xl border border-gray-200 focus:border-rose-500 focus:ring-2 focus:ring-rose-200 outline-none transition-all resize-none h-24 bg-white shadow-sm placeholder:text-gray-400" disabled={status === 'generating'} />
<p className="text-[10px] text-gray-500 mt-2 flex items-center gap-1.5"><Sparkles className="w-3 h-3 text-rose-400" />{t.customInstructHint}</p>
</div>
</div>
</section>
<div className={`pt-4 flex flex-col gap-3 transition-opacity duration-500 ${!hasUploaded ? 'opacity-50' : 'opacity-100'}`}>
{errorMsg && <div className="p-3 bg-red-50 text-red-600 rounded-lg flex items-center gap-2 text-sm"><AlertCircle className="w-4 h-4 shrink-0" />{errorMsg}</div>}
{status === 'generating' && progress && (
<div className="space-y-3 bg-white p-4 rounded-xl shadow-lg border border-rose-100 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-rose-500/5 rounded-full blur-2xl animate-pulse"></div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-rose-50 flex items-center justify-center shrink-0">
<Loader2 className="w-5 h-5 text-rose-500 animate-spin" />
</div>
<div className="flex-1">
<p className="text-sm font-bold text-gray-800 animate-fade-in">{progress.statusMsg}</p>
<p className="text-xs text-rose-400 italic mt-0.5">{rotatingTip}</p>
</div>
<div className="text-right">
<span className="text-xl font-bold text-gray-900">{Math.round((progress.current / progress.total) * 100)}%</span>
</div>
</div>
<div className="h-2 w-full bg-gray-100 rounded-full overflow-hidden mt-2">
<div className="h-full bg-gradient-to-r from-rose-400 to-rose-600 transition-all duration-300 relative" style={{width: `${(progress.current / progress.total) * 100}%`}}>
<div className="absolute top-0 right-0 h-full w-2 bg-white/50 animate-pulse"></div>
</div>
</div>
<button onClick={stopGeneration} className="w-full mt-2 text-[10px] text-gray-400 hover:text-red-500 flex items-center justify-center gap-1">
<StopCircle className="w-3 h-3" /> Stop Generation
</button>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button onClick={handleGenerateSingle} disabled={!hasUploaded || !selectedStyle || status === 'generating'} className={`flex items-center justify-center gap-2 px-6 py-3 rounded-xl font-bold text-white shadow-lg transition-all duration-200 ${!hasUploaded || !selectedStyle || status === 'generating' ? 'bg-gray-300 text-gray-500 cursor-not-allowed shadow-none' : 'bg-gray-900 hover:bg-black hover:scale-[1.02]'}`}>
<Wand2 className="w-4 h-4" />
<span>{selectedStyle?.id === 'custom' ? t.genCustom : t.genSelected}</span>
</button>
<button onClick={handleGenerateAll} disabled={!hasUploaded || status === 'generating'} className={`relative overflow-hidden group flex items-center justify-center gap-2 px-6 py-3 rounded-xl font-bold text-white shadow-lg shadow-rose-200 transition-all duration-200 ${!hasUploaded || status === 'generating' ? 'bg-rose-200 text-white cursor-not-allowed shadow-none opacity-70' : 'bg-gradient-to-r from-rose-500 to-rose-600 hover:scale-[1.02] hover:shadow-rose-300'}`}>
<Layers className="w-4 h-4" />
<span>{t.genAll}</span>
</button>
</div>
</div>
</div>
<div className="lg:col-span-7" ref={resultRef}>
{hasResults ? (
<ResultViewer
originalImages={uploadedImages}
results={results}
initialSelectedId={selectedStyle?.id}
onReset={handleReset}
activeFilter={activeFilterCss}
blurAmount={blurAmount}
language={language}
onBookClick={handleBookClick}
onShareClick={() => toggleModal('share', true)}
/>
) : (
<div className="h-full min-h-[400px] rounded-3xl border-2 border-dashed border-gray-100 flex flex-col items-center justify-center text-center p-8 bg-gray-50/50">
<div className="w-16 h-16 bg-white rounded-2xl flex items-center justify-center shadow-sm mb-4"><Sparkles className="w-8 h-8 text-gray-300" /></div>
<h3 className="text-xl font-serif font-bold text-gray-300">{t.emptyGallery}</h3>
<p className="text-gray-400 mt-2 max-w-xs text-sm">{t.emptyGalleryDesc}</p>
</div>
)}
</div>
</div>
</main>
<Features language={language} />
<Testimonials language={language} />
<footer className="bg-gray-900 text-white py-8 border-t border-gray-800">
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="text-center md:text-left">
<h3 className="font-serif font-bold text-xl">{t.posterBrand}</h3>
<div className="flex gap-4 justify-center md:justify-start mt-2">
<button onClick={() => toggleModal('about', true)} className="text-xs text-gray-400 hover:text-rose-400 transition-colors">
{t.aboutUsBtn}
</button>
<p className="text-xs text-gray-500 cursor-default select-none active:text-gray-400" onClick={handleAdminTrigger}>
{t.footerRights}
</p>
</div>
</div>
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-12">
<div className="flex flex-col gap-2 text-sm text-gray-300 md:text-right">
<p className="flex items-center gap-2 justify-center md:justify-end"><MapPin className="w-4 h-4 text-rose-500" /> {adminConfig.footerAddress}</p>
<p className="flex items-center gap-2 justify-center md:justify-end"><Phone className="w-4 h-4 text-rose-500" /> {adminConfig.contactPhone}</p>
</div>
<div className="flex flex-col items-center gap-2 group cursor-pointer" onClick={() => window.open(adminConfig.qrCodeUrl || 'https://romantic-life.com/book', '_blank')}>
<div className="bg-white p-2 rounded-xl shadow-lg border-2 border-rose-500/30 group-hover:border-rose-500 group-hover:scale-105 transition-all duration-300">
<img src={adminConfig.qrCodeUrl || "https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=https://romantic-life.com/book"} alt="Book QR" className="w-20 h-20 sm:w-24 sm:h-24 object-cover" />
</div>
<span className="text-[10px] text-rose-200 uppercase font-bold tracking-wider group-hover:text-rose-400 transition-colors">{t.posterTitle}</span>
</div>
</div>
</div>
</footer>
</div>
);
};
export default App;