Lianjx commited on
Commit
8fb4cca
·
verified ·
1 Parent(s): 5448183

Upload 75 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. APP_DEVELOPMENT.md +188 -0
  2. App.tsx +280 -0
  3. README.md +39 -5
  4. components/._AboutModal.tsx +0 -0
  5. components/._AdminDashboard.tsx +0 -0
  6. components/._AnalysisModal.tsx +0 -0
  7. components/._AuthModal.tsx +0 -0
  8. components/._ErrorBoundary.tsx +0 -0
  9. components/._ExitIntentModal.tsx +0 -0
  10. components/._Features.tsx +0 -0
  11. components/._FeedbackModal.tsx +0 -0
  12. components/._FilterSelector.tsx +0 -0
  13. components/._FloatingConcierge.tsx +0 -0
  14. components/._Header.tsx +0 -0
  15. components/._ImageUploader.tsx +0 -0
  16. components/._RedPacketModal.tsx +0 -0
  17. components/._ResultViewer.tsx +0 -0
  18. components/._ShareModal.tsx +0 -0
  19. components/._SlashModal.tsx +0 -0
  20. components/._StyleSelector.tsx +0 -0
  21. components/._Testimonials.tsx +0 -0
  22. components/._UserCenter.tsx +0 -0
  23. components/AboutModal.tsx +103 -0
  24. components/AdminDashboard.tsx +360 -0
  25. components/AnalysisModal.tsx +86 -0
  26. components/AuthModal.tsx +242 -0
  27. components/ErrorBoundary.tsx +59 -0
  28. components/ExitIntentModal.tsx +78 -0
  29. components/Features.tsx +60 -0
  30. components/FeedbackModal.tsx +146 -0
  31. components/FilterSelector.tsx +102 -0
  32. components/FloatingConcierge.tsx +82 -0
  33. components/Header.tsx +139 -0
  34. components/ImageUploader.tsx +128 -0
  35. components/RedPacketModal.tsx +135 -0
  36. components/ResultViewer.tsx +203 -0
  37. components/ShareModal.tsx +143 -0
  38. components/SlashModal.tsx +135 -0
  39. components/StyleSelector.tsx +389 -0
  40. components/Testimonials.tsx +72 -0
  41. components/UserCenter.tsx +232 -0
  42. constants/._translations.ts +0 -0
  43. constants/translations.ts +46 -0
  44. index.css +48 -0
  45. index.html +45 -0
  46. index.tsx +32 -0
  47. manifest.json +24 -0
  48. metadata.json +7 -0
  49. nginx.conf +1 -0
  50. package.json +32 -0
APP_DEVELOPMENT.md ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 厦门浪漫一生 AI - 移动端 App 开发指南
2
+
3
+ 本文档旨在指导开发人员将 **Romantic Life AI (Web)** 项目打包为移动端应用 (**Android / iOS**)。
4
+
5
+ 本项目采用 **Hybrid (混合开发)** 架构:
6
+ * **核心代码**: React + TypeScript + Vite (现有 Web 代码)
7
+ * **原生容器**: [Capacitor](https://capacitorjs.com/) (用于打包和调用原生 API)
8
+ * **UI 适配**: Tailwind CSS (已配置响应式设计)
9
+
10
+ ---
11
+
12
+ ## 1. 方案选择
13
+
14
+ 我们提供两种移动端交付方式:
15
+
16
+ ### 方案 A: PWA (渐进式 Web 应用)
17
+ * **优点**: 无需上架应用商店,通过浏览器直接安装,更新即时。
18
+ * **现状**: 项目已包含 `manifest.json` 和 `service-worker.js`,已支持 PWA。
19
+ * **适用**: 快速验证,微信生态分享。
20
+
21
+ ### 方案 B: 原生 App (使用 Capacitor)
22
+ * **优点**: 可上架 App Store / Google Play,性能更好,原生权限(相机/相册)支持更佳。
23
+ * **适用**: 正式商业发布,建立品牌形象。
24
+
25
+ ---
26
+
27
+ ## 2. 环境准备 (Prerequisites)
28
+
29
+ 在开始构建原生 App 之前,请确保您的开发环境已安装:
30
+
31
+ 1. **Node.js** (v18+)
32
+ 2. **移动端开发工具**:
33
+ * **Android**: [Android Studio](https://developer.android.com/studio) (需安装 Android SDK)
34
+ * **iOS**: Xcode (仅限 macOS 系统, 需安装 CocoaPods)
35
+
36
+ ---
37
+
38
+ ## 3. Capacitor 集成步骤
39
+
40
+ 如果您决定构建原生 App,请执行以下步骤将 Capacitor 集成到项目中。
41
+
42
+ ### 第一步:安装依赖
43
+
44
+ 在项目根目录下运行终端命令:
45
+
46
+ ```bash
47
+ # 安装 Capacitor 核心
48
+ npm install @capacitor/core
49
+ npm install -D @capacitor/cli
50
+
51
+ # 初始化 Capacitor (App Name: RomanticAI, ID: com.romantic.life)
52
+ npx cap init "Romantic AI" com.romantic.life
53
+ ```
54
+
55
+ ### 第二步:安装平台依赖
56
+
57
+ ```bash
58
+ # 安装 Android 和 iOS 平台库
59
+ npm install @capacitor/android @capacitor/ios
60
+
61
+ # 添加平台到项目
62
+ npx cap add android
63
+ npx cap add ios
64
+ ```
65
+
66
+ ### 第三步:构建 Web 资源
67
+
68
+ 在同步到原生项目之前,必须先构建 React 代码:
69
+
70
+ ```bash
71
+ # 必须先生成 dist 目录
72
+ npm run build
73
+ ```
74
+
75
+ ### 第四步:同步资源到原生平台
76
+
77
+ 此命令会将 `dist` 目录下的代码复制到 `android/` 和 `ios/` 目录中:
78
+
79
+ ```bash
80
+ npx cap sync
81
+ ```
82
+
83
+ ---
84
+
85
+ ## 4. 权限配置 (关键)
86
+
87
+ 由于本项目核心功能是 **AI 试衣**,必须配置 **相机 (Camera)** 和 **相册 (Photo Library)** 权限。
88
+
89
+ ### Android 配置
90
+ 修改文件: `android/app/src/main/AndroidManifest.xml`
91
+
92
+ 在 `<manifest>` 标签内添加:
93
+ ```xml
94
+ <!-- 网络权限 -->
95
+ <uses-permission android:name="android.permission.INTERNET" />
96
+ <!-- 相机权限 -->
97
+ <uses-permission android:name="android.permission.CAMERA" />
98
+ <uses-feature android:name="android.hardware.camera" />
99
+ <!-- 文件读取权限 -->
100
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
101
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
102
+ ```
103
+
104
+ ### iOS 配置
105
+ 修改文件: `ios/App/App/Info.plist`
106
+
107
+ 添加以下键值对:
108
+ ```xml
109
+ <key>NSCameraUsageDescription</key>
110
+ <string>我们需要使用您的相机来拍摄照片进行 AI 试衣。</string>
111
+ <key>NSPhotoLibraryAddUsageDescription</key>
112
+ <string>我们需要保存生成的婚纱照到您的相册。</string>
113
+ <key>NSPhotoLibraryUsageDescription</key>
114
+ <string>我们需要访问您的相册以选择照片进行 AI 试衣。</string>
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 5. 构建与运行
120
+
121
+ ### 运行 Android App
122
+
123
+ 1. 执行同步命令确保代码最新:
124
+ ```bash
125
+ npm run build && npx cap sync
126
+ ```
127
+ 2. 打开 Android Studio:
128
+ ```bash
129
+ npx cap open android
130
+ ```
131
+ 3. 在 Android Studio 中,连接真机或启动模拟器,点击 **Run (绿三角)** 按钮。
132
+
133
+ ### 运行 iOS App (仅 macOS)
134
+
135
+ 1. 执行同步命令:
136
+ ```bash
137
+ npm run build && npx cap sync
138
+ ```
139
+ 2. 打开 Xcode:
140
+ ```bash
141
+ npx cap open ios
142
+ ```
143
+ 3. 在 Xcode 中选择模拟器或连接 iPhone,点击 **Run**。
144
+
145
+ ---
146
+
147
+ ## 6. 应用图标与启动图 (Assets)
148
+
149
+ 为了生成不同尺寸的应用图标和启动图,推荐使用 `@capacitor/assets`。
150
+
151
+ 1. 准备一张 `1024x1024` 的 logo 图片,命名为 `logo.png`,放入 `assets` 文件夹(需新建)。
152
+ 2. 安装工具:
153
+ ```bash
154
+ npm install -D @capacitor/assets
155
+ ```
156
+ 3. 生成资源:
157
+ ```bash
158
+ npx capacitor-assets generate
159
+ ```
160
+
161
+ 这将自动替换 Android 和 iOS 项目中的默认图标。
162
+
163
+ ---
164
+
165
+ ## 7. 打包发布 (Release)
166
+
167
+ ### Android (APK / AAB)
168
+ 1. 在 Android Studio 中,点击菜单栏 **Build > Generate Signed Bundle / APK**。
169
+ 2. 创建密钥库 (Keystore) 并填写密码。
170
+ 3. 选择 **Release** 模式。
171
+ 4. 生成的 APK 文件即可分发给用户安装。
172
+
173
+ ### iOS (IPA)
174
+ 1. 在 Xcode 中,选择 **Product > Archive**。
175
+ 2. 构建完成后,使用 **Distribute App** 上传至 App Store Connect 或导出 Ad Hoc 用于测试。
176
+
177
+ ---
178
+
179
+ ## 8. 常见问题 (FAQ)
180
+
181
+ * **Q: 接口请求失败?**
182
+ * **A**: 请确保 Android/iOS 允许明文 HTTP 请求(如果 API 不是 HTTPS),或者配置 `server.allowNavigation`。在 `capacitor.config.ts` 中配置 `server` 选项可以指向本地 IP 进行调试。
183
+
184
+ * **Q: 页面包含 "刘海屏" 遮挡?**
185
+ * **A**: 我们已经在 `index.html` 中设置了 `viewport-fit=cover`,并在 CSS 中使用了 `safe-area-inset` 变量,通常能自动适配。
186
+
187
+ * **Q: 返回键退出应用?**
188
+ * **A**: Capacitor 默认处理了 Android 物理返回键。如果需要自定义逻辑(如在首页双击退出),需在 `App.tsx` 中监听 `App.addListener('backButton', ...)`。
App.tsx ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { Features } from './components/Features';
8
+ import { Testimonials } from './components/Testimonials';
9
+ const AdminDashboard = React.lazy(() => import('./components/AdminDashboard').then(module => ({ default: module.AdminDashboard })));
10
+ import { SlashModal } from './components/SlashModal';
11
+ import { ShareModal } from './components/ShareModal';
12
+ import { generateStyledWeddingImage } from './services/geminiService';
13
+ import { WeddingStyle } from './types';
14
+ import { TRANSLATIONS } from './constants/translations';
15
+ import { Loader2, Wand2, CheckCircle, Sparkles, GitMerge, Zap } from 'lucide-react';
16
+ // @ts-ignore
17
+ import confetti from 'canvas-confetti';
18
+ import { useUserStore, useUIStore, useGenerationStore } from './store';
19
+ import { FloatingConcierge } from './components/FloatingConcierge';
20
+ import { ipService } from './services/ipService';
21
+
22
+ const Toast = ({ msg }: { msg: string }) => (
23
+ <div className="fixed top-24 left-1/2 -translate-x-1/2 z-[200] animate-fade-in-down">
24
+ <div className="bg-gray-900/95 backdrop-blur text-white text-sm font-black px-6 py-3 rounded-full shadow-2xl flex items-center gap-2 border border-white/10 ring-4 ring-rose-500/10">
25
+ <CheckCircle className="w-4 h-4 text-emerald-400" />
26
+ {msg}
27
+ </div>
28
+ </div>
29
+ );
30
+
31
+ const App: React.FC = () => {
32
+ const { currentUser, updateUser } = useUserStore();
33
+ const { language, adminConfig, modals, toastMsg, setLanguage, toggleModal } = useUIStore();
34
+ const {
35
+ uploadedImages, selectedStyle, customStyleImage,
36
+ compositionMode, resolution, subjectType, customPrompt,
37
+ results, status, progress, setUploadedImages, setSelectedStyle, setCustomStyleImage,
38
+ setGenerationConfig, setStatus, setProgress, setErrorMsg, addResult, resetGeneration,
39
+ } = useGenerationStore();
40
+
41
+ const [selectedStylesForBlend, setSelectedStylesForBlend] = useState<WeddingStyle[]>([]);
42
+ const t = TRANSLATIONS[language];
43
+ const [slashingStyle, setSlashingStyle] = useState<WeddingStyle | null>(null);
44
+ const resultRef = useRef<HTMLDivElement>(null);
45
+
46
+ useEffect(() => { ipService.trackVisit(); }, []);
47
+ useEffect(() => {
48
+ const spinner = document.getElementById('loading-spinner');
49
+ if (spinner) {
50
+ spinner.style.opacity = '0';
51
+ setTimeout(() => spinner.remove(), 500);
52
+ }
53
+ }, []);
54
+
55
+ const handleStyleSelection = (style: WeddingStyle) => {
56
+ // Clear blend selections when choosing a direct single style
57
+ setSelectedStylesForBlend([]);
58
+ setSelectedStyle(style);
59
+ setCustomStyleImage(null);
60
+ };
61
+
62
+ const handleBlendSelection = (styles: WeddingStyle[]) => {
63
+ setSelectedStylesForBlend(styles);
64
+ if (styles.length > 0) {
65
+ // Clear direct single style when blend is active
66
+ setSelectedStyle(null);
67
+ setCustomStyleImage(null);
68
+ }
69
+ };
70
+
71
+ const handleGenerate = async () => {
72
+ const isBlending = selectedStylesForBlend.length === 2;
73
+ const activeStyle = isBlending
74
+ ? { id: 'blend', name: 'Hybrid Blend', prompt: selectedStylesForBlend.map(s => s.prompt), keywords: selectedStylesForBlend.map(s => s.promptKeywords || []) }
75
+ : { ...selectedStyle, keywords: selectedStyle?.promptKeywords || [] };
76
+
77
+ if (uploadedImages.length === 0 || !activeStyle) return;
78
+
79
+ setStatus('generating');
80
+ setErrorMsg(null);
81
+ setProgress({ current: 0, total: 1, statusMsg: isBlending ? "Synthesizing Hybrid Aesthetic..." : "Preparing Studio Environment..." });
82
+
83
+ if (resultRef.current) {
84
+ resultRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
85
+ }
86
+
87
+ try {
88
+ const generated = await generateStyledWeddingImage(
89
+ uploadedImages,
90
+ activeStyle.prompt,
91
+ customPrompt,
92
+ selectedStyle?.id === 'custom' ? customStyleImage : null,
93
+ compositionMode,
94
+ resolution,
95
+ subjectType,
96
+ (activeStyle as any).keywords
97
+ );
98
+
99
+ const resId = isBlending ? `blend-${Date.now()}` : activeStyle.id;
100
+ addResult({
101
+ styleId: resId,
102
+ imageUrl: generated,
103
+ timestamp: Date.now(),
104
+ config: { customInstruction: customPrompt, resolution } as any
105
+ });
106
+
107
+ setStatus('success');
108
+ setProgress(null);
109
+
110
+ // Flashy celebration
111
+ if (isBlending) {
112
+ confetti({ particleCount: 150, spread: 90, origin: { y: 0.6 }, colors: ['#f43f5e', '#fbbf24', '#ffffff'] });
113
+ }
114
+ } catch (error: any) {
115
+ setStatus('error');
116
+ setErrorMsg(error.message || "Something went wrong. Please try again.");
117
+ setProgress(null);
118
+ }
119
+ };
120
+
121
+ const isBlendModeActive = selectedStylesForBlend.length > 0;
122
+ const isBlendValid = selectedStylesForBlend.length === 2;
123
+ const canGenerate = uploadedImages.length > 0 && status !== 'generating' && (selectedStyle || isBlendValid);
124
+
125
+ return (
126
+ <div className="min-h-screen bg-[#fafafa] font-sans text-gray-900 selection:bg-rose-100 flex flex-col relative overflow-x-hidden">
127
+ {toastMsg && <Toast msg={toastMsg} />}
128
+ <FloatingConcierge language={language} config={adminConfig} />
129
+
130
+ <Header
131
+ language={language}
132
+ onLanguageChange={setLanguage}
133
+ onBookClick={() => toggleModal('consult', true)}
134
+ user={currentUser}
135
+ config={adminConfig}
136
+ onOpenAbout={() => toggleModal('about', true)}
137
+ />
138
+
139
+ <main className="max-w-7xl mx-auto px-4 sm:px-6 py-8 sm:py-16 flex-grow w-full">
140
+ <div className="text-center mb-16 max-w-3xl mx-auto space-y-4">
141
+ <div className="inline-flex items-center gap-2 px-3 py-1 bg-rose-50 text-rose-600 rounded-full text-[10px] font-black uppercase tracking-widest shadow-sm">
142
+ <Sparkles className="w-3 h-3" /> Professional AI Photography
143
+ </div>
144
+ <h2 className="font-serif text-4xl sm:text-6xl font-black text-gray-900 leading-tight">{t.heroTitle}</h2>
145
+ <p className="text-gray-500 text-lg leading-relaxed">{t.heroDesc}</p>
146
+ </div>
147
+
148
+ <div className="grid lg:grid-cols-12 gap-10 items-start">
149
+ <div className="lg:col-span-5 space-y-10">
150
+ {/* Step 1: Upload */}
151
+ <section className="space-y-5">
152
+ <div className="flex items-center justify-between">
153
+ <h3 className="text-xl font-black flex items-center gap-3">
154
+ <span className="w-10 h-10 rounded-2xl bg-gray-900 text-white flex items-center justify-center text-sm shadow-xl">1</span>
155
+ {t.step1}
156
+ </h3>
157
+ </div>
158
+ <ImageUploader onImagesSelect={setUploadedImages} currentImages={uploadedImages} disabled={status === 'generating'} language={language} />
159
+ </section>
160
+
161
+ {/* Step 2: Select Style */}
162
+ <section className={`space-y-5 transition-all duration-500 ${uploadedImages.length === 0 ? 'opacity-30 blur-sm pointer-events-none' : 'opacity-100'}`}>
163
+ <h3 className="text-xl font-black flex items-center gap-3">
164
+ <span className="w-10 h-10 rounded-2xl bg-gray-900 text-white flex items-center justify-center text-sm shadow-xl">2</span>
165
+ {t.step2}
166
+ </h3>
167
+ <StyleSelector
168
+ selectedStyle={selectedStyle}
169
+ selectedStyles={selectedStylesForBlend}
170
+ onSelect={handleStyleSelection}
171
+ onBlendSelect={handleBlendSelection}
172
+ onCustomSelect={(img) => { setCustomStyleImage(img); setSelectedStyle({ id: 'custom', prompt: 'custom', name: 'Custom' } as any); }}
173
+ onLuckySelect={() => handleStyleSelection(STYLES[Math.floor(Math.random() * STYLES.length)])}
174
+ onSlashClick={(s) => { setSlashingStyle(s); toggleModal('slash', true); }}
175
+ disabled={status === 'generating'}
176
+ language={language}
177
+ isVipUnlocked={currentUser?.isVip}
178
+ resolution={resolution}
179
+ onResolutionChange={(res) => setGenerationConfig({ resolution: res })}
180
+ />
181
+ </section>
182
+
183
+ {/* Step 3: Refine & Generate */}
184
+ <section className={`space-y-5 transition-all duration-500 ${uploadedImages.length === 0 ? 'opacity-30 blur-sm pointer-events-none' : 'opacity-100'}`}>
185
+ <h3 className="text-xl font-black flex items-center gap-3">
186
+ <span className="w-10 h-10 rounded-2xl bg-gray-900 text-white flex items-center justify-center text-sm shadow-xl">3</span>
187
+ {t.step3}
188
+ </h3>
189
+ <div className="bg-white rounded-3xl p-8 shadow-xl border border-gray-100 space-y-6">
190
+ <div className="space-y-3">
191
+ <label className="text-xs font-black text-gray-400 uppercase tracking-widest flex items-center gap-2">
192
+ <Zap className="w-3 h-3" /> Custom Instructions (Optional)
193
+ </label>
194
+ <textarea
195
+ value={customPrompt}
196
+ onChange={(e) => setGenerationConfig({ customPrompt: e.target.value })}
197
+ placeholder="e.g. Add a sunset background, make the dress more flowing..."
198
+ className="w-full px-5 py-4 rounded-2xl border border-gray-200 outline-none h-32 bg-gray-50 focus:bg-white focus:ring-4 focus:ring-rose-500/5 transition-all text-sm font-medium"
199
+ disabled={status === 'generating'}
200
+ />
201
+ </div>
202
+
203
+ <div className="space-y-4">
204
+ {status === 'generating' && progress && (
205
+ <div className="p-5 bg-rose-50 rounded-2xl border border-rose-100 animate-pulse">
206
+ <div className="flex justify-between items-center mb-3">
207
+ <span className="text-[10px] font-black text-rose-600 uppercase tracking-tighter">{progress.statusMsg}</span>
208
+ <Loader2 className="w-4 h-4 text-rose-500 animate-spin" />
209
+ </div>
210
+ <div className="h-2 w-full bg-rose-100/50 rounded-full overflow-hidden">
211
+ <div className="h-full bg-rose-500 transition-all duration-1000" style={{width: '60%'}}></div>
212
+ </div>
213
+ </div>
214
+ )}
215
+
216
+ <button
217
+ onClick={handleGenerate}
218
+ disabled={!canGenerate}
219
+ className="w-full relative overflow-hidden group flex items-center justify-center gap-3 px-8 py-5 rounded-2xl font-black text-white bg-gray-900 hover:bg-black transition-all active:scale-[0.98] disabled:opacity-30 disabled:grayscale shadow-2xl shadow-gray-200"
220
+ >
221
+ <div className="absolute inset-0 bg-gradient-to-r from-rose-500 to-orange-500 opacity-0 group-hover:opacity-10 transition-opacity"></div>
222
+ {isBlendModeActive ? <GitMerge className={`w-6 h-6 ${isBlendValid ? 'animate-pulse' : ''}`} /> : <Wand2 className="w-6 h-6" />}
223
+ <span className="uppercase tracking-widest text-sm">
224
+ {isBlendModeActive ? (isBlendValid ? "Synthesize Hybrid Style" : "Pick 2nd Style to Blend") : t.genSelected}
225
+ </span>
226
+ </button>
227
+
228
+ {/* Secondary Context Tip */}
229
+ {!isBlendValid && isBlendModeActive && (
230
+ <p className="text-[10px] text-rose-400 font-bold text-center animate-fade-in uppercase tracking-tighter">
231
+ Select one more style below to unlock Hybrid Synthesis
232
+ </p>
233
+ )}
234
+ </div>
235
+ </div>
236
+ </section>
237
+ </div>
238
+
239
+ <div className="lg:col-span-7" ref={resultRef}>
240
+ {Object.keys(results).length > 0 ? (
241
+ <ResultViewer
242
+ originalImages={uploadedImages}
243
+ results={results}
244
+ onReset={resetGeneration}
245
+ language={language}
246
+ onBookClick={() => toggleModal('consult', true)}
247
+ onShareClick={() => toggleModal('share', true)}
248
+ />
249
+ ) : (
250
+ <div className="h-full min-h-[600px] rounded-[3rem] border-4 border-dashed border-gray-100 flex flex-col items-center justify-center text-center p-12 bg-white/50 backdrop-blur-sm relative overflow-hidden">
251
+ <div className="absolute top-0 left-0 w-full h-full opacity-[0.03] pointer-events-none" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div>
252
+ <div className="w-24 h-24 bg-rose-50 rounded-full flex items-center justify-center mb-8 animate-float">
253
+ <Sparkles className="w-10 h-10 text-rose-300" />
254
+ </div>
255
+ <h3 className="text-2xl font-black text-gray-300 uppercase tracking-tighter mb-4">{t.emptyGallery}</h3>
256
+ <p className="text-gray-400 max-w-sm text-sm font-medium leading-relaxed">{t.emptyGalleryDesc}</p>
257
+ </div>
258
+ )}
259
+ </div>
260
+ </div>
261
+ </main>
262
+
263
+ <Features language={language} />
264
+ <Testimonials language={language} />
265
+
266
+ <SlashModal
267
+ isVisible={modals.slash}
268
+ onClose={() => toggleModal('slash', false)}
269
+ style={slashingStyle}
270
+ language={language}
271
+ adminConfig={adminConfig}
272
+ onUnlock={() => { if(currentUser) updateUser(currentUser.id, { isVip: true }); toggleModal('slash', false); }}
273
+ onOpenShare={() => toggleModal('share', true)}
274
+ onUpdateUser={() => {}}
275
+ />
276
+ </div>
277
+ );
278
+ };
279
+
280
+ export default App;
README.md CHANGED
@@ -1,11 +1,45 @@
1
  ---
2
- title: Ai
3
- emoji: 💻
4
  colorFrom: pink
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
- short_description: 厦门浪漫一生婚纱摄影ai试衣间
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 婚纱摄影试衣间应用,专为移动端优化,支持 PWA 离线访问。
14
+
15
+ ## 🚀 Hugging Face 部署步骤
16
+
17
+ 1. **创建 Space**: 在 Hugging Face 上创建一个新的 Space。
18
+ 2. **选择 SDK**: 选择 **Docker**。
19
+ 3. **上传文件**: 将本项目的所有文件(包括 `Dockerfile`, `nginx.conf`, `package.json` 等)上传到该 Space。
20
+ 4. **配置变量**: 在 Space 的 **Settings** 页面,添加环境变量 `API_KEY`(您的 Google Gemini API Key)。
21
+ 5. **自定义域名**: 在 Space 的 **Settings** 页面配置自定义域名 `ai.xmloveai.com`。
22
+ 6. **自动构建**: Hugging Face 会自动根据 `Dockerfile` 构建镜像并在 7860 端口运行。
23
+
24
+ ## ✨ 访问地址
25
+ 上线后可通过以下地址访问:
26
+ * **官方域名**: [https://ai.xmloveai.com](https://ai.xmloveai.com)
27
+ * **HF 镜像**: `https://huggingface.co/spaces/您的用户名/您的Space名`
28
+
29
+ ## 🛠 开发与构建
30
+ 本地开发:
31
+ ```bash
32
+ npm install
33
+ npm run dev
34
+ ```
35
+
36
+ 构建发布:
37
+ ```bash
38
+ npm run build
39
+ ```
40
+
41
+ ## 📄 架构说明
42
+ - **Frontend**: React 19 + Tailwind CSS + Lucide Icons
43
+ - **State**: Zustand (Persisted)
44
+ - **AI**: Gemini-3-flash-preview (Standard) / Gemini-3-pro-image-preview (High-res)
45
+ - **Deployment**: Docker + Nginx (Port 7860)
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,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { Component, 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 Component<Props, State> {
14
+ public state: State = {
15
+ hasError: false
16
+ };
17
+
18
+ // Explicitly declare props to satisfy strict TypeScript configurations
19
+ declare props: Readonly<Props>;
20
+
21
+ constructor(props: Props) {
22
+ super(props);
23
+ }
24
+
25
+ public static getDerivedStateFromError(error: Error): State {
26
+ return { hasError: true, error };
27
+ }
28
+
29
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
30
+ console.error('Uncaught error:', error, errorInfo);
31
+ }
32
+
33
+ public render() {
34
+ if (this.state.hasError) {
35
+ return (
36
+ <div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-4 text-center">
37
+ <div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center text-red-500 mb-4">
38
+ <AlertTriangle className="w-8 h-8" />
39
+ </div>
40
+ <h1 className="text-2xl font-bold text-gray-800 mb-2">出错了</h1>
41
+ <p className="text-gray-600 mb-6 max-w-xs">
42
+ 抱歉,系统遇到了一些问题。<br/>
43
+ <span className="text-xs text-gray-400 mt-2 block font-mono bg-gray-100 p-2 rounded">
44
+ {this.state.error?.message || 'Unknown Error'}
45
+ </span>
46
+ </p>
47
+ <button
48
+ 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"
49
+ onClick={() => window.location.reload()}
50
+ >
51
+ <RefreshCw className="w-4 h-4" /> 刷新页面
52
+ </button>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return this.props.children;
58
+ }
59
+ }
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,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
3
+ import { Download, Maximize2, X, Grid, Loader2, Video, Settings, Sparkles, Share2, ArrowLeftRight, PlayCircle, CheckCircle, Info, Quote, FolderDown } from 'lucide-react';
4
+ import { GeneratedResult, WeddingStyle, Language } from '../types';
5
+ import { STYLES } from './StyleSelector';
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
+ export const ResultViewer: React.FC<ResultViewerProps> = ({
22
+ originalImages, results, initialSelectedId, onReset, language, onBookClick, onShareClick
23
+ }) => {
24
+ const resultKeys = Object.keys(results);
25
+ const [activeId, setActiveId] = useState<string | null>(initialSelectedId || resultKeys[0]);
26
+ const [compareMode, setCompareMode] = useState(false);
27
+ const [sliderPosition, setSliderPosition] = useState(50);
28
+ const [isZipping, setIsZipping] = useState(false);
29
+ const containerRef = useRef<HTMLDivElement>(null);
30
+ const isDragging = useRef(false);
31
+ const t = TRANSLATIONS[language];
32
+
33
+ // Sync activeId if results update
34
+ useEffect(() => {
35
+ if (!activeId && resultKeys.length > 0) {
36
+ setActiveId(resultKeys[0]);
37
+ }
38
+ }, [results, activeId, resultKeys]);
39
+
40
+ const activeResult = activeId ? results[activeId] : null;
41
+
42
+ const getFounderAdvice = (styleId: string) => {
43
+ const advices: Record<string, string> = {
44
+ 'korean': language === 'zh' ? "韩式风格最能体现您的温婉,建议搭配简约珍珠项链。" : "Korean style best highlights your elegance. Pair with pearls.",
45
+ 'chinese': language === 'zh' ? "大红喜嫁色非常衬您的肤色,建议拍摄时多一些互动抓拍。" : "The wedding red really complements your skin tone. Go for candid shots.",
46
+ 'cinematic': language === 'zh' ? "这种电影感非常适合您的五官,光影层次感十足。" : "This cinematic vibe suits your features perfectly, full of depth."
47
+ };
48
+ if (styleId.startsWith('blend')) {
49
+ return language === 'zh' ? "混搭风格展现了您的独特个性,建议妆造保持自然通透。" : "Hybrid styles showcase your unique personality. Keep the makeup natural and sheer.";
50
+ }
51
+ return advices[styleId] || (language === 'zh' ? "这一套造型非常惊艳,完美展现了您的气质。" : "This look is stunning and fits your aura perfectly.");
52
+ };
53
+
54
+ const updateSlider = (clientX: number) => {
55
+ if (!containerRef.current) return;
56
+ const rect = containerRef.current.getBoundingClientRect();
57
+ const pos = ((clientX - rect.left) / rect.width) * 100;
58
+ setSliderPosition(Math.max(0, Math.min(100, pos)));
59
+ };
60
+
61
+ const handleMouseMove = (e: any) => {
62
+ if (!isDragging.current) return;
63
+ const clientX = e.clientX || e.touches?.[0]?.clientX;
64
+ updateSlider(clientX);
65
+ };
66
+
67
+ const handleDownloadAll = async () => {
68
+ if (Object.keys(results).length === 0) return;
69
+ setIsZipping(true);
70
+ try {
71
+ const zip = new JSZip();
72
+ const timestamp = new Date().getTime();
73
+
74
+ const promises = Object.entries(results).map(async ([id, res], index) => {
75
+ const resultItem = res as GeneratedResult;
76
+ // Extract base64 part
77
+ const base64Data = resultItem.imageUrl.split(',')[1];
78
+ const fileName = `RomanticLife_${id}_${index + 1}.png`;
79
+ zip.file(fileName, base64Data, { base64: true });
80
+ });
81
+
82
+ await Promise.all(promises);
83
+ const content = await zip.generateAsync({ type: "blob" });
84
+ const url = URL.createObjectURL(content);
85
+ const link = document.createElement('a');
86
+ link.href = url;
87
+ link.download = `RomanticLife_Wedding_Album_${timestamp}.zip`;
88
+ document.body.appendChild(link);
89
+ link.click();
90
+ document.body.removeChild(link);
91
+ URL.revokeObjectURL(url);
92
+ } catch (error) {
93
+ console.error("ZIP Generation failed:", error);
94
+ alert("Failed to generate ZIP. Please try individual downloads.");
95
+ } finally {
96
+ setIsZipping(false);
97
+ }
98
+ };
99
+
100
+ if (!activeResult) return null;
101
+
102
+ return (
103
+ <div className="flex flex-col h-full gap-4 animate-fade-in">
104
+ {/* 沉浸式展示区 */}
105
+ <div
106
+ ref={containerRef}
107
+ className="relative flex-1 bg-zinc-900 rounded-3xl border border-zinc-800 overflow-hidden shadow-2xl min-h-[500px]"
108
+ onMouseDown={() => { isDragging.current = true; }}
109
+ onMouseUp={() => { isDragging.current = false; }}
110
+ onMouseMove={handleMouseMove}
111
+ onTouchMove={handleMouseMove}
112
+ onTouchStart={() => { isDragging.current = true; }}
113
+ onTouchEnd={() => { isDragging.current = false; }}
114
+ >
115
+ <img src={originalImages[0]} className="absolute inset-0 w-full h-full object-contain p-4 opacity-50 blur-sm" alt="original" />
116
+
117
+ <img
118
+ src={activeResult.imageUrl}
119
+ className="absolute inset-0 w-full h-full object-contain p-4 z-10"
120
+ alt="result"
121
+ style={compareMode ? { clipPath: `inset(0 0 0 ${sliderPosition}%)` } : {}}
122
+ />
123
+
124
+ {compareMode && (
125
+ <>
126
+ <img
127
+ src={originalImages[0]}
128
+ className="absolute inset-0 w-full h-full object-contain p-4 z-10"
129
+ alt="original-overlay"
130
+ style={{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }}
131
+ />
132
+ <div className="absolute top-0 bottom-0 z-20 w-1 bg-white/50 cursor-ew-resize" style={{ left: `${sliderPosition}%` }}>
133
+ <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-zinc-900">
134
+ <ArrowLeftRight className="w-4 h-4" />
135
+ </div>
136
+ </div>
137
+ </>
138
+ )}
139
+
140
+ <div className="absolute bottom-6 left-6 right-6 z-30 flex justify-between items-end pointer-events-none">
141
+ <div className="bg-black/40 backdrop-blur-md p-4 rounded-2xl border border-white/10 max-w-[70%] pointer-events-auto">
142
+ <div className="flex items-center gap-2 mb-1 text-rose-400">
143
+ <Quote className="w-4 h-4" />
144
+ <span className="text-[10px] font-bold uppercase tracking-widest">{t.aboutUsBtn}</span>
145
+ </div>
146
+ <p className="text-white text-xs leading-relaxed italic">
147
+ "{getFounderAdvice(activeId!)}"
148
+ </p>
149
+ </div>
150
+
151
+ <div className="flex flex-col gap-2 pointer-events-auto">
152
+ <button
153
+ onClick={() => setCompareMode(!compareMode)}
154
+ className={`p-3 rounded-full backdrop-blur-md transition-all ${compareMode ? 'bg-rose-500 text-white' : 'bg-white/10 text-white border border-white/20'}`}
155
+ >
156
+ <ArrowLeftRight className="w-5 h-5" />
157
+ </button>
158
+ <button onClick={onShareClick} className="p-3 bg-white/10 backdrop-blur-md text-white rounded-full border border-white/20">
159
+ <Share2 className="w-5 h-5" />
160
+ </button>
161
+ </div>
162
+ </div>
163
+
164
+ <div className="absolute top-6 left-6 z-30 pointer-events-none opacity-30">
165
+ <p className="text-white font-serif tracking-widest text-lg uppercase">{t.watermark}</p>
166
+ </div>
167
+ </div>
168
+
169
+ {/* 底部交互 */}
170
+ <div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100 flex flex-col sm:flex-row items-center justify-between gap-4">
171
+ <div className="flex -space-x-2 overflow-hidden py-1">
172
+ {Object.keys(results).map(id => (
173
+ <button
174
+ key={id}
175
+ onClick={() => setActiveId(id)}
176
+ className={`w-12 h-12 rounded-lg border-2 transition-all overflow-hidden shrink-0 ${activeId === id ? 'border-rose-500 scale-110 z-10 shadow-lg' : 'border-transparent opacity-50 hover:opacity-100'}`}
177
+ >
178
+ <img src={(results[id] as GeneratedResult).imageUrl} className="w-full h-full object-cover" alt="thumb" />
179
+ </button>
180
+ ))}
181
+ </div>
182
+
183
+ <div className="flex gap-2 w-full sm:w-auto">
184
+ <button
185
+ onClick={handleDownloadAll}
186
+ disabled={isZipping || resultKeys.length === 0}
187
+ className="flex-1 sm:flex-none px-4 py-2.5 bg-gray-100 text-gray-800 rounded-xl text-xs font-black transition-all flex items-center justify-center gap-2 hover:bg-gray-200 active:scale-95 disabled:opacity-50"
188
+ >
189
+ {isZipping ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderDown className="w-4 h-4" />}
190
+ <span>{isZipping ? "Packing..." : (t.zipBtn || "Download All ZIP")}</span>
191
+ </button>
192
+ <button
193
+ onClick={onBookClick}
194
+ className="flex-1 sm:flex-none px-6 py-2.5 bg-rose-600 text-white rounded-xl text-xs font-black hover:bg-rose-700 shadow-lg shadow-rose-200 transition-all flex items-center justify-center gap-2 active:scale-95"
195
+ >
196
+ <CheckCircle className="w-4 h-4" />
197
+ <span>{t.bookStyle}</span>
198
+ </button>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ );
203
+ };
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,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useRef, useState, useMemo } from 'react';
3
+ import { WeddingStyle, Language, Resolution } from '../types';
4
+ import { TRANSLATIONS } from '../constants/translations';
5
+ import { useUserStore } from '../store';
6
+ import {
7
+ Heart, Crown, Leaf, Sparkles, Film, Sun, Star, Aperture,
8
+ Upload, Cpu, Clock, Lock, Search, Check, Loader2, GitMerge,
9
+ Dice5, Wand2
10
+ } from 'lucide-react';
11
+
12
+ interface StyleSelectorProps {
13
+ selectedStyle: WeddingStyle | null;
14
+ selectedStyles?: WeddingStyle[];
15
+ onSelect: (style: WeddingStyle) => void;
16
+ onCustomSelect: (image: string) => void;
17
+ onLuckySelect: () => void;
18
+ onSlashClick: (style: WeddingStyle) => void;
19
+ disabled: boolean;
20
+ language: Language;
21
+ recommendedStyleIds?: string[];
22
+ isVipUnlocked?: boolean;
23
+ isLoading?: boolean;
24
+ resolution: Resolution;
25
+ onResolutionChange: (res: Resolution) => void;
26
+ onBlendSelect?: (styles: WeddingStyle[]) => void;
27
+ }
28
+
29
+ const LazyStyleBackground = ({ src, colorClass }: { src?: string, colorClass: string }) => {
30
+ const [loaded, setLoaded] = useState(false);
31
+ const [error, setError] = useState(false);
32
+ if (!src || error) {
33
+ return <div className={`absolute inset-0 opacity-40 ${colorClass} transition-opacity group-hover:opacity-60`} aria-hidden="true" />;
34
+ }
35
+ return (
36
+ <>
37
+ {!loaded && (
38
+ <div className={`absolute inset-0 ${colorClass} flex items-center justify-center z-10`} aria-hidden="true">
39
+ <Loader2 className="w-5 h-5 text-gray-400/50 animate-spin" />
40
+ </div>
41
+ )}
42
+ <img
43
+ src={src}
44
+ alt=""
45
+ loading="lazy"
46
+ onLoad={() => setLoaded(true)}
47
+ onError={() => setError(true)}
48
+ className={`absolute inset-0 w-full h-full object-cover transition-all duration-700 ${loaded ? 'opacity-30 group-hover:opacity-50 scale-100' : 'opacity-0 scale-110'}`}
49
+ aria-hidden="true"
50
+ />
51
+ </>
52
+ );
53
+ };
54
+
55
+ export const STYLES: WeddingStyle[] = [
56
+ {
57
+ id: 'korean',
58
+ name: '韩式 (Korean)',
59
+ 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.',
60
+ promptKeywords: ['minimalist', 'sweet', 'soft lighting', 'mermaid gown', 'K-drama', 'clean background', 'elegant'],
61
+ description: 'Minimalist, sweet, and elegant.',
62
+ coverColor: 'bg-rose-50',
63
+ previewImage: 'https://images.unsplash.com/photo-1520854221256-17451cc330e7?auto=format&fit=crop&w=400&q=60',
64
+ icon: <Heart className="w-5 h-5 text-rose-400" aria-hidden="true" />,
65
+ tags: ['hot'],
66
+ isLocked: true
67
+ },
68
+ {
69
+ id: 'chinese',
70
+ name: '中国风 (Chinese)',
71
+ prompt: 'Modern Chinese wedding style, luxurious red embroidery, traditional elements mixed with modern aesthetics, fan, porcelain, red background, festive and grand.',
72
+ promptKeywords: ['red', 'gold embroidery', 'oriental', 'traditional', 'festive', 'luxury', 'porcelain aesthetics'],
73
+ description: 'Modern red aesthetics and embroidery.',
74
+ coverColor: 'bg-red-50',
75
+ previewImage: 'https://images.unsplash.com/photo-1588667822949-a034d612df08?auto=format&fit=crop&w=400&q=60',
76
+ icon: <Leaf className="w-5 h-5 text-red-600" aria-hidden="true" />,
77
+ tags: ['hot'],
78
+ isLocked: true
79
+ },
80
+ {
81
+ id: 'cinematic',
82
+ name: '电影感 (Cinematic)',
83
+ prompt: 'Cinematic movie still, Wong Kar-wai style, moody lighting, strong shadows, emotional storytelling, color graded, wide aspect ratio composition feeling.',
84
+ promptKeywords: ['moody', 'high contrast', 'storytelling', 'noir', 'atmospheric', 'Wong Kar-wai', 'color graded'],
85
+ description: 'Moody, storytelling movie stills.',
86
+ coverColor: 'bg-gray-100',
87
+ previewImage: 'https://images.unsplash.com/photo-1470163395405-d2b80e7450ed?auto=format&fit=crop&w=400&q=60',
88
+ icon: <Film className="w-5 h-5 text-gray-700" aria-hidden="true" />,
89
+ tags: ['recommend']
90
+ },
91
+ {
92
+ id: 'british',
93
+ name: '英伦 (British)',
94
+ 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.',
95
+ promptKeywords: ['royal', 'lace', 'aristocratic', 'manor', 'vintage', 'noble', 'high society'],
96
+ description: 'Aristocratic, vintage manor vibes.',
97
+ coverColor: 'bg-blue-50',
98
+ previewImage: 'https://images.unsplash.com/photo-1505944270255-72b8c68c6a70?auto=format&fit=crop&w=400&q=60',
99
+ icon: <Crown className="w-5 h-5 text-blue-600" aria-hidden="true" />
100
+ },
101
+ {
102
+ id: 'japanese',
103
+ name: '日系 (Japanese)',
104
+ 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.',
105
+ promptKeywords: ['airy', 'soft focus', 'pure', 'fresh', 'minimalist', 'nature', 'bright'],
106
+ description: 'Bright, airy, and emotional.',
107
+ coverColor: 'bg-sky-50',
108
+ previewImage: 'https://images.unsplash.com/photo-1492596985094-52771095ad79?auto=format&fit=crop&w=400&q=60',
109
+ icon: <Sun className="w-5 h-5 text-sky-400" aria-hidden="true" />
110
+ },
111
+ {
112
+ id: 'fairytale',
113
+ name: '童话 (Fairy Tale)',
114
+ prompt: 'Disney fairy tale style, magical castle, glowing lights, cinderella dress, sparkles, pumpkin carriage, dreamy blue and purple tones.',
115
+ promptKeywords: ['magical', 'fantasy', 'glow', 'dreamy', 'royal', 'Disney-like', 'sparkles'],
116
+ description: 'Magical castles and dreams.',
117
+ coverColor: 'bg-purple-100',
118
+ previewImage: 'https://images.unsplash.com/photo-1520024146169-3240400354ae?auto=format&fit=crop&w=400&q=60',
119
+ icon: <Sparkles className="w-5 h-5 text-purple-600" aria-hidden="true" />
120
+ },
121
+ {
122
+ id: 'retro',
123
+ name: '复古 (Retro)',
124
+ prompt: '1980s or 1990s retro hong kong style, warm yellowish tint, vintage disco vibe, sequins, puffy sleeves, nostalgic romance.',
125
+ promptKeywords: ['vintage', 'nostalgic', '80s', '90s', 'sequins', 'HK style', 'warm tint'],
126
+ description: '80s/90s nostalgic vibes.',
127
+ coverColor: 'bg-orange-100',
128
+ previewImage: 'https://images.unsplash.com/photo-1551024601-56296352630a?auto=format&fit=crop&w=400&q=60',
129
+ icon: <Clock className="w-5 h-5 text-orange-700" aria-hidden="true" />,
130
+ tags: ['hot']
131
+ },
132
+ {
133
+ id: 'minimalist',
134
+ name: '简约 (Minimalist)',
135
+ prompt: 'Minimalist wedding, clean lines, plenty of negative space, monochromatic color palette, simple but high-quality silk dress, sophisticated simplicity.',
136
+ promptKeywords: ['clean', 'silk', 'sophisticated', 'monochrome', 'negative space', 'modern', 'understated'],
137
+ description: 'Less is more, sophisticated.',
138
+ coverColor: 'bg-stone-50',
139
+ previewImage: 'https://images.unsplash.com/photo-1445633475854-60c07d57cf84?auto=format&fit=crop&w=400&q=60',
140
+ icon: <Aperture className="w-5 h-5 text-stone-500" aria-hidden="true" />
141
+ },
142
+ {
143
+ id: 'cyberpunk',
144
+ name: '赛博朋克 (Cyberpunk)',
145
+ prompt: 'Cyberpunk wedding style, neon lights, night city, rain, futuristic techwear mixed with wedding attire, glowing accessories, blue and pink lighting, Blade Runner aesthetic.',
146
+ promptKeywords: ['neon', 'futuristic', 'high-tech', 'night city', 'glow', 'synthetic', 'urban'],
147
+ description: 'Neon, high-tech, futuristic city.',
148
+ coverColor: 'bg-cyan-100',
149
+ previewImage: 'https://images.unsplash.com/photo-1535295972055-1c762f4483e5?auto=format&fit=crop&w=400&q=60',
150
+ icon: <Cpu className="w-5 h-5 text-cyan-600" aria-hidden="true" />,
151
+ tags: ['new'],
152
+ isLocked: true
153
+ }
154
+ ];
155
+
156
+ export const StyleSelector: React.FC<StyleSelectorProps> = ({
157
+ selectedStyle,
158
+ selectedStyles = [],
159
+ onSelect,
160
+ onCustomSelect,
161
+ onLuckySelect,
162
+ onSlashClick,
163
+ disabled,
164
+ language,
165
+ recommendedStyleIds,
166
+ isVipUnlocked,
167
+ resolution,
168
+ onResolutionChange,
169
+ onBlendSelect
170
+ }) => {
171
+ const t = TRANSLATIONS[language];
172
+ const customFileInputRef = useRef<HTMLInputElement>(null);
173
+ const [searchTerm, setSearchTerm] = useState('');
174
+ const { currentUser, guestFavorites, toggleFavorite } = useUserStore();
175
+ const favorites = currentUser ? (currentUser.favorites || []) : guestFavorites;
176
+
177
+ const filteredStyles = useMemo(() => {
178
+ return STYLES.filter(style => {
179
+ const name = (t.styles as any)[style.id] || style.name;
180
+ const term = searchTerm.toLowerCase();
181
+ return name.toLowerCase().includes(term) || style.description.toLowerCase().includes(term);
182
+ });
183
+ }, [searchTerm, language, t.styles]);
184
+
185
+ const handleCustomStyleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
186
+ const file = e.target.files?.[0];
187
+ if (file) {
188
+ const reader = new FileReader();
189
+ reader.onloadend = () => {
190
+ onCustomSelect(reader.result as string);
191
+ };
192
+ reader.readAsDataURL(file);
193
+ }
194
+ };
195
+
196
+ const handleLuckyDip = () => {
197
+ if (disabled) return;
198
+ const randomIndex = Math.floor(Math.random() * STYLES.length);
199
+ const randomStyle = STYLES[randomIndex];
200
+ onSelect(randomStyle);
201
+ onLuckySelect();
202
+ };
203
+
204
+ const handleStyleInteraction = (style: WeddingStyle) => {
205
+ if (disabled) return;
206
+ const isLocked = style.isLocked && !isVipUnlocked;
207
+ if (isLocked && currentUser?.role !== 'admin') {
208
+ onSlashClick(style);
209
+ return;
210
+ }
211
+
212
+ if (onBlendSelect) {
213
+ const existsIdx = selectedStyles.findIndex(s => s.id === style.id);
214
+ if (existsIdx !== -1) {
215
+ // Toggle off if already selected in blend
216
+ onBlendSelect(selectedStyles.filter(s => s.id !== style.id));
217
+ } else {
218
+ // Selection logic for blend: limit to 2
219
+ if (selectedStyles.length < 2) {
220
+ onBlendSelect([...selectedStyles, style]);
221
+ } else {
222
+ // Replace second if already have two
223
+ onBlendSelect([selectedStyles[0], style]);
224
+ }
225
+ }
226
+ } else {
227
+ onSelect(style);
228
+ }
229
+ };
230
+
231
+ const isBlendReady = selectedStyles.length === 2;
232
+
233
+ return (
234
+ <div className="space-y-6 relative">
235
+ {/* Toolbar */}
236
+ <div className="flex flex-col sm:flex-row gap-3">
237
+ <div className="relative flex-1">
238
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
239
+ <input
240
+ type="text"
241
+ placeholder={t.styleSearchPlace || "Search styles..."}
242
+ value={searchTerm}
243
+ onChange={e => setSearchTerm(e.target.value)}
244
+ className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 outline-none text-sm transition-all focus:bg-white focus:ring-2 focus:ring-rose-100"
245
+ disabled={disabled}
246
+ />
247
+ </div>
248
+
249
+ <div className="flex bg-gray-100 p-1 rounded-xl shrink-0">
250
+ {['standard', 'high', 'ultra'].map((res) => (
251
+ <button
252
+ key={res}
253
+ onClick={() => onResolutionChange(res as Resolution)}
254
+ className={`px-3 py-1.5 rounded-lg text-[10px] font-black transition-all ${resolution === res ? 'bg-white shadow text-rose-600' : 'text-gray-500'}`}
255
+ >
256
+ {res === 'standard' ? 'HD' : res === 'high' ? '4K' : '8K'}
257
+ </button>
258
+ ))}
259
+ </div>
260
+ </div>
261
+
262
+ {/* Quick Actions & Blender Status */}
263
+ <ul className="grid grid-cols-2 sm:grid-cols-4 gap-3">
264
+ <li>
265
+ <button
266
+ onClick={() => customFileInputRef.current?.click()}
267
+ disabled={disabled}
268
+ className={`w-full h-full group flex flex-col items-center justify-center p-3 rounded-xl border-2 border-dashed transition-all ${selectedStyle?.id === 'custom' ? 'border-rose-500 bg-rose-50 text-rose-600' : 'border-gray-200 hover:border-rose-300 hover:bg-rose-50'}`}
269
+ >
270
+ <div className="relative">
271
+ <Upload className={`w-4 h-4 mb-1 ${selectedStyle?.id === 'custom' ? 'text-rose-600' : 'text-rose-500'}`} />
272
+ {selectedStyle?.id === 'custom' && (
273
+ <div className="absolute -top-1 -right-1 w-2 h-2 bg-rose-600 rounded-full border border-white"></div>
274
+ )}
275
+ </div>
276
+ <span className="text-[10px] font-bold text-gray-700">{t.customStyle}</span>
277
+ <input
278
+ type="file"
279
+ accept="image/*"
280
+ className="hidden"
281
+ ref={customFileInputRef}
282
+ onChange={handleCustomStyleUpload}
283
+ />
284
+ </button>
285
+ </li>
286
+ <li>
287
+ <button
288
+ onClick={handleLuckyDip}
289
+ disabled={disabled}
290
+ className="w-full h-full group flex flex-col items-center justify-center p-3 rounded-xl border border-gray-100 hover:border-amber-200 bg-white transition-all shadow-sm active:scale-95"
291
+ >
292
+ <Dice5 className="w-4 h-4 text-amber-500 mb-1 animate-in fade-in zoom-in" />
293
+ <span className="text-[10px] font-bold text-gray-700">{t.luckyStyle}</span>
294
+ </button>
295
+ </li>
296
+ <li className="col-span-2">
297
+ <div className={`w-full h-full rounded-xl border p-3 flex items-center justify-between transition-all ${isBlendReady ? 'bg-rose-600 border-rose-500 shadow-lg shadow-rose-200' : 'bg-rose-50/50 border-rose-100'}`}>
298
+ <div className="flex flex-col">
299
+ <div className="flex items-center gap-1.5">
300
+ <GitMerge className={`w-4 h-4 ${isBlendReady ? 'text-white' : 'text-rose-600'}`} />
301
+ <span className={`text-[11px] font-black uppercase tracking-wider ${isBlendReady ? 'text-white' : 'text-rose-900'}`}>Style Blender</span>
302
+ </div>
303
+ <span className={`text-[9px] font-bold ${isBlendReady ? 'text-rose-100' : 'text-rose-400'}`}>
304
+ {isBlendReady ? "Ready to synthesize" : "Pick 2 to blend aesthetics"}
305
+ </span>
306
+ </div>
307
+ <div className="flex gap-1.5">
308
+ {[0, 1].map(i => (
309
+ <div key={i} className={`w-9 h-9 rounded-lg border-2 flex items-center justify-center text-xs font-black transition-all duration-500 ${selectedStyles[i] ? (isBlendReady ? 'border-white bg-white text-rose-600' : 'border-rose-500 bg-rose-500 text-white shadow-lg') : 'border-rose-200 border-dashed bg-white text-rose-200'}`}>
310
+ {selectedStyles[i] ? (i === 0 ? 'A' : 'B') : '+'}
311
+ </div>
312
+ ))}
313
+ </div>
314
+ </div>
315
+ </li>
316
+ </ul>
317
+
318
+ {/* Style Grid */}
319
+ <section className="space-y-4">
320
+ <ul className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 max-h-[550px] overflow-y-auto pr-2 custom-scrollbar pb-8">
321
+ {filteredStyles.map(style => {
322
+ const blendIdx = selectedStyles.findIndex(s => s.id === style.id);
323
+ const isSelectedInBlend = blendIdx !== -1;
324
+ const isDirectSelected = selectedStyle?.id === style.id;
325
+ const isSelected = isDirectSelected || isSelectedInBlend;
326
+
327
+ const isLocked = style.isLocked && !isVipUnlocked;
328
+ const name = (t.styles as any)[style.id] || style.name;
329
+ const isFav = favorites.includes(style.id);
330
+
331
+ return (
332
+ <li key={style.id}>
333
+ <button
334
+ onClick={() => handleStyleInteraction(style)}
335
+ disabled={disabled}
336
+ className={`
337
+ w-full group relative aspect-[4/5] rounded-2xl overflow-hidden text-left transition-all duration-500
338
+ ${isSelected ? 'ring-4 ring-rose-500 ring-offset-2 scale-[1.03] z-10 shadow-2xl' : 'hover:scale-[1.02] shadow-sm'}
339
+ ${isLocked ? 'cursor-not-allowed grayscale-[0.5]' : 'cursor-pointer'}
340
+ `}
341
+ >
342
+ <LazyStyleBackground src={style.previewImage} colorClass={style.coverColor} />
343
+
344
+ {/* Status Overlays */}
345
+ <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent p-4 flex flex-col justify-end">
346
+ <div className="absolute top-3 left-3 flex flex-col gap-1.5">
347
+ {style.tags?.includes('hot') && (
348
+ <span className="px-2 py-1 bg-red-600 text-white text-[8px] font-black rounded-full shadow-lg flex items-center gap-1 w-fit uppercase">
349
+ <Sparkles className="w-2.5 h-2.5" /> Hot
350
+ </span>
351
+ )}
352
+ {recommendedStyleIds?.includes(style.id) && (
353
+ <span className="px-2 py-1 bg-emerald-500 text-white text-[8px] font-black rounded-full shadow-lg flex items-center gap-1 w-fit uppercase animate-pulse">
354
+ <Check className="w-2.5 h-2.5" /> Best Match
355
+ </span>
356
+ )}
357
+ </div>
358
+
359
+ <div className="absolute top-3 right-3 flex gap-2">
360
+ <button
361
+ onClick={(e) => { e.stopPropagation(); toggleFavorite(style.id); }}
362
+ className={`p-2 rounded-full backdrop-blur-md transition-all ${isFav ? 'bg-yellow-400 text-white scale-110 shadow-lg' : 'bg-black/30 text-white hover:bg-white hover:text-rose-500'}`}
363
+ >
364
+ <Star className={`w-3.5 h-3.5 ${isFav ? 'fill-current' : ''}`} />
365
+ </button>
366
+ {isLocked && <div className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white"><Lock className="w-3.5 h-3.5" /></div>}
367
+
368
+ {/* Selection Marker */}
369
+ {isSelected && (
370
+ <div className="p-2 bg-rose-600 text-white rounded-full shadow-lg animate-fade-in-down font-black text-[10px] w-8 h-8 flex items-center justify-center">
371
+ {isSelectedInBlend ? (blendIdx === 0 ? 'A' : 'B') : <Check className="w-4 h-4" />}
372
+ </div>
373
+ )}
374
+ </div>
375
+
376
+ <div className="space-y-0.5">
377
+ <h3 className="font-black text-white text-sm tracking-tight drop-shadow-lg">{name}</h3>
378
+ <p className="text-[10px] text-gray-300 font-medium line-clamp-1">{style.description}</p>
379
+ </div>
380
+ </div>
381
+ </button>
382
+ </li>
383
+ );
384
+ })}
385
+ </ul>
386
+ </section>
387
+ </div>
388
+ );
389
+ };
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,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import { Language } from '../types';
3
+
4
+ const en = {
5
+ title: "Romantic Life",
6
+ subtitle: "AI Wedding Fitting Studio",
7
+ heroTitle: "Preview Your Perfection",
8
+ heroDesc: "The founder's pick: Try 100+ premier wedding styles instantly with our professional AI photography engine.",
9
+ // ... rest of en translations (shortened for brevity but keeping structure)
10
+ bookStyle: "Book This Style",
11
+ watermark: "Xiamen Romantic Life Photography",
12
+ zipBtn: "Save All Previews",
13
+ aboutUsBtn: "Founder's Advice",
14
+ styles: {
15
+ korean: "Korean Minimalist",
16
+ chinese: "Chinese Traditional",
17
+ cinematic: "Cinematic Mood",
18
+ // ...
19
+ }
20
+ } as any;
21
+
22
+ const zh = {
23
+ title: "厦门浪漫一生",
24
+ subtitle: "创始级 AI 婚纱试衣间",
25
+ heroTitle: "在线试衣 · 遇见最美的你",
26
+ heroDesc: "创始人亲自精选 100+ 款热门婚纱风格,搭载影楼级 AI 引擎,让您在拍摄前精准锁定心仪造型。",
27
+ bookStyle: "预约拍摄此风格",
28
+ watermark: "浪漫一生 · 匠心摄影",
29
+ zipBtn: "保存电子相册",
30
+ aboutUsBtn: "创始人点评",
31
+ emptyGallery: "等待开启浪漫之旅",
32
+ emptyGalleryDesc: "上传照片后,我将为您精准匹配最佳的婚纱方案。",
33
+ step1: "上传您的影像",
34
+ step2: "挑选心动风格",
35
+ step3: "定制专属细节",
36
+ styles: {
37
+ korean: "韩式极简主义",
38
+ chinese: "中式传统喜嫁",
39
+ cinematic: "电影叙事感",
40
+ // ...
41
+ }
42
+ } as any;
43
+
44
+ export const TRANSLATIONS: Record<Language, any> = {
45
+ en, zh, ja: en, ko: en, es: en, fr: en
46
+ };
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
+ "@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2",
19
+ "react-dom/": "https://esm.sh/react-dom@^19.2.3/",
20
+ "react/": "https://esm.sh/react@^19.2.3/",
21
+ "react": "https://esm.sh/react@^19.2.3",
22
+ "@google/genai": "https://esm.sh/@google/genai@^1.33.0",
23
+ "lucide-react": "https://esm.sh/lucide-react@^0.560.0",
24
+ "canvas-confetti": "https://esm.sh/canvas-confetti@^1.9.4",
25
+ "zustand/": "https://esm.sh/zustand@^5.0.9/",
26
+ "zustand": "https://esm.sh/zustand@^5.0.9",
27
+ "jszip": "https://esm.sh/jszip@^3.10.1",
28
+ "vite": "https://esm.sh/vite@^7.2.7"
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,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Romantic Life - AI Wedding Fitting Room",
3
+ "description": "Xiamen Romantic Life Wedding Photography AI Fitting Room (ai.xmloveai.com). Try different wedding styles instantly and book your photoshoot.",
4
+ "requestFramePermissions": [
5
+ "camera"
6
+ ]
7
+ }
nginx.conf ADDED
@@ -0,0 +1 @@
 
 
1
+ ���z
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
+ }