|
|
| 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'; |
| |
| 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'; |
| |
| 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'; |
|
|
| |
| 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 = () => { |
| |
| 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]; |
|
|
| |
| const [formState, setFormState] = useState<'idle'|'submitting'|'success'>('idle'); |
| const [formData, setFormData] = useState({ name: '', phone: '', wechat: '', date: '', budget: '', service: '' }); |
| |
| |
| const [slashingStyle, setSlashingStyle] = useState<WeddingStyle | null>(null); |
| |
| |
| const [tickerVisible, setTickerVisible] = useState(false); |
| const [tickerData, setTickerData] = useState({ name: '', style: '', time: '' }); |
| |
| |
| const [countdown, setCountdown] = useState("02:14:59"); |
| const [rotatingTip, setRotatingTip] = useState(t.genTips[0]); |
|
|
| |
| const abortControllerRef = useRef<AbortController | null>(null); |
| const resultRef = useRef<HTMLDivElement>(null); |
| const adminTriggerCount = useRef(0); |
|
|
| |
| |
| |
| useEffect(() => { |
| ipService.trackVisit(); |
| }, []); |
|
|
| |
| 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]); |
|
|
| |
| 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); |
| } |
| }, []); |
|
|
| |
| 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]); |
|
|
| |
| 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]); |
|
|
| |
| 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]); |
|
|
| |
|
|
| 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' |
| }; |
| |
| 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 }); |
| |
| |
| 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' |
| }; |
|
|
| |
| 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; |
| |
| const availableStyles = STYLES.filter(s => !s.isLocked || isVip); |
| |
| if (availableStyles.length > 0) { |
| const randomStyle = availableStyles[Math.floor(Math.random() * availableStyles.length)]; |
| handleStyleSelect(randomStyle); |
| |
| |
| const styleName = (TRANSLATIONS[language].styles as any)[randomStyle.id] || randomStyle.name; |
| showToast(`✨ Lucky Pick: ${styleName}!`); |
| |
| |
| 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; |
|
|