Spaces:
Paused
Paused
Upload 71 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- App.tsx +1064 -0
- README.md +49 -6
- components/._AboutModal.tsx +0 -0
- components/._AdminDashboard.tsx +0 -0
- components/._AnalysisModal.tsx +0 -0
- components/._AuthModal.tsx +0 -0
- components/._ErrorBoundary.tsx +0 -0
- components/._ExitIntentModal.tsx +0 -0
- components/._Features.tsx +0 -0
- components/._FeedbackModal.tsx +0 -0
- components/._FilterSelector.tsx +0 -0
- components/._FloatingConcierge.tsx +0 -0
- components/._Header.tsx +0 -0
- components/._ImageUploader.tsx +0 -0
- components/._RedPacketModal.tsx +0 -0
- components/._ResultViewer.tsx +0 -0
- components/._ShareModal.tsx +0 -0
- components/._SlashModal.tsx +0 -0
- components/._StyleSelector.tsx +0 -0
- components/._Testimonials.tsx +0 -0
- components/._UserCenter.tsx +0 -0
- components/AboutModal.tsx +103 -0
- components/AdminDashboard.tsx +360 -0
- components/AnalysisModal.tsx +86 -0
- components/AuthModal.tsx +242 -0
- components/ErrorBoundary.tsx +52 -0
- components/ExitIntentModal.tsx +78 -0
- components/Features.tsx +60 -0
- components/FeedbackModal.tsx +146 -0
- components/FilterSelector.tsx +102 -0
- components/FloatingConcierge.tsx +82 -0
- components/Header.tsx +139 -0
- components/ImageUploader.tsx +128 -0
- components/RedPacketModal.tsx +135 -0
- components/ResultViewer.tsx +762 -0
- components/ShareModal.tsx +143 -0
- components/SlashModal.tsx +135 -0
- components/StyleSelector.tsx +1318 -0
- components/Testimonials.tsx +72 -0
- components/UserCenter.tsx +232 -0
- constants/._translations.ts +0 -0
- constants/translations.ts +965 -0
- index.css +48 -0
- index.html +45 -0
- index.tsx +32 -0
- manifest.json +24 -0
- metadata.json +5 -0
- nginx.conf +0 -0
- package.json +32 -0
- postcss.config.js +6 -0
App.tsx
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useRef, useEffect, Suspense } from 'react';
|
| 3 |
+
import { Header } from './components/Header';
|
| 4 |
+
import { ImageUploader } from './components/ImageUploader';
|
| 5 |
+
import { StyleSelector, STYLES } from './components/StyleSelector';
|
| 6 |
+
import { ResultViewer } from './components/ResultViewer';
|
| 7 |
+
import { FilterSelector, FILTERS } from './components/FilterSelector';
|
| 8 |
+
import { Features } from './components/Features';
|
| 9 |
+
import { Testimonials } from './components/Testimonials';
|
| 10 |
+
// Lazy load AdminDashboard
|
| 11 |
+
const AdminDashboard = React.lazy(() => import('./components/AdminDashboard').then(module => ({ default: module.AdminDashboard })));
|
| 12 |
+
import { UserCenter } from './components/UserCenter';
|
| 13 |
+
import { AuthModal } from './components/AuthModal';
|
| 14 |
+
import { AboutModal } from './components/AboutModal';
|
| 15 |
+
import { RedPacketModal } from './components/RedPacketModal';
|
| 16 |
+
import { SlashModal } from './components/SlashModal';
|
| 17 |
+
import { ShareModal } from './components/ShareModal';
|
| 18 |
+
import { AnalysisModal } from './components/AnalysisModal';
|
| 19 |
+
import { FeedbackModal } from './components/FeedbackModal';
|
| 20 |
+
import { generateStyledWeddingImage } from './services/geminiService';
|
| 21 |
+
import { crmService } from './services/crmService';
|
| 22 |
+
import { WeddingStyle, LeadData, UserAccount, FeedbackItem } from './types';
|
| 23 |
+
import { TRANSLATIONS } from './constants/translations';
|
| 24 |
+
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';
|
| 25 |
+
// @ts-ignore
|
| 26 |
+
import confetti from 'canvas-confetti';
|
| 27 |
+
import { useUserStore, useUIStore, useGenerationStore } from './store';
|
| 28 |
+
import { FloatingConcierge } from './components/FloatingConcierge';
|
| 29 |
+
import { ExitIntentModal } from './components/ExitIntentModal';
|
| 30 |
+
import { ipService } from './services/ipService';
|
| 31 |
+
|
| 32 |
+
// Simple Toast Component
|
| 33 |
+
const Toast = ({ msg, onClose }: { msg: string, onClose: () => void }) => (
|
| 34 |
+
<div className="fixed top-24 left-1/2 -translate-x-1/2 z-[200] animate-fade-in-down pointer-events-none">
|
| 35 |
+
<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">
|
| 36 |
+
<CheckCircle className="w-4 h-4 text-green-400" />
|
| 37 |
+
{msg}
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
const App: React.FC = () => {
|
| 43 |
+
// --- ZUSTAND STORES ---
|
| 44 |
+
const {
|
| 45 |
+
currentUser, allUsers, leads, feedback, logs,
|
| 46 |
+
login, register, logout, updateUser, resetPassword, addPoints,
|
| 47 |
+
addLead, updateLeadStatus, deleteLead, addFeedback, addLog
|
| 48 |
+
} = useUserStore();
|
| 49 |
+
|
| 50 |
+
const {
|
| 51 |
+
language, adminConfig, modals, toastMsg,
|
| 52 |
+
setLanguage, setAdminConfig, toggleModal, showToast, closeAllModals
|
| 53 |
+
} = useUIStore();
|
| 54 |
+
|
| 55 |
+
const {
|
| 56 |
+
uploadedImages, selectedStyle, customStyleImage,
|
| 57 |
+
filter, blurAmount, compositionMode, resolution, subjectType, customPrompt,
|
| 58 |
+
results, status, progress, errorMsg,
|
| 59 |
+
isScanning, scanStep, recommendedStyleIds, analysisResult,
|
| 60 |
+
setUploadedImages, setSelectedStyle, setCustomStyleImage,
|
| 61 |
+
setGenerationConfig, setStatus, setProgress, setErrorMsg, addResult, setResults, resetGeneration,
|
| 62 |
+
startScanning, setScanStep, setAnalysisData, stopScanning
|
| 63 |
+
} = useGenerationStore();
|
| 64 |
+
|
| 65 |
+
const t = TRANSLATIONS[language];
|
| 66 |
+
|
| 67 |
+
// Lead Capture Local State (Ephemeral)
|
| 68 |
+
const [formState, setFormState] = useState<'idle'|'submitting'|'success'>('idle');
|
| 69 |
+
const [formData, setFormData] = useState({ name: '', phone: '', wechat: '', date: '', budget: '', service: '' });
|
| 70 |
+
|
| 71 |
+
// PDD Viral Local State
|
| 72 |
+
const [slashingStyle, setSlashingStyle] = useState<WeddingStyle | null>(null);
|
| 73 |
+
|
| 74 |
+
// Ticker State
|
| 75 |
+
const [tickerVisible, setTickerVisible] = useState(false);
|
| 76 |
+
const [tickerData, setTickerData] = useState({ name: '', style: '', time: '' });
|
| 77 |
+
|
| 78 |
+
// Countdown State
|
| 79 |
+
const [countdown, setCountdown] = useState("02:14:59");
|
| 80 |
+
const [rotatingTip, setRotatingTip] = useState(t.genTips[0]);
|
| 81 |
+
|
| 82 |
+
// Refs
|
| 83 |
+
const abortControllerRef = useRef<AbortController | null>(null);
|
| 84 |
+
const resultRef = useRef<HTMLDivElement>(null);
|
| 85 |
+
const adminTriggerCount = useRef(0);
|
| 86 |
+
|
| 87 |
+
// --- EFFECTS ---
|
| 88 |
+
|
| 89 |
+
// IP Tracking
|
| 90 |
+
useEffect(() => {
|
| 91 |
+
ipService.trackVisit();
|
| 92 |
+
}, []);
|
| 93 |
+
|
| 94 |
+
// Failsafe: Remove loading spinner when App mounts
|
| 95 |
+
useEffect(() => {
|
| 96 |
+
const spinner = document.getElementById('loading-spinner');
|
| 97 |
+
if (spinner) {
|
| 98 |
+
spinner.style.opacity = '0';
|
| 99 |
+
setTimeout(() => spinner.remove(), 500);
|
| 100 |
+
}
|
| 101 |
+
}, []);
|
| 102 |
+
|
| 103 |
+
useEffect(() => {
|
| 104 |
+
if (navigator.hardwareConcurrency && navigator.hardwareConcurrency < 4) {
|
| 105 |
+
document.body.classList.add('low-power');
|
| 106 |
+
}
|
| 107 |
+
if (selectedStyle) {
|
| 108 |
+
document.title = `${t.title} - ${selectedStyle.name}`;
|
| 109 |
+
} else {
|
| 110 |
+
document.title = t.title + " - AI Fitting Room";
|
| 111 |
+
}
|
| 112 |
+
}, [selectedStyle, t.title]);
|
| 113 |
+
|
| 114 |
+
// Initial Red Packet check
|
| 115 |
+
useEffect(() => {
|
| 116 |
+
const hasSeenRP = localStorage.getItem('seen_rp_' + new Date().toDateString());
|
| 117 |
+
if (!hasSeenRP) {
|
| 118 |
+
setTimeout(() => {
|
| 119 |
+
if (!currentUser && !modals.auth) toggleModal('redPacket', true);
|
| 120 |
+
localStorage.setItem('seen_rp_' + new Date().toDateString(), 'true');
|
| 121 |
+
}, 2000);
|
| 122 |
+
}
|
| 123 |
+
}, []);
|
| 124 |
+
|
| 125 |
+
// Rotating Tips
|
| 126 |
+
useEffect(() => {
|
| 127 |
+
if (status === 'generating') {
|
| 128 |
+
let i = 0;
|
| 129 |
+
const interval = setInterval(() => {
|
| 130 |
+
i = (i + 1) % t.genTips.length;
|
| 131 |
+
setRotatingTip(t.genTips[i]);
|
| 132 |
+
}, 4000);
|
| 133 |
+
return () => clearInterval(interval);
|
| 134 |
+
}
|
| 135 |
+
}, [status, language]);
|
| 136 |
+
|
| 137 |
+
// Countdown
|
| 138 |
+
useEffect(() => {
|
| 139 |
+
const timer = setInterval(() => {
|
| 140 |
+
const now = new Date();
|
| 141 |
+
const targetTime = new Date(adminConfig.promoEnds).getTime() > now.getTime()
|
| 142 |
+
? new Date(adminConfig.promoEnds)
|
| 143 |
+
: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
|
| 144 |
+
const diff = targetTime.getTime() - now.getTime();
|
| 145 |
+
const hours = Math.floor((diff / (1000 * 60 * 60)));
|
| 146 |
+
const minutes = Math.floor((diff / (1000 * 60)) % 60);
|
| 147 |
+
const seconds = Math.floor((diff / 1000) % 60);
|
| 148 |
+
setCountdown(`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
| 149 |
+
}, 1000);
|
| 150 |
+
return () => clearInterval(timer);
|
| 151 |
+
}, [adminConfig.promoEnds]);
|
| 152 |
+
|
| 153 |
+
// Ticker
|
| 154 |
+
useEffect(() => {
|
| 155 |
+
const names = ['李小姐', '王女士', 'Ms. Chen', 'Sarah', '张小姐', 'Jessica', '刘女士', 'Emily'];
|
| 156 |
+
const showTicker = () => {
|
| 157 |
+
const randomName = names[Math.floor(Math.random() * names.length)];
|
| 158 |
+
const randomStyle = STYLES[Math.floor(Math.random() * STYLES.length)];
|
| 159 |
+
const styleName = (TRANSLATIONS[language].styles as any)[randomStyle.id] || randomStyle.name;
|
| 160 |
+
const time = Math.floor(Math.random() * 5) + 1;
|
| 161 |
+
const timeStr = time === 1 ? TRANSLATIONS[language].tickerJustNow : `${time} ${TRANSLATIONS[language].tickerMinsAgo}`;
|
| 162 |
+
setTickerData({ name: randomName, style: styleName, time: timeStr });
|
| 163 |
+
setTickerVisible(true);
|
| 164 |
+
setTimeout(() => setTickerVisible(false), 5000);
|
| 165 |
+
};
|
| 166 |
+
const interval = setInterval(showTicker, 15000);
|
| 167 |
+
return () => clearInterval(interval);
|
| 168 |
+
}, [language]);
|
| 169 |
+
|
| 170 |
+
// --- HANDLERS ---
|
| 171 |
+
|
| 172 |
+
const handleLogin = (phone: string, password?: string) => {
|
| 173 |
+
const result = login(phone, password);
|
| 174 |
+
if (result.success) {
|
| 175 |
+
showToast(result.msg);
|
| 176 |
+
toggleModal('auth', false);
|
| 177 |
+
const user = allUsers.find(u => u.phone === phone);
|
| 178 |
+
if (user && (user.role === 'admin' || user.role === 'staff')) {
|
| 179 |
+
setTimeout(() => toggleModal('admin', true), 500);
|
| 180 |
+
}
|
| 181 |
+
} else {
|
| 182 |
+
alert(result.msg);
|
| 183 |
+
}
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
const handleRegister = (phone: string, name: string, pass: string) => {
|
| 187 |
+
const result = register(phone, name, pass);
|
| 188 |
+
if (result.success) {
|
| 189 |
+
showToast(result.msg);
|
| 190 |
+
} else {
|
| 191 |
+
showToast(result.msg);
|
| 192 |
+
}
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
const handlePasswordReset = async (phone: string, newPass: string) => {
|
| 196 |
+
return resetPassword(phone, newPass);
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
const handleAdminLoginSuccess = () => {
|
| 200 |
+
const adminUser: UserAccount = {
|
| 201 |
+
id: 'ADMIN_SUPER',
|
| 202 |
+
name: 'Administrator',
|
| 203 |
+
phone: '000-0000',
|
| 204 |
+
points: 999999,
|
| 205 |
+
isVip: true,
|
| 206 |
+
joinDate: Date.now(),
|
| 207 |
+
history: [],
|
| 208 |
+
role: 'admin'
|
| 209 |
+
};
|
| 210 |
+
// We hack the currentUser in store for session only
|
| 211 |
+
useUserStore.setState({ currentUser: adminUser });
|
| 212 |
+
showToast("Admin Mode Activated");
|
| 213 |
+
};
|
| 214 |
+
|
| 215 |
+
const trackStyleUsage = (styleId: string) => {
|
| 216 |
+
if (!currentUser) return;
|
| 217 |
+
const stats = currentUser.styleStats || {};
|
| 218 |
+
stats[styleId] = (stats[styleId] || 0) + 1;
|
| 219 |
+
updateUser(currentUser.id, { styleStats: stats });
|
| 220 |
+
|
| 221 |
+
// Log generation event
|
| 222 |
+
addLog({
|
| 223 |
+
styleId,
|
| 224 |
+
styleName: (TRANSLATIONS[language].styles as any)[styleId] || styleId,
|
| 225 |
+
userId: currentUser.id,
|
| 226 |
+
timestamp: Date.now(),
|
| 227 |
+
ip: ipService.currentIp,
|
| 228 |
+
location: ipService.currentLocation,
|
| 229 |
+
device: /Mobi|Android/i.test(navigator.userAgent) ? 'Mobile' : 'Desktop'
|
| 230 |
+
});
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
const handleBookClick = () => toggleModal('consult', true);
|
| 234 |
+
|
| 235 |
+
const handleAdminTrigger = () => {
|
| 236 |
+
adminTriggerCount.current += 1;
|
| 237 |
+
if (adminTriggerCount.current >= 5) {
|
| 238 |
+
toggleModal('admin', true);
|
| 239 |
+
adminTriggerCount.current = 0;
|
| 240 |
+
}
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
const handleSlashClick = (style: WeddingStyle) => {
|
| 244 |
+
setSlashingStyle(style);
|
| 245 |
+
toggleModal('slash', true);
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
const handleSlashUnlock = () => {
|
| 249 |
+
if (currentUser) {
|
| 250 |
+
updateUser(currentUser.id, { isVip: true });
|
| 251 |
+
showToast(t.pddSlashSuccess);
|
| 252 |
+
} else {
|
| 253 |
+
showToast("Please login first!");
|
| 254 |
+
toggleModal('auth', true);
|
| 255 |
+
}
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
const handleFeedbackSubmit = async (data: Omit<FeedbackItem, 'id' | 'timestamp'>) => {
|
| 259 |
+
const newItem: FeedbackItem = {
|
| 260 |
+
id: Date.now().toString(),
|
| 261 |
+
timestamp: Date.now(),
|
| 262 |
+
...data
|
| 263 |
+
};
|
| 264 |
+
|
| 265 |
+
addFeedback(newItem);
|
| 266 |
+
|
| 267 |
+
await new Promise(resolve => setTimeout(resolve, 800));
|
| 268 |
+
showToast(t.toastFeedback);
|
| 269 |
+
toggleModal('feedback', false);
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const handleFormSubmit = async (e: React.FormEvent) => {
|
| 273 |
+
e.preventDefault();
|
| 274 |
+
setFormState('submitting');
|
| 275 |
+
|
| 276 |
+
let preferredStyleName = 'Unknown';
|
| 277 |
+
let genCount = 0;
|
| 278 |
+
if (currentUser && currentUser.styleStats) {
|
| 279 |
+
const entries = Object.entries(currentUser.styleStats);
|
| 280 |
+
if (entries.length > 0) {
|
| 281 |
+
genCount = entries.reduce((sum, [_, count]) => sum + (count as number), 0);
|
| 282 |
+
const [topId, _] = entries.reduce((max, curr) => (curr[1] as number) > (max[1] as number) ? curr : max);
|
| 283 |
+
const topStyleObj = STYLES.find(s => s.id === topId);
|
| 284 |
+
if (topStyleObj) preferredStyleName = (TRANSLATIONS[language].styles as any)[topId] || topStyleObj.name;
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
const newLead: LeadData = {
|
| 289 |
+
id: Date.now().toString(),
|
| 290 |
+
userId: currentUser?.id,
|
| 291 |
+
name: formData.name,
|
| 292 |
+
phone: formData.phone,
|
| 293 |
+
wechat: formData.wechat,
|
| 294 |
+
service: formData.service,
|
| 295 |
+
budget: formData.budget,
|
| 296 |
+
date: formData.date,
|
| 297 |
+
timestamp: Date.now(),
|
| 298 |
+
status: 'new',
|
| 299 |
+
preferredStyle: preferredStyleName,
|
| 300 |
+
generationCount: genCount,
|
| 301 |
+
syncStatus: 'pending'
|
| 302 |
+
};
|
| 303 |
+
|
| 304 |
+
// CRM Sync Logic
|
| 305 |
+
try {
|
| 306 |
+
const syncResult = await crmService.syncLead(newLead, adminConfig);
|
| 307 |
+
if (syncResult.success) {
|
| 308 |
+
newLead.syncStatus = 'synced';
|
| 309 |
+
newLead.crmId = syncResult.crmId;
|
| 310 |
+
} else {
|
| 311 |
+
newLead.syncStatus = 'failed';
|
| 312 |
+
}
|
| 313 |
+
} catch (e) {
|
| 314 |
+
console.error("CRM Sync failed", e);
|
| 315 |
+
newLead.syncStatus = 'failed';
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
addLead(newLead);
|
| 319 |
+
|
| 320 |
+
setTimeout(() => {
|
| 321 |
+
setFormState('success');
|
| 322 |
+
if (currentUser && !currentUser.isVip) {
|
| 323 |
+
addPoints(adminConfig.pointsBook || 100, 'Booking Inquiry');
|
| 324 |
+
updateUser(currentUser.id, { isVip: true });
|
| 325 |
+
}
|
| 326 |
+
confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
|
| 327 |
+
setTimeout(() => {
|
| 328 |
+
toggleModal('consult', false);
|
| 329 |
+
setFormState('idle');
|
| 330 |
+
setFormData({ name: '', phone: '', wechat: '', date: '', budget: '', service: '' });
|
| 331 |
+
}, 3000);
|
| 332 |
+
}, 1500);
|
| 333 |
+
};
|
| 334 |
+
|
| 335 |
+
const handleImagesSelect = (base64Images: string[]) => {
|
| 336 |
+
setUploadedImages(base64Images);
|
| 337 |
+
setResults({});
|
| 338 |
+
setStatus('idle');
|
| 339 |
+
setErrorMsg(null);
|
| 340 |
+
setAnalysisData([], { faceShape: '', skinTone: '', bestVibe: '' });
|
| 341 |
+
|
| 342 |
+
if (base64Images.length > 0) {
|
| 343 |
+
startScanning();
|
| 344 |
+
|
| 345 |
+
setTimeout(() => setScanStep(1), 1000);
|
| 346 |
+
setTimeout(() => {
|
| 347 |
+
if (language === 'zh') {
|
| 348 |
+
setAnalysisData([], {
|
| 349 |
+
faceShape: "标准鹅蛋脸",
|
| 350 |
+
skinTone: "暖白皮 / 象牙色",
|
| 351 |
+
bestVibe: "法式浪漫 & 韩式唯美"
|
| 352 |
+
});
|
| 353 |
+
} else {
|
| 354 |
+
setAnalysisData([], {
|
| 355 |
+
faceShape: "Oval (Goose Egg)",
|
| 356 |
+
skinTone: "Warm Porcelain",
|
| 357 |
+
bestVibe: "Romantic & Elegant"
|
| 358 |
+
});
|
| 359 |
+
}
|
| 360 |
+
setScanStep(2);
|
| 361 |
+
}, 2500);
|
| 362 |
+
|
| 363 |
+
setTimeout(() => {
|
| 364 |
+
stopScanning();
|
| 365 |
+
const hotStyles = STYLES.filter(s => s.tags?.includes('hot'));
|
| 366 |
+
const otherStyles = STYLES.filter(s => !s.tags?.includes('hot'));
|
| 367 |
+
const randomHot = hotStyles.sort(() => 0.5 - Math.random()).slice(0, 1);
|
| 368 |
+
const randomOthers = otherStyles.sort(() => 0.5 - Math.random()).slice(0, 2);
|
| 369 |
+
const finalRecs = [...randomHot, ...randomOthers].map(s => s.id);
|
| 370 |
+
setAnalysisData(finalRecs, {
|
| 371 |
+
faceShape: language === 'zh' ? "标准鹅蛋脸" : "Oval",
|
| 372 |
+
skinTone: language === 'zh' ? "暖白皮" : "Warm",
|
| 373 |
+
bestVibe: language === 'zh' ? "法式浪漫" : "Romantic"
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
toggleModal('analysis', true);
|
| 377 |
+
}, 3500);
|
| 378 |
+
}
|
| 379 |
+
};
|
| 380 |
+
|
| 381 |
+
const handleStyleSelect = (style: WeddingStyle) => {
|
| 382 |
+
if (currentUser?.role === 'admin') {
|
| 383 |
+
setSelectedStyle(style);
|
| 384 |
+
setCustomStyleImage(null);
|
| 385 |
+
return;
|
| 386 |
+
}
|
| 387 |
+
if (style.isLocked && (!currentUser || !currentUser.isVip)) {
|
| 388 |
+
if (!currentUser) toggleModal('auth', true);
|
| 389 |
+
else toggleModal('consult', true);
|
| 390 |
+
return;
|
| 391 |
+
}
|
| 392 |
+
setSelectedStyle(style);
|
| 393 |
+
setCustomStyleImage(null);
|
| 394 |
+
};
|
| 395 |
+
|
| 396 |
+
const handleCustomStyleSelect = (base64Style: string) => {
|
| 397 |
+
setCustomStyleImage(base64Style);
|
| 398 |
+
const customStyle: WeddingStyle = {
|
| 399 |
+
id: 'custom',
|
| 400 |
+
name: t.customStyle,
|
| 401 |
+
prompt: 'Custom style from reference image',
|
| 402 |
+
description: 'Using your uploaded photo as style guide',
|
| 403 |
+
coverColor: 'bg-rose-100',
|
| 404 |
+
icon: <ImageIcon className="w-5 h-5 text-rose-500" />,
|
| 405 |
+
isCustom: true
|
| 406 |
+
};
|
| 407 |
+
setSelectedStyle(customStyle);
|
| 408 |
+
};
|
| 409 |
+
|
| 410 |
+
const handleLuckySelect = () => {
|
| 411 |
+
const isVip = currentUser?.isVip || currentUser?.role === 'admin' || false;
|
| 412 |
+
// Filter available styles: if not VIP, exclude locked styles
|
| 413 |
+
const availableStyles = STYLES.filter(s => !s.isLocked || isVip);
|
| 414 |
+
|
| 415 |
+
if (availableStyles.length > 0) {
|
| 416 |
+
const randomStyle = availableStyles[Math.floor(Math.random() * availableStyles.length)];
|
| 417 |
+
handleStyleSelect(randomStyle);
|
| 418 |
+
|
| 419 |
+
// Show Toast
|
| 420 |
+
const styleName = (TRANSLATIONS[language].styles as any)[randomStyle.id] || randomStyle.name;
|
| 421 |
+
showToast(`✨ Lucky Pick: ${styleName}!`);
|
| 422 |
+
|
| 423 |
+
// Trigger confetti for fun
|
| 424 |
+
confetti({
|
| 425 |
+
particleCount: 50,
|
| 426 |
+
spread: 60,
|
| 427 |
+
origin: { y: 0.7 },
|
| 428 |
+
colors: ['#fbbf24', '#f43f5e']
|
| 429 |
+
});
|
| 430 |
+
}
|
| 431 |
+
};
|
| 432 |
+
|
| 433 |
+
const handleReset = () => {
|
| 434 |
+
resetGeneration();
|
| 435 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 436 |
+
};
|
| 437 |
+
|
| 438 |
+
const generateSingleStyle = async (images: string[], style: WeddingStyle, customPromptText: string) => {
|
| 439 |
+
try {
|
| 440 |
+
const referenceImage = style.id === 'custom' ? customStyleImage : null;
|
| 441 |
+
const generated = await generateStyledWeddingImage(images, style.prompt, customPromptText, referenceImage, compositionMode, resolution, subjectType);
|
| 442 |
+
|
| 443 |
+
if (style.id !== 'custom') trackStyleUsage(style.id);
|
| 444 |
+
|
| 445 |
+
const dateKey = new Date().toDateString();
|
| 446 |
+
const hasGenToday = localStorage.getItem('gen_today_' + dateKey);
|
| 447 |
+
if (!hasGenToday) {
|
| 448 |
+
addPoints(50, 'Daily First Look');
|
| 449 |
+
localStorage.setItem('gen_today_' + dateKey, 'true');
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
return {
|
| 453 |
+
styleId: style.id,
|
| 454 |
+
imageUrl: generated,
|
| 455 |
+
timestamp: Date.now(),
|
| 456 |
+
config: {
|
| 457 |
+
customInstruction: customPromptText,
|
| 458 |
+
filter: filter,
|
| 459 |
+
blurAmount: blurAmount,
|
| 460 |
+
compositionMode: compositionMode,
|
| 461 |
+
resolution: resolution,
|
| 462 |
+
subjectType: subjectType
|
| 463 |
+
}
|
| 464 |
+
};
|
| 465 |
+
} catch (e) {
|
| 466 |
+
console.error(`Failed to generate style ${style.name}`, e);
|
| 467 |
+
throw e;
|
| 468 |
+
}
|
| 469 |
+
};
|
| 470 |
+
|
| 471 |
+
const handleGenerateSingle = async () => {
|
| 472 |
+
if (uploadedImages.length === 0 || !selectedStyle) return;
|
| 473 |
+
setStatus('generating');
|
| 474 |
+
setErrorMsg(null);
|
| 475 |
+
setProgress({ current: 0, total: 1, statusMsg: "Initializing AI Designer..." });
|
| 476 |
+
setTimeout(() => { resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100);
|
| 477 |
+
|
| 478 |
+
try {
|
| 479 |
+
setTimeout(() => setProgress({ current: 0, total: 1, statusMsg: "Analyzing facial features..." }), 500);
|
| 480 |
+
setTimeout(() => setProgress({ current: 0, total: 1, statusMsg: "Applying wedding style..." }), 1500);
|
| 481 |
+
|
| 482 |
+
const result = await generateSingleStyle(uploadedImages, selectedStyle, customPrompt);
|
| 483 |
+
if (result) {
|
| 484 |
+
addResult(result);
|
| 485 |
+
setStatus('success');
|
| 486 |
+
setProgress(null);
|
| 487 |
+
} else {
|
| 488 |
+
throw new Error("Generation failed");
|
| 489 |
+
}
|
| 490 |
+
} catch (error) {
|
| 491 |
+
setStatus('error');
|
| 492 |
+
setErrorMsg("Failed to generate image. Please try again.");
|
| 493 |
+
setProgress(null);
|
| 494 |
+
}
|
| 495 |
+
};
|
| 496 |
+
|
| 497 |
+
const stopGeneration = () => {
|
| 498 |
+
if (abortControllerRef.current) {
|
| 499 |
+
abortControllerRef.current.abort();
|
| 500 |
+
abortControllerRef.current = null;
|
| 501 |
+
}
|
| 502 |
+
setStatus('idle');
|
| 503 |
+
setProgress(null);
|
| 504 |
+
showToast("Generation stopped.");
|
| 505 |
+
};
|
| 506 |
+
|
| 507 |
+
const handleGenerateAll = () => {
|
| 508 |
+
if (uploadedImages.length === 0) return;
|
| 509 |
+
setStatus('generating');
|
| 510 |
+
setErrorMsg(null);
|
| 511 |
+
showToast("Starting Batch Generation...");
|
| 512 |
+
setProgress({ current: 0, total: STYLES.length, statusMsg: "Initializing Batch Engine..." });
|
| 513 |
+
|
| 514 |
+
setTimeout(async () => {
|
| 515 |
+
try {
|
| 516 |
+
resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| 517 |
+
|
| 518 |
+
const isSuperAdmin = currentUser?.role === 'admin';
|
| 519 |
+
const isVip = currentUser?.isVip || isSuperAdmin;
|
| 520 |
+
|
| 521 |
+
if (!isSuperAdmin && !isVip) {
|
| 522 |
+
const hasLocked = STYLES.some(s => s.isLocked);
|
| 523 |
+
if(hasLocked) {
|
| 524 |
+
setStatus('idle');
|
| 525 |
+
setProgress(null);
|
| 526 |
+
if(!currentUser) toggleModal('auth', true);
|
| 527 |
+
else toggleModal('consult', true);
|
| 528 |
+
return;
|
| 529 |
+
}
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
abortControllerRef.current = new AbortController();
|
| 533 |
+
const presetStyles = STYLES;
|
| 534 |
+
const total = presetStyles.length;
|
| 535 |
+
let successCount = 0;
|
| 536 |
+
|
| 537 |
+
const baseDelay = resolution === 'ultra' ? 6000 : resolution === 'high' ? 4000 : 2500;
|
| 538 |
+
|
| 539 |
+
for (let i = 0; i < presetStyles.length; i++) {
|
| 540 |
+
if (abortControllerRef.current?.signal.aborted) break;
|
| 541 |
+
|
| 542 |
+
const style = presetStyles[i];
|
| 543 |
+
const styleName = (TRANSLATIONS[language].styles as any)[style.id] || style.name;
|
| 544 |
+
|
| 545 |
+
if (results[style.id]) {
|
| 546 |
+
successCount++;
|
| 547 |
+
setProgress({ current: i + 1, total, statusMsg: `Skipping ${styleName} (Done)` });
|
| 548 |
+
continue;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
let attempts = 0;
|
| 552 |
+
let success = false;
|
| 553 |
+
while (attempts < 3 && !success) {
|
| 554 |
+
if (abortControllerRef.current?.signal.aborted) break;
|
| 555 |
+
try {
|
| 556 |
+
setProgress({ current: i + 1, total, statusMsg: `Designing: ${styleName}...` });
|
| 557 |
+
const result = await generateSingleStyle(uploadedImages, style, customPrompt);
|
| 558 |
+
if (result) {
|
| 559 |
+
addResult(result);
|
| 560 |
+
successCount++;
|
| 561 |
+
success = true;
|
| 562 |
+
}
|
| 563 |
+
} catch (e: any) {
|
| 564 |
+
attempts++;
|
| 565 |
+
console.warn(`Failed ${styleName} attempt ${attempts}`, e);
|
| 566 |
+
const isRateLimit = e?.message?.includes('429');
|
| 567 |
+
const waitTime = isRateLimit ? 5000 * attempts : 1500;
|
| 568 |
+
setProgress({ current: i + 1, total, statusMsg: `Cooling down (Retrying)...` });
|
| 569 |
+
await new Promise(r => setTimeout(r, waitTime));
|
| 570 |
+
}
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
if (i < presetStyles.length - 1) {
|
| 574 |
+
setProgress({ current: i + 1, total, statusMsg: `Success! Preparing next style...` });
|
| 575 |
+
await new Promise(r => setTimeout(r, baseDelay));
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
setStatus(successCount > 0 ? 'success' : 'error');
|
| 580 |
+
if (successCount === 0) setErrorMsg("Batch generation failed. Please check network.");
|
| 581 |
+
|
| 582 |
+
} catch (err) {
|
| 583 |
+
console.error(err);
|
| 584 |
+
setStatus('error');
|
| 585 |
+
setErrorMsg("System error during batch.");
|
| 586 |
+
} finally {
|
| 587 |
+
abortControllerRef.current = null;
|
| 588 |
+
setProgress(null);
|
| 589 |
+
}
|
| 590 |
+
}, 100);
|
| 591 |
+
};
|
| 592 |
+
|
| 593 |
+
const hasResults = Object.keys(results).length > 0;
|
| 594 |
+
const activeFilterCss = FILTERS.find(f => f.id === filter)?.css || 'none';
|
| 595 |
+
const hasUploaded = uploadedImages.length > 0;
|
| 596 |
+
|
| 597 |
+
return (
|
| 598 |
+
<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">
|
| 599 |
+
|
| 600 |
+
{toastMsg && <Toast msg={toastMsg} onClose={() => {}} />}
|
| 601 |
+
|
| 602 |
+
<FloatingConcierge language={language} config={adminConfig} />
|
| 603 |
+
<ExitIntentModal language={language} onClose={() => {}} />
|
| 604 |
+
|
| 605 |
+
<Suspense fallback={null}>
|
| 606 |
+
<AdminDashboard
|
| 607 |
+
isVisible={modals.admin}
|
| 608 |
+
onClose={() => toggleModal('admin', false)}
|
| 609 |
+
language={language}
|
| 610 |
+
leads={leads}
|
| 611 |
+
feedback={feedback}
|
| 612 |
+
config={adminConfig}
|
| 613 |
+
allUsers={allUsers}
|
| 614 |
+
currentUser={currentUser}
|
| 615 |
+
onUpdateConfig={setAdminConfig}
|
| 616 |
+
onUpdateLeadStatus={updateLeadStatus}
|
| 617 |
+
onDeleteLead={deleteLead}
|
| 618 |
+
onUpdateUser={updateUser}
|
| 619 |
+
showToast={showToast}
|
| 620 |
+
onAdminLoginSuccess={handleAdminLoginSuccess}
|
| 621 |
+
logs={logs}
|
| 622 |
+
/>
|
| 623 |
+
</Suspense>
|
| 624 |
+
|
| 625 |
+
{currentUser && (
|
| 626 |
+
<UserCenter
|
| 627 |
+
isVisible={modals.userCenter}
|
| 628 |
+
onClose={() => toggleModal('userCenter', false)}
|
| 629 |
+
language={language}
|
| 630 |
+
user={currentUser}
|
| 631 |
+
leads={leads}
|
| 632 |
+
config={adminConfig}
|
| 633 |
+
onRedeem={() => {
|
| 634 |
+
const cost = adminConfig.pointsVipCost || 100;
|
| 635 |
+
if (currentUser.points >= cost) {
|
| 636 |
+
updateUser(currentUser.id, { points: currentUser.points - cost, isVip: true });
|
| 637 |
+
showToast("VIP Unlocked!");
|
| 638 |
+
}
|
| 639 |
+
}}
|
| 640 |
+
showToast={showToast}
|
| 641 |
+
onAddPoints={addPoints}
|
| 642 |
+
/>
|
| 643 |
+
)}
|
| 644 |
+
|
| 645 |
+
<AuthModal
|
| 646 |
+
isVisible={modals.auth}
|
| 647 |
+
onClose={() => toggleModal('auth', false)}
|
| 648 |
+
language={language}
|
| 649 |
+
onLogin={handleLogin}
|
| 650 |
+
onRegister={handleRegister}
|
| 651 |
+
onResetPassword={handlePasswordReset}
|
| 652 |
+
/>
|
| 653 |
+
|
| 654 |
+
<AboutModal
|
| 655 |
+
isVisible={modals.about}
|
| 656 |
+
onClose={() => toggleModal('about', false)}
|
| 657 |
+
language={language}
|
| 658 |
+
config={adminConfig}
|
| 659 |
+
/>
|
| 660 |
+
|
| 661 |
+
<AnalysisModal
|
| 662 |
+
isVisible={modals.analysis}
|
| 663 |
+
onClose={() => toggleModal('analysis', false)}
|
| 664 |
+
language={language}
|
| 665 |
+
result={analysisResult}
|
| 666 |
+
onUnlock={() => toggleModal('analysis', false)}
|
| 667 |
+
/>
|
| 668 |
+
|
| 669 |
+
<RedPacketModal
|
| 670 |
+
isVisible={modals.redPacket}
|
| 671 |
+
onClose={() => toggleModal('redPacket', false)}
|
| 672 |
+
language={language}
|
| 673 |
+
adminConfig={adminConfig}
|
| 674 |
+
user={currentUser}
|
| 675 |
+
onUpdateUser={(bal) => currentUser && updateUser(currentUser.id, { redPacketBalance: bal })}
|
| 676 |
+
onOpenShare={() => toggleModal('share', true)}
|
| 677 |
+
/>
|
| 678 |
+
|
| 679 |
+
<SlashModal
|
| 680 |
+
isVisible={modals.slash}
|
| 681 |
+
onClose={() => toggleModal('slash', false)}
|
| 682 |
+
style={slashingStyle}
|
| 683 |
+
onUnlock={handleSlashUnlock}
|
| 684 |
+
language={language}
|
| 685 |
+
adminConfig={adminConfig}
|
| 686 |
+
user={currentUser}
|
| 687 |
+
onUpdateUser={(prog) => currentUser && updateUser(currentUser.id, { slashProgress: prog })}
|
| 688 |
+
onOpenShare={() => toggleModal('share', true)}
|
| 689 |
+
/>
|
| 690 |
+
|
| 691 |
+
<ShareModal
|
| 692 |
+
isVisible={modals.share}
|
| 693 |
+
onClose={() => toggleModal('share', false)}
|
| 694 |
+
language={language}
|
| 695 |
+
config={adminConfig.shareConfig}
|
| 696 |
+
showToast={showToast}
|
| 697 |
+
onShareSuccess={() => addPoints(adminConfig.pointsShare || 10, 'Viral Share')}
|
| 698 |
+
/>
|
| 699 |
+
|
| 700 |
+
<FeedbackModal
|
| 701 |
+
isVisible={modals.feedback}
|
| 702 |
+
onClose={() => toggleModal('feedback', false)}
|
| 703 |
+
language={language}
|
| 704 |
+
user={currentUser}
|
| 705 |
+
onSubmit={handleFeedbackSubmit}
|
| 706 |
+
/>
|
| 707 |
+
|
| 708 |
+
{isScanning && (
|
| 709 |
+
<div className="fixed inset-0 z-[70] bg-black/95 flex flex-col items-center justify-center text-white p-4">
|
| 710 |
+
<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)]">
|
| 711 |
+
<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>
|
| 712 |
+
<div className="absolute inset-0 border-4 border-rose-500 rounded-full animate-ping opacity-20"></div>
|
| 713 |
+
{uploadedImages[0] && <img src={uploadedImages[0]} className="w-full h-full object-cover opacity-50 grayscale" />}
|
| 714 |
+
<ScanFace className="absolute w-16 h-16 text-rose-500 animate-pulse" />
|
| 715 |
+
<div className="absolute top-10 left-10 w-2 h-2 bg-rose-400 rounded-full animate-ping"></div>
|
| 716 |
+
<div className="absolute bottom-10 right-10 w-2 h-2 bg-rose-400 rounded-full animate-ping" style={{animationDelay: '0.3s'}}></div>
|
| 717 |
+
</div>
|
| 718 |
+
<div className="text-center space-y-2">
|
| 719 |
+
<h2 className="text-2xl font-bold font-serif text-rose-300 tracking-wider">
|
| 720 |
+
{scanStep === 0 ? t.aiScanning : scanStep === 1 ? t.aiAnalyzing : t.aiMatching}
|
| 721 |
+
</h2>
|
| 722 |
+
<p className="text-gray-400 text-sm font-mono tracking-widest">SYSTEM ANALYSIS v2.4.0</p>
|
| 723 |
+
</div>
|
| 724 |
+
<div className="mt-8 w-64 h-1 bg-gray-800 rounded-full overflow-hidden">
|
| 725 |
+
<div className="h-full bg-rose-500 transition-all duration-[3500ms] ease-linear w-full shadow-[0_0_10px_#f43f5e]"></div>
|
| 726 |
+
</div>
|
| 727 |
+
</div>
|
| 728 |
+
)}
|
| 729 |
+
|
| 730 |
+
<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'}`}>
|
| 731 |
+
<div className="w-8 h-8 rounded-full bg-rose-100 flex items-center justify-center">
|
| 732 |
+
<Bell className="w-4 h-4 text-rose-500 animate-swing" />
|
| 733 |
+
</div>
|
| 734 |
+
<div className="text-xs">
|
| 735 |
+
<p className="font-bold text-gray-800">
|
| 736 |
+
<span className="text-rose-600">{tickerData.name}</span> {t.tickerBooked}
|
| 737 |
+
</p>
|
| 738 |
+
<p className="text-gray-500 flex items-center gap-1">
|
| 739 |
+
{tickerData.style} • {tickerData.time}
|
| 740 |
+
</p>
|
| 741 |
+
</div>
|
| 742 |
+
</div>
|
| 743 |
+
|
| 744 |
+
{adminConfig.showBanner && (
|
| 745 |
+
<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]">
|
| 746 |
+
<span className="text-center drop-shadow-sm">{adminConfig.promoText}</span>
|
| 747 |
+
<div className="flex items-center gap-1.5 bg-rose-800/40 px-2 py-0.5 rounded-lg border border-rose-400/30">
|
| 748 |
+
<Timer className="w-3.5 h-3.5 text-rose-200" />
|
| 749 |
+
<span className="font-mono font-bold text-rose-100 tracking-wide">{t.promoEnds} {countdown}</span>
|
| 750 |
+
</div>
|
| 751 |
+
<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">
|
| 752 |
+
<X className="w-3 h-3" />
|
| 753 |
+
</button>
|
| 754 |
+
</div>
|
| 755 |
+
)}
|
| 756 |
+
|
| 757 |
+
<Header
|
| 758 |
+
language={language}
|
| 759 |
+
onLanguageChange={setLanguage}
|
| 760 |
+
onBookClick={handleBookClick}
|
| 761 |
+
user={currentUser}
|
| 762 |
+
config={adminConfig}
|
| 763 |
+
onOpenUserCenter={() => toggleModal('userCenter', true)}
|
| 764 |
+
onLoginClick={() => toggleModal('auth', true)}
|
| 765 |
+
onOpenAdmin={() => toggleModal('admin', true)}
|
| 766 |
+
onOpenAbout={() => toggleModal('about', true)}
|
| 767 |
+
onOpenFeedback={() => toggleModal('feedback', true)}
|
| 768 |
+
/>
|
| 769 |
+
|
| 770 |
+
<button
|
| 771 |
+
onClick={handleBookClick}
|
| 772 |
+
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"
|
| 773 |
+
>
|
| 774 |
+
<MessageCircle className="w-6 h-6" />
|
| 775 |
+
<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">
|
| 776 |
+
{t.floatConsult}
|
| 777 |
+
</span>
|
| 778 |
+
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
| 779 |
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
| 780 |
+
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
| 781 |
+
</span>
|
| 782 |
+
</button>
|
| 783 |
+
|
| 784 |
+
{modals.consult && (
|
| 785 |
+
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
| 786 |
+
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden relative">
|
| 787 |
+
<button onClick={() => toggleModal('consult', false)} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 p-1">
|
| 788 |
+
<X className="w-5 h-5" />
|
| 789 |
+
</button>
|
| 790 |
+
|
| 791 |
+
<div className="p-6">
|
| 792 |
+
{formState === 'success' ? (
|
| 793 |
+
<div className="flex flex-col items-center justify-center py-8 text-center space-y-4">
|
| 794 |
+
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center text-green-600 mb-2">
|
| 795 |
+
<CheckCircle className="w-10 h-10" />
|
| 796 |
+
</div>
|
| 797 |
+
<h3 className="text-xl font-bold text-gray-800">{t.formSuccess}</h3>
|
| 798 |
+
<p className="text-gray-500 text-sm">We will contact you shortly.</p>
|
| 799 |
+
</div>
|
| 800 |
+
) : (
|
| 801 |
+
<>
|
| 802 |
+
<div className="text-center mb-6">
|
| 803 |
+
{(!currentUser?.isVip) ? (
|
| 804 |
+
<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">
|
| 805 |
+
<Lock className="w-6 h-6" />
|
| 806 |
+
</div>
|
| 807 |
+
) : (
|
| 808 |
+
<div className="w-12 h-12 bg-rose-100 rounded-full flex items-center justify-center text-rose-600 mx-auto mb-3">
|
| 809 |
+
<Calendar className="w-6 h-6" />
|
| 810 |
+
</div>
|
| 811 |
+
)}
|
| 812 |
+
<h3 className="text-xl font-bold text-gray-900">{(!currentUser?.isVip) ? t.vipUnlockTitle : t.consultTitle}</h3>
|
| 813 |
+
<p className="text-sm text-gray-500 mt-1">{(!currentUser?.isVip) ? t.vipUnlockDesc : t.consultDesc}</p>
|
| 814 |
+
</div>
|
| 815 |
+
|
| 816 |
+
<form onSubmit={handleFormSubmit} className="space-y-4">
|
| 817 |
+
<div className="grid grid-cols-2 gap-4">
|
| 818 |
+
<div>
|
| 819 |
+
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formName} *</label>
|
| 820 |
+
<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" />
|
| 821 |
+
</div>
|
| 822 |
+
<div>
|
| 823 |
+
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formPhone} *</label>
|
| 824 |
+
<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" />
|
| 825 |
+
</div>
|
| 826 |
+
</div>
|
| 827 |
+
<div>
|
| 828 |
+
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formWeChat}</label>
|
| 829 |
+
<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" />
|
| 830 |
+
</div>
|
| 831 |
+
<div>
|
| 832 |
+
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formService}</label>
|
| 833 |
+
<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">
|
| 834 |
+
<option value="">- Select -</option>
|
| 835 |
+
<option value="wedding">{t.service1}</option>
|
| 836 |
+
<option value="art">{t.service2}</option>
|
| 837 |
+
<option value="family">{t.service3}</option>
|
| 838 |
+
</select>
|
| 839 |
+
</div>
|
| 840 |
+
<div>
|
| 841 |
+
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formBudget}</label>
|
| 842 |
+
<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">
|
| 843 |
+
<option value="">- Select -</option>
|
| 844 |
+
<option value="1">{t.budget1}</option>
|
| 845 |
+
<option value="2">{t.budget2}</option>
|
| 846 |
+
<option value="3">{t.budget3}</option>
|
| 847 |
+
<option value="4">{t.budget4}</option>
|
| 848 |
+
</select>
|
| 849 |
+
</div>
|
| 850 |
+
<div>
|
| 851 |
+
<label className="block text-xs font-bold text-gray-700 mb-1">{t.formDate}</label>
|
| 852 |
+
<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" />
|
| 853 |
+
</div>
|
| 854 |
+
<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">
|
| 855 |
+
{formState === 'submitting' && <Loader2 className="w-4 h-4 animate-spin" />}
|
| 856 |
+
{t.formSubmit}
|
| 857 |
+
</button>
|
| 858 |
+
</form>
|
| 859 |
+
</>
|
| 860 |
+
)}
|
| 861 |
+
</div>
|
| 862 |
+
</div>
|
| 863 |
+
</div>
|
| 864 |
+
)}
|
| 865 |
+
|
| 866 |
+
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-8 sm:py-12 flex-grow w-full">
|
| 867 |
+
<div className="text-center mb-8 sm:mb-12 max-w-2xl mx-auto animate-fade-in">
|
| 868 |
+
<h2 className="font-serif text-3xl sm:text-5xl font-bold text-gray-900 mb-4 leading-tight">
|
| 869 |
+
{t.heroTitle}
|
| 870 |
+
</h2>
|
| 871 |
+
<p className="text-sm sm:text-lg text-gray-600">
|
| 872 |
+
{t.heroDesc}
|
| 873 |
+
</p>
|
| 874 |
+
</div>
|
| 875 |
+
|
| 876 |
+
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 items-start">
|
| 877 |
+
<div className="lg:col-span-5 space-y-8">
|
| 878 |
+
<section className="space-y-4">
|
| 879 |
+
<div className="flex items-center gap-3 mb-2">
|
| 880 |
+
<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>
|
| 881 |
+
<h3 className="text-lg sm:text-xl font-bold">{t.step1}</h3>
|
| 882 |
+
</div>
|
| 883 |
+
<ImageUploader onImagesSelect={handleImagesSelect} currentImages={uploadedImages} disabled={status === 'generating'} language={language} />
|
| 884 |
+
</section>
|
| 885 |
+
|
| 886 |
+
<section className={`space-y-4 transition-opacity duration-500 ${!hasUploaded ? 'opacity-40 pointer-events-none' : 'opacity-100'}`}>
|
| 887 |
+
<div className="flex items-center gap-3 mb-2 justify-between">
|
| 888 |
+
<div className="flex items-center gap-3">
|
| 889 |
+
<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>
|
| 890 |
+
<h3 className="text-lg sm:text-xl font-bold">{t.step2}</h3>
|
| 891 |
+
</div>
|
| 892 |
+
{selectedStyle && <button onClick={() => { setSelectedStyle(null); setCustomStyleImage(null); }} className="text-xs text-rose-500 hover:underline">{t.clearSelection}</button>}
|
| 893 |
+
</div>
|
| 894 |
+
<div className="max-h-[350px] overflow-y-auto pr-2 custom-scrollbar">
|
| 895 |
+
<StyleSelector
|
| 896 |
+
selectedStyle={selectedStyle}
|
| 897 |
+
onSelect={handleStyleSelect}
|
| 898 |
+
onCustomSelect={handleCustomStyleSelect}
|
| 899 |
+
onLuckySelect={handleLuckySelect}
|
| 900 |
+
onSlashClick={handleSlashClick}
|
| 901 |
+
disabled={status === 'generating'}
|
| 902 |
+
language={language}
|
| 903 |
+
recommendedStyleIds={recommendedStyleIds}
|
| 904 |
+
isVipUnlocked={currentUser?.isVip}
|
| 905 |
+
resolution={resolution}
|
| 906 |
+
onResolutionChange={(res) => setGenerationConfig({ resolution: res })}
|
| 907 |
+
/>
|
| 908 |
+
</div>
|
| 909 |
+
{customStyleImage && (
|
| 910 |
+
<div className="flex items-center gap-3 p-3 bg-rose-50 rounded-xl border border-rose-100">
|
| 911 |
+
<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>
|
| 912 |
+
<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>
|
| 913 |
+
<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>
|
| 914 |
+
</div>
|
| 915 |
+
)}
|
| 916 |
+
</section>
|
| 917 |
+
|
| 918 |
+
<section className={`space-y-4 transition-opacity duration-500 ${!hasUploaded ? 'opacity-40 pointer-events-none' : 'opacity-100'}`}>
|
| 919 |
+
<div className="flex items-center gap-3 mb-2">
|
| 920 |
+
<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>
|
| 921 |
+
<h3 className="text-lg sm:text-xl font-bold">{t.step3}</h3>
|
| 922 |
+
</div>
|
| 923 |
+
<div className="bg-gray-50 rounded-2xl border border-gray-200 p-5 space-y-5">
|
| 924 |
+
<div>
|
| 925 |
+
<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>
|
| 926 |
+
<div className="grid grid-cols-3 gap-2">
|
| 927 |
+
<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>
|
| 928 |
+
<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>
|
| 929 |
+
<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>
|
| 930 |
+
<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>
|
| 931 |
+
<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>
|
| 932 |
+
</div>
|
| 933 |
+
</div>
|
| 934 |
+
|
| 935 |
+
<div className="border-t border-gray-200 my-4"></div>
|
| 936 |
+
<div><FilterSelector selectedFilter={filter} onSelect={(f) => setGenerationConfig({ filter: f })} blurAmount={blurAmount} onBlurChange={(b) => setGenerationConfig({ blurAmount: b })} disabled={status === 'generating'} language={language} /></div>
|
| 937 |
+
|
| 938 |
+
<div className="border-t border-gray-200 my-4"></div>
|
| 939 |
+
|
| 940 |
+
{(uploadedImages.length > 1 || subjectType === 'family' || subjectType === 'couple') && (
|
| 941 |
+
<div className="animate-fade-in">
|
| 942 |
+
<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>
|
| 943 |
+
<div className="grid grid-cols-3 gap-2">
|
| 944 |
+
<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>
|
| 945 |
+
<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>
|
| 946 |
+
<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>
|
| 947 |
+
</div>
|
| 948 |
+
<div className="border-t border-gray-200 my-4"></div>
|
| 949 |
+
</div>
|
| 950 |
+
)}
|
| 951 |
+
|
| 952 |
+
<div>
|
| 953 |
+
<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>
|
| 954 |
+
<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'} />
|
| 955 |
+
<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>
|
| 956 |
+
</div>
|
| 957 |
+
</div>
|
| 958 |
+
</section>
|
| 959 |
+
|
| 960 |
+
<div className={`pt-4 flex flex-col gap-3 transition-opacity duration-500 ${!hasUploaded ? 'opacity-50' : 'opacity-100'}`}>
|
| 961 |
+
{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>}
|
| 962 |
+
{status === 'generating' && progress && (
|
| 963 |
+
<div className="space-y-3 bg-white p-4 rounded-xl shadow-lg border border-rose-100 relative overflow-hidden">
|
| 964 |
+
<div className="absolute top-0 right-0 w-32 h-32 bg-rose-500/5 rounded-full blur-2xl animate-pulse"></div>
|
| 965 |
+
|
| 966 |
+
<div className="flex items-center gap-3">
|
| 967 |
+
<div className="w-10 h-10 rounded-full bg-rose-50 flex items-center justify-center shrink-0">
|
| 968 |
+
<Loader2 className="w-5 h-5 text-rose-500 animate-spin" />
|
| 969 |
+
</div>
|
| 970 |
+
<div className="flex-1">
|
| 971 |
+
<p className="text-sm font-bold text-gray-800 animate-fade-in">{progress.statusMsg}</p>
|
| 972 |
+
<p className="text-xs text-rose-400 italic mt-0.5">{rotatingTip}</p>
|
| 973 |
+
</div>
|
| 974 |
+
<div className="text-right">
|
| 975 |
+
<span className="text-xl font-bold text-gray-900">{Math.round((progress.current / progress.total) * 100)}%</span>
|
| 976 |
+
</div>
|
| 977 |
+
</div>
|
| 978 |
+
|
| 979 |
+
<div className="h-2 w-full bg-gray-100 rounded-full overflow-hidden mt-2">
|
| 980 |
+
<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}%`}}>
|
| 981 |
+
<div className="absolute top-0 right-0 h-full w-2 bg-white/50 animate-pulse"></div>
|
| 982 |
+
</div>
|
| 983 |
+
</div>
|
| 984 |
+
|
| 985 |
+
<button onClick={stopGeneration} className="w-full mt-2 text-[10px] text-gray-400 hover:text-red-500 flex items-center justify-center gap-1">
|
| 986 |
+
<StopCircle className="w-3 h-3" /> Stop Generation
|
| 987 |
+
</button>
|
| 988 |
+
</div>
|
| 989 |
+
)}
|
| 990 |
+
|
| 991 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
| 992 |
+
<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]'}`}>
|
| 993 |
+
<Wand2 className="w-4 h-4" />
|
| 994 |
+
<span>{selectedStyle?.id === 'custom' ? t.genCustom : t.genSelected}</span>
|
| 995 |
+
</button>
|
| 996 |
+
<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'}`}>
|
| 997 |
+
<Layers className="w-4 h-4" />
|
| 998 |
+
<span>{t.genAll}</span>
|
| 999 |
+
</button>
|
| 1000 |
+
</div>
|
| 1001 |
+
</div>
|
| 1002 |
+
</div>
|
| 1003 |
+
|
| 1004 |
+
<div className="lg:col-span-7" ref={resultRef}>
|
| 1005 |
+
{hasResults ? (
|
| 1006 |
+
<ResultViewer
|
| 1007 |
+
originalImages={uploadedImages}
|
| 1008 |
+
results={results}
|
| 1009 |
+
initialSelectedId={selectedStyle?.id}
|
| 1010 |
+
onReset={handleReset}
|
| 1011 |
+
activeFilter={activeFilterCss}
|
| 1012 |
+
blurAmount={blurAmount}
|
| 1013 |
+
language={language}
|
| 1014 |
+
onBookClick={handleBookClick}
|
| 1015 |
+
onShareClick={() => toggleModal('share', true)}
|
| 1016 |
+
/>
|
| 1017 |
+
) : (
|
| 1018 |
+
<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">
|
| 1019 |
+
<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>
|
| 1020 |
+
<h3 className="text-xl font-serif font-bold text-gray-300">{t.emptyGallery}</h3>
|
| 1021 |
+
<p className="text-gray-400 mt-2 max-w-xs text-sm">{t.emptyGalleryDesc}</p>
|
| 1022 |
+
</div>
|
| 1023 |
+
)}
|
| 1024 |
+
</div>
|
| 1025 |
+
</div>
|
| 1026 |
+
</main>
|
| 1027 |
+
|
| 1028 |
+
<Features language={language} />
|
| 1029 |
+
<Testimonials language={language} />
|
| 1030 |
+
|
| 1031 |
+
<footer className="bg-gray-900 text-white py-8 border-t border-gray-800">
|
| 1032 |
+
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row items-center justify-between gap-6">
|
| 1033 |
+
<div className="text-center md:text-left">
|
| 1034 |
+
<h3 className="font-serif font-bold text-xl">{t.posterBrand}</h3>
|
| 1035 |
+
<div className="flex gap-4 justify-center md:justify-start mt-2">
|
| 1036 |
+
<button onClick={() => toggleModal('about', true)} className="text-xs text-gray-400 hover:text-rose-400 transition-colors">
|
| 1037 |
+
{t.aboutUsBtn}
|
| 1038 |
+
</button>
|
| 1039 |
+
<p className="text-xs text-gray-500 cursor-default select-none active:text-gray-400" onClick={handleAdminTrigger}>
|
| 1040 |
+
{t.footerRights}
|
| 1041 |
+
</p>
|
| 1042 |
+
</div>
|
| 1043 |
+
</div>
|
| 1044 |
+
|
| 1045 |
+
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-12">
|
| 1046 |
+
<div className="flex flex-col gap-2 text-sm text-gray-300 md:text-right">
|
| 1047 |
+
<p className="flex items-center gap-2 justify-center md:justify-end"><MapPin className="w-4 h-4 text-rose-500" /> {adminConfig.footerAddress}</p>
|
| 1048 |
+
<p className="flex items-center gap-2 justify-center md:justify-end"><Phone className="w-4 h-4 text-rose-500" /> {adminConfig.contactPhone}</p>
|
| 1049 |
+
</div>
|
| 1050 |
+
|
| 1051 |
+
<div className="flex flex-col items-center gap-2 group cursor-pointer" onClick={() => window.open(adminConfig.qrCodeUrl || 'https://romantic-life.com/book', '_blank')}>
|
| 1052 |
+
<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">
|
| 1053 |
+
<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" />
|
| 1054 |
+
</div>
|
| 1055 |
+
<span className="text-[10px] text-rose-200 uppercase font-bold tracking-wider group-hover:text-rose-400 transition-colors">{t.posterTitle}</span>
|
| 1056 |
+
</div>
|
| 1057 |
+
</div>
|
| 1058 |
+
</div>
|
| 1059 |
+
</footer>
|
| 1060 |
+
</div>
|
| 1061 |
+
);
|
| 1062 |
+
};
|
| 1063 |
+
|
| 1064 |
+
export default App;
|
README.md
CHANGED
|
@@ -1,11 +1,54 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Romantic Life - AI Wedding Fitting Room
|
| 3 |
+
emoji: 👰♀️
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# 厦门浪漫一生 - AI 婚纱试衣间 (Romantic Life AI)
|
| 12 |
+
|
| 13 |
+
本项目是一个基于 React + Vite + Google Gemini API 的 AI 婚纱摄影试衣间应用。
|
| 14 |
+
|
| 15 |
+
## 🌐 域名访问故障排查 (Troubleshooting)
|
| 16 |
+
|
| 17 |
+
如果您上传后无法通过 `xmlove520.dpdns.org` 访问,请按以下步骤检查:
|
| 18 |
+
|
| 19 |
+
1. **检查 HTTPS**:
|
| 20 |
+
* 如果您没有为该域名购买/配置 SSL 证书,请**务必使用 `http://`** 而不是 `https://` 访问。
|
| 21 |
+
* 尝试访问: `http://xmlove520.dpdns.org`
|
| 22 |
+
|
| 23 |
+
2. **检查端口**:
|
| 24 |
+
* 家庭宽带通常屏蔽 80/443 端口。您可能需要访问带端口的地址,例如 `http://xmlove520.dpdns.org:8080` (具体端口取决于您的映射设置)。
|
| 25 |
+
|
| 26 |
+
3. **检查构建**:
|
| 27 |
+
* 确保您上传的是 `npm run build` 生成的 `dist` 文件夹中的内容,而不是源码。
|
| 28 |
+
* 确保 `dist` 文件夹中包含 `index.html`。
|
| 29 |
+
|
| 30 |
+
## 🚀 部署指南
|
| 31 |
+
|
| 32 |
+
### 选项 A: Hugging Face Spaces (Docker)
|
| 33 |
+
|
| 34 |
+
1. 创建 Space (SDK: Docker, Template: Blank)。
|
| 35 |
+
2. 上传所有文件(包括 Dockerfile, nginx.conf, package.json 等)。
|
| 36 |
+
3. 系统会自动构建并运行在 `https://huggingface.co/spaces/您的用户名/您的Space名`。
|
| 37 |
+
|
| 38 |
+
### 选项 B: Windows IIS / 空间 (dpdns.org)
|
| 39 |
+
|
| 40 |
+
1. 运行 `npm run build`。
|
| 41 |
+
2. 将 `dist` 文件夹内的所有文件上传到您的服务器根目录。
|
| 42 |
+
3. **注意**:`web.config` 文件已包含在构建中,这对于 IIS 服务器至关重要,它能防止刷新页面 404。
|
| 43 |
+
|
| 44 |
+
## ✨ 功能特性
|
| 45 |
+
|
| 46 |
+
* **60+ 婚纱风格**: 涵盖韩式、法式、中式、古风、二次元等。
|
| 47 |
+
* **AI 智能换装**: 基于 Google Gemini Vision 模型。
|
| 48 |
+
* **后台管理**: 完整的客资管理(CRM)与系统设置面板。
|
| 49 |
+
|
| 50 |
+
## 🛠️ 技术栈
|
| 51 |
+
|
| 52 |
+
* **Frontend**: React 18, TypeScript, Vite
|
| 53 |
+
* **State**: Zustand
|
| 54 |
+
* **AI**: Google Gemini Pro Vision
|
components/._AboutModal.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._AdminDashboard.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._AnalysisModal.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._AuthModal.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._ErrorBoundary.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._ExitIntentModal.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._Features.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._FeedbackModal.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._FilterSelector.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._FloatingConcierge.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._Header.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._ImageUploader.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._RedPacketModal.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._ResultViewer.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._ShareModal.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._SlashModal.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._StyleSelector.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._Testimonials.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/._UserCenter.tsx
ADDED
|
Binary file (4.1 kB). View file
|
|
|
components/AboutModal.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import { X, Camera, Heart, MapPin, Award } from 'lucide-react';
|
| 4 |
+
import { Language, AdminConfig } from '../types';
|
| 5 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 6 |
+
|
| 7 |
+
interface AboutModalProps {
|
| 8 |
+
isVisible: boolean;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
language: Language;
|
| 11 |
+
config?: AdminConfig; // Optional to handle undefined initially
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export const AboutModal: React.FC<AboutModalProps> = ({ isVisible, onClose, language, config }) => {
|
| 15 |
+
const t = TRANSLATIONS[language];
|
| 16 |
+
|
| 17 |
+
if (!isVisible) return null;
|
| 18 |
+
|
| 19 |
+
// Prefer dynamic config content, fallback to translation file
|
| 20 |
+
const story = config?.aboutStory || t.aboutStoryContent;
|
| 21 |
+
const philosophy = config?.aboutPhilosophy || t.aboutPhilosophyContent;
|
| 22 |
+
const location = config?.aboutLocation || t.aboutLocationContent;
|
| 23 |
+
const address = config?.footerAddress || t.footerAddress;
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
| 27 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden relative flex flex-col max-h-[90vh]">
|
| 28 |
+
{/* Header with Image Background */}
|
| 29 |
+
<div className="relative h-48 bg-gray-900 flex items-center justify-center overflow-hidden shrink-0">
|
| 30 |
+
<div className="absolute inset-0 bg-gradient-to-r from-rose-900 to-gray-900 opacity-90"></div>
|
| 31 |
+
{/* Decorative patterns */}
|
| 32 |
+
<div className="absolute top-0 left-0 w-full h-full opacity-20" style={{backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px)', backgroundSize: '20px 20px'}}></div>
|
| 33 |
+
|
| 34 |
+
<button
|
| 35 |
+
onClick={onClose}
|
| 36 |
+
className="absolute top-4 right-4 p-2 bg-black/30 text-white rounded-full hover:bg-black/50 transition-colors z-20"
|
| 37 |
+
>
|
| 38 |
+
<X className="w-5 h-5" />
|
| 39 |
+
</button>
|
| 40 |
+
|
| 41 |
+
<div className="relative z-10 text-center px-6">
|
| 42 |
+
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center text-rose-600 mx-auto mb-3 shadow-lg">
|
| 43 |
+
<Camera className="w-8 h-8" />
|
| 44 |
+
</div>
|
| 45 |
+
<h2 className="text-3xl font-serif font-bold text-white tracking-wide">{t.aboutTitle}</h2>
|
| 46 |
+
<p className="text-rose-200 text-sm mt-1 uppercase tracking-widest">{t.subtitle}</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
{/* Content */}
|
| 51 |
+
<div className="p-6 sm:p-8 overflow-y-auto space-y-8 bg-gray-50/50">
|
| 52 |
+
{/* Brand Story */}
|
| 53 |
+
<div className="flex gap-4">
|
| 54 |
+
<div className="w-10 h-10 rounded-full bg-rose-100 flex items-center justify-center text-rose-600 shrink-0 mt-1">
|
| 55 |
+
<Award className="w-5 h-5" />
|
| 56 |
+
</div>
|
| 57 |
+
<div>
|
| 58 |
+
<h3 className="text-lg font-bold text-gray-900 mb-2">{t.aboutStory}</h3>
|
| 59 |
+
<p className="text-gray-600 leading-relaxed text-sm text-justify whitespace-pre-wrap">
|
| 60 |
+
{story}
|
| 61 |
+
</p>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
{/* Philosophy */}
|
| 66 |
+
<div className="flex gap-4">
|
| 67 |
+
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 shrink-0 mt-1">
|
| 68 |
+
<Heart className="w-5 h-5" />
|
| 69 |
+
</div>
|
| 70 |
+
<div>
|
| 71 |
+
<h3 className="text-lg font-bold text-gray-900 mb-2">{t.aboutPhilosophy}</h3>
|
| 72 |
+
<p className="text-gray-600 leading-relaxed text-sm text-justify whitespace-pre-wrap">
|
| 73 |
+
{philosophy}
|
| 74 |
+
</p>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{/* Location */}
|
| 79 |
+
<div className="flex gap-4">
|
| 80 |
+
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center text-amber-600 shrink-0 mt-1">
|
| 81 |
+
<MapPin className="w-5 h-5" />
|
| 82 |
+
</div>
|
| 83 |
+
<div>
|
| 84 |
+
<h3 className="text-lg font-bold text-gray-900 mb-2">{t.aboutLocation}</h3>
|
| 85 |
+
<p className="text-gray-600 leading-relaxed text-sm text-justify whitespace-pre-wrap">
|
| 86 |
+
{location}
|
| 87 |
+
</p>
|
| 88 |
+
<div className="mt-3 p-3 bg-white rounded-lg border border-gray-200 text-xs text-gray-500 font-mono flex items-center gap-2">
|
| 89 |
+
<MapPin className="w-3 h-3 text-rose-500" />
|
| 90 |
+
{address}
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
{/* Footer */}
|
| 97 |
+
<div className="p-4 bg-white border-t border-gray-100 text-center">
|
| 98 |
+
<p className="text-xs text-gray-400">{t.footerRights}</p>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
);
|
| 103 |
+
};
|
components/AdminDashboard.tsx
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { X, Users, Settings, Download, Save, LogIn, Cloud, RefreshCw, ChevronDown, ChevronUp, Link as LinkIcon, Image as ImageIcon, MessageSquare } from 'lucide-react';
|
| 3 |
+
import { Language, LeadData, AdminConfig, UserAccount, FeedbackItem } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface AdminDashboardProps {
|
| 7 |
+
isVisible: boolean;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
language: Language;
|
| 10 |
+
leads: LeadData[];
|
| 11 |
+
feedback?: FeedbackItem[];
|
| 12 |
+
config: AdminConfig;
|
| 13 |
+
allUsers: UserAccount[];
|
| 14 |
+
currentUser?: UserAccount;
|
| 15 |
+
onUpdateConfig: (newConfig: AdminConfig) => void;
|
| 16 |
+
onUpdateLeadStatus: (id: string, status: LeadData['status']) => void;
|
| 17 |
+
onDeleteLead: (id: string) => void;
|
| 18 |
+
onUpdateUser: (id: string, updates: Partial<UserAccount>) => void;
|
| 19 |
+
showToast: (msg: string) => void;
|
| 20 |
+
onAdminLoginSuccess: () => void;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const CollapsibleSection = ({ title, children, defaultOpen = false }: { title: string, children?: React.ReactNode, defaultOpen?: boolean }) => {
|
| 24 |
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
| 25 |
+
return (
|
| 26 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden mb-4 shadow-sm">
|
| 27 |
+
<button
|
| 28 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 29 |
+
className="w-full flex justify-between items-center p-4 bg-gray-50 hover:bg-gray-100 transition-colors font-bold text-gray-800"
|
| 30 |
+
>
|
| 31 |
+
{title}
|
| 32 |
+
{isOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
| 33 |
+
</button>
|
| 34 |
+
{isOpen && <div className="p-4 bg-white animate-fade-in">{children}</div>}
|
| 35 |
+
</div>
|
| 36 |
+
);
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
export const AdminDashboard: React.FC<AdminDashboardProps> = ({
|
| 40 |
+
isVisible, onClose, language, leads, feedback = [], config, onUpdateConfig, onUpdateLeadStatus, onDeleteLead, showToast, onAdminLoginSuccess
|
| 41 |
+
}) => {
|
| 42 |
+
const [activeTab, setActiveTab] = useState<'leads' | 'settings' | 'feedback'>('leads');
|
| 43 |
+
const [tempConfig, setTempConfig] = useState<AdminConfig>(config);
|
| 44 |
+
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
| 45 |
+
const [password, setPassword] = useState('');
|
| 46 |
+
const t = TRANSLATIONS[language];
|
| 47 |
+
|
| 48 |
+
// Sync temp config when prop changes
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
setTempConfig(config);
|
| 51 |
+
}, [config]);
|
| 52 |
+
|
| 53 |
+
if (!isVisible) return null;
|
| 54 |
+
|
| 55 |
+
const handleLogin = (e: React.FormEvent) => {
|
| 56 |
+
e.preventDefault();
|
| 57 |
+
if (password === 'admin888') {
|
| 58 |
+
setIsLoggedIn(true);
|
| 59 |
+
onAdminLoginSuccess();
|
| 60 |
+
} else { alert("密码错误"); }
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-gray-900/80 backdrop-blur-sm">
|
| 65 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden relative">
|
| 66 |
+
|
| 67 |
+
{!isLoggedIn && (
|
| 68 |
+
<div className="absolute inset-0 z-50 bg-gray-900 flex flex-col items-center justify-center text-white">
|
| 69 |
+
<h2 className="text-2xl font-bold font-serif mb-6">{t.adminLoginTitle || '员工登录'}</h2>
|
| 70 |
+
<form onSubmit={handleLogin} className="flex flex-col gap-4 w-64">
|
| 71 |
+
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="请输入管理密码" className="p-3 rounded bg-gray-800 border border-gray-700 text-center outline-none focus:border-rose-500 placeholder:text-gray-500" />
|
| 72 |
+
<button type="submit" className="p-3 bg-rose-600 rounded font-bold hover:bg-rose-700 transition-colors">登录后台</button>
|
| 73 |
+
</form>
|
| 74 |
+
</div>
|
| 75 |
+
)}
|
| 76 |
+
|
| 77 |
+
<div className="bg-gray-900 text-white p-4 flex justify-between items-center shadow-md z-10">
|
| 78 |
+
<div className="flex items-center gap-2">
|
| 79 |
+
<Settings className="w-5 h-5 text-rose-500" />
|
| 80 |
+
<h2 className="text-lg font-bold">{t.adminTitle}</h2>
|
| 81 |
+
</div>
|
| 82 |
+
<button onClick={onClose} className="p-2 hover:bg-gray-800 rounded-full"><X className="w-5 h-5" /></button>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div className="flex flex-1 overflow-hidden">
|
| 86 |
+
<div className="w-48 bg-gray-50 border-r border-gray-200 p-4 space-y-2 shrink-0">
|
| 87 |
+
<button onClick={() => setActiveTab('leads')} className={`w-full text-left p-3 rounded-lg font-medium transition-all ${activeTab === 'leads' ? 'bg-white shadow text-rose-600 border border-rose-100' : 'text-gray-600 hover:bg-gray-200'}`}>客资管理 (Leads)</button>
|
| 88 |
+
<button onClick={() => setActiveTab('feedback')} className={`w-full text-left p-3 rounded-lg font-medium transition-all ${activeTab === 'feedback' ? 'bg-white shadow text-rose-600 border border-rose-100' : 'text-gray-600 hover:bg-gray-200'}`}>用户反馈 (Feedback)</button>
|
| 89 |
+
<button onClick={() => setActiveTab('settings')} className={`w-full text-left p-3 rounded-lg font-medium transition-all ${activeTab === 'settings' ? 'bg-white shadow text-rose-600 border border-rose-100' : 'text-gray-600 hover:bg-gray-200'}`}>系统设置 (Settings)</button>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar bg-gray-50/50">
|
| 93 |
+
{activeTab === 'leads' && (
|
| 94 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
| 95 |
+
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
| 96 |
+
<h3 className="font-bold text-gray-800">客资列表 ({leads.length})</h3>
|
| 97 |
+
<button className="text-xs flex items-center gap-1 text-gray-500 hover:text-gray-800">
|
| 98 |
+
<Download className="w-3 h-3" /> 导出表格
|
| 99 |
+
</button>
|
| 100 |
+
</div>
|
| 101 |
+
<div className="overflow-x-auto">
|
| 102 |
+
<table className="w-full text-sm">
|
| 103 |
+
<thead>
|
| 104 |
+
<tr className="bg-gray-100 text-left text-gray-600">
|
| 105 |
+
<th className="p-3 font-semibold">姓名</th>
|
| 106 |
+
<th className="p-3 font-semibold">电话</th>
|
| 107 |
+
<th className="p-3 font-semibold">意向服务</th>
|
| 108 |
+
<th className="p-3 font-semibold">CRM同步</th>
|
| 109 |
+
<th className="p-3 font-semibold">状态</th>
|
| 110 |
+
<th className="p-3 font-semibold">操作</th>
|
| 111 |
+
</tr>
|
| 112 |
+
</thead>
|
| 113 |
+
<tbody className="divide-y divide-gray-100">
|
| 114 |
+
{leads.map(lead => (
|
| 115 |
+
<tr key={lead.id} className="hover:bg-gray-50 transition-colors">
|
| 116 |
+
<td className="p-3 font-medium">{lead.name}</td>
|
| 117 |
+
<td className="p-3 text-gray-600">{lead.phone}</td>
|
| 118 |
+
<td className="p-3 text-gray-500">{lead.service || '-'}</td>
|
| 119 |
+
<td className="p-3">
|
| 120 |
+
{lead.syncStatus === 'synced' ? (
|
| 121 |
+
<span className="flex items-center gap-1 text-green-600 text-xs font-bold"><Cloud className="w-3 h-3"/> 已同步</span>
|
| 122 |
+
) : (
|
| 123 |
+
<span className="text-gray-400 text-xs">-</span>
|
| 124 |
+
)}
|
| 125 |
+
</td>
|
| 126 |
+
<td className="p-3">
|
| 127 |
+
<select
|
| 128 |
+
value={lead.status}
|
| 129 |
+
onChange={e => onUpdateLeadStatus(lead.id, e.target.value as any)}
|
| 130 |
+
className={`border rounded-lg p-1.5 text-xs font-bold outline-none cursor-pointer ${
|
| 131 |
+
lead.status === 'new' ? 'bg-blue-50 text-blue-700 border-blue-200' :
|
| 132 |
+
lead.status === 'contacted' ? 'bg-amber-50 text-amber-700 border-amber-200' :
|
| 133 |
+
'bg-green-50 text-green-700 border-green-200'
|
| 134 |
+
}`}
|
| 135 |
+
>
|
| 136 |
+
<option value="new">新客</option>
|
| 137 |
+
<option value="contacted">已联系</option>
|
| 138 |
+
<option value="booked">已成交</option>
|
| 139 |
+
</select>
|
| 140 |
+
</td>
|
| 141 |
+
<td className="p-3">
|
| 142 |
+
<button onClick={() => onDeleteLead(lead.id)} className="text-red-400 hover:text-red-600 p-1" title="删除"><X className="w-4 h-4" /></button>
|
| 143 |
+
</td>
|
| 144 |
+
</tr>
|
| 145 |
+
))}
|
| 146 |
+
{leads.length === 0 && (
|
| 147 |
+
<tr>
|
| 148 |
+
<td colSpan={6} className="p-8 text-center text-gray-400">暂无客资数据</td>
|
| 149 |
+
</tr>
|
| 150 |
+
)}
|
| 151 |
+
</tbody>
|
| 152 |
+
</table>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
|
| 157 |
+
{activeTab === 'feedback' && (
|
| 158 |
+
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
| 159 |
+
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
| 160 |
+
<h3 className="font-bold text-gray-800">用户反馈 ({feedback.length})</h3>
|
| 161 |
+
</div>
|
| 162 |
+
<div className="overflow-x-auto">
|
| 163 |
+
<table className="w-full text-sm">
|
| 164 |
+
<thead>
|
| 165 |
+
<tr className="bg-gray-100 text-left text-gray-600">
|
| 166 |
+
<th className="p-3 font-semibold">类型</th>
|
| 167 |
+
<th className="p-3 font-semibold">评分</th>
|
| 168 |
+
<th className="p-3 font-semibold">内容</th>
|
| 169 |
+
<th className="p-3 font-semibold">联系方式</th>
|
| 170 |
+
<th className="p-3 font-semibold">时间</th>
|
| 171 |
+
</tr>
|
| 172 |
+
</thead>
|
| 173 |
+
<tbody className="divide-y divide-gray-100">
|
| 174 |
+
{feedback.map(item => (
|
| 175 |
+
<tr key={item.id} className="hover:bg-gray-50 transition-colors">
|
| 176 |
+
<td className="p-3">
|
| 177 |
+
<span className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${
|
| 178 |
+
item.type === 'bug' ? 'bg-red-100 text-red-600' :
|
| 179 |
+
item.type === 'suggestion' ? 'bg-amber-100 text-amber-600' : 'bg-blue-100 text-blue-600'
|
| 180 |
+
}`}>
|
| 181 |
+
{item.type}
|
| 182 |
+
</span>
|
| 183 |
+
</td>
|
| 184 |
+
<td className="p-3 font-bold text-yellow-500">{item.rating} ★</td>
|
| 185 |
+
<td className="p-3 text-gray-700 max-w-xs truncate" title={item.content}>{item.content}</td>
|
| 186 |
+
<td className="p-3 text-gray-500 text-xs">{item.contact || '-'}</td>
|
| 187 |
+
<td className="p-3 text-gray-400 text-xs">{new Date(item.timestamp).toLocaleString()}</td>
|
| 188 |
+
</tr>
|
| 189 |
+
))}
|
| 190 |
+
{feedback.length === 0 && (
|
| 191 |
+
<tr>
|
| 192 |
+
<td colSpan={5} className="p-8 text-center text-gray-400">暂无反馈数据</td>
|
| 193 |
+
</tr>
|
| 194 |
+
)}
|
| 195 |
+
</tbody>
|
| 196 |
+
</table>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
+
|
| 201 |
+
{activeTab === 'settings' && (
|
| 202 |
+
<div className="max-w-3xl mx-auto space-y-2">
|
| 203 |
+
<div className="flex justify-between items-center mb-6">
|
| 204 |
+
<h3 className="text-xl font-bold text-gray-800">小程序/网页配置</h3>
|
| 205 |
+
<button
|
| 206 |
+
onClick={() => { onUpdateConfig(tempConfig); showToast("配置已保存!"); }}
|
| 207 |
+
className="px-6 py-2.5 bg-rose-600 hover:bg-rose-700 text-white rounded-xl font-bold shadow-lg shadow-rose-200 flex items-center gap-2 transition-all active:scale-95"
|
| 208 |
+
>
|
| 209 |
+
<Save className="w-4 h-4" /> 保存设置
|
| 210 |
+
</button>
|
| 211 |
+
</div>
|
| 212 |
+
|
| 213 |
+
<CollapsibleSection title="📍 基础信息 & 品牌设置" defaultOpen>
|
| 214 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 215 |
+
<div className="col-span-2">
|
| 216 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">顶部活动通知文案</label>
|
| 217 |
+
<input type="text" value={tempConfig.promoText} onChange={e => setTempConfig({...tempConfig, promoText: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 218 |
+
</div>
|
| 219 |
+
<div>
|
| 220 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">联系电话</label>
|
| 221 |
+
<input type="text" value={tempConfig.contactPhone} onChange={e => setTempConfig({...tempConfig, contactPhone: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 222 |
+
</div>
|
| 223 |
+
<div>
|
| 224 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">门店地址</label>
|
| 225 |
+
<input type="text" value={tempConfig.footerAddress} onChange={e => setTempConfig({...tempConfig, footerAddress: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 226 |
+
</div>
|
| 227 |
+
<div className="col-span-2">
|
| 228 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">Logo URL (品牌标志图片)</label>
|
| 229 |
+
<div className="flex gap-2 relative">
|
| 230 |
+
<ImageIcon className="absolute left-3 top-3 w-4 h-4 text-gray-400" />
|
| 231 |
+
<input type="text" value={tempConfig.logoUrl || ''} onChange={e => setTempConfig({...tempConfig, logoUrl: e.target.value})} placeholder="https://example.com/logo.png" className="w-full p-2.5 pl-10 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 232 |
+
{tempConfig.logoUrl && <img src={tempConfig.logoUrl} alt="Logo" className="w-10 h-10 object-contain rounded border bg-white" />}
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
<div className="col-span-2">
|
| 236 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">预约/客服二维码图片链接 (QR Code URL)</label>
|
| 237 |
+
<div className="flex gap-2">
|
| 238 |
+
<input type="text" value={tempConfig.qrCodeUrl || ''} onChange={e => setTempConfig({...tempConfig, qrCodeUrl: e.target.value})} placeholder="https://example.com/my-qr.png" className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 239 |
+
{tempConfig.qrCodeUrl && <img src={tempConfig.qrCodeUrl} alt="Preview" className="w-10 h-10 object-cover rounded border" />}
|
| 240 |
+
</div>
|
| 241 |
+
<p className="text-[10px] text-gray-400 mt-1">
|
| 242 |
+
提示: 如果您使用家庭宽带部署,请记得在 URL 中加上端口号 (例如 :8080)
|
| 243 |
+
</p>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
</CollapsibleSection>
|
| 247 |
+
|
| 248 |
+
<CollapsibleSection title="📖 关于我们内容设置">
|
| 249 |
+
<div className="space-y-4">
|
| 250 |
+
<div>
|
| 251 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">品牌故事 (Brand Story)</label>
|
| 252 |
+
<textarea rows={3} value={tempConfig.aboutStory || ''} onChange={e => setTempConfig({...tempConfig, aboutStory: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" placeholder="请输入品牌故事..." />
|
| 253 |
+
</div>
|
| 254 |
+
<div>
|
| 255 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">服务理念 (Philosophy)</label>
|
| 256 |
+
<textarea rows={2} value={tempConfig.aboutPhilosophy || ''} onChange={e => setTempConfig({...tempConfig, aboutPhilosophy: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" placeholder="请输入服务理念..." />
|
| 257 |
+
</div>
|
| 258 |
+
<div>
|
| 259 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">基地环境 (Location)</label>
|
| 260 |
+
<textarea rows={2} value={tempConfig.aboutLocation || ''} onChange={e => setTempConfig({...tempConfig, aboutLocation: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" placeholder="描述拍摄基地环境..." />
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
</CollapsibleSection>
|
| 264 |
+
|
| 265 |
+
<CollapsibleSection title="💎 会员与积分策略">
|
| 266 |
+
<div className="grid grid-cols-2 gap-4">
|
| 267 |
+
<div>
|
| 268 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">奖励: 分享海报 (积分)</label>
|
| 269 |
+
<div className="relative">
|
| 270 |
+
<input type="number" value={tempConfig.pointsShare ?? 10} onChange={e => setTempConfig({...tempConfig, pointsShare: parseInt(e.target.value)})} className="w-full p-2.5 pl-8 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 271 |
+
<span className="absolute left-3 top-2.5 text-gray-400 font-bold">Pt</span>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
<div>
|
| 275 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">奖励: 邀请好友 (积分)</label>
|
| 276 |
+
<div className="relative">
|
| 277 |
+
<input type="number" value={tempConfig.pointsInvite ?? 50} onChange={e => setTempConfig({...tempConfig, pointsInvite: parseInt(e.target.value)})} className="w-full p-2.5 pl-8 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 278 |
+
<span className="absolute left-3 top-2.5 text-gray-400 font-bold">Pt</span>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
<div>
|
| 282 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">奖励: 预约留资 (积分)</label>
|
| 283 |
+
<div className="relative">
|
| 284 |
+
<input type="number" value={tempConfig.pointsBook ?? 100} onChange={e => setTempConfig({...tempConfig, pointsBook: parseInt(e.target.value)})} className="w-full p-2.5 pl-8 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 285 |
+
<span className="absolute left-3 top-2.5 text-gray-400 font-bold">Pt</span>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
<div>
|
| 289 |
+
<label className="block text-xs font-bold text-rose-500 mb-1">消耗: 兑换VIP (积分)</label>
|
| 290 |
+
<div className="relative">
|
| 291 |
+
<input type="number" value={tempConfig.pointsVipCost ?? 100} onChange={e => setTempConfig({...tempConfig, pointsVipCost: parseInt(e.target.value)})} className="w-full p-2.5 pl-8 border border-rose-200 bg-rose-50 text-rose-700 font-bold rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 292 |
+
<span className="absolute left-3 top-2.5 text-rose-400 font-bold">Pt</span>
|
| 293 |
+
</div>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</CollapsibleSection>
|
| 297 |
+
|
| 298 |
+
<CollapsibleSection title="🚀 裂变营销与红包">
|
| 299 |
+
<div className="space-y-4">
|
| 300 |
+
<div>
|
| 301 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">分享标题 (Share Title)</label>
|
| 302 |
+
<input type="text" value={tempConfig.shareTitle || ''} onChange={e => setTempConfig({...tempConfig, shareTitle: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 303 |
+
</div>
|
| 304 |
+
<div>
|
| 305 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">分享描述 (Share Description)</label>
|
| 306 |
+
<input type="text" value={tempConfig.shareDesc || ''} onChange={e => setTempConfig({...tempConfig, shareDesc: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 307 |
+
</div>
|
| 308 |
+
<div className="flex gap-4">
|
| 309 |
+
<div className="flex-1">
|
| 310 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">红包最大金额 (¥)</label>
|
| 311 |
+
<input type="number" value={tempConfig.redPacketMax || 2000} onChange={e => setTempConfig({...tempConfig, redPacketMax: parseInt(e.target.value)})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none" />
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
</CollapsibleSection>
|
| 316 |
+
|
| 317 |
+
<CollapsibleSection title="🔌 系统集成 (CRM/AI)">
|
| 318 |
+
<div className="space-y-4">
|
| 319 |
+
<div>
|
| 320 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">CRM 接口地址 (API Endpoint)</label>
|
| 321 |
+
<div className="relative">
|
| 322 |
+
<Cloud className="absolute left-3 top-3 w-4 h-4 text-gray-400" />
|
| 323 |
+
<input type="text" value={tempConfig.crmApiUrl || ''} onChange={e => setTempConfig({...tempConfig, crmApiUrl: e.target.value})} className="w-full p-2.5 pl-10 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none font-mono text-xs" placeholder="https://api.crm.com/leads" />
|
| 324 |
+
</div>
|
| 325 |
+
</div>
|
| 326 |
+
<div>
|
| 327 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">CRM API 密钥</label>
|
| 328 |
+
<input type="password" value={tempConfig.crmApiKey || ''} onChange={e => setTempConfig({...tempConfig, crmApiKey: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-rose-500 outline-none font-mono" />
|
| 329 |
+
</div>
|
| 330 |
+
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
| 331 |
+
<h4 className="font-bold text-blue-700 mb-2 text-xs uppercase tracking-wider flex items-center gap-2">
|
| 332 |
+
<Cloud className="w-4 h-4" /> Gemini AI Configuration
|
| 333 |
+
</h4>
|
| 334 |
+
<div className="space-y-3">
|
| 335 |
+
<div>
|
| 336 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">API Key</label>
|
| 337 |
+
<input type="password" value={tempConfig.geminiApiKey || ''} onChange={e => setTempConfig({...tempConfig, geminiApiKey: e.target.value})} className="w-full p-2.5 border border-gray-200 rounded-lg text-sm focus:border-blue-500 outline-none font-mono" placeholder="AIzSy..." />
|
| 338 |
+
</div>
|
| 339 |
+
<div>
|
| 340 |
+
<label className="block text-xs font-bold text-gray-500 mb-1">API Base URL (Proxy/Mirror) - Optional</label>
|
| 341 |
+
<div className="relative">
|
| 342 |
+
<LinkIcon className="absolute left-3 top-3 w-4 h-4 text-gray-400" />
|
| 343 |
+
<input type="text" value={tempConfig.geminiApiUrl || ''} onChange={e => setTempConfig({...tempConfig, geminiApiUrl: e.target.value})} className="w-full p-2.5 pl-10 border border-gray-200 rounded-lg text-sm focus:border-blue-500 outline-none font-mono" placeholder="https://my-proxy.com (Leave empty for default)" />
|
| 344 |
+
</div>
|
| 345 |
+
<p className="text-[10px] text-gray-400 mt-1">
|
| 346 |
+
如果您在中国大陆地区,请配置反向代理地址以解决网络问题。
|
| 347 |
+
</p>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
</CollapsibleSection>
|
| 353 |
+
</div>
|
| 354 |
+
)}
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
);
|
| 360 |
+
};
|
components/AnalysisModal.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
import React from 'react';
|
| 4 |
+
import { X, ScanFace, CheckCircle, ArrowRight, Sparkles } from 'lucide-react';
|
| 5 |
+
import { Language } from '../types';
|
| 6 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 7 |
+
|
| 8 |
+
interface AnalysisModalProps {
|
| 9 |
+
isVisible: boolean;
|
| 10 |
+
onClose: () => void;
|
| 11 |
+
language: Language;
|
| 12 |
+
result: { faceShape: string; skinTone: string; bestVibe: string; };
|
| 13 |
+
onUnlock: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export const AnalysisModal: React.FC<AnalysisModalProps> = ({
|
| 17 |
+
isVisible, onClose, language, result, onUnlock
|
| 18 |
+
}) => {
|
| 19 |
+
const t = TRANSLATIONS[language];
|
| 20 |
+
|
| 21 |
+
if (!isVisible) return null;
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-gray-900/90 backdrop-blur-md animate-fade-in">
|
| 25 |
+
<div className="w-full max-w-md bg-black text-white rounded-3xl overflow-hidden border border-gray-800 shadow-2xl relative">
|
| 26 |
+
{/* Header */}
|
| 27 |
+
<div className="p-6 text-center border-b border-gray-800 relative">
|
| 28 |
+
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-rose-500 to-transparent"></div>
|
| 29 |
+
<div className="w-16 h-16 mx-auto bg-gray-900 rounded-full flex items-center justify-center border border-gray-700 mb-4 shadow-[0_0_30px_rgba(244,63,94,0.3)]">
|
| 30 |
+
<ScanFace className="w-8 h-8 text-rose-500" />
|
| 31 |
+
</div>
|
| 32 |
+
<h2 className="text-2xl font-serif font-bold tracking-wide text-rose-100">{t.analysisTitle}</h2>
|
| 33 |
+
<p className="text-xs text-gray-500 font-mono mt-1 tracking-widest uppercase">{t.analysisSubtitle}</p>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
{/* Report Content */}
|
| 37 |
+
<div className="p-8 space-y-6">
|
| 38 |
+
<div className="flex items-center justify-between group">
|
| 39 |
+
<div>
|
| 40 |
+
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{t.lblFaceShape}</p>
|
| 41 |
+
<p className="text-lg font-bold text-white group-hover:text-rose-400 transition-colors">{result.faceShape}</p>
|
| 42 |
+
</div>
|
| 43 |
+
<div className="w-10 h-10 rounded-full border border-gray-700 flex items-center justify-center">
|
| 44 |
+
<div className="w-6 h-8 border-2 border-white rounded-[40%] opacity-80"></div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div className="w-full h-px bg-gray-800"></div>
|
| 49 |
+
|
| 50 |
+
<div className="flex items-center justify-between group">
|
| 51 |
+
<div>
|
| 52 |
+
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{t.lblSkinTone}</p>
|
| 53 |
+
<p className="text-lg font-bold text-white group-hover:text-rose-400 transition-colors">{result.skinTone}</p>
|
| 54 |
+
</div>
|
| 55 |
+
<div className="w-8 h-8 rounded-full bg-[#fde3e3] border-2 border-white/20"></div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div className="w-full h-px bg-gray-800"></div>
|
| 59 |
+
|
| 60 |
+
<div className="bg-gradient-to-r from-rose-900/50 to-gray-900 p-4 rounded-xl border border-rose-500/30 flex items-center gap-4">
|
| 61 |
+
<div className="w-10 h-10 bg-rose-500 rounded-full flex items-center justify-center text-white shrink-0 shadow-lg shadow-rose-900">
|
| 62 |
+
<Sparkles className="w-5 h-5" />
|
| 63 |
+
</div>
|
| 64 |
+
<div>
|
| 65 |
+
<p className="text-xs text-rose-300 font-bold uppercase mb-0.5">{t.lblRecVibe}</p>
|
| 66 |
+
<p className="text-xl font-serif font-bold text-white leading-none">{result.bestVibe}</p>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{/* Footer Action */}
|
| 72 |
+
<div className="p-6 bg-gray-900 border-t border-gray-800">
|
| 73 |
+
<button
|
| 74 |
+
onClick={onUnlock}
|
| 75 |
+
className="w-full py-4 bg-white text-black font-bold text-lg rounded-xl hover:bg-rose-50 transition-all flex items-center justify-center gap-2 group"
|
| 76 |
+
>
|
| 77 |
+
{t.pddSlashBtn} <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
| 78 |
+
</button>
|
| 79 |
+
<p className="text-center text-[10px] text-gray-600 mt-3 font-mono">
|
| 80 |
+
{t.aiConfidence}: 98.4%
|
| 81 |
+
</p>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
};
|
components/AuthModal.tsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react';
|
| 4 |
+
import { X, User, Lock, Phone, CheckCircle, ArrowRight, AlertCircle, RefreshCw, MessageSquare, Eye, EyeOff } from 'lucide-react';
|
| 5 |
+
import { Language } from '../types';
|
| 6 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 7 |
+
|
| 8 |
+
interface AuthModalProps {
|
| 9 |
+
isVisible: boolean;
|
| 10 |
+
onClose: () => void;
|
| 11 |
+
language: Language;
|
| 12 |
+
onLogin: (phone: string, password?: string) => void;
|
| 13 |
+
onRegister: (phone: string, name: string, pass: string) => void;
|
| 14 |
+
onResetPassword?: (phone: string, newPass: string) => Promise<boolean>;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export const AuthModal: React.FC<AuthModalProps> = ({ isVisible, onClose, language, onLogin, onRegister, onResetPassword }) => {
|
| 18 |
+
const [mode, setMode] = useState<'login' | 'register' | 'reset'>('login');
|
| 19 |
+
const [formData, setFormData] = useState({ phone: '', name: '', password: '', confirmPassword: '', code: '' });
|
| 20 |
+
const [error, setError] = useState<string | null>(null);
|
| 21 |
+
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
| 22 |
+
const [isCodeSent, setIsCodeSent] = useState(false);
|
| 23 |
+
const [showPassword, setShowPassword] = useState(false);
|
| 24 |
+
const t = TRANSLATIONS[language];
|
| 25 |
+
|
| 26 |
+
if (!isVisible) return null;
|
| 27 |
+
|
| 28 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 29 |
+
e.preventDefault();
|
| 30 |
+
setError(null);
|
| 31 |
+
setSuccessMsg(null);
|
| 32 |
+
|
| 33 |
+
if (mode === 'login') {
|
| 34 |
+
onLogin(formData.phone, formData.password);
|
| 35 |
+
} else if (mode === 'register') {
|
| 36 |
+
// Registration Validation
|
| 37 |
+
if (!formData.name.trim()) {
|
| 38 |
+
setError("Name is required");
|
| 39 |
+
return;
|
| 40 |
+
}
|
| 41 |
+
if (formData.password.length < 6) {
|
| 42 |
+
setError("Password must be at least 6 characters");
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
if (formData.password !== formData.confirmPassword) {
|
| 46 |
+
setError(t.authPassMismatch);
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
onRegister(formData.phone, formData.name, formData.password);
|
| 50 |
+
setFormData({ phone: '', name: '', password: '', confirmPassword: '', code: '' });
|
| 51 |
+
onClose();
|
| 52 |
+
} else if (mode === 'reset') {
|
| 53 |
+
// Reset Password Flow
|
| 54 |
+
if (!isCodeSent) {
|
| 55 |
+
// Simulate Sending Code
|
| 56 |
+
if (formData.phone.length < 5) {
|
| 57 |
+
setError("Please enter a valid phone number");
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
setIsCodeSent(true);
|
| 61 |
+
setSuccessMsg(`${t.authCodeSent} 8888`); // Simulation
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Verify Step
|
| 66 |
+
if (formData.code !== '8888') {
|
| 67 |
+
setError("Invalid verification code (Demo: 8888)");
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
if (formData.password.length < 6) {
|
| 71 |
+
setError("New password must be at least 6 characters");
|
| 72 |
+
return;
|
| 73 |
+
}
|
| 74 |
+
if (formData.password !== formData.confirmPassword) {
|
| 75 |
+
setError(t.authPassMismatch);
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (onResetPassword) {
|
| 80 |
+
const success = await onResetPassword(formData.phone, formData.password);
|
| 81 |
+
if (success) {
|
| 82 |
+
setSuccessMsg(t.authResetSuccess);
|
| 83 |
+
setTimeout(() => {
|
| 84 |
+
setMode('login');
|
| 85 |
+
setIsCodeSent(false);
|
| 86 |
+
setSuccessMsg(null);
|
| 87 |
+
setFormData({ ...formData, password: '', confirmPassword: '', code: '' });
|
| 88 |
+
}, 2000);
|
| 89 |
+
} else {
|
| 90 |
+
setError(t.authPhoneNotFound);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const toggleMode = (newMode: 'login' | 'register' | 'reset') => {
|
| 97 |
+
setMode(newMode);
|
| 98 |
+
setFormData({ phone: '', name: '', password: '', confirmPassword: '', code: '' }); // Clear form
|
| 99 |
+
setError(null);
|
| 100 |
+
setSuccessMsg(null);
|
| 101 |
+
setIsCodeSent(false);
|
| 102 |
+
setShowPassword(false);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
|
| 107 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden relative">
|
| 108 |
+
<button onClick={onClose} className="absolute top-4 right-4 p-1 rounded-full hover:bg-gray-100 transition-colors z-10">
|
| 109 |
+
<X className="w-5 h-5 text-gray-500" />
|
| 110 |
+
</button>
|
| 111 |
+
|
| 112 |
+
<div className="p-8">
|
| 113 |
+
<div className="text-center mb-6">
|
| 114 |
+
<div className={`w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3 transition-colors ${mode === 'login' ? 'bg-rose-100 text-rose-500' : mode === 'register' ? 'bg-green-100 text-green-500' : 'bg-blue-100 text-blue-500'}`}>
|
| 115 |
+
{mode === 'login' ? <User className="w-6 h-6" /> : mode === 'register' ? <CheckCircle className="w-6 h-6" /> : <RefreshCw className="w-6 h-6" />}
|
| 116 |
+
</div>
|
| 117 |
+
<h2 className="text-2xl font-serif font-bold text-gray-900">
|
| 118 |
+
{mode === 'login' ? t.authLogin : mode === 'register' ? t.authRegister : t.authReset}
|
| 119 |
+
</h2>
|
| 120 |
+
<p className="text-sm text-gray-500 mt-1">Romantic Life Studio</p>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{error && (
|
| 124 |
+
<div className="mb-4 p-3 bg-red-50 border border-red-100 rounded-lg flex items-center gap-2 text-xs text-red-600 font-medium animate-pulse">
|
| 125 |
+
<AlertCircle className="w-4 h-4 shrink-0" />
|
| 126 |
+
{error}
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
{successMsg && (
|
| 131 |
+
<div className="mb-4 p-3 bg-green-50 border border-green-100 rounded-lg flex items-center gap-2 text-xs text-green-600 font-medium animate-fade-in">
|
| 132 |
+
<CheckCircle className="w-4 h-4 shrink-0" />
|
| 133 |
+
{successMsg}
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
|
| 137 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 138 |
+
{mode === 'register' && (
|
| 139 |
+
<div className="relative group animate-fade-in-down">
|
| 140 |
+
<User className="absolute top-3 left-3 w-5 h-5 text-gray-400 group-focus-within:text-rose-500 transition-colors" />
|
| 141 |
+
<input
|
| 142 |
+
type="text"
|
| 143 |
+
placeholder={t.authNamePlace}
|
| 144 |
+
required
|
| 145 |
+
value={formData.name}
|
| 146 |
+
onChange={e => setFormData({...formData, name: e.target.value})}
|
| 147 |
+
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-200 focus:border-rose-500 outline-none bg-gray-50 focus:bg-white transition-all"
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
)}
|
| 151 |
+
|
| 152 |
+
<div className="relative group">
|
| 153 |
+
<Phone className="absolute top-3 left-3 w-5 h-5 text-gray-400 group-focus-within:text-rose-500 transition-colors" />
|
| 154 |
+
<input
|
| 155 |
+
type="tel"
|
| 156 |
+
placeholder={t.authPhonePlace}
|
| 157 |
+
required
|
| 158 |
+
disabled={mode === 'reset' && isCodeSent}
|
| 159 |
+
value={formData.phone}
|
| 160 |
+
onChange={e => setFormData({...formData, phone: e.target.value})}
|
| 161 |
+
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-200 focus:border-rose-500 outline-none bg-gray-50 focus:bg-white transition-all disabled:bg-gray-100 disabled:text-gray-500"
|
| 162 |
+
/>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
{mode === 'reset' && isCodeSent && (
|
| 166 |
+
<div className="relative group animate-fade-in-down">
|
| 167 |
+
<MessageSquare className="absolute top-3 left-3 w-5 h-5 text-gray-400 group-focus-within:text-rose-500 transition-colors" />
|
| 168 |
+
<input
|
| 169 |
+
type="text"
|
| 170 |
+
placeholder={t.authCodePlace}
|
| 171 |
+
required
|
| 172 |
+
value={formData.code}
|
| 173 |
+
onChange={e => setFormData({...formData, code: e.target.value})}
|
| 174 |
+
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-200 focus:border-rose-500 outline-none bg-gray-50 focus:bg-white transition-all"
|
| 175 |
+
/>
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
|
| 179 |
+
{/* Password Fields - Hidden for Reset step 1 */}
|
| 180 |
+
{!(mode === 'reset' && !isCodeSent) && (
|
| 181 |
+
<div className="relative group">
|
| 182 |
+
<Lock className="absolute top-3 left-3 w-5 h-5 text-gray-400 group-focus-within:text-rose-500 transition-colors" />
|
| 183 |
+
<input
|
| 184 |
+
type={showPassword ? "text" : "password"}
|
| 185 |
+
placeholder={mode === 'reset' ? t.authNewPassPlace : t.authPassPlace}
|
| 186 |
+
required
|
| 187 |
+
value={formData.password}
|
| 188 |
+
onChange={e => setFormData({...formData, password: e.target.value})}
|
| 189 |
+
className="w-full pl-10 pr-10 py-2.5 rounded-lg border border-gray-200 focus:border-rose-500 outline-none bg-gray-50 focus:bg-white transition-all"
|
| 190 |
+
/>
|
| 191 |
+
<button
|
| 192 |
+
type="button"
|
| 193 |
+
onClick={() => setShowPassword(!showPassword)}
|
| 194 |
+
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 focus:outline-none"
|
| 195 |
+
tabIndex={-1}
|
| 196 |
+
>
|
| 197 |
+
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
| 198 |
+
</button>
|
| 199 |
+
</div>
|
| 200 |
+
)}
|
| 201 |
+
|
| 202 |
+
{(mode === 'register' || (mode === 'reset' && isCodeSent)) && (
|
| 203 |
+
<div className="relative group animate-fade-in-down">
|
| 204 |
+
<CheckCircle className="absolute top-3 left-3 w-5 h-5 text-gray-400 group-focus-within:text-rose-500 transition-colors" />
|
| 205 |
+
<input
|
| 206 |
+
type="password"
|
| 207 |
+
placeholder={t.authConfirmPassPlace}
|
| 208 |
+
required
|
| 209 |
+
value={formData.confirmPassword}
|
| 210 |
+
onChange={e => setFormData({...formData, confirmPassword: e.target.value})}
|
| 211 |
+
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-200 focus:border-rose-500 outline-none bg-gray-50 focus:bg-white transition-all"
|
| 212 |
+
/>
|
| 213 |
+
</div>
|
| 214 |
+
)}
|
| 215 |
+
|
| 216 |
+
<button type="submit" className={`w-full py-3 text-white rounded-xl font-bold shadow-lg transition-all flex items-center justify-center gap-2 mt-2 ${mode === 'login' ? 'bg-rose-600 hover:bg-rose-700 shadow-rose-200' : mode === 'reset' ? 'bg-blue-600 hover:bg-blue-700 shadow-blue-200' : 'bg-green-600 hover:bg-green-700 shadow-green-200'}`}>
|
| 217 |
+
{mode === 'login' ? t.authLogin : mode === 'register' ? t.authRegister : isCodeSent ? t.authSubmit : t.authSendCode}
|
| 218 |
+
{!(mode === 'reset' && !isCodeSent) && <ArrowRight className="w-4 h-4" />}
|
| 219 |
+
</button>
|
| 220 |
+
</form>
|
| 221 |
+
|
| 222 |
+
{mode === 'login' && (
|
| 223 |
+
<div className="mt-3 text-right">
|
| 224 |
+
<button onClick={() => toggleMode('reset')} className="text-xs text-gray-500 hover:text-rose-500 transition-colors">
|
| 225 |
+
{t.authForgotPass}
|
| 226 |
+
</button>
|
| 227 |
+
</div>
|
| 228 |
+
)}
|
| 229 |
+
|
| 230 |
+
<div className="mt-6 text-center border-t border-gray-100 pt-4">
|
| 231 |
+
<button
|
| 232 |
+
onClick={() => toggleMode(mode === 'login' ? 'register' : 'login')}
|
| 233 |
+
className="text-sm text-rose-500 font-bold hover:text-rose-600 transition-colors hover:underline"
|
| 234 |
+
>
|
| 235 |
+
{mode === 'login' ? t.authNoAccount : t.authHasAccount}
|
| 236 |
+
</button>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
);
|
| 242 |
+
};
|
components/ErrorBoundary.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { ErrorInfo, ReactNode } from 'react';
|
| 2 |
+
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
children?: ReactNode;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
interface State {
|
| 9 |
+
hasError: boolean;
|
| 10 |
+
error?: Error;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export class ErrorBoundary extends React.Component<Props, State> {
|
| 14 |
+
public state: State = {
|
| 15 |
+
hasError: false
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
public static getDerivedStateFromError(error: Error): State {
|
| 19 |
+
return { hasError: true, error };
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
| 23 |
+
console.error('Uncaught error:', error, errorInfo);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
public render() {
|
| 27 |
+
if (this.state.hasError) {
|
| 28 |
+
return (
|
| 29 |
+
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-4 text-center">
|
| 30 |
+
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center text-red-500 mb-4">
|
| 31 |
+
<AlertTriangle className="w-8 h-8" />
|
| 32 |
+
</div>
|
| 33 |
+
<h1 className="text-2xl font-bold text-gray-800 mb-2">出错了</h1>
|
| 34 |
+
<p className="text-gray-600 mb-6 max-w-xs">
|
| 35 |
+
抱歉,系统遇到了一些问题。<br/>
|
| 36 |
+
<span className="text-xs text-gray-400 mt-2 block font-mono bg-gray-100 p-2 rounded">
|
| 37 |
+
{this.state.error?.message || 'Unknown Error'}
|
| 38 |
+
</span>
|
| 39 |
+
</p>
|
| 40 |
+
<button
|
| 41 |
+
className="flex items-center gap-2 px-6 py-3 bg-rose-600 text-white rounded-xl font-bold hover:bg-rose-700 transition-all shadow-lg"
|
| 42 |
+
onClick={() => window.location.reload()}
|
| 43 |
+
>
|
| 44 |
+
<RefreshCw className="w-4 h-4" /> 刷新页面
|
| 45 |
+
</button>
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return this.props.children;
|
| 51 |
+
}
|
| 52 |
+
}
|
components/ExitIntentModal.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { X, Gift } from 'lucide-react';
|
| 3 |
+
import { Language } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface ExitIntentModalProps {
|
| 7 |
+
language: Language;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const ExitIntentModal: React.FC<ExitIntentModalProps> = ({ language, onClose }) => {
|
| 12 |
+
const [isVisible, setIsVisible] = useState(false);
|
| 13 |
+
const t = TRANSLATIONS[language];
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
// Only run on desktop where mouse leaves viewport
|
| 17 |
+
if (window.innerWidth < 768) return;
|
| 18 |
+
|
| 19 |
+
const handleMouseLeave = (e: MouseEvent) => {
|
| 20 |
+
if (e.clientY <= 0) {
|
| 21 |
+
// User moved mouse to top of browser (tabs/close button)
|
| 22 |
+
const hasShown = localStorage.getItem('exit_intent_shown');
|
| 23 |
+
if (!hasShown) {
|
| 24 |
+
setIsVisible(true);
|
| 25 |
+
localStorage.setItem('exit_intent_shown', 'true');
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
document.addEventListener('mouseleave', handleMouseLeave);
|
| 31 |
+
return () => document.removeEventListener('mouseleave', handleMouseLeave);
|
| 32 |
+
}, []);
|
| 33 |
+
|
| 34 |
+
if (!isVisible) return null;
|
| 35 |
+
|
| 36 |
+
const handleClose = () => {
|
| 37 |
+
setIsVisible(false);
|
| 38 |
+
onClose();
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
|
| 43 |
+
<div className="bg-white rounded-2xl shadow-2xl max-w-sm w-full overflow-hidden relative text-center p-8">
|
| 44 |
+
<button
|
| 45 |
+
onClick={handleClose}
|
| 46 |
+
className="absolute top-2 right-2 p-1.5 hover:bg-gray-100 rounded-full text-gray-400"
|
| 47 |
+
>
|
| 48 |
+
<X className="w-5 h-5" />
|
| 49 |
+
</button>
|
| 50 |
+
|
| 51 |
+
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4 text-red-500 animate-bounce">
|
| 52 |
+
<Gift className="w-8 h-8" />
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<h3 className="text-2xl font-bold text-gray-900 mb-2">等一下!</h3>
|
| 56 |
+
<p className="text-gray-600 mb-6 text-sm">
|
| 57 |
+
现在离开就错过了 <strong>500元</strong> 现金抵用券!<br/>
|
| 58 |
+
仅限今日,留下联系方式即可领取。
|
| 59 |
+
</p>
|
| 60 |
+
|
| 61 |
+
<div className="space-y-3">
|
| 62 |
+
<button
|
| 63 |
+
onClick={handleClose} // In real app, open consult modal
|
| 64 |
+
className="w-full py-3 bg-red-600 hover:bg-red-700 text-white font-bold rounded-xl shadow-lg shadow-red-200 transition-all"
|
| 65 |
+
>
|
| 66 |
+
领取优惠
|
| 67 |
+
</button>
|
| 68 |
+
<button
|
| 69 |
+
onClick={handleClose}
|
| 70 |
+
className="w-full py-2 text-gray-400 text-xs hover:text-gray-600 font-medium"
|
| 71 |
+
>
|
| 72 |
+
忍痛放弃
|
| 73 |
+
</button>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
);
|
| 78 |
+
};
|
components/Features.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { ShieldCheck, HeartHandshake, Crown, Camera, Sparkles } from 'lucide-react';
|
| 3 |
+
import { Language } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface FeaturesProps {
|
| 7 |
+
language: Language;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const Features: React.FC<FeaturesProps> = ({ language }) => {
|
| 11 |
+
const t = TRANSLATIONS[language];
|
| 12 |
+
|
| 13 |
+
const features = [
|
| 14 |
+
{
|
| 15 |
+
icon: <ShieldCheck className="w-8 h-8 text-rose-500" />,
|
| 16 |
+
title: t.feat1Title,
|
| 17 |
+
desc: t.feat1Desc,
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
icon: <HeartHandshake className="w-8 h-8 text-rose-500" />,
|
| 21 |
+
title: t.feat2Title,
|
| 22 |
+
desc: t.feat2Desc,
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
icon: <Crown className="w-8 h-8 text-rose-500" />,
|
| 26 |
+
title: t.feat3Title,
|
| 27 |
+
desc: t.feat3Desc,
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
icon: <Camera className="w-8 h-8 text-rose-500" />,
|
| 31 |
+
title: t.feat4Title,
|
| 32 |
+
desc: t.feat4Desc,
|
| 33 |
+
},
|
| 34 |
+
];
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<section className="py-12 bg-white">
|
| 38 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6">
|
| 39 |
+
<div className="text-center mb-10">
|
| 40 |
+
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-gray-900 mb-2">
|
| 41 |
+
{t.whyUsTitle}
|
| 42 |
+
</h2>
|
| 43 |
+
<p className="text-gray-500">{t.whyUsDesc}</p>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
| 47 |
+
{features.map((feat, idx) => (
|
| 48 |
+
<div key={idx} className="bg-rose-50/50 rounded-xl p-6 text-center hover:shadow-lg transition-shadow border border-rose-100">
|
| 49 |
+
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center shadow-sm mx-auto mb-4 text-rose-500">
|
| 50 |
+
{feat.icon}
|
| 51 |
+
</div>
|
| 52 |
+
<h3 className="font-bold text-gray-800 mb-2">{feat.title}</h3>
|
| 53 |
+
<p className="text-sm text-gray-600 leading-relaxed">{feat.desc}</p>
|
| 54 |
+
</div>
|
| 55 |
+
))}
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</section>
|
| 59 |
+
);
|
| 60 |
+
};
|
components/FeedbackModal.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { X, MessageSquare, AlertCircle, Lightbulb, HelpCircle, Star, Loader2 } from 'lucide-react';
|
| 3 |
+
import { Language, FeedbackItem, UserAccount } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface FeedbackModalProps {
|
| 7 |
+
isVisible: boolean;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
language: Language;
|
| 10 |
+
user?: UserAccount;
|
| 11 |
+
onSubmit: (data: Omit<FeedbackItem, 'id' | 'timestamp'>) => Promise<void>;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export const FeedbackModal: React.FC<FeedbackModalProps> = ({ isVisible, onClose, language, user, onSubmit }) => {
|
| 15 |
+
const t = TRANSLATIONS[language];
|
| 16 |
+
const [type, setType] = useState<FeedbackItem['type']>('suggestion');
|
| 17 |
+
const [content, setContent] = useState('');
|
| 18 |
+
const [rating, setRating] = useState(5);
|
| 19 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 20 |
+
|
| 21 |
+
if (!isVisible) return null;
|
| 22 |
+
|
| 23 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 24 |
+
e.preventDefault();
|
| 25 |
+
if (!content.trim()) return;
|
| 26 |
+
|
| 27 |
+
setIsSubmitting(true);
|
| 28 |
+
try {
|
| 29 |
+
await onSubmit({
|
| 30 |
+
userId: user?.id,
|
| 31 |
+
type,
|
| 32 |
+
rating,
|
| 33 |
+
content,
|
| 34 |
+
contact: user?.phone
|
| 35 |
+
});
|
| 36 |
+
// Reset form
|
| 37 |
+
setContent('');
|
| 38 |
+
setRating(5);
|
| 39 |
+
setType('suggestion');
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.error("Feedback error", error);
|
| 42 |
+
} finally {
|
| 43 |
+
setIsSubmitting(false);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const getIcon = () => {
|
| 48 |
+
switch (type) {
|
| 49 |
+
case 'bug': return <AlertCircle className="w-6 h-6 text-red-500" />;
|
| 50 |
+
case 'suggestion': return <Lightbulb className="w-6 h-6 text-amber-500" />;
|
| 51 |
+
default: return <HelpCircle className="w-6 h-6 text-blue-500" />;
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
| 57 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden relative">
|
| 58 |
+
<button onClick={onClose} className="absolute top-4 right-4 p-1 rounded-full hover:bg-gray-100 transition-colors z-10">
|
| 59 |
+
<X className="w-5 h-5 text-gray-500" />
|
| 60 |
+
</button>
|
| 61 |
+
|
| 62 |
+
<div className="p-6">
|
| 63 |
+
<div className="text-center mb-6">
|
| 64 |
+
<div className="w-12 h-12 bg-rose-50 rounded-full flex items-center justify-center mx-auto mb-3 text-rose-500">
|
| 65 |
+
<MessageSquare className="w-6 h-6" />
|
| 66 |
+
</div>
|
| 67 |
+
<h2 className="text-xl font-bold text-gray-900">{t.feedbackTitle}</h2>
|
| 68 |
+
<p className="text-sm text-gray-500 mt-1">{t.feedbackDesc}</p>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<form onSubmit={handleSubmit} className="space-y-5">
|
| 72 |
+
{/* Rating */}
|
| 73 |
+
<div className="text-center">
|
| 74 |
+
<label className="block text-xs font-bold text-gray-500 mb-2">{t.feedbackRating}</label>
|
| 75 |
+
<div className="flex justify-center gap-2">
|
| 76 |
+
{[1, 2, 3, 4, 5].map((star) => (
|
| 77 |
+
<button
|
| 78 |
+
key={star}
|
| 79 |
+
type="button"
|
| 80 |
+
onClick={() => setRating(star)}
|
| 81 |
+
className="focus:outline-none transition-transform hover:scale-110"
|
| 82 |
+
>
|
| 83 |
+
<Star className={`w-8 h-8 ${star <= rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}`} />
|
| 84 |
+
</button>
|
| 85 |
+
))}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
{/* Type Selection */}
|
| 90 |
+
<div className="grid grid-cols-3 gap-2">
|
| 91 |
+
<button
|
| 92 |
+
type="button"
|
| 93 |
+
onClick={() => setType('bug')}
|
| 94 |
+
className={`p-3 rounded-xl border flex flex-col items-center gap-1 transition-all ${type === 'bug' ? 'bg-red-50 border-red-200 text-red-600' : 'bg-white border-gray-200 text-gray-500 hover:bg-gray-50'}`}
|
| 95 |
+
>
|
| 96 |
+
<AlertCircle className="w-5 h-5" />
|
| 97 |
+
<span className="text-xs font-bold">{t.feedbackTypeBug}</span>
|
| 98 |
+
</button>
|
| 99 |
+
<button
|
| 100 |
+
type="button"
|
| 101 |
+
onClick={() => setType('suggestion')}
|
| 102 |
+
className={`p-3 rounded-xl border flex flex-col items-center gap-1 transition-all ${type === 'suggestion' ? 'bg-amber-50 border-amber-200 text-amber-600' : 'bg-white border-gray-200 text-gray-500 hover:bg-gray-50'}`}
|
| 103 |
+
>
|
| 104 |
+
<Lightbulb className="w-5 h-5" />
|
| 105 |
+
<span className="text-xs font-bold">{t.feedbackTypeSugg}</span>
|
| 106 |
+
</button>
|
| 107 |
+
<button
|
| 108 |
+
type="button"
|
| 109 |
+
onClick={() => setType('other')}
|
| 110 |
+
className={`p-3 rounded-xl border flex flex-col items-center gap-1 transition-all ${type === 'other' ? 'bg-blue-50 border-blue-200 text-blue-600' : 'bg-white border-gray-200 text-gray-500 hover:bg-gray-50'}`}
|
| 111 |
+
>
|
| 112 |
+
<HelpCircle className="w-5 h-5" />
|
| 113 |
+
<span className="text-xs font-bold">{t.feedbackTypeOther}</span>
|
| 114 |
+
</button>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* Content */}
|
| 118 |
+
<div>
|
| 119 |
+
<div className="relative">
|
| 120 |
+
<textarea
|
| 121 |
+
value={content}
|
| 122 |
+
onChange={(e) => setContent(e.target.value)}
|
| 123 |
+
placeholder={t.feedbackContentPlace}
|
| 124 |
+
className="w-full p-4 rounded-xl border border-gray-200 focus:border-rose-500 focus:ring-1 focus:ring-rose-200 outline-none h-32 resize-none bg-gray-50 focus:bg-white transition-all text-sm"
|
| 125 |
+
required
|
| 126 |
+
/>
|
| 127 |
+
<div className="absolute top-3 right-3 opacity-50 pointer-events-none">
|
| 128 |
+
{getIcon()}
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<button
|
| 134 |
+
type="submit"
|
| 135 |
+
disabled={isSubmitting || !content.trim()}
|
| 136 |
+
className="w-full py-3 bg-gray-900 text-white rounded-xl font-bold shadow-lg hover:bg-black transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 137 |
+
>
|
| 138 |
+
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
| 139 |
+
{t.feedbackSubmit}
|
| 140 |
+
</button>
|
| 141 |
+
</form>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
);
|
| 146 |
+
};
|
components/FilterSelector.tsx
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Palette, Sun, Moon, Coffee, Droplet, Zap, Maximize, Aperture, Sparkles, Cloud } from 'lucide-react';
|
| 3 |
+
import { Language } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
export interface ImageFilter {
|
| 7 |
+
id: string;
|
| 8 |
+
name: string;
|
| 9 |
+
css: string; // CSS filter string
|
| 10 |
+
icon?: React.ReactNode;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const FILTERS: ImageFilter[] = [
|
| 14 |
+
{ id: 'none', name: 'Original', css: 'none', icon: <Palette className="w-4 h-4" /> },
|
| 15 |
+
{ id: 'bw', name: 'B&W', css: 'grayscale(100%)', icon: <Aperture className="w-4 h-4" /> },
|
| 16 |
+
{ id: 'sepia', name: 'Sepia', css: 'sepia(100%)', icon: <Coffee className="w-4 h-4" /> },
|
| 17 |
+
{ id: 'vintage', name: 'Vintage', css: 'sepia(0.4) contrast(1.2) brightness(0.9)', icon: <Droplet className="w-4 h-4" /> },
|
| 18 |
+
{ id: 'soft', name: 'Soft', css: 'brightness(1.1) contrast(0.9) saturate(0.8)', icon: <Sun className="w-4 h-4" /> },
|
| 19 |
+
{ id: 'dramatic', name: 'Dramatic', css: 'contrast(1.4) saturate(0.2)', icon: <Zap className="w-4 h-4" /> },
|
| 20 |
+
{ id: 'golden', name: 'Golden', css: 'sepia(0.3) saturate(1.4) contrast(1.1)', icon: <Maximize className="w-4 h-4" /> },
|
| 21 |
+
{ id: 'glow', name: 'Glow', css: 'brightness(1.1) saturate(1.2) contrast(0.9)', icon: <Sparkles className="w-4 h-4" /> },
|
| 22 |
+
{ id: 'dark', name: 'Dark Mode', css: 'brightness(0.8) contrast(1.2) saturate(1.1) hue-rotate(-15deg)', icon: <Moon className="w-4 h-4" /> },
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
interface FilterSelectorProps {
|
| 26 |
+
selectedFilter: string;
|
| 27 |
+
onSelect: (filterId: string) => void;
|
| 28 |
+
blurAmount: number;
|
| 29 |
+
onBlurChange: (amount: number) => void;
|
| 30 |
+
disabled: boolean;
|
| 31 |
+
language: Language;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export const FilterSelector: React.FC<FilterSelectorProps> = ({
|
| 35 |
+
selectedFilter,
|
| 36 |
+
onSelect,
|
| 37 |
+
blurAmount,
|
| 38 |
+
onBlurChange,
|
| 39 |
+
disabled,
|
| 40 |
+
language
|
| 41 |
+
}) => {
|
| 42 |
+
const t = TRANSLATIONS[language];
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div className="w-full space-y-4">
|
| 46 |
+
{/* Filters Row */}
|
| 47 |
+
<div>
|
| 48 |
+
<div className="flex items-center gap-2 mb-2">
|
| 49 |
+
<Palette className="w-4 h-4 text-gray-500" />
|
| 50 |
+
<h4 className="text-sm font-bold text-gray-700">{t.colorFilters}</h4>
|
| 51 |
+
</div>
|
| 52 |
+
<div className="flex flex-wrap gap-2">
|
| 53 |
+
{FILTERS.map((filter) => (
|
| 54 |
+
<button
|
| 55 |
+
key={filter.id}
|
| 56 |
+
onClick={() => onSelect(filter.id)}
|
| 57 |
+
disabled={disabled}
|
| 58 |
+
className={`
|
| 59 |
+
flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium border transition-all
|
| 60 |
+
${selectedFilter === filter.id
|
| 61 |
+
? 'bg-rose-50 border-rose-500 text-rose-700 shadow-sm'
|
| 62 |
+
: 'bg-white border-gray-200 text-gray-600 hover:border-rose-200 hover:bg-gray-50'}
|
| 63 |
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
| 64 |
+
`}
|
| 65 |
+
>
|
| 66 |
+
{filter.icon}
|
| 67 |
+
{filter.name}
|
| 68 |
+
</button>
|
| 69 |
+
))}
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
{/* Blur Slider Row */}
|
| 74 |
+
<div className="pt-2">
|
| 75 |
+
<div className="flex items-center justify-between mb-2">
|
| 76 |
+
<div className="flex items-center gap-2">
|
| 77 |
+
<Cloud className="w-4 h-4 text-gray-500" />
|
| 78 |
+
<h4 className="text-sm font-bold text-gray-700">{t.bgBlur}</h4>
|
| 79 |
+
</div>
|
| 80 |
+
<span className="text-xs font-mono text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
| 81 |
+
{blurAmount.toFixed(1)}px
|
| 82 |
+
</span>
|
| 83 |
+
</div>
|
| 84 |
+
<div className="flex items-center gap-4">
|
| 85 |
+
<input
|
| 86 |
+
type="range"
|
| 87 |
+
min="0"
|
| 88 |
+
max="10"
|
| 89 |
+
step="0.5"
|
| 90 |
+
value={blurAmount}
|
| 91 |
+
onChange={(e) => onBlurChange(parseFloat(e.target.value))}
|
| 92 |
+
disabled={disabled}
|
| 93 |
+
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-rose-500"
|
| 94 |
+
/>
|
| 95 |
+
</div>
|
| 96 |
+
<p className="text-[10px] text-gray-400 mt-1">
|
| 97 |
+
{t.bgBlurDesc}
|
| 98 |
+
</p>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
};
|
components/FloatingConcierge.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Phone, MessageCircle, X, ChevronLeft } from 'lucide-react';
|
| 3 |
+
import { Language, AdminConfig } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface FloatingConciergeProps {
|
| 7 |
+
language: Language;
|
| 8 |
+
config: AdminConfig;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const FloatingConcierge: React.FC<FloatingConciergeProps> = ({ language, config }) => {
|
| 12 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 13 |
+
const t = TRANSLATIONS[language];
|
| 14 |
+
|
| 15 |
+
const handleContact = (type: 'phone' | 'wechat') => {
|
| 16 |
+
if (type === 'phone' && config.contactPhone) {
|
| 17 |
+
window.location.href = `tel:${config.contactPhone}`;
|
| 18 |
+
}
|
| 19 |
+
// WeChat logic usually involves showing a QR code, handled by the UI expansion
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
if (!isOpen) {
|
| 23 |
+
return (
|
| 24 |
+
<button
|
| 25 |
+
onClick={() => setIsOpen(true)}
|
| 26 |
+
className="fixed right-0 top-1/2 -translate-y-1/2 z-40 bg-rose-600 text-white p-3 rounded-l-xl shadow-lg hover:bg-rose-700 transition-all flex flex-col items-center gap-1 group"
|
| 27 |
+
aria-label="Open Concierge"
|
| 28 |
+
>
|
| 29 |
+
<MessageCircle className="w-5 h-5 animate-pulse" />
|
| 30 |
+
<span className="text-[10px] font-bold writing-vertical-lr hidden sm:block pt-1">咨询</span>
|
| 31 |
+
<ChevronLeft className="w-3 h-3 group-hover:-translate-x-1 transition-transform" />
|
| 32 |
+
</button>
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-50 animate-fade-in">
|
| 38 |
+
<div className="bg-white rounded-2xl shadow-2xl p-5 border border-rose-100 w-64 relative">
|
| 39 |
+
<button
|
| 40 |
+
onClick={() => setIsOpen(false)}
|
| 41 |
+
className="absolute -top-3 -right-3 bg-gray-900 text-white p-1.5 rounded-full hover:bg-black transition-colors shadow-md"
|
| 42 |
+
>
|
| 43 |
+
<X className="w-4 h-4" />
|
| 44 |
+
</button>
|
| 45 |
+
|
| 46 |
+
<div className="text-center mb-4">
|
| 47 |
+
<h3 className="font-bold text-gray-800 text-lg">金牌管家</h3>
|
| 48 |
+
<p className="text-xs text-gray-500">1对1 专属服务</p>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div className="space-y-4">
|
| 52 |
+
{/* Phone */}
|
| 53 |
+
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl hover:bg-rose-50 transition-colors cursor-pointer" onClick={() => handleContact('phone')}>
|
| 54 |
+
<div className="w-10 h-10 bg-rose-100 rounded-full flex items-center justify-center text-rose-600">
|
| 55 |
+
<Phone className="w-5 h-5" />
|
| 56 |
+
</div>
|
| 57 |
+
<div className="text-left">
|
| 58 |
+
<p className="text-xs font-bold text-gray-500">电话咨询</p>
|
| 59 |
+
<p className="text-sm font-bold text-gray-800">{config.contactPhone || '0592-8888888'}</p>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
{/* WeChat / QR */}
|
| 64 |
+
<div className="flex flex-col items-center gap-2 p-3 bg-gray-50 rounded-xl border-2 border-dashed border-gray-200">
|
| 65 |
+
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center text-green-600 mb-1">
|
| 66 |
+
<MessageCircle className="w-5 h-5" />
|
| 67 |
+
</div>
|
| 68 |
+
<p className="text-xs font-bold text-gray-500 mb-1">添加微信客服</p>
|
| 69 |
+
<div className="w-32 h-32 bg-white p-1 rounded-lg shadow-sm">
|
| 70 |
+
<img
|
| 71 |
+
src={config.qrCodeUrl || "https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=https://romantic-life.com"}
|
| 72 |
+
alt="WeChat QR"
|
| 73 |
+
className="w-full h-full object-cover rounded"
|
| 74 |
+
/>
|
| 75 |
+
</div>
|
| 76 |
+
<p className="text-[10px] text-gray-400">扫一扫,获取最新报价</p>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
);
|
| 82 |
+
};
|
components/Header.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Camera, Globe, PhoneCall, User, LogIn, Settings, Info, MessageSquarePlus } from 'lucide-react';
|
| 3 |
+
import { Language, UserAccount, AdminConfig } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface HeaderProps {
|
| 7 |
+
language: Language;
|
| 8 |
+
onLanguageChange: (lang: Language) => void;
|
| 9 |
+
onBookClick: () => void;
|
| 10 |
+
user?: UserAccount;
|
| 11 |
+
config?: AdminConfig; // NEW: Receive AdminConfig for logo
|
| 12 |
+
onOpenUserCenter?: () => void;
|
| 13 |
+
onLoginClick?: () => void;
|
| 14 |
+
onOpenAdmin?: () => void;
|
| 15 |
+
onOpenAbout?: () => void;
|
| 16 |
+
onOpenFeedback?: () => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const Header: React.FC<HeaderProps> = ({ language, onLanguageChange, onBookClick, user, config, onOpenUserCenter, onLoginClick, onOpenAdmin, onOpenAbout, onOpenFeedback }) => {
|
| 20 |
+
const t = TRANSLATIONS[language];
|
| 21 |
+
const isStaffOrAdmin = user && (user.role === 'admin' || user.role === 'staff');
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<header className="w-full py-4 sm:py-6 px-4 sm:px-8 border-b border-rose-100 bg-white/80 backdrop-blur-md sticky top-0 z-50">
|
| 25 |
+
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
| 26 |
+
<div className="flex items-center gap-3 cursor-pointer" onClick={() => window.location.reload()}>
|
| 27 |
+
{config?.logoUrl ? (
|
| 28 |
+
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden shadow-lg ring-2 ring-rose-100">
|
| 29 |
+
<img src={config.logoUrl} alt="Logo" className="w-full h-full object-cover" />
|
| 30 |
+
</div>
|
| 31 |
+
) : (
|
| 32 |
+
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gray-900 rounded-full flex items-center justify-center text-rose-300 shadow-lg ring-2 ring-rose-100">
|
| 33 |
+
<Camera className="w-6 h-6" />
|
| 34 |
+
</div>
|
| 35 |
+
)}
|
| 36 |
+
|
| 37 |
+
<div>
|
| 38 |
+
<h1 className="font-serif text-xl sm:text-2xl font-bold text-gray-900 tracking-tight leading-none">
|
| 39 |
+
{t.title}
|
| 40 |
+
</h1>
|
| 41 |
+
<p className="text-[10px] sm:text-xs text-rose-500 font-sans tracking-widest uppercase font-bold mt-1">
|
| 42 |
+
{t.subtitle}
|
| 43 |
+
</p>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div className="flex items-center gap-3 sm:gap-4">
|
| 48 |
+
{/* About Us Button (Desktop) */}
|
| 49 |
+
<button
|
| 50 |
+
onClick={onOpenAbout}
|
| 51 |
+
className="hidden sm:flex items-center gap-1 text-xs font-bold text-gray-600 hover:text-rose-600 transition-colors mr-2"
|
| 52 |
+
>
|
| 53 |
+
<Info className="w-3.5 h-3.5" />
|
| 54 |
+
{t.aboutUsBtn}
|
| 55 |
+
</button>
|
| 56 |
+
|
| 57 |
+
{/* Feedback Button */}
|
| 58 |
+
<button
|
| 59 |
+
onClick={onOpenFeedback}
|
| 60 |
+
className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 text-gray-500 hover:text-rose-500 transition-colors"
|
| 61 |
+
title="Feedback"
|
| 62 |
+
>
|
| 63 |
+
<MessageSquarePlus className="w-5 h-5" />
|
| 64 |
+
</button>
|
| 65 |
+
|
| 66 |
+
{/* Admin Panel Button */}
|
| 67 |
+
{isStaffOrAdmin && (
|
| 68 |
+
<button
|
| 69 |
+
onClick={onOpenAdmin}
|
| 70 |
+
className="hidden sm:flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white rounded-full text-xs font-bold hover:bg-gray-700 transition-colors"
|
| 71 |
+
>
|
| 72 |
+
<Settings className="w-3.5 h-3.5" />
|
| 73 |
+
Admin Panel
|
| 74 |
+
</button>
|
| 75 |
+
)}
|
| 76 |
+
|
| 77 |
+
{/* Book Now Button (Mobile: Icon only, Desktop: Text) */}
|
| 78 |
+
<button
|
| 79 |
+
onClick={onBookClick}
|
| 80 |
+
className="flex items-center gap-2 bg-rose-600 hover:bg-rose-700 text-white px-4 py-2 rounded-full shadow-lg shadow-rose-200 transition-all hover:scale-105 active:scale-95"
|
| 81 |
+
>
|
| 82 |
+
<PhoneCall className="w-4 h-4" />
|
| 83 |
+
<span className="hidden sm:inline text-xs font-bold uppercase tracking-wide">{t.bookNow}</span>
|
| 84 |
+
</button>
|
| 85 |
+
|
| 86 |
+
{/* User Avatar / Points OR Login Button */}
|
| 87 |
+
{user ? (
|
| 88 |
+
<button
|
| 89 |
+
onClick={onOpenUserCenter}
|
| 90 |
+
className="flex items-center gap-2 bg-gray-50 hover:bg-rose-50 border border-gray-200 pl-2 pr-3 py-1.5 rounded-full transition-colors group"
|
| 91 |
+
>
|
| 92 |
+
<div className="w-6 h-6 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
| 93 |
+
{user.avatar ? <img src={user.avatar} className="w-full h-full object-cover" /> : <User className="w-4 h-4 text-gray-500" />}
|
| 94 |
+
</div>
|
| 95 |
+
<div className="flex flex-col items-start leading-none">
|
| 96 |
+
<span className="text-[9px] text-gray-400 font-bold uppercase">{t.myPoints}</span>
|
| 97 |
+
<span className="text-xs font-bold text-amber-500 group-hover:text-amber-600">{user.points}</span>
|
| 98 |
+
</div>
|
| 99 |
+
</button>
|
| 100 |
+
) : (
|
| 101 |
+
<button
|
| 102 |
+
onClick={onLoginClick}
|
| 103 |
+
className="flex items-center gap-2 text-gray-600 hover:text-rose-600 font-bold text-xs"
|
| 104 |
+
>
|
| 105 |
+
<LogIn className="w-4 h-4" />
|
| 106 |
+
<span className="hidden sm:inline">{t.authLogin}</span>
|
| 107 |
+
</button>
|
| 108 |
+
)}
|
| 109 |
+
|
| 110 |
+
{/* Language Selector */}
|
| 111 |
+
<div className="relative group hidden sm:block">
|
| 112 |
+
<button className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white border border-gray-200 text-xs font-semibold text-gray-700 hover:bg-gray-50 transition-colors">
|
| 113 |
+
<Globe className="w-3.5 h-3.5" />
|
| 114 |
+
<span className="uppercase">{language}</span>
|
| 115 |
+
</button>
|
| 116 |
+
|
| 117 |
+
<div className="absolute right-0 mt-2 w-32 bg-white rounded-xl shadow-xl border border-gray-100 overflow-hidden hidden group-hover:block animate-fade-in z-50">
|
| 118 |
+
<div className="flex flex-col py-1">
|
| 119 |
+
{['en', 'zh', 'ja', 'ko', 'es', 'fr'].map((lang) => (
|
| 120 |
+
<button
|
| 121 |
+
key={lang}
|
| 122 |
+
onClick={() => onLanguageChange(lang as Language)}
|
| 123 |
+
className={`px-4 py-2 text-left text-xs hover:bg-rose-50 ${language === lang ? 'text-rose-600 font-bold bg-rose-50' : 'text-gray-700'}`}
|
| 124 |
+
>
|
| 125 |
+
{lang === 'en' ? 'English' :
|
| 126 |
+
lang === 'zh' ? '中文' :
|
| 127 |
+
lang === 'ja' ? '日本語' :
|
| 128 |
+
lang === 'ko' ? '한국어' :
|
| 129 |
+
lang === 'es' ? 'Español' : 'Français'}
|
| 130 |
+
</button>
|
| 131 |
+
))}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</header>
|
| 138 |
+
);
|
| 139 |
+
};
|
components/ImageUploader.tsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback } from 'react';
|
| 2 |
+
import { Upload, X, Plus } from 'lucide-react';
|
| 3 |
+
import { Language } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface ImageUploaderProps {
|
| 7 |
+
onImagesSelect: (base64Images: string[]) => void;
|
| 8 |
+
currentImages: string[];
|
| 9 |
+
disabled: boolean;
|
| 10 |
+
language: Language;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const ImageUploader: React.FC<ImageUploaderProps> = ({ onImagesSelect, currentImages, disabled, language }) => {
|
| 14 |
+
const t = TRANSLATIONS[language];
|
| 15 |
+
|
| 16 |
+
const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
| 17 |
+
const files = event.target.files;
|
| 18 |
+
if (files && files.length > 0) {
|
| 19 |
+
const newImages: string[] = [];
|
| 20 |
+
let processed = 0;
|
| 21 |
+
|
| 22 |
+
// Limit to 5 images for performance
|
| 23 |
+
const maxFiles = Math.min(files.length, 5);
|
| 24 |
+
|
| 25 |
+
for (let i = 0; i < maxFiles; i++) {
|
| 26 |
+
const file = files[i];
|
| 27 |
+
if (file.size > 5 * 1024 * 1024) {
|
| 28 |
+
alert(`File ${file.name} is too large. Skip.`);
|
| 29 |
+
processed++;
|
| 30 |
+
if (processed === maxFiles) onImagesSelect([...currentImages, ...newImages]);
|
| 31 |
+
continue;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const reader = new FileReader();
|
| 35 |
+
reader.onloadend = () => {
|
| 36 |
+
newImages.push(reader.result as string);
|
| 37 |
+
processed++;
|
| 38 |
+
if (processed === maxFiles) {
|
| 39 |
+
// Append to existing images
|
| 40 |
+
onImagesSelect([...currentImages, ...newImages]);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
reader.readAsDataURL(file);
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}, [currentImages, onImagesSelect]);
|
| 47 |
+
|
| 48 |
+
const removeImage = (index: number) => {
|
| 49 |
+
const newImages = currentImages.filter((_, i) => i !== index);
|
| 50 |
+
onImagesSelect(newImages);
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const hasImages = currentImages.length > 0;
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="w-full">
|
| 57 |
+
<div className={`
|
| 58 |
+
relative w-full min-h-[300px] rounded-2xl border-2 border-dashed
|
| 59 |
+
transition-all duration-300 flex flex-col items-center justify-center p-4
|
| 60 |
+
${hasImages ? 'border-rose-300 bg-rose-50' : 'border-gray-300 bg-gray-50 hover:bg-gray-100 hover:border-gray-400'}
|
| 61 |
+
${disabled ? 'opacity-60 cursor-not-allowed' : ''}
|
| 62 |
+
`}>
|
| 63 |
+
|
| 64 |
+
{!hasImages ? (
|
| 65 |
+
// Empty State
|
| 66 |
+
<div className="flex flex-col items-center justify-center text-center pointer-events-none">
|
| 67 |
+
<div className="w-16 h-16 mb-4 rounded-full bg-white flex items-center justify-center shadow-sm">
|
| 68 |
+
<Upload className="w-8 h-8 text-rose-400" />
|
| 69 |
+
</div>
|
| 70 |
+
<p className="text-lg font-medium text-gray-700">{t.uploadTitle}</p>
|
| 71 |
+
<p className="text-sm text-gray-500 mt-2 max-w-xs">{t.uploadDesc}</p>
|
| 72 |
+
</div>
|
| 73 |
+
) : (
|
| 74 |
+
// Grid View for Multiple Images
|
| 75 |
+
<div className="w-full grid grid-cols-2 sm:grid-cols-3 gap-4">
|
| 76 |
+
{currentImages.map((img, idx) => (
|
| 77 |
+
<div key={idx} className="relative aspect-[3/4] rounded-xl overflow-hidden shadow-sm group">
|
| 78 |
+
<img src={img} alt={`Uploaded ${idx}`} className="w-full h-full object-cover" />
|
| 79 |
+
{!disabled && (
|
| 80 |
+
<button
|
| 81 |
+
onClick={() => removeImage(idx)}
|
| 82 |
+
className="absolute top-2 right-2 bg-black/50 text-white p-1 rounded-full hover:bg-red-500 transition-colors"
|
| 83 |
+
>
|
| 84 |
+
<X className="w-4 h-4" />
|
| 85 |
+
</button>
|
| 86 |
+
)}
|
| 87 |
+
</div>
|
| 88 |
+
))}
|
| 89 |
+
|
| 90 |
+
{/* Add More Button (if less than 5) */}
|
| 91 |
+
{currentImages.length < 5 && !disabled && (
|
| 92 |
+
<div className="relative aspect-[3/4] rounded-xl border-2 border-dashed border-rose-300 flex flex-col items-center justify-center bg-white/50 hover:bg-white transition-colors cursor-pointer group">
|
| 93 |
+
<Plus className="w-8 h-8 text-rose-400 mb-2 group-hover:scale-110 transition-transform" />
|
| 94 |
+
<span className="text-xs text-rose-500 font-bold">Add Photo</span>
|
| 95 |
+
<input
|
| 96 |
+
type="file"
|
| 97 |
+
multiple
|
| 98 |
+
accept="image/png, image/jpeg, image/jpg, image/webp"
|
| 99 |
+
onChange={handleFileChange}
|
| 100 |
+
disabled={disabled}
|
| 101 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
)}
|
| 105 |
+
</div>
|
| 106 |
+
)}
|
| 107 |
+
|
| 108 |
+
{/* Hidden Input for Initial Drag/Drop area */}
|
| 109 |
+
{!hasImages && (
|
| 110 |
+
<input
|
| 111 |
+
type="file"
|
| 112 |
+
multiple
|
| 113 |
+
accept="image/png, image/jpeg, image/jpg, image/webp"
|
| 114 |
+
onChange={handleFileChange}
|
| 115 |
+
disabled={disabled}
|
| 116 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
| 117 |
+
/>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
{hasImages && (
|
| 122 |
+
<p className="text-center text-xs text-gray-400 mt-2">
|
| 123 |
+
{currentImages.length} photo(s) selected. Max 5.
|
| 124 |
+
</p>
|
| 125 |
+
)}
|
| 126 |
+
</div>
|
| 127 |
+
);
|
| 128 |
+
};
|
components/RedPacketModal.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { X, Gift } from 'lucide-react';
|
| 3 |
+
import { Language, AdminConfig, UserAccount } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
// @ts-ignore
|
| 6 |
+
import confetti from 'canvas-confetti';
|
| 7 |
+
|
| 8 |
+
interface RedPacketModalProps {
|
| 9 |
+
isVisible: boolean;
|
| 10 |
+
onClose: () => void;
|
| 11 |
+
language: Language;
|
| 12 |
+
adminConfig: AdminConfig;
|
| 13 |
+
user?: UserAccount;
|
| 14 |
+
onUpdateUser: (balance: number) => void;
|
| 15 |
+
onOpenShare: () => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export const RedPacketModal: React.FC<RedPacketModalProps> = ({
|
| 19 |
+
isVisible, onClose, language, adminConfig, user, onUpdateUser, onOpenShare
|
| 20 |
+
}) => {
|
| 21 |
+
const [stage, setStage] = useState<'closed' | 'opened'>('closed');
|
| 22 |
+
const [amount, setAmount] = useState(0);
|
| 23 |
+
const t = TRANSLATIONS[language];
|
| 24 |
+
|
| 25 |
+
// Determine target amount
|
| 26 |
+
const max = adminConfig.redPacketMax || 2000;
|
| 27 |
+
// If user exists, show their balance. If no user (guest), show default almost-max (e.g. max - 20)
|
| 28 |
+
const userBalance = user?.redPacketBalance ?? (max - 20);
|
| 29 |
+
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
if (isVisible) {
|
| 32 |
+
setStage('closed');
|
| 33 |
+
|
| 34 |
+
// Animate amount up to userBalance
|
| 35 |
+
let current = 0;
|
| 36 |
+
// Animation speed
|
| 37 |
+
const step = userBalance / 40;
|
| 38 |
+
const interval = setInterval(() => {
|
| 39 |
+
current += step;
|
| 40 |
+
if (current >= userBalance) {
|
| 41 |
+
current = userBalance;
|
| 42 |
+
clearInterval(interval);
|
| 43 |
+
}
|
| 44 |
+
setAmount(Math.floor(current));
|
| 45 |
+
}, 30);
|
| 46 |
+
return () => clearInterval(interval);
|
| 47 |
+
}
|
| 48 |
+
}, [isVisible, userBalance]);
|
| 49 |
+
|
| 50 |
+
if (!isVisible) return null;
|
| 51 |
+
|
| 52 |
+
const handleOpen = () => {
|
| 53 |
+
setStage('opened');
|
| 54 |
+
confetti({
|
| 55 |
+
particleCount: 150,
|
| 56 |
+
spread: 80,
|
| 57 |
+
origin: { y: 0.6 },
|
| 58 |
+
colors: ['#fbbf24', '#ef4444', '#ffffff']
|
| 59 |
+
});
|
| 60 |
+
// Ensure user gets this balance if they haven't already
|
| 61 |
+
if (user && (user.redPacketBalance === undefined || user.redPacketBalance === 0)) {
|
| 62 |
+
onUpdateUser(userBalance);
|
| 63 |
+
}
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
const handleWithdraw = () => {
|
| 67 |
+
onOpenShare();
|
| 68 |
+
onClose();
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
|
| 73 |
+
<div className="relative w-full max-w-sm">
|
| 74 |
+
<button onClick={onClose} className="absolute -top-10 right-0 p-2 bg-white/20 rounded-full text-white hover:bg-white/40">
|
| 75 |
+
<X className="w-5 h-5" />
|
| 76 |
+
</button>
|
| 77 |
+
|
| 78 |
+
{stage === 'closed' ? (
|
| 79 |
+
// Stage 1: The Big Red Envelope
|
| 80 |
+
<div className="bg-gradient-to-b from-red-500 to-red-600 rounded-3xl shadow-2xl overflow-hidden text-center p-8 relative animate-bounce-slow">
|
| 81 |
+
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-32 h-32 bg-red-700 rounded-b-full opacity-50"></div>
|
| 82 |
+
<div className="relative z-10 pt-10">
|
| 83 |
+
<p className="text-yellow-200 text-lg font-bold mb-2">{t.pddRedPacketTitle}</p>
|
| 84 |
+
<h3 className="text-3xl font-bold text-white mb-8">{t.pddRedPacketDesc}</h3>
|
| 85 |
+
|
| 86 |
+
<button
|
| 87 |
+
onClick={handleOpen}
|
| 88 |
+
className="w-24 h-24 rounded-full bg-yellow-400 border-4 border-yellow-200 text-red-600 font-bold text-xl shadow-lg hover:scale-110 transition-transform flex items-center justify-center mx-auto"
|
| 89 |
+
>
|
| 90 |
+
<span className="animate-pulse">{t.pddRedPacketCta}</span>
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
) : (
|
| 95 |
+
// Stage 2: The "Almost There" Trap
|
| 96 |
+
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden relative">
|
| 97 |
+
<div className="bg-red-500 p-6 text-center pb-12">
|
| 98 |
+
<p className="text-white/80 text-sm font-bold uppercase tracking-wider">Account Balance</p>
|
| 99 |
+
<h2 className="text-5xl font-bold text-white mt-2">¥{amount}</h2>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div className="px-6 -mt-8 relative z-10">
|
| 103 |
+
<div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100 text-center">
|
| 104 |
+
<p className="text-gray-800 font-bold text-lg">{t.pddWithdrawTitle}</p>
|
| 105 |
+
<div className="w-full h-3 bg-gray-100 rounded-full mt-3 overflow-hidden">
|
| 106 |
+
<div className="h-full bg-red-500 animate-pulse" style={{ width: `${(amount / max) * 100}%` }}></div>
|
| 107 |
+
</div>
|
| 108 |
+
<p className="text-xs text-red-500 mt-2 font-bold">{t.pddWithdrawDesc}</p>
|
| 109 |
+
|
| 110 |
+
<button
|
| 111 |
+
onClick={handleWithdraw}
|
| 112 |
+
className="w-full py-3 bg-red-500 text-white rounded-full font-bold mt-4 shadow-lg shadow-red-200 hover:bg-red-600 flex items-center justify-center gap-2"
|
| 113 |
+
>
|
| 114 |
+
<Gift className="w-4 h-4" /> Share to Withdraw
|
| 115 |
+
</button>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<div className="p-6 bg-gray-50">
|
| 120 |
+
<div className="space-y-3">
|
| 121 |
+
{['User 123 withdrew ¥2000', 'Amy unlocked VIP', 'Mike got ¥500'].map((txt, i) => (
|
| 122 |
+
<div key={i} className="flex items-center gap-2 text-xs text-gray-500">
|
| 123 |
+
<div className="w-6 h-6 bg-gray-200 rounded-full"></div>
|
| 124 |
+
<span>{txt}</span>
|
| 125 |
+
<span className="ml-auto text-gray-400">1m ago</span>
|
| 126 |
+
</div>
|
| 127 |
+
))}
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
)}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
);
|
| 135 |
+
};
|
components/ResultViewer.tsx
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
| 2 |
+
import { Download, Maximize2, X, Grid, Loader2, Video, Settings, Type, Sparkles, Layers, Stamp, Clock, PlayCircle, Share2, CalendarCheck, ArrowLeftRight, Music, FileText, CheckCircle, Monitor, SplitSquareHorizontal, Zap, Activity, Snowflake, ChevronDown, ChevronUp, CheckSquare, Square, Check } from 'lucide-react';
|
| 3 |
+
import { GeneratedResult, WeddingStyle, Language, VideoTemplate } from '../types';
|
| 4 |
+
import { STYLES } from './StyleSelector';
|
| 5 |
+
import { FILTERS } from './FilterSelector';
|
| 6 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 7 |
+
import JSZip from 'jszip';
|
| 8 |
+
|
| 9 |
+
interface ResultViewerProps {
|
| 10 |
+
originalImages: string[];
|
| 11 |
+
results: Record<string, GeneratedResult>;
|
| 12 |
+
initialSelectedId?: string;
|
| 13 |
+
onReset: () => void;
|
| 14 |
+
activeFilter?: string;
|
| 15 |
+
blurAmount?: number;
|
| 16 |
+
language: Language;
|
| 17 |
+
onBookClick: () => void;
|
| 18 |
+
onShareClick: () => void;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
interface VideoConfig {
|
| 22 |
+
transition: 'fade' | 'slide' | 'none';
|
| 23 |
+
customTitle: string;
|
| 24 |
+
enableCustomTitle: boolean;
|
| 25 |
+
showWatermark: boolean;
|
| 26 |
+
transitionDuration: number;
|
| 27 |
+
musicTrack: string;
|
| 28 |
+
format: 'mp4' | 'webm';
|
| 29 |
+
resolution: '720p' | '1080p';
|
| 30 |
+
template: VideoTemplate;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Available Music Options
|
| 34 |
+
const MUSIC_OPTIONS = [
|
| 35 |
+
{ id: 'none', labelKey: 'musicNone', src: '' },
|
| 36 |
+
{ id: 'romantic', labelKey: 'musicRomantic', src: 'https://cdn.pixabay.com/download/audio/2022/05/27/audio_1808fbf07a.mp3?filename=romantic-wedding-111663.mp3' },
|
| 37 |
+
{ id: 'cinematic', labelKey: 'musicCinematic', src: 'https://cdn.pixabay.com/download/audio/2022/03/24/audio_3335555c27.mp3?filename=cinematic-atmosphere-1936.mp3' },
|
| 38 |
+
{ id: 'upbeat', labelKey: 'musicUpbeat', src: 'https://cdn.pixabay.com/download/audio/2022/10/25/audio_5e062d1646.mp3?filename=upbeat-pop-1234.mp3' },
|
| 39 |
+
];
|
| 40 |
+
|
| 41 |
+
const applyEffectsToCanvas = (
|
| 42 |
+
ctx: CanvasRenderingContext2D,
|
| 43 |
+
img: HTMLImageElement,
|
| 44 |
+
width: number,
|
| 45 |
+
height: number,
|
| 46 |
+
filter: string,
|
| 47 |
+
blurAmount: number,
|
| 48 |
+
addWatermark: boolean,
|
| 49 |
+
watermarkText: string
|
| 50 |
+
) => {
|
| 51 |
+
ctx.filter = filter;
|
| 52 |
+
ctx.drawImage(img, 0, 0, width, height);
|
| 53 |
+
ctx.filter = 'none';
|
| 54 |
+
|
| 55 |
+
if (blurAmount > 0) {
|
| 56 |
+
const offCanvas = document.createElement('canvas');
|
| 57 |
+
offCanvas.width = width;
|
| 58 |
+
offCanvas.height = height;
|
| 59 |
+
const offCtx = offCanvas.getContext('2d');
|
| 60 |
+
|
| 61 |
+
if (offCtx) {
|
| 62 |
+
const scale = Math.max(1, width / 600);
|
| 63 |
+
const scaledBlur = blurAmount * scale;
|
| 64 |
+
const filterStr = filter !== 'none' ? `${filter} blur(${scaledBlur}px)` : `blur(${scaledBlur}px)`;
|
| 65 |
+
offCtx.filter = filterStr;
|
| 66 |
+
offCtx.drawImage(img, 0, 0, width, height);
|
| 67 |
+
offCtx.globalCompositeOperation = 'destination-out';
|
| 68 |
+
const minDim = Math.min(width, height);
|
| 69 |
+
const gradient = offCtx.createRadialGradient(
|
| 70 |
+
width / 2, height / 2, minDim * 0.4,
|
| 71 |
+
width / 2, height / 2, minDim * 0.8
|
| 72 |
+
);
|
| 73 |
+
gradient.addColorStop(0, 'rgba(0,0,0,1)');
|
| 74 |
+
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
| 75 |
+
offCtx.fillStyle = gradient;
|
| 76 |
+
offCtx.fillRect(0, 0, width, height);
|
| 77 |
+
ctx.globalCompositeOperation = 'source-over';
|
| 78 |
+
ctx.drawImage(offCanvas, 0, 0);
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
if (addWatermark) {
|
| 83 |
+
ctx.save();
|
| 84 |
+
const fontSize = Math.max(14, Math.floor(width * 0.03));
|
| 85 |
+
const padding = Math.floor(fontSize * 1.5);
|
| 86 |
+
ctx.font = `500 ${fontSize}px 'Playfair Display', serif`;
|
| 87 |
+
ctx.textAlign = 'right';
|
| 88 |
+
ctx.textBaseline = 'bottom';
|
| 89 |
+
ctx.shadowColor = 'rgba(0,0,0,0.5)';
|
| 90 |
+
ctx.shadowBlur = 4;
|
| 91 |
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
| 92 |
+
ctx.fillText(watermarkText, width - padding, height - padding);
|
| 93 |
+
ctx.restore();
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
async function generateVideo(
|
| 98 |
+
originalSrcs: string[],
|
| 99 |
+
results: Record<string, GeneratedResult>,
|
| 100 |
+
allStyles: WeddingStyle[],
|
| 101 |
+
filter: string = 'none',
|
| 102 |
+
blurAmount: number = 0,
|
| 103 |
+
config: VideoConfig,
|
| 104 |
+
language: Language,
|
| 105 |
+
targetStyleId?: string
|
| 106 |
+
): Promise<string | null> {
|
| 107 |
+
return new Promise(async (resolve, reject) => {
|
| 108 |
+
try {
|
| 109 |
+
const canvas = document.createElement('canvas');
|
| 110 |
+
const is1080p = config.resolution === '1080p';
|
| 111 |
+
canvas.width = is1080p ? 1080 : 720;
|
| 112 |
+
canvas.height = is1080p ? 1920 : 1280;
|
| 113 |
+
const ctx = canvas.getContext('2d');
|
| 114 |
+
|
| 115 |
+
if (!ctx) throw new Error("Canvas context failed");
|
| 116 |
+
|
| 117 |
+
// Template Logic
|
| 118 |
+
let durationPerSlide = 2000;
|
| 119 |
+
let transitionDuration = config.transition === 'none' ? 0 : config.transitionDuration;
|
| 120 |
+
|
| 121 |
+
if (config.template === 'flash') {
|
| 122 |
+
durationPerSlide = 500;
|
| 123 |
+
transitionDuration = 0;
|
| 124 |
+
} else if (config.template === 'slow') {
|
| 125 |
+
durationPerSlide = 3000;
|
| 126 |
+
transitionDuration = 1000;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Prepare Audio
|
| 130 |
+
let audioCtx: AudioContext | null = null;
|
| 131 |
+
let dest: MediaStreamAudioDestinationNode | null = null;
|
| 132 |
+
if (config.musicTrack !== 'none') {
|
| 133 |
+
const track = MUSIC_OPTIONS.find(m => m.id === config.musicTrack);
|
| 134 |
+
if (track && track.src) {
|
| 135 |
+
try {
|
| 136 |
+
audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
| 137 |
+
const response = await fetch(track.src);
|
| 138 |
+
const arrayBuffer = await response.arrayBuffer();
|
| 139 |
+
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
| 140 |
+
dest = audioCtx.createMediaStreamDestination();
|
| 141 |
+
const sourceNode = audioCtx.createBufferSource();
|
| 142 |
+
sourceNode.buffer = audioBuffer;
|
| 143 |
+
sourceNode.loop = true;
|
| 144 |
+
sourceNode.connect(dest);
|
| 145 |
+
sourceNode.start(0);
|
| 146 |
+
} catch (e) { console.warn("Audio load failed", e); }
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const stream = canvas.captureStream(30);
|
| 151 |
+
if (dest) {
|
| 152 |
+
const audioTrack = dest.stream.getAudioTracks()[0];
|
| 153 |
+
if (audioTrack) stream.addTrack(audioTrack);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
const mimeType = MediaRecorder.isTypeSupported('video/webm; codecs=vp9') ? 'video/webm; codecs=vp9' : 'video/webm';
|
| 157 |
+
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 });
|
| 158 |
+
const chunks: Blob[] = [];
|
| 159 |
+
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); };
|
| 160 |
+
recorder.onstop = () => {
|
| 161 |
+
const blob = new Blob(chunks, { type: 'video/webm' });
|
| 162 |
+
const url = URL.createObjectURL(blob);
|
| 163 |
+
if (audioCtx) audioCtx.close();
|
| 164 |
+
resolve(url);
|
| 165 |
+
};
|
| 166 |
+
recorder.start();
|
| 167 |
+
|
| 168 |
+
let itemsToRender: GeneratedResult[] = [];
|
| 169 |
+
if (targetStyleId) {
|
| 170 |
+
itemsToRender = [{ styleId: 'ORIGINAL', imageUrl: originalSrcs[0], timestamp: 0 }];
|
| 171 |
+
if(results[targetStyleId]) itemsToRender.push(results[targetStyleId]);
|
| 172 |
+
} else {
|
| 173 |
+
itemsToRender = Object.values(results) as GeneratedResult[];
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const loadImage = (url: string) => new Promise<HTMLImageElement>(r => {
|
| 177 |
+
const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => r(i); i.src = url;
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
for (let i = 0; i < itemsToRender.length; i++) {
|
| 181 |
+
const item = itemsToRender[i];
|
| 182 |
+
const img = await loadImage(item.imageUrl);
|
| 183 |
+
|
| 184 |
+
let label = '';
|
| 185 |
+
if (config.enableCustomTitle && config.customTitle) label = config.customTitle;
|
| 186 |
+
else if (item.styleId === 'ORIGINAL') label = 'Before';
|
| 187 |
+
else {
|
| 188 |
+
const s = allStyles.find(st => st.id === item.styleId);
|
| 189 |
+
label = s ? s.name : item.styleId;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
const startTime = Date.now();
|
| 193 |
+
while (Date.now() - startTime < durationPerSlide) {
|
| 194 |
+
applyEffectsToCanvas(ctx, img, canvas.width, canvas.height, filter, blurAmount, config.showWatermark, "Romantic Life");
|
| 195 |
+
|
| 196 |
+
if (config.template !== 'flash') {
|
| 197 |
+
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
| 198 |
+
ctx.fillRect(0, canvas.height - 100, canvas.width, 100);
|
| 199 |
+
ctx.fillStyle = 'white';
|
| 200 |
+
ctx.font = 'bold 40px sans-serif';
|
| 201 |
+
ctx.textAlign = 'center';
|
| 202 |
+
ctx.fillText(label, canvas.width / 2, canvas.height - 35);
|
| 203 |
+
}
|
| 204 |
+
await new Promise(r => requestAnimationFrame(r));
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
if (config.template !== 'flash' && i < itemsToRender.length - 1) {
|
| 208 |
+
const nextImg = await loadImage(itemsToRender[i+1].imageUrl);
|
| 209 |
+
const transStart = Date.now();
|
| 210 |
+
while (Date.now() - transStart < transitionDuration) {
|
| 211 |
+
const progress = (Date.now() - transStart) / transitionDuration;
|
| 212 |
+
ctx.globalAlpha = 1;
|
| 213 |
+
applyEffectsToCanvas(ctx, img, canvas.width, canvas.height, filter, blurAmount, config.showWatermark, "Romantic Life");
|
| 214 |
+
ctx.globalAlpha = progress;
|
| 215 |
+
applyEffectsToCanvas(ctx, nextImg, canvas.width, canvas.height, filter, blurAmount, config.showWatermark, "Romantic Life");
|
| 216 |
+
ctx.globalAlpha = 1;
|
| 217 |
+
await new Promise(r => requestAnimationFrame(r));
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
recorder.stop();
|
| 222 |
+
} catch (e) { reject(e); }
|
| 223 |
+
});
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
export const ResultViewer: React.FC<ResultViewerProps> = ({
|
| 227 |
+
originalImages, results, initialSelectedId, onReset, activeFilter = 'none', blurAmount = 0, language, onBookClick, onShareClick
|
| 228 |
+
}) => {
|
| 229 |
+
const resultKeys = Object.keys(results);
|
| 230 |
+
const [activeId, setActiveId] = useState<string | null>(initialSelectedId && results[initialSelectedId] ? initialSelectedId : resultKeys[0]);
|
| 231 |
+
const [showVideoSettings, setShowVideoSettings] = useState(false);
|
| 232 |
+
const [videoConfig, setVideoConfig] = useState<VideoConfig>({
|
| 233 |
+
transition: 'fade',
|
| 234 |
+
customTitle: '',
|
| 235 |
+
enableCustomTitle: false,
|
| 236 |
+
showWatermark: true,
|
| 237 |
+
transitionDuration: 800,
|
| 238 |
+
musicTrack: 'none',
|
| 239 |
+
format: 'mp4',
|
| 240 |
+
resolution: '1080p',
|
| 241 |
+
template: 'default'
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
// States
|
| 245 |
+
const [liveEffect, setLiveEffect] = useState<'none'|'breath'|'glitch'|'particles'>('none');
|
| 246 |
+
const [compareMode, setCompareMode] = useState(false);
|
| 247 |
+
const [compareId, setCompareId] = useState<string | null>(null); // null means Original
|
| 248 |
+
const [sliderPosition, setSliderPosition] = useState(50);
|
| 249 |
+
const [isSingleVideoGenerating, setIsSingleVideoGenerating] = useState(false);
|
| 250 |
+
const [includeWatermark, setIncludeWatermark] = useState(true);
|
| 251 |
+
const [openSection, setOpenSection] = useState<'composition' | 'effects' | 'audio' | null>('composition');
|
| 252 |
+
const [isZipping, setIsZipping] = useState(false);
|
| 253 |
+
|
| 254 |
+
// Selection States
|
| 255 |
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 256 |
+
|
| 257 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 258 |
+
const isDragging = useRef(false);
|
| 259 |
+
const t = TRANSLATIONS[language];
|
| 260 |
+
|
| 261 |
+
// SYNC Logic: Listen for parent prop changes
|
| 262 |
+
useEffect(() => {
|
| 263 |
+
if (initialSelectedId && results[initialSelectedId]) {
|
| 264 |
+
setActiveId(initialSelectedId);
|
| 265 |
+
}
|
| 266 |
+
}, [initialSelectedId, results]);
|
| 267 |
+
|
| 268 |
+
const activeResult = activeId ? results[activeId] : null;
|
| 269 |
+
const activeStyle = activeId ? STYLES.find(s => s.id === activeId) : null;
|
| 270 |
+
const activeStyleName = activeStyle ? (t.styles as any)[activeStyle.id] || activeStyle.name : '';
|
| 271 |
+
|
| 272 |
+
// Resolve Comparison Image (Left side)
|
| 273 |
+
const compareResult = compareId ? results[compareId] : null;
|
| 274 |
+
const compareImageUrl = compareResult ? compareResult.imageUrl : originalImages[0];
|
| 275 |
+
const compareLabel = compareId
|
| 276 |
+
? (STYLES.find(s => s.id === compareId) ? ((t.styles as any)[compareId] || compareId) : compareId)
|
| 277 |
+
: t.original;
|
| 278 |
+
|
| 279 |
+
// Slider Handlers
|
| 280 |
+
const handleMouseDown = useCallback(() => { isDragging.current = true; }, []);
|
| 281 |
+
const handleMouseUp = useCallback(() => { isDragging.current = false; }, []);
|
| 282 |
+
|
| 283 |
+
const updateSlider = useCallback((clientX: number) => {
|
| 284 |
+
if (!containerRef.current) return;
|
| 285 |
+
const rect = containerRef.current.getBoundingClientRect();
|
| 286 |
+
const pos = ((clientX - rect.left) / rect.width) * 100;
|
| 287 |
+
setSliderPosition(Math.max(0, Math.min(100, pos)));
|
| 288 |
+
}, []);
|
| 289 |
+
|
| 290 |
+
const handleMouseMove = useCallback((e: MouseEvent | TouchEvent) => {
|
| 291 |
+
if (!isDragging.current) return;
|
| 292 |
+
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
|
| 293 |
+
updateSlider(clientX);
|
| 294 |
+
}, [updateSlider]);
|
| 295 |
+
|
| 296 |
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
| 297 |
+
if (!compareMode) return;
|
| 298 |
+
|
| 299 |
+
let delta = 0;
|
| 300 |
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = -5;
|
| 301 |
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = 5;
|
| 302 |
+
|
| 303 |
+
if (delta !== 0) {
|
| 304 |
+
e.preventDefault();
|
| 305 |
+
setSliderPosition(prev => Math.max(0, Math.min(100, prev + delta)));
|
| 306 |
+
}
|
| 307 |
+
}, [compareMode]);
|
| 308 |
+
|
| 309 |
+
useEffect(() => {
|
| 310 |
+
window.addEventListener('mouseup', handleMouseUp);
|
| 311 |
+
window.addEventListener('touchend', handleMouseUp);
|
| 312 |
+
window.addEventListener('mousemove', handleMouseMove);
|
| 313 |
+
window.addEventListener('touchmove', handleMouseMove);
|
| 314 |
+
return () => {
|
| 315 |
+
window.removeEventListener('mouseup', handleMouseUp);
|
| 316 |
+
window.removeEventListener('touchend', handleMouseUp);
|
| 317 |
+
window.removeEventListener('mousemove', handleMouseMove);
|
| 318 |
+
window.removeEventListener('touchmove', handleMouseMove);
|
| 319 |
+
};
|
| 320 |
+
}, [handleMouseMove, handleMouseUp]);
|
| 321 |
+
|
| 322 |
+
// Selection Logic
|
| 323 |
+
const toggleSelection = (id: string, e: React.MouseEvent) => {
|
| 324 |
+
e.stopPropagation();
|
| 325 |
+
const newSet = new Set(selectedIds);
|
| 326 |
+
if (newSet.has(id)) newSet.delete(id);
|
| 327 |
+
else newSet.add(id);
|
| 328 |
+
setSelectedIds(newSet);
|
| 329 |
+
};
|
| 330 |
+
|
| 331 |
+
const handleSelectAll = () => {
|
| 332 |
+
const resultIds = Object.keys(results);
|
| 333 |
+
// If all currently selected, deselect all. Otherwise select all.
|
| 334 |
+
// Check if current selection covers all available results
|
| 335 |
+
const allSelected = resultIds.every(id => selectedIds.has(id));
|
| 336 |
+
|
| 337 |
+
if (allSelected) {
|
| 338 |
+
setSelectedIds(new Set());
|
| 339 |
+
} else {
|
| 340 |
+
setSelectedIds(new Set(resultIds));
|
| 341 |
+
}
|
| 342 |
+
};
|
| 343 |
+
|
| 344 |
+
const handleGenerateSingleVideo = async () => {
|
| 345 |
+
if (!activeResult || !activeStyle) return;
|
| 346 |
+
setIsSingleVideoGenerating(true);
|
| 347 |
+
try {
|
| 348 |
+
const url = await generateVideo(originalImages, results, STYLES, activeFilter, blurAmount, videoConfig, language, activeResult.styleId);
|
| 349 |
+
if (url) {
|
| 350 |
+
const link = document.createElement('a');
|
| 351 |
+
link.href = url;
|
| 352 |
+
link.download = `RomanticLife_Video_${activeStyleName}.webm`;
|
| 353 |
+
document.body.appendChild(link);
|
| 354 |
+
link.click();
|
| 355 |
+
document.body.removeChild(link);
|
| 356 |
+
alert("Video saved! You can import this into CapCut or TikTok for editing.");
|
| 357 |
+
}
|
| 358 |
+
} catch (e) {
|
| 359 |
+
console.error("Video gen failed", e);
|
| 360 |
+
alert("Video generation failed.");
|
| 361 |
+
} finally {
|
| 362 |
+
setIsSingleVideoGenerating(false);
|
| 363 |
+
}
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
const handleDownloadAll = async () => {
|
| 367 |
+
if (Object.keys(results).length === 0) return;
|
| 368 |
+
setIsZipping(true);
|
| 369 |
+
|
| 370 |
+
try {
|
| 371 |
+
const zip = new JSZip();
|
| 372 |
+
const imgFolder = zip.folder("RomanticLife_Photos");
|
| 373 |
+
|
| 374 |
+
// Filter: If selectedIds has items, use only those. Else use all results.
|
| 375 |
+
// Explicitly typing res to GeneratedResult to fix type error
|
| 376 |
+
const values = Object.values(results) as GeneratedResult[];
|
| 377 |
+
const targetResults: GeneratedResult[] = selectedIds.size > 0
|
| 378 |
+
? values.filter((res) => selectedIds.has(res.styleId))
|
| 379 |
+
: values;
|
| 380 |
+
|
| 381 |
+
let count = 0;
|
| 382 |
+
targetResults.forEach((res: GeneratedResult) => {
|
| 383 |
+
if (res.imageUrl.startsWith('data:image')) {
|
| 384 |
+
const base64Data = res.imageUrl.split(',')[1];
|
| 385 |
+
const style = STYLES.find(s => s.id === res.styleId);
|
| 386 |
+
const styleName = style ? ((t.styles as any)[style.id] || style.name) : res.styleId;
|
| 387 |
+
const safeName = styleName.replace(/[^a-z0-9\u4e00-\u9fa5]/gi, '_');
|
| 388 |
+
imgFolder?.file(`${safeName}_${res.timestamp}.png`, base64Data, { base64: true });
|
| 389 |
+
count++;
|
| 390 |
+
}
|
| 391 |
+
});
|
| 392 |
+
if (count === 0) {
|
| 393 |
+
alert("No valid images to zip.");
|
| 394 |
+
setIsZipping(false);
|
| 395 |
+
return;
|
| 396 |
+
}
|
| 397 |
+
const content = await zip.generateAsync({ type: "blob" });
|
| 398 |
+
const url = URL.createObjectURL(content);
|
| 399 |
+
const link = document.createElement('a');
|
| 400 |
+
const dateStr = new Date().toISOString().slice(0,10);
|
| 401 |
+
link.href = url;
|
| 402 |
+
link.download = `RomanticLife_Album_${dateStr}.zip`;
|
| 403 |
+
document.body.appendChild(link);
|
| 404 |
+
link.click();
|
| 405 |
+
document.body.removeChild(link);
|
| 406 |
+
URL.revokeObjectURL(url);
|
| 407 |
+
} catch (error) {
|
| 408 |
+
console.error("Zip failed", error);
|
| 409 |
+
alert("Failed to package images.");
|
| 410 |
+
} finally {
|
| 411 |
+
setIsZipping(false);
|
| 412 |
+
}
|
| 413 |
+
};
|
| 414 |
+
|
| 415 |
+
if (!activeResult || !activeStyle) return null;
|
| 416 |
+
|
| 417 |
+
return (
|
| 418 |
+
<div className="flex flex-col h-full gap-6">
|
| 419 |
+
{/* Video Settings Modal */}
|
| 420 |
+
{showVideoSettings && (
|
| 421 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
| 422 |
+
<div className="bg-white rounded-2xl p-0 max-w-sm w-full shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
|
| 423 |
+
{/* Header */}
|
| 424 |
+
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
| 425 |
+
<h3 className="font-bold text-lg font-serif text-gray-800">{t.vidSettings}</h3>
|
| 426 |
+
<button
|
| 427 |
+
onClick={() => setShowVideoSettings(false)}
|
| 428 |
+
className="p-1 hover:bg-gray-200 rounded-full transition-colors"
|
| 429 |
+
aria-label="Close"
|
| 430 |
+
>
|
| 431 |
+
<X className="w-5 h-5 text-gray-500" />
|
| 432 |
+
</button>
|
| 433 |
+
</div>
|
| 434 |
+
|
| 435 |
+
{/* Scrollable Content */}
|
| 436 |
+
<div className="overflow-y-auto p-4 space-y-3 custom-scrollbar">
|
| 437 |
+
{/* Composition Section */}
|
| 438 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
| 439 |
+
<button
|
| 440 |
+
onClick={() => setOpenSection(openSection === 'composition' ? null : 'composition')}
|
| 441 |
+
className="w-full flex justify-between items-center p-3 bg-gray-50 hover:bg-gray-100 transition-colors text-sm font-bold text-gray-700"
|
| 442 |
+
aria-expanded={openSection === 'composition'}
|
| 443 |
+
>
|
| 444 |
+
<span className="flex items-center gap-2"><Grid className="w-4 h-4 text-rose-500" /> {t.sectComposition || 'Composition'}</span>
|
| 445 |
+
{openSection === 'composition' ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
| 446 |
+
</button>
|
| 447 |
+
{openSection === 'composition' && (
|
| 448 |
+
<div className="p-4 bg-white space-y-4 animate-fade-in">
|
| 449 |
+
<div>
|
| 450 |
+
<label className="text-xs font-bold text-gray-500 mb-1.5 block">Template</label>
|
| 451 |
+
<div className="relative">
|
| 452 |
+
<select
|
| 453 |
+
value={videoConfig.template}
|
| 454 |
+
onChange={e => setVideoConfig({...videoConfig, template: e.target.value as any})}
|
| 455 |
+
className="w-full p-2.5 pl-3 pr-8 bg-gray-50 border border-gray-200 rounded-lg text-sm appearance-none outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-200"
|
| 456 |
+
>
|
| 457 |
+
<option value="default">Default</option>
|
| 458 |
+
<option value="flash">Flash Cut (Fast)</option>
|
| 459 |
+
<option value="slow">Slow Motion</option>
|
| 460 |
+
</select>
|
| 461 |
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
| 462 |
+
</div>
|
| 463 |
+
</div>
|
| 464 |
+
<div className="grid grid-cols-2 gap-3">
|
| 465 |
+
<div>
|
| 466 |
+
<label className="text-xs font-bold text-gray-500 mb-1.5 block">{t.vidFormat}</label>
|
| 467 |
+
<div className="relative">
|
| 468 |
+
<select
|
| 469 |
+
value={videoConfig.format}
|
| 470 |
+
onChange={e => setVideoConfig({...videoConfig, format: e.target.value as any})}
|
| 471 |
+
className="w-full p-2.5 pl-3 pr-8 bg-gray-50 border border-gray-200 rounded-lg text-sm appearance-none outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-200"
|
| 472 |
+
>
|
| 473 |
+
<option value="mp4">MP4</option>
|
| 474 |
+
<option value="webm">WebM</option>
|
| 475 |
+
</select>
|
| 476 |
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
<div>
|
| 480 |
+
<label className="text-xs font-bold text-gray-500 mb-1.5 block">{t.vidResolution}</label>
|
| 481 |
+
<div className="relative">
|
| 482 |
+
<select
|
| 483 |
+
value={videoConfig.resolution}
|
| 484 |
+
onChange={e => setVideoConfig({...videoConfig, resolution: e.target.value as any})}
|
| 485 |
+
className="w-full p-2.5 pl-3 pr-8 bg-gray-50 border border-gray-200 rounded-lg text-sm appearance-none outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-200"
|
| 486 |
+
>
|
| 487 |
+
<option value="1080p">1080p</option>
|
| 488 |
+
<option value="720p">720p</option>
|
| 489 |
+
</select>
|
| 490 |
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
| 491 |
+
</div>
|
| 492 |
+
</div>
|
| 493 |
+
</div>
|
| 494 |
+
</div>
|
| 495 |
+
)}
|
| 496 |
+
</div>
|
| 497 |
+
|
| 498 |
+
{/* Effects Section */}
|
| 499 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
| 500 |
+
<button
|
| 501 |
+
onClick={() => setOpenSection(openSection === 'effects' ? null : 'effects')}
|
| 502 |
+
className="w-full flex justify-between items-center p-3 bg-gray-50 hover:bg-gray-100 transition-colors text-sm font-bold text-gray-700"
|
| 503 |
+
aria-expanded={openSection === 'effects'}
|
| 504 |
+
>
|
| 505 |
+
<span className="flex items-center gap-2"><Sparkles className="w-4 h-4 text-purple-500" /> {t.sectEffects || 'Effects'}</span>
|
| 506 |
+
{openSection === 'effects' ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
| 507 |
+
</button>
|
| 508 |
+
{openSection === 'effects' && (
|
| 509 |
+
<div className="p-4 bg-white space-y-4 animate-fade-in">
|
| 510 |
+
<div>
|
| 511 |
+
<label className="text-xs font-bold text-gray-500 mb-1.5 block">{t.transEffect}</label>
|
| 512 |
+
<div className="relative">
|
| 513 |
+
<select
|
| 514 |
+
value={videoConfig.transition}
|
| 515 |
+
onChange={e => setVideoConfig({...videoConfig, transition: e.target.value as any})}
|
| 516 |
+
className="w-full p-2.5 pl-3 pr-8 bg-gray-50 border border-gray-200 rounded-lg text-sm appearance-none outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-200"
|
| 517 |
+
>
|
| 518 |
+
<option value="fade">Fade</option>
|
| 519 |
+
<option value="slide">Slide</option>
|
| 520 |
+
<option value="none">None</option>
|
| 521 |
+
</select>
|
| 522 |
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
</div>
|
| 526 |
+
)}
|
| 527 |
+
</div>
|
| 528 |
+
|
| 529 |
+
{/* Audio Section */}
|
| 530 |
+
<div className="border border-gray-200 rounded-xl overflow-hidden">
|
| 531 |
+
<button
|
| 532 |
+
onClick={() => setOpenSection(openSection === 'audio' ? null : 'audio')}
|
| 533 |
+
className="w-full flex justify-between items-center p-3 bg-gray-50 hover:bg-gray-100 transition-colors text-sm font-bold text-gray-700"
|
| 534 |
+
aria-expanded={openSection === 'audio'}
|
| 535 |
+
>
|
| 536 |
+
<span className="flex items-center gap-2"><Music className="w-4 h-4 text-blue-500" /> {t.sectAudio || 'Audio'}</span>
|
| 537 |
+
{openSection === 'audio' ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
| 538 |
+
</button>
|
| 539 |
+
{openSection === 'audio' && (
|
| 540 |
+
<div className="p-4 bg-white space-y-4 animate-fade-in">
|
| 541 |
+
<div>
|
| 542 |
+
<label className="text-xs font-bold text-gray-500 mb-1.5 block">{t.videoMusic}</label>
|
| 543 |
+
<div className="relative">
|
| 544 |
+
<select
|
| 545 |
+
value={videoConfig.musicTrack}
|
| 546 |
+
onChange={e => setVideoConfig({...videoConfig, musicTrack: e.target.value})}
|
| 547 |
+
className="w-full p-2.5 pl-3 pr-8 bg-gray-50 border border-gray-200 rounded-lg text-sm appearance-none outline-none focus:border-rose-500 focus:ring-1 focus:ring-rose-200"
|
| 548 |
+
>
|
| 549 |
+
{MUSIC_OPTIONS.map(m => (
|
| 550 |
+
<option key={m.id} value={m.id}>{(t as any)[m.labelKey] || m.id}</option>
|
| 551 |
+
))}
|
| 552 |
+
</select>
|
| 553 |
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
| 554 |
+
</div>
|
| 555 |
+
</div>
|
| 556 |
+
</div>
|
| 557 |
+
)}
|
| 558 |
+
</div>
|
| 559 |
+
</div>
|
| 560 |
+
|
| 561 |
+
{/* Footer */}
|
| 562 |
+
<div className="p-4 border-t border-gray-100 bg-gray-50/50">
|
| 563 |
+
<button
|
| 564 |
+
onClick={() => setShowVideoSettings(false)}
|
| 565 |
+
className="w-full py-3 bg-gray-900 text-white rounded-xl font-bold hover:bg-black transition-all shadow-lg shadow-gray-200 flex items-center justify-center gap-2"
|
| 566 |
+
>
|
| 567 |
+
<CheckCircle className="w-4 h-4" />
|
| 568 |
+
Done
|
| 569 |
+
</button>
|
| 570 |
+
</div>
|
| 571 |
+
</div>
|
| 572 |
+
</div>
|
| 573 |
+
)}
|
| 574 |
+
|
| 575 |
+
{/* Main Preview Area */}
|
| 576 |
+
<div className="flex-1 bg-gray-50 rounded-3xl border border-gray-200 overflow-hidden relative shadow-inner min-h-[500px] group">
|
| 577 |
+
{/* Live Effects Layer */}
|
| 578 |
+
<div className={`absolute inset-0 pointer-events-none z-10 ${
|
| 579 |
+
liveEffect === 'breath' ? 'effect-breath' :
|
| 580 |
+
liveEffect === 'glitch' ? 'effect-glitch' :
|
| 581 |
+
liveEffect === 'particles' ? 'effect-particles' : ''
|
| 582 |
+
}`} aria-hidden="true"></div>
|
| 583 |
+
|
| 584 |
+
{/* Control Bar */}
|
| 585 |
+
<div className="absolute top-0 left-0 right-0 p-4 flex justify-between items-start z-40 pointer-events-none">
|
| 586 |
+
<div className="bg-white/90 backdrop-blur-sm px-4 py-2 rounded-full shadow-sm border border-gray-100 pointer-events-auto">
|
| 587 |
+
<span className="font-serif font-bold text-gray-800">{activeStyleName}</span>
|
| 588 |
+
</div>
|
| 589 |
+
|
| 590 |
+
<div className="flex gap-2 pointer-events-auto">
|
| 591 |
+
<button onClick={() => setLiveEffect(liveEffect === 'none' ? 'breath' : 'none')} className="p-2 bg-white rounded-full shadow-sm" aria-label="Toggle Live Effect"><Zap className="w-4 h-4" aria-hidden="true" /></button>
|
| 592 |
+
<button
|
| 593 |
+
onClick={() => setCompareMode(!compareMode)}
|
| 594 |
+
className={`p-2 rounded-full shadow-sm transition-colors ${compareMode ? 'bg-blue-600 text-white' : 'bg-white hover:bg-gray-100'}`}
|
| 595 |
+
aria-label={compareMode ? "Exit Compare Mode" : "Enter Compare Mode"}
|
| 596 |
+
>
|
| 597 |
+
<SplitSquareHorizontal className="w-4 h-4" aria-hidden="true" />
|
| 598 |
+
</button>
|
| 599 |
+
<button onClick={onReset} className="p-2 bg-gray-900 text-white rounded-full shadow-lg hover:bg-black" aria-label="Reset and Close"><X className="w-4 h-4" aria-hidden="true" /></button>
|
| 600 |
+
</div>
|
| 601 |
+
</div>
|
| 602 |
+
|
| 603 |
+
{/* Canvas Area with Keyboard Support */}
|
| 604 |
+
<div
|
| 605 |
+
className="relative w-full h-full cursor-col-resize select-none flex items-center justify-center bg-gray-200 outline-none focus:ring-2 focus:ring-rose-500 focus:ring-inset rounded-lg overflow-hidden"
|
| 606 |
+
ref={containerRef}
|
| 607 |
+
onMouseDown={handleMouseDown}
|
| 608 |
+
onTouchStart={handleMouseDown}
|
| 609 |
+
role={compareMode ? "slider" : "img"}
|
| 610 |
+
aria-label={compareMode ? "Comparison Slider. Use Left/Right arrows to adjust." : `Generated image for ${activeStyleName}`}
|
| 611 |
+
aria-valuenow={compareMode ? sliderPosition : undefined}
|
| 612 |
+
aria-valuemin={compareMode ? 0 : undefined}
|
| 613 |
+
aria-valuemax={compareMode ? 100 : undefined}
|
| 614 |
+
tabIndex={0}
|
| 615 |
+
onKeyDown={handleKeyDown}
|
| 616 |
+
>
|
| 617 |
+
{/* Background Image (Active Style / Right Side) */}
|
| 618 |
+
<img src={activeResult.imageUrl} className="absolute inset-0 w-full h-full object-contain p-2 sm:p-8 transition-opacity duration-300" alt="After" />
|
| 619 |
+
|
| 620 |
+
{compareMode && (
|
| 621 |
+
<>
|
| 622 |
+
{/* Overlay Image (Original or Compare Style / Left Side) - Clipped */}
|
| 623 |
+
<img
|
| 624 |
+
src={compareImageUrl}
|
| 625 |
+
className="absolute inset-0 w-full h-full object-contain p-2 sm:p-8 transition-[clip-path] duration-75 ease-linear bg-gray-200"
|
| 626 |
+
alt="Before"
|
| 627 |
+
style={{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }}
|
| 628 |
+
/>
|
| 629 |
+
|
| 630 |
+
{/* Slider Handle */}
|
| 631 |
+
<div
|
| 632 |
+
className="absolute top-0 bottom-0 w-0.5 bg-white cursor-ew-resize z-30 filter drop-shadow-[0_0_5px_rgba(0,0,0,0.5)]"
|
| 633 |
+
style={{ left: `${sliderPosition}%` }}
|
| 634 |
+
>
|
| 635 |
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center text-rose-500 hover:scale-110 transition-transform group-active:scale-110">
|
| 636 |
+
<ArrowLeftRight className="w-4 h-4" />
|
| 637 |
+
</div>
|
| 638 |
+
</div>
|
| 639 |
+
|
| 640 |
+
{/* Floating Labels */}
|
| 641 |
+
<div className="absolute top-16 left-4 z-20 bg-black/60 text-white px-3 py-1 rounded-full text-xs font-bold backdrop-blur-md border border-white/10 shadow-lg pointer-events-none animate-fade-in">
|
| 642 |
+
{compareLabel}
|
| 643 |
+
</div>
|
| 644 |
+
<div className="absolute top-16 right-4 z-20 bg-rose-500/90 text-white px-3 py-1 rounded-full text-xs font-bold backdrop-blur-md border border-white/10 shadow-lg pointer-events-none animate-fade-in">
|
| 645 |
+
{activeStyleName}
|
| 646 |
+
</div>
|
| 647 |
+
</>
|
| 648 |
+
)}
|
| 649 |
+
</div>
|
| 650 |
+
</div>
|
| 651 |
+
|
| 652 |
+
{/* Action Bar */}
|
| 653 |
+
<div className="flex justify-between items-center bg-white p-4 rounded-2xl shadow-sm border border-gray-100">
|
| 654 |
+
<button onClick={() => setIncludeWatermark(!includeWatermark)} className={`text-xs font-bold ${includeWatermark ? 'text-rose-600' : 'text-gray-400'}`}>
|
| 655 |
+
Watermark: {includeWatermark ? 'ON' : 'OFF'}
|
| 656 |
+
</button>
|
| 657 |
+
<div className="flex gap-2">
|
| 658 |
+
<button onClick={onShareClick} className="p-2 bg-gray-100 rounded-full hover:bg-gray-200 text-gray-700" aria-label="Share">
|
| 659 |
+
<Share2 className="w-4 h-4" aria-hidden="true" />
|
| 660 |
+
</button>
|
| 661 |
+
<button onClick={() => setShowVideoSettings(true)} className="p-2 bg-gray-100 rounded-full hover:bg-gray-200" aria-label="Video Settings"><Settings className="w-4 h-4" aria-hidden="true" /></button>
|
| 662 |
+
|
| 663 |
+
<button
|
| 664 |
+
onClick={handleDownloadAll}
|
| 665 |
+
disabled={isZipping}
|
| 666 |
+
className="px-4 py-2 bg-gray-800 text-white rounded-xl font-bold text-xs flex items-center gap-2 hover:bg-black transition-colors"
|
| 667 |
+
>
|
| 668 |
+
{isZipping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
|
| 669 |
+
{isZipping ? t.zipLoading : (selectedIds.size > 0 ? `Download (${selectedIds.size})` : t.zipBtn)}
|
| 670 |
+
</button>
|
| 671 |
+
|
| 672 |
+
<button onClick={handleGenerateSingleVideo} disabled={isSingleVideoGenerating} className="px-4 py-2 bg-rose-600 text-white rounded-xl font-bold text-xs flex items-center gap-2">
|
| 673 |
+
{isSingleVideoGenerating ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> : <PlayCircle className="w-4 h-4" aria-hidden="true" />}
|
| 674 |
+
{t.videoBtn}
|
| 675 |
+
</button>
|
| 676 |
+
</div>
|
| 677 |
+
</div>
|
| 678 |
+
|
| 679 |
+
{/* Thumbnail Grid */}
|
| 680 |
+
<div className="flex flex-col gap-2">
|
| 681 |
+
{/* Grid Toolbar */}
|
| 682 |
+
<div className="flex items-center justify-between px-2">
|
| 683 |
+
<button
|
| 684 |
+
onClick={handleSelectAll}
|
| 685 |
+
className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-rose-500 transition-colors"
|
| 686 |
+
>
|
| 687 |
+
{Object.keys(results).length > 0 && selectedIds.size === Object.keys(results).length ? (
|
| 688 |
+
<CheckSquare className="w-4 h-4 text-rose-500" />
|
| 689 |
+
) : (
|
| 690 |
+
<Square className="w-4 h-4" />
|
| 691 |
+
)}
|
| 692 |
+
Select All
|
| 693 |
+
</button>
|
| 694 |
+
<span className="text-xs text-gray-400 font-mono">
|
| 695 |
+
{selectedIds.size} selected
|
| 696 |
+
</span>
|
| 697 |
+
</div>
|
| 698 |
+
|
| 699 |
+
<div className="grid grid-cols-5 gap-2 overflow-x-auto pb-2" role="list" aria-label="Generated Results">
|
| 700 |
+
{/* Original Image Button (Visible only in Compare Mode) */}
|
| 701 |
+
{compareMode && (
|
| 702 |
+
<button
|
| 703 |
+
onClick={() => setCompareId(null)}
|
| 704 |
+
aria-pressed={compareId === null}
|
| 705 |
+
className={`aspect-square rounded-lg overflow-hidden cursor-pointer border-2 relative ${compareId === null ? 'border-blue-500 ring-2 ring-blue-200' : 'border-gray-200'}`}
|
| 706 |
+
>
|
| 707 |
+
<img src={originalImages[0]} className="w-full h-full object-cover" alt="Original" />
|
| 708 |
+
<span className="absolute bottom-0 left-0 w-full bg-black/60 text-white text-[9px] font-bold text-center py-0.5 backdrop-blur-sm">{t.original}</span>
|
| 709 |
+
{compareId === null && <div className="absolute top-1 left-1 w-2 h-2 rounded-full bg-blue-500 ring-1 ring-white"></div>}
|
| 710 |
+
</button>
|
| 711 |
+
)}
|
| 712 |
+
|
| 713 |
+
{STYLES.filter(s => results[s.id]).map(style => {
|
| 714 |
+
const isSelectedView = activeId === style.id;
|
| 715 |
+
const isCompare = compareId === style.id;
|
| 716 |
+
const isChecked = selectedIds.has(style.id);
|
| 717 |
+
|
| 718 |
+
let borderClass = 'border-transparent';
|
| 719 |
+
if (compareMode) {
|
| 720 |
+
if (isSelectedView) borderClass = 'border-rose-500'; // Right Side
|
| 721 |
+
if (isCompare) borderClass = 'border-blue-500 ring-2 ring-blue-200'; // Left Side
|
| 722 |
+
} else {
|
| 723 |
+
if (isSelectedView) borderClass = 'border-rose-500';
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
return (
|
| 727 |
+
<button
|
| 728 |
+
key={style.id}
|
| 729 |
+
onClick={() => {
|
| 730 |
+
if(compareMode) {
|
| 731 |
+
if (activeId !== style.id) setCompareId(style.id);
|
| 732 |
+
} else {
|
| 733 |
+
setActiveId(style.id);
|
| 734 |
+
}
|
| 735 |
+
}}
|
| 736 |
+
aria-pressed={isSelectedView || isCompare}
|
| 737 |
+
aria-label={`View ${style.name} result`}
|
| 738 |
+
className={`aspect-square rounded-lg overflow-hidden cursor-pointer border-2 relative transition-all group ${borderClass}`}
|
| 739 |
+
>
|
| 740 |
+
<img src={results[style.id].imageUrl} className="w-full h-full object-cover" alt="" />
|
| 741 |
+
|
| 742 |
+
{/* Selection Checkbox Overlay */}
|
| 743 |
+
<div
|
| 744 |
+
className="absolute top-1 left-1 z-20 cursor-pointer"
|
| 745 |
+
onClick={(e) => toggleSelection(style.id, e)}
|
| 746 |
+
>
|
| 747 |
+
<div className={`w-5 h-5 rounded border shadow-sm flex items-center justify-center transition-colors ${isChecked ? 'bg-rose-500 border-rose-600' : 'bg-white/80 border-gray-300 hover:bg-white'}`}>
|
| 748 |
+
{isChecked && <Check className="w-3.5 h-3.5 text-white" />}
|
| 749 |
+
</div>
|
| 750 |
+
</div>
|
| 751 |
+
|
| 752 |
+
{/* Visual Indicators for Compare Mode */}
|
| 753 |
+
{compareMode && isCompare && <div className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 ring-1 ring-white"></div>}
|
| 754 |
+
{compareMode && isSelectedView && <div className="absolute top-1 right-1 w-2 h-2 rounded-full bg-rose-500 ring-1 ring-white"></div>}
|
| 755 |
+
</button>
|
| 756 |
+
);
|
| 757 |
+
})}
|
| 758 |
+
</div>
|
| 759 |
+
</div>
|
| 760 |
+
</div>
|
| 761 |
+
);
|
| 762 |
+
};
|
components/ShareModal.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import { X, Copy, Share2, MessageCircle, Facebook, Twitter, Check } from 'lucide-react';
|
| 4 |
+
import { Language } from '../types';
|
| 5 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 6 |
+
|
| 7 |
+
interface ShareModalProps {
|
| 8 |
+
isVisible: boolean;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
language: Language;
|
| 11 |
+
config?: any; // Share config from admin
|
| 12 |
+
showToast: (msg: string) => void;
|
| 13 |
+
onShareSuccess: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export const ShareModal: React.FC<ShareModalProps> = ({
|
| 17 |
+
isVisible, onClose, language, config, showToast, onShareSuccess
|
| 18 |
+
}) => {
|
| 19 |
+
const t = TRANSLATIONS[language];
|
| 20 |
+
const [copied, setCopied] = React.useState(false);
|
| 21 |
+
|
| 22 |
+
if (!isVisible) return null;
|
| 23 |
+
|
| 24 |
+
const shareTitle = config?.shareTitle || "Check out Romantic Life AI Studio!";
|
| 25 |
+
const shareUrl = window.location.href;
|
| 26 |
+
const fullShareText = `${shareTitle} ${shareUrl}`;
|
| 27 |
+
|
| 28 |
+
const handleCopyLink = async () => {
|
| 29 |
+
try {
|
| 30 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 31 |
+
await navigator.clipboard.writeText(fullShareText);
|
| 32 |
+
setCopied(true);
|
| 33 |
+
showToast(t.toastCopy);
|
| 34 |
+
onShareSuccess();
|
| 35 |
+
setTimeout(() => {
|
| 36 |
+
setCopied(false);
|
| 37 |
+
onClose();
|
| 38 |
+
}, 1500);
|
| 39 |
+
} else {
|
| 40 |
+
// Fallback for older browsers
|
| 41 |
+
const textArea = document.createElement("textarea");
|
| 42 |
+
textArea.value = fullShareText;
|
| 43 |
+
document.body.appendChild(textArea);
|
| 44 |
+
textArea.select();
|
| 45 |
+
document.execCommand('copy');
|
| 46 |
+
document.body.removeChild(textArea);
|
| 47 |
+
setCopied(true);
|
| 48 |
+
showToast(t.toastCopy);
|
| 49 |
+
onShareSuccess();
|
| 50 |
+
setTimeout(() => onClose(), 1500);
|
| 51 |
+
}
|
| 52 |
+
} catch (err) {
|
| 53 |
+
console.error('Failed to copy: ', err);
|
| 54 |
+
alert("Failed to copy link. Please copy manually from address bar.");
|
| 55 |
+
}
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
const handleWeChat = () => {
|
| 59 |
+
// In a real PWA, this might trigger a native share if available,
|
| 60 |
+
// or show a QR code modal. For now, we simulate the "Shared" callback.
|
| 61 |
+
if (navigator.share) {
|
| 62 |
+
navigator.share({
|
| 63 |
+
title: shareTitle,
|
| 64 |
+
text: shareTitle,
|
| 65 |
+
url: shareUrl,
|
| 66 |
+
}).then(() => {
|
| 67 |
+
onShareSuccess();
|
| 68 |
+
onClose();
|
| 69 |
+
}).catch((error) => console.log('Error sharing', error));
|
| 70 |
+
} else {
|
| 71 |
+
alert("Please screenshot this page and share to WeChat Moments.");
|
| 72 |
+
onShareSuccess(); // Assume they did it for UX flow
|
| 73 |
+
setTimeout(onClose, 500);
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
| 79 |
+
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden relative">
|
| 80 |
+
<div className="bg-gray-50 p-4 border-b border-gray-100 flex justify-between items-center">
|
| 81 |
+
<h3 className="font-bold text-gray-800 flex items-center gap-2">
|
| 82 |
+
<Share2 className="w-4 h-4 text-rose-500" /> Share to Earn
|
| 83 |
+
</h3>
|
| 84 |
+
<button onClick={onClose} className="p-1 hover:bg-gray-200 rounded-full">
|
| 85 |
+
<X className="w-5 h-5 text-gray-500" />
|
| 86 |
+
</button>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div className="p-6 grid grid-cols-2 gap-4">
|
| 90 |
+
<button
|
| 91 |
+
onClick={handleWeChat}
|
| 92 |
+
className="flex flex-col items-center justify-center gap-2 p-4 rounded-xl bg-green-50 text-green-700 hover:bg-green-100 transition-colors group"
|
| 93 |
+
>
|
| 94 |
+
<div className="w-10 h-10 bg-green-500 text-white rounded-full flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
|
| 95 |
+
<MessageCircle className="w-6 h-6" />
|
| 96 |
+
</div>
|
| 97 |
+
<span className="text-xs font-bold">WeChat</span>
|
| 98 |
+
</button>
|
| 99 |
+
|
| 100 |
+
<button
|
| 101 |
+
onClick={handleCopyLink}
|
| 102 |
+
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-xl transition-colors group ${copied ? 'bg-gray-800 text-white' : 'bg-gray-50 text-gray-700 hover:bg-gray-100'}`}
|
| 103 |
+
>
|
| 104 |
+
<div className={`w-10 h-10 rounded-full flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform ${copied ? 'bg-green-500 text-white' : 'bg-gray-700 text-white'}`}>
|
| 105 |
+
{copied ? <Check className="w-6 h-6" /> : <Copy className="w-5 h-5" />}
|
| 106 |
+
</div>
|
| 107 |
+
<span className="text-xs font-bold">{copied ? "Copied!" : "Copy Link"}</span>
|
| 108 |
+
</button>
|
| 109 |
+
|
| 110 |
+
<button
|
| 111 |
+
className="flex flex-col items-center justify-center gap-2 p-4 rounded-xl bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors opacity-60 hover:opacity-100"
|
| 112 |
+
onClick={() => {
|
| 113 |
+
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank');
|
| 114 |
+
onShareSuccess();
|
| 115 |
+
}}
|
| 116 |
+
>
|
| 117 |
+
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center shadow-lg">
|
| 118 |
+
<Facebook className="w-5 h-5" />
|
| 119 |
+
</div>
|
| 120 |
+
<span className="text-xs font-bold">Facebook</span>
|
| 121 |
+
</button>
|
| 122 |
+
|
| 123 |
+
<button
|
| 124 |
+
className="flex flex-col items-center justify-center gap-2 p-4 rounded-xl bg-sky-50 text-sky-700 hover:bg-sky-100 transition-colors opacity-60 hover:opacity-100"
|
| 125 |
+
onClick={() => {
|
| 126 |
+
window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareTitle)}&url=${encodeURIComponent(shareUrl)}`, '_blank');
|
| 127 |
+
onShareSuccess();
|
| 128 |
+
}}
|
| 129 |
+
>
|
| 130 |
+
<div className="w-10 h-10 bg-sky-500 text-white rounded-full flex items-center justify-center shadow-lg">
|
| 131 |
+
<Twitter className="w-5 h-5" />
|
| 132 |
+
</div>
|
| 133 |
+
<span className="text-xs font-bold">Twitter</span>
|
| 134 |
+
</button>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div className="p-4 bg-gray-50 text-center">
|
| 138 |
+
<p className="text-xs text-gray-500">Share now to earn +10 Points instantly!</p>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
);
|
| 143 |
+
};
|
components/SlashModal.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { X, Zap, Timer } from 'lucide-react';
|
| 3 |
+
import { Language, WeddingStyle, AdminConfig, UserAccount } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface SlashModalProps {
|
| 7 |
+
isVisible: boolean;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
language: Language;
|
| 10 |
+
style: WeddingStyle | null;
|
| 11 |
+
onUnlock: () => void;
|
| 12 |
+
adminConfig: AdminConfig;
|
| 13 |
+
user?: UserAccount;
|
| 14 |
+
onUpdateUser: (progress: Record<string, number>) => void;
|
| 15 |
+
onOpenShare: () => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export const SlashModal: React.FC<SlashModalProps> = ({
|
| 19 |
+
isVisible, onClose, language, style, onUnlock, adminConfig, user, onUpdateUser, onOpenShare
|
| 20 |
+
}) => {
|
| 21 |
+
const t = TRANSLATIONS[language];
|
| 22 |
+
const [progress, setProgress] = useState(98);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
if (isVisible && style) {
|
| 26 |
+
if (user?.slashProgress && user.slashProgress[style.id]) {
|
| 27 |
+
setProgress(user.slashProgress[style.id]);
|
| 28 |
+
} else {
|
| 29 |
+
setProgress(98);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}, [isVisible, style, user]);
|
| 33 |
+
|
| 34 |
+
if (!isVisible || !style) return null;
|
| 35 |
+
|
| 36 |
+
const styleName = (t.styles as any)[style.id] || style.name;
|
| 37 |
+
|
| 38 |
+
const handleInvite = () => {
|
| 39 |
+
onOpenShare();
|
| 40 |
+
|
| 41 |
+
// Update progress logic
|
| 42 |
+
const remaining = 100 - progress;
|
| 43 |
+
// Slash logic: Cut 50-80% of remaining
|
| 44 |
+
const cutPercent = 0.5 + Math.random() * 0.3;
|
| 45 |
+
let newProgress = progress + (remaining * cutPercent);
|
| 46 |
+
|
| 47 |
+
// If very close, complete it (e.g. > 99.5)
|
| 48 |
+
if (newProgress > 99.5) {
|
| 49 |
+
newProgress = 100;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
setProgress(newProgress);
|
| 53 |
+
|
| 54 |
+
if (user) {
|
| 55 |
+
const newMap = { ...(user.slashProgress || {}), [style.id]: newProgress };
|
| 56 |
+
onUpdateUser(newMap);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
if (newProgress >= 100) {
|
| 60 |
+
onUnlock();
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
onClose();
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-fade-in">
|
| 68 |
+
<div className="bg-gradient-to-b from-orange-500 to-red-600 rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden relative text-white">
|
| 69 |
+
<button onClick={onClose} className="absolute top-2 right-2 p-1.5 bg-black/20 rounded-full hover:bg-black/40 z-20">
|
| 70 |
+
<X className="w-4 h-4" />
|
| 71 |
+
</button>
|
| 72 |
+
|
| 73 |
+
<div className="p-6 text-center">
|
| 74 |
+
<div className="inline-block px-3 py-1 bg-black/30 rounded-full text-xs font-bold mb-4 flex items-center gap-1 mx-auto border border-white/20">
|
| 75 |
+
<Timer className="w-3 h-3" /> Ends in 23:59:10
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<h2 className="text-2xl font-bold italic drop-shadow-md">{t.pddSlashTitle}</h2>
|
| 79 |
+
<p className="text-orange-100 text-sm mt-1">{t.pddSlashDesc}</p>
|
| 80 |
+
|
| 81 |
+
{/* Product Card */}
|
| 82 |
+
<div className="bg-white text-gray-900 rounded-xl p-3 mt-6 flex items-center gap-3 shadow-lg">
|
| 83 |
+
<div className="w-16 h-16 bg-gray-200 rounded-lg shrink-0 overflow-hidden relative">
|
| 84 |
+
<div className={`w-full h-full ${style.coverColor} opacity-50`}></div>
|
| 85 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 86 |
+
<span className="text-xs font-bold text-gray-500">IMG</span>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
<div className="text-left flex-1">
|
| 90 |
+
<p className="font-bold text-sm truncate">{styleName}</p>
|
| 91 |
+
<p className="text-xs text-gray-500">VIP Premium Collection</p>
|
| 92 |
+
<p className="text-red-500 font-bold text-sm mt-1">¥0.00 <span className="text-gray-400 line-through text-xs">¥199</span></p>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
{/* Progress Bar */}
|
| 97 |
+
<div className="mt-6 relative">
|
| 98 |
+
<div className="flex justify-between text-xs font-bold mb-1">
|
| 99 |
+
<span className="text-yellow-200">{t.pddSlashProgress}</span>
|
| 100 |
+
<span>{progress.toFixed(2)}%</span>
|
| 101 |
+
</div>
|
| 102 |
+
<div className="h-4 bg-black/30 rounded-full overflow-hidden border border-white/20">
|
| 103 |
+
<div className="h-full bg-gradient-to-r from-yellow-300 to-yellow-500 relative" style={{ width: `${progress}%` }}>
|
| 104 |
+
<div className="absolute top-0 right-0 h-full w-2 bg-white/50 animate-pulse"></div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
<div className="absolute -right-2 -top-2 bg-red-100 text-red-600 text-[10px] font-bold px-1.5 rounded-full border border-red-500 shadow-sm animate-bounce">
|
| 108 |
+
Only {(100 - progress).toFixed(2)}% left!
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<button
|
| 113 |
+
onClick={handleInvite}
|
| 114 |
+
className="w-full py-3 bg-yellow-400 hover:bg-yellow-300 text-red-700 font-extrabold text-lg rounded-full mt-6 shadow-xl shadow-orange-700/50 flex items-center justify-center gap-2 transform active:scale-95 transition-all"
|
| 115 |
+
>
|
| 116 |
+
<Zap className="w-5 h-5 fill-current" /> {t.pddSlashCta}
|
| 117 |
+
</button>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
{/* Social Proof List */}
|
| 121 |
+
<div className="bg-white/10 p-4 border-t border-white/10">
|
| 122 |
+
<p className="text-xs text-white/60 mb-2 text-center">Friends who helped</p>
|
| 123 |
+
<div className="flex justify-center -space-x-2">
|
| 124 |
+
{[1,2,3].map(i => (
|
| 125 |
+
<div key={i} className="w-8 h-8 rounded-full bg-gray-200 border-2 border-orange-500"></div>
|
| 126 |
+
))}
|
| 127 |
+
<div className="w-8 h-8 rounded-full bg-gray-800 border-2 border-orange-500 flex items-center justify-center text-[10px] text-white">
|
| 128 |
+
+99
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
);
|
| 135 |
+
};
|
components/StyleSelector.tsx
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useRef, useState, useMemo, useEffect } from 'react';
|
| 2 |
+
import { WeddingStyle, Language, Resolution } from '../types';
|
| 3 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 4 |
+
import { useUserStore } from '../store';
|
| 5 |
+
import {
|
| 6 |
+
Heart, Crown, Leaf, Zap, Clock, Sparkles,
|
| 7 |
+
Camera, Feather, Film, Palette, Coffee,
|
| 8 |
+
Sun, Music, BookOpen, Star, Aperture,
|
| 9 |
+
Smile, PenTool, Layout, Image as ImageIcon, Upload,
|
| 10 |
+
Flower, Gem, Eye, Cog, Rocket, Cpu,
|
| 11 |
+
Castle, Anchor, Snowflake, Droplets, Warehouse,
|
| 12 |
+
Trees, Tv, Brush, Cloud, Layers, Unlink, ScanFace,
|
| 13 |
+
Lock, Dice5, Users, ArrowRight, Search, Briefcase, User, Watch, GraduationCap, Baby, Grid,
|
| 14 |
+
Fish, Flame, Moon, Hourglass, Landmark, Sword, Monitor, Shirt, Box, Home, Book, Circle, Globe, Activity, Check, Maximize, Loader2
|
| 15 |
+
} from 'lucide-react';
|
| 16 |
+
|
| 17 |
+
interface StyleSelectorProps {
|
| 18 |
+
selectedStyle: WeddingStyle | null;
|
| 19 |
+
onSelect: (style: WeddingStyle) => void;
|
| 20 |
+
onCustomSelect: (image: string) => void;
|
| 21 |
+
onLuckySelect: () => void;
|
| 22 |
+
onSlashClick: (style: WeddingStyle) => void;
|
| 23 |
+
disabled: boolean;
|
| 24 |
+
language: Language;
|
| 25 |
+
recommendedStyleIds?: string[];
|
| 26 |
+
isVipUnlocked?: boolean;
|
| 27 |
+
isLoading?: boolean;
|
| 28 |
+
resolution: Resolution;
|
| 29 |
+
onResolutionChange: (res: Resolution) => void;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Simple icon wrapper
|
| 33 |
+
const BoxIcon = ({ className }: { className?: string }) => (
|
| 34 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={className} aria-hidden="true">
|
| 35 |
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
| 36 |
+
</svg>
|
| 37 |
+
);
|
| 38 |
+
|
| 39 |
+
// Skeleton for loading state
|
| 40 |
+
const StyleCardSkeleton = () => (
|
| 41 |
+
<li className="list-none aspect-[3/4] rounded-xl overflow-hidden bg-gray-100 relative animate-pulse border border-gray-100">
|
| 42 |
+
<div className="absolute inset-0 bg-gray-200/50"></div>
|
| 43 |
+
<div className="absolute bottom-0 left-0 right-0 p-3 space-y-2 bg-gradient-to-t from-gray-200 to-transparent pt-10">
|
| 44 |
+
<div className="flex justify-between items-end">
|
| 45 |
+
<div className="space-y-1.5 w-full">
|
| 46 |
+
<div className="h-4 bg-gray-300 rounded w-2/3"></div>
|
| 47 |
+
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
| 48 |
+
</div>
|
| 49 |
+
<div className="w-8 h-8 rounded-full bg-gray-300 shrink-0"></div>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</li>
|
| 53 |
+
);
|
| 54 |
+
|
| 55 |
+
// Lazy Background Component with Pulse Effect
|
| 56 |
+
const LazyStyleBackground = ({ src, colorClass }: { src?: string, colorClass: string }) => {
|
| 57 |
+
const [loaded, setLoaded] = useState(false);
|
| 58 |
+
const [error, setError] = useState(false);
|
| 59 |
+
|
| 60 |
+
// If no image or error, return just the color background (original behavior)
|
| 61 |
+
if (!src || error) {
|
| 62 |
+
return <div className={`absolute inset-0 opacity-40 ${colorClass} transition-opacity group-hover:opacity-60`} aria-hidden="true" />;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<>
|
| 67 |
+
{/* Skeleton / Loading State for individual image */}
|
| 68 |
+
{!loaded && (
|
| 69 |
+
<div className={`absolute inset-0 ${colorClass} flex items-center justify-center z-10`} aria-hidden="true">
|
| 70 |
+
<div className="absolute inset-0 bg-white/20 animate-pulse" />
|
| 71 |
+
<Loader2 className="w-6 h-6 text-gray-400/50 animate-spin" />
|
| 72 |
+
</div>
|
| 73 |
+
)}
|
| 74 |
+
|
| 75 |
+
{/* Lazy Loaded Image */}
|
| 76 |
+
<img
|
| 77 |
+
src={src}
|
| 78 |
+
alt=""
|
| 79 |
+
loading="lazy"
|
| 80 |
+
onLoad={() => setLoaded(true)}
|
| 81 |
+
onError={() => setError(true)}
|
| 82 |
+
className={`absolute inset-0 w-full h-full object-cover transition-all duration-500 ${loaded ? 'opacity-30 group-hover:opacity-50 scale-100' : 'opacity-0 scale-105'}`}
|
| 83 |
+
aria-hidden="true"
|
| 84 |
+
/>
|
| 85 |
+
</>
|
| 86 |
+
);
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
// We keep the logic constants here, but names will be dynamic based on language
|
| 90 |
+
export const STYLES: WeddingStyle[] = [
|
| 91 |
+
// 1. Classic & Western
|
| 92 |
+
{
|
| 93 |
+
id: 'korean',
|
| 94 |
+
name: '韩式 (Korean)', // Fallback
|
| 95 |
+
prompt: 'Korean wedding photography style, minimalist, clean solid background, soft studio lighting, elegant white mermaid dress, simple veil, romantic and sweet atmosphere, flawless makeup, K-drama aesthetic.',
|
| 96 |
+
description: 'Minimalist, sweet, and elegant.',
|
| 97 |
+
coverColor: 'bg-rose-50',
|
| 98 |
+
previewImage: 'https://images.unsplash.com/photo-1520854221256-17451cc330e7?auto=format&fit=crop&w=400&q=60',
|
| 99 |
+
icon: <Heart className="w-5 h-5 text-rose-400" aria-hidden="true" />,
|
| 100 |
+
tags: ['hot'],
|
| 101 |
+
isLocked: true, // Aggressive VIP Strategy: Lock the most popular style
|
| 102 |
+
groupCount: 523 // PDD: Fake group count
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
id: 'british',
|
| 106 |
+
name: '英伦 (British)',
|
| 107 |
+
prompt: 'British royal style wedding, vintage manor background, groom in morning suit, bride in lace vintage gown, elegant hat, overcast soft light, noble and aristocratic atmosphere.',
|
| 108 |
+
description: 'Aristocratic, vintage manor vibes.',
|
| 109 |
+
coverColor: 'bg-blue-50',
|
| 110 |
+
previewImage: 'https://images.unsplash.com/photo-1505944270255-72b8c68c6a70?auto=format&fit=crop&w=400&q=60',
|
| 111 |
+
icon: <Crown className="w-5 h-5 text-blue-600" aria-hidden="true" />
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
id: 'euro_american',
|
| 115 |
+
name: '欧美 (Western)',
|
| 116 |
+
prompt: 'Western fashion wedding style, Vogue magazine aesthetic, bold emotions, grand cathedral or outdoor lawn background, classic tuxedo and ballgown, dramatic lighting.',
|
| 117 |
+
description: 'Classic western grandeur and emotion.',
|
| 118 |
+
coverColor: 'bg-slate-50',
|
| 119 |
+
previewImage: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=400&q=60',
|
| 120 |
+
icon: <Star className="w-5 h-5 text-slate-600" aria-hidden="true" />
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
id: 'hollywood',
|
| 124 |
+
name: '好莱坞 (Hollywood)',
|
| 125 |
+
prompt: 'Classic Hollywood movie glamour, black and white, dramatic lighting, elegant 1950s gowns, tuxedo, red carpet aesthetic, high contrast film noir style.',
|
| 126 |
+
description: 'Glamorous 1950s film noir.',
|
| 127 |
+
coverColor: 'bg-gray-200',
|
| 128 |
+
previewImage: 'https://images.unsplash.com/photo-1537633552985-df8429e8048b?auto=format&fit=crop&w=400&q=60',
|
| 129 |
+
icon: <Film className="w-5 h-5 text-gray-800" aria-hidden="true" />
|
| 130 |
+
},
|
| 131 |
+
|
| 132 |
+
// 2. Cultural & Traditional
|
| 133 |
+
{
|
| 134 |
+
id: 'chinese',
|
| 135 |
+
name: '中国风 (Chinese)',
|
| 136 |
+
prompt: 'Modern Chinese wedding style, luxurious red embroidery, traditional elements mixed with modern aesthetics, fan, porcelain, red background, festive and grand.',
|
| 137 |
+
description: 'Modern red aesthetics and embroidery.',
|
| 138 |
+
coverColor: 'bg-red-50',
|
| 139 |
+
previewImage: 'https://images.unsplash.com/photo-1588667822949-a034d612df08?auto=format&fit=crop&w=400&q=60',
|
| 140 |
+
icon: <Leaf className="w-5 h-5 text-red-600" aria-hidden="true" />,
|
| 141 |
+
tags: ['hot'],
|
| 142 |
+
isLocked: true, // Aggressive VIP Strategy: Lock the most popular style
|
| 143 |
+
groupCount: 890 // PDD
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
id: 'gufeng',
|
| 147 |
+
name: '古风 (Ancient)',
|
| 148 |
+
prompt: 'Ancient Chinese Hanfu style, ethereal fairy tale vibe, flowing silk robes, ink painting background, bamboo forest, flute, poetic and historical atmosphere.',
|
| 149 |
+
description: 'Ethereal Hanfu and poetic vibes.',
|
| 150 |
+
coverColor: 'bg-emerald-50',
|
| 151 |
+
previewImage: 'https://images.unsplash.com/photo-1535189043414-47a3c49a0bed?auto=format&fit=crop&w=400&q=60',
|
| 152 |
+
icon: <Feather className="w-5 h-5 text-emerald-600" aria-hidden="true" />
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
id: 'japanese',
|
| 156 |
+
name: '日系 (Japanese)',
|
| 157 |
+
prompt: 'Japanese aesthetic, bright and airy, overexposed soft light, film grain, emotional close-up, clean streets or cherry blossoms background, natural makeup, pure and fresh.',
|
| 158 |
+
description: 'Bright, airy, and emotional.',
|
| 159 |
+
coverColor: 'bg-sky-50',
|
| 160 |
+
previewImage: 'https://images.unsplash.com/photo-1492596985094-52771095ad79?auto=format&fit=crop&w=400&q=60',
|
| 161 |
+
icon: <Sun className="w-5 h-5 text-sky-400" aria-hidden="true" />
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
id: 'ethnic',
|
| 165 |
+
name: '民族风 (Ethnic)',
|
| 166 |
+
prompt: 'Exotic ethnic wedding style, colorful traditional patterns, heavy silver jewelry, vibrant fabrics, wild nature background, bohemian spirit, cultural richness.',
|
| 167 |
+
description: 'Vibrant colors and cultural patterns.',
|
| 168 |
+
coverColor: 'bg-orange-50',
|
| 169 |
+
previewImage: 'https://images.unsplash.com/photo-1606216794074-735e91aa2c92?auto=format&fit=crop&w=400&q=60',
|
| 170 |
+
icon: <Music className="w-5 h-5 text-orange-500" aria-hidden="true" />
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
id: 'exotic',
|
| 174 |
+
name: '异域风 (Exotic)',
|
| 175 |
+
prompt: 'Exotic desert or island wedding, Persian or Moroccan influence, warm sunset lighting, golden accessories, mysterious and alluring atmosphere.',
|
| 176 |
+
description: 'Mysterious, warm, and unique.',
|
| 177 |
+
coverColor: 'bg-amber-50',
|
| 178 |
+
previewImage: 'https://images.unsplash.com/photo-1516815231560-8f41ec531527?auto=format&fit=crop&w=400&q=60',
|
| 179 |
+
icon: <Sun className="w-5 h-5 text-amber-600" aria-hidden="true" />
|
| 180 |
+
},
|
| 181 |
+
|
| 182 |
+
// 3. Artistic & Mood
|
| 183 |
+
{
|
| 184 |
+
id: 'cinematic',
|
| 185 |
+
name: '电影感 (Cinematic)',
|
| 186 |
+
prompt: 'Cinematic movie still, Wong Kar-wai style, moody lighting, strong shadows, emotional storytelling, color graded, wide aspect ratio composition feeling.',
|
| 187 |
+
description: 'Moody, storytelling movie stills.',
|
| 188 |
+
coverColor: 'bg-gray-100',
|
| 189 |
+
previewImage: 'https://images.unsplash.com/photo-1470163395405-d2b80e7450ed?auto=format&fit=crop&w=400&q=60',
|
| 190 |
+
icon: <Film className="w-5 h-5 text-gray-700" aria-hidden="true" />,
|
| 191 |
+
tags: ['recommend']
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
id: 'film',
|
| 195 |
+
name: '胶片 (Film)',
|
| 196 |
+
prompt: 'Vintage film photography, Kodak Portra 400 look, grain, light leaks, nostalgic color palette, candid moment, timeless texture.',
|
| 197 |
+
description: 'Nostalgic grain and analog texture.',
|
| 198 |
+
coverColor: 'bg-yellow-50',
|
| 199 |
+
previewImage: 'https://images.unsplash.com/photo-1522673607200-1645062cd958?auto=format&fit=crop&w=400&q=60',
|
| 200 |
+
icon: <Camera className="w-5 h-5 text-yellow-600" aria-hidden="true" />
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
id: 'blackwhite',
|
| 204 |
+
name: '黑白 (B&W)',
|
| 205 |
+
prompt: 'Classic black and white photography, high contrast, Ansel Adams style, timeless, emotional focus, artistic composition, removing color distractions.',
|
| 206 |
+
description: 'Timeless high-contrast monochrome.',
|
| 207 |
+
coverColor: 'bg-gray-50',
|
| 208 |
+
previewImage: 'https://images.unsplash.com/photo-1604017011826-d3b4c23f8914?auto=format&fit=crop&w=400&q=60',
|
| 209 |
+
icon: <Aperture className="w-5 h-5 text-black" aria-hidden="true" />
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
id: 'artistic',
|
| 213 |
+
name: '艺术 (Artistic)',
|
| 214 |
+
prompt: 'Fine art wedding photography, abstract elements, creative lighting, blurry motion, painterly texture, museum quality, avant-garde outfit styling.',
|
| 215 |
+
description: 'Abstract, creative, fine art.',
|
| 216 |
+
coverColor: 'bg-purple-50',
|
| 217 |
+
previewImage: 'https://images.unsplash.com/photo-1523438885200-e635ba2c371e?auto=format&fit=crop&w=400&q=60',
|
| 218 |
+
icon: <Palette className="w-5 h-5 text-purple-500" aria-hidden="true" />
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
id: 'illustration',
|
| 222 |
+
name: '手绘 (Illustration)',
|
| 223 |
+
prompt: 'Wedding illustration style, watercolor or sketch effect, soft pastel colors, artistic strokes, dreamy and romantic drawing style, 2D art.',
|
| 224 |
+
description: 'Watercolor and sketch effects.',
|
| 225 |
+
coverColor: 'bg-pink-50',
|
| 226 |
+
previewImage: 'https://images.unsplash.com/photo-1544890225-2f3faec4cd60?auto=format&fit=crop&w=400&q=60',
|
| 227 |
+
icon: <PenTool className="w-5 h-5 text-pink-500" aria-hidden="true" />
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
id: 'oil_painting',
|
| 231 |
+
name: '油画 (Oil Painting)',
|
| 232 |
+
prompt: 'Classic oil painting style wedding, rich textured brushstrokes, Renaissance lighting, deep colors, artistic masterpiece, framed museum quality.',
|
| 233 |
+
description: 'Classic textured masterpiece.',
|
| 234 |
+
coverColor: 'bg-amber-100',
|
| 235 |
+
previewImage: 'https://images.unsplash.com/photo-1579783902614-a3fb39279c0f?auto=format&fit=crop&w=400&q=60',
|
| 236 |
+
icon: <Brush className="w-5 h-5 text-amber-700" aria-hidden="true" />
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
id: 'anime',
|
| 240 |
+
name: '动漫 (Anime)',
|
| 241 |
+
prompt: 'Japanese anime art style, Makoto Shinkai inspired, vibrant blue sky with cumulus clouds, sparkling light effects, high contrast, emotional and beautiful 2D animation aesthetic.',
|
| 242 |
+
description: 'Vibrant, emotional 2D animation.',
|
| 243 |
+
coverColor: 'bg-indigo-50',
|
| 244 |
+
previewImage: 'https://images.unsplash.com/photo-1563784462041-5f97ac9523dd?auto=format&fit=crop&w=400&q=60',
|
| 245 |
+
icon: <Tv className="w-5 h-5 text-indigo-500" aria-hidden="true" />
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
id: 'glitch',
|
| 249 |
+
name: 'Glitch',
|
| 250 |
+
prompt: 'Cyberpunk glitch art aesthetic, distorted neon lights, digital artifacts, fragmented reality, futuristic wedding attire, vibrant cyan and magenta hues, high-tech urban backdrop.',
|
| 251 |
+
description: 'Digital distortion and neon artifacts.',
|
| 252 |
+
coverColor: 'bg-teal-50',
|
| 253 |
+
previewImage: 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?auto=format&fit=crop&w=400&q=60',
|
| 254 |
+
icon: <Unlink className="w-5 h-5 text-teal-600" aria-hidden="true" />,
|
| 255 |
+
isLocked: true // VIP
|
| 256 |
+
},
|
| 257 |
+
|
| 258 |
+
// 4. Modern & Trendy
|
| 259 |
+
{
|
| 260 |
+
id: 'ins',
|
| 261 |
+
name: 'Ins风 (Insta)',
|
| 262 |
+
prompt: 'Instagram aesthetic, trendy filters, low contrast, lifestyle vibe, stylish decor, succulents, macrame, influencer style, highly shareable.',
|
| 263 |
+
description: 'Trendy, lifestyle, social media ready.',
|
| 264 |
+
coverColor: 'bg-fuchsia-50',
|
| 265 |
+
previewImage: 'https://images.unsplash.com/photo-1515934751635-c81c6bc9a2d8?auto=format&fit=crop&w=400&q=60',
|
| 266 |
+
icon: <Camera className="w-5 h-5 text-fuchsia-500" aria-hidden="true" />
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
id: 'magazine',
|
| 270 |
+
name: '杂志 (Magazine)',
|
| 271 |
+
prompt: 'High fashion magazine cover, Vogue or Harper\'s Bazaar style, bold typography feeling, confident poses, studio lighting, sharp focus, luxury fashion.',
|
| 272 |
+
description: 'High fashion editorial look.',
|
| 273 |
+
coverColor: 'bg-zinc-50',
|
| 274 |
+
previewImage: 'https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=400&q=60',
|
| 275 |
+
icon: <Layout className="w-5 h-5 text-zinc-600" aria-hidden="true" />
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
id: 'minimalist',
|
| 279 |
+
name: '简约 (Minimalist)',
|
| 280 |
+
prompt: 'Minimalist wedding, clean lines, plenty of negative space, monochromatic color palette, simple but high-quality silk dress, sophisticated simplicity.',
|
| 281 |
+
description: 'Less is more, sophisticated.',
|
| 282 |
+
coverColor: 'bg-stone-50',
|
| 283 |
+
previewImage: 'https://images.unsplash.com/photo-1445633475854-60c07d57cf84?auto=format&fit=crop&w=400&q=60',
|
| 284 |
+
icon: <BoxIcon className="w-5 h-5 text-stone-500" />
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
id: 'fashion',
|
| 288 |
+
name: '时尚 (Fashion)',
|
| 289 |
+
prompt: 'Street fashion wedding, urban setting, sunglasses, blazer over dress, running shoes with gown, chic, modern, rebellious, city lights.',
|
| 290 |
+
description: 'Chic, urban, and runway ready.',
|
| 291 |
+
coverColor: 'bg-indigo-50',
|
| 292 |
+
previewImage: 'https://images.unsplash.com/photo-1532453288672-3a27e9be9efd?auto=format&fit=crop&w=400&q=60',
|
| 293 |
+
icon: <Zap className="w-5 h-5 text-indigo-500" aria-hidden="true" />
|
| 294 |
+
},
|
| 295 |
+
|
| 296 |
+
// 5. Atmosphere & Theme
|
| 297 |
+
{
|
| 298 |
+
id: 'retro',
|
| 299 |
+
name: '复古 (Retro)',
|
| 300 |
+
prompt: '1980s or 1990s retro hong kong style, warm yellowish tint, vintage disco vibe, sequins, puffy sleeves, nostalgic romance.',
|
| 301 |
+
description: '80s/90s nostalgic vibes.',
|
| 302 |
+
coverColor: 'bg-orange-100',
|
| 303 |
+
previewImage: 'https://images.unsplash.com/photo-1551024601-56296352630a?auto=format&fit=crop&w=400&q=60',
|
| 304 |
+
icon: <Clock className="w-5 h-5 text-orange-700" aria-hidden="true" />,
|
| 305 |
+
tags: ['hot']
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
id: 'literary',
|
| 309 |
+
name: '文艺 (Literary)',
|
| 310 |
+
prompt: 'Literary youth style, library or cafe background, soft knitted textures, glasses, muted colors, intellectual and quiet atmosphere.',
|
| 311 |
+
description: 'Soft, intellectual, quiet beauty.',
|
| 312 |
+
coverColor: 'bg-teal-50',
|
| 313 |
+
previewImage: 'https://images.unsplash.com/photo-1456327102063-fb5054efe647?auto=format&fit=crop&w=400&q=60',
|
| 314 |
+
icon: <BookOpen className="w-5 h-5 text-teal-600" aria-hidden="true" />
|
| 315 |
+
},
|
| 316 |
+
{
|
| 317 |
+
id: 'campus',
|
| 318 |
+
name: '校园风 (Campus)',
|
| 319 |
+
prompt: 'School days romance, school uniforms or casual wedding wear, classroom or playground background, youthful energy, sunshine, innocent love.',
|
| 320 |
+
description: 'Youthful, innocent school memories.',
|
| 321 |
+
coverColor: 'bg-blue-100',
|
| 322 |
+
previewImage: 'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?auto=format&fit=crop&w=400&q=60',
|
| 323 |
+
icon: <BookOpen className="w-5 h-5 text-blue-500" aria-hidden="true" />
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
id: 'fairytale',
|
| 327 |
+
name: '童话 (Fairy Tale)',
|
| 328 |
+
prompt: 'Disney fairy tale style, magical castle, glowing lights, cinderella dress, sparkles, pumpkin carriage, dreamy blue and purple tones.',
|
| 329 |
+
description: 'Magical castles and dreams.',
|
| 330 |
+
coverColor: 'bg-purple-100',
|
| 331 |
+
previewImage: 'https://images.unsplash.com/photo-1520024146169-3240400354ae?auto=format&fit=crop&w=400&q=60',
|
| 332 |
+
icon: <Sparkles className="w-5 h-5 text-purple-600" aria-hidden="true" />
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
id: 'sweet',
|
| 336 |
+
name: '甜美 (Sweet)',
|
| 337 |
+
prompt: 'Sweet and cute style, candy colors, balloons, playful poses, pink background, joyful expressions, ribbons and bows.',
|
| 338 |
+
description: 'Cute, playful, candy colors.',
|
| 339 |
+
coverColor: 'bg-pink-100',
|
| 340 |
+
previewImage: 'https://images.unsplash.com/photo-1522083165195-3424ed129620?auto=format&fit=crop&w=400&q=60',
|
| 341 |
+
icon: <Smile className="w-5 h-5 text-pink-500" aria-hidden="true" />
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
id: 'story',
|
| 345 |
+
name: '故事 (Story)',
|
| 346 |
+
prompt: 'Storytelling photography, sequence of emotions, documentary approach, meaningful props, looking at each other, narrative composition.',
|
| 347 |
+
description: 'Narrative and emotional moments.',
|
| 348 |
+
coverColor: 'bg-slate-100',
|
| 349 |
+
previewImage: 'https://images.unsplash.com/photo-1511285560982-1351c4f809b9?auto=format&fit=crop&w=400&q=60',
|
| 350 |
+
icon: <Film className="w-5 h-5 text-slate-600" aria-hidden="true" />
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
id: 'documentary',
|
| 354 |
+
name: '纪实 (Documentary)',
|
| 355 |
+
prompt: 'Documentary wedding photojournalism, raw emotions, unposed, natural lighting, messy hair, genuine laughter, tears of joy, authentic.',
|
| 356 |
+
description: 'Raw, unposed, authentic moments.',
|
| 357 |
+
coverColor: 'bg-neutral-50',
|
| 358 |
+
previewImage: 'https://images.unsplash.com/photo-1519225421980-715cb0202128?auto=format&fit=crop&w=400&q=60',
|
| 359 |
+
icon: <Camera className="w-5 h-5 text-neutral-600" aria-hidden="true" />
|
| 360 |
+
},
|
| 361 |
+
{
|
| 362 |
+
id: 'fresh',
|
| 363 |
+
name: '小清新 (Fresh)',
|
| 364 |
+
prompt: 'Small fresh style (Xiao Qing Xin), nature, green grass, blue sky, white dress, oxygen girl vibe, high key lighting, natural and clean.',
|
| 365 |
+
description: 'Nature, clean air, greenery.',
|
| 366 |
+
coverColor: 'bg-lime-50',
|
| 367 |
+
previewImage: 'https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?auto=format&fit=crop&w=400&q=60',
|
| 368 |
+
icon: <Leaf className="w-5 h-5 text-lime-500" aria-hidden="true" />
|
| 369 |
+
},
|
| 370 |
+
{
|
| 371 |
+
id: 'garden',
|
| 372 |
+
name: '森系 (Garden)',
|
| 373 |
+
prompt: 'Dreamy garden wedding, surrounded by blooming flowers, lush greenery, soft dappled sunlight filtering through trees, romantic fairy tale atmosphere, floral arch, ethereal beauty.',
|
| 374 |
+
description: 'Lush greenery and soft sunlight.',
|
| 375 |
+
coverColor: 'bg-green-50',
|
| 376 |
+
previewImage: 'https://images.unsplash.com/photo-1465495976277-4387d4b0b4c6?auto=format&fit=crop&w=400&q=60',
|
| 377 |
+
icon: <Trees className="w-5 h-5 text-green-600" aria-hidden="true" />
|
| 378 |
+
},
|
| 379 |
+
{
|
| 380 |
+
id: 'sexy',
|
| 381 |
+
name: '性感 (Sexy)',
|
| 382 |
+
prompt: 'Boudoir or glamorous sexy style, silk slip dress, moody lighting, red lipstick, confident gaze, allure, intimate atmosphere.',
|
| 383 |
+
description: 'Confident, glamorous, allure.',
|
| 384 |
+
coverColor: 'bg-red-100',
|
| 385 |
+
previewImage: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=400&q=60',
|
| 386 |
+
icon: <Heart className="w-5 h-5 text-red-800" aria-hidden="true" />
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
id: 'personality',
|
| 390 |
+
name: '个性 (Unique)',
|
| 391 |
+
prompt: 'Avant-garde personality style, quirky props, sunglasses, unconventional wedding outfit, color blocking, pop art background, fun and weird.',
|
| 392 |
+
description: 'Quirky, fun, unconventional.',
|
| 393 |
+
coverColor: 'bg-yellow-100',
|
| 394 |
+
previewImage: 'https://images.unsplash.com/photo-1502685104226-ee32379fefbe?auto=format&fit=crop&w=400&q=60',
|
| 395 |
+
icon: <Zap className="w-5 h-5 text-yellow-500" aria-hidden="true" />
|
| 396 |
+
},
|
| 397 |
+
|
| 398 |
+
// 6. New Diverse Styles
|
| 399 |
+
{
|
| 400 |
+
id: 'bohemian',
|
| 401 |
+
name: '波西米亚 (Bohemian)',
|
| 402 |
+
prompt: 'Bohemian wedding style, outdoor nature setting, macrame decorations, pampas grass, wildflower bouquet, lace dress, relaxed groom attire, warm earth tones, sunset light.',
|
| 403 |
+
description: 'Free-spirited, earthy, and natural.',
|
| 404 |
+
coverColor: 'bg-orange-50',
|
| 405 |
+
previewImage: 'https://images.unsplash.com/photo-1515488764276-beab7607c1e6?auto=format&fit=crop&w=400&q=60',
|
| 406 |
+
icon: <Flower className="w-5 h-5 text-orange-600" aria-hidden="true" />
|
| 407 |
+
},
|
| 408 |
+
{
|
| 409 |
+
id: 'art_deco',
|
| 410 |
+
name: '装饰艺术 (Art Deco)',
|
| 411 |
+
prompt: 'Great Gatsby 1920s Art Deco style, gold and black geometric patterns, luxurious ballroom, pearls, feathers, sharp tuxedo, vintage glamour, jazz age atmosphere.',
|
| 412 |
+
description: '1920s Gatsby luxury and geometry.',
|
| 413 |
+
coverColor: 'bg-yellow-50',
|
| 414 |
+
previewImage: 'https://images.unsplash.com/photo-1560963689-02e0397d66b7?auto=format&fit=crop&w=400&q=60',
|
| 415 |
+
icon: <Gem className="w-5 h-5 text-yellow-600" aria-hidden="true" />
|
| 416 |
+
},
|
| 417 |
+
{
|
| 418 |
+
id: 'masquerade',
|
| 419 |
+
name: 'Masquerade Ball',
|
| 420 |
+
prompt: 'Venetian Masquerade Ball wedding, elegant ornate masks, candlelit ballroom, velvet textures, mystery, dramatic shadows, opulent gowns and suits.',
|
| 421 |
+
description: 'Mysterious, opulent, masked elegance.',
|
| 422 |
+
coverColor: 'bg-purple-50',
|
| 423 |
+
previewImage: 'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=400&q=60',
|
| 424 |
+
icon: <Eye className="w-5 h-5 text-purple-700" aria-hidden="true" />
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
id: 'steampunk',
|
| 428 |
+
name: '蒸汽朋克 (Steampunk)',
|
| 429 |
+
prompt: 'Steampunk wedding style, Victorian industrial era, brass gears, clockwork elements, corsets, goggles, leather accessories, steam fog, copper tones, sepia lighting.',
|
| 430 |
+
description: 'Victorian sci-fi, gears, and brass.',
|
| 431 |
+
coverColor: 'bg-amber-100',
|
| 432 |
+
previewImage: 'https://images.unsplash.com/photo-1535581652167-3d6b98c39327?auto=format&fit=crop&w=400&q=60',
|
| 433 |
+
icon: <Cog className="w-5 h-5 text-amber-700" aria-hidden="true" />
|
| 434 |
+
},
|
| 435 |
+
{
|
| 436 |
+
id: 'galactic',
|
| 437 |
+
name: '银河 (Galactic)',
|
| 438 |
+
prompt: 'Sci-fi galactic wedding, outer space background, nebulae, stars, futuristic glowing fabrics, metallic silver outfits, neon lights, ethereal cosmic atmosphere.',
|
| 439 |
+
description: 'Futuristic, cosmic, among the stars.',
|
| 440 |
+
coverColor: 'bg-indigo-100',
|
| 441 |
+
previewImage: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=400&q=60',
|
| 442 |
+
icon: <Rocket className="w-5 h-5 text-indigo-600" aria-hidden="true" />,
|
| 443 |
+
isLocked: true // VIP
|
| 444 |
+
},
|
| 445 |
+
{
|
| 446 |
+
id: 'cyberpunk',
|
| 447 |
+
name: '赛博朋克 (Cyberpunk)',
|
| 448 |
+
prompt: 'Cyberpunk wedding style, neon lights, night city, rain, futuristic techwear mixed with wedding attire, glowing accessories, blue and pink lighting, Blade Runner aesthetic.',
|
| 449 |
+
description: 'Neon, high-tech, futuristic city.',
|
| 450 |
+
coverColor: 'bg-cyan-100',
|
| 451 |
+
previewImage: 'https://images.unsplash.com/photo-1535295972055-1c762f4483e5?auto=format&fit=crop&w=400&q=60',
|
| 452 |
+
icon: <Cpu className="w-5 h-5 text-cyan-600" aria-hidden="true" />,
|
| 453 |
+
tags: ['new'],
|
| 454 |
+
isLocked: true // VIP
|
| 455 |
+
},
|
| 456 |
+
|
| 457 |
+
// 7. Expanded Thematic Styles
|
| 458 |
+
{
|
| 459 |
+
id: 'gothic',
|
| 460 |
+
name: '哥特 (Gothic)',
|
| 461 |
+
prompt: 'High-fashion Gothic wedding, dramatic cathedral architecture, stained glass light, black lace gown, mysterious fog, candlelight, deep burgundy and charcoal tones, moody cinematic atmosphere.',
|
| 462 |
+
description: 'Dark, mysterious, dramatic elegance.',
|
| 463 |
+
coverColor: 'bg-slate-700',
|
| 464 |
+
previewImage: 'https://images.unsplash.com/photo-1539103377911-4909a1bac382?auto=format&fit=crop&w=400&q=60',
|
| 465 |
+
icon: <Castle className="w-5 h-5 text-slate-800" aria-hidden="true" />
|
| 466 |
+
},
|
| 467 |
+
{
|
| 468 |
+
id: 'beach',
|
| 469 |
+
name: '海岛 (Beach)',
|
| 470 |
+
prompt: 'Luxury destination beach wedding, golden hour sunlight, turquoise ocean background, white sands, flowing boho dress, tropical florals, relaxed but elegant, warm and airy aesthetic.',
|
| 471 |
+
description: 'Sunset, ocean, tropical breeze.',
|
| 472 |
+
coverColor: 'bg-cyan-50',
|
| 473 |
+
previewImage: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=400&q=60',
|
| 474 |
+
icon: <Anchor className="w-5 h-5 text-cyan-500" aria-hidden="true" />,
|
| 475 |
+
tags: ['hot']
|
| 476 |
+
},
|
| 477 |
+
{
|
| 478 |
+
id: 'winter',
|
| 479 |
+
name: '冰雪 (Winter)',
|
| 480 |
+
prompt: 'Ethereal winter wedding, snowy forest backdrop, falling snow, ice blue and silver tones, fur stole, crystal accessories, magical frozen lighting, crisp and pure atmosphere.',
|
| 481 |
+
description: 'Snowy, magical, frozen beauty.',
|
| 482 |
+
coverColor: 'bg-blue-50',
|
| 483 |
+
previewImage: 'https://images.unsplash.com/photo-1483664852095-d6cc6870705d?auto=format&fit=crop&w=400&q=60',
|
| 484 |
+
icon: <Snowflake className="w-5 h-5 text-blue-400" aria-hidden="true" />
|
| 485 |
+
},
|
| 486 |
+
{
|
| 487 |
+
id: 'underwater',
|
| 488 |
+
name: '水下 (Underwater)',
|
| 489 |
+
prompt: 'Fine art underwater wedding photography, weightless flowing fabric, deep blue water, shafts of sunlight breaking through surface, bubbles, serene and dreamlike composition.',
|
| 490 |
+
description: 'Ethereal, weightless, dreamlike.',
|
| 491 |
+
coverColor: 'bg-indigo-50',
|
| 492 |
+
previewImage: 'https://images.unsplash.com/photo-1504198266287-1659872e6590?auto=format&fit=crop&w=400&q=60',
|
| 493 |
+
icon: <Droplets className="w-5 h-5 text-indigo-500" aria-hidden="true" />
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
id: 'rustic',
|
| 497 |
+
name: '乡村 (Rustic)',
|
| 498 |
+
prompt: 'Cozy rustic barn wedding, warm string lights, wooden beams, dried flowers, vintage lace, warm brown and amber tones, intimate countryside celebration vibe.',
|
| 499 |
+
description: 'Barns, fairy lights, cozy warmth.',
|
| 500 |
+
coverColor: 'bg-amber-50',
|
| 501 |
+
previewImage: 'https://images.unsplash.com/photo-1470790376778-a9fcd484f839?auto=format&fit=crop&w=400&q=60',
|
| 502 |
+
icon: <Warehouse className="w-5 h-5 text-amber-600" aria-hidden="true" />
|
| 503 |
+
},
|
| 504 |
+
|
| 505 |
+
// 8. Cultural & Artistic Extras
|
| 506 |
+
{
|
| 507 |
+
id: 'elf',
|
| 508 |
+
name: '精灵 (Elf)',
|
| 509 |
+
prompt: 'Ethereal Elven wedding style, Lord of the Rings Rivendell vibe, magical forest, soft glowing light, bride in flowing gown with cape, pointed ears hint, mystical and dreamy atmosphere.',
|
| 510 |
+
description: 'Magical forest and ethereal vibes.',
|
| 511 |
+
coverColor: 'bg-emerald-100',
|
| 512 |
+
previewImage: 'https://images.unsplash.com/photo-1500917293891-ef795e70e1f6?auto=format&fit=crop&w=400&q=60',
|
| 513 |
+
icon: <Cloud className="w-5 h-5 text-emerald-500" aria-hidden="true" />,
|
| 514 |
+
tags: ['new'],
|
| 515 |
+
isLocked: true // VIP
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
id: 'indian',
|
| 519 |
+
name: '印度 (Indian)',
|
| 520 |
+
prompt: 'Traditional Indian wedding, luxurious red and gold Lehenga and Sherwani, intricate jewelry, Henna, marigold flowers, festive and opulent atmosphere, Bollywood grandeur.',
|
| 521 |
+
description: 'Opulent red and gold festivity.',
|
| 522 |
+
coverColor: 'bg-orange-200',
|
| 523 |
+
previewImage: 'https://images.unsplash.com/photo-1583391724094-067980337059?auto=format&fit=crop&w=400&q=60',
|
| 524 |
+
icon: <Sun className="w-5 h-5 text-orange-700" aria-hidden="true" />
|
| 525 |
+
},
|
| 526 |
+
{
|
| 527 |
+
id: 'thai',
|
| 528 |
+
name: '泰式 (Thai)',
|
| 529 |
+
prompt: 'Traditional Thai wedding style, Chut Thai silk costumes, golden accessories, elegant temple architectural background, warm lighting, noble and serene.',
|
| 530 |
+
description: 'Elegant silk and golden nobility.',
|
| 531 |
+
coverColor: 'bg-yellow-200',
|
| 532 |
+
previewImage: 'https://images.unsplash.com/photo-1596711912530-9b391787d55c?auto=format&fit=crop&w=400&q=60',
|
| 533 |
+
icon: <Flower className="w-5 h-5 text-yellow-700" aria-hidden="true" />
|
| 534 |
+
},
|
| 535 |
+
{
|
| 536 |
+
id: 'double_exposure',
|
| 537 |
+
name: '双重曝光 (Double Exp)',
|
| 538 |
+
prompt: 'Artistic double exposure photography, silhouette of the couple blended with a forest or cityscape, dreamy overlay, high contrast, surreal and creative art style.',
|
| 539 |
+
description: 'Surreal artistic silhouette blend.',
|
| 540 |
+
coverColor: 'bg-gray-200',
|
| 541 |
+
previewImage: 'https://images.unsplash.com/photo-1550684848-fac1c5b4e853?auto=format&fit=crop&w=400&q=60',
|
| 542 |
+
icon: <Layers className="w-5 h-5 text-gray-700" aria-hidden="true" />
|
| 543 |
+
},
|
| 544 |
+
|
| 545 |
+
// 9. Douyin Viral (TikTok China)
|
| 546 |
+
{
|
| 547 |
+
id: 'old_money',
|
| 548 |
+
name: '老钱风 (Old Money)',
|
| 549 |
+
prompt: 'Old Money aesthetic wedding, Ralph Lauren style, luxurious country club or yacht background, understated elegance, beige and navy tones, pearl necklace, tennis sweater or classy suit, rich lifestyle vibe.',
|
| 550 |
+
description: 'Understated wealth, luxury vibe.',
|
| 551 |
+
coverColor: 'bg-stone-100',
|
| 552 |
+
previewImage: 'https://images.unsplash.com/photo-1566737236500-c8ac43014a67?auto=format&fit=crop&w=400&q=60',
|
| 553 |
+
icon: <Briefcase className="w-5 h-5 text-stone-700" aria-hidden="true" />,
|
| 554 |
+
tags: ['new', 'hot'],
|
| 555 |
+
isLocked: true
|
| 556 |
+
},
|
| 557 |
+
{
|
| 558 |
+
id: 'phoenix_crown',
|
| 559 |
+
name: '凤冠霞帔 (Phoenix)',
|
| 560 |
+
prompt: 'Traditional Chinese Phoenix Crown style, extremely luxurious gold headdress, heavy makeup with red lips, dark background with spot lighting, majestic and dramatic queen vibe, intricate embroidery.',
|
| 561 |
+
description: 'Majestic gold crown, dramatic.',
|
| 562 |
+
coverColor: 'bg-red-200',
|
| 563 |
+
previewImage: 'https://images.unsplash.com/photo-1540932296774-34d6d45e933a?auto=format&fit=crop&w=400&q=60',
|
| 564 |
+
icon: <Crown className="w-5 h-5 text-red-800" aria-hidden="true" />,
|
| 565 |
+
tags: ['hot']
|
| 566 |
+
},
|
| 567 |
+
{
|
| 568 |
+
id: 'balletcore',
|
| 569 |
+
name: '芭蕾风 (Balletcore)',
|
| 570 |
+
prompt: 'Balletcore wedding style, ballerina aesthetics, tulle skirt, corset, ribbon lace-up, ballet flats, soft pink studio background, elegant poses, dreamy and graceful.',
|
| 571 |
+
description: 'Graceful ballerina ribbons.',
|
| 572 |
+
coverColor: 'bg-pink-50',
|
| 573 |
+
previewImage: 'https://images.unsplash.com/photo-1516575334481-f85287c2c81d?auto=format&fit=crop&w=400&q=60',
|
| 574 |
+
icon: <Music className="w-5 h-5 text-pink-400" aria-hidden="true" />,
|
| 575 |
+
tags: ['new']
|
| 576 |
+
},
|
| 577 |
+
{
|
| 578 |
+
id: 'dopamine',
|
| 579 |
+
name: '多巴胺 (Dopamine)',
|
| 580 |
+
prompt: 'Dopamine dressing wedding style, high saturation bright colors, colorful sunglasses, playful props, y2k vibes, happy energetic atmosphere, rainbow background elements.',
|
| 581 |
+
description: 'Bright colors, happy Y2K vibe.',
|
| 582 |
+
coverColor: 'bg-yellow-100',
|
| 583 |
+
previewImage: 'https://images.unsplash.com/photo-1496747611176-843222e1e57c?auto=format&fit=crop&w=400&q=60',
|
| 584 |
+
icon: <Palette className="w-5 h-5 text-yellow-600" aria-hidden="true" />
|
| 585 |
+
},
|
| 586 |
+
{
|
| 587 |
+
id: 'clean_fit',
|
| 588 |
+
name: '极简智性 (Clean Fit)',
|
| 589 |
+
prompt: 'Clean fit aesthetic wedding, oversized blazer, city walk street background, effortless chic, neutral tones, smart casual wedding attire, intellectual and high-end vibe.',
|
| 590 |
+
description: 'Effortless chic, intellectual.',
|
| 591 |
+
coverColor: 'bg-gray-50',
|
| 592 |
+
previewImage: 'https://images.unsplash.com/photo-1496440738361-1e38b979f6d4?auto=format&fit=crop&w=400&q=60',
|
| 593 |
+
icon: <Search className="w-5 h-5 text-gray-600" aria-hidden="true" />
|
| 594 |
+
},
|
| 595 |
+
|
| 596 |
+
// 10. Groom / Male Styles
|
| 597 |
+
{
|
| 598 |
+
id: 'groom_classic',
|
| 599 |
+
name: 'Classic Tuxedo',
|
| 600 |
+
prompt: 'Classic Groom portrait, sharp black tuxedo, bow tie, crisp white shirt, studio grey background, confident pose, high-end men\'s fashion photography.',
|
| 601 |
+
description: 'Sharp, timeless black tuxedo.',
|
| 602 |
+
coverColor: 'bg-gray-100',
|
| 603 |
+
previewImage: 'https://images.unsplash.com/photo-1507679799987-c73779587ccf?auto=format&fit=crop&w=400&q=60',
|
| 604 |
+
icon: <Briefcase className="w-5 h-5 text-gray-800" aria-hidden="true" />
|
| 605 |
+
},
|
| 606 |
+
{
|
| 607 |
+
id: 'groom_modern',
|
| 608 |
+
name: 'Modern Suit',
|
| 609 |
+
prompt: 'Modern Groom style, fitted navy or beige suit, no tie, relaxed but elegant, outdoor urban background, natural lighting, GQ magazine style.',
|
| 610 |
+
description: 'Relaxed modern fitted suit.',
|
| 611 |
+
coverColor: 'bg-blue-50',
|
| 612 |
+
previewImage: 'https://images.unsplash.com/photo-1480455624313-e29b44bbfde1?auto=format&fit=crop&w=400&q=60',
|
| 613 |
+
icon: <User className="w-5 h-5 text-blue-600" aria-hidden="true" />
|
| 614 |
+
},
|
| 615 |
+
{
|
| 616 |
+
id: 'groom_hanfu',
|
| 617 |
+
name: 'Scholar Hanfu',
|
| 618 |
+
prompt: 'Traditional Chinese Scholar Groom, elegant flowing Hanfu robes, ink painting background, holding a fan or scroll, poetic and noble atmosphere.',
|
| 619 |
+
description: 'Noble scholar in flowing robes.',
|
| 620 |
+
coverColor: 'bg-emerald-50',
|
| 621 |
+
previewImage: 'https://images.unsplash.com/photo-1531303435785-3853fb435460?auto=format&fit=crop&w=400&q=60',
|
| 622 |
+
icon: <Feather className="w-5 h-5 text-emerald-600" aria-hidden="true" />
|
| 623 |
+
},
|
| 624 |
+
{
|
| 625 |
+
id: 'groom_retro',
|
| 626 |
+
name: 'Retro Gent',
|
| 627 |
+
prompt: 'Retro Gentleman Groom, Peaky Blinders style, tweed suit, flat cap, vintage pocket watch, moody lighting, 1920s vintage atmosphere.',
|
| 628 |
+
description: '1920s vintage gentleman.',
|
| 629 |
+
coverColor: 'bg-amber-100',
|
| 630 |
+
previewImage: 'https://images.unsplash.com/photo-1506839683516-433b283c7438?auto=format&fit=crop&w=400&q=60',
|
| 631 |
+
icon: <Watch className="w-5 h-5 text-amber-800" aria-hidden="true" />
|
| 632 |
+
},
|
| 633 |
+
{
|
| 634 |
+
id: 'groom_cyber',
|
| 635 |
+
name: 'Cyber Warrior',
|
| 636 |
+
prompt: 'Futuristic Groom, high-tech tactical suit mixed with formal wear, neon lighting, cyberpunk city background, cool and edgy sci-fi vibe.',
|
| 637 |
+
description: 'Edgy, high-tech futuristic suit.',
|
| 638 |
+
coverColor: 'bg-cyan-100',
|
| 639 |
+
previewImage: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&w=400&q=60',
|
| 640 |
+
icon: <Cpu className="w-5 h-5 text-cyan-600" aria-hidden="true" />
|
| 641 |
+
},
|
| 642 |
+
|
| 643 |
+
// 11. Children's Styles
|
| 644 |
+
{
|
| 645 |
+
id: 'child_prince',
|
| 646 |
+
name: 'Little Prince',
|
| 647 |
+
prompt: 'Cute little boy dressed as a prince, royal outfit with sash and medals, small crown, castle interior background, magical lighting, adorable and noble.',
|
| 648 |
+
description: 'Royal outfit for a little prince.',
|
| 649 |
+
coverColor: 'bg-blue-100',
|
| 650 |
+
previewImage: 'https://images.unsplash.com/photo-1519340570198-63a234f9a56e?auto=format&fit=crop&w=400&q=60',
|
| 651 |
+
icon: <Crown className="w-5 h-5 text-blue-600" aria-hidden="true" />
|
| 652 |
+
},
|
| 653 |
+
{
|
| 654 |
+
id: 'child_princess',
|
| 655 |
+
name: 'Little Princess',
|
| 656 |
+
prompt: 'Adorable little girl in a puffy princess ballgown, sparkling tiara, magical fairy tale forest background, soft pink and glittery atmosphere.',
|
| 657 |
+
description: 'Magical puffy dress and tiara.',
|
| 658 |
+
coverColor: 'bg-pink-100',
|
| 659 |
+
previewImage: 'https://images.unsplash.com/photo-1526466384813-f54668b82402?auto=format&fit=crop&w=400&q=60',
|
| 660 |
+
icon: <Heart className="w-5 h-5 text-pink-500" aria-hidden="true" />
|
| 661 |
+
},
|
| 662 |
+
{
|
| 663 |
+
id: 'child_suit',
|
| 664 |
+
name: 'Mini Gentleman',
|
| 665 |
+
prompt: 'Little boy in a miniature modern suit and bow tie, hands in pockets, clean studio background, looking cool and fashion-forward, child model vibe.',
|
| 666 |
+
description: 'Cool mini suit and bow tie.',
|
| 667 |
+
coverColor: 'bg-gray-100',
|
| 668 |
+
previewImage: 'https://images.unsplash.com/photo-1489710437720-ebb67ec84dd2?auto=format&fit=crop&w=400&q=60',
|
| 669 |
+
icon: <Briefcase className="w-5 h-5 text-gray-800" aria-hidden="true" />
|
| 670 |
+
},
|
| 671 |
+
|
| 672 |
+
// 12. NEW 38 STYLES (Total 100)
|
| 673 |
+
{
|
| 674 |
+
id: 'mermaid',
|
| 675 |
+
name: 'Mermaid',
|
| 676 |
+
prompt: 'Magical Mermaid fantasy, underwater vibe, iridescent scales, flowing hair, pearls, shells, ethereal lighting, blue and aquamarine tones.',
|
| 677 |
+
description: 'Magical underwater mermaid.',
|
| 678 |
+
coverColor: 'bg-cyan-200',
|
| 679 |
+
previewImage: 'https://images.unsplash.com/photo-1534954714659-3a362f79029a?auto=format&fit=crop&w=400&q=60',
|
| 680 |
+
icon: <Fish className="w-5 h-5 text-cyan-600" />
|
| 681 |
+
},
|
| 682 |
+
{
|
| 683 |
+
id: 'angel',
|
| 684 |
+
name: 'Angel',
|
| 685 |
+
prompt: 'Heavenly Angel wedding style, massive white feathered wings, glowing halo, clouds background, divine light, pure white gown, holy and serene.',
|
| 686 |
+
description: 'Divine wings and halo.',
|
| 687 |
+
coverColor: 'bg-blue-50',
|
| 688 |
+
previewImage: 'https://images.unsplash.com/photo-1560130958-eff2f8546fd7?auto=format&fit=crop&w=400&q=60',
|
| 689 |
+
icon: <Feather className="w-5 h-5 text-blue-400" />
|
| 690 |
+
},
|
| 691 |
+
{
|
| 692 |
+
id: 'demon',
|
| 693 |
+
name: 'Demon',
|
| 694 |
+
prompt: 'Dark Fantasy Demon queen style, black horns, gothic makeup, red eyes, dramatic shadows, fire and smoke background, fierce and powerful.',
|
| 695 |
+
description: 'Dark fantasy demon queen.',
|
| 696 |
+
coverColor: 'bg-red-900',
|
| 697 |
+
previewImage: 'https://images.unsplash.com/photo-1620583486376-79c855422892?auto=format&fit=crop&w=400&q=60',
|
| 698 |
+
icon: <Flame className="w-5 h-5 text-red-600" />,
|
| 699 |
+
isLocked: true
|
| 700 |
+
},
|
| 701 |
+
{
|
| 702 |
+
id: 'vampire',
|
| 703 |
+
name: 'Vampire',
|
| 704 |
+
prompt: 'Victorian Vampire aesthetic, pale skin, red lips, vintage lace choker, gothic castle background, moonlight, romantic horror vibe.',
|
| 705 |
+
description: 'Romantic gothic horror.',
|
| 706 |
+
coverColor: 'bg-slate-800',
|
| 707 |
+
previewImage: 'https://images.unsplash.com/photo-1628260412297-a3377e45006f?auto=format&fit=crop&w=400&q=60',
|
| 708 |
+
icon: <Moon className="w-5 h-5 text-purple-400" />
|
| 709 |
+
},
|
| 710 |
+
{
|
| 711 |
+
id: 'victorian',
|
| 712 |
+
name: 'Victorian',
|
| 713 |
+
prompt: 'Victorian era portrait, high collar lace dress, corset, cameo brooch, vintage furniture background, sepia tone, historical accuracy.',
|
| 714 |
+
description: '19th century historical elegance.',
|
| 715 |
+
coverColor: 'bg-amber-100',
|
| 716 |
+
previewImage: 'https://images.unsplash.com/photo-1534567215160-b9df4b4f8d29?auto=format&fit=crop&w=400&q=60',
|
| 717 |
+
icon: <Hourglass className="w-5 h-5 text-amber-700" />
|
| 718 |
+
},
|
| 719 |
+
{
|
| 720 |
+
id: 'renaissance',
|
| 721 |
+
name: 'Renaissance',
|
| 722 |
+
prompt: 'Renaissance art style portrait, velvet robes, rich jewel tones, chiaroscuro lighting, Da Vinci painting aesthetic, museum quality.',
|
| 723 |
+
description: 'Classic art masterpiece.',
|
| 724 |
+
coverColor: 'bg-yellow-200',
|
| 725 |
+
previewImage: 'https://images.unsplash.com/photo-1578301978693-85fa9c0320b9?auto=format&fit=crop&w=400&q=60',
|
| 726 |
+
icon: <Brush className="w-5 h-5 text-yellow-800" />
|
| 727 |
+
},
|
| 728 |
+
{
|
| 729 |
+
id: 'egyptian',
|
| 730 |
+
name: 'Egyptian',
|
| 731 |
+
prompt: 'Ancient Egyptian royalty, Cleopatra style, gold headpiece, heavy eyeliner, desert background, pyramids, turquoise and gold jewelry.',
|
| 732 |
+
description: 'Pharaoh queen luxury.',
|
| 733 |
+
coverColor: 'bg-yellow-300',
|
| 734 |
+
previewImage: 'https://images.unsplash.com/photo-1563200989-123498877665?auto=format&fit=crop&w=400&q=60',
|
| 735 |
+
icon: <Landmark className="w-5 h-5 text-yellow-600" />
|
| 736 |
+
},
|
| 737 |
+
{
|
| 738 |
+
id: 'greek',
|
| 739 |
+
name: 'Greek Goddess',
|
| 740 |
+
prompt: 'Greek Goddess style, flowing white toga, laurel wreath, marble columns background, Mediterranean sunlight, ethereal and statuesque.',
|
| 741 |
+
description: 'Olympian goddess ethereal.',
|
| 742 |
+
coverColor: 'bg-blue-50',
|
| 743 |
+
previewImage: 'https://images.unsplash.com/photo-1601004890684-d8cbf643f5f2?auto=format&fit=crop&w=400&q=60',
|
| 744 |
+
icon: <Landmark className="w-5 h-5 text-blue-500" />
|
| 745 |
+
},
|
| 746 |
+
{
|
| 747 |
+
id: 'roman',
|
| 748 |
+
name: 'Roman Holiday',
|
| 749 |
+
prompt: 'Roman Holiday movie style, 1950s Vespa, Audrey Hepburn look, Rome street background, black and white or technicolor, chic and joyful.',
|
| 750 |
+
description: '50s chic Rome holiday.',
|
| 751 |
+
coverColor: 'bg-orange-50',
|
| 752 |
+
previewImage: 'https://images.unsplash.com/photo-1516483638261-f4dbaf036963?auto=format&fit=crop&w=400&q=60',
|
| 753 |
+
icon: <Film className="w-5 h-5 text-orange-500" />
|
| 754 |
+
},
|
| 755 |
+
{
|
| 756 |
+
id: 'viking',
|
| 757 |
+
name: 'Viking',
|
| 758 |
+
prompt: 'Nordic Viking wedding, fur cloaks, braided hair, snowy mountain background, leather armor elements, fierce and rustic atmosphere.',
|
| 759 |
+
description: 'Fierce Nordic warrior.',
|
| 760 |
+
coverColor: 'bg-stone-200',
|
| 761 |
+
previewImage: 'https://images.unsplash.com/photo-1616786524317-1c19b673676c?auto=format&fit=crop&w=400&q=60',
|
| 762 |
+
icon: <Sword className="w-5 h-5 text-stone-700" />
|
| 763 |
+
},
|
| 764 |
+
{
|
| 765 |
+
id: 'y2k',
|
| 766 |
+
name: 'Y2K',
|
| 767 |
+
prompt: 'Y2K aesthetic wedding, millennium cyber fashion, metallic fabrics, butterfly clips, tinted sunglasses, flip phone prop, holographic background.',
|
| 768 |
+
description: 'Millennium cyber fashion.',
|
| 769 |
+
coverColor: 'bg-pink-200',
|
| 770 |
+
previewImage: 'https://images.unsplash.com/photo-1617196034096-16408229e710?auto=format&fit=crop&w=400&q=60',
|
| 771 |
+
icon: <Monitor className="w-5 h-5 text-pink-600" />
|
| 772 |
+
},
|
| 773 |
+
{
|
| 774 |
+
id: 'neon',
|
| 775 |
+
name: 'Neon',
|
| 776 |
+
prompt: 'Neon noir style, dark background with bright neon sign lights (blue/pink), wet pavement, cinematic night vibe, edgy and cool.',
|
| 777 |
+
description: 'Dark city neon lights.',
|
| 778 |
+
coverColor: 'bg-purple-900',
|
| 779 |
+
previewImage: 'https://images.unsplash.com/photo-1563297129-9dc476717462?auto=format&fit=crop&w=400&q=60',
|
| 780 |
+
icon: <Zap className="w-5 h-5 text-purple-400" />
|
| 781 |
+
},
|
| 782 |
+
{
|
| 783 |
+
id: 'denim',
|
| 784 |
+
name: 'Denim',
|
| 785 |
+
prompt: 'Casual Denim wedding theme, matching denim jackets over wedding outfits, cool sunglasses, American vibes, relaxed and stylish.',
|
| 786 |
+
description: 'Casual cool denim.',
|
| 787 |
+
coverColor: 'bg-blue-200',
|
| 788 |
+
previewImage: 'https://images.unsplash.com/photo-1520336208070-13d80630b4ac?auto=format&fit=crop&w=400&q=60',
|
| 789 |
+
icon: <Shirt className="w-5 h-5 text-blue-700" />
|
| 790 |
+
},
|
| 791 |
+
{
|
| 792 |
+
id: 'suit_bride',
|
| 793 |
+
name: 'Bridal Suit',
|
| 794 |
+
prompt: 'Androgynous chic, bride in a tailored white tuxedo suit, slicked back hair, minimalist studio background, confident power pose, high fashion.',
|
| 795 |
+
description: 'Power suit chic.',
|
| 796 |
+
coverColor: 'bg-gray-50',
|
| 797 |
+
previewImage: 'https://images.unsplash.com/photo-1594912959648-2c262a6d71b3?auto=format&fit=crop&w=400&q=60',
|
| 798 |
+
icon: <Briefcase className="w-5 h-5 text-gray-700" />
|
| 799 |
+
},
|
| 800 |
+
{
|
| 801 |
+
id: 'sketch',
|
| 802 |
+
name: 'Sketch',
|
| 803 |
+
prompt: 'Pencil sketch art style, graphite texture, white paper background, artistic shading, hand-drawn look, creative and unique.',
|
| 804 |
+
description: 'Hand-drawn pencil art.',
|
| 805 |
+
coverColor: 'bg-gray-100',
|
| 806 |
+
previewImage: 'https://images.unsplash.com/photo-1516962215378-7fa2e137ae93?auto=format&fit=crop&w=400&q=60',
|
| 807 |
+
icon: <PenTool className="w-5 h-5 text-gray-500" />
|
| 808 |
+
},
|
| 809 |
+
{
|
| 810 |
+
id: 'pixel',
|
| 811 |
+
name: 'Pixel Art',
|
| 812 |
+
prompt: '8-bit Pixel Art style, retro game aesthetic, blocky graphics, vibrant colors, digital nostalgia, fun and playful.',
|
| 813 |
+
description: 'Retro 8-bit game style.',
|
| 814 |
+
coverColor: 'bg-green-100',
|
| 815 |
+
previewImage: 'https://images.unsplash.com/photo-1550684848-86a5d8727436?auto=format&fit=crop&w=400&q=60',
|
| 816 |
+
icon: <Box className="w-5 h-5 text-green-600" />
|
| 817 |
+
},
|
| 818 |
+
{
|
| 819 |
+
id: 'clay',
|
| 820 |
+
name: 'Claymation',
|
| 821 |
+
prompt: 'Stop-motion claymation style, Aardman animation look, plasticine texture, soft lighting, whimsical and cute.',
|
| 822 |
+
description: 'Whimsical stop-motion clay.',
|
| 823 |
+
coverColor: 'bg-orange-200',
|
| 824 |
+
previewImage: 'https://images.unsplash.com/photo-1620023642398-a006c7104a37?auto=format&fit=crop&w=400&q=60',
|
| 825 |
+
icon: <Smile className="w-5 h-5 text-orange-600" />
|
| 826 |
+
},
|
| 827 |
+
{
|
| 828 |
+
id: 'pop_art',
|
| 829 |
+
name: 'Pop Art',
|
| 830 |
+
prompt: 'Andy Warhol Pop Art style, comic book dots, bold solid colors, speech bubbles, high contrast, retro 60s artistic vibe.',
|
| 831 |
+
description: 'Bold colorful comic art.',
|
| 832 |
+
coverColor: 'bg-yellow-200',
|
| 833 |
+
previewImage: 'https://images.unsplash.com/photo-1563725656-78c9b2f67644?auto=format&fit=crop&w=400&q=60',
|
| 834 |
+
icon: <Zap className="w-5 h-5 text-yellow-600" />
|
| 835 |
+
},
|
| 836 |
+
{
|
| 837 |
+
id: 'autumn',
|
| 838 |
+
name: 'Autumn',
|
| 839 |
+
prompt: 'Golden Autumn wedding, falling yellow leaves, park setting, warm sweater textures, soft golden light, cozy and romantic.',
|
| 840 |
+
description: 'Golden leaves and cozy.',
|
| 841 |
+
coverColor: 'bg-orange-100',
|
| 842 |
+
previewImage: 'https://images.unsplash.com/photo-1507783548239-5198030bb402?auto=format&fit=crop&w=400&q=60',
|
| 843 |
+
icon: <Leaf className="w-5 h-5 text-orange-500" />
|
| 844 |
+
},
|
| 845 |
+
{
|
| 846 |
+
id: 'sakura',
|
| 847 |
+
name: 'Sakura',
|
| 848 |
+
prompt: 'Cherry Blossom season, pink petals falling, soft spring light, Japanese street or park background, pastel pink tones, romantic anime vibe.',
|
| 849 |
+
description: 'Pink cherry blossom romance.',
|
| 850 |
+
coverColor: 'bg-pink-100',
|
| 851 |
+
previewImage: 'https://images.unsplash.com/photo-1522383225653-ed111181a951?auto=format&fit=crop&w=400&q=60',
|
| 852 |
+
icon: <Flower className="w-5 h-5 text-pink-400" />
|
| 853 |
+
},
|
| 854 |
+
{
|
| 855 |
+
id: 'rainforest',
|
| 856 |
+
name: 'Rainforest',
|
| 857 |
+
prompt: 'Tropical Rainforest wedding, giant monstera leaves, waterfall background, mist, vibrant green tones, exotic flowers, wild nature.',
|
| 858 |
+
description: 'Lush tropical jungle.',
|
| 859 |
+
coverColor: 'bg-green-100',
|
| 860 |
+
previewImage: 'https://images.unsplash.com/photo-1542614945-316279f064f2?auto=format&fit=crop&w=400&q=60',
|
| 861 |
+
icon: <Trees className="w-5 h-5 text-green-700" />
|
| 862 |
+
},
|
| 863 |
+
{
|
| 864 |
+
id: 'barbie',
|
| 865 |
+
name: 'Barbie',
|
| 866 |
+
prompt: 'Barbiecore aesthetic, everything hot pink, plastic perfect textures, doll-like poses, fun and glamorous, fashion doll box prop.',
|
| 867 |
+
description: 'Hot pink doll glamour.',
|
| 868 |
+
coverColor: 'bg-pink-300',
|
| 869 |
+
previewImage: 'https://images.unsplash.com/photo-1596489369903-a128695d73ba?auto=format&fit=crop&w=400&q=60',
|
| 870 |
+
icon: <Heart className="w-5 h-5 text-pink-600" />
|
| 871 |
+
},
|
| 872 |
+
{
|
| 873 |
+
id: 'wes',
|
| 874 |
+
name: 'Wes Anderson',
|
| 875 |
+
prompt: 'Wes Anderson film style, perfect symmetry, pastel color palette, quirky props, deadpan expressions, distinctive centered composition.',
|
| 876 |
+
description: 'Symmetrical pastel quirky.',
|
| 877 |
+
coverColor: 'bg-yellow-100',
|
| 878 |
+
previewImage: 'https://images.unsplash.com/photo-1520635467475-6eb86a22b406?auto=format&fit=crop&w=400&q=60',
|
| 879 |
+
icon: <Film className="w-5 h-5 text-yellow-600" />
|
| 880 |
+
},
|
| 881 |
+
{
|
| 882 |
+
id: 'cottagecore',
|
| 883 |
+
name: 'Cottagecore',
|
| 884 |
+
prompt: 'Cottagecore aesthetic, picnic in a meadow, gingham dress, straw hat, wildflowers, soft sunlight, simple rural life vibe.',
|
| 885 |
+
description: 'Rural meadow picnic.',
|
| 886 |
+
coverColor: 'bg-green-50',
|
| 887 |
+
previewImage: 'https://images.unsplash.com/photo-1523168892408-c8167f0022d1?auto=format&fit=crop&w=400&q=60',
|
| 888 |
+
icon: <Home className="w-5 h-5 text-green-500" />
|
| 889 |
+
},
|
| 890 |
+
{
|
| 891 |
+
id: 'dark_academia',
|
| 892 |
+
name: 'Dark Academia',
|
| 893 |
+
prompt: 'Dark Academia aesthetic, old library background, tweed blazers, books, mood lighting, coffee tones, intellectual and mysterious.',
|
| 894 |
+
description: 'Moody library intellectual.',
|
| 895 |
+
coverColor: 'bg-stone-200',
|
| 896 |
+
previewImage: 'https://images.unsplash.com/photo-1519340241574-2cec6aef0c01?auto=format&fit=crop&w=400&q=60',
|
| 897 |
+
icon: <Book className="w-5 h-5 text-stone-700" />
|
| 898 |
+
},
|
| 899 |
+
{
|
| 900 |
+
id: 'light_academia',
|
| 901 |
+
name: 'Light Academia',
|
| 902 |
+
prompt: 'Light Academia aesthetic, beige and cream tones, classical statues, sunlight, poetry books, soft and scholarly vibe.',
|
| 903 |
+
description: 'Beige scholarly softness.',
|
| 904 |
+
coverColor: 'bg-orange-50',
|
| 905 |
+
previewImage: 'https://images.unsplash.com/photo-1457369804613-52c61a468e7d?auto=format&fit=crop&w=400&q=60',
|
| 906 |
+
icon: <BookOpen className="w-5 h-5 text-orange-400" />
|
| 907 |
+
},
|
| 908 |
+
{
|
| 909 |
+
id: 'mob_wife',
|
| 910 |
+
name: 'Mob Wife',
|
| 911 |
+
prompt: 'Mob Wife aesthetic, fur coat, heavy gold jewelry, animal print, big sunglasses, confident attitude, luxury SUV background, maximalist glamour.',
|
| 912 |
+
description: 'Maximalist fur glamour.',
|
| 913 |
+
coverColor: 'bg-stone-300',
|
| 914 |
+
previewImage: 'https://images.unsplash.com/photo-1616166417772-520f34086db0?auto=format&fit=crop&w=400&q=60',
|
| 915 |
+
icon: <Gem className="w-5 h-5 text-stone-800" />
|
| 916 |
+
},
|
| 917 |
+
{
|
| 918 |
+
id: 'all_red',
|
| 919 |
+
name: 'Monochrome Red',
|
| 920 |
+
prompt: 'Artistic monochrome red photography, everything in shades of red, high fashion, bold and dramatic, intense emotion.',
|
| 921 |
+
description: 'Intense all-red artistic.',
|
| 922 |
+
coverColor: 'bg-red-200',
|
| 923 |
+
previewImage: 'https://images.unsplash.com/photo-1529139574466-a302d2d3f524?auto=format&fit=crop&w=400&q=60',
|
| 924 |
+
icon: <Circle className="w-5 h-5 text-red-600" />
|
| 925 |
+
},
|
| 926 |
+
{
|
| 927 |
+
id: 'all_black',
|
| 928 |
+
name: 'Monochrome Black',
|
| 929 |
+
prompt: 'Artistic monochrome black photography, playing with shadows and textures, mysterious, elegant, high contrast.',
|
| 930 |
+
description: 'Elegant all-black mystery.',
|
| 931 |
+
coverColor: 'bg-gray-800',
|
| 932 |
+
previewImage: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=400&q=60',
|
| 933 |
+
icon: <Circle className="w-5 h-5 text-black" />
|
| 934 |
+
},
|
| 935 |
+
{
|
| 936 |
+
id: 'pastel',
|
| 937 |
+
name: 'Pastel Dream',
|
| 938 |
+
prompt: 'Pastel dreamscape, cotton candy clouds, soft mint, lilac and baby pink tones, dreamy and surreal atmosphere.',
|
| 939 |
+
description: 'Soft pastel dreamscape.',
|
| 940 |
+
coverColor: 'bg-purple-50',
|
| 941 |
+
previewImage: 'https://images.unsplash.com/photo-1492596985094-52771095ad79?auto=format&fit=crop&w=400&q=60',
|
| 942 |
+
icon: <Cloud className="w-5 h-5 text-purple-300" />
|
| 943 |
+
},
|
| 944 |
+
{
|
| 945 |
+
id: 'cyber_angel',
|
| 946 |
+
name: 'Cyber Angel',
|
| 947 |
+
prompt: 'Cybernetic Angel, mechanical wings, glowing halo, futuristic armor mixed with robes, high-tech divine aesthetic.',
|
| 948 |
+
description: 'High-tech divine wings.',
|
| 949 |
+
coverColor: 'bg-cyan-100',
|
| 950 |
+
previewImage: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=400&q=60',
|
| 951 |
+
icon: <Cpu className="w-5 h-5 text-cyan-500" />
|
| 952 |
+
},
|
| 953 |
+
{
|
| 954 |
+
id: 'hanbok',
|
| 955 |
+
name: 'Hanbok',
|
| 956 |
+
prompt: 'Traditional Korean Hanbok wedding, vibrant colors, simple elegance, palace background, graceful and cultural.',
|
| 957 |
+
description: 'Traditional Korean dress.',
|
| 958 |
+
coverColor: 'bg-rose-50',
|
| 959 |
+
previewImage: 'https://images.unsplash.com/photo-1588667822949-a034d612df08?auto=format&fit=crop&w=400&q=60',
|
| 960 |
+
icon: <Globe className="w-5 h-5 text-rose-500" />
|
| 961 |
+
},
|
| 962 |
+
{
|
| 963 |
+
id: 'kimono',
|
| 964 |
+
name: 'Kimono',
|
| 965 |
+
prompt: 'Traditional Japanese Kimono wedding (Shiromuku), shrine background, white silk, serene and ceremonial atmosphere.',
|
| 966 |
+
description: 'Traditional Japanese silk.',
|
| 967 |
+
coverColor: 'bg-white',
|
| 968 |
+
previewImage: 'https://images.unsplash.com/photo-1492596985094-52771095ad79?auto=format&fit=crop&w=400&q=60',
|
| 969 |
+
icon: <Globe className="w-5 h-5 text-red-500" />
|
| 970 |
+
},
|
| 971 |
+
{
|
| 972 |
+
id: 'cheongsam',
|
| 973 |
+
name: 'Qipao',
|
| 974 |
+
prompt: 'Shanghai 1930s style Qipao (Cheongsam), vintage interior, finger wave hair, elegant and seductive oriental beauty.',
|
| 975 |
+
description: 'Vintage Shanghai elegance.',
|
| 976 |
+
coverColor: 'bg-red-50',
|
| 977 |
+
previewImage: 'https://images.unsplash.com/photo-1540932296774-34d6d45e933a?auto=format&fit=crop&w=400&q=60',
|
| 978 |
+
icon: <Flower className="w-5 h-5 text-red-700" />
|
| 979 |
+
},
|
| 980 |
+
{
|
| 981 |
+
id: 'cowboy',
|
| 982 |
+
name: 'Cowboy',
|
| 983 |
+
prompt: 'Western Cowboy wedding, ranch setting, cowboy hats, boots, denim and leather, horses in background, rustic and adventurous.',
|
| 984 |
+
description: 'Western ranch adventure.',
|
| 985 |
+
coverColor: 'bg-amber-100',
|
| 986 |
+
previewImage: 'https://images.unsplash.com/photo-1533514114760-43846b07bd26?auto=format&fit=crop&w=400&q=60',
|
| 987 |
+
icon: <Briefcase className="w-5 h-5 text-amber-800" />
|
| 988 |
+
},
|
| 989 |
+
{
|
| 990 |
+
id: 'disco',
|
| 991 |
+
name: 'Disco',
|
| 992 |
+
prompt: '70s Disco wedding, mirror ball, funky patterns, platform shoes, dance floor lighting, groovy and fun.',
|
| 993 |
+
description: '70s funky groove.',
|
| 994 |
+
coverColor: 'bg-purple-100',
|
| 995 |
+
previewImage: 'https://images.unsplash.com/photo-1551024601-56296352630a?auto=format&fit=crop&w=400&q=60',
|
| 996 |
+
icon: <Music className="w-5 h-5 text-purple-600" />
|
| 997 |
+
},
|
| 998 |
+
{
|
| 999 |
+
id: 'jazz',
|
| 1000 |
+
name: 'Jazz Bar',
|
| 1001 |
+
prompt: 'Jazz Club wedding vibe, smoky atmosphere, saxophone, spotlight, velvet curtains, classy and soulful.',
|
| 1002 |
+
description: 'Smoky classy club vibe.',
|
| 1003 |
+
coverColor: 'bg-stone-900',
|
| 1004 |
+
previewImage: 'https://images.unsplash.com/photo-1514525253440-b39345208668?auto=format&fit=crop&w=400&q=60',
|
| 1005 |
+
icon: <Music className="w-5 h-5 text-yellow-500" />
|
| 1006 |
+
},
|
| 1007 |
+
{
|
| 1008 |
+
id: 'sporty',
|
| 1009 |
+
name: 'Sporty',
|
| 1010 |
+
prompt: 'Sporty chic wedding, tennis court or stadium background, sneakers with wedding outfit, energetic and healthy vibe.',
|
| 1011 |
+
description: 'Active energy and sneakers.',
|
| 1012 |
+
coverColor: 'bg-green-50',
|
| 1013 |
+
previewImage: 'https://images.unsplash.com/photo-1530549387789-4c1017266635?auto=format&fit=crop&w=400&q=60',
|
| 1014 |
+
icon: <Activity className="w-5 h-5 text-green-600" />
|
| 1015 |
+
}
|
| 1016 |
+
];
|
| 1017 |
+
|
| 1018 |
+
export const StyleSelector: React.FC<StyleSelectorProps> = ({
|
| 1019 |
+
selectedStyle,
|
| 1020 |
+
onSelect,
|
| 1021 |
+
onCustomSelect,
|
| 1022 |
+
onLuckySelect,
|
| 1023 |
+
onSlashClick,
|
| 1024 |
+
disabled,
|
| 1025 |
+
language,
|
| 1026 |
+
recommendedStyleIds,
|
| 1027 |
+
isVipUnlocked,
|
| 1028 |
+
isLoading,
|
| 1029 |
+
resolution,
|
| 1030 |
+
onResolutionChange
|
| 1031 |
+
}) => {
|
| 1032 |
+
const t = TRANSLATIONS[language];
|
| 1033 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 1034 |
+
const [searchTerm, setSearchTerm] = useState('');
|
| 1035 |
+
|
| 1036 |
+
// Connect to User Store for Favorites Management
|
| 1037 |
+
const { currentUser, guestFavorites, toggleFavorite } = useUserStore();
|
| 1038 |
+
|
| 1039 |
+
// Derived favorites state: Use user's if logged in, otherwise guest favorites
|
| 1040 |
+
const favorites = currentUser ? (currentUser.favorites || []) : guestFavorites;
|
| 1041 |
+
|
| 1042 |
+
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 1043 |
+
const file = event.target.files?.[0];
|
| 1044 |
+
if (file) {
|
| 1045 |
+
const reader = new FileReader();
|
| 1046 |
+
reader.onloadend = () => {
|
| 1047 |
+
onCustomSelect(reader.result as string);
|
| 1048 |
+
};
|
| 1049 |
+
reader.readAsDataURL(file);
|
| 1050 |
+
}
|
| 1051 |
+
};
|
| 1052 |
+
|
| 1053 |
+
const handleCustomClick = () => {
|
| 1054 |
+
fileInputRef.current?.click();
|
| 1055 |
+
};
|
| 1056 |
+
|
| 1057 |
+
const handleToggleFavorite = (id: string, e: React.MouseEvent) => {
|
| 1058 |
+
e.stopPropagation();
|
| 1059 |
+
toggleFavorite(id);
|
| 1060 |
+
};
|
| 1061 |
+
|
| 1062 |
+
const filteredStyles = useMemo(() => {
|
| 1063 |
+
return STYLES.filter(style => {
|
| 1064 |
+
const name = (t.styles as any)[style.id] || style.name;
|
| 1065 |
+
const term = searchTerm.toLowerCase();
|
| 1066 |
+
return name.toLowerCase().includes(term) || style.description.toLowerCase().includes(term);
|
| 1067 |
+
});
|
| 1068 |
+
}, [searchTerm, language, t.styles]);
|
| 1069 |
+
|
| 1070 |
+
// Card Renderer Helper
|
| 1071 |
+
const renderStyleCard = (style: WeddingStyle) => {
|
| 1072 |
+
const isSelected = selectedStyle?.id === style.id;
|
| 1073 |
+
const isRecommended = recommendedStyleIds?.includes(style.id);
|
| 1074 |
+
const isLocked = style.isLocked && !isVipUnlocked;
|
| 1075 |
+
const name = (t.styles as any)[style.id] || style.name;
|
| 1076 |
+
const isFav = favorites.includes(style.id);
|
| 1077 |
+
|
| 1078 |
+
return (
|
| 1079 |
+
<li key={style.id} className="list-none">
|
| 1080 |
+
<button
|
| 1081 |
+
onClick={() => !isLocked && onSelect(style)}
|
| 1082 |
+
disabled={disabled}
|
| 1083 |
+
className={`
|
| 1084 |
+
w-full group relative aspect-[3/4] rounded-xl overflow-hidden text-left transition-all duration-300
|
| 1085 |
+
border-2 ${isSelected ? 'border-rose-500 ring-4 ring-rose-100 scale-[1.02] shadow-xl z-10' : 'border-transparent hover:border-gray-200 hover:shadow-lg hover:scale-[1.01]'}
|
| 1086 |
+
${isLocked ? 'cursor-not-allowed opacity-80' : 'cursor-pointer'}
|
| 1087 |
+
`}
|
| 1088 |
+
>
|
| 1089 |
+
{/* Background Image / Color */}
|
| 1090 |
+
<LazyStyleBackground src={style.previewImage} colorClass={style.coverColor} />
|
| 1091 |
+
|
| 1092 |
+
{/* Content Overlay */}
|
| 1093 |
+
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent p-3 flex flex-col justify-end">
|
| 1094 |
+
<div className="flex items-start justify-between mb-auto">
|
| 1095 |
+
{/* Left: Tags */}
|
| 1096 |
+
<div className="flex flex-col gap-1">
|
| 1097 |
+
{style.tags?.includes('hot') && (
|
| 1098 |
+
<span className="px-1.5 py-0.5 bg-red-500 text-white text-[9px] font-bold rounded flex items-center gap-0.5 w-fit shadow-sm">
|
| 1099 |
+
<Flame className="w-2.5 h-2.5" aria-hidden="true" /> HOT
|
| 1100 |
+
</span>
|
| 1101 |
+
)}
|
| 1102 |
+
{style.tags?.includes('new') && (
|
| 1103 |
+
<span className="px-1.5 py-0.5 bg-blue-500 text-white text-[9px] font-bold rounded flex items-center gap-0.5 w-fit shadow-sm">
|
| 1104 |
+
<Sparkles className="w-2.5 h-2.5" aria-hidden="true" /> NEW
|
| 1105 |
+
</span>
|
| 1106 |
+
)}
|
| 1107 |
+
{isRecommended && (
|
| 1108 |
+
<span className="px-1.5 py-0.5 bg-green-500 text-white text-[9px] font-bold rounded flex items-center gap-0.5 w-fit animate-pulse shadow-sm">
|
| 1109 |
+
<ScanFace className="w-2.5 h-2.5" aria-hidden="true" /> MATCH
|
| 1110 |
+
</span>
|
| 1111 |
+
)}
|
| 1112 |
+
</div>
|
| 1113 |
+
|
| 1114 |
+
{/* Right: Actions (Fav + Status) */}
|
| 1115 |
+
<div className="flex gap-1 items-start">
|
| 1116 |
+
{/* Favorite Button */}
|
| 1117 |
+
<button
|
| 1118 |
+
onClick={(e) => handleToggleFavorite(style.id, e)}
|
| 1119 |
+
className={`
|
| 1120 |
+
p-1.5 rounded-full backdrop-blur-sm transition-all duration-300 z-20 shadow-sm
|
| 1121 |
+
hover:scale-110 active:scale-90
|
| 1122 |
+
${isFav
|
| 1123 |
+
? 'bg-white text-yellow-400 shadow-yellow-200/50'
|
| 1124 |
+
: 'bg-black/40 text-white hover:bg-black/60'}
|
| 1125 |
+
`}
|
| 1126 |
+
aria-label={isFav ? "Remove from favorites" : "Add to favorites"}
|
| 1127 |
+
aria-pressed={isFav}
|
| 1128 |
+
>
|
| 1129 |
+
<Star
|
| 1130 |
+
className={`w-3.5 h-3.5 transition-all duration-300 ${isFav ? 'fill-yellow-400 scale-110' : 'fill-transparent'}`}
|
| 1131 |
+
/>
|
| 1132 |
+
</button>
|
| 1133 |
+
|
| 1134 |
+
{/* Status Icon */}
|
| 1135 |
+
{isSelected ? (
|
| 1136 |
+
<div className="bg-rose-500 text-white p-1 rounded-full shadow-lg animate-fade-in" aria-label="Selected">
|
| 1137 |
+
<CheckIcon className="w-3.5 h-3.5" />
|
| 1138 |
+
</div>
|
| 1139 |
+
) : isLocked ? (
|
| 1140 |
+
<button
|
| 1141 |
+
onClick={(e) => { e.stopPropagation(); onSlashClick(style); }}
|
| 1142 |
+
className="bg-black/60 backdrop-blur text-white p-1.5 rounded-full hover:bg-rose-500 transition-colors z-20"
|
| 1143 |
+
aria-label="Locked, click to unlock"
|
| 1144 |
+
>
|
| 1145 |
+
<Lock className="w-3.5 h-3.5" />
|
| 1146 |
+
</button>
|
| 1147 |
+
) : (
|
| 1148 |
+
<div className="bg-white/20 backdrop-blur text-white p-1.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden="true">
|
| 1149 |
+
{style.icon}
|
| 1150 |
+
</div>
|
| 1151 |
+
)}
|
| 1152 |
+
</div>
|
| 1153 |
+
</div>
|
| 1154 |
+
|
| 1155 |
+
<h3 className="font-bold text-white text-sm leading-tight drop-shadow-md" aria-hidden={isLocked && !isSelected}>
|
| 1156 |
+
{name}
|
| 1157 |
+
</h3>
|
| 1158 |
+
<p className="text-[10px] text-gray-300 line-clamp-2 mt-0.5" aria-hidden="true">
|
| 1159 |
+
{style.description}
|
| 1160 |
+
</p>
|
| 1161 |
+
</div>
|
| 1162 |
+
|
| 1163 |
+
{/* Selection Indicator Border Overlay */}
|
| 1164 |
+
{isSelected && <div className="absolute inset-0 border-2 border-rose-500 rounded-xl pointer-events-none"></div>}
|
| 1165 |
+
</button>
|
| 1166 |
+
</li>
|
| 1167 |
+
);
|
| 1168 |
+
};
|
| 1169 |
+
|
| 1170 |
+
if (isLoading) {
|
| 1171 |
+
return (
|
| 1172 |
+
<div className="space-y-6">
|
| 1173 |
+
<div className="flex flex-col sm:flex-row gap-3">
|
| 1174 |
+
<div className="flex-1 h-12 bg-gray-100 rounded-xl animate-pulse"></div>
|
| 1175 |
+
<div className="w-full sm:w-48 h-12 bg-gray-100 rounded-xl animate-pulse"></div>
|
| 1176 |
+
</div>
|
| 1177 |
+
<ul className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 list-none p-0">
|
| 1178 |
+
{[...Array(8)].map((_, i) => <StyleCardSkeleton key={i} />)}
|
| 1179 |
+
</ul>
|
| 1180 |
+
</div>
|
| 1181 |
+
);
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
return (
|
| 1185 |
+
<div className="space-y-6">
|
| 1186 |
+
{/* Search and Resolution Toolbar */}
|
| 1187 |
+
<div className="flex flex-col sm:flex-row gap-3">
|
| 1188 |
+
{/* Search Bar */}
|
| 1189 |
+
<div className="relative flex-1">
|
| 1190 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" aria-hidden="true" />
|
| 1191 |
+
<input
|
| 1192 |
+
type="text"
|
| 1193 |
+
placeholder={t.styleSearchPlace}
|
| 1194 |
+
value={searchTerm}
|
| 1195 |
+
onChange={e => setSearchTerm(e.target.value)}
|
| 1196 |
+
className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 focus:bg-white focus:border-rose-500 focus:ring-1 focus:ring-rose-200 outline-none text-sm transition-all"
|
| 1197 |
+
disabled={disabled}
|
| 1198 |
+
aria-label="Search styles"
|
| 1199 |
+
/>
|
| 1200 |
+
</div>
|
| 1201 |
+
|
| 1202 |
+
{/* Resolution Selector */}
|
| 1203 |
+
<div className="flex bg-gray-100 p-1 rounded-xl shrink-0 overflow-x-auto">
|
| 1204 |
+
<button
|
| 1205 |
+
onClick={() => onResolutionChange('standard')}
|
| 1206 |
+
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all whitespace-nowrap ${resolution === 'standard' ? 'bg-white shadow text-gray-800' : 'text-gray-500 hover:text-gray-700'}`}
|
| 1207 |
+
>
|
| 1208 |
+
HD
|
| 1209 |
+
</button>
|
| 1210 |
+
<button
|
| 1211 |
+
onClick={() => onResolutionChange('high')}
|
| 1212 |
+
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all whitespace-nowrap ${resolution === 'high' ? 'bg-white shadow text-rose-600' : 'text-gray-500 hover:text-gray-700'}`}
|
| 1213 |
+
>
|
| 1214 |
+
4K
|
| 1215 |
+
</button>
|
| 1216 |
+
<button
|
| 1217 |
+
onClick={() => onResolutionChange('ultra')}
|
| 1218 |
+
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1 whitespace-nowrap ${resolution === 'ultra' ? 'bg-gradient-to-r from-rose-500 to-orange-500 text-white shadow' : 'text-gray-500 hover:text-gray-700'}`}
|
| 1219 |
+
>
|
| 1220 |
+
{resolution === 'ultra' && <Maximize className="w-3 h-3" />}
|
| 1221 |
+
Ultra 8K
|
| 1222 |
+
</button>
|
| 1223 |
+
</div>
|
| 1224 |
+
</div>
|
| 1225 |
+
|
| 1226 |
+
{/* Quick Actions Grid */}
|
| 1227 |
+
<ul className="grid grid-cols-2 sm:grid-cols-4 gap-3 list-none p-0">
|
| 1228 |
+
{/* Custom Upload */}
|
| 1229 |
+
<li>
|
| 1230 |
+
<button
|
| 1231 |
+
onClick={handleCustomClick}
|
| 1232 |
+
disabled={disabled}
|
| 1233 |
+
className="w-full h-full relative overflow-hidden group flex flex-col items-center justify-center p-3 rounded-xl border border-gray-200 hover:border-rose-300 hover:bg-rose-50 transition-all bg-white"
|
| 1234 |
+
aria-label="Upload custom style image"
|
| 1235 |
+
>
|
| 1236 |
+
<div className="w-8 h-8 rounded-full bg-rose-100 flex items-center justify-center mb-1 group-hover:scale-110 transition-transform">
|
| 1237 |
+
<Upload className="w-4 h-4 text-rose-500" aria-hidden="true" />
|
| 1238 |
+
</div>
|
| 1239 |
+
<span className="text-xs font-bold text-gray-700">{t.customStyle}</span>
|
| 1240 |
+
<input type="file" accept="image/*" className="hidden" ref={fileInputRef} onChange={handleFileChange} tabIndex={-1} aria-hidden="true" />
|
| 1241 |
+
</button>
|
| 1242 |
+
</li>
|
| 1243 |
+
|
| 1244 |
+
{/* Lucky Pick */}
|
| 1245 |
+
<li>
|
| 1246 |
+
<button
|
| 1247 |
+
onClick={onLuckySelect}
|
| 1248 |
+
disabled={disabled}
|
| 1249 |
+
className="w-full h-full relative overflow-hidden group flex flex-col items-center justify-center p-3 rounded-xl border border-gray-200 hover:border-amber-300 hover:bg-amber-50 transition-all bg-white"
|
| 1250 |
+
aria-label="Lucky Pick: Random style"
|
| 1251 |
+
>
|
| 1252 |
+
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center mb-1 group-hover:scale-110 transition-transform">
|
| 1253 |
+
<Sparkles className="w-4 h-4 text-amber-500" aria-hidden="true" />
|
| 1254 |
+
</div>
|
| 1255 |
+
<span className="text-xs font-bold text-gray-700">{t.luckyStyle}</span>
|
| 1256 |
+
</button>
|
| 1257 |
+
</li>
|
| 1258 |
+
|
| 1259 |
+
{/* VIP Unlock / Slash - Only show if not VIP */}
|
| 1260 |
+
{!isVipUnlocked && (
|
| 1261 |
+
<li className="col-span-2 sm:col-span-2">
|
| 1262 |
+
<button
|
| 1263 |
+
onClick={() => onSlashClick(STYLES[0])} // Just trigger modal
|
| 1264 |
+
disabled={disabled}
|
| 1265 |
+
className="w-full h-full relative overflow-hidden group flex flex-col items-center justify-center p-3 rounded-xl border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all bg-white"
|
| 1266 |
+
aria-label="Unlock styles via viral share"
|
| 1267 |
+
>
|
| 1268 |
+
<div className="flex items-center gap-2 mb-1">
|
| 1269 |
+
<div className="w-8 h-8 rounded-full bg-purple-100 flex items-center justify-center group-hover:scale-110 transition-transform">
|
| 1270 |
+
<Zap className="w-4 h-4 text-purple-500" aria-hidden="true" />
|
| 1271 |
+
</div>
|
| 1272 |
+
</div>
|
| 1273 |
+
<span className="text-xs font-bold text-gray-700">{t.pddSlashTitle}</span>
|
| 1274 |
+
<span className="text-[10px] text-gray-400" aria-hidden="true">Limited Time Offer</span>
|
| 1275 |
+
</button>
|
| 1276 |
+
</li>
|
| 1277 |
+
)}
|
| 1278 |
+
</ul>
|
| 1279 |
+
|
| 1280 |
+
{/* Favorites Section */}
|
| 1281 |
+
{!searchTerm && favorites.length > 0 && (
|
| 1282 |
+
<section className="space-y-3" aria-label="Favorites">
|
| 1283 |
+
<h3 className="text-sm font-bold text-gray-800 flex items-center gap-2 px-1">
|
| 1284 |
+
<Star className="w-4 h-4 fill-rose-500 text-rose-500" aria-hidden="true" />
|
| 1285 |
+
{t.sectFavorites || "My Favorites"}
|
| 1286 |
+
</h3>
|
| 1287 |
+
<ul className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 list-none p-0">
|
| 1288 |
+
{favorites.map(id => {
|
| 1289 |
+
const s = STYLES.find(style => style.id === id);
|
| 1290 |
+
return s ? renderStyleCard(s) : null;
|
| 1291 |
+
})}
|
| 1292 |
+
</ul>
|
| 1293 |
+
</section>
|
| 1294 |
+
)}
|
| 1295 |
+
|
| 1296 |
+
{/* Styles Grid */}
|
| 1297 |
+
<section className="space-y-3" aria-label="All Styles">
|
| 1298 |
+
{(!searchTerm && favorites.length > 0) && (
|
| 1299 |
+
<h3 className="text-sm font-bold text-gray-700 px-1">{t.browseAll || "All Styles"}</h3>
|
| 1300 |
+
)}
|
| 1301 |
+
<ul className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar pb-10 list-none p-0">
|
| 1302 |
+
{filteredStyles.map(renderStyleCard)}
|
| 1303 |
+
{filteredStyles.length === 0 && (
|
| 1304 |
+
<li className="col-span-full py-8 text-center text-gray-400 text-sm">
|
| 1305 |
+
No styles found matching "{searchTerm}"
|
| 1306 |
+
</li>
|
| 1307 |
+
)}
|
| 1308 |
+
</ul>
|
| 1309 |
+
</section>
|
| 1310 |
+
</div>
|
| 1311 |
+
);
|
| 1312 |
+
};
|
| 1313 |
+
|
| 1314 |
+
const CheckIcon = ({ className }: { className?: string }) => (
|
| 1315 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className={className} aria-hidden="true">
|
| 1316 |
+
<polyline points="20 6 9 17 4 12" />
|
| 1317 |
+
</svg>
|
| 1318 |
+
);
|
components/Testimonials.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Star, Quote } from 'lucide-react';
|
| 3 |
+
import { Language } from '../types';
|
| 4 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 5 |
+
|
| 6 |
+
interface TestimonialsProps {
|
| 7 |
+
language: Language;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const Testimonials: React.FC<TestimonialsProps> = ({ language }) => {
|
| 11 |
+
const t = TRANSLATIONS[language];
|
| 12 |
+
|
| 13 |
+
const reviews = [
|
| 14 |
+
{
|
| 15 |
+
text: t.review1,
|
| 16 |
+
user: t.review1User,
|
| 17 |
+
initials: "AT",
|
| 18 |
+
color: "bg-blue-100 text-blue-600"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
text: t.review2,
|
| 22 |
+
user: t.review2User,
|
| 23 |
+
initials: "Z",
|
| 24 |
+
color: "bg-rose-100 text-rose-600"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
text: t.review3,
|
| 28 |
+
user: t.review3User,
|
| 29 |
+
initials: "EW",
|
| 30 |
+
color: "bg-amber-100 text-amber-600"
|
| 31 |
+
}
|
| 32 |
+
];
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<section className="py-12 bg-gray-50 border-t border-gray-100">
|
| 36 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6">
|
| 37 |
+
<div className="text-center mb-10">
|
| 38 |
+
<h2 className="text-2xl sm:text-3xl font-serif font-bold text-gray-900 mb-2">
|
| 39 |
+
{t.reviewsTitle}
|
| 40 |
+
</h2>
|
| 41 |
+
<div className="flex items-center justify-center gap-1 text-yellow-400 mb-2">
|
| 42 |
+
{[1,2,3,4,5].map(i => <Star key={i} className="w-5 h-5 fill-current" />)}
|
| 43 |
+
</div>
|
| 44 |
+
<p className="text-gray-500">{t.reviewsDesc}</p>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 48 |
+
{reviews.map((review, idx) => (
|
| 49 |
+
<div key={idx} className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 relative hover:-translate-y-1 transition-transform duration-300">
|
| 50 |
+
<Quote className="absolute top-6 right-6 w-8 h-8 text-gray-100" />
|
| 51 |
+
<div className="flex items-center gap-1 text-yellow-400 mb-4">
|
| 52 |
+
{[1,2,3,4,5].map(i => <Star key={i} className="w-4 h-4 fill-current" />)}
|
| 53 |
+
</div>
|
| 54 |
+
<p className="text-gray-600 text-sm italic mb-6 leading-relaxed relative z-10">
|
| 55 |
+
"{review.text}"
|
| 56 |
+
</p>
|
| 57 |
+
<div className="flex items-center gap-3">
|
| 58 |
+
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm ${review.color}`}>
|
| 59 |
+
{review.initials}
|
| 60 |
+
</div>
|
| 61 |
+
<div>
|
| 62 |
+
<h4 className="font-bold text-gray-900 text-sm">{review.user}</h4>
|
| 63 |
+
<p className="text-xs text-gray-400">Verified Customer</p>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
))}
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
</section>
|
| 71 |
+
);
|
| 72 |
+
};
|
components/UserCenter.tsx
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState } from 'react';
|
| 3 |
+
import { X, Crown, Star, Share2, UserPlus, Gift, Calendar, Check, User, Clock, FileText } from 'lucide-react';
|
| 4 |
+
import { Language, UserAccount, LeadData, AdminConfig } from '../types';
|
| 5 |
+
import { TRANSLATIONS } from '../constants/translations';
|
| 6 |
+
|
| 7 |
+
interface UserCenterProps {
|
| 8 |
+
isVisible: boolean;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
language: Language;
|
| 11 |
+
user: UserAccount;
|
| 12 |
+
leads?: LeadData[];
|
| 13 |
+
config?: AdminConfig; // NEW
|
| 14 |
+
onRedeem: () => void;
|
| 15 |
+
showToast: (msg: string) => void;
|
| 16 |
+
onAddPoints: (amount: number, reason: string) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const UserCenter: React.FC<UserCenterProps> = ({
|
| 20 |
+
isVisible, onClose, language, user, leads = [], config, onRedeem, showToast, onAddPoints
|
| 21 |
+
}) => {
|
| 22 |
+
const [activeTab, setActiveTab] = useState<'profile' | 'bookings'>('profile');
|
| 23 |
+
const t = TRANSLATIONS[language];
|
| 24 |
+
|
| 25 |
+
if (!isVisible) return null;
|
| 26 |
+
|
| 27 |
+
// Filter leads for this user
|
| 28 |
+
const myBookings = leads.filter(l => l.userId === user.id);
|
| 29 |
+
|
| 30 |
+
// Dynamic Point Values
|
| 31 |
+
const ptShare = config?.pointsShare || 10;
|
| 32 |
+
const ptInvite = config?.pointsInvite || 50;
|
| 33 |
+
const ptBook = config?.pointsBook || 100;
|
| 34 |
+
const costVip = config?.pointsVipCost || 100;
|
| 35 |
+
|
| 36 |
+
const handleCopyLink = () => {
|
| 37 |
+
navigator.clipboard.writeText("https://romantic-life.com/invite/" + user.id);
|
| 38 |
+
showToast(t.toastCopy);
|
| 39 |
+
setTimeout(() => {
|
| 40 |
+
const recent = user.history.find(h => h.action === 'invite' && (Date.now() - h.timestamp) < 60000);
|
| 41 |
+
if(!recent) onAddPoints(ptInvite, 'Invite Link Shared');
|
| 42 |
+
}, 1000);
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const handleShare = () => {
|
| 46 |
+
showToast("Sharing to WeChat...");
|
| 47 |
+
setTimeout(() => {
|
| 48 |
+
const recent = user.history.find(h => h.action === 'share' && (Date.now() - h.timestamp) < 60000);
|
| 49 |
+
if(!recent) onAddPoints(ptShare, 'Poster Shared');
|
| 50 |
+
}, 1000);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const getStatusBadge = (status: LeadData['status']) => {
|
| 54 |
+
switch(status) {
|
| 55 |
+
case 'new': return <span className="text-[10px] font-bold px-2 py-0.5 rounded bg-blue-100 text-blue-700 uppercase">{t.statusNew}</span>;
|
| 56 |
+
case 'contacted': return <span className="text-[10px] font-bold px-2 py-0.5 rounded bg-amber-100 text-amber-700 uppercase">{t.statusContacted}</span>;
|
| 57 |
+
case 'booked': return <span className="text-[10px] font-bold px-2 py-0.5 rounded bg-green-100 text-green-700 uppercase">{t.statusBooked}</span>;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
| 63 |
+
<div className="bg-white rounded-3xl shadow-2xl w-full max-w-md overflow-hidden relative flex flex-col max-h-[80vh]">
|
| 64 |
+
{/* Header Card */}
|
| 65 |
+
<div className="bg-gray-900 text-white p-6 relative overflow-hidden shrink-0">
|
| 66 |
+
<button onClick={onClose} className="absolute top-4 right-4 p-1 bg-white/10 rounded-full hover:bg-white/20 transition-colors z-20">
|
| 67 |
+
<X className="w-5 h-5 text-white" />
|
| 68 |
+
</button>
|
| 69 |
+
|
| 70 |
+
{/* Decorative Circles */}
|
| 71 |
+
<div className="absolute -top-10 -right-10 w-32 h-32 bg-rose-500/20 rounded-full blur-2xl"></div>
|
| 72 |
+
<div className="absolute bottom-0 left-0 w-24 h-24 bg-blue-500/20 rounded-full blur-2xl"></div>
|
| 73 |
+
|
| 74 |
+
<div className="relative z-10 flex items-center gap-4">
|
| 75 |
+
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-rose-400 to-orange-500 p-0.5">
|
| 76 |
+
<div className="w-full h-full rounded-full bg-gray-800 flex items-center justify-center overflow-hidden">
|
| 77 |
+
{user.avatar ? (
|
| 78 |
+
<img src={user.avatar} className="w-full h-full object-cover" />
|
| 79 |
+
) : (
|
| 80 |
+
<span className="text-2xl font-bold text-white">{user.name.charAt(0)}</span>
|
| 81 |
+
)}
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
<div>
|
| 85 |
+
<h2 className="text-xl font-bold">{user.name}</h2>
|
| 86 |
+
<div className="flex items-center gap-2 mt-1">
|
| 87 |
+
<div className={`text-[10px] font-bold px-2 py-0.5 rounded-full flex items-center gap-1 ${user.isVip ? 'bg-amber-400 text-amber-900' : 'bg-gray-700 text-gray-300'}`}>
|
| 88 |
+
<Crown className="w-3 h-3" />
|
| 89 |
+
{user.isVip ? t.vipActive : t.vipInactive}
|
| 90 |
+
</div>
|
| 91 |
+
<span className="text-xs text-gray-400">ID: {user.id.slice(0,6)}</span>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div className="mt-6 bg-white/10 rounded-xl p-4 flex items-center justify-between backdrop-blur-sm">
|
| 97 |
+
<div>
|
| 98 |
+
<p className="text-xs text-gray-400 uppercase tracking-wider">{t.myPoints}</p>
|
| 99 |
+
<p className="text-3xl font-bold text-amber-400 mt-1">{user.points}</p>
|
| 100 |
+
</div>
|
| 101 |
+
{user.isVip ? (
|
| 102 |
+
<div className="px-3 py-1.5 bg-amber-500/20 text-amber-400 rounded-lg text-xs font-bold border border-amber-500/50 flex items-center gap-1">
|
| 103 |
+
<Check className="w-3 h-3" /> VIP Unlocked
|
| 104 |
+
</div>
|
| 105 |
+
) : (
|
| 106 |
+
<button
|
| 107 |
+
onClick={onRedeem}
|
| 108 |
+
disabled={user.points < costVip}
|
| 109 |
+
className={`px-4 py-2 rounded-lg text-xs font-bold transition-colors ${user.points >= costVip ? 'bg-amber-400 text-amber-900 hover:bg-amber-300' : 'bg-gray-700 text-gray-500 cursor-not-allowed'}`}
|
| 110 |
+
>
|
| 111 |
+
{t.rewardVip} ({costVip} pts)
|
| 112 |
+
</button>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{/* Navigation Tabs */}
|
| 118 |
+
<div className="flex border-b border-gray-100 bg-white shrink-0">
|
| 119 |
+
<button
|
| 120 |
+
onClick={() => setActiveTab('profile')}
|
| 121 |
+
className={`flex-1 py-3 text-sm font-bold flex items-center justify-center gap-2 ${activeTab === 'profile' ? 'text-rose-600 border-b-2 border-rose-600' : 'text-gray-500'}`}
|
| 122 |
+
>
|
| 123 |
+
<User className="w-4 h-4" /> {t.tabProfile}
|
| 124 |
+
</button>
|
| 125 |
+
<button
|
| 126 |
+
onClick={() => setActiveTab('bookings')}
|
| 127 |
+
className={`flex-1 py-3 text-sm font-bold flex items-center justify-center gap-2 ${activeTab === 'bookings' ? 'text-rose-600 border-b-2 border-rose-600' : 'text-gray-500'}`}
|
| 128 |
+
>
|
| 129 |
+
<Clock className="w-4 h-4" /> {t.tabBookings}
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
{/* Content Area */}
|
| 134 |
+
<div className="p-6 space-y-6 overflow-y-auto flex-1 bg-gray-50/50">
|
| 135 |
+
|
| 136 |
+
{activeTab === 'profile' && (
|
| 137 |
+
<>
|
| 138 |
+
{/* Tasks Section */}
|
| 139 |
+
<div>
|
| 140 |
+
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
| 141 |
+
<Star className="w-4 h-4 text-rose-500" /> {t.earnPoints}
|
| 142 |
+
</h3>
|
| 143 |
+
<div className="space-y-3">
|
| 144 |
+
<div className="flex items-center justify-between p-3 bg-white rounded-xl border border-rose-100 shadow-sm">
|
| 145 |
+
<div className="flex items-center gap-3">
|
| 146 |
+
<div className="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center text-rose-500"><Share2 className="w-4 h-4" /></div>
|
| 147 |
+
<div>
|
| 148 |
+
<p className="text-sm font-bold text-gray-800">{t.taskShare}</p>
|
| 149 |
+
<p className="text-xs text-rose-500">+{ptShare} pts</p>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
<button onClick={handleShare} className="text-xs font-bold bg-gray-50 px-3 py-1.5 rounded-full border border-gray-200 text-gray-600 hover:bg-rose-50 transition-colors">Go</button>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<div className="flex items-center justify-between p-3 bg-white rounded-xl border border-blue-100 shadow-sm">
|
| 156 |
+
<div className="flex items-center gap-3">
|
| 157 |
+
<div className="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center text-blue-500"><UserPlus className="w-4 h-4" /></div>
|
| 158 |
+
<div>
|
| 159 |
+
<p className="text-sm font-bold text-gray-800">{t.taskInvite}</p>
|
| 160 |
+
<p className="text-xs text-blue-500">+{ptInvite} pts</p>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
<button onClick={handleCopyLink} className="text-xs font-bold bg-gray-50 px-3 py-1.5 rounded-full border border-gray-200 text-gray-600 hover:bg-blue-50 transition-colors">Copy</button>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div className="flex items-center justify-between p-3 bg-white rounded-xl border border-amber-100 shadow-sm">
|
| 167 |
+
<div className="flex items-center gap-3">
|
| 168 |
+
<div className="w-8 h-8 rounded-full bg-amber-50 flex items-center justify-center text-amber-500"><Calendar className="w-4 h-4" /></div>
|
| 169 |
+
<div>
|
| 170 |
+
<p className="text-sm font-bold text-gray-800">{t.taskBook}</p>
|
| 171 |
+
<p className="text-xs text-amber-500">+{ptBook} pts</p>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
<button className="text-xs font-bold bg-gray-50 px-3 py-1.5 rounded-full border border-gray-200 text-gray-600 cursor-default opacity-60">Book</button>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
{/* Rewards Section */}
|
| 180 |
+
<div>
|
| 181 |
+
<h3 className="font-bold text-gray-900 mb-3 flex items-center gap-2">
|
| 182 |
+
<Gift className="w-4 h-4 text-purple-500" /> {t.redeemRewards}
|
| 183 |
+
</h3>
|
| 184 |
+
<div className="grid grid-cols-2 gap-3">
|
| 185 |
+
<div className={`p-4 bg-white rounded-xl border border-gray-200 shadow-sm flex flex-col items-center text-center transition-opacity ${user.isVip ? 'opacity-50' : 'opacity-100'}`}>
|
| 186 |
+
<div className="w-10 h-10 bg-gray-50 rounded-full flex items-center justify-center text-gray-400 mb-2"><Crown className="w-5 h-5" /></div>
|
| 187 |
+
<p className="text-xs font-bold text-gray-800">{t.rewardVip}</p>
|
| 188 |
+
<p className="text-[10px] text-gray-500 mt-1">{costVip} pts</p>
|
| 189 |
+
</div>
|
| 190 |
+
<div className="p-4 bg-white rounded-xl border border-gray-200 shadow-sm flex flex-col items-center text-center opacity-50">
|
| 191 |
+
<div className="w-10 h-10 bg-green-50 rounded-full flex items-center justify-center text-green-500 mb-2"><Gift className="w-5 h-5" /></div>
|
| 192 |
+
<p className="text-xs font-bold text-gray-800">{t.rewardCoupon}</p>
|
| 193 |
+
<p className="text-[10px] text-gray-500 mt-1">500 pts</p>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</>
|
| 198 |
+
)}
|
| 199 |
+
|
| 200 |
+
{activeTab === 'bookings' && (
|
| 201 |
+
<div className="space-y-4">
|
| 202 |
+
{myBookings.length === 0 ? (
|
| 203 |
+
<div className="text-center py-10 text-gray-400">
|
| 204 |
+
<Calendar className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
| 205 |
+
<p>{t.noBookings}</p>
|
| 206 |
+
</div>
|
| 207 |
+
) : (
|
| 208 |
+
myBookings.map(lead => (
|
| 209 |
+
<div key={lead.id} className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
| 210 |
+
<div className="flex items-center justify-between mb-2">
|
| 211 |
+
<div className="flex items-center gap-2">
|
| 212 |
+
<FileText className="w-4 h-4 text-gray-400" />
|
| 213 |
+
<span className="text-sm font-bold text-gray-800">{lead.service}</span>
|
| 214 |
+
</div>
|
| 215 |
+
{getStatusBadge(lead.status)}
|
| 216 |
+
</div>
|
| 217 |
+
<div className="text-xs text-gray-500 space-y-1 pl-6">
|
| 218 |
+
<p>Date: {lead.date || 'Pending'}</p>
|
| 219 |
+
<p>Budget: {lead.budget}</p>
|
| 220 |
+
<p className="text-[10px] text-gray-400 mt-2">{new Date(lead.timestamp).toLocaleString()}</p>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
))
|
| 224 |
+
)}
|
| 225 |
+
</div>
|
| 226 |
+
)}
|
| 227 |
+
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
);
|
| 232 |
+
};
|
constants/._translations.ts
ADDED
|
Binary file (4.1 kB). View file
|
|
|
constants/translations.ts
ADDED
|
@@ -0,0 +1,965 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
import { Language } from '../types';
|
| 4 |
+
|
| 5 |
+
const en = {
|
| 6 |
+
title: "Romantic Life",
|
| 7 |
+
subtitle: "Xiamen Wedding Photography",
|
| 8 |
+
heroTitle: "AI Wedding Fitting Room",
|
| 9 |
+
heroDesc: "Try 100+ premier wedding styles instantly. From Classic to Avant-Garde, find your perfect look with Romantic Life.",
|
| 10 |
+
beta: "AI Fitting v2.0",
|
| 11 |
+
|
| 12 |
+
// Marketing
|
| 13 |
+
promoBanner: "🎉 Spring Wedding Expo: Book online today and get a Free Makeup Trial + ¥500 Voucher!",
|
| 14 |
+
promoEnds: "Offer ends in:",
|
| 15 |
+
tagHot: "HOT",
|
| 16 |
+
tagNew: "NEW",
|
| 17 |
+
tagRec: "EDITOR'S PICK",
|
| 18 |
+
floatConsult: "Consult",
|
| 19 |
+
|
| 20 |
+
// PDD Viral Copy
|
| 21 |
+
pddSlashBtn: "Help Me Unlock",
|
| 22 |
+
pddSlashTitle: "Unlock for FREE!",
|
| 23 |
+
pddSlashDesc: "Invite 1 friend to cut the price to ¥0!",
|
| 24 |
+
pddSlashProgress: "Unlocked 98%...",
|
| 25 |
+
pddSlashCta: "Invite Friend to Cut",
|
| 26 |
+
pddSlashSuccess: "Cut Successfully!",
|
| 27 |
+
pddRedPacketTitle: "Congratulations!",
|
| 28 |
+
pddRedPacketDesc: "You received a Wedding Cash Voucher",
|
| 29 |
+
pddRedPacketCta: "Open Now",
|
| 30 |
+
pddWithdrawTitle: "Withdraw ¥2000",
|
| 31 |
+
pddWithdrawDesc: "Only ¥20 left to withdraw! Share to boost.",
|
| 32 |
+
pddGroupJoin: "Join Group",
|
| 33 |
+
pddGroupJoined: "people joined",
|
| 34 |
+
|
| 35 |
+
// AI & Ticker
|
| 36 |
+
aiScanning: "AI Face Scanning...",
|
| 37 |
+
aiAnalyzing: "Analyzing facial features & skin tone...",
|
| 38 |
+
aiMatching: "Matching best wedding styles...",
|
| 39 |
+
aiBestMatch: "AI MATCH 98%",
|
| 40 |
+
tickerBooked: "booked",
|
| 41 |
+
tickerJustNow: "Just now",
|
| 42 |
+
tickerMinsAgo: "mins ago",
|
| 43 |
+
|
| 44 |
+
// Analysis Modal (NEW)
|
| 45 |
+
analysisTitle: "AI DIAGNOSIS",
|
| 46 |
+
analysisSubtitle: "Biometric Esthetics Report",
|
| 47 |
+
lblFaceShape: "Face Shape",
|
| 48 |
+
lblSkinTone: "Skin Tone Analysis",
|
| 49 |
+
lblRecVibe: "Recommended Vibe",
|
| 50 |
+
aiConfidence: "AI MATCHING CONFIDENCE",
|
| 51 |
+
|
| 52 |
+
// Steps
|
| 53 |
+
step1: "Upload Photo",
|
| 54 |
+
step2: "Select Style",
|
| 55 |
+
step3: "Personalize",
|
| 56 |
+
|
| 57 |
+
// Upload
|
| 58 |
+
uploadTitle: "Upload Your Portrait",
|
| 59 |
+
uploadDesc: "Drag & drop or click. Best results with clear face visibility.",
|
| 60 |
+
changePhoto: "Change Photo",
|
| 61 |
+
|
| 62 |
+
// Styles
|
| 63 |
+
styleSearchPlace: "Search styles...",
|
| 64 |
+
customStyle: "Custom Style",
|
| 65 |
+
customStyleDesc: "Upload reference photo",
|
| 66 |
+
luckyStyle: "Lucky Pick",
|
| 67 |
+
luckyStyleDesc: "Random style selection",
|
| 68 |
+
clearSelection: "Clear selection",
|
| 69 |
+
usingCustom: "Using Custom Reference",
|
| 70 |
+
usingCustomDesc: "Applying this style to your photo.",
|
| 71 |
+
sectFavorites: "My Favorites", // NEW
|
| 72 |
+
browseAll: "Browse All Styles", // NEW
|
| 73 |
+
viewCategories: "View Categories", // NEW
|
| 74 |
+
|
| 75 |
+
// VIP
|
| 76 |
+
vipLocked: "VIP Style",
|
| 77 |
+
vipUnlockTitle: "Unlock VIP Styles",
|
| 78 |
+
vipUnlockDesc: "Register as a member to unlock all premium styles for free!",
|
| 79 |
+
|
| 80 |
+
// Personalize
|
| 81 |
+
colorFilters: "Color Grading",
|
| 82 |
+
bgBlur: "Depth of Field",
|
| 83 |
+
bgBlurDesc: "Professional lens blur effect.",
|
| 84 |
+
groupComp: "Group Composition",
|
| 85 |
+
compClassic: "Classic Formal",
|
| 86 |
+
compDynamic: "Candid Moment",
|
| 87 |
+
compCinematic: "Cinematic",
|
| 88 |
+
resolutionTitle: "Image Quality",
|
| 89 |
+
resStandard: "Standard",
|
| 90 |
+
resHigh: "High (4K)",
|
| 91 |
+
resUltra: "Ultra (8K)",
|
| 92 |
+
customInstruct: "Stylist Instructions",
|
| 93 |
+
customInstructPlaceholder: "e.g. 'Add a bouquet of red roses', 'Sunset beach background'...",
|
| 94 |
+
customInstructHint: "Tell our AI stylist specific details you want.",
|
| 95 |
+
|
| 96 |
+
// Actions
|
| 97 |
+
generating: "Designing...",
|
| 98 |
+
genWait: "Our AI stylist is working on your photos...",
|
| 99 |
+
genSelected: "Try This Style",
|
| 100 |
+
genCustom: "Try Custom Style",
|
| 101 |
+
genAll: "Generate All Styles",
|
| 102 |
+
confirmGenAll: "Generate previews for all 100+ styles? This takes a few minutes.",
|
| 103 |
+
|
| 104 |
+
// Results
|
| 105 |
+
gallery: "My Lookbook",
|
| 106 |
+
startOver: "New Customer",
|
| 107 |
+
holdCompare: "Compare Original",
|
| 108 |
+
sliderHint: "Drag to Compare",
|
| 109 |
+
compareMode: "PK Mode",
|
| 110 |
+
compareHint: "Select another style to compare",
|
| 111 |
+
exitCompare: "Exit PK",
|
| 112 |
+
original: "ORIGINAL",
|
| 113 |
+
watermark: "Romantic Life Photography",
|
| 114 |
+
videoBtn: "Video",
|
| 115 |
+
zipBtn: "Download All",
|
| 116 |
+
zipLoading: "Packing...",
|
| 117 |
+
videoLoading: "Recording...",
|
| 118 |
+
emptyGallery: "Your fitting results will appear here",
|
| 119 |
+
emptyGalleryDesc: "Select a style to see how you look in our latest collections.",
|
| 120 |
+
saveRecipe: "Save Recipe",
|
| 121 |
+
|
| 122 |
+
// Marketing & Lead Gen
|
| 123 |
+
bookNow: "Book Consultation",
|
| 124 |
+
bookStyle: "Book This Style",
|
| 125 |
+
genPoster: "Share Poster",
|
| 126 |
+
consultTitle: "Book Your Shoot",
|
| 127 |
+
consultDesc: "Leave your info, our consultant will contact you with an exclusive offer.",
|
| 128 |
+
formName: "Your Name",
|
| 129 |
+
formPhone: "Phone Number",
|
| 130 |
+
formWeChat: "WeChat ID (Optional)",
|
| 131 |
+
formDate: "Wedding Date (Optional)",
|
| 132 |
+
formBudget: "Budget Range",
|
| 133 |
+
formService: "Service Type",
|
| 134 |
+
budget1: "Under ¥5k",
|
| 135 |
+
budget2: "¥5k - ¥10k",
|
| 136 |
+
budget3: "¥10k - ¥20k",
|
| 137 |
+
budget4: "¥20k+",
|
| 138 |
+
service1: "Wedding Photography",
|
| 139 |
+
service2: "Artistic Portrait",
|
| 140 |
+
service3: "Family / Group",
|
| 141 |
+
formSubmit: "Submit Inquiry",
|
| 142 |
+
formSuccess: "Received! VIP Styles Unlocked!",
|
| 143 |
+
posterTitle: "Scan to Book",
|
| 144 |
+
posterBrand: "Romantic Life Photography",
|
| 145 |
+
|
| 146 |
+
// Features (Why Choose Us)
|
| 147 |
+
whyUsTitle: "Why Choose Romantic Life?",
|
| 148 |
+
whyUsDesc: "Experience the premier wedding photography service in Xiamen.",
|
| 149 |
+
feat1Title: "No Hidden Costs",
|
| 150 |
+
feat1Desc: "Transparent pricing. All raw files included.",
|
| 151 |
+
feat2Title: "Satisfaction Guaranteed",
|
| 152 |
+
feat2Desc: "Free reshoot if you are not satisfied.",
|
| 153 |
+
feat3Title: "1-on-1 Service",
|
| 154 |
+
feat3Desc: "Dedicated team for every couple.",
|
| 155 |
+
feat4Title: "Premium Gowns",
|
| 156 |
+
feat4Desc: "1000+ designer dresses to choose from.",
|
| 157 |
+
|
| 158 |
+
// Testimonials
|
| 159 |
+
reviewsTitle: "Client Love Notes",
|
| 160 |
+
reviewsDesc: "See what our happy couples have to say.",
|
| 161 |
+
review1: "The AI fitting saved us so much time! We knew exactly what we wanted before arriving. The actual shoot was even better.",
|
| 162 |
+
review1User: "Alice & Tom",
|
| 163 |
+
review2: "Highly recommend the 'Chinese Traditional' style. The team was professional and the photos are stunning.",
|
| 164 |
+
review2User: "Ms. Zhang",
|
| 165 |
+
review3: "No hidden fees, great service. The makeup artist is a magician! Loved every moment.",
|
| 166 |
+
review3User: "Emily W.",
|
| 167 |
+
|
| 168 |
+
// Footer
|
| 169 |
+
footerAddress: "No. 188, Huandao Road, Siming District, Xiamen",
|
| 170 |
+
footerPhone: "TEL: 0592-8888888",
|
| 171 |
+
footerRights: "© 2024 Romantic Life Photography. All Rights Reserved.",
|
| 172 |
+
|
| 173 |
+
// About Us
|
| 174 |
+
aboutUsBtn: "About Us",
|
| 175 |
+
aboutTitle: "About Romantic Life",
|
| 176 |
+
aboutStory: "Brand Story",
|
| 177 |
+
aboutStoryContent: "Founded in 2004, Xiamen Romantic Life Photography is a premier luxury wedding studio located on the scenic Huandao Road. With over 20 years of experience and serving 100,000+ couples, we combine artistic aesthetics with genuine emotion to capture timeless memories.",
|
| 178 |
+
aboutPhilosophy: "Our Philosophy",
|
| 179 |
+
aboutPhilosophyContent: "We reject assembly-line photography. Every shutter press is a tribute to love. This AI Fitting Room allows you to explore possibilities, but our dedicated team makes them reality.",
|
| 180 |
+
aboutLocation: "Prime Location",
|
| 181 |
+
aboutLocationContent: "Our 5000m² exclusive sea-view base offers private beaches, gardens, and diverse indoor scenes.",
|
| 182 |
+
|
| 183 |
+
// Video Settings
|
| 184 |
+
vidSettings: "Video Settings",
|
| 185 |
+
sectComposition: "Composition",
|
| 186 |
+
sectEffects: "Effects",
|
| 187 |
+
sectAudio: "Audio",
|
| 188 |
+
transEffect: "Transition",
|
| 189 |
+
transSpeed: "Speed",
|
| 190 |
+
textOverlay: "Title Overlay",
|
| 191 |
+
textOverlayDesc: "Show style name",
|
| 192 |
+
watermarkToggle: "Show Brand Watermark",
|
| 193 |
+
videoMusic: "Background Music",
|
| 194 |
+
musicNone: "None",
|
| 195 |
+
musicRomantic: "Romantic Piano",
|
| 196 |
+
musicCinematic: "Cinematic Strings",
|
| 197 |
+
musicUpbeat: "Upbeat Pop",
|
| 198 |
+
cancel: "Cancel",
|
| 199 |
+
genVideo: "Create Video",
|
| 200 |
+
vidFormat: "Format",
|
| 201 |
+
vidResolution: "Resolution",
|
| 202 |
+
|
| 203 |
+
// --- NEW: User Center & Admin ---
|
| 204 |
+
userCenterTitle: "User Center",
|
| 205 |
+
myPoints: "My Points",
|
| 206 |
+
vipStatus: "VIP Status",
|
| 207 |
+
vipActive: "Active",
|
| 208 |
+
vipInactive: "Inactive",
|
| 209 |
+
earnPoints: "Earn Points",
|
| 210 |
+
taskShare: "Share Poster",
|
| 211 |
+
taskInvite: "Invite Friends",
|
| 212 |
+
taskBook: "Book Shoot",
|
| 213 |
+
redeemRewards: "Redeem Rewards",
|
| 214 |
+
rewardVip: "Unlock All Styles (VIP)",
|
| 215 |
+
rewardCoupon: "¥100 Voucher",
|
| 216 |
+
|
| 217 |
+
// Admin & CRM
|
| 218 |
+
adminTitle: "Studio Admin Panel",
|
| 219 |
+
adminLoginTitle: "Staff Login",
|
| 220 |
+
adminPassword: "Password",
|
| 221 |
+
adminLoginBtn: "Login",
|
| 222 |
+
adminTabLeads: "Leads Management",
|
| 223 |
+
adminTabSettings: "App Settings",
|
| 224 |
+
adminTabUsers: "User Management",
|
| 225 |
+
adminTabStaff: "Staff & Permissions",
|
| 226 |
+
adminTotalLeads: "Total Leads",
|
| 227 |
+
adminNewLeads: "New Today",
|
| 228 |
+
adminExport: "Export CSV",
|
| 229 |
+
adminSave: "Save Settings",
|
| 230 |
+
adminPromoText: "Promo Banner Text",
|
| 231 |
+
adminContactPhone: "Contact Phone",
|
| 232 |
+
adminFooterAddr: "Studio Address",
|
| 233 |
+
adminInsights: "Insights",
|
| 234 |
+
adminHotLead: "🔥 High Intent",
|
| 235 |
+
|
| 236 |
+
statusNew: "New",
|
| 237 |
+
statusContacted: "Contacted",
|
| 238 |
+
statusBooked: "Booked",
|
| 239 |
+
actionDelete: "Delete",
|
| 240 |
+
actionUpdate: "Update Status",
|
| 241 |
+
|
| 242 |
+
// Auth
|
| 243 |
+
authLogin: "Login",
|
| 244 |
+
authRegister: "Register",
|
| 245 |
+
authReset: "Reset Password",
|
| 246 |
+
authPhonePlace: "Phone Number",
|
| 247 |
+
authNamePlace: "Your Name",
|
| 248 |
+
authPassPlace: "Password",
|
| 249 |
+
authNewPassPlace: "New Password",
|
| 250 |
+
authConfirmPassPlace: "Confirm Password",
|
| 251 |
+
authCodePlace: "Verification Code (SMS)",
|
| 252 |
+
authSendCode: "Get Code",
|
| 253 |
+
authCodeSent: "Code sent: ",
|
| 254 |
+
authPassMismatch: "Passwords do not match!",
|
| 255 |
+
authNoAccount: "No account? Register",
|
| 256 |
+
authHasAccount: "Have an account? Login",
|
| 257 |
+
authForgotPass: "Forgot Password?",
|
| 258 |
+
authSubmit: "Submit",
|
| 259 |
+
authWelcome: "Welcome!",
|
| 260 |
+
authResetSuccess: "Password reset successful! Please login.",
|
| 261 |
+
authPhoneNotFound: "Phone number not registered.",
|
| 262 |
+
|
| 263 |
+
// User Management
|
| 264 |
+
userPointsEdit: "Edit Points",
|
| 265 |
+
userVipToggle: "VIP Access",
|
| 266 |
+
|
| 267 |
+
// Staff Management
|
| 268 |
+
staffAddBtn: "Add Staff",
|
| 269 |
+
staffSearchPlace: "Enter user phone...",
|
| 270 |
+
staffPromote: "Promote to Staff",
|
| 271 |
+
staffRole: "Role",
|
| 272 |
+
permExport: "Can Export Data",
|
| 273 |
+
permDelete: "Can Delete Data",
|
| 274 |
+
permSettings: "Can Edit Settings",
|
| 275 |
+
permUsers: "Can Manage Users",
|
| 276 |
+
|
| 277 |
+
// Toasts
|
| 278 |
+
toastCopy: "Link copied to clipboard!",
|
| 279 |
+
toastSaved: "Settings saved successfully!",
|
| 280 |
+
toastWelcome: "Welcome back, Admin!",
|
| 281 |
+
toastDeleted: "Lead deleted.",
|
| 282 |
+
toastPromoted: "User promoted to Staff!",
|
| 283 |
+
toastUpdated: "Permissions updated!",
|
| 284 |
+
toastFeedback: "Thank you for your feedback!",
|
| 285 |
+
|
| 286 |
+
// Feedback
|
| 287 |
+
feedbackTitle: "User Feedback",
|
| 288 |
+
feedbackDesc: "We value your opinion. Help us improve.",
|
| 289 |
+
feedbackTypeBug: "Bug Report",
|
| 290 |
+
feedbackTypeSugg: "Suggestion",
|
| 291 |
+
feedbackTypeOther: "Other",
|
| 292 |
+
feedbackContentPlace: "Please describe your issue or suggestion...",
|
| 293 |
+
feedbackSubmit: "Submit Feedback",
|
| 294 |
+
feedbackRating: "Rate your experience",
|
| 295 |
+
|
| 296 |
+
// My Bookings
|
| 297 |
+
tabProfile: "Profile",
|
| 298 |
+
tabBookings: "My Bookings",
|
| 299 |
+
noBookings: "No bookings yet.",
|
| 300 |
+
|
| 301 |
+
// Tips & Nudges
|
| 302 |
+
genTips: [
|
| 303 |
+
"Did you know? We have a private beach for sunset shoots.",
|
| 304 |
+
"Tip: Match your accessories to the style for best results.",
|
| 305 |
+
"Our makeup artists are trained in Korean & Japanese styles.",
|
| 306 |
+
"Fun Fact: We've shot over 10,000 couples at the Botanical Garden.",
|
| 307 |
+
"Relax your shoulders for a more natural pose.",
|
| 308 |
+
"We provide fresh flowers for all garden shoots."
|
| 309 |
+
],
|
| 310 |
+
pointNudgeDownload: "+10 Pts",
|
| 311 |
+
pointNudgeShare: "Share for +10 Pts!",
|
| 312 |
+
|
| 313 |
+
// New Live Effects
|
| 314 |
+
liveEffect: "Live Photo",
|
| 315 |
+
effectBreath: "Breathing",
|
| 316 |
+
effectGlitch: "Glitch",
|
| 317 |
+
effectParticles: "Particles",
|
| 318 |
+
|
| 319 |
+
// New Subject Types
|
| 320 |
+
subjType: "Subject",
|
| 321 |
+
subjFemale: "Bride / Female",
|
| 322 |
+
subjMale: "Groom / Male",
|
| 323 |
+
subjCouple: "Couple",
|
| 324 |
+
subjChild: "Child",
|
| 325 |
+
subjFamily: "Family",
|
| 326 |
+
|
| 327 |
+
// Style Names
|
| 328 |
+
styles: {
|
| 329 |
+
korean: "Korean Minimalist",
|
| 330 |
+
british: "British Royal",
|
| 331 |
+
euro_american: "Western Vogue",
|
| 332 |
+
hollywood: "Classic Hollywood",
|
| 333 |
+
chinese: "Chinese Traditional",
|
| 334 |
+
gufeng: "Ancient Hanfu",
|
| 335 |
+
japanese: "Japanese Airy",
|
| 336 |
+
ethnic: "Ethnic Vibes",
|
| 337 |
+
exotic: "Exotic Morocco",
|
| 338 |
+
cinematic: "Cinematic Mood",
|
| 339 |
+
film: "Vintage Film",
|
| 340 |
+
blackwhite: "Classic B&W",
|
| 341 |
+
artistic: "Fine Art",
|
| 342 |
+
illustration: "Illustration",
|
| 343 |
+
oil_painting: "Oil Painting",
|
| 344 |
+
anime: "Anime Dream",
|
| 345 |
+
glitch: "Cyber Glitch",
|
| 346 |
+
ins: "Insta Chic",
|
| 347 |
+
magazine: "Magazine Cover",
|
| 348 |
+
minimalist: "Modern Minimalist",
|
| 349 |
+
fashion: "High Fashion",
|
| 350 |
+
retro: "Retro HK",
|
| 351 |
+
literary: "Literary",
|
| 352 |
+
campus: "Campus Love",
|
| 353 |
+
fairytale: "Fairy Tale",
|
| 354 |
+
sweet: "Sweet Pink",
|
| 355 |
+
story: "Love Story",
|
| 356 |
+
documentary: "Documentary",
|
| 357 |
+
fresh: "Nature Fresh",
|
| 358 |
+
garden: "Secret Garden",
|
| 359 |
+
sexy: "Glamour",
|
| 360 |
+
personality: "Unique Pop",
|
| 361 |
+
bohemian: "Bohemian",
|
| 362 |
+
art_deco: "Art Deco",
|
| 363 |
+
masquerade: "Masquerade Ball",
|
| 364 |
+
steampunk: "Steampunk",
|
| 365 |
+
galactic: "Galactic",
|
| 366 |
+
cyberpunk: "Cyberpunk",
|
| 367 |
+
gothic: "Gothic Romance",
|
| 368 |
+
beach: "Island Beach",
|
| 369 |
+
winter: "Winter Snow",
|
| 370 |
+
underwater: "Underwater",
|
| 371 |
+
rustic: "Rustic Barn",
|
| 372 |
+
elf: "Elven Magic",
|
| 373 |
+
indian: "Indian Luxury",
|
| 374 |
+
thai: "Thai Royal",
|
| 375 |
+
double_exposure: "Double Exposure",
|
| 376 |
+
|
| 377 |
+
// Douyin Viral
|
| 378 |
+
old_money: "Old Money Aesthetic",
|
| 379 |
+
phoenix_crown: "Phoenix Crown",
|
| 380 |
+
balletcore: "Balletcore",
|
| 381 |
+
dopamine: "Dopamine",
|
| 382 |
+
clean_fit: "Clean Fit",
|
| 383 |
+
|
| 384 |
+
// Groom Styles
|
| 385 |
+
groom_classic: "Classic Tuxedo",
|
| 386 |
+
groom_modern: "Modern Suit",
|
| 387 |
+
groom_hanfu: "Scholar Hanfu",
|
| 388 |
+
groom_retro: "Retro Gent",
|
| 389 |
+
groom_cyber: "Cyber Warrior",
|
| 390 |
+
|
| 391 |
+
// Child Styles
|
| 392 |
+
child_prince: "Little Prince",
|
| 393 |
+
child_princess: "Little Princess",
|
| 394 |
+
child_suit: "Mini Gentleman",
|
| 395 |
+
child_traditional: "Eastern Kid",
|
| 396 |
+
child_fairy: "Forest Fairy",
|
| 397 |
+
|
| 398 |
+
// NEW 38 STYLES
|
| 399 |
+
mermaid: "Mermaid Core",
|
| 400 |
+
angel: "Angel Wings",
|
| 401 |
+
demon: "Dark Fantasy",
|
| 402 |
+
vampire: "Vampire Gothic",
|
| 403 |
+
victorian: "Victorian Era",
|
| 404 |
+
renaissance: "Renaissance Art",
|
| 405 |
+
egyptian: "Ancient Egypt",
|
| 406 |
+
greek: "Greek Goddess",
|
| 407 |
+
roman: "Roman Holiday",
|
| 408 |
+
viking: "Nordic Viking",
|
| 409 |
+
y2k: "Y2K Millennium",
|
| 410 |
+
neon: "Neon Lights",
|
| 411 |
+
denim: "Denim Casual",
|
| 412 |
+
suit_bride: "Bridal Suit",
|
| 413 |
+
sketch: "Pencil Sketch",
|
| 414 |
+
pixel: "Pixel Art",
|
| 415 |
+
clay: "Claymation",
|
| 416 |
+
pop_art: "Pop Art",
|
| 417 |
+
autumn: "Golden Autumn",
|
| 418 |
+
sakura: "Cherry Blossom",
|
| 419 |
+
rainforest: "Tropical Rainforest",
|
| 420 |
+
barbie: "Pink Doll",
|
| 421 |
+
wes: "Pastel Symmetry",
|
| 422 |
+
cottagecore: "Cottagecore",
|
| 423 |
+
dark_academia: "Dark Academia",
|
| 424 |
+
light_academia: "Light Academia",
|
| 425 |
+
mob_wife: "Mob Wife Aesthetic",
|
| 426 |
+
all_red: "Monochrome Red",
|
| 427 |
+
all_black: "Gothic Black",
|
| 428 |
+
pastel: "Pastel Dream",
|
| 429 |
+
cyber_angel: "Cybernetic Angel",
|
| 430 |
+
hanbok: "Korean Hanbok",
|
| 431 |
+
kimono: "Japanese Kimono",
|
| 432 |
+
cheongsam: "Qipao",
|
| 433 |
+
cowboy: "Western Cowboy",
|
| 434 |
+
disco: "70s Disco",
|
| 435 |
+
jazz: "Jazz Bar",
|
| 436 |
+
sporty: "Sporty Chic"
|
| 437 |
+
}
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
export type TranslationType = typeof en;
|
| 441 |
+
|
| 442 |
+
const zh: TranslationType = {
|
| 443 |
+
title: "厦门浪漫一生",
|
| 444 |
+
subtitle: "婚纱摄影 AI 试衣间",
|
| 445 |
+
heroTitle: "在线试衣 预见你的美",
|
| 446 |
+
heroDesc: "上传照片,一键体验100+种全球热门婚纱风格。先试穿,再拍摄,所见即所得。",
|
| 447 |
+
beta: "AI 拓客版",
|
| 448 |
+
promoBanner: "🎉 春季婚博会限时活动:现在预约进店,赠送“明星级”试妆一次 + 500元现金抵用券!",
|
| 449 |
+
promoEnds: "距活动结束仅剩",
|
| 450 |
+
tagHot: "爆款",
|
| 451 |
+
tagNew: "新品",
|
| 452 |
+
tagRec: "店长推荐",
|
| 453 |
+
floatConsult: "在线咨询",
|
| 454 |
+
|
| 455 |
+
// PDD Viral
|
| 456 |
+
pddSlashBtn: "免费拿",
|
| 457 |
+
pddSlashTitle: "0元解锁 VIP风格",
|
| 458 |
+
pddSlashDesc: "邀请1位好友助力,直接砍到 0元 拿走!",
|
| 459 |
+
pddSlashProgress: "已砍 98.9%...",
|
| 460 |
+
pddSlashCta: "喊好友砍一刀",
|
| 461 |
+
pddSlashSuccess: "砍价成功!",
|
| 462 |
+
pddRedPacketTitle: "恭喜!",
|
| 463 |
+
pddRedPacketDesc: "您获得一个婚纱照现金红包",
|
| 464 |
+
pddRedPacketCta: "立即拆开",
|
| 465 |
+
pddWithdrawTitle: "提现 2000元",
|
| 466 |
+
pddWithdrawDesc: "再分享给 2个 群,即可提现到账!",
|
| 467 |
+
pddGroupJoin: "去拼团",
|
| 468 |
+
pddGroupJoined: "人已拼",
|
| 469 |
+
|
| 470 |
+
aiScanning: "AI 智能面部扫描中...",
|
| 471 |
+
aiAnalyzing: "正在分析五官骨相与肤色...",
|
| 472 |
+
aiMatching: "正在匹配最适合您的婚纱风格...",
|
| 473 |
+
aiBestMatch: "AI 契合度 98%",
|
| 474 |
+
tickerBooked: "预约了",
|
| 475 |
+
tickerJustNow: "刚刚",
|
| 476 |
+
tickerMinsAgo: "分钟前",
|
| 477 |
+
|
| 478 |
+
// Analysis Modal (NEW - CHINESE)
|
| 479 |
+
analysisTitle: "AI 形象诊断报告",
|
| 480 |
+
analysisSubtitle: "面部生物美学分析",
|
| 481 |
+
lblFaceShape: "面部轮廓",
|
| 482 |
+
lblSkinTone: "肤色质感",
|
| 483 |
+
lblRecVibe: "推荐风格气质",
|
| 484 |
+
aiConfidence: "AI 匹配置信度",
|
| 485 |
+
|
| 486 |
+
step1: "上传客照",
|
| 487 |
+
step2: "选择风格",
|
| 488 |
+
step3: "定制细节",
|
| 489 |
+
uploadTitle: "上传您的照片",
|
| 490 |
+
uploadDesc: "支持自拍或生活照,五官清晰效果最佳",
|
| 491 |
+
changePhoto: "更换照片",
|
| 492 |
+
styleSearchPlace: "搜索风格...",
|
| 493 |
+
customStyle: "自定义风格",
|
| 494 |
+
customStyleDesc: "上传网图/样片",
|
| 495 |
+
luckyStyle: "手气不错",
|
| 496 |
+
luckyStyleDesc: "随机匹配一款风格",
|
| 497 |
+
clearSelection: "清除选择",
|
| 498 |
+
usingCustom: "使用自定义样片",
|
| 499 |
+
usingCustomDesc: "我们将复刻这张样片的风格。",
|
| 500 |
+
sectFavorites: "我的收藏", // NEW
|
| 501 |
+
browseAll: "浏览全部风格", // NEW
|
| 502 |
+
viewCategories: "查看分类", // NEW
|
| 503 |
+
|
| 504 |
+
vipLocked: "VIP 专享",
|
| 505 |
+
vipUnlockTitle: "解锁 VIP 风格",
|
| 506 |
+
vipUnlockDesc: "简单注册/留资,即可免费解锁全部高级风格!",
|
| 507 |
+
|
| 508 |
+
colorFilters: "后期滤镜",
|
| 509 |
+
bgBlur: "大光圈虚化",
|
| 510 |
+
bgBlurDesc: "模拟单反镜头景深效果",
|
| 511 |
+
groupComp: "多人拍摄模式",
|
| 512 |
+
compClassic: "经典合影",
|
| 513 |
+
compDynamic: "互动抓拍",
|
| 514 |
+
compCinematic: "电影叙事",
|
| 515 |
+
resolutionTitle: "画质选择",
|
| 516 |
+
resStandard: "标准 (HD)",
|
| 517 |
+
resHigh: "高清 (4K)",
|
| 518 |
+
resUltra: "超清 (8K)",
|
| 519 |
+
customInstruct: "修图师指令",
|
| 520 |
+
customInstructPlaceholder: "例如:'要手捧红玫瑰','背景换成鼓浪屿海边'...",
|
| 521 |
+
customInstructHint: "像告诉修图师一样告诉AI您的需求。",
|
| 522 |
+
generating: "正在设计中...",
|
| 523 |
+
genWait: "AI修图师正在为您生成样片,请稍候...",
|
| 524 |
+
genSelected: "生成该风格",
|
| 525 |
+
genCustom: "生成自定义",
|
| 526 |
+
genAll: "一键生成全套样片",
|
| 527 |
+
confirmGenAll: "确定生成全套100+种风格吗?这将制作您的专属电子相册。",
|
| 528 |
+
gallery: "我的试衣间",
|
| 529 |
+
startOver: "接待下一位",
|
| 530 |
+
holdCompare: "对比原图",
|
| 531 |
+
sliderHint: "拖动对比",
|
| 532 |
+
compareMode: "风格PK",
|
| 533 |
+
compareHint: "请点击下方选择另一张进行对比",
|
| 534 |
+
exitCompare: "退出PK",
|
| 535 |
+
original: "原图",
|
| 536 |
+
watermark: "厦门浪漫一生婚纱摄影",
|
| 537 |
+
videoBtn: "生成视频",
|
| 538 |
+
zipBtn: "打包下载",
|
| 539 |
+
zipLoading: "打包中...",
|
| 540 |
+
videoLoading: "渲染中...",
|
| 541 |
+
emptyGallery: "试衣效果将在这里显示",
|
| 542 |
+
emptyGalleryDesc: "选择左侧风格,为客户展示拍摄效果。",
|
| 543 |
+
saveRecipe: "保存配方",
|
| 544 |
+
|
| 545 |
+
// Marketing & Lead Gen
|
| 546 |
+
bookNow: "预约进店",
|
| 547 |
+
bookStyle: "拍这套",
|
| 548 |
+
genPoster: "生成种草海报",
|
| 549 |
+
consultTitle: "预约拍摄档期",
|
| 550 |
+
consultDesc: "留下您的联系方式,获取本季最新优惠报价。",
|
| 551 |
+
formName: "您的称呼",
|
| 552 |
+
formPhone: "联系电话",
|
| 553 |
+
formWeChat: "微信号 (选填)",
|
| 554 |
+
formDate: "婚期/拍摄日期 (选填)",
|
| 555 |
+
formBudget: "预算范围",
|
| 556 |
+
formService: "拍摄类型",
|
| 557 |
+
budget1: "5000元以下",
|
| 558 |
+
budget2: "5000 - 10000元",
|
| 559 |
+
budget3: "10000 - 20000元",
|
| 560 |
+
budget4: "20000元以上",
|
| 561 |
+
service1: "婚纱摄影",
|
| 562 |
+
service2: "艺术写真",
|
| 563 |
+
service3: "全家福/多人",
|
| 564 |
+
formSubmit: "提交预约,解锁VIP",
|
| 565 |
+
formSuccess: "预约成功!VIP风格已解锁!",
|
| 566 |
+
posterTitle: "扫码咨询",
|
| 567 |
+
posterBrand: "厦门浪漫一生婚纱摄影",
|
| 568 |
+
|
| 569 |
+
// Features
|
| 570 |
+
whyUsTitle: "为什么选择浪漫一生?",
|
| 571 |
+
whyUsDesc: "专注高端婚纱摄影20年,服务超10万对新人。",
|
| 572 |
+
feat1Title: "无隐形消费",
|
| 573 |
+
feat1Desc: "价格透明,所有底片全送。",
|
| 574 |
+
feat2Title: "不满意重拍",
|
| 575 |
+
feat2Desc: "拍摄不满意无条件重拍,直到满意为止。",
|
| 576 |
+
feat3Title: "一客一选",
|
| 577 |
+
feat3Desc: "专属团队一对一服务,拒绝流水线。",
|
| 578 |
+
feat4Title: "高定礼服",
|
| 579 |
+
feat4Desc: "全馆1000+套品牌礼服任选。",
|
| 580 |
+
|
| 581 |
+
// Testimonials
|
| 582 |
+
reviewsTitle: "客户真实评价",
|
| 583 |
+
reviewsDesc: "听听我们的新人怎么说",
|
| 584 |
+
review1: "AI试衣太方便了,不用去店里就能选好风格,省了一整天时间!",
|
| 585 |
+
review1User: "林小姐 & 张先生",
|
| 586 |
+
review2: "极力推荐‘中式喜嫁’风格,非常喜庆,家人都很喜欢。服务团队也很专业。",
|
| 587 |
+
review2User: "张女士",
|
| 588 |
+
review3: "没有任何隐形消费,化妆师技术超好,成片效果比AI生成的还要惊艳!",
|
| 589 |
+
review3User: "Emily W.",
|
| 590 |
+
|
| 591 |
+
// Footer
|
| 592 |
+
footerAddress: "厦门市思明区环岛路188号",
|
| 593 |
+
footerPhone: "咨询热线: 0592-8888888",
|
| 594 |
+
footerRights: "© 2024 厦门浪漫一生婚纱摄影 版权所有",
|
| 595 |
+
|
| 596 |
+
// About Us
|
| 597 |
+
aboutUsBtn: "关于我们",
|
| 598 |
+
aboutTitle: "关于浪漫一生",
|
| 599 |
+
aboutStory: "品牌故事",
|
| 600 |
+
aboutStoryContent: "厦门浪漫一生婚纱摄影成立于2004年,是坐落于环岛路的高端婚纱摄影品牌。20年来,我们服务了超过10万对新人。我们坚持用镜头捕捉最真实的情感,将艺术美学与幸福瞬间完美融合。",
|
| 601 |
+
aboutPhilosophy: "服务理念",
|
| 602 |
+
aboutPhilosophyContent: "我们拒绝流水线式的拍摄。每一次快门都是对爱情的礼赞。这个AI试衣间只是开始,我们专业的摄影团队将把无限的想象变为触手可及的现实。",
|
| 603 |
+
aboutLocation: "黄金地段",
|
| 604 |
+
aboutLocationContent: "我们拥有5000平米独家海景基地,私人沙滩、花园及多样化室内实景,满足您对婚纱照的所有幻想。",
|
| 605 |
+
|
| 606 |
+
vidSettings: "视频参数",
|
| 607 |
+
sectComposition: "画面构图",
|
| 608 |
+
sectEffects: "特效调整",
|
| 609 |
+
sectAudio: "音频配乐",
|
| 610 |
+
transEffect: "转场",
|
| 611 |
+
transSpeed: "速度",
|
| 612 |
+
textOverlay: "风格字幕",
|
| 613 |
+
textOverlayDesc: "显示风格名称",
|
| 614 |
+
watermarkToggle: "显示品牌水印",
|
| 615 |
+
videoMusic: "背景音乐",
|
| 616 |
+
musicNone: "无音乐",
|
| 617 |
+
musicRomantic: "浪漫钢琴",
|
| 618 |
+
musicCinematic: "电影弦乐",
|
| 619 |
+
musicUpbeat: "欢快流行",
|
| 620 |
+
cancel: "取消",
|
| 621 |
+
genVideo: "导出视频",
|
| 622 |
+
vidFormat: "视频格式",
|
| 623 |
+
vidResolution: "分辨率",
|
| 624 |
+
|
| 625 |
+
// User Center & Admin
|
| 626 |
+
userCenterTitle: "用户中心",
|
| 627 |
+
myPoints: "我的积分",
|
| 628 |
+
vipStatus: "VIP 状态",
|
| 629 |
+
vipActive: "已激活",
|
| 630 |
+
vipInactive: "未激活",
|
| 631 |
+
earnPoints: "赚取积分",
|
| 632 |
+
taskShare: "分享海报 (+10)",
|
| 633 |
+
taskInvite: "邀请好友 (+50)",
|
| 634 |
+
taskBook: "预约拍摄 (+100)",
|
| 635 |
+
redeemRewards: "积分兑换",
|
| 636 |
+
rewardVip: "解锁全套风格 (VIP)",
|
| 637 |
+
rewardCoupon: "100元现金券",
|
| 638 |
+
|
| 639 |
+
// Admin CRM
|
| 640 |
+
adminTitle: "影楼管理后台",
|
| 641 |
+
adminLoginTitle: "员工登录",
|
| 642 |
+
adminPassword: "密码",
|
| 643 |
+
adminLoginBtn: "登录",
|
| 644 |
+
adminTabLeads: "客资管理",
|
| 645 |
+
adminTabSettings: "软件设置",
|
| 646 |
+
adminTabUsers: "用户管理",
|
| 647 |
+
adminTabStaff: "员工权限",
|
| 648 |
+
adminTotalLeads: "总客资数",
|
| 649 |
+
adminNewLeads: "今日新增",
|
| 650 |
+
adminExport: "导出 CSV",
|
| 651 |
+
adminSave: "保存设置",
|
| 652 |
+
adminPromoText: "顶部活动文案",
|
| 653 |
+
adminContactPhone: "联系电话",
|
| 654 |
+
adminFooterAddr: "店铺地址",
|
| 655 |
+
adminInsights: "AI画像分析",
|
| 656 |
+
adminHotLead: "🔥 高意向",
|
| 657 |
+
|
| 658 |
+
statusNew: "新客",
|
| 659 |
+
statusContacted: "已联系",
|
| 660 |
+
statusBooked: "已成交",
|
| 661 |
+
actionDelete: "删除",
|
| 662 |
+
actionUpdate: "更新状态",
|
| 663 |
+
|
| 664 |
+
// Auth
|
| 665 |
+
authLogin: "用户登录",
|
| 666 |
+
authRegister: "注册会员",
|
| 667 |
+
authReset: "重置密码",
|
| 668 |
+
authPhonePlace: "手机号码",
|
| 669 |
+
authNamePlace: "您的称呼",
|
| 670 |
+
authPassPlace: "输入密码",
|
| 671 |
+
authNewPassPlace: "设置新密码",
|
| 672 |
+
authConfirmPassPlace: "确认密码",
|
| 673 |
+
authCodePlace: "短信验证码",
|
| 674 |
+
authSendCode: "获取验证码",
|
| 675 |
+
authCodeSent: "验证码已发送: ",
|
| 676 |
+
authPassMismatch: "两次密码不一致!",
|
| 677 |
+
authNoAccount: "没有账号?去注册",
|
| 678 |
+
authHasAccount: "已有账号?去登录",
|
| 679 |
+
authForgotPass: "忘记密码?",
|
| 680 |
+
authSubmit: "确定",
|
| 681 |
+
authWelcome: "欢迎回来!",
|
| 682 |
+
authResetSuccess: "密码重置成功!请重新登录。",
|
| 683 |
+
authPhoneNotFound: "手机号未注册",
|
| 684 |
+
|
| 685 |
+
// User Management
|
| 686 |
+
userPointsEdit: "修改积分",
|
| 687 |
+
userVipToggle: "VIP权限",
|
| 688 |
+
|
| 689 |
+
// Staff Management
|
| 690 |
+
staffAddBtn: "添加员工",
|
| 691 |
+
staffSearchPlace: "输入用户手机号...",
|
| 692 |
+
staffPromote: "设为员工",
|
| 693 |
+
staffRole: "角色",
|
| 694 |
+
permExport: "导出数据",
|
| 695 |
+
permDelete: "删除数据",
|
| 696 |
+
permSettings: "修改设置",
|
| 697 |
+
permUsers: "管理用户",
|
| 698 |
+
|
| 699 |
+
toastCopy: "链接已复制!",
|
| 700 |
+
toastSaved: "设置已保存!",
|
| 701 |
+
toastWelcome: "欢迎回来,店长!",
|
| 702 |
+
toastDeleted: "客资已删除。",
|
| 703 |
+
toastPromoted: "已成功设为员工!",
|
| 704 |
+
toastUpdated: "权限已更新!",
|
| 705 |
+
toastFeedback: "感谢您的反馈!",
|
| 706 |
+
|
| 707 |
+
// Feedback
|
| 708 |
+
feedbackTitle: "用户反馈",
|
| 709 |
+
feedbackDesc: "您的意见是我们进步的动力",
|
| 710 |
+
feedbackTypeBug: "功能故障",
|
| 711 |
+
feedbackTypeSugg: "产品建议",
|
| 712 |
+
feedbackTypeOther: "其他问题",
|
| 713 |
+
feedbackContentPlace: "请详细描述您遇到的问题或建议...",
|
| 714 |
+
feedbackSubmit: "提交反馈",
|
| 715 |
+
feedbackRating: "您对本次体验满意吗?",
|
| 716 |
+
|
| 717 |
+
// My Bookings
|
| 718 |
+
tabProfile: "个人资料",
|
| 719 |
+
tabBookings: "我的预约",
|
| 720 |
+
noBookings: "暂无预约记录。",
|
| 721 |
+
|
| 722 |
+
genTips: [
|
| 723 |
+
"您知道吗?我们拥有环岛路私人海滩拍摄基地。",
|
| 724 |
+
"小贴士:搭配与风格相符的配���效果更佳哦。",
|
| 725 |
+
"我们的化妆师团队均定期赴韩、日进修。",
|
| 726 |
+
"有趣的事实:我们在植物园已经拍摄了超过一万对新人。",
|
| 727 |
+
"放松肩膀,您的体态会更加自然优雅。",
|
| 728 |
+
"花园拍摄我们提供当日空运的鲜花道具。"
|
| 729 |
+
],
|
| 730 |
+
pointNudgeDownload: "+10 积分",
|
| 731 |
+
pointNudgeShare: "分享赚 +10 积分!",
|
| 732 |
+
|
| 733 |
+
// New Live Effects
|
| 734 |
+
liveEffect: "动态照片",
|
| 735 |
+
effectBreath: "呼吸",
|
| 736 |
+
effectGlitch: "故障",
|
| 737 |
+
effectParticles: "粒子",
|
| 738 |
+
|
| 739 |
+
// New Subject Types
|
| 740 |
+
subjType: "主角类型",
|
| 741 |
+
subjFemale: "新娘/女士",
|
| 742 |
+
subjMale: "新郎/男士",
|
| 743 |
+
subjCouple: "情侣/夫妻",
|
| 744 |
+
subjChild: "儿童/宝宝",
|
| 745 |
+
subjFamily: "全家福",
|
| 746 |
+
|
| 747 |
+
styles: {
|
| 748 |
+
korean: "韩式唯美",
|
| 749 |
+
british: "英伦皇室",
|
| 750 |
+
euro_american: "欧美时尚",
|
| 751 |
+
hollywood: "好莱坞大片",
|
| 752 |
+
chinese: "中式喜嫁",
|
| 753 |
+
gufeng: "古风汉服",
|
| 754 |
+
japanese: "日系小清新",
|
| 755 |
+
ethnic: "民族风情",
|
| 756 |
+
exotic: "异域风情",
|
| 757 |
+
cinematic: "电影质感",
|
| 758 |
+
film: "胶片复古",
|
| 759 |
+
blackwhite: "经典黑白",
|
| 760 |
+
artistic: "艺术画意",
|
| 761 |
+
illustration: "手绘插画",
|
| 762 |
+
oil_painting: "古典油画",
|
| 763 |
+
anime: "唯美动漫",
|
| 764 |
+
glitch: "故障艺术",
|
| 765 |
+
ins: "Ins网红风",
|
| 766 |
+
magazine: "杂志大片",
|
| 767 |
+
minimalist: "极简主义",
|
| 768 |
+
fashion: "潮流街拍",
|
| 769 |
+
retro: "复古港风",
|
| 770 |
+
literary: "文艺青年",
|
| 771 |
+
campus: "校园回忆",
|
| 772 |
+
fairytale: "迪士尼童话",
|
| 773 |
+
sweet: "甜美可爱",
|
| 774 |
+
story: "故事剧情",
|
| 775 |
+
documentary: "纪实抓拍",
|
| 776 |
+
fresh: "森系氧气",
|
| 777 |
+
garden: "莫奈花园",
|
| 778 |
+
sexy: "性感私房",
|
| 779 |
+
personality: "个性搞怪",
|
| 780 |
+
bohemian: "波西米亚",
|
| 781 |
+
art_deco: "盖茨比奢华",
|
| 782 |
+
masquerade: "假面舞会",
|
| 783 |
+
steampunk: "蒸汽朋克",
|
| 784 |
+
galactic: "银河科幻",
|
| 785 |
+
cyberpunk: "赛博朋克",
|
| 786 |
+
gothic: "暗黑哥特",
|
| 787 |
+
beach: "海岛旅拍",
|
| 788 |
+
winter: "冰雪奇缘",
|
| 789 |
+
underwater: "唯美水下",
|
| 790 |
+
rustic: "美式庄园",
|
| 791 |
+
elf: "梦幻精灵",
|
| 792 |
+
indian: "印度风情",
|
| 793 |
+
thai: "泰式风情",
|
| 794 |
+
double_exposure: "双重曝光",
|
| 795 |
+
|
| 796 |
+
// Douyin Viral
|
| 797 |
+
old_money: "老钱风",
|
| 798 |
+
phoenix_crown: "凤冠霞帔",
|
| 799 |
+
balletcore: "芭蕾风",
|
| 800 |
+
dopamine: "多巴胺",
|
| 801 |
+
clean_fit: "极简智性",
|
| 802 |
+
|
| 803 |
+
// Groom Styles
|
| 804 |
+
groom_classic: "经典黑西装",
|
| 805 |
+
groom_modern: "现代轻奢",
|
| 806 |
+
groom_hanfu: "书卷汉服",
|
| 807 |
+
groom_retro: "复古绅士",
|
| 808 |
+
groom_cyber: "赛博战士",
|
| 809 |
+
|
| 810 |
+
// Child Styles
|
| 811 |
+
child_prince: "在逃小王子",
|
| 812 |
+
child_princess: "迪士尼公主",
|
| 813 |
+
child_suit: "小小绅士",
|
| 814 |
+
child_traditional: "国潮萌娃",
|
| 815 |
+
child_fairy: "森林小精灵",
|
| 816 |
+
|
| 817 |
+
// NEW 38 STYLES (CHINESE)
|
| 818 |
+
mermaid: "人鱼传说",
|
| 819 |
+
angel: "天使之翼",
|
| 820 |
+
demon: "暗黑恶魔",
|
| 821 |
+
vampire: "暮光之城",
|
| 822 |
+
victorian: "维多利亚",
|
| 823 |
+
renaissance: "文艺复兴",
|
| 824 |
+
egyptian: "埃及艳后",
|
| 825 |
+
greek: "希腊女神",
|
| 826 |
+
roman: "罗马假日",
|
| 827 |
+
viking: "北欧维京",
|
| 828 |
+
y2k: "Y2K千禧风",
|
| 829 |
+
neon: "霓虹光影",
|
| 830 |
+
denim: "丹宁牛仔",
|
| 831 |
+
suit_bride: "新娘西装",
|
| 832 |
+
sketch: "素描手绘",
|
| 833 |
+
pixel: "像素艺术",
|
| 834 |
+
clay: "粘土动画",
|
| 835 |
+
pop_art: "波普艺术",
|
| 836 |
+
autumn: "金秋童话",
|
| 837 |
+
sakura: "樱花恋人",
|
| 838 |
+
rainforest: "热带雨林",
|
| 839 |
+
barbie: "芭比粉红",
|
| 840 |
+
wes: "韦斯安德森",
|
| 841 |
+
cottagecore: "田园牧歌",
|
| 842 |
+
dark_academia: "暗黑学院",
|
| 843 |
+
light_academia: "明亮学院",
|
| 844 |
+
mob_wife: "黑帮大嫂",
|
| 845 |
+
all_red: "纯红视觉",
|
| 846 |
+
all_black: "纯黑哥特",
|
| 847 |
+
pastel: "马卡龙梦境",
|
| 848 |
+
cyber_angel: "机械天使",
|
| 849 |
+
hanbok: "传统韩服",
|
| 850 |
+
kimono: "传统和服",
|
| 851 |
+
cheongsam: "海派旗袍",
|
| 852 |
+
cowboy: "西部牛仔",
|
| 853 |
+
disco: "复古迪斯科",
|
| 854 |
+
jazz: "爵士名伶",
|
| 855 |
+
sporty: "运动活力"
|
| 856 |
+
}
|
| 857 |
+
};
|
| 858 |
+
|
| 859 |
+
const createTranslation = (overrides: Partial<TranslationType> & { styles?: Partial<TranslationType['styles']> }): TranslationType => {
|
| 860 |
+
return {
|
| 861 |
+
...en,
|
| 862 |
+
...overrides,
|
| 863 |
+
styles: {
|
| 864 |
+
...en.styles,
|
| 865 |
+
...overrides.styles,
|
| 866 |
+
} as TranslationType['styles']
|
| 867 |
+
};
|
| 868 |
+
};
|
| 869 |
+
|
| 870 |
+
const ja = createTranslation({
|
| 871 |
+
title: "Romantic Life",
|
| 872 |
+
subtitle: "Wedding Studio",
|
| 873 |
+
styleSearchPlace: "スタイルを検索...",
|
| 874 |
+
customStyle: "カスタムスタイル",
|
| 875 |
+
luckyStyle: "ラッキーピック",
|
| 876 |
+
luckyStyleDesc: "スタイルをランダムに選択",
|
| 877 |
+
compareMode: "比較モード",
|
| 878 |
+
compareHint: "別のスタイルを選択してください",
|
| 879 |
+
exitCompare: "終了",
|
| 880 |
+
adminInsights: "AI分析",
|
| 881 |
+
adminHotLead: "🔥 高関心",
|
| 882 |
+
authReset: "パスワードのリセット",
|
| 883 |
+
authNewPassPlace: "新しいパスワード",
|
| 884 |
+
authCodePlace: "認証コード",
|
| 885 |
+
authSendCode: "コードを送信",
|
| 886 |
+
authForgotPass: "パスワードをお忘れですか?",
|
| 887 |
+
sectFavorites: "お気に入り", // NEW
|
| 888 |
+
browseAll: "すべて表示", // NEW
|
| 889 |
+
viewCategories: "カテゴリ表示", // NEW
|
| 890 |
+
});
|
| 891 |
+
|
| 892 |
+
const ko = createTranslation({
|
| 893 |
+
title: "Romantic Life",
|
| 894 |
+
subtitle: "Wedding Studio",
|
| 895 |
+
styleSearchPlace: "스타일 검색...",
|
| 896 |
+
customStyle: "사용자 정의 스타일",
|
| 897 |
+
luckyStyle: "럭키 픽",
|
| 898 |
+
luckyStyleDesc: "스타일 랜덤 선택",
|
| 899 |
+
compareMode: "비교 모드",
|
| 900 |
+
compareHint: "비교할 다른 스타일을 선택하세요",
|
| 901 |
+
exitCompare: "나가기",
|
| 902 |
+
adminInsights: "AI 인사이트",
|
| 903 |
+
adminHotLead: "🔥 높은 관심",
|
| 904 |
+
authReset: "비밀번호 재설정",
|
| 905 |
+
authNewPassPlace: "새 비밀번호",
|
| 906 |
+
authCodePlace: "인증 코드",
|
| 907 |
+
authSendCode: "코드 보내기",
|
| 908 |
+
authForgotPass: "비밀번호를 잊으셨나요?",
|
| 909 |
+
sectFavorites: "즐겨찾기", // NEW
|
| 910 |
+
browseAll: "전체 스타일 보기", // NEW
|
| 911 |
+
viewCategories: "카테고리 보기", // NEW
|
| 912 |
+
});
|
| 913 |
+
|
| 914 |
+
const es = createTranslation({
|
| 915 |
+
title: "Romantic Life",
|
| 916 |
+
subtitle: "Wedding Studio",
|
| 917 |
+
styleSearchPlace: "Buscar estilos...",
|
| 918 |
+
customStyle: "Estilo personalizado",
|
| 919 |
+
luckyStyle: "Elección de suerte",
|
| 920 |
+
luckyStyleDesc: "Selección de estilo aleatorio",
|
| 921 |
+
compareMode: "Modo Comparar",
|
| 922 |
+
compareHint: "Selecciona otro estilo",
|
| 923 |
+
exitCompare: "Salir",
|
| 924 |
+
adminInsights: "Insights IA",
|
| 925 |
+
adminHotLead: "🔥 Alto Interés",
|
| 926 |
+
authReset: "Restablecer contraseña",
|
| 927 |
+
authNewPassPlace: "Nueva contraseña",
|
| 928 |
+
authCodePlace: "Código de verificación",
|
| 929 |
+
authSendCode: "Enviar código",
|
| 930 |
+
authForgotPass: "¿Olvidaste tu contraseña?",
|
| 931 |
+
sectFavorites: "Mis Favoritos", // NEW
|
| 932 |
+
browseAll: "Ver todos", // NEW
|
| 933 |
+
viewCategories: "Ver categorías", // NEW
|
| 934 |
+
});
|
| 935 |
+
|
| 936 |
+
const fr = createTranslation({
|
| 937 |
+
title: "Romantic Life",
|
| 938 |
+
subtitle: "Wedding Studio",
|
| 939 |
+
styleSearchPlace: "Rechercher des styles...",
|
| 940 |
+
customStyle: "Style personnalisé",
|
| 941 |
+
luckyStyle: "Choix chanceux",
|
| 942 |
+
luckyStyleDesc: "Sélection de style aléatoire",
|
| 943 |
+
compareMode: "Mode Comparaison",
|
| 944 |
+
compareHint: "Sélectionnez un autre style",
|
| 945 |
+
exitCompare: "Quitter",
|
| 946 |
+
adminInsights: "Aperçu IA",
|
| 947 |
+
adminHotLead: "🔥 Fort Intérêt",
|
| 948 |
+
authReset: "Réinitialiser le mot de passe",
|
| 949 |
+
authNewPassPlace: "Nouveau mot de passe",
|
| 950 |
+
authCodePlace: "Code de vérification",
|
| 951 |
+
authSendCode: "Envoyer le code",
|
| 952 |
+
authForgotPass: "Mot de passe oublié ?",
|
| 953 |
+
sectFavorites: "Mes Favoris", // NEW
|
| 954 |
+
browseAll: "Tout afficher", // NEW
|
| 955 |
+
viewCategories: "Voir catégories", // NEW
|
| 956 |
+
});
|
| 957 |
+
|
| 958 |
+
export const TRANSLATIONS: Record<Language, TranslationType> = {
|
| 959 |
+
en,
|
| 960 |
+
zh,
|
| 961 |
+
ja,
|
| 962 |
+
ko,
|
| 963 |
+
es,
|
| 964 |
+
fr
|
| 965 |
+
};
|
index.css
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
/* Custom Global Styles */
|
| 6 |
+
body {
|
| 7 |
+
background-color: #fdfdfd;
|
| 8 |
+
-webkit-tap-highlight-color: transparent;
|
| 9 |
+
overscroll-behavior-y: none;
|
| 10 |
+
}
|
| 11 |
+
.custom-scrollbar::-webkit-scrollbar {
|
| 12 |
+
width: 6px;
|
| 13 |
+
height: 6px;
|
| 14 |
+
}
|
| 15 |
+
.custom-scrollbar::-webkit-scrollbar-track {
|
| 16 |
+
background: transparent;
|
| 17 |
+
}
|
| 18 |
+
.custom-scrollbar::-webkit-scrollbar-thumb {
|
| 19 |
+
background-color: rgba(244, 63, 94, 0.3);
|
| 20 |
+
border-radius: 20px;
|
| 21 |
+
}
|
| 22 |
+
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
| 23 |
+
background-color: rgba(244, 63, 94, 0.6);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.animate-scan { animation: scan 2s linear infinite; }
|
| 27 |
+
.animate-swing { animation: swing 3s ease-in-out infinite; }
|
| 28 |
+
|
| 29 |
+
/* Live Effects Utility Classes */
|
| 30 |
+
.effect-breath { animation: breathing 5s ease-in-out infinite; }
|
| 31 |
+
.effect-glitch { animation: glitch 0.3s cubic-bezier(.25, .46, .45, .94) both infinite; }
|
| 32 |
+
.effect-particles::before {
|
| 33 |
+
content: '';
|
| 34 |
+
position: absolute;
|
| 35 |
+
top: 0; left: 0; width: 100%; height: 100%;
|
| 36 |
+
background-image: radial-gradient(white 1px, transparent 1px);
|
| 37 |
+
background-size: 20px 20px;
|
| 38 |
+
opacity: 0.3;
|
| 39 |
+
animation: float 3s linear infinite;
|
| 40 |
+
pointer-events: none;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* Low Power Mode Disables */
|
| 44 |
+
body.low-power .effect-breath,
|
| 45 |
+
body.low-power .effect-glitch,
|
| 46 |
+
body.low-power .animate-scan {
|
| 47 |
+
animation: none !important;
|
| 48 |
+
}
|
index.html
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
| 6 |
+
<title>厦门浪漫一生 - AI 婚纱试衣间</title>
|
| 7 |
+
<meta name="description" content="AI Wedding Style Fitting Room - Xiamen Romantic Life Photography">
|
| 8 |
+
<meta name="theme-color" content="#f43f5e">
|
| 9 |
+
|
| 10 |
+
<!-- PWA Config -->
|
| 11 |
+
<link rel="manifest" href="/manifest.json">
|
| 12 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 13 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 14 |
+
<meta name="apple-mobile-web-app-title" content="Romantic AI">
|
| 15 |
+
<script type="importmap">
|
| 16 |
+
{
|
| 17 |
+
"imports": {
|
| 18 |
+
"react": "https://aistudiocdn.com/react@^19.2.1",
|
| 19 |
+
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.1/",
|
| 20 |
+
"react/": "https://aistudiocdn.com/react@^19.2.1/",
|
| 21 |
+
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.31.0",
|
| 22 |
+
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.556.0",
|
| 23 |
+
"jszip": "https://aistudiocdn.com/jszip@^3.10.1",
|
| 24 |
+
"canvas-confetti": "https://aistudiocdn.com/canvas-confetti@^1.9.4",
|
| 25 |
+
"zustand": "https://aistudiocdn.com/zustand@^5.0.9",
|
| 26 |
+
"zustand/": "https://aistudiocdn.com/zustand@^5.0.9/",
|
| 27 |
+
"vite": "https://aistudiocdn.com/vite@^7.2.6",
|
| 28 |
+
"@vitejs/plugin-react": "https://aistudiocdn.com/@vitejs/plugin-react@^5.1.1"
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
</script>
|
| 32 |
+
<link rel="stylesheet" href="/index.css">
|
| 33 |
+
</head>
|
| 34 |
+
<body>
|
| 35 |
+
<div id="root">
|
| 36 |
+
<!-- Loading Spinner for Initial Load -->
|
| 37 |
+
<div id="loading-spinner" style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; background-color:#fdfdfd; position:fixed; top:0; left:0; width:100%; z-index:9999;">
|
| 38 |
+
<div style="width:40px; height:40px; border:4px solid #fecdd3; border-top-color:#f43f5e; border-radius:50%; animation:spin 1s linear infinite;"></div>
|
| 39 |
+
<p style="margin-top:16px; font-family:sans-serif; color:#fb7185; font-size:14px; font-weight:bold;">Loading...</p>
|
| 40 |
+
</div>
|
| 41 |
+
<style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
|
| 42 |
+
</div>
|
| 43 |
+
<script type="module" src="/index.tsx"></script>
|
| 44 |
+
</body>
|
| 45 |
+
</html>
|
index.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
import './index.css';
|
| 5 |
+
import { ErrorBoundary } from './components/ErrorBoundary';
|
| 6 |
+
|
| 7 |
+
const rootElement = document.getElementById('root');
|
| 8 |
+
if (!rootElement) {
|
| 9 |
+
throw new Error("Could not find root element to mount to");
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
// Register Service Worker for PWA support
|
| 13 |
+
if ('serviceWorker' in navigator) {
|
| 14 |
+
window.addEventListener('load', () => {
|
| 15 |
+
navigator.serviceWorker.register('/service-worker.js')
|
| 16 |
+
.then(registration => {
|
| 17 |
+
console.log('SW registered: ', registration);
|
| 18 |
+
})
|
| 19 |
+
.catch(registrationError => {
|
| 20 |
+
console.log('SW registration failed: ', registrationError);
|
| 21 |
+
});
|
| 22 |
+
});
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const root = ReactDOM.createRoot(rootElement);
|
| 26 |
+
root.render(
|
| 27 |
+
<React.StrictMode>
|
| 28 |
+
<ErrorBoundary>
|
| 29 |
+
<App />
|
| 30 |
+
</ErrorBoundary>
|
| 31 |
+
</React.StrictMode>
|
| 32 |
+
);
|
manifest.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "厦门浪漫一生 AI 试衣间",
|
| 3 |
+
"short_name": "Romantic AI",
|
| 4 |
+
"description": "Xiamen Romantic Life Wedding Photography AI Fitting Room",
|
| 5 |
+
"start_url": "/",
|
| 6 |
+
"display": "standalone",
|
| 7 |
+
"background_color": "#ffffff",
|
| 8 |
+
"theme_color": "#f43f5e",
|
| 9 |
+
"orientation": "portrait",
|
| 10 |
+
"icons": [
|
| 11 |
+
{
|
| 12 |
+
"src": "https://api.iconify.design/lucide:camera.svg?color=%23f43f5e",
|
| 13 |
+
"sizes": "192x192",
|
| 14 |
+
"type": "image/svg+xml",
|
| 15 |
+
"purpose": "any maskable"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"src": "https://api.iconify.design/lucide:camera.svg?color=%23f43f5e",
|
| 19 |
+
"sizes": "512x512",
|
| 20 |
+
"type": "image/svg+xml",
|
| 21 |
+
"purpose": "any maskable"
|
| 22 |
+
}
|
| 23 |
+
]
|
| 24 |
+
}
|
metadata.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Copy of Romantic Life - AI Wedding Fitting Room",
|
| 3 |
+
"description": "Xiamen Romantic Life Wedding Photography AI Fitting Room. Try different wedding styles instantly and book your photoshoot.",
|
| 4 |
+
"requestFramePermissions": []
|
| 5 |
+
}
|
nginx.conf
ADDED
|
File without changes
|
package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
{
|
| 3 |
+
"name": "romantic-life-ai-studio",
|
| 4 |
+
"private": true,
|
| 5 |
+
"version": "2.0.0",
|
| 6 |
+
"type": "module",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"dev": "vite",
|
| 9 |
+
"build": "tsc && vite build",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@google/genai": "^0.1.0",
|
| 14 |
+
"canvas-confetti": "^1.9.2",
|
| 15 |
+
"jszip": "^3.10.1",
|
| 16 |
+
"lucide-react": "^0.344.0",
|
| 17 |
+
"react": "^18.2.0",
|
| 18 |
+
"react-dom": "^18.2.0",
|
| 19 |
+
"zustand": "^4.5.2"
|
| 20 |
+
},
|
| 21 |
+
"devDependencies": {
|
| 22 |
+
"@types/canvas-confetti": "^1.6.4",
|
| 23 |
+
"@types/react": "^18.2.64",
|
| 24 |
+
"@types/react-dom": "^18.2.21",
|
| 25 |
+
"@vitejs/plugin-react": "^4.2.1",
|
| 26 |
+
"autoprefixer": "^10.4.18",
|
| 27 |
+
"postcss": "^8.4.35",
|
| 28 |
+
"tailwindcss": "^3.4.1",
|
| 29 |
+
"typescript": "^5.4.2",
|
| 30 |
+
"vite": "^5.1.5"
|
| 31 |
+
}
|
| 32 |
+
}
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|