Upload 75 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- APP_DEVELOPMENT.md +188 -0
- App.tsx +280 -0
- README.md +39 -5
- components/._AboutModal.tsx +0 -0
- components/._AdminDashboard.tsx +0 -0
- components/._AnalysisModal.tsx +0 -0
- components/._AuthModal.tsx +0 -0
- components/._ErrorBoundary.tsx +0 -0
- components/._ExitIntentModal.tsx +0 -0
- components/._Features.tsx +0 -0
- components/._FeedbackModal.tsx +0 -0
- components/._FilterSelector.tsx +0 -0
- components/._FloatingConcierge.tsx +0 -0
- components/._Header.tsx +0 -0
- components/._ImageUploader.tsx +0 -0
- components/._RedPacketModal.tsx +0 -0
- components/._ResultViewer.tsx +0 -0
- components/._ShareModal.tsx +0 -0
- components/._SlashModal.tsx +0 -0
- components/._StyleSelector.tsx +0 -0
- components/._Testimonials.tsx +0 -0
- components/._UserCenter.tsx +0 -0
- components/AboutModal.tsx +103 -0
- components/AdminDashboard.tsx +360 -0
- components/AnalysisModal.tsx +86 -0
- components/AuthModal.tsx +242 -0
- components/ErrorBoundary.tsx +59 -0
- components/ExitIntentModal.tsx +78 -0
- components/Features.tsx +60 -0
- components/FeedbackModal.tsx +146 -0
- components/FilterSelector.tsx +102 -0
- components/FloatingConcierge.tsx +82 -0
- components/Header.tsx +139 -0
- components/ImageUploader.tsx +128 -0
- components/RedPacketModal.tsx +135 -0
- components/ResultViewer.tsx +203 -0
- components/ShareModal.tsx +143 -0
- components/SlashModal.tsx +135 -0
- components/StyleSelector.tsx +389 -0
- components/Testimonials.tsx +72 -0
- components/UserCenter.tsx +232 -0
- constants/._translations.ts +0 -0
- constants/translations.ts +46 -0
- index.css +48 -0
- index.html +45 -0
- index.tsx +32 -0
- manifest.json +24 -0
- metadata.json +7 -0
- nginx.conf +1 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: pink
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Romantic Life - AI Wedding Fitting Room
|
| 3 |
+
emoji: 👰♀️
|
| 4 |
colorFrom: pink
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# 厦门浪漫一生 - AI 婚纱试衣间 (Romantic Life AI)
|
| 12 |
+
|
| 13 |
+
本项目是一个基于 React + Vite + Google Gemini API 的 AI 婚纱摄影试衣间应用,专为移动端优化,支持 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 |
+
}
|