From 29c9ad92fe8f606d2b16bbc88f85f003a86170a5 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Sun, 5 Oct 2025 23:43:14 +0200 Subject: [PATCH] add funnel --- ANALYSIS_REPORT.md | 659 ------- BUILD_VARIANTS.md | 232 --- PERFORMANCE_IMPROVEMENTS.md | 219 --- README-ADMIN.md | 218 --- REFACTORING_SUMMARY.md | 170 -- docs/templates-and-builder.md | 232 --- public/funnels/soulmate.json | 1581 ++++++++++++++++ .../ac321d94-62e3-45c6-85f4-51faf6769bab.svg | 13 + scripts/sync-funnels-from-db.mjs | 19 +- .../builder/[id]/FunnelBuilderPageClient.tsx | 4 +- src/app/api/funnels/[id]/route.ts | 64 +- src/app/api/funnels/route.ts | 79 +- src/app/api/images/[filename]/route.ts | 23 +- .../builder/templates/TemplateConfig.tsx | 61 +- .../FormTemplate/FormTemplate.stories.tsx | 6 +- .../SoulmatePortraitTemplate.stories.tsx | 6 +- src/lib/admin/builder/state/reducer.ts | 82 +- src/lib/funnel/bakedFunnels.ts | 1582 ++++++++++++++++- src/lib/funnel/mappers.tsx | 5 + src/lib/funnel/types.ts | 2 +- src/lib/models/Funnel.ts | 14 +- 21 files changed, 3496 insertions(+), 1775 deletions(-) delete mode 100644 ANALYSIS_REPORT.md delete mode 100644 BUILD_VARIANTS.md delete mode 100644 PERFORMANCE_IMPROVEMENTS.md delete mode 100644 README-ADMIN.md delete mode 100644 REFACTORING_SUMMARY.md delete mode 100644 docs/templates-and-builder.md create mode 100644 public/funnels/soulmate.json create mode 100644 public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg 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/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/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/public/funnels/soulmate.json b/public/funnels/soulmate.json new file mode 100644 index 0000000..d86f1bb --- /dev/null +++ b/public/funnels/soulmate.json @@ -0,0 +1,1581 @@ +{ + "meta": { + "id": "soulmate", + "title": "Soulmate V1", + "description": "Soulmate", + "firstScreenId": "onboarding" + }, + "defaultTexts": { + "nextButton": "Next", + "privacyBanner": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем." + }, + "screens": [ + { + "id": "onboarding", + "template": "soulmate", + "header": { + "showBackButton": false, + "show": false + }, + "title": { + "text": "Soulmate Portrait", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Все начинается с тебя! Выбери свой пол.\nВсего 2 минуты — и Портрет откроет того, кто связан с тобой судьбой.\nПоразительная точность 99%.\nТебя ждёт неожиданное открытие.\nОсталось лишь осмелиться взглянуть.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "Counitue", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": true + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "gender", + "isEndScreen": false + }, + "description": { + "text": "Ваш персональный портрет почти готов.", + "show": true, + "font": "manrope", + "weight": "regular", + "size": "md", + "align": "center", + "color": "default" + }, + "fields": [], + "list": { + "options": [] + }, + "variants": [] + }, + { + "id": "gender", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Какого ты пола?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Все начинается с тебя! Выбери свой пол.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "left", + "color": "default" + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-status", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "female", + "label": "FEMALE", + "emoji": "🩷", + "disabled": false + }, + { + "id": "male", + "label": "MALE", + "emoji": "💙", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-status", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Вы сейчаc?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Это нужно, чтобы портрет и советы были точнее.", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "in_relationship", + "label": "В отношениях", + "disabled": false + }, + { + "id": "single", + "label": "Свободны", + "disabled": false + }, + { + "id": "after_breakup", + "label": " После расставания", + "disabled": false + }, + { + "id": "its_complicated", + "label": "Всё сложно", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "analysis-target", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Кого анализируем?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "current_partner", + "label": "Текущего партнёра", + "disabled": false + }, + { + "id": "crush", + "label": "Человека, который нравится", + "disabled": false + }, + { + "id": "ex_partner", + "label": "Бывшего", + "disabled": false + }, + { + "id": "future_date", + "label": "Будущую встречу", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-age", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Возраст текущего партнера", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "under_29", + "label": "До 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" + ] + } + ] + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "crush" + ] + } + ], + "overrides": { + "title": { + "text": "Возраст человека, который нравится" + } + } + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "ex_partner" + ] + } + ], + "overrides": { + "title": { + "text": "Возраст бывшего" + } + } + }, + { + "conditions": [ + { + "screenId": "analysis-target", + "operator": "includesAny", + "optionIds": [ + "future_date" + ] + } + ], + "overrides": { + "title": { + "text": "Возраст будущего партнёра" + } + } + } + ] + }, + { + "id": "partner-age-detail", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Уточните чуть точнее", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Чтобы портрет был максимально похож.", + "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 + }, + "fields": [], + "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": "Этническая принадлежность твоей второй половинки?", + "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 + }, + "fields": [], + "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": "Цвет глаз твоей второй половинки?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "brown", + "label": "Brown", + "disabled": false + }, + { + "id": "blue", + "label": "Голубые", + "disabled": false + }, + { + "id": "hazel", + "label": "Hazel", + "disabled": false + }, + { + "id": "green", + "label": "Green", + "disabled": false + }, + { + "id": "amber", + "label": "Янтарные", + "disabled": false + }, + { + "id": "gray", + "label": "Серые", + "disabled": false + }, + { + "id": "unknown", + "label": "Не знаю", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-hair-length", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Выберите длину волос", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "От неё зависит форма и настроение портрета.", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "short", + "label": "Короткие", + "disabled": false + }, + { + "id": "medium", + "label": "Средние", + "disabled": false + }, + { + "id": "long", + "label": "Длинные", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "burnout-support", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Когда ты выгораешь, тебе нужно чтобы партнёр", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "acknowledged_and_calmed", + "label": "Признал ваше разочарование и успокоил", + "disabled": false + }, + { + "id": "gave_emotional_support", + "label": "Дал эмоциональную опору и безопасное пространство", + "disabled": false + }, + { + "id": "took_over_tasks", + "label": "Перехватил быт/дела, чтобы вы восстановились", + "disabled": false + }, + { + "id": "inspired_with_plan", + "label": "Вдохнул энергию через цель и короткий план действий", + "disabled": false + }, + { + "id": "shifted_to_positive", + "label": "Переключил на позитив: прогулка, кино, смешные истории", + "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": "Такой партнёр **умеет слышать и поддерживать**, а вы — **человек с глубокой душой**, который ценит искренность и силу настоящих чувств.", + "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/ac321d94-62e3-45c6-85f4-51faf6769bab.svg", + "size": "md" + }, + "fields": [], + "list": { + "options": [] + }, + "variants": [ + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "acknowledged_and_calmed" + ] + } + ] + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "gave_emotional_support" + ] + } + ], + "overrides": { + "subtitle": { + "text": "Такой человек создаёт **чувство надёжности**, а вы обладаете мудростью и внутренней зрелостью, выбирая близость и доверие." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "took_over_tasks" + ] + } + ], + "overrides": { + "subtitle": { + "text": "Такой партнёр готов **подставить плечо** в нужный момент, а вы сильны тем, что умеете **доверять** и **принимать поддержку** — это ваша природная мудрость." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "inspired_with_plan" + ] + } + ], + "overrides": { + "subtitle": { + "text": "Такой человек **заряжает ясностью** и **мотивирует**, а вы выделяетесь **силой воли** и **стремлением к росту** — вы не боитесь идти вперёд." + } + } + }, + { + "conditions": [ + { + "screenId": "burnout-support", + "operator": "includesAny", + "optionIds": [ + "shifted_to_positive" + ] + } + ], + "overrides": { + "subtitle": { + "text": "Такой партнёр умеет **возвращать радость**, а вы показываете свою силу в умении **сохранять лёгкость** и **светлый взгляд** на жизнь." + } + } + } + ] + }, + { + "id": "birthdate", + "template": "date", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Когда ты родился?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "В момент вашего рождения заложенны глубинные закономерности.", + "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": "Месяц", + "dayLabel": "День", + "yearLabel": "Год", + "monthPlaceholder": "ММ", + "dayPlaceholder": "ДД", + "yearPlaceholder": "ГГГГ", + "showSelectedDate": true, + "selectedDateFormat": "dd MMMM yyyy", + "selectedDateLabel": "Выбранная дата:", + "zodiac": { + "enabled": true, + "storageKey": "userZodiac" + } + }, + "fields": [], + "list": { + "options": [] + }, + "variants": [] + }, + { + "id": "nature-archetype", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Какой природный образ ближе вашему характеру?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "flower", + "label": "Цветок — нежность, забота, притягательность", + "emoji": "🌹", + "disabled": false + }, + { + "id": "sea", + "label": "Море — глубина, тайна, эмоции", + "emoji": "🌊", + "disabled": false + }, + { + "id": "sun", + "label": "Солнце — энергия, сила, яркость", + "emoji": "🌞️", + "disabled": false + }, + { + "id": "moon", + "label": "Луна — интуиция, чувствительность", + "emoji": "🌙", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "love-priority", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Когда речь о любви, что для вас важнее: сердце или разум?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "core-need", + "isEndScreen": false + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "follow_heart", + "label": "Доверяю сердцу", + "emoji": "🧡", + "disabled": false + }, + { + "id": "follow_mind", + "label": "Опираюсь на разум", + "emoji": "🧠", + "disabled": false + }, + { + "id": "balance_heart_mind", + "label": "Сочетание сердца и разума", + "emoji": "🎯", + "disabled": false + }, + { + "id": "depends_on_situation", + "label": "Зависит от ситуации", + "emoji": "⚖️", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "core-need", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "В чём ваша базовая потребность сейчас?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "safety_and_support", + "label": "Безопасность и опора", + "disabled": false + }, + { + "id": "passion_and_spark", + "label": "Страсть и искра", + "disabled": false + }, + { + "id": "calm_and_acceptance", + "label": "Спокойствие и принятие", + "disabled": false + }, + { + "id": "inspiration_and_growth", + "label": "Вдохновение и рост", + "disabled": false + }, + { + "id": "not_important", + "label": "Неважно", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-similarity", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Твоя вторая половинка похожа на тебя?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "similar", + "label": "Да, есть сходство", + "disabled": false + }, + { + "id": "different", + "label": "Мы совершенно разные", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "partner-role", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Предпочитаемая роль партнёра", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "leader", + "label": "Ведущий", + "disabled": false + }, + { + "id": "equal", + "label": "Равный", + "disabled": false + }, + { + "id": "supportive", + "label": "Поддерживающий", + "disabled": false + }, + { + "id": "flexible", + "label": "Гибкая роль", + "disabled": false + }, + { + "id": "dependent", + "label": "Зависимый от меня", + "disabled": false + }, + { + "id": "situational", + "label": "По ситуации", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-strength", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Что для тебя главный источник силы в отношениях?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "support_and_care", + "label": "Поддержка и забота", + "disabled": false + }, + { + "id": "admiration_and_recognition", + "label": "Восхищение и признание", + "disabled": false + }, + { + "id": "freedom_and_space", + "label": "Свобода и пространство", + "disabled": false + }, + { + "id": "shared_goals_and_plans", + "label": "Общие цели и планы", + "disabled": false + }, + { + "id": "joy_and_lightness", + "label": "Радость и лёгкость", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "love-expression", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Как ты проявляешь любовь?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "words", + "label": "Словами", + "disabled": false + }, + { + "id": "actions", + "label": "Поступками", + "disabled": false + }, + { + "id": "quality_time", + "label": "Совместным временем", + "disabled": false + }, + { + "id": "care", + "label": "Заботой", + "disabled": false + }, + { + "id": "passion", + "label": "Страстью", + "disabled": false + }, + { + "id": "in_my_own_way", + "label": "По-своему", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-future", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Как ты воспринимаешь будущее твоей пары?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "home_and_family", + "label": "Совместный дом и семья", + "disabled": false + }, + { + "id": "travel_and_discovery", + "label": "Путешествия и открытия", + "disabled": false + }, + { + "id": "shared_goals", + "label": "Совместные проекты и цели", + "disabled": false + }, + { + "id": "present_moment", + "label": "Просто быть рядом «здесь и сейчас»", + "disabled": false + }, + { + "id": "unsure", + "label": "Пока сложно сказать", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-energy", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Какую энергию ты хочешь в отношениях?", + "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 + }, + "fields": [], + "list": { + "selectionType": "single", + "options": [ + { + "id": "lightness_and_joy", + "label": "Лёгкость и радость", + "disabled": false + }, + { + "id": "strength_and_drive", + "label": "Сила и драйв", + "disabled": false + }, + { + "id": "comfort_and_safety", + "label": "Уют и надёжность", + "disabled": false + }, + { + "id": "depth_and_meaning", + "label": "Глубина и смысл", + "disabled": false + }, + { + "id": "freedom_and_space", + "label": "Свобода и простор", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "relationship-metaphor", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Какой образ отношений вам ближе?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "subtitle": { + "text": "Можно выбрать несколько вариантов.", + "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 + }, + "fields": [], + "list": { + "selectionType": "multi", + "options": [ + { + "id": "bridge", + "label": "Мост — связь сквозь препятствия", + "disabled": false + }, + { + "id": "mountain_path", + "label": "Путь в горах — испытания и смысл", + "disabled": false + }, + { + "id": "dance", + "label": "Танец — баланс и взаимные шаги", + "disabled": false + }, + { + "id": "key_and_lock", + "label": "Ключ и замок — совпадение ценностей", + "disabled": false + }, + { + "id": "harbor", + "label": "Гавань — безопасность и покой", + "disabled": false + }, + { + "id": "lighthouse", + "label": "Маяк — ориентир и поддержка", + "disabled": false + }, + { + "id": "ocean_after_storm", + "label": "Океан после шторма — очищение и новое", + "disabled": false + }, + { + "id": "garden", + "label": "Сад — забота и рост", + "disabled": false + } + ] + }, + "variants": [] + }, + { + "id": "portrait-generation", + "template": "loaders", + "header": { + "showBackButton": false, + "show": false + }, + "title": { + "text": "Создаем портрет твоей второй половинки.", + "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 + }, + "fields": [], + "list": { + "options": [] + }, + "progressbars": { + "items": [ + { + "processingTitle": "Анализ твоих ответов", + "processingSubtitle": "Processing...", + "completedTitle": "Анализ твоих ответов", + "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": "Портрет твоей второй половинки готов! Куда нам его отправить?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "Continue", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": true + }, + "navigation": { + "rules": [], + "isEndScreen": true + }, + "fields": [], + "list": { + "options": [] + }, + "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" + } + } + } + ] + } + ] +} \ No newline at end of file diff --git a/public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg b/public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg new file mode 100644 index 0000000..5ddd5de --- /dev/null +++ b/public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/scripts/sync-funnels-from-db.mjs b/scripts/sync-funnels-from-db.mjs index 0186744..48360c0 100755 --- a/scripts/sync-funnels-from-db.mjs +++ b/scripts/sync-funnels-from-db.mjs @@ -159,11 +159,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}`); } 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..6e1123d 100644 --- a/src/app/api/funnels/[id]/route.ts +++ b/src/app/api/funnels/[id]/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; + }), + }; +} interface RouteParams { params: Promise<{ @@ -110,8 +160,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 +184,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 +198,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/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index 3a7fbcd..61e241a 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -106,18 +106,22 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT }, [storageKey]); const handleTextChange = (text: string) => { - if (text.trim() === "" && allowRemove) { - onChange(undefined); - return; - } - - // Сохраняем существующие настройки или используем минимальные дефолты + // Всегда обновляем текст, даже если пустой + // Это позволяет controlled input работать корректно onChange({ ...value, text, + show: value?.show ?? true, // Если show не задан, по умолчанию true }); }; + const handleTextBlur = () => { + // При потере фокуса удаляем объект если текст пустой + if (allowRemove && (!value?.text || value.text.trim() === "")) { + onChange(undefined); + } + }; + const handleAdvancedChange = (field: keyof TypographyVariant, fieldValue: string) => { onChange({ ...value, @@ -127,11 +131,27 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT }; const handleShowToggle = (show: boolean) => { - onChange({ - ...value, - text: value?.text || "", - show, - }); + if (!show) { + // Скрываем элемент + if (allowRemove) { + // Для опциональных полей - удаляем объект полностью + onChange(undefined); + } else { + // Для обязательных полей - сохраняем с show: false + onChange({ + ...value, + text: value?.text || "", + show: false, + }); + } + } else { + // Показываем элемент + onChange({ + ...value, + text: value?.text || "", + show: true, + }); + } }; return ( @@ -140,7 +160,7 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT