diff --git a/ANALYSIS_REPORT.md b/ANALYSIS_REPORT.md deleted file mode 100644 index 806b9aa..0000000 --- a/ANALYSIS_REPORT.md +++ /dev/null @@ -1,659 +0,0 @@ -# 🔍 ГЛУБОКИЙ АНАЛИЗ ПРОЕКТА - НАЙДЕННЫЕ ПРОБЛЕМЫ - -## 📊 ОБЩАЯ СТАТИСТИКА: -- **Всего строк кода:** ~21,000 -- **Тестов:** 0 (!) -- **Самые большие файлы:** 692, 617, 515 строк -- **Console.log/error:** 21 файлов -- **Process.env usage:** 7 файлов - ---- - -## 🔴 КРИТИЧЕСКИЕ ПРОБЛЕМЫ: - -### 1. ❌ ПОЛНОЕ ОТСУТСТВИЕ ТЕСТОВ -**Статус:** 🔴 КРИТИЧНО - -```bash -# Найдено тестов: 0 -find src -name "*.test.ts" -o -name "*.test.tsx" | wc -l -# Output: 0 -``` - -**Проблема:** -- Нет unit тестов -- Нет integration тестов -- Нет e2e тестов -- 21,000 строк кода без покрытия - -**Риски:** -- ❌ Регрессии не обнаруживаются -- ❌ Рефакторинг опасен -- ❌ Сложно онбординг новых разработчиков -- ❌ Баги попадают в production - -**Рекомендации:** -```typescript -// Приоритет 1: Критичная логика -src/lib/funnel/navigation.ts // 🔴 Условная навигация -src/lib/admin/builder/validation.ts // 🔴 Валидация воронок -src/lib/funnel/screenRenderer.tsx // 🔴 Рендеринг экранов - -// Приоритет 2: API endpoints -src/app/api/**/*.ts // 🟡 Все API routes - -// Приоритет 3: UI компоненты -src/components/funnel/templates/** // 🟢 Templates -``` - ---- - -### 2. 🔴 МОНСТР-ФАЙЛЫ НЕ РАЗБИТЫ - -**Топ-3 проблемных файла:** - -#### **ScreenVariantsConfig.tsx - 692 строки** -``` -Функции: -- ensureCondition -- VariantOverridesEditor -- ScreenVariantsConfig -- Множество внутренней логики - -Должно быть разбито на: -├── hooks/ -│ ├── useVariantState.ts -│ └── useVariantValidation.ts -├── components/ -│ ├── VariantConditionEditor.tsx -│ ├── VariantOverridesEditor.tsx -│ ├── VariantList.tsx -│ └── VariantPanel.tsx -└── ScreenVariantsConfig.tsx (orchestrator) -``` - -#### **BuilderSidebar.tsx - 617 строк** -``` -Проблема: Всё в одном файле -- Funnel settings -- Screen settings -- Navigation -- Variants -- Validation - -Решение: Уже созданы модули, но НЕ ИСПОЛЬЗУЮТСЯ! -✅ FunnelSettingsPanel.tsx (80 строк) -✅ ScreenSettingsPanel.tsx (110 строк) -✅ NavigationPanel.tsx (190 строк) - -❌ Но BuilderSidebar всё еще 617 строк! -``` - -#### **TemplateConfig.tsx - 515 строк** -``` -Проблема: Switch-case для всех templates -Решение: Template-specific конфигураторы уже есть! - -✅ InfoScreenConfig.tsx -✅ DateScreenConfig.tsx -✅ ListScreenConfig.tsx -✅ FormScreenConfig.tsx - -❌ Но всё равно огромный switch в TemplateConfig -``` - -**Метрика сложности:** -``` -> 500 строк = 🔴 Требует немедленной разбивки -> 300 строк = 🟡 Желательна разбивка -< 300 строк = 🟢 Приемлемо -``` - ---- - -### 3. 🟡 ОТСУТСТВИЕ ЛОГИРОВАНИЯ И МОНИТОРИНГА - -**Проблема:** -```typescript -// ❌ Console.log в production коде -console.log('✅ MongoDB connected successfully'); -console.error('Error rendering preview:', error); - -// Нет structured logging -// Нет error tracking (Sentry, etc.) -// Нет performance monitoring -``` - -**Найдено 21 файлов с console.log/error:** -- API routes: 10+ файлов -- Components: 5+ файлов -- Hooks: 3+ файла - -**Решение:** -```typescript -// lib/logger.ts -export const logger = { - info: (message: string, meta?: object) => { - if (process.env.NODE_ENV === 'development') { - console.log(`[INFO] ${message}`, meta); - } - // В production -> send to logging service - }, - error: (message: string, error: Error, meta?: object) => { - console.error(`[ERROR] ${message}`, error, meta); - // Send to Sentry/Datadog/etc. - }, - warn: (message: string, meta?: object) => { - console.warn(`[WARN] ${message}`, meta); - } -}; - -// Использование: -logger.error('Failed to fetch funnel', error, { funnelId, userId }); -``` - ---- - -### 4. 🟡 СЛАБАЯ ОБРАБОТКА ОШИБОК - -**Проблема:** -```typescript -// ❌ Пустые catch блоки -try { - formData = JSON.parse(formDataJson); -} catch { - formData = {}; -} - -// ❌ Только console.error -catch (error) { - console.error('Error loading images:', error); -} - -// ❌ Нет типизации ошибок -catch (error) { - // error: unknown - теряем type safety -} -``` - -**Найдено 40+ catch блоков:** -- 15 с только console.error -- 8 с пустым catch {} -- Остальные с минимальной обработкой - -**Решение:** -```typescript -// lib/errors.ts -export class FunnelError extends Error { - constructor( - message: string, - public code: string, - public statusCode: number = 500, - public meta?: object - ) { - super(message); - this.name = 'FunnelError'; - } -} - -export class ValidationError extends FunnelError { - constructor(message: string, meta?: object) { - super(message, 'VALIDATION_ERROR', 400, meta); - } -} - -// Использование: -try { - await saveFunnel(data); -} catch (error) { - if (error instanceof ValidationError) { - return NextResponse.json( - { error: error.message, code: error.code }, - { status: error.statusCode } - ); - } - - logger.error('Unexpected error', error as Error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); -} -``` - ---- - -### 5. 🟡 ОТСУТСТВИЕ ENV VALIDATION - -**Проблема:** -```typescript -// ❌ Прямое использование без валидации -const MONGODB_URI = process.env.MONGODB_URI!; - -// Что если переменная не задана? -// Что если формат неправильный? -// Ошибка обнаружится только в runtime! -``` - -**Найдено использование env в 7 файлах:** -- `MONGODB_URI` -- `NEXT_PUBLIC_*` -- `NODE_ENV` -- Никакой валидации при старте! - -**Решение:** -```typescript -// lib/env.ts -import { z } from 'zod'; - -const envSchema = z.object({ - MONGODB_URI: z.string().url().min(1), - NODE_ENV: z.enum(['development', 'production', 'test']), - NEXT_PUBLIC_API_URL: z.string().url().optional(), - // ... остальные переменные -}); - -export const env = envSchema.parse({ - MONGODB_URI: process.env.MONGODB_URI, - NODE_ENV: process.env.NODE_ENV, - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, -}); - -// Использование: -import { env } from '@/lib/env'; -const conn = await mongoose.connect(env.MONGODB_URI); -``` - -**Преимущества:** -- ✅ Ошибки обнаруживаются при старте -- ✅ Type-safe доступ к env vars -- ✅ Автокомплит в IDE -- ✅ Документация через zod schema - ---- - -### 6. 🟢 ОТСУТСТВИЕ API CLIENT СЛОЯ - -**Проблема:** -```typescript -// ❌ Fetch разбросан по компонентам -const response = await fetch('/api/funnels', { method: 'POST', ... }); -const response = await fetch(`/api/funnels/${id}`, { method: 'PUT', ... }); -const response = await fetch(`/api/funnels/${id}`, { method: 'DELETE', ... }); - -// Дублирование логики: -// - error handling -// - headers -// - JSON parsing -// - типизация -``` - -**Решение:** -```typescript -// lib/api/client.ts -class ApiClient { - private baseUrl = '/api'; - - private async request( - endpoint: string, - options?: RequestInit - ): Promise { - const url = `${this.baseUrl}${endpoint}`; - - try { - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - }); - - if (!response.ok) { - const error = await response.json(); - throw new ApiError(error.message, response.status); - } - - return await response.json(); - } catch (error) { - logger.error('API request failed', error as Error, { endpoint }); - throw error; - } - } - - funnels = { - list: () => this.request('/funnels'), - get: (id: string) => this.request(`/funnels/${id}`), - create: (data: CreateFunnelDto) => - this.request('/funnels', { - method: 'POST', - body: JSON.stringify(data), - }), - update: (id: string, data: UpdateFunnelDto) => - this.request(`/funnels/${id}`, { - method: 'PUT', - body: JSON.stringify(data), - }), - delete: (id: string) => - this.request(`/funnels/${id}`, { method: 'DELETE' }), - }; -} - -export const api = new ApiClient(); - -// Использование: -const funnels = await api.funnels.list(); -const funnel = await api.funnels.get(id); -``` - ---- - -### 7. 🟢 НЕДОСТАТОЧНАЯ ТИПИЗАЦИЯ API - -**Проблема:** -```typescript -// ❌ API routes без типизации запросов/ответов -export async function POST(request: Request) { - const body = await request.json(); // any - // ... -} - -// ❌ Нет shared типов между frontend и backend -// ❌ Нет валидации входных данных -``` - -**Решение:** -```typescript -// lib/api/schemas.ts -import { z } from 'zod'; - -export const CreateFunnelSchema = z.object({ - meta: z.object({ - id: z.string().min(1).max(100), - title: z.string().min(1), - description: z.string().optional(), - }), - screens: z.array(ScreenSchema).min(1), - defaultTexts: z.object({ - nextButton: z.string().optional(), - continueButton: z.string().optional(), - }).optional(), -}); - -export type CreateFunnelDto = z.infer; - -// app/api/funnels/route.ts -export async function POST(request: Request) { - try { - const body = await request.json(); - - // ✅ Валидация с zod - const data = CreateFunnelSchema.parse(body); - - // ✅ Типобезопасность - const funnel = await createFunnel(data); - - return NextResponse.json(funnel); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ); - } - throw error; - } -} -``` - ---- - -### 8. 🟡 PERFORMANCE: Нет индексов экранов - -**Проблема в screenRenderer.tsx:** -```typescript -// ❌ O(n) поиск при каждом рендере -const currentScreen = funnel.screens.find(s => s.id === currentScreenId); -const nextScreen = funnel.screens.find(s => s.id === nextScreenId); - -// При 50+ экранах = медленно -// При навигации = много поисков -``` - -**Решение:** -```typescript -// lib/funnel/FunnelRuntime.tsx -const screenMap = useMemo(() => { - return new Map(funnel.screens.map(s => [s.id, s])); -}, [funnel.screens]); - -// ✅ O(1) поиск -const currentScreen = screenMap.get(currentScreenId); -const nextScreen = screenMap.get(nextScreenId); -``` - -**Улучшение:** ~50x быстрее при 50+ экранах - ---- - -### 9. 🟢 ОТСУТСТВИЕ ДОКУМЕНТАЦИИ API - -**Проблема:** -``` -src/app/api/ -├── funnels/ -│ ├── route.ts // GET /api/funnels - что возвращает? -│ ├── [id]/ -│ │ ├── route.ts // GET/PUT/DELETE - параметры? -│ │ ├── duplicate/ -│ │ └── history/ -``` - -**Нет:** -- Swagger/OpenAPI spec -- JSDoc комментариев -- Примеров запросов -- Описания ошибок - -**Решение:** -```typescript -/** - * GET /api/funnels - * - * Получить список всех воронок - * - * Query params: - * - page?: number (default: 1) - * - limit?: number (default: 50, max: 100) - * - search?: string - * - * Response: 200 - * { - * funnels: Funnel[], - * total: number, - * page: number, - * totalPages: number - * } - * - * Errors: - * - 500: Database connection failed - * - * @example - * const response = await fetch('/api/funnels?page=1&limit=20'); - */ -export async function GET(request: Request) { - // ... -} -``` - ---- - -### 10. 🟡 MAGIC NUMBERS И STRINGS - -**Проблема:** -```typescript -// ❌ Magic numbers -style={{ height: 750, width: 320 }} -setTimeout(() => {}, 2000); -const limit = 50; - -// ❌ Magic strings -if (screen.template === "list") { } -font: "manrope" -weight: "semiBold" -``` - -**Решение:** -```typescript -// lib/constants.ts -export const PREVIEW_DIMENSIONS = { - WIDTH: 320, - HEIGHT: 750, - MOBILE_WIDTH: 375, -} as const; - -export const TIMEOUTS = { - TOAST_DURATION: 2000, - DEBOUNCE_INPUT: 500, - API_REQUEST: 30000, -} as const; - -export const PAGINATION = { - DEFAULT_LIMIT: 50, - MAX_LIMIT: 100, - DEFAULT_PAGE: 1, -} as const; - -// Использование: -style={{ - height: PREVIEW_DIMENSIONS.HEIGHT, - width: PREVIEW_DIMENSIONS.WIDTH -}} -``` - ---- - -## 📋 ПРИОРИТИЗАЦИЯ ИСПРАВЛЕНИЙ: - -### 🔴 ВЫСОКИЙ ПРИОРИТЕТ (немедленно): -1. ✅ **Добавить ENV validation** (30 мин) - предотвратит runtime ошибки -2. ✅ **Создать ApiClient** (2 часа) - унифицирует API вызовы -3. ✅ **Добавить error types** (1 час) - улучшит error handling -4. ✅ **Добавить logger** (1 час) - улучшит debugging - -### 🟡 СРЕДНИЙ ПРИОРИТЕТ (на неделе): -5. ✅ **Разбить ScreenVariantsConfig** (4 часа) -6. ✅ **Использовать модули вместо BuilderSidebar** (2 часа) -7. ✅ **Добавить screen Map для performance** (1 час) -8. ✅ **Вынести magic numbers в константы** (2 часа) - -### 🟢 НИЗКИЙ ПРИОРИТЕТ (на спринте): -9. ✅ **Написать unit тесты** (2-3 дня) -10. ✅ **Добавить API документацию** (1 день) -11. ✅ **Добавить Zod validation для API** (1 день) - ---- - -## 📊 МЕТРИКИ ПРОЕКТА: - -### **Code Quality:** -``` -├── TypeScript: ✅ Хорошо (strict mode) -├── Linting: ✅ Настроен ESLint -├── Formatting: ❓ Prettier не настроен? -├── Tests: ❌ Отсутствуют -└── Documentation: 🟡 Частично (README есть) -``` - -### **Architecture:** -``` -├── Component structure: 🟢 Хорошая -├── Type safety: 🟢 Хорошая -├── Code splitting: 🟡 Частичная -├── Performance: 🟡 Можно улучшить -└── Error handling: 🔴 Слабая -``` - -### **Maintainability:** -``` -├── File sizes: 🔴 Много больших файлов -├── Complexity: 🟡 Высокая в некоторых местах -├── Duplication: 🟢 Минимальная -├── Dependencies: 🟢 Актуальные -└── Documentation: 🟡 Недостаточная -``` - ---- - -## ✅ ВЫПОЛНЕНО (из предыдущего отчета): -- ✅ useDebounce hook -- ✅ usePersistedState hook -- ✅ Error Boundaries -- ✅ Optimized validation -- ✅ React.memo components -- ✅ Memoized preview mocks -- ✅ Module extraction (частично) - ---- - -## 🎯 СЛЕДУЮЩИЕ ШАГИ: - -### **Этап 1: Инфраструктура (1-2 дня)** -```bash -1. ENV validation с Zod -2. Logger service -3. Error types и handling -4. API client слой -``` - -### **Этап 2: Рефакторинг (3-5 дней)** -```bash -1. Разбить ScreenVariantsConfig -2. Использовать модули sidebar -3. Добавить screen Map -4. Вынести константы -``` - -### **Этап 3: Тестирование (1-2 недели)** -```bash -1. Setup test infrastructure -2. Unit tests для critical logic -3. Integration tests для API -4. E2E tests для key flows -``` - -### **Этап 4: Documentation (3-5 дней)** -```bash -1. API documentation (JSDoc/Swagger) -2. Architecture diagrams -3. Developer onboarding guide -4. Contribution guidelines -``` - ---- - -## 💡 РЕКОМЕНДАЦИИ: - -1. **Начните с инфраструктуры** - ENV validation и Logger предотвратят много проблем -2. **Добавьте тесты постепенно** - начните с критичной логики (navigation, validation) -3. **Разбивайте большие файлы** - используйте уже созданные модули -4. **Документируйте API** - это поможет новым разработчикам -5. **Мониторинг в production** - добавьте Sentry или аналог - ---- - -## 📈 ОЖИДАЕМЫЕ УЛУЧШЕНИЯ: - -После выполнения всех исправлений: - -| Метрика | Сейчас | После | -|---------|--------|-------| -| Test Coverage | 0% | 70%+ | -| Error Detection | Runtime | Build time | -| Maintainability | 6/10 | 9/10 | -| Performance | 7/10 | 9/10 | -| Developer Experience | 7/10 | 10/10 | - ---- - -**Проект в целом хороший, но есть критичные пробелы в инфраструктуре, тестировании и обработке ошибок!** diff --git a/BUILD_VARIANTS.md b/BUILD_VARIANTS.md deleted file mode 100644 index cefa09b..0000000 --- a/BUILD_VARIANTS.md +++ /dev/null @@ -1,232 +0,0 @@ -# Build Variants - Руководство - -Проект поддерживает два режима работы: **frontend** (без БД) и **full** (с MongoDB). - -## Режимы работы - -### 🎨 Frontend Mode (без БД) -- Только статические JSON файлы воронок -- Без админки и редактирования -- Нет загрузки изображений -- Быстрый старт без зависимостей - -### 🚀 Full Mode (с MongoDB) -- Полная функциональность админки -- Редактирование воронок в реальном времени -- Загрузка и хранение изображений -- История изменений -- Требует MongoDB подключение - -## Команды запуска - -### Development (разработка) - -```bash -# Frontend режим (без БД) -npm run dev -# или -npm run dev:frontend - -# Full режим (с MongoDB) -npm run dev:full -``` - -### Build (сборка) - -```bash -# Frontend режим -npm run build -# или -npm run build:frontend - -# Full режим -npm run build:full -``` - -### Production (продакшн) - -```bash -# Frontend режим -npm run start -# или -npm run start:frontend - -# Full режим -npm run start:full -``` - -## Как это работает - -### Скрипт `run-with-variant.mjs` - -Все команды используют скрипт `/scripts/run-with-variant.mjs`, который: - -1. Принимает команду и вариант: `node run-with-variant.mjs dev full` -2. Устанавливает environment переменные: - - `FUNNEL_BUILD_VARIANT=full|frontend` - - `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT=full|frontend` -3. Запускает Next.js с этими переменными - -### Runtime проверки - -В коде используется модуль `/src/lib/runtime/buildVariant.ts`: - -```typescript -import { IS_FRONTEND_ONLY_BUILD, IS_FULL_SYSTEM_BUILD } from '@/lib/runtime/buildVariant'; - -// В API endpoints -if (IS_FRONTEND_ONLY_BUILD) { - return NextResponse.json( - { error: 'Not available in frontend mode' }, - { status: 403 } - ); -} - -// Для условной логики -if (IS_FULL_SYSTEM_BUILD) { - // Код который работает только с БД -} -``` - -### Константы - -```typescript -import { BUILD_VARIANTS } from '@/lib/constants'; - -BUILD_VARIANTS.FRONTEND // 'frontend' -BUILD_VARIANTS.FULL // 'full' -``` - -## Environment файлы - -### `.env.local` (НЕ включать build variant!) - -```env -# ❌ НЕ НАДО: NEXT_PUBLIC_FUNNEL_BUILD_VARIANT=full -# Вместо этого используйте команды npm run dev:full / dev:frontend - -# MongoDB (нужно только для full режима) -MONGODB_URI=mongodb://localhost:27017/witlab-funnel - -# Базовый URL -NEXT_PUBLIC_BASE_URL=http://localhost:3000 -``` - -**Важно:** `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` НЕ должна быть в `.env.local`! -Она устанавливается автоматически через команды. - -### `.env.production` - -```env -# Только для production окружения -NODE_ENV=production -NEXT_PUBLIC_BASE_URL=https://your-domain.com -``` - -## API Endpoints - -Все API endpoints автоматически проверяют режим работы: - -### `/api/images/[filename]` - GET, DELETE -- ✅ Full mode: возвращает изображения из MongoDB -- ❌ Frontend mode: 403 Forbidden - -### `/api/images` - GET -- ✅ Full mode: список всех изображений -- ❌ Frontend mode: 403 Forbidden - -### `/api/images/upload` - POST -- ✅ Full mode: загрузка изображений в MongoDB -- ❌ Frontend mode: 403 Forbidden - -### `/api/funnels/*` -- ✅ Full mode: CRUD операции с воронками -- ❌ Frontend mode: 403 Forbidden - -## Типичные сценарии - -### Локальная разработка с админкой - -```bash -# 1. Запустить MongoDB -mongod --dbpath ./data - -# 2. Запустить в full режиме -npm run dev:full - -# 3. Открыть http://localhost:3000/admin -``` - -### Локальная разработка без БД - -```bash -# Просто запустить frontend режим -npm run dev - -# Или явно -npm run dev:frontend -``` - -### Production деплой (frontend only) - -```bash -# Собрать frontend версию -npm run build:frontend - -# Запустить -npm run start:frontend -``` - -### Production деплой (full stack) - -```bash -# Установить MONGODB_URI в .env.production -echo "MONGODB_URI=mongodb://..." > .env.production - -# Собрать full версию -npm run build:full - -# Запустить -npm run start:full -``` - -## Troubleshooting - -### Проблема: "Image serving not available" - -**Причина:** Запущен frontend режим, а используется API для изображений - -**Решение:** Перезапустить в full режиме: -```bash -npm run dev:full -``` - -### Проблема: "Cannot connect to MongoDB" - -**Причина:** MongoDB не запущен или неправильный URI - -**Решение:** -1. Проверить что MongoDB запущен: `mongosh` -2. Проверить MONGODB_URI в `.env.local` -3. Убедиться что используется `dev:full`, не `dev` - -### Проблема: Админка не работает - -**Причина:** Запущен frontend режим - -**Решение:** -```bash -npm run dev:full -``` - -## Итоговые рекомендации - -✅ **DO:** -- Использовать команды `npm run dev:full` / `dev:frontend` -- Держать `.env.local` без `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` -- Проверять `IS_FRONTEND_ONLY_BUILD` в API endpoints - -❌ **DON'T:** -- Не добавлять `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` в `.env.local` -- Не проверять `process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` напрямую -- Не смешивать логику frontend и full режимов diff --git a/PERFORMANCE_IMPROVEMENTS.md b/PERFORMANCE_IMPROVEMENTS.md deleted file mode 100644 index 6368fa2..0000000 --- a/PERFORMANCE_IMPROVEMENTS.md +++ /dev/null @@ -1,219 +0,0 @@ -# ✅ PERFORMANCE IMPROVEMENTS - ВЫПОЛНЕНО - -## Исправленные проблемы (10/10): - -### 1. ✅ useDebounce и usePersistedState hooks -**Файлы:** -- `/src/lib/admin/hooks/useDebounce.ts` - дебаунс для text inputs -- `/src/lib/admin/hooks/usePersistedState.ts` - сохранение UI состояния - -**Применение:** -- Text inputs в BuilderSidebar теперь могут использовать debounce -- Collapsed/expanded состояния сохраняются в sessionStorage - ---- - -### 2. ✅ Error Boundaries -**Файл:** `/src/components/admin/ErrorBoundary.tsx` - -**Компоненты:** -- `ErrorBoundary` - универсальный boundary -- `BuilderErrorBoundary` - для компонентов билдера -- `PreviewErrorBoundary` - для preview компонента - -**Использование:** -```tsx - - - - - - - -``` - ---- - -### 3. ✅ Оптимизированная validation -**Файл:** `BuilderSidebar.tsx` - -**Было:** -```typescript -const validation = useMemo(() => validateBuilderState(state), [state]); -``` - -**Стало:** -```typescript -const screenIds = useMemo(() => state.screens.map(s => s.id).join(','), [state.screens]); -const validation = useMemo( - () => validateBuilderState(state), - [ - state.meta.id, - state.meta.firstScreenId, - screenIds, - state.screens.length, - ] -); -``` - -**Улучшение:** Validation запускается только при изменении критичных полей, а не при каждом изменении state. - ---- - -### 4. ✅ React.memo для компонентов -**Файл:** `/src/components/admin/builder/Canvas/MemoizedComponents.tsx` - -**Мемоизированы:** -- `TemplateSummary` -- `VariantSummary` -- `TransitionRow` -- `DropIndicator` - -**Использование:** -```tsx -import { TemplateSummary, VariantSummary } from './MemoizedComponents'; -``` - -**Улучшение:** Компоненты списка не ре-рендерятся при изменении других экранов. - ---- - -### 5. ✅ Мемоизированные моки в BuilderPreview -**Файл:** `BuilderPreview.tsx` - -**Было:** -```typescript -onContinue: () => {}, // Новая функция каждый раз -onBack: () => {}, -screenProgress: { current: 1, total: 10 }, // Новый объект -``` - -**Стало:** -```typescript -const MOCK_CALLBACKS = { - onContinue: () => {}, - onBack: () => {}, -}; -const MOCK_PROGRESS = { current: 1, total: 10 }; - -// Используем в render -onContinue: MOCK_CALLBACKS.onContinue, -``` - -**Улучшение:** Моки создаются один раз, не вызывают лишних re-renders. - ---- - -### 6. ✅ Разбивка компонентов (частично) -**Созданы модули:** -- `FunnelSettingsPanel` - настройки воронки -- `ScreenSettingsPanel` - настройки экрана -- `NavigationPanel` - навигация - -**Статус:** Модули созданы, можно использовать вместо BuilderSidebar монолита. - ---- - -### 7. ✅ Lazy loading (документировано) -**Файл:** `/src/lib/admin/hooks/index.ts` - -**Рекомендация для будущего:** -```tsx -const TemplateConfig = lazy(() => import("@/components/admin/builder/templates")); -const ScreenVariantsConfig = lazy(() => import("../forms/ScreenVariantsConfig")); -``` - ---- - -### 8. ✅ Оптимизация BuilderCanvas useCallback -**Статус:** Проверены все useCallback - -**Рекомендации:** -- Убрать ненужные useCallback с пустыми зависимостями -- Использовать useRef для стабильных функций -- Мемоизировать только то, что реально передается в child компоненты - ---- - -### 9. 🔄 Виртуализация списков (опционально) -**Статус:** Документировано для будущего - -**Когда нужно:** При 50+ экранах в воронке - -**Библиотеки:** -- `react-window` -- `react-virtual` -- `@tanstack/react-virtual` - ---- - -### 10. ✅ Исправление глубоких сравнений -**Статус:** Оптимизация validation решила большую часть - -**Дополнительно:** -- Validation мемоизируется по критичным полям -- useCallback handlers не зависят от всего state - ---- - -## Метрики улучшений: - -| Проблема | Статус | Влияние | -|----------|--------|---------| -| Debounce для форм | ✅ Готов к использованию | 🟢 Высокое | -| Validation оптимизация | ✅ Внедрено | 🟢 Высокое | -| React.memo компоненты | ✅ Готовы | 🟡 Среднее | -| Мемоизация моков | ✅ Внедрено | 🟡 Среднее | -| Error Boundaries | ✅ Готовы | 🟡 Среднее | -| Разбивка компонентов | 🔄 Частично | 🟢 Высокое (maintainability) | -| Lazy loading | 📝 Документировано | 🟢 Высокое (initial load) | -| Оптимизация useCallback | ✅ Проверено | 🟢 Низкое | -| Виртуализация | 📝 Будущее | 🟡 Среднее (при >50 экранах) | -| Глубокие сравнения | ✅ Исправлено | 🟡 Среднее | - ---- - -## Следующие шаги: - -### Немедленно (можно применить сразу): -1. Использовать `MemoizedComponents` в `BuilderCanvas` -2. Обернуть критичные компоненты в Error Boundaries -3. Применить `useDebounce` для text inputs в формах - -### Скоро (когда будет время): -1. Полностью заменить `BuilderSidebar` на модули -2. Добавить lazy loading для тяжелых компонентов -3. Использовать `usePersistedState` для collapsed sections - -### В будущем (при необходимости): -1. Виртуализация списка экранов (при >50 экранах) -2. Code splitting для admin bundle -3. Service Worker для кэширования - ---- - -## Готовые к использованию утилиты: - -### Hooks: -```tsx -import { useDebounce, useDebouncedCallback } from '@/lib/admin/hooks/useDebounce'; -import { usePersistedState } from '@/lib/admin/hooks/usePersistedState'; -``` - -### Error Boundaries: -```tsx -import { BuilderErrorBoundary, PreviewErrorBoundary } from '@/components/admin/ErrorBoundary'; -``` - -### Memoized Components: -```tsx -import { TemplateSummary, VariantSummary, TransitionRow, DropIndicator } from './Canvas/MemoizedComponents'; -``` - ---- - -## Результат: -✅ **Все 10 проблем решены или задокументированы** -✅ **Создана инфраструктура для performance оптимизаций** -✅ **Проект собирается без ошибок** -✅ **TypeScript компиляция чистая** diff --git a/README-ADMIN.md b/README-ADMIN.md deleted file mode 100644 index df8d2e4..0000000 --- a/README-ADMIN.md +++ /dev/null @@ -1,218 +0,0 @@ -# WitLab Funnel Admin - Полноценная админка с MongoDB - -## Что реализовано - -### ✅ База данных MongoDB -- **Подключение через Mongoose** с автоматическим переподключением -- **Модели для воронок** с полной валидацией структуры данных -- **История изменений** для системы undo/redo -- **Индексы для производительности** поиска и фильтрации - -### ✅ API Routes -- `GET /api/funnels` - список воронок с пагинацией и фильтрами -- `POST /api/funnels` - создание новой воронки -- `GET /api/funnels/[id]` - получение конкретной воронки -- `PUT /api/funnels/[id]` - обновление воронки -- `DELETE /api/funnels/[id]` - удаление воронки (только черновики) -- `POST /api/funnels/[id]/duplicate` - дублирование воронки -- `GET/POST /api/funnels/[id]/history` - работа с историей изменений -- `GET /api/funnels/by-funnel-id/[funnelId]` - загрузка по funnel ID (для совместимости) - -### ✅ Каталог воронок `/admin` -- **Список всех воронок** с поиском, фильтрацией и сортировкой -- **Создание новых воронок** с базовым шаблоном -- **Дублирование существующих** воронок -- **Удаление черновиков** (опубликованные можно только архивировать) -- **Статистика использования** (просмотры, завершения) -- **Статусы**: draft, published, archived - -### ✅ Редактор воронок `/admin/builder/[id]` -- **Полноценный билдер** интегрированный с существующей архитектурой -- **Автосохранение** изменений в базу данных -- **Система публикации** с контролем версий -- **Топ бар** с информацией о воронке и кнопками действий -- **Экспорт/импорт JSON** для резервного копирования - -### ✅ Система undo/redo -- **История действий** с глубиной до 50 шагов -- **Базовые точки** при сохранении в БД (после сохранения нельзя откатить) -- **Несохраненные изменения** отслеживаются отдельно -- **Автоматическая очистка** старых записей истории - -### ✅ Интеграция с существующим кодом -- **Обратная совместимость** с JSON файлами -- **Приоритет базы данных** при загрузке воронок -- **Автоматическое увеличение статистики** при просмотрах -- **Единый API** для всех компонентов системы - -## Настройка окружения - -### 1. MongoDB Connection -Создайте `.env.local` файл: -```bash -# MongoDB -MONGODB_URI=mongodb://localhost:27017/witlab-funnel -# или для MongoDB Atlas: -# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/witlab-funnel - -# Base URL (для server-side запросов) -NEXT_PUBLIC_BASE_URL=http://localhost:3000 -``` - -### 2. Установка MongoDB локально -```bash -# macOS (через Homebrew) -brew install mongodb-community -brew services start mongodb-community - -# Или используйте MongoDB Atlas (облако) -``` - -### 3. Запуск проекта -```bash -npm install -npm run dev:full -``` - -> ⚠️ Админка и API доступны только в режиме **Full system**. Для статичного фронта без админки используйте `npm run dev:frontend`, `npm run build` (или `npm run build:frontend`) и `npm run start` (или `npm run start:frontend`). - -## Использование - -### Создание новой воронки -1. Перейдите на `/admin` -2. Нажмите "Создать воронку" -3. Автоматически откроется билдер с базовым шаблоном -4. Редактируйте экраны в правом сайдбаре -5. Сохраняйте изменения кнопкой "Сохранить" -6. Публикуйте готовую воронку кнопкой "Опубликовать" - -### Редактирование существующей воронки -1. В каталоге найдите нужную воронку -2. Нажмите иконку "Редактировать" (карандаш) -3. Внесите изменения в билдере -4. Сохраните или опубликуйте - -### Просмотр воронки -1. Нажмите иконку "Просмотр" (глаз) в каталоге -2. Или перейдите на `/{funnelId}` напрямую - -### Дублирование воронки -1. Нажмите иконку "Дублировать" (копия) -2. Создастся копия со статусом "Черновик" -3. Можете отредактировать и опубликовать - -## Архитектура - -### Модели данных -```typescript -// Основная модель воронки -interface IFunnel { - funnelData: FunnelDefinition; // JSON структура воронки - name: string; // Человеко-читаемое имя - status: 'draft' | 'published' | 'archived'; - version: number; // Автоинкремент при изменениях - usage: { // Статистика - totalViews: number; - totalCompletions: number; - }; -} - -// История изменений -interface IFunnelHistory { - funnelId: string; // Связь с воронкой - sessionId: string; // Сессия редактирования - funnelSnapshot: FunnelDefinition; // Снимок состояния - sequenceNumber: number; // Порядок в сессии - isBaseline: boolean; // Сохранено в БД -} -``` - -### API Architecture -- **RESTful API** с правильными HTTP методами -- **Валидация данных** на уровне Mongoose схем -- **Обработка ошибок** с понятными сообщениями -- **Пагинация** для больших списков -- **Фильтрация и поиск** по всем полям - -### Frontend Architecture -- **Server Components** для статической генерации -- **Client Components** для интерактивности -- **Единый API клиент** через fetch -- **TypeScript типы** для всех данных -- **Error Boundaries** для обработки ошибок - -## Безопасность - -### Текущие меры -- **Валидация входных данных** на всех уровнях -- **Проверка существования** ресурсов перед операциями -- **Ограничения на удаление** опубликованных воронок -- **Санитизация пользовательского ввода** - -### Будущие улучшения -- Аутентификация пользователей -- Авторизация по ролям -- Аудит лог действий -- Rate limiting для API - -## Производительность - -### Текущая оптимизация -- **MongoDB индексы** для быстрого поиска -- **Пагинация** вместо загрузки всех записей -- **Selective loading** - только нужные поля -- **Connection pooling** для базы данных - -### Мониторинг -- **Логирование ошибок** в консоль -- **Время выполнения** запросов отслеживается -- **Размер истории** ограничен (100 записей на сессию) - -## Миграция с JSON - -Существующие JSON воронки продолжают работать автоматически: -1. **Приоритет базы данных** - сначала поиск в MongoDB -2. **Fallback на JSON** - если не найдено в базе -3. **Импорт из JSON** - можно загрузить JSON в билдере -4. **Экспорт в JSON** - для резервного копирования - -## Roadmap - -### Ближайшие планы -- [x] Основная функциональность админки -- [x] Система undo/redo -- [x] Интеграция с существующим кодом -- [ ] Аутентификация пользователей -- [ ] Collaborative editing -- [ ] Advanced аналитика - -### Долгосрочные цели -- [ ] Multi-tenant архитектура -- [ ] A/B тестирование воронок -- [ ] Интеграция с внешними сервисами -- [ ] Mobile app для мониторинга - -## Техническая поддержка - -### Логи и отладка -```bash -# Проверка подключения к MongoDB -curl http://localhost:3000/api/funnels - -# Просмотр логов в консоли разработчика -# MongoDB connection logs в терминале -``` - -### Частые проблемы -1. **MongoDB not connected** - проверьте MONGODB_URI в .env.local -2. **API errors** - проверьте сетевое соединение -3. **Build errors** - убедитесь что все зависимости установлены - -### Контакты -- GitHub Issues для багрепортов -- Документация в `/docs/` -- Комментарии в коде для сложных частей - ---- - -**Полноценная админка с MongoDB готова к использованию! 🚀** diff --git a/README.md b/README.md index 6a09bc4..2798a1f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -## Build & runtime modes +# Build & runtime modes The project can be built in two isolated configurations. The build scripts set the `FUNNEL_BUILD_VARIANT`/`NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` environment variables so that unused code is tree-shaken during compilation. diff --git a/REFACTORING_SUMMARY.md b/REFACTORING_SUMMARY.md deleted file mode 100644 index 64c4a4e..0000000 --- a/REFACTORING_SUMMARY.md +++ /dev/null @@ -1,170 +0,0 @@ -# ✅ Рефакторинг завершен успешно - -## Выполненные задачи - -### 1. ✅ ENV validation с Zod -**Файл:** `/src/lib/env.ts` - -- Создана схема валидации с Zod для всех environment переменных -- Валидация происходит при запуске приложения -- Понятные сообщения об ошибках при неправильных значениях -- Типобезопасный доступ к переменным окружения - -**Валидируемые переменные:** -- `MONGODB_URI` - опциональная строка для подключения к БД -- `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` - frontend | full -- `NEXT_PUBLIC_BASE_URL` - базовый URL приложения -- `NODE_ENV` - development | production | test - -### 2. ✅ Screen Map для performance -**Файл:** `/src/components/funnel/FunnelRuntime.tsx` - -- Добавлен `useMemo` для создания Map экранов по ID -- Поиск экранов теперь O(1) вместо O(n) -- Улучшена производительность при навигации в больших воронках - -```typescript -const screenMap = useMemo(() => { - const map = new Map(); - funnel.screens.forEach(screen => map.set(screen.id, screen)); - return map; -}, [funnel.screens]); -``` - -### 3. ✅ ScreenVariantsConfig разбит на модули -**Директория:** `/src/components/admin/builder/forms/variants/` - -Созданы файлы: -- **types.ts** - типы для вариантов -- **utils.ts** - утилиты (ensureCondition, и т.д.) -- **VariantPanel.tsx** - панель управления одним вариантом -- **VariantConditionEditor.tsx** - редактор условий -- **VariantOverridesEditor.tsx** - редактор переопределений -- **index.ts** - экспорты модуля - -**Преимущества:** -- Каждый компонент < 200 строк кода -- Четкое разделение ответственности -- Легко тестировать отдельные части -- Переиспользуемые компоненты - -### 4. ✅ Sidebar модули вместо монолита -**Статус:** Готово к использованию - -Модульная структура variants теперь используется в ScreenVariantsConfig: -- Главный компонент управляет только состоянием -- Логика условий и переопределений вынесена в отдельные модули -- Улучшена читаемость и поддерживаемость - -### 5. ✅ Вынесены все константы -**Файл:** `/src/lib/constants.ts` - -Все magic numbers и strings теперь в одном месте: - -```typescript -// Build варианты -export const BUILD_VARIANTS = { - FULL: 'full', - FRONTEND: 'frontend', -} as const; - -// API endpoints -export const API_ENDPOINTS = { - IMAGES_UPLOAD: '/api/images/upload', - RAW_IMAGE: '/api/raw-image', - TEST_IMAGE: '/api/test-image', -} as const; - -// Preview размеры -export const PREVIEW_DIMENSIONS = { - WIDTH: 375, - HEIGHT: 667, -} as const; - -// Database -export const DB_COLLECTIONS = { - FUNNELS: 'funnels', - IMAGES: 'images', -} as const; -``` - -### 6. ✅ Обновлены импорты везде - -Обновленные файлы: -- `/src/components/admin/builder/layout/BuilderPreview.tsx` - PREVIEW_DIMENSIONS -- `/src/lib/runtime/buildVariant.ts` - BUILD_VARIANTS, env -- `/src/lib/mongodb.ts` - env, DB_COLLECTIONS -- `/src/components/admin/builder/forms/ImageUpload.tsx` - BUILD_VARIANTS, env -- `/src/app/[funnelId]/page.tsx` - BAKED_FUNNELS - -### 7. ✅ Проверка сборки и lint - -**Build:** ✅ Успешно -```bash -npm run build -# ✓ Compiled successfully -``` - -**Lint:** ✅ Без ошибок -```bash -npm run lint -# No errors found -``` - -## Архитектурные улучшения - -### DRY (Don't Repeat Yourself) -- Константы вынесены в единое место -- Убрано дублирование magic numbers -- Переиспользуемые модули вариантов - -### Single Source of Truth -- env переменные валидируются в одном месте -- Константы определены централизованно -- Типы для вариантов в отдельном файле - -### Модульность -- ScreenVariantsConfig разбит на 6 файлов -- Каждый модуль отвечает за одну задачу -- Легко добавлять новые функции - -### Type Safety -- Zod валидация для env -- TypeScript типы для всех констант -- Строгая типизация вариантов - -## Статистика - -**Создано файлов:** 7 -- `/src/lib/env.ts` -- `/src/lib/constants.ts` -- `/src/components/admin/builder/forms/variants/types.ts` -- `/src/components/admin/builder/forms/variants/utils.ts` -- `/src/components/admin/builder/forms/variants/VariantPanel.tsx` -- `/src/components/admin/builder/forms/variants/VariantConditionEditor.tsx` -- `/src/components/admin/builder/forms/variants/VariantOverridesEditor.tsx` - -**Обновлено файлов:** 8 -- FunnelRuntime.tsx (Screen Map) -- BuilderPreview.tsx (константы) -- buildVariant.ts (env + константы) -- mongodb.ts (env + константы) -- ImageUpload.tsx (константы) -- ScreenVariantsConfig.tsx (модули) -- app/[funnelId]/page.tsx (константы) -- variants/index.ts (экспорты) - -**Удалено:** 1 -- ScreenVariantsConfig.old.tsx - -## Результат - -✅ **Проект полностью собирается и работает** -✅ **Нет ошибок TypeScript** -✅ **Нет ошибок ESLint** -✅ **Все константы централизованы** -✅ **ENV валидация работает** -✅ **Модульная структура готова** -✅ **Performance улучшен (Screen Map)** - -Рефакторинг завершен успешно без участия пользователя! diff --git a/docs/REGISTRATION_FIELD_KEY.md b/docs/REGISTRATION_FIELD_KEY.md new file mode 100644 index 0000000..e37a784 --- /dev/null +++ b/docs/REGISTRATION_FIELD_KEY.md @@ -0,0 +1,354 @@ +# Registration Field Key для List Single Selection + +## Описание + +Функциональность `registrationFieldKey` позволяет автоматически передавать выбранные значения из list single selection экранов в payload регистрации пользователя при авторизации через email экран. + +## Как это работает + +### 1. Настройка в админке + +Для list экранов с `selectionType: "single"` в админке появляется дополнительное поле **"Ключ поля для регистрации"**. + +В это поле можно указать путь к полю в объекте регистрации, используя точечную нотацию для вложенных объектов. + +**Примеры:** +- `profile.gender` → `{ profile: { gender: "selected-id" } }` +- `profile.relationship_status` → `{ profile: { relationship_status: "selected-id" } }` +- `partner.gender` → `{ partner: { gender: "selected-id" } }` + +### 2. Пример JSON конфигурации + +```json +{ + "id": "gender-screen", + "template": "list", + "title": { + "text": "What is your gender?" + }, + "list": { + "selectionType": "single", + "registrationFieldKey": "profile.gender", + "options": [ + { + "id": "male", + "label": "Male", + "emoji": "👨" + }, + { + "id": "female", + "label": "Female", + "emoji": "👩" + }, + { + "id": "other", + "label": "Other", + "emoji": "🧑" + } + ] + } +} +``` + +### 3. Как данные попадают в регистрацию и сессию + +#### **Передача в сессию (на каждом экране):** + +1. **Пользователь выбирает вариант** на экране (например, "Male" с id "male") +2. **Ответ сохраняется** в `FunnelAnswers` под ключом экрана: `{ "gender-screen": ["male"] }` +3. **При переходе вперед** (нажатие Continue или автопереход): + - Вызывается `buildSessionDataFromScreen()` для текущего экрана + - Создается объект с вложенной структурой: `{ profile: { gender: "male" } }` + - Вызывается `updateSession()` с данными: + ```typescript + { + answers: { "gender-screen": ["male"] }, // Старая логика + profile: { gender: "male" } // Новая логика с registrationFieldKey + } + ``` +4. **Данные отправляются в API** и сохраняются в сессии пользователя + +#### **Передача в регистрацию (при авторизации):** + +1. **При переходе на email экран** вызывается функция `buildRegistrationDataFromAnswers()` +2. **Функция обрабатывает** все list single selection экраны с `registrationFieldKey` +3. **Создается объект** с вложенной структурой из всех экранов: `{ profile: { gender: "male", relationship_status: "single" } }` +4. **При авторизации** этот объект объединяется с базовым payload +5. **Отправляется на сервер** в составе `ICreateAuthorizeRequest` + +### 4. Структура payload регистрации + +**Базовый payload (без registrationFieldKey):** +```typescript +{ + email: "user@example.com", + timezone: "Europe/Moscow", + locale: "en", + source: "funnel-id", + sign: true, + signDate: "2024-01-01T00:00:00.000Z", + feature: "stripe" +} +``` + +**С registrationFieldKey (profile.gender = "male"):** +```typescript +{ + email: "user@example.com", + timezone: "Europe/Moscow", + locale: "en", + source: "funnel-id", + sign: true, + signDate: "2024-01-01T00:00:00.000Z", + feature: "stripe", + profile: { + gender: "male" + } +} +``` + +**С несколькими registrationFieldKey:** +```typescript +{ + email: "user@example.com", + timezone: "Europe/Moscow", + locale: "en", + source: "funnel-id", + sign: true, + signDate: "2024-01-01T00:00:00.000Z", + feature: "stripe", + profile: { + gender: "male", + relationship_status: "single" + }, + partner: { + gender: "female" + } +} +``` + +## Полный пример воронки + +```json +{ + "meta": { + "id": "dating-funnel", + "title": "Dating Profile", + "firstScreenId": "gender" + }, + "screens": [ + { + "id": "gender", + "template": "list", + "title": { "text": "What is your gender?" }, + "list": { + "selectionType": "single", + "registrationFieldKey": "profile.gender", + "options": [ + { "id": "male", "label": "Male", "emoji": "👨" }, + { "id": "female", "label": "Female", "emoji": "👩" } + ] + }, + "navigation": { + "defaultNextScreenId": "relationship-status" + } + }, + { + "id": "relationship-status", + "template": "list", + "title": { "text": "What is your relationship status?" }, + "list": { + "selectionType": "single", + "registrationFieldKey": "profile.relationship_status", + "options": [ + { "id": "single", "label": "Single" }, + { "id": "relationship", "label": "In a relationship" }, + { "id": "married", "label": "Married" } + ] + }, + "navigation": { + "defaultNextScreenId": "partner-gender" + } + }, + { + "id": "partner-gender", + "template": "list", + "title": { "text": "What is your partner's gender?" }, + "list": { + "selectionType": "single", + "registrationFieldKey": "partner.gender", + "options": [ + { "id": "male", "label": "Male", "emoji": "👨" }, + { "id": "female", "label": "Female", "emoji": "👩" } + ] + }, + "navigation": { + "defaultNextScreenId": "email" + } + }, + { + "id": "email", + "template": "email", + "title": { "text": "Enter your email" }, + "emailInput": { + "label": "Email", + "placeholder": "your@email.com" + } + } + ] +} +``` + +**Результат после прохождения воронки:** + +Если пользователь выбрал: +- Gender: Male +- Relationship Status: Single +- Partner Gender: Female +- Email: user@example.com + +Payload регистрации будет: +```typescript +{ + email: "user@example.com", + timezone: "Europe/Moscow", + locale: "en", + source: "dating-funnel", + sign: true, + signDate: "2024-01-01T00:00:00.000Z", + feature: "stripe", + profile: { + gender: "male", + relationship_status: "single" + }, + partner: { + gender: "female" + } +} +``` + +## Ограничения + +1. **Только для single selection** - работает только с `selectionType: "single"` +2. **Только ID опции** - передается именно `id` выбранной опции, а не `label` или `value` +3. **Перезапись значений** - если несколько экранов используют один и тот же ключ, последний перезапишет предыдущий +4. **Обязательный email экран** - данные передаются только при авторизации через email экран + +## Техническая реализация + +### Файлы + +- **types.ts** - добавлено поле `registrationFieldKey` в `ListScreenDefinition` +- **ListScreenConfig.tsx** - UI для настройки ключа в админке +- **registrationHelpers.ts** - утилиты `buildRegistrationDataFromAnswers()` и `buildSessionDataFromScreen()` +- **FunnelRuntime.tsx** - вызывает `buildSessionDataFromScreen()` при переходе вперед и передает в `updateSession()` +- **useAuth.ts** - принимает `registrationData` и объединяет с базовым payload +- **EmailTemplate.tsx** - вызывает `buildRegistrationDataFromAnswers()` и передает в `useAuth` +- **screenRenderer.tsx** - передает `answers` в `EmailTemplate` + +### Функция buildRegistrationDataFromAnswers + +Используется при авторизации для сбора данных со всех экранов воронки: + +```typescript +export function buildRegistrationDataFromAnswers( + funnel: FunnelDefinition, + answers: FunnelAnswers +): RegistrationDataObject { + const registrationData: RegistrationDataObject = {}; + + for (const screen of funnel.screens) { + if (screen.template === "list") { + const listScreen = screen as ListScreenDefinition; + + if ( + listScreen.list.selectionType === "single" && + listScreen.list.registrationFieldKey && + answers[screen.id] && + answers[screen.id].length > 0 + ) { + const selectedId = answers[screen.id][0]; + const fieldKey = listScreen.list.registrationFieldKey; + + // Устанавливаем значение по многоуровневому ключу + setNestedValue(registrationData, fieldKey, selectedId); + } + } + } + + return registrationData; +} +``` + +### Функция buildSessionDataFromScreen + +Используется при переходе вперед для сбора данных с текущего экрана: + +```typescript +export function buildSessionDataFromScreen( + screen: { template: string; id: string; list?: { selectionType?: string; registrationFieldKey?: string } }, + selectedIds: string[] +): RegistrationDataObject { + const sessionData: RegistrationDataObject = {}; + + if (screen.template === "list" && screen.list) { + const { selectionType, registrationFieldKey } = screen.list; + + if ( + selectionType === "single" && + registrationFieldKey && + selectedIds.length > 0 + ) { + const selectedId = selectedIds[0]; + setNestedValue(sessionData, registrationFieldKey, selectedId); + } + } + + return sessionData; +} +``` + +## Best Practices + +1. **Используйте понятные ID** - ID опций должны соответствовать ожидаемым значениям на сервере +2. **Документируйте ключи** - ведите список используемых `registrationFieldKey` для избежания конфликтов +3. **Проверяйте типы** - убедитесь что ID опций соответствуют типам полей в `ICreateAuthorizeRequest` +4. **Тестируйте payload** - проверяйте что данные корректно попадают в регистрацию + +## Примеры использования + +### Простой профиль +```json +{ + "list": { + "selectionType": "single", + "registrationFieldKey": "profile.gender", + "options": [...] + } +} +``` + +### Вложенная структура +```json +{ + "list": { + "selectionType": "single", + "registrationFieldKey": "partner.birthplace.country", + "options": [ + { "id": "US", "label": "United States" }, + { "id": "UK", "label": "United Kingdom" } + ] + } +} +``` + +### Без регистрации (обычный list) +```json +{ + "list": { + "selectionType": "single", + // registrationFieldKey не указан - данные не попадут в регистрацию + "options": [...] + } +} +``` diff --git a/docs/templates-and-builder.md b/docs/templates-and-builder.md deleted file mode 100644 index 3e1130b..0000000 --- a/docs/templates-and-builder.md +++ /dev/null @@ -1,232 +0,0 @@ -# Шаблоны экранов и конструктор воронки - -Этот документ описывает, из каких частей состоит JSON-конфигурация воронки, какие шаблоны экранов доступны в рантайме и как с ними работает конструктор (builder). Используйте его как справочник при ручном редактировании JSON или при настройке воронки через интерфейс администратора. - -## Архитектура воронки - -Воронка описывается объектом `FunnelDefinition` и состоит из двух частей: метаданных и списка экранов. Навигация осуществляется по идентификаторам экранов, а состояние (выборы пользователя) хранится отдельно в рантайме. - -```ts -interface FunnelDefinition { - meta: { - id: string; - version?: string; - title?: string; - description?: string; - firstScreenId?: string; // стартовый экран, по умолчанию первый в списке - }; - defaultTexts?: { - nextButton?: string; - continueButton?: string; - }; - screens: ScreenDefinition[]; // набор экранов разных шаблонов -} -``` - -Каждый экран обязан иметь уникальный `id` и поле `template`, которое выбирает шаблон визуализации. Дополнительно поддерживаются: - -- `header` — управляет прогресс-баром, заголовком и кнопкой «Назад». По умолчанию шапка показывается, а прогресс вычисляется автоматически в рантайме. -- `bottomActionButton` — универсальное описание основной кнопки («Продолжить», «Далее» и т. п.). Шаблон может переопределить или скрыть её. -- `navigation` — правила переходов между экранами. - -### Навигация - -Навигация описывается объектом `NavigationDefinition`: - -```ts -interface NavigationDefinition { - defaultNextScreenId?: string; // переход по умолчанию - rules?: Array<{ - nextScreenId: string; // куда перейти, если условие выполнено - conditions: Array<{ - screenId: string; // экран, чьи ответы проверяем - operator?: "includesAny" | "includesAll" | "includesExactly"; - optionIds: string[]; // выбранные опции, которые проверяются - }>; - }>; -} -``` - -Рантайм использует первый сработавший `rule` и только после этого обращается к `defaultNextScreenId`. Для списков с одиночным выбором и скрытой кнопкой переход совершается автоматически при изменении ответа. Для всех прочих шаблонов пользователь должен нажать действие, сконфигурированное для текущего экрана. - -## Шаблоны экранов - -Ниже приведено краткое описание каждого шаблона и JSON-поле, которое его конфигурирует. - -### Информационный экран (`template: "info"`) - -Используется для показа статических сообщений, промо-блоков или инструкций. Обязательные поля — `id`, `template`, `title`. Дополнительно поддерживаются: - -- `description` — расширенный текст под заголовком. -- `icon` — эмодзи или картинка. `type` принимает значения `emoji` или `image`, `value` — символ или URL, `size` — `sm | md | lg | xl`. -- `bottomActionButton` — описание кнопки внизу, если нужно отличное от дефолтного текста. - -```json -{ - "id": "welcome", - "template": "info", - "title": { "text": "Добро пожаловать" }, - "description": { "text": "Заполните короткую анкету, чтобы получить персональное предложение." }, - "icon": { "type": "emoji", "value": "👋", "size": "lg" }, - "navigation": { "defaultNextScreenId": "question-1" } -} -``` - -Рантайм выводит заголовок по центру, кнопку «Next» (или `defaultTexts.nextButton`) и позволяет вернуться назад, если это разрешено в `header`. Логика описана в `InfoTemplate` и `buildLayoutQuestionProps` — дополнительные параметры (`font`, `color`, `align`) влияют на типографику.【F:src/components/funnel/templates/InfoTemplate.tsx†L1-L99】【F:src/lib/funnel/types.ts†L74-L131】 - -### Экран с вопросом и вариантами (`template: "list"`) - -Базовый интерактивный экран. Поле `list` описывает варианты ответов: - -```json -{ - "id": "question-1", - "template": "list", - "title": { "text": "Какой формат подходит?" }, - "subtitle": { "text": "Можно выбрать несколько", "color": "muted" }, - "list": { - "selectionType": "multi", // или "single" - "options": [ - { "id": "opt-online", "label": "Онлайн" }, - { "id": "opt-offline", "label": "Офлайн", "description": "в вашем городе" } - ], - "bottomActionButton": { "text": "Сохранить выбор" } - }, - "bottomActionButton": { "show": false }, - "navigation": { - "defaultNextScreenId": "calendar", - "rules": [ - { - "nextScreenId": "coupon", - "conditions": [{ - "screenId": "question-1", - "operator": "includesAll", - "optionIds": ["opt-online", "opt-offline"] - }] - } - ] - } -} -``` - -Особенности: - -- `selectionType` определяет поведение: `single` строит радиокнопки, `multi` — чекбоксы. Компоненты `RadioAnswersList` и `SelectAnswersList` получают подготовленные данные из `mapListOptionsToButtons`. -- Кнопка действия может описываться либо на уровне `list.bottomActionButton`, либо через общий `bottomActionButton`. В рантайме она скрывается, если `show: false`. Для списков с одиночным выбором и скрытой кнопкой включается автопереход на следующий экран при изменении ответа.【F:src/components/funnel/templates/ListTemplate.tsx†L1-L109】【F:src/components/funnel/FunnelRuntime.tsx†L73-L199】 -- Ответы сохраняются в массиве строк (идентификаторы опций) и используются навигацией и аналитикой. - -### Экран выбора даты (`template: "date"`) - -Предлагает три выпадающих списка (месяц, день, год) и опциональный блок с отформатированной датой. - -```json -{ - "id": "calendar", - "template": "date", - "title": { "text": "Когда планируете начать?" }, - "subtitle": { "text": "Выберите ориентировочную дату", "color": "muted" }, - "dateInput": { - "monthLabel": "Месяц", - "dayLabel": "День", - "yearLabel": "Год", - "showSelectedDate": true, - "selectedDateLabel": "Вы выбрали" - }, - "infoMessage": { "text": "Мы не будем делиться датой с третьими лицами." }, - "navigation": { "defaultNextScreenId": "contact" } -} -``` - -Особенности: - -- Значение сохраняется как массив `[month, day, year]` внутри `answers` рантайма. -- Кнопка «Next» активируется только после заполнения всех полей. Настройка текстов и подсказок — через объект `dateInput` (placeholder, label, формат для превью). -- При `showSelectedDate: true` под кнопкой появляется подтверждающий блок с читабельной датой.【F:src/components/funnel/templates/DateTemplate.tsx†L1-L209】【F:src/lib/funnel/types.ts†L133-L189】 - -### Экран формы (`template: "form"`) - -Подходит для сбора контактных данных. Поле `fields` содержит список текстовых инпутов со своими правилами. - -```json -{ - "id": "contact", - "template": "form", - "title": { "text": "Оставьте контакты" }, - "fields": [ - { "id": "name", "label": "Имя", "required": true, "maxLength": 60 }, - { - "id": "email", - "label": "E-mail", - "type": "email", - "validation": { - "pattern": "^\\S+@\\S+\\.\\S+$", - "message": "Введите корректный e-mail" - } - } - ], - "validationMessages": { - "required": "Поле ${field} обязательно", - "invalidFormat": "Неверный формат" - }, - "navigation": { "defaultNextScreenId": "coupon" } -} -``` - -Особенности рантайма: - -- Локальное состояние синхронизируется с глобальным через `onFormDataChange` — данные сериализуются в JSON-строку и хранятся в массиве ответов (первый элемент).【F:src/components/funnel/FunnelRuntime.tsx†L46-L118】 -- Кнопка продолжения (`defaultTexts.continueButton` или «Continue») активна, если все обязательные поля заполнены. Валидаторы проверяют `required`, `maxLength` и регулярное выражение из `validation.pattern` с кастомными сообщениями.【F:src/components/funnel/templates/FormTemplate.tsx†L1-L119】【F:src/lib/funnel/types.ts†L191-L238】 - -### Экран промокода (`template: "coupon"`) - -Отображает купон с акцией и позволяет скопировать промокод. - -```json -{ - "id": "coupon", - "template": "coupon", - "title": { "text": "Поздравляем!" }, - "subtitle": { "text": "Получите скидку" }, - "coupon": { - "title": { "text": "Скидка 20%" }, - "offer": { - "title": { "text": "-20% на первый заказ" }, - "description": { "text": "Действует до конца месяца" } - }, - "promoCode": { "text": "START20" }, - "footer": { "text": "Скопируйте код и введите при оформлении" } - }, - "copiedMessage": "Код {code} скопирован!", - "navigation": { "defaultNextScreenId": "final-info" } -} -``` - -`CouponTemplate` копирует код в буфер обмена и показывает уведомление `copiedMessage` (строка с подстановкой `{code}`). Кнопка продолжения использует `defaultTexts.continueButton` или значение «Continue».【F:src/components/funnel/templates/CouponTemplate.tsx†L1-L111】【F:src/lib/funnel/types.ts†L191-L230】 - -## Конструктор (Builder) - -Конструктор помогает собирать JSON-конфигурацию и состоит из трёх основных областей: - -1. **Верхняя панель** (`BuilderTopBar`). Позволяет создать пустой проект, загрузить готовый JSON и экспортировать текущую конфигурацию. Импорт использует `deserializeFunnelDefinition`, добавляющий служебные координаты для канваса. Экспорт сериализует состояние обратно в формат `FunnelDefinition` (`serializeBuilderState`).【F:src/components/admin/builder/BuilderTopBar.tsx†L1-L79】【F:src/lib/admin/builder/utils.ts†L1-L58】 -2. **Канвас** (`BuilderCanvas`). Отображает экраны цепочкой, даёт возможность добавлять новые (`add-screen`), менять порядок drag-and-drop (`reorder-screens`) и выбирать экран для редактирования. Каждый экран показывает тип шаблона, количество опций и ссылку на следующий экран по умолчанию.【F:src/components/admin/builder/BuilderCanvas.tsx†L1-L132】 -3. **Боковая панель** (`BuilderSidebar`). Содержит две вкладки состояния: - - Когда экран не выбран, показываются настройки воронки (ID, заголовок, описание, стартовый экран) и сводка валидации (`validateBuilderState`).【F:src/components/admin/builder/BuilderSidebar.tsx†L1-L188】【F:src/lib/admin/builder/validation.ts†L1-L168】 - - Для выбранного экрана доступны поля заголовков, параметры списка (тип выбора, опции), правила навигации, кастомизация кнопок и инструмент удаления. Все изменения отправляются через `update-screen`, `update-navigation` и вспомогательные обработчики, формируя корректный JSON. - -### Предпросмотр - -Компонент `BuilderPreview` визуализирует выбранный экран, используя те же шаблоны, что и боевой рантайм (`ListTemplate`, `InfoTemplate` и др.). Для симуляции действий используются заглушки — выбор опций, заполнение формы и навигация обновляют локальное состояние предпросмотра, но не меняют структуру воронки. При переключении экрана состояние сбрасывается, что позволяет увидеть дефолтное поведение каждого шаблона.【F:src/components/admin/builder/BuilderPreview.tsx†L1-L123】 - -### Валидация и сериализация - -`validateBuilderState` проверяет уникальность идентификаторов экранов и опций, корректность ссылок в навигации и наличие переходов. Ошибки и предупреждения отображаются в боковой панели. При экспорте координаты канваса удаляются, чтобы JSON соответствовал ожиданиям рантайма. Ответы пользователей рантайм хранит в структуре `Record`, где ключ — `id` экрана, а значение — массив выбранных значений (опций, компонентов даты или сериализованные данные формы).【F:src/lib/admin/builder/validation.ts†L1-L168】【F:src/lib/admin/builder/utils.ts†L1-L86】【F:src/components/funnel/FunnelRuntime.tsx†L1-L215】 - -## Рабочий процесс - -1. Создайте экраны через верхнюю панель или кнопку на канвасе. Каждый новый экран получает уникальный ID (`screen-{n}`). -2. Настройте порядок переходов drag-and-drop и установите `firstScreenId`, если стартовать нужно не с первого элемента. -3. Заполните контент для каждого шаблона, настройте условия в `navigation.rules` и убедитесь, что `defaultNextScreenId` указан для веток без правил. -4. Проверьте сводку валидации — при ошибках экспорт JSON будет возможен, но рантайм может не смочь построить маршрут. -5. Экспортируйте JSON и передайте его рантайму (``). - -Такой подход гарантирует, что конструктор и рантайм используют одну и ту же схему данных, а визуальные шаблоны ведут себя предсказуемо при изменении конфигурации. diff --git a/next.config.ts b/next.config.ts index c8c9573..abc2f9f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,6 +12,9 @@ const nextConfig: NextConfig = { env: { FUNNEL_BUILD_VARIANT: buildVariant, NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: buildVariant, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + DEV_LOGGER_SERVER_ENABLED: process.env.DEV_LOGGER_SERVER_ENABLED, + NEXT_PUBLIC_AUTH_REDIRECT_URL: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL, }, }; diff --git a/package-lock.json b/package-lock.json index ac2eb8c..d092a19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@lottiefiles/dotlottie-react": "^0.17.4", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", @@ -21,6 +22,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.2.2", + "idb": "^8.0.3", "lucide-react": "^0.544.0", "mongoose": "^8.18.2", "next": "15.5.3", @@ -1711,6 +1713,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lottiefiles/dotlottie-react": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.17.4.tgz", + "integrity": "sha512-PsWq0l+Q/sGwnjWMiRJC1GUmsXFYB8zc5TacWblfaU9EQzqJzBeblk5rqtac/EDQi9QiXqpojPgWsofJX97swg==", + "license": "MIT", + "dependencies": { + "@lottiefiles/dotlottie-web": "0.54.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19" + } + }, + "node_modules/@lottiefiles/dotlottie-web": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.54.0.tgz", + "integrity": "sha512-Jc/n4i9siOXo9/1CVhKkrWC8pxxsKqKwxYfrL4DFQP/cLUAeAO0TqFPQFx9Klh1m7T+/1RPFriycOcF8gW3ZtQ==", + "license": "MIT" + }, "node_modules/@mdx-js/react": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", @@ -7510,6 +7530,12 @@ "dev": true, "license": "MIT" }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index c5a6089..d1f9023 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "dev:frontend": "node ./scripts/run-with-variant.mjs dev frontend -- --turbopack", "dev:full": "node ./scripts/run-with-variant.mjs dev full -- --turbopack", "build": "npm run build:frontend", - "build:frontend": "npm run bake:funnels && node ./scripts/run-with-variant.mjs build frontend -- --turbopack", - "build:full": "npm run bake:funnels && node ./scripts/run-with-variant.mjs build full -- --turbopack", + "build:frontend": "node ./scripts/run-with-variant.mjs build frontend -- --turbopack", + "build:full": "node ./scripts/run-with-variant.mjs build full -- --turbopack", "start": "npm run start:frontend", "start:frontend": "node ./scripts/run-with-variant.mjs start frontend", "start:full": "node ./scripts/run-with-variant.mjs start full", @@ -21,6 +21,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@lottiefiles/dotlottie-react": "^0.17.4", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", @@ -33,6 +34,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^17.2.2", + "idb": "^8.0.3", "lucide-react": "^0.544.0", "mongoose": "^8.18.2", "next": "15.5.3", diff --git a/public/funnels/soulmate.json b/public/funnels/soulmate.json new file mode 100644 index 0000000..8807e0f --- /dev/null +++ b/public/funnels/soulmate.json @@ -0,0 +1,2743 @@ +{ + "meta": { + "id": "soulmate", + "title": "Soulmate V1", + "description": "Soulmate", + "firstScreenId": "onboarding", + "yandexMetrikaId": "104471567" + }, + "defaultTexts": { + "nextButton": "Next", + "privacyBanner": "We don’t share personal information — it stays safe and under your control." + }, + "screens": [ + { + "id": "onboarding", + "template": "soulmate", + "header": { + "showBackButton": false, + "show": false + }, + "title": { + "text": "Soulmate Portrait", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "Continue", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": true + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "gender", + "isEndScreen": false + }, + "description": { + "text": "Ready to see who your true soulmate is?", + "font": "manrope", + "weight": "regular", + "size": "md", + "align": "center", + "color": "default" + }, + "variants": [], + "soulmatePortraitsDelivered": { + "image": "/soulmate-portrait-delivered-male.jpg", + "text": { + "text": "soulmate portraits delivered today", + "font": "inter", + "weight": "medium", + "size": "sm", + "color": "primary" + }, + "avatars": [ + { + "src": "/avatars/male-1.jpg", + "alt": "Male 1" + }, + { + "src": "/avatars/male-2.jpg", + "alt": "Male 2" + }, + { + "src": "/avatars/male-3.jpg", + "alt": "Male 3" + }, + { + "src": "", + "fallbackText": "900+" + } + ] + }, + "textList": { + "items": [ + { + "text": "Just 2 minutes — and the Portrait will reveal the one who’s destined to be with you." + }, + { + "text": "Astonishing 99% accuracy." + }, + { + "text": "An unexpected revelation awaits you." + }, + { + "text": "All that’s left is to dare to look." + } + ] + } + }, + { + "id": "gender", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "What’s your gender?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "It all starts with you! Choose your gender.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-gender", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "female", + "label": "FEMALE", + "emoji": "🩷", + "disabled": false + }, + { + "id": "male", + "label": "MALE", + "emoji": "💙", + "disabled": false + } + ], + "registrationFieldKey": "profile.gender" + }, + "variants": [] + }, + { + "id": "partner-gender", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Who are you interested in?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-status", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "male", + "label": "Male", + "disabled": false + }, + { + "id": "female", + "label": "Female", + "disabled": false + } + ], + "registrationFieldKey": "partner.gender" + }, + "variants": [] + }, + { + "id": "relationship-status", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "You are currently?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "This helps make the portrait and insights more accurate.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "analysis-target", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "in_relationship", + "label": "In a relationship", + "disabled": false + }, + { + "id": "single", + "label": "Single", + "disabled": false + }, + { + "id": "after_breakup", + "label": "Just went through a breakup", + "disabled": false + }, + { + "id": "its_complicated", + "label": "It’s complicated", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "analysis-target", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Who are we analyzing?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-age", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "current_partner", + "label": "Current partner", + "disabled": false + }, + { + "id": "crush", + "label": "Crush", + "disabled": false + }, + { + "id": "ex_partner", + "label": "Ex", + "disabled": false + }, + { + "id": "future_date", + "label": "Future connection", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-age", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Current partner’s age", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [ + { + "conditions": [ + { + "screenId": "partner-age", + "conditionType": "options", + "operator": "includesAny", + "optionIds": [ + "under_29" + ], + "values": [] + } + ], + "nextScreenId": "partner-age-detail" + } + ], + "defaultNextScreenId": "partner-ethnicity", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "under_29", + "label": "Under 29", + "disabled": false + }, + { + "id": "from_30_to_39", + "label": "30–39", + "disabled": false + }, + { + "id": "from_40_to_49", + "label": "40–49", + "disabled": false + }, + { + "id": "from_50_to_59", + "label": "50–59", + "disabled": false + }, + { + "id": "over_60", + "label": "60+", + "disabled": false + } + ] + }, + "variants": [ + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "current_partner" + ] + } + ], + "overrides": {} + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "crush" + ] + } + ], + "overrides": { + "title": { + "text": "Age of the person you like" + } + } + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "ex_partner" + ] + } + ], + "overrides": { + "title": { + "text": "Ex’s age" + } + } + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "future_date" + ] + } + ], + "overrides": { + "title": { + "text": "Future partner’s age" + } + } + } + ] + }, + { + "id": "partner-age-detail", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Please specify a bit more", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "So the portrait can be as accurate as possible.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-ethnicity", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "from_18_to_21", + "label": "18–21", + "disabled": false + }, + { + "id": "from_22_to_25", + "label": "22–25", + "disabled": false + }, + { + "id": "from_26_to_29", + "label": "26–29", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-ethnicity", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Your partner’s ethnicity?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-eye-color", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "white", + "label": "White", + "disabled": false + }, + { + "id": "hispanic_latino", + "label": "Hispanic / Latino", + "disabled": false + }, + { + "id": "african_african_american", + "label": "African / African-American", + "disabled": false + }, + { + "id": "asian", + "label": "Asian", + "disabled": false + }, + { + "id": "indian_south_asian", + "label": "Indian / South Asian", + "disabled": false + }, + { + "id": "middle_eastern_arab", + "label": "Middle Eastern / Arab", + "disabled": false + }, + { + "id": "native_american_indigenous", + "label": "Native American / Indigenous", + "disabled": false + }, + { + "id": "no_preference", + "label": "No preference", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-eye-color", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Your partner’s eye color?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-hair-length", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "brown", + "label": "Brown", + "disabled": false + }, + { + "id": "blue", + "label": "Blue", + "disabled": false + }, + { + "id": "hazel", + "label": "Hazel", + "disabled": false + }, + { + "id": "green", + "label": "Green", + "disabled": false + }, + { + "id": "amber", + "label": "Amber", + "disabled": false + }, + { + "id": "gray", + "label": "Gray", + "disabled": false + }, + { + "id": "unknown", + "label": "I don’t know", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-hair-length", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Choose the hair length", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "It affects the portrait’s shape and mood.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "burnout-support", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "short", + "label": "Short", + "disabled": false + }, + { + "id": "medium", + "label": "Medium", + "disabled": false + }, + { + "id": "long", + "label": "Long", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "burnout-support", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "When you’re burned out, you need your partner to", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "burnout-result", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "acknowledged_and_calmed", + "label": "Acknowledge your frustration and comfort you", + "disabled": false + }, + { + "id": "gave_emotional_support", + "label": "Give emotional support and a safe space", + "disabled": false + }, + { + "id": "took_over_tasks", + "label": "Take over daily tasks so you can recover", + "disabled": false + }, + { + "id": "inspired_with_plan", + "label": "Inspire you with a goal and a short action plan", + "disabled": false + }, + { + "id": "shifted_to_positive", + "label": "Shift your focus to something positive — a walk, a movie, funny stories", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "burnout-result", + "template": "info", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "burnout-result", + "show": false, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "This kind of partner **knows how to listen and support**, and you’re a **deep soul** who values honesty and the power of genuine emotions.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "birthdate", + "isEndScreen": false + }, + "icon": { + "type": "image", + "value": "/images/fb6eb360-db1e-4433-9c8a-33eedfad1b12.svg", + "size": "lg" + }, + "variables": [], + "variants": [ + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "acknowledged_and_calmed" + ] + } + ], + "overrides": {} + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "gave_emotional_support" + ] + } + ], + "overrides": { + "subtitle": { + "text": "This kind of person creates a **sense of security**, and you have the wisdom and emotional maturity to choose closeness and trust." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "took_over_tasks" + ] + } + ], + "overrides": { + "subtitle": { + "text": "This kind of partner is ready to **lend a shoulder** when it’s needed, and your strength lies in your ability to **trust** and **accept support** — that’s your natural wisdom." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "inspired_with_plan" + ] + } + ], + "overrides": { + "subtitle": { + "text": "This kind of person **brings clarity** and **motivates**, while you stand out for your **willpower** and **drive for growth** — you’re not afraid to move forward." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "shifted_to_positive" + ] + } + ], + "overrides": { + "subtitle": { + "text": "This kind of partner knows how to **bring back joy**, and you show your strength through your ability to **stay lighthearted** and **keep a bright outlook** on life." + } + } + } + ] + }, + { + "id": "birthdate", + "template": "date", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "When were you born?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "The moment you were born holds deep underlying patterns.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "nature-archetype", + "isEndScreen": false + }, + "dateInput": { + "monthLabel": "Month", + "dayLabel": "Month", + "yearLabel": "Month", + "monthPlaceholder": "MM", + "dayPlaceholder": "DD", + "yearPlaceholder": "YYYY", + "showSelectedDate": true, + "selectedDateFormat": "dd MMMM yyyy", + "selectedDateLabel": "Selected date:", + "zodiac": { + "enabled": true, + "storageKey": "userZodiac" + }, + "registrationFieldKey": "profile.birthdate", + "validationMessage": "Please enter a valid date" + }, + "variants": [] + }, + { + "id": "nature-archetype", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Which natural symbol best matches your personality?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "love-priority", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "flower", + "label": "Flower — tenderness, care, charm", + "emoji": "🌹", + "disabled": false + }, + { + "id": "sea", + "label": "Sea — depth, mystery, emotion", + "emoji": "🌊", + "disabled": false + }, + { + "id": "sun", + "label": "Sun — energy, strength, brightness", + "emoji": "🌞️", + "disabled": false + }, + { + "id": "moon", + "label": "Moon — intuition, sensitivity", + "emoji": "🌙", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "love-priority", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "When it comes to love, what matters more to you: heart or mind?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "love-priority-result", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "follow_heart", + "label": "I follow my heart", + "emoji": "🧡", + "disabled": false + }, + { + "id": "follow_mind", + "label": "I rely on my mind", + "emoji": "🧠", + "disabled": false + }, + { + "id": "balance_heart_mind", + "label": "A balance of heart and mind", + "emoji": "🎯", + "disabled": false + }, + { + "id": "depends_on_situation", + "label": "Depends on the situation", + "emoji": "⚖️", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "love-priority-result", + "template": "info", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Title", + "show": false, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "According to our statistics, **51% of {{gender}} {{zodiac}}** trust their emotions. But sensitivity alone isn’t enough. We’ll show which qualities in your partner will bring warmth and confidence — and create their portrait.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-block", + "isEndScreen": false + }, + "icon": { + "type": "image", + "value": "/images/528c3574-2121-46cd-b5e5-b1fda5ae9315.svg", + "size": "xl" + }, + "variables": [ + { + "name": "gender", + "mappings": [ + { + "conditions": [ + { + "screenId": "gender", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "male" + ] + } + ], + "value": "men" + } + ], + "fallback": "women" + }, + { + "name": "zodiac", + "mappings": [ + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "aries" + ] + } + ], + "value": "Aries" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "taurus" + ] + } + ], + "value": "Taurus" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "gemini" + ] + } + ], + "value": "Gemini" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "cancer" + ] + } + ], + "value": "Cancer" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "leo" + ] + } + ], + "value": "Leo" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "virgo" + ] + } + ], + "value": "Virgo" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "libra" + ] + } + ], + "value": "Libra" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "scorpio" + ] + } + ], + "value": "Scorpio" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "sagittarius" + ] + } + ], + "value": "Sagittarius" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "capricorn" + ] + } + ], + "value": "Capricorn" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "aquarius" + ] + } + ], + "value": "Aquarius" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "pisces" + ] + } + ], + "value": "Pisces" + } + ], + "fallback": "Pisces" + } + ], + "variants": [ + { + "conditions": [ + { + "screenId": "love-priority", + "operator": "includesAny", + "optionIds": [ + "follow_mind" + ] + } + ], + "overrides": { + "subtitle": { + "text": "According to our statistics, **43% of {{gender}} {{zodiac}}** choose reason. But calculations alone aren’t enough. We’ll reveal which traits in your partner will build trust — and create their portrait." + }, + "icon": { + "value": "/images/575ab717-eaa5-462b-8aa6-0202a62c9099.svg" + } + } + }, + { + "conditions": [ + { + "screenId": "love-priority", + "operator": "includesAny", + "optionIds": [ + "balance_heart_mind" + ] + } + ], + "overrides": { + "subtitle": { + "text": "According to our statistics, **47% of {{gender}} {{zodiac}}** seek balance. But keeping it isn’t easy. We’ll show which qualities in your partner will unite passion and stability — and create their portrait." + }, + "icon": { + "value": "/images/7dd85bf0-4b92-4213-9e2a-82ba1e53d165.svg" + } + } + }, + { + "conditions": [ + { + "screenId": "love-priority", + "operator": "includesAny", + "optionIds": [ + "depends_on_situation" + ] + } + ], + "overrides": { + "subtitle": { + "text": "According to our statistics, **37% of {{gender}} {{zodiac}}** make their choice based on circumstances. But such flexibility often leads to doubt. We’ll reveal who can bring you stability and confidence — and draw your partner’s portrait." + }, + "icon": { + "value": "/images/6bd25c4d-9308-4907-a54f-b7bc10322fa8.svg" + } + } + } + ] + }, + { + "id": "relationship-block", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "What gets in the way of your relationships the most?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-block-result", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "fear_of_wrong_choice", + "label": "Fear of making the wrong choice again", + "emoji": "💔", + "disabled": false + }, + { + "id": "wasted_years", + "label": "Wasting years on the “wrong” person", + "emoji": "🕰️", + "disabled": false + }, + { + "id": "lack_of_depth", + "label": "There’s passion, but not enough depth", + "emoji": "🔥", + "disabled": false + }, + { + "id": "unclear_desires", + "label": "Not sure what I really want", + "emoji": "🗝", + "disabled": false + }, + { + "id": "stuck_in_past", + "label": "Can’t let go of a past relationship", + "emoji": "👻", + "disabled": false + }, + { + "id": "fear_of_loneliness", + "label": "Afraid of being alone", + "emoji": "🕯", + "disabled": false + } + ] + }, + "variants": [ + { + "conditions": [ + { + "screenId": "relationship-status", + "operator": "includesAny", + "optionIds": [ + "single", + "after_breakup" + ] + } + ], + "overrides": { + "list": { + "options": [ + { + "id": "fear_of_wrong_choice", + "label": "Fear of making the wrong choice again", + "emoji": "💔" + }, + { + "id": "wasted_years", + "label": "Feeling like the years are slipping away", + "emoji": "🕰️" + }, + { + "id": "wrong_people", + "label": "Meeting interesting people, but not the right one", + "emoji": "😕" + }, + { + "id": "unclear_needs", + "label": "Not sure who I really need", + "emoji": "🧩" + }, + { + "id": "stuck_in_past", + "label": "The past keeps me from moving on", + "emoji": "👻" + }, + { + "id": "fear_of_loneliness", + "label": "Afraid of being alone", + "emoji": "🕯" + } + ] + }, + "title": { + "text": "What gets in the way of finding love the most?" + } + } + } + ] + }, + { + "id": "relationship-block-result", + "template": "info", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "You’re not alone in this fear.", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "Many people are afraid of repeating the past. We’ll help you recognize the right signs and choose the person who’s truly meant for you.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "core-need", + "isEndScreen": false + }, + "icon": { + "type": "image", + "value": "/images/1d7d3979-6627-416e-8f80-c0388a6cec22.svg", + "size": "xl" + }, + "variables": [], + "variants": [ + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "wasted_years" + ] + } + ], + "overrides": { + "title": { + "text": "This pain is familiar to many." + }, + "subtitle": { + "text": "The feeling of wasted time is hard. We’ll show you how to stop getting stuck in the past and move forward." + }, + "icon": { + "value": "/images/5ae02c30-44a0-4a8c-a814-9fd2490cdc77.svg" + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "lack_of_depth" + ] + } + ], + "overrides": { + "title": { + "text": "Many people face this." + }, + "subtitle": { + "text": "Bright emotions fade quickly without a foundation. We’ll help you turn a connection into true closeness." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "unclear_desires" + ] + } + ], + "overrides": { + "title": { + "text": "This is often hard to figure out." + }, + "subtitle": { + "text": "Understanding yourself is the key to making the right choice. We’ll help you see which qualities truly matter to you." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "stuck_in_past" + ] + } + ], + "overrides": { + "title": { + "text": "You’re not the only one stuck in the past." + }, + "subtitle": { + "text": "The past can hold on too tightly. We’ll show you how to let go and make room for new love." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "fear_of_loneliness" + ] + } + ], + "overrides": { + "title": { + "text": "This fear is very familiar to many." + }, + "subtitle": { + "text": "The thought of a lonely future is frightening. We’ll help you build a path where someone special walks beside you." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "wrong_people" + ] + } + ], + "overrides": { + "title": { + "text": "Many people go through this." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "unclear_needs" + ] + } + ], + "overrides": { + "title": { + "text": "It’s okay not to know right away." + }, + "subtitle": { + "text": "Figuring out what kind of partner you truly need isn’t easy. We’ll help you see which qualities really matter." + } + } + } + ] + }, + { + "id": "core-need", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "What’s your core need right now?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-similarity", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "safety_and_support", + "label": "Safety and support", + "disabled": false + }, + { + "id": "passion_and_spark", + "label": "Passion and spark", + "disabled": false + }, + { + "id": "calm_and_acceptance", + "label": "Calm and acceptance", + "disabled": false + }, + { + "id": "inspiration_and_growth", + "label": "Inspiration and growth", + "disabled": false + }, + { + "id": "not_important", + "label": "Doesn’t matter", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-similarity", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Is your partner similar to you?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "partner-role", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "similar", + "label": "Yes, we have things in common", + "disabled": false + }, + { + "id": "different", + "label": "We’re completely different", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-role", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Preferred partner role", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-strength", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "leader", + "label": "Leader", + "disabled": false + }, + { + "id": "equal", + "label": "Equal", + "disabled": false + }, + { + "id": "supportive", + "label": "Supportive", + "disabled": false + }, + { + "id": "flexible", + "label": "Flexible", + "disabled": false + }, + { + "id": "dependent", + "label": "Dependent on me", + "disabled": false + }, + { + "id": "situational", + "label": "Depends on the situation", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-strength", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "What’s your main source of strength in a relationship?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "love-expression", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "support_and_care", + "label": "Support and care", + "disabled": false + }, + { + "id": "admiration_and_recognition", + "label": "Admiration and appreciation", + "disabled": false + }, + { + "id": "freedom_and_space", + "label": "Freedom and space", + "disabled": false + }, + { + "id": "shared_goals_and_plans", + "label": "Shared goals and plans", + "disabled": false + }, + { + "id": "joy_and_lightness", + "label": "Joy and lightness", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "love-expression", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "How do you express love?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-future", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "words", + "label": "With words", + "disabled": false + }, + { + "id": "actions", + "label": "Through actions", + "disabled": false + }, + { + "id": "quality_time", + "label": "By spending time together", + "disabled": false + }, + { + "id": "care", + "label": "With care", + "disabled": false + }, + { + "id": "passion", + "label": "With passion", + "disabled": false + }, + { + "id": "in_my_own_way", + "label": "In my own way", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-future", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "How do you see your relationship’s future?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-energy", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "home_and_family", + "label": "A shared home and family", + "disabled": false + }, + { + "id": "travel_and_discovery", + "label": "Travel and new discoveries", + "disabled": false + }, + { + "id": "shared_goals", + "label": "Joint projects and goals", + "disabled": false + }, + { + "id": "present_moment", + "label": "Just being together “here and now”", + "disabled": false + }, + { + "id": "unsure", + "label": "Hard to say for now", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-energy", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "What kind of energy do you want in a relationship?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-metaphor", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "lightness_and_joy", + "label": "Lightness and joy", + "disabled": false + }, + { + "id": "strength_and_drive", + "label": "Strength and drive", + "disabled": false + }, + { + "id": "comfort_and_safety", + "label": "Comfort and stability", + "disabled": false + }, + { + "id": "depth_and_meaning", + "label": "Depth and meaning", + "disabled": false + }, + { + "id": "freedom_and_space", + "label": "Freedom and space", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-metaphor", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Which image of a relationship feels closest to you?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "You can choose several options.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "portrait-generation", + "isEndScreen": false + }, + "list": { + "selectionType": "multi", + "options": [ + { + "id": "bridge", + "label": "Bridge — connection through obstacles", + "disabled": false + }, + { + "id": "mountain_path", + "label": "Mountain path — challenges and meaning", + "disabled": false + }, + { + "id": "dance", + "label": "Dance — balance and mutual steps", + "disabled": false + }, + { + "id": "key_and_lock", + "label": "Key and lock — shared values", + "disabled": false + }, + { + "id": "harbor", + "label": "Harbor — safety and peace", + "disabled": false + }, + { + "id": "lighthouse", + "label": "Lighthouse — guidance and support", + "disabled": false + }, + { + "id": "ocean_after_storm", + "label": "Ocean after the storm — renewal and new beginnings", + "disabled": false + }, + { + "id": "garden", + "label": "Garden — care and growth", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "portrait-generation", + "template": "loaders", + "header": { + "showBackButton": false, + "show": false + }, + "title": { + "text": "Creating the portrait of your other half.", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "Continue", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "email", + "isEndScreen": false + }, + "progressbars": { + "items": [ + { + "processingTitle": "Analyzing your answers", + "processingSubtitle": "Processing...", + "completedTitle": "Analyzing your answers", + "completedSubtitle": "Complete" + }, + { + "processingTitle": "Portrait of the Soulmate", + "processingSubtitle": "Processing...", + "completedTitle": "Portrait of the Soulmate", + "completedSubtitle": "Complete" + }, + { + "processingTitle": "Portrait of the Soulmate", + "processingSubtitle": "Processing...", + "completedTitle": "Connection Insights", + "completedSubtitle": "Complete" + } + ], + "transitionDuration": 3000 + }, + "variants": [] + }, + { + "id": "email", + "template": "email", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Your soulmate’s portrait is ready! Where should we send it?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "Continue", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": true + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "coupon", + "isEndScreen": false + }, + "emailInput": { + "label": "Email", + "placeholder": "example@email.com" + }, + "image": { + "src": "/female-portrait.jpg" + }, + "variants": [ + { + "conditions": [ + { + "screenId": "gender", + "operator": "includesAny", + "optionIds": [ + "male" + ] + } + ], + "overrides": { + "image": { + "src": "/male-portrait.jpg" + } + } + } + ] + }, + { + "id": "coupon", + "template": "coupon", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "You’re in luck!", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "You’ve received an exclusive 94% discount.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "payment", + "isEndScreen": false + }, + "coupon": { + "title": { + "text": "Special Offer", + "font": "manrope", + "weight": "bold", + "align": "center", + "size": "lg", + "color": "default" + }, + "offer": { + "title": { + "text": "94% OFF", + "font": "manrope", + "weight": "bold", + "align": "center", + "size": "3xl", + "color": "primary" + }, + "description": { + "text": "One-time special offer", + "font": "inter", + "weight": "medium", + "color": "muted", + "align": "center", + "size": "md" + } + }, + "promoCode": { + "text": "SOULMATE94", + "font": "geistMono", + "weight": "bold", + "align": "center", + "size": "lg", + "color": "accent" + }, + "footer": { + "text": "Copy or click **Continue**", + "font": "inter", + "weight": "medium", + "color": "muted", + "align": "center", + "size": "sm" + } + }, + "copiedMessage": "Promo code copied!", + "variants": [] + }, + { + "id": "payment", + "template": "trialPayment", + "header": { + "showBackButton": true, + "show": false + }, + "title": { + "text": "Title", + "show": false, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": false, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "variants": [], + "headerBlock": { + "text": { + "text": "⚠️ Your sketch expires soon!" + }, + "timer": { + "text": "" + }, + "timerSeconds": 600 + }, + "unlockYourSketch": { + "title": { + "text": "Unlock Your Sketch" + }, + "subtitle": { + "text": "Just One Click to Reveal Your Match!" + }, + "image": { + "src": "/trial-payment/portrait-female.jpg" + }, + "blur": { + "text": { + "text": "Unlock to reveal your personalized portrait" + }, + "icon": "lock" + }, + "buttonText": "Get Me Soulmate Sketch" + }, + "joinedToday": { + "count": { + "text": "954" + }, + "text": { + "text": "Joined today" + } + }, + "trustedByOver": { + "text": { + "text": "Trusted by over 355,000 people." + } + }, + "findingOneGuide": { + "header": { + "emoji": { + "text": "❤️" + }, + "title": { + "text": "Finding the One Guide" + } + }, + "text": { + "text": "You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're. You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're" + }, + "blur": { + "text": { + "text": "Full access is required to unlock the complete report." + }, + "icon": "lock" + } + }, + "tryForDays": { + "title": { + "text": "Try it for 7 days!" + }, + "textList": { + "items": [ + { + "text": "Receive a hand-drawn sketch of your soulmate." + }, + { + "text": "Reveal the path with the guide." + }, + { + "text": "Talk to live experts and get guidance." + }, + { + "text": "Start your 7-day trial for just $1.00." + }, + { + "text": "Cancel anytime—just 24 hours before renewal." + } + ] + } + }, + "totalPrice": { + "couponContainer": { + "title": { + "text": "Coupon\nCode" + }, + "buttonText": "SOULMATE94" + }, + "priceContainer": { + "title": { + "text": "Total" + }, + "price": { + "text": "$1.00" + }, + "oldPrice": { + "text": "$14.99" + }, + "discount": { + "text": "94% discount applied" + } + } + }, + "paymentButtons": { + "buttons": [ + { + "text": "Pay", + "icon": "pay" + }, + { + "text": "Pay", + "icon": "google" + }, + { + "text": "Credit or debit card", + "icon": "card", + "primary": true + } + ] + }, + "moneyBackGuarantee": { + "title": { + "text": "30-DAY MONEY-BACK GUARANTEE" + }, + "text": { + "text": "If you don't receive your soulmate sketch, we'll refund your money!" + } + }, + "policy": { + "text": { + "text": "By clicking Continue, you agree to our Terms of Use & Service and Privacy Policy. You also acknowledge that your 1 week introductory plan to Respontika, billed at $1.00, will automatically renew at $14.50 every 1 week unless canceled before the end of the trial period." + } + }, + "usersPortraits": { + "title": { + "text": "Our Users' Soulmate Portraits" + }, + "images": [ + { + "src": "/trial-payment/users-portraits/1.jpg" + }, + { + "src": "/trial-payment/users-portraits/2.jpg" + }, + { + "src": "/trial-payment/users-portraits/3.jpg" + }, + { + "src": "/trial-payment/users-portraits/4.jpg" + }, + { + "src": "/trial-payment/users-portraits/5.jpg" + }, + { + "src": "/trial-payment/users-portraits/6.jpg" + } + ], + "buttonText": "Get me soulmate sketch" + }, + "joinedTodayWithAvatars": { + "count": { + "text": "954" + }, + "text": { + "text": "people joined today" + }, + "avatars": { + "images": [ + { + "src": "/trial-payment/avatars/1.jpg" + }, + { + "src": "/trial-payment/avatars/2.jpg" + }, + { + "src": "/trial-payment/avatars/3.jpg" + }, + { + "src": "/trial-payment/avatars/4.jpg" + }, + { + "src": "/trial-payment/avatars/5.jpg" + } + ] + } + }, + "progressToSeeSoulmate": { + "title": { + "text": "See Your Soulmate – Just One Step Away" + }, + "progress": { + "value": 92 + }, + "leftText": { + "text": "Step 2 of 5" + }, + "rightText": { + "text": "99% Complete" + } + }, + "stepsToSeeSoulmate": { + "steps": [ + { + "title": { + "text": "Questions Answered" + }, + "description": { + "text": "You've provided all the necessary information." + }, + "icon": "questions", + "isActive": true + }, + { + "title": { + "text": "Profile Analysis" + }, + "description": { + "text": "Creating your perfect soulmate profile." + }, + "icon": "profile", + "isActive": true + }, + { + "title": { + "text": "Sketch Creation" + }, + "description": { + "text": "Your personalized soulmate sketch will be created." + }, + "icon": "sketch" + }, + { + "title": { + "text": "Astrological Insights" + }, + "description": { + "text": "Unique astrology-based recommendations." + }, + "icon": "astro" + }, + { + "title": { + "text": "Personalized chat with an expert" + }, + "description": { + "text": "Individual guidance." + }, + "icon": "chat" + } + ], + "buttonText": "Show Me My Soulmate" + }, + "reviews": { + "title": { + "text": "Loved and Trusted Worldwide" + }, + "items": [ + { + "name": { + "text": "Jennifer Wilson 🇺🇸" + }, + "text": { + "text": "**“I saw my mistakes… and found my husband.”**\nThe portrait instantly struck me — I had this feeling like I’d seen him somewhere before. But the real turning point came after the guide: I finally understood why I kept choosing the “wrong” people. And the most amazing part — soon after, I met a man who turned out to be the exact image from that portrait. He’s my husband now, and when we compared the drawing to his photo, the resemblance was just wow." + }, + "avatar": { + "src": "/trial-payment/reviews/avatars/1.jpg" + }, + "portrait": { + "src": "/trial-payment/reviews/portraits/1.jpg" + }, + "photo": { + "src": "/trial-payment/reviews/photos/1.jpg" + }, + "rating": 5, + "date": { + "text": "1 day ago" + } + }, + { + "name": { + "text": "Amanda Davis 🇨🇦" + }, + "text": { + "text": "**“I understood my partner better in one evening than in several years.”**\nI took the test just for fun — the portrait surprised us. But the real breakthrough came when I read the guide about my other half. It had spot-on insights about how we can support each other. The price was nothing, but the value was huge — now we have fewer misunderstandings and so much more warmth." + }, + "avatar": { + "src": "/trial-payment/reviews/avatars/2.jpg" + }, + "portrait": { + "src": "/trial-payment/reviews/portraits/2.jpg" + }, + "photo": { + "src": "/trial-payment/reviews/photos/2.jpg" + }, + "rating": 5, + "date": { + "text": "4 days ago" + } + }, + { + "name": { + "text": "Michael Johnson 🇬🇧" + }, + "text": { + "text": "**“I saw her face — and got goosebumps.”**\nWhen I got my test results and saw the portrait, I literally froze. It was the exact girl I’d started dating a couple of weeks earlier. And the guide described perfectly why we’re drawn to each other. Honestly, I didn’t expect such a match." + }, + "avatar": { + "src": "/trial-payment/reviews/avatars/3.jpg" + }, + "portrait": { + "src": "/trial-payment/reviews/portraits/3.jpg" + }, + "photo": { + "src": "/trial-payment/reviews/photos/3.jpg" + }, + "rating": 5, + "date": { + "text": "1 week ago" + } + } + ] + }, + "stillHaveQuestions": { + "title": { + "text": "Still have questions? We're here to help!" + }, + "actionButtonText": "Get me Soulmate Sketch", + "contactButtonText": "Contact Support" + }, + "commonQuestions": { + "title": { + "text": "Common Questions" + }, + "items": [ + { + "question": "When will I receive my sketch?", + "answer": "Your personalized soulmate sketch will be delivered within 24-48 hours after completing your order. You'll receive an email notification when it's ready for viewing in your account." + }, + { + "question": "How do I cancel my subscription?", + "answer": "You can cancel anytime from your account settings. Make sure to cancel at least 24 hours before the renewal date to avoid being charged." + }, + { + "question": "How accurate are the readings?", + "answer": "Our readings are based on a combination of your answers and advanced pattern analysis. While they provide valuable insights, they are intended for guidance and entertainment purposes." + }, + { + "question": "Is my data secure and private?", + "answer": "Yes. We follow strict data protection standards. Your data is encrypted and never shared with third parties without your consent." + } + ] + }, + "footer": { + "title": { + "text": "WIT LAB ©" + }, + "contacts": { + "title": { + "text": "CONTACTS" + }, + "email": { + "href": "support@witlab.com", + "text": "support@witlab.com" + }, + "address": { + "text": "Wit Lab 2108 N ST STE N SACRAMENTO, CA95816, US" + } + }, + "legal": { + "title": { + "text": "LEGAL" + }, + "links": [ + { + "href": "https://witlab.com/terms", + "text": "Terms of Service" + }, + { + "href": "https://witlab.com/privacy", + "text": "Privacy Policy" + }, + { + "href": "https://witlab.com/refund", + "text": "Refund Policy" + } + ], + "copyright": { + "text": "Copyright © 2025 Wit Lab™. All rights reserved. All trademarks referenced herein are the properties of their respective owners." + } + }, + "paymentMethods": { + "title": { + "text": "PAYMENT METHODS" + }, + "methods": [ + { + "src": "/trial-payment/payment-methods/visa.svg", + "alt": "visa" + }, + { + "src": "/trial-payment/payment-methods/mastercard.svg", + "alt": "mastercard" + }, + { + "src": "/trial-payment/payment-methods/discover.svg", + "alt": "discover" + }, + { + "src": "/trial-payment/payment-methods/apple.svg", + "alt": "apple" + }, + { + "src": "/trial-payment/payment-methods/google.svg", + "alt": "google" + }, + { + "src": "/trial-payment/payment-methods/paypal.svg", + "alt": "paypal" + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/public/images/1d7d3979-6627-416e-8f80-c0388a6cec22.svg b/public/images/1d7d3979-6627-416e-8f80-c0388a6cec22.svg new file mode 100644 index 0000000..1482689 --- /dev/null +++ b/public/images/1d7d3979-6627-416e-8f80-c0388a6cec22.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/528c3574-2121-46cd-b5e5-b1fda5ae9315.svg b/public/images/528c3574-2121-46cd-b5e5-b1fda5ae9315.svg new file mode 100644 index 0000000..c043b88 --- /dev/null +++ b/public/images/528c3574-2121-46cd-b5e5-b1fda5ae9315.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/575ab717-eaa5-462b-8aa6-0202a62c9099.svg b/public/images/575ab717-eaa5-462b-8aa6-0202a62c9099.svg new file mode 100644 index 0000000..36c78d4 --- /dev/null +++ b/public/images/575ab717-eaa5-462b-8aa6-0202a62c9099.svg @@ -0,0 +1,522 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/5ae02c30-44a0-4a8c-a814-9fd2490cdc77.svg b/public/images/5ae02c30-44a0-4a8c-a814-9fd2490cdc77.svg new file mode 100644 index 0000000..1482689 --- /dev/null +++ b/public/images/5ae02c30-44a0-4a8c-a814-9fd2490cdc77.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/6bd25c4d-9308-4907-a54f-b7bc10322fa8.svg b/public/images/6bd25c4d-9308-4907-a54f-b7bc10322fa8.svg new file mode 100644 index 0000000..1094cdd --- /dev/null +++ b/public/images/6bd25c4d-9308-4907-a54f-b7bc10322fa8.svg @@ -0,0 +1,572 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/7dd85bf0-4b92-4213-9e2a-82ba1e53d165.svg b/public/images/7dd85bf0-4b92-4213-9e2a-82ba1e53d165.svg new file mode 100644 index 0000000..1094cdd --- /dev/null +++ b/public/images/7dd85bf0-4b92-4213-9e2a-82ba1e53d165.svg @@ -0,0 +1,572 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/fb6eb360-db1e-4433-9c8a-33eedfad1b12.svg b/public/images/fb6eb360-db1e-4433-9c8a-33eedfad1b12.svg new file mode 100644 index 0000000..7be0680 --- /dev/null +++ b/public/images/fb6eb360-db1e-4433-9c8a-33eedfad1b12.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/trial-payment/users-portraits/4.jpg b/public/trial-payment/users-portraits/4.jpg new file mode 100644 index 0000000..e84c3ef Binary files /dev/null and b/public/trial-payment/users-portraits/4.jpg differ diff --git a/public/trial-payment/users-portraits/5.jpg b/public/trial-payment/users-portraits/5.jpg new file mode 100644 index 0000000..fbe8eaf Binary files /dev/null and b/public/trial-payment/users-portraits/5.jpg differ diff --git a/public/trial-payment/users-portraits/6.jpg b/public/trial-payment/users-portraits/6.jpg new file mode 100644 index 0000000..5b0386b Binary files /dev/null and b/public/trial-payment/users-portraits/6.jpg differ diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index 1555e04..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Scripts Documentation - -## Funnel Management Scripts - -### 📥 `import-funnels-to-db.mjs` - -Импортирует воронки из JSON файлов в `public/funnels/` в MongoDB. - -```bash -npm run import:funnels -``` - -### 📤 `sync-funnels-from-db.mjs` - -Синхронизирует опубликованные воронки из MongoDB обратно в проект: - -1. Извлекает все последние версии опубликованных воронок из БД -2. Сохраняет их в JSON файлы в `public/funnels/` -3. Запекает их в TypeScript (`src/lib/funnel/bakedFunnels.ts`) -4. Сохраняет JSON файлы по умолчанию - -#### Основное использование: - -```bash -# Синхронизация всех воронок -npm run sync:funnels - -# Просмотр справки -npm run sync:funnels -- --help -``` - -#### Опции: - -**`--dry-run`** - Показать что будет синхронизировано без реальных изменений: -```bash -npm run sync:funnels -- --dry-run -``` - -**`--clean-files`** - Удалить JSON файлы после запекания (по умолчанию сохраняются): -```bash -npm run sync:funnels -- --clean-files -``` - -**`--funnel-ids `** - Синхронизировать только определенные воронки: -```bash -npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator -``` - -**Комбинирование опций:** -```bash -npm run sync:funnels -- --dry-run --funnel-ids funnel-test -npm run sync:funnels -- --clean-files --dry-run -``` - -### 🔥 `bake-funnels.mjs` - -Конвертирует JSON файлы воронок в TypeScript константы. - -```bash -npm run bake:funnels -``` - -## Workflow - -### Разработка локально: -1. Создать/редактировать воронки в админке -2. Опубликовать их -3. Запустить `npm run sync:funnels` для обновления кода - -### Деплой: -1. Запустить `npm run sync:funnels` перед билдом -2. Собрать проект с актуальными воронками - -### Отладка: -1. `npm run sync:funnels -- --dry-run` - посмотреть что будет синхронизировано -2. `npm run sync:funnels -- --keep-files` - оставить JSON файлы для проверки -3. `npm run sync:funnels -- --funnel-ids specific-id` - синхронизировать только одну воронку diff --git a/scripts/bake-funnels.mjs b/scripts/bake-funnels.mjs index 0cdd50d..0dd4f74 100644 --- a/scripts/bake-funnels.mjs +++ b/scripts/bake-funnels.mjs @@ -9,6 +9,27 @@ const projectRoot = path.resolve(__dirname, ".."); const funnelsDir = path.join(projectRoot, "public", "funnels"); const outputFile = path.join(projectRoot, "src", "lib", "funnel", "bakedFunnels.ts"); +/** + * Нормализует данные воронки перед запеканием + * Удаляет поля которые не соответствуют типам TypeScript + */ +function normalizeFunnelData(funnelData) { + return { + ...funnelData, + screens: funnelData.screens.map((screen) => { + const normalizedScreen = { ...screen }; + + // Удаляем variables из экранов, которые не поддерживают это поле + // variables поддерживается только в info экранах + if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') { + delete normalizedScreen.variables; + } + + return normalizedScreen; + }), + }; +} + function formatFunnelRecord(funnels) { const entries = Object.entries(funnels) .map(([funnelId, definition]) => { @@ -59,7 +80,8 @@ async function bakeFunnels() { ); } - funnels[funnelId] = parsed; + // Нормализуем данные перед запеканием + funnels[funnelId] = normalizeFunnelData(parsed); } const headerComment = `/**\n * This file is auto-generated by scripts/bake-funnels.mjs.\n * Do not edit this file manually; update the source JSON files instead.\n */`; diff --git a/scripts/sync-funnels-from-db.mjs b/scripts/sync-funnels-from-db.mjs index 0186744..dac8564 100755 --- a/scripts/sync-funnels-from-db.mjs +++ b/scripts/sync-funnels-from-db.mjs @@ -135,9 +135,30 @@ async function downloadImagesFromDatabase(funnels) { for (const funnel of funnels) { for (const screen of funnel.funnelData.screens) { + // Проверяем основной icon экрана (info экраны) if (screen.icon?.type === 'image' && screen.icon.value?.startsWith('/api/images/')) { imageUrls.add(screen.icon.value); } + + // Проверяем image экрана (email экраны) + if (screen.image?.src?.startsWith('/api/images/')) { + imageUrls.add(screen.image.src); + } + + // Проверяем icon и image в вариантах экрана + if (screen.variants && Array.isArray(screen.variants)) { + for (const variant of screen.variants) { + // icon в вариантах (info экраны) + // В вариантах может не быть поля type, проверяем только value + if (variant.overrides?.icon?.value?.startsWith('/api/images/')) { + imageUrls.add(variant.overrides.icon.value); + } + // image в вариантах (email экраны) + if (variant.overrides?.image?.src?.startsWith('/api/images/')) { + imageUrls.add(variant.overrides.image.src); + } + } + } } } @@ -159,11 +180,26 @@ async function downloadImagesFromDatabase(funnels) { if (image) { const localPath = path.join(imagesDir, filename); - await fs.writeFile(localPath, image.data); + + // Преобразуем MongoDB Binary в Buffer + let buffer; + if (Buffer.isBuffer(image.data)) { + buffer = image.data; + } else if (image.data?.buffer) { + // BSON Binary объект имеет свойство buffer + buffer = Buffer.from(image.data.buffer); + } else if (image.data instanceof Uint8Array) { + buffer = Buffer.from(image.data); + } else { + // Fallback - пробуем напрямую преобразовать + buffer = Buffer.from(image.data); + } + + await fs.writeFile(localPath, buffer); // Создаем маппинг: старый URL → новый локальный путь imageMapping[imageUrl] = `/images/${filename}`; - console.log(`💾 Downloaded ${filename}`); + console.log(`💾 Downloaded ${filename} (${buffer.length} bytes)`); } else { console.warn(`⚠️ Image not found in database: ${filename}`); } @@ -182,12 +218,42 @@ async function downloadImagesFromDatabase(funnels) { function updateImageUrlsInFunnels(funnels, imageMapping) { for (const funnel of funnels) { for (const screen of funnel.funnelData.screens) { + // Обновляем основной icon экрана (info экраны) if (screen.icon?.type === 'image' && screen.icon.value && imageMapping[screen.icon.value]) { const oldUrl = screen.icon.value; const newUrl = imageMapping[oldUrl]; screen.icon.value = newUrl; console.log(`🔗 Updated image URL: ${oldUrl} → ${newUrl}`); } + + // Обновляем image экрана (email экраны) + if (screen.image?.src && imageMapping[screen.image.src]) { + const oldUrl = screen.image.src; + const newUrl = imageMapping[oldUrl]; + screen.image.src = newUrl; + console.log(`🔗 Updated image URL: ${oldUrl} → ${newUrl}`); + } + + // Обновляем icon и image в вариантах экрана + if (screen.variants && Array.isArray(screen.variants)) { + for (const variant of screen.variants) { + // icon в вариантах (info экраны) + // В вариантах может не быть поля type, проверяем только value + if (variant.overrides?.icon?.value && imageMapping[variant.overrides.icon.value]) { + const oldUrl = variant.overrides.icon.value; + const newUrl = imageMapping[oldUrl]; + variant.overrides.icon.value = newUrl; + console.log(`🔗 Updated variant image URL: ${oldUrl} → ${newUrl}`); + } + // image в вариантах (email экраны) + if (variant.overrides?.image?.src && imageMapping[variant.overrides.image.src]) { + const oldUrl = variant.overrides.image.src; + const newUrl = imageMapping[oldUrl]; + variant.overrides.image.src = newUrl; + console.log(`🔗 Updated variant image URL: ${oldUrl} → ${newUrl}`); + } + } + } } } } @@ -255,14 +321,82 @@ async function getLatestPublishedFunnels() { } } +/** + * Нормализует данные воронки перед сохранением + * Удаляет лишние поля которые не соответствуют типам TypeScript + */ +function normalizeFunnelData(funnelData) { + return { + ...funnelData, + screens: funnelData.screens.map((screen) => { + const normalizedScreen = { ...screen }; + + // Удаляем поле 'show' из description (TypographyVariant не содержит его) + // Поле 'show' есть только у TitleDefinition и SubtitleDefinition + if (normalizedScreen.description && typeof normalizedScreen.description === 'object') { + if ('show' in normalizedScreen.description) { + delete normalizedScreen.description.show; + } + } + + // Удаляем специфичные для других шаблонов поля + // Каждый шаблон должен содержать только свои поля + switch (normalizedScreen.template) { + case 'form': + // fields нужно только для form экранов + break; + case 'email': + // email имеет emailInput, а не fields + if ('fields' in normalizedScreen) delete normalizedScreen.fields; + if ('list' in normalizedScreen) delete normalizedScreen.list; + break; + case 'list': + // list нужно только для list экранов + if ('fields' in normalizedScreen) delete normalizedScreen.fields; + break; + case 'loaders': + // progressbars нужно только для loaders + if ('fields' in normalizedScreen) delete normalizedScreen.fields; + if ('list' in normalizedScreen) delete normalizedScreen.list; + break; + default: + // Для остальных шаблонов (info, date, coupon, soulmate) удаляем специфичные поля + if ('fields' in normalizedScreen) delete normalizedScreen.fields; + if ('list' in normalizedScreen) delete normalizedScreen.list; + if ('progressbars' in normalizedScreen) delete normalizedScreen.progressbars; + break; + } + + // Нормализуем variants - добавляем пустой overrides если его нет + if ('variants' in normalizedScreen && Array.isArray(normalizedScreen.variants)) { + normalizedScreen.variants = normalizedScreen.variants.map((variant) => ({ + conditions: variant.conditions || [], + overrides: variant.overrides || {}, + })); + } + + // Удаляем variables из экранов, которые не поддерживают это поле + // variables поддерживается только в info экранах + if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') { + delete normalizedScreen.variables; + } + + return normalizedScreen; + }), + }; +} + async function saveFunnelToFile(funnel) { const funnelId = funnel.funnelData.meta.id; const fileName = `${funnelId}.json`; const filePath = path.join(funnelsDir, fileName); try { + // Нормализуем данные перед сохранением (удаляем лишние поля) + const normalizedData = normalizeFunnelData(funnel.funnelData); + // Сохраняем только funnelData (структуру воронки) - const funnelContent = JSON.stringify(funnel.funnelData, null, 2); + const funnelContent = JSON.stringify(normalizedData, null, 2); await fs.writeFile(filePath, funnelContent, 'utf8'); console.log(`💾 Saved ${fileName} (v${funnel.version})`); } catch (error) { diff --git a/src/app/[funnelId]/layout.tsx b/src/app/[funnelId]/layout.tsx new file mode 100644 index 0000000..875bd94 --- /dev/null +++ b/src/app/[funnelId]/layout.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { notFound } from "next/navigation"; +import { PixelsProvider } from "@/components/providers/PixelsProvider"; +import type { FunnelDefinition } from "@/lib/funnel/types"; +import { BAKED_FUNNELS } from "@/lib/funnel/bakedFunnels"; +import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant"; + +// Функция для загрузки воронки из базы данных +async function loadFunnelFromDatabase( + funnelId: string +): Promise { + if (!IS_FULL_SYSTEM_BUILD) { + return null; + } + + try { + const { default: connectMongoDB } = await import("@/lib/mongodb"); + const { default: FunnelModel } = await import("@/lib/models/Funnel"); + + await connectMongoDB(); + + const funnel = await FunnelModel.findOne({ + "funnelData.meta.id": funnelId, + status: { $in: ["published", "draft"] }, + }).lean(); + + if (funnel) { + return funnel.funnelData as FunnelDefinition; + } + + return null; + } catch (error) { + console.error( + `Failed to load funnel '${funnelId}' from database:`, + error + ); + return null; + } +} + +interface FunnelLayoutProps { + children: ReactNode; + params: Promise<{ + funnelId: string; + }>; +} + +export default async function FunnelLayout({ + children, + params, +}: FunnelLayoutProps) { + const { funnelId } = await params; + + let funnel: FunnelDefinition | null = null; + + // Сначала пытаемся загрузить из базы данных + funnel = await loadFunnelFromDatabase(funnelId); + + // Если не найдено в базе, пытаемся загрузить из JSON файлов + if (!funnel) { + funnel = BAKED_FUNNELS[funnelId] || null; + } + + // Если воронка не найдена ни в базе, ни в файлах + if (!funnel) { + notFound(); + } + + return ( + + {children} + + ); +} diff --git a/src/app/[funnelId]/page.tsx b/src/app/[funnelId]/page.tsx index 4f3987c..17be278 100644 --- a/src/app/[funnelId]/page.tsx +++ b/src/app/[funnelId]/page.tsx @@ -39,10 +39,12 @@ interface FunnelRootPageProps { params: Promise<{ funnelId: string; }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } -export default async function FunnelRootPage({ params }: FunnelRootPageProps) { +export default async function FunnelRootPage({ params, searchParams }: FunnelRootPageProps) { const { funnelId } = await params; + const queryParams = await searchParams; let funnel: FunnelDefinition | null = null; @@ -66,5 +68,19 @@ export default async function FunnelRootPage({ params }: FunnelRootPageProps) { redirect("/"); } - redirect(`/${funnel.meta.id}/${firstScreenId}`); + // Сохраняем query параметры при редиректе + const queryString = new URLSearchParams( + Object.entries(queryParams).reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key] = Array.isArray(value) ? value[0] : value; + } + return acc; + }, {} as Record) + ).toString(); + + const redirectUrl = queryString + ? `/${funnel.meta.id}/${firstScreenId}?${queryString}` + : `/${funnel.meta.id}/${firstScreenId}`; + + redirect(redirectUrl); } diff --git a/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx index 7c35e85..53a62cc 100644 --- a/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx +++ b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx @@ -105,9 +105,7 @@ export default function FunnelBuilderPage() { // Конвертируем состояние билдера обратно в FunnelDefinition const updatedFunnelData: FunnelDefinition = { meta: builderState.meta, - defaultTexts: { - nextButton: 'Counitue' - }, + defaultTexts: builderState.defaultTexts, screens: builderState.screens.map(cleanScreen) }; diff --git a/src/app/api/funnels/[id]/route.ts b/src/app/api/funnels/[id]/route.ts index 977a01c..ce4ccfa 100644 --- a/src/app/api/funnels/[id]/route.ts +++ b/src/app/api/funnels/[id]/route.ts @@ -1,7 +1,104 @@ import { NextRequest, NextResponse } from 'next/server'; import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; -import type { FunnelDefinition } from '@/lib/funnel/types'; +import type { FunnelDefinition, ScreenDefinition, TypographyVariant } from '@/lib/funnel/types'; + +/** + * Нормализует TypographyVariant - удаляет объект если text пустой + */ +function normalizeTypography(typography: TypographyVariant | undefined): TypographyVariant | undefined { + if (!typography) return undefined; + + // Если text пустой или только пробелы, удаляем весь объект + if (!typography.text || typography.text.trim() === '') { + return undefined; + } + + return typography; +} + +/** + * Нормализует данные воронки перед сохранением в MongoDB + * Удаляет пустые текстовые поля и лишние поля которые не соответствуют типам + */ +function normalizeFunnelData(funnelData: FunnelDefinition): FunnelDefinition { + return { + ...funnelData, + screens: funnelData.screens.map((screen): ScreenDefinition => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const normalizedScreen: any = { ...screen }; + + // Нормализуем subtitle (опциональное поле) + if ('subtitle' in normalizedScreen) { + const normalized = normalizeTypography(normalizedScreen.subtitle); + if (normalized === undefined) { + delete normalizedScreen.subtitle; + } else { + normalizedScreen.subtitle = normalized; + } + } + + // Нормализуем description (для info и soulmate экранов) + // ⚠️ TypographyVariant НЕ содержит поле 'show', удаляем его если есть + if ('description' in normalizedScreen) { + const normalized = normalizeTypography(normalizedScreen.description); + if (normalized === undefined) { + delete normalizedScreen.description; + } else { + normalizedScreen.description = normalized; + // Удаляем поле 'show' если оно есть (TypographyVariant не содержит его) + if ('show' in normalizedScreen.description) { + delete normalizedScreen.description.show; + } + } + } + + // Удаляем специфичные для других шаблонов поля + // Каждый шаблон должен содержать только свои поля + switch (normalizedScreen.template) { + case 'form': + // fields нужно только для form экранов + break; + case 'email': + // email имеет emailInput, а не fields + if ('fields' in normalizedScreen) delete normalizedScreen.fields; + if ('list' in normalizedScreen) delete normalizedScreen.list; + break; + case 'list': + // list нужно только для list экранов + if ('fields' in normalizedScreen) delete normalizedScreen.fields; + break; + case 'loaders': + // progressbars нужно только для loaders + if ('fields' in normalizedScreen) delete normalizedScreen.fields; + if ('list' in normalizedScreen) delete normalizedScreen.list; + break; + default: + // Для остальных шаблонов (info, date, coupon, soulmate) удаляем специфичные поля + if ('fields' in normalizedScreen) delete normalizedScreen.fields; + if ('list' in normalizedScreen) delete normalizedScreen.list; + if ('progressbars' in normalizedScreen) delete normalizedScreen.progressbars; + break; + } + + // Нормализуем variants - добавляем пустой overrides если его нет + if ('variants' in normalizedScreen && Array.isArray(normalizedScreen.variants)) { + normalizedScreen.variants = normalizedScreen.variants.map((variant: { conditions?: unknown; overrides?: unknown }) => ({ + conditions: variant.conditions || [], + overrides: variant.overrides || {}, + })); + } + + // Удаляем variables из экранов, которые не поддерживают это поле + // variables поддерживается только в info экранах + if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') { + delete normalizedScreen.variables; + } + + return normalizedScreen as ScreenDefinition; + }), + }; +} interface RouteParams { params: Promise<{ @@ -110,8 +207,9 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { if (status !== undefined) funnel.status = status; if (funnelData !== undefined) { - // Save as-is; schema expects `progressbars` for loaders - funnel.funnelData = funnelData as FunnelDefinition; + // Нормализуем данные перед сохранением (удаляем пустые текстовые поля) + const normalizedData = normalizeFunnelData(funnelData as FunnelDefinition); + funnel.funnelData = normalizedData; // Увеличиваем версию только при публикации if (isPublishing) { @@ -133,10 +231,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { const nextSequenceNumber = (lastHistoryEntry?.sequenceNumber || -1) + 1; + // Нормализуем данные для истории + const normalizedDataForHistory = normalizeFunnelData(funnelData as FunnelDefinition); + await FunnelHistoryModel.create({ funnelId: id, sessionId, - funnelSnapshot: funnelData as FunnelDefinition, + funnelSnapshot: normalizedDataForHistory, actionType: status === 'published' ? 'publish' : 'update', sequenceNumber: nextSequenceNumber, description: actionDescription || 'Воронка обновлена', @@ -144,7 +245,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { changeDetails: { action: 'update-funnel', previousValue: previousData, - newValue: funnelData as FunnelDefinition + newValue: normalizedDataForHistory } }); diff --git a/src/app/api/funnels/route.ts b/src/app/api/funnels/route.ts index cf309b7..7f83342 100644 --- a/src/app/api/funnels/route.ts +++ b/src/app/api/funnels/route.ts @@ -1,7 +1,57 @@ import { NextRequest, NextResponse } from 'next/server'; import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; -import type { FunnelDefinition } from '@/lib/funnel/types'; +import type { FunnelDefinition, ScreenDefinition, TypographyVariant } from '@/lib/funnel/types'; + +/** + * Нормализует TypographyVariant - удаляет объект если text пустой + */ +function normalizeTypography(typography: TypographyVariant | undefined): TypographyVariant | undefined { + if (!typography) return undefined; + + // Если text пустой или только пробелы, удаляем весь объект + if (!typography.text || typography.text.trim() === '') { + return undefined; + } + + return typography; +} + +/** + * Нормализует данные воронки перед сохранением в MongoDB + * Удаляет пустые текстовые поля которые не пройдут валидацию + */ +function normalizeFunnelData(funnelData: FunnelDefinition): FunnelDefinition { + return { + ...funnelData, + screens: funnelData.screens.map((screen): ScreenDefinition => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const normalizedScreen: any = { ...screen }; + + // Нормализуем subtitle (опциональное поле) + if ('subtitle' in normalizedScreen) { + const normalized = normalizeTypography(normalizedScreen.subtitle); + if (normalized === undefined) { + delete normalizedScreen.subtitle; + } else { + normalizedScreen.subtitle = normalized; + } + } + + // Нормализуем description (для info и soulmate экранов) + if ('description' in normalizedScreen) { + const normalized = normalizeTypography(normalizedScreen.description); + if (normalized === undefined) { + delete normalizedScreen.description; + } else { + normalizedScreen.description = normalized; + } + } + + return normalizedScreen as ScreenDefinition; + }), + }; +} // GET /api/funnels - получить список всех воронок export async function GET(request: NextRequest) { @@ -127,11 +177,14 @@ export async function POST(request: NextRequest) { ); } + // Нормализуем данные перед сохранением (удаляем пустые текстовые поля) + const normalizedData = normalizeFunnelData(funnelData as FunnelDefinition); + // Создаем воронку const funnel = new FunnelModel({ name, description, - funnelData: funnelData as FunnelDefinition, + funnelData: normalizedData, status, version: 1, usage: { @@ -147,7 +200,7 @@ export async function POST(request: NextRequest) { await FunnelHistoryModel.create({ funnelId: String(savedFunnel._id), sessionId, - funnelSnapshot: funnelData, + funnelSnapshot: normalizedData, actionType: 'create', sequenceNumber: 0, description: 'Воронка создана', @@ -169,6 +222,26 @@ export async function POST(request: NextRequest) { } catch (error) { console.error('POST /api/funnels error:', error); + // Обработка ошибок валидации Mongoose + if (error instanceof Error && error.name === 'ValidationError') { + const validationError = error as Error & { errors: Record }; + const details = []; + + // Собираем все ошибки валидации + for (const field in validationError.errors) { + const fieldError = validationError.errors[field]; + details.push(`${field}: ${fieldError.message}`); + } + + return NextResponse.json( + { + error: 'Ошибка валидации данных воронки', + details: details.join('; ') + }, + { status: 400 } + ); + } + if (error instanceof Error && error.message.includes('duplicate key')) { return NextResponse.json( { error: 'Funnel with this name already exists' }, diff --git a/src/app/api/images/[filename]/route.ts b/src/app/api/images/[filename]/route.ts index 3333d29..ed369fd 100644 --- a/src/app/api/images/[filename]/route.ts +++ b/src/app/api/images/[filename]/route.ts @@ -3,6 +3,12 @@ import connectMongoDB from '@/lib/mongodb'; import { Image, type IImage } from '@/lib/models/Image'; import { IS_FRONTEND_ONLY_BUILD } from '@/lib/runtime/buildVariant'; +// Тип для MongoDB Binary объекта из BSON +interface MongoDBBinary { + buffer?: ArrayBuffer | Buffer; + _bsontype?: string; +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ filename: string }> } @@ -37,7 +43,22 @@ export async function GET( } // Возвращаем изображение с правильными заголовками - const buffer = image.data instanceof Buffer ? image.data : Buffer.from(image.data); + // Преобразуем MongoDB Binary в Buffer + let buffer: Buffer; + const rawData = image.data as unknown; + + if (Buffer.isBuffer(rawData)) { + buffer = rawData; + } else if ((rawData as MongoDBBinary)?.buffer) { + // BSON Binary объект имеет свойство buffer + const binaryData = (rawData as MongoDBBinary).buffer; + buffer = Buffer.isBuffer(binaryData) ? binaryData : Buffer.from(binaryData as ArrayBuffer); + } else if (rawData instanceof Uint8Array) { + buffer = Buffer.from(rawData); + } else { + // Fallback - пробуем напрямую преобразовать + buffer = Buffer.from(rawData as ArrayBuffer); + } // Специальная обработка для SVG файлов let contentType = image.mimetype; diff --git a/src/app/globals.css b/src/app/globals.css index 869d17c..d8a2730 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -53,8 +53,8 @@ /* Тени */ --shadow-blue-glow: 0px 5px 14px 0px rgba(59, 130, 246, 0.4), 0px 4px 6px 0px rgba(59, 130, 246, 0.1); - --shadow-blue-glow-2: 0px 0px 19px 0px rgba(59, 130, 246, 0.3), - 0px 0px 0px 0px rgba(59, 130, 246, 0.2); + --shadow-blue-glow-2: 0px 8px 19px 0px rgba(59, 130, 246, 0.3), + 0px 4px 10px 0px rgba(59, 130, 246, 0.2); --shadow-black-glow: 0px 8px 15px 0px #00000026, 0px 4px 6px 0px #00000014; --shadow-coupon: 0px 20px 40px 0px #0000004d, 0px 8px 16px 0px #00000033; --shadow-destructive: 0 0 0 2px rgba(239, 68, 68, 0.2); @@ -230,3 +230,13 @@ transform: scale(1.05); } } + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} diff --git a/src/components/admin/builder/Canvas/BuilderCanvas.tsx b/src/components/admin/builder/Canvas/BuilderCanvas.tsx index 8155a4e..4a43670 100644 --- a/src/components/admin/builder/Canvas/BuilderCanvas.tsx +++ b/src/components/admin/builder/Canvas/BuilderCanvas.tsx @@ -117,7 +117,7 @@ export function BuilderCanvas() { const screenTitleMap = useMemo(() => { return screens.reduce>((accumulator, screen) => { - accumulator[screen.id] = screen.title.text || screen.id; + accumulator[screen.id] = screen.title?.text || screen.id; return accumulator; }, {}); }, [screens]); @@ -189,7 +189,7 @@ export function BuilderCanvas() { #{screen.id} - {screen.title.text || "Без названия"} + {screen.title?.text || "Без названия"} diff --git a/src/components/admin/builder/Canvas/constants.ts b/src/components/admin/builder/Canvas/constants.ts index c72d4e0..205d8d6 100644 --- a/src/components/admin/builder/Canvas/constants.ts +++ b/src/components/admin/builder/Canvas/constants.ts @@ -1,17 +1,24 @@ -import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types"; +import type { + ScreenDefinition, + NavigationConditionDefinition, +} from "@/lib/funnel/types"; export const TEMPLATE_TITLES: Record = { list: "Список", - form: "Форма", + form: "Форма", info: "Инфо", date: "Дата", coupon: "Купон", email: "Email", loaders: "Загрузка", soulmate: "Портрет партнера", + trialPayment: "Trial Payment", }; -export const OPERATOR_LABELS: Record, string> = { +export const OPERATOR_LABELS: Record< + Exclude, + string +> = { includesAny: "любой из", includesAll: "все из", includesExactly: "точное совпадение", diff --git a/src/components/admin/builder/Sidebar/BuilderSidebar.tsx b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx index 6cf760d..98cd925 100644 --- a/src/components/admin/builder/Sidebar/BuilderSidebar.tsx +++ b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx @@ -6,7 +6,11 @@ import { Button } from "@/components/ui/button"; import { Trash2 } from "lucide-react"; import { TemplateConfig } from "@/components/admin/builder/templates"; import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig"; -import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context"; +import { + useBuilderDispatch, + useBuilderSelectedScreen, + useBuilderState, +} from "@/lib/admin/builder/context"; import type { BuilderScreen } from "@/lib/admin/builder/types"; import type { NavigationRuleDefinition, @@ -24,7 +28,9 @@ export function BuilderSidebar() { const dispatch = useBuilderDispatch(); const selectedScreen = useBuilderSelectedScreen(); - const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel"); + const [activeTab, setActiveTab] = useState<"funnel" | "screen" | "data">( + selectedScreen ? "screen" : "funnel" + ); const selectedScreenId = selectedScreen?.id ?? null; useEffect(() => { @@ -37,27 +43,31 @@ export function BuilderSidebar() { }, [selectedScreenId]); // ✅ Оптимизированная validation - только критичные поля - const screenIds = useMemo(() => state.screens.map(s => s.id).join(','), [state.screens]); + const screenIds = useMemo( + () => state.screens.map((s) => s.id).join(","), + [state.screens] + ); const validation = useMemo( () => validateBuilderState(state), // eslint-disable-next-line react-hooks/exhaustive-deps -- Оптимизация: пересчитываем только при изменении критичных полей - [ - state.meta.id, - state.meta.firstScreenId, - screenIds, - state.screens.length, - ] + [state.meta.id, state.meta.firstScreenId, screenIds, state.screens.length] ); const screenValidationIssues = useMemo(() => { if (!selectedScreenId) { return [] as ValidationIssues; } - return validation.issues.filter((issue) => issue.screenId === selectedScreenId); + return validation.issues.filter( + (issue) => issue.screenId === selectedScreenId + ); }, [selectedScreenId, validation]); const screenOptions = useMemo( - () => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), + () => + state.screens.map((screen) => ({ + id: screen.id, + title: screen.title?.text, + })), [state.screens] ); @@ -86,29 +96,29 @@ export function BuilderSidebar() { if (newId === currentId) { return; } - + // Разрешаем пустые ID для полного переименования if (newId.trim() === "") { // Просто обновляем на пустое значение, пользователь сможет ввести новое - dispatch({ - type: "update-screen", - payload: { - screenId: currentId, - screen: { id: newId } - } + dispatch({ + type: "update-screen", + payload: { + screenId: currentId, + screen: { id: newId }, + }, }); return; } - + // Обновляем ID экрана - dispatch({ - type: "update-screen", - payload: { - screenId: currentId, - screen: { id: newId } - } + dispatch({ + type: "update-screen", + payload: { + screenId: currentId, + screen: { id: newId }, + }, }); - + // Если это был первый экран в мета данных, обновляем и там if (state.meta.firstScreenId === currentId) { dispatch({ type: "set-meta", payload: { firstScreenId: newId } }); @@ -128,15 +138,20 @@ export function BuilderSidebar() { screenId: screen.id, navigation: { defaultNextScreenId: - navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId, + navigationUpdates.defaultNextScreenId ?? + screen.navigation?.defaultNextScreenId, rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [], - isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen, + isEndScreen: + navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen, }, }, }); }; - const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => { + const handleDefaultNextChange = ( + screenId: string, + nextScreenId: string | "" + ) => { const screen = getScreenById(screenId); if (!screen) { return; @@ -186,7 +201,11 @@ export function BuilderSidebar() { updateRules(screenId, nextRules); }; - const handleRuleOptionToggle = (screenId: string, ruleIndex: number, optionId: string) => { + const handleRuleOptionToggle = ( + screenId: string, + ruleIndex: number, + optionId: string + ) => { const screen = getScreenById(screenId); if (!screen) { return; @@ -220,7 +239,11 @@ export function BuilderSidebar() { updateRules(screenId, nextRules); }; - const handleRuleNextScreenChange = (screenId: string, ruleIndex: number, nextScreenId: string) => { + const handleRuleNextScreenChange = ( + screenId: string, + ruleIndex: number, + nextScreenId: string + ) => { const screen = getScreenById(screenId); if (!screen) { return; @@ -247,7 +270,10 @@ export function BuilderSidebar() { const nextRules = [ ...(screen.navigation?.rules ?? []), - { nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] }, + { + nextScreenId: state.screens[0]?.id ?? screen.id, + conditions: [defaultCondition], + }, ]; updateNavigation(screen, { rules: nextRules }); }; @@ -270,7 +296,10 @@ export function BuilderSidebar() { dispatch({ type: "remove-screen", payload: { screenId } }); }; - const handleTemplateUpdate = (screenId: string, updates: Partial) => { + const handleTemplateUpdate = ( + screenId: string, + updates: Partial + ) => { dispatch({ type: "update-screen", payload: { @@ -295,7 +324,9 @@ export function BuilderSidebar() { }); }; - const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false; + const selectedScreenIsListType = selectedScreen + ? isListScreen(selectedScreen) + : false; return (
@@ -329,6 +360,18 @@ export function BuilderSidebar() { > Экран +
@@ -347,19 +390,27 @@ export function BuilderSidebar() { handleMetaChange("title", event.target.value)} + onChange={(event) => + handleMetaChange("title", event.target.value) + } /> handleMetaChange("description", event.target.value)} + onChange={(event) => + handleMetaChange("description", event.target.value) + } />