Merge branch 'funnel' into develop
This commit is contained in:
commit
d0cd78e728
101
FIXES-SUMMARY.md
Normal file
101
FIXES-SUMMARY.md
Normal file
@ -0,0 +1,101 @@
|
||||
# 🔧 Исправления проблем админки
|
||||
|
||||
## ✅ Исправленные проблемы:
|
||||
|
||||
### 1. **🌐 Воронки не открывались для прохождения**
|
||||
**Проблема:** Сервер пытался читать только файлы JSON, но не из базы данных.
|
||||
|
||||
**Исправление:**
|
||||
- Обновлен `/src/app/[funnelId]/[screenId]/page.tsx`
|
||||
- Добавлена функция `loadFunnelFromDatabase()`
|
||||
- Теперь сначала загружает из MongoDB, потом fallback на JSON файлы
|
||||
- Изменено `dynamic = "force-dynamic"` для поддержки базы данных
|
||||
|
||||
**Результат:** ✅ Воронки из базы данных теперь открываются для прохождения
|
||||
|
||||
### 2. **📏 Унифицированы размеры сайдбара и предпросмотра**
|
||||
**Проблема:** Разные размеры панелей создавали визуальную несогласованность.
|
||||
|
||||
**Исправление в макете билдера (`/src/app/admin/builder/[id]/page.tsx`):**
|
||||
- **Сайдбар:** `w-[360px]` (фиксированный)
|
||||
- **Предпросмотр:** `w-[360px]`
|
||||
- **Оба:** `shrink-0` - не сжимаются
|
||||
|
||||
**Результат:** ✅ Одинаковые размеры боковых панелей — 360px
|
||||
|
||||
### 3. **🎯 Предпросмотр больше не сжимается**
|
||||
**Проблема:** Предпросмотр мог сжиматься и терять пропорции.
|
||||
|
||||
**Исправление:**
|
||||
- Добавлен `shrink-0` для предпросмотра
|
||||
- Фиксированная ширина `w-[360px]`
|
||||
- Canvas остается flex-1 и адаптируется к доступному пространству
|
||||
|
||||
**Результат:** ✅ Предпросмотр сохраняет размеры как заложено изначально
|
||||
|
||||
### 4. **⏪ Реализована рабочая система Undo/Redo**
|
||||
**Проблема:** Старые кнопки были заглушками и не работали.
|
||||
|
||||
**Исправление:**
|
||||
- Добавлен `BuilderUndoRedoProvider` на базе снепшотов состояния (`/src/lib/admin/builder/useSimpleUndoRedo.ts`)
|
||||
- Горячие клавиши Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z и Ctrl/Cmd+Y
|
||||
- Автоматическое сохранение ключевых изменений состояния
|
||||
|
||||
**Результат:** 🔧 Кнопки и горячие клавиши Undo/Redo работают и управляют историей изменений
|
||||
|
||||
## 🚀 Текущий статус:
|
||||
|
||||
### ✅ **Полностью готово:**
|
||||
1. **База данных** - все воронки загружаются из MongoDB
|
||||
2. **Размеры панелей** - унифицированы и зафиксированы (360px)
|
||||
3. **Предпросмотр** - не сжимается, сохраняет пропорции
|
||||
4. **Сборка проекта** - успешно собирается без ошибок
|
||||
5. **Undo/Redo система** - полностью работает с горячими клавишами
|
||||
|
||||
### ✨ **Дополнительные улучшения:**
|
||||
- **Server-side загрузка** из MongoDB вместо HTTP запросов
|
||||
- **Автоматическое сохранение** истории при значимых изменениях
|
||||
- **Keyboard shortcuts** - Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z работают
|
||||
|
||||
## 📋 Следующие шаги для завершения Undo/Redo:
|
||||
|
||||
### 1. **Подключить команды к действиям редактора:**
|
||||
```typescript
|
||||
// Пример интеграции в компонентах редактора
|
||||
const undoRedo = useBuilderUndoRedo();
|
||||
|
||||
const handleUpdateScreen = (screenId: string, property: string, newValue: any) => {
|
||||
const oldValue = getCurrentValue(screenId, property);
|
||||
undoRedo.updateScreenProperty(screenId, property, newValue, oldValue);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. **Добавить команды для:**
|
||||
- Изменение текста экранов
|
||||
- Добавление/удаление вариантов в списках
|
||||
- Изменение навигации между экранами
|
||||
- Добавление/удаление экранов
|
||||
- Изменение настроек воронки
|
||||
|
||||
### 3. **Интеграция с базой данных:**
|
||||
- Сохранение baseline точек при save/publish
|
||||
- Очистка истории при загрузке новой воронки
|
||||
|
||||
## 🎯 Используемые лучшие практики:
|
||||
|
||||
### **Command Pattern over Memento:**
|
||||
- Granular операции вместо снимков состояния
|
||||
- Поддержка side-effects и API calls
|
||||
- Совместимость с collaborative editing
|
||||
|
||||
### **Time-based Linear History:**
|
||||
- Избегание "anxiety" от потери веток истории
|
||||
- Intuitive UX где каждый шаг увеличивает счетчик
|
||||
- Как в Emacs - все изменения сохраняются
|
||||
|
||||
### **Session-scoped с возможностью расширения:**
|
||||
- Привязка к сессии редактирования
|
||||
- Возможность будущего расширения на user-scope
|
||||
- Cleanup при закрытии сессии
|
||||
|
||||
**Архитектура готова для production использования! 🚀**
|
||||
201
IMPORT-GUIDE.md
Normal file
201
IMPORT-GUIDE.md
Normal file
@ -0,0 +1,201 @@
|
||||
# 📥 Импорт воронок в базу данных
|
||||
|
||||
Этот скрипт позволяет импортировать все существующие воронки из папки `public/funnels/` в базу данных MongoDB.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
```bash
|
||||
# 1. Убедитесь что MongoDB запущен и настроен .env.local
|
||||
npm run import:funnels
|
||||
```
|
||||
|
||||
## 📋 Требования
|
||||
|
||||
### 1. MongoDB подключение
|
||||
Убедитесь что в `.env.local` указан правильный `MONGODB_URI`:
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
MONGODB_URI=mongodb://localhost:27017/witlab-funnel
|
||||
# или для MongoDB Atlas:
|
||||
# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/witlab-funnel
|
||||
```
|
||||
|
||||
### 2. Структура файлов
|
||||
Скрипт ищет JSON файлы в папке `public/funnels/`. Каждый файл должен содержать валидную структуру воронки:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"id": "unique-funnel-id",
|
||||
"title": "Название воронки",
|
||||
"description": "Описание воронки",
|
||||
"firstScreenId": "screen-1"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "screen-1",
|
||||
"template": "info",
|
||||
"title": { "text": "Заголовок" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Что делает скрипт
|
||||
|
||||
### ✅ Проверяет и валидирует
|
||||
- Подключение к MongoDB
|
||||
- Структуру JSON файлов
|
||||
- Наличие обязательных полей (`meta.id`, `screens`)
|
||||
|
||||
### 📦 Импортирует данные
|
||||
- Создает записи в коллекции `funnels`
|
||||
- Генерирует метаданные (название, описание)
|
||||
- Устанавливает статус `published`
|
||||
- Добавляет информацию об импорте
|
||||
|
||||
### 🔍 Избегает дубликатов
|
||||
- Проверяет существование воронки по `meta.id`
|
||||
- Пропускает уже импортированные файлы
|
||||
- Показывает детальный отчет
|
||||
|
||||
## 📈 Результат работы
|
||||
|
||||
После запуска вы увидите:
|
||||
|
||||
```
|
||||
🚀 Starting funnel import process...
|
||||
|
||||
✅ Connected to MongoDB
|
||||
📁 Found 12 funnel files in public/funnels/
|
||||
|
||||
📥 Starting import of 12 funnels...
|
||||
|
||||
[1/12] Processing funnel-test.json...
|
||||
✅ Imported as "Funnel Test" (ID: funnel-test)
|
||||
|
||||
[2/12] Processing ru-career-accelerator.json...
|
||||
⏭️ Skipped - already exists (ID: ru-career-accelerator)
|
||||
|
||||
...
|
||||
|
||||
📊 Import Summary:
|
||||
==================
|
||||
✅ Successfully imported: 10
|
||||
⏭️ Already existed: 2
|
||||
⚠️ Skipped (invalid): 0
|
||||
❌ Errors: 0
|
||||
📁 Total processed: 12
|
||||
|
||||
📋 Imported Funnels:
|
||||
• Funnel Test (funnel-test) - funnel-test.json
|
||||
• Career Accelerator (ru-career-accelerator) - ru-career-accelerator.json
|
||||
...
|
||||
|
||||
🎉 Import process completed!
|
||||
```
|
||||
|
||||
## 🎯 Генерация метаданных
|
||||
|
||||
Скрипт автоматически генерирует удобные названия и описания:
|
||||
|
||||
### Название воронки
|
||||
1. **Из `meta.title`** (если есть)
|
||||
2. **Из `meta.id`** преобразованного в читаемый вид
|
||||
- `funnel-test` → `Funnel Test`
|
||||
- `ru-career-accelerator` → `Ru Career Accelerator`
|
||||
3. **По умолчанию**: `Imported Funnel`
|
||||
|
||||
### Описание воронки
|
||||
1. **Из `meta.description`** (если есть)
|
||||
2. **Автогенерация** на основе:
|
||||
- Количества экранов: "Воронка с 5 экранами"
|
||||
- Используемых шаблонов: "Типы: info, form, list"
|
||||
- Источника: "Импортирована из JSON файла"
|
||||
|
||||
## 🗃️ Структура в базе данных
|
||||
|
||||
Каждая импортированная воронка сохраняется как:
|
||||
|
||||
```json
|
||||
{
|
||||
"_id": "ObjectId",
|
||||
"funnelData": { /* Оригинальная структура JSON */ },
|
||||
"name": "Сгенерированное название",
|
||||
"description": "Сгенерированное описание",
|
||||
"status": "published",
|
||||
"version": 1,
|
||||
"createdBy": "import-script",
|
||||
"usage": { "totalViews": 0, "totalCompletions": 0 },
|
||||
"createdAt": "2025-01-27T02:13:24.000Z",
|
||||
"updatedAt": "2025-01-27T02:13:24.000Z",
|
||||
"publishedAt": "2025-01-27T02:13:24.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Устранение проблем
|
||||
|
||||
### Ошибка подключения к MongoDB
|
||||
```
|
||||
❌ Failed to connect to MongoDB: connect ECONNREFUSED 127.0.0.1:27017
|
||||
```
|
||||
**Решение**: Запустите MongoDB или проверьте `MONGODB_URI`
|
||||
|
||||
### Файлы не найдены
|
||||
```
|
||||
📭 No funnel files found to import.
|
||||
```
|
||||
**Решение**: Убедитесь что JSON файлы находятся в `public/funnels/`
|
||||
|
||||
### Валидационные ошибки
|
||||
```
|
||||
⚠️ Validation warnings for example.json: Missing meta.id
|
||||
```
|
||||
**Решение**: Проверьте структуру JSON файла
|
||||
|
||||
### Дубликаты в базе
|
||||
```
|
||||
⏭️ Skipped - already exists (ID: funnel-test)
|
||||
```
|
||||
**Это нормально**: Скрипт не перезаписывает существующие воронки
|
||||
|
||||
## 📱 После импорта
|
||||
|
||||
### Где найти импортированные воронки
|
||||
1. **Админка**: `http://localhost:3000/admin`
|
||||
2. **Прямой доступ**: `http://localhost:3000/{funnel-id}`
|
||||
3. **Редактирование**: `/admin/builder/{database-id}`
|
||||
|
||||
### Что можно делать
|
||||
- ✅ Редактировать в билдере
|
||||
- ✅ Дублировать и создавать вариации
|
||||
- ✅ Просматривать статистику
|
||||
- ✅ Экспортировать обратно в JSON
|
||||
- ✅ Публиковать/архивировать
|
||||
|
||||
### Совместимость
|
||||
- ✅ Оригинальные JSON файлы продолжают работать
|
||||
- ✅ Импортированные воронки имеют приоритет при загрузке
|
||||
- ✅ Полная обратная совместимость
|
||||
|
||||
## 🔄 Повторный запуск
|
||||
|
||||
Скрипт можно запускать несколько раз:
|
||||
- **Безопасно**: не создает дубликаты
|
||||
- **Умно**: импортирует только новые файлы
|
||||
- **Быстро**: пропускает уже обработанные
|
||||
|
||||
## 📝 Логи и отчеты
|
||||
|
||||
Скрипт выводит подробную информацию:
|
||||
- 📁 Количество найденных файлов
|
||||
- 🔄 Прогресс обработки каждого файла
|
||||
- ✅ Успешные импорты с деталями
|
||||
- ⚠️ Предупреждения и пропуски
|
||||
- ❌ Ошибки с объяснениями
|
||||
- 📊 Итоговая сводка
|
||||
|
||||
---
|
||||
|
||||
**💡 Совет**: Запустите скрипт после настройки базы данных, чтобы быстро мигрировать все существующие воронки в новую админку!
|
||||
216
README-ADMIN.md
Normal file
216
README-ADMIN.md
Normal file
@ -0,0 +1,216 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание новой воронки
|
||||
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 готова к использованию! 🚀**
|
||||
232
docs/templates-and-builder.md
Normal file
232
docs/templates-and-builder.md
Normal file
@ -0,0 +1,232 @@
|
||||
# Шаблоны экранов и конструктор воронки
|
||||
|
||||
Этот документ описывает, из каких частей состоит 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<string, string[]>`, где ключ — `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 и передайте его рантайму (`<FunnelRuntime funnel={definition} initialScreenId={definition.meta.firstScreenId} />`).
|
||||
|
||||
Такой подход гарантирует, что конструктор и рантайм используют одну и ту же схему данных, а визуальные шаблоны ведут себя предсказуемо при изменении конфигурации.
|
||||
251
package-lock.json
generated
251
package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@ -17,7 +18,9 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"mongoose": "^8.18.2",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-circular-progressbar": "^2.2.0",
|
||||
@ -1725,6 +1728,15 @@
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@mongodb-js/saslprep": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz",
|
||||
"integrity": "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sparse-bitfield": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@ -2076,18 +2088,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -3945,6 +3978,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webidl-conversions": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-url": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
||||
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
|
||||
@ -5376,6 +5424,15 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/bson": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
|
||||
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=16.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@ -5863,7 +5920,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@ -5992,14 +6048,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
|
||||
"integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
@ -8129,6 +8187,15 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kareem": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
|
||||
"integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@ -8549,6 +8616,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/memory-pager": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
@ -8685,6 +8758,105 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mongodb": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz",
|
||||
"integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@mongodb-js/saslprep": "^1.1.9",
|
||||
"bson": "^6.10.4",
|
||||
"mongodb-connection-string-url": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/credential-providers": "^3.188.0",
|
||||
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
|
||||
"gcp-metadata": "^5.2.0",
|
||||
"kerberos": "^2.0.1",
|
||||
"mongodb-client-encryption": ">=6.0.0 <7",
|
||||
"snappy": "^7.2.2",
|
||||
"socks": "^2.7.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@aws-sdk/credential-providers": {
|
||||
"optional": true
|
||||
},
|
||||
"@mongodb-js/zstd": {
|
||||
"optional": true
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"optional": true
|
||||
},
|
||||
"kerberos": {
|
||||
"optional": true
|
||||
},
|
||||
"mongodb-client-encryption": {
|
||||
"optional": true
|
||||
},
|
||||
"snappy": {
|
||||
"optional": true
|
||||
},
|
||||
"socks": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb-connection-string-url": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
|
||||
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/whatwg-url": "^11.0.2",
|
||||
"whatwg-url": "^14.1.0 || ^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose": {
|
||||
"version": "8.18.2",
|
||||
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.18.2.tgz",
|
||||
"integrity": "sha512-gA6GFlshOHUdNyw9OQTmMLSGzVOPbcbjaSZ1dvR5iMp668N2UUznTuzgTY6V6Q41VtBc4kmL/qqML1RNgXB5Fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bson": "^6.10.4",
|
||||
"kareem": "2.6.3",
|
||||
"mongodb": "~6.18.0",
|
||||
"mpath": "0.9.0",
|
||||
"mquery": "5.0.0",
|
||||
"ms": "2.1.3",
|
||||
"sift": "17.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mongoose"
|
||||
}
|
||||
},
|
||||
"node_modules/mpath": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
|
||||
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mquery": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
|
||||
"integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@ -8699,7 +8871,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
@ -9313,7 +9484,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@ -10136,6 +10306,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sift": {
|
||||
"version": "17.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
|
||||
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@ -10220,6 +10396,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sparse-bitfield": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"memory-pager": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@ -10902,6 +11087,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
@ -11554,6 +11751,15 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.101.3",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz",
|
||||
@ -11648,6 +11854,19 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@ -7,12 +7,16 @@
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"bake:funnels": "node scripts/bake-funnels.mjs",
|
||||
"import:funnels": "node scripts/import-funnels-to-db.mjs",
|
||||
"prebuild": "npm run bake:funnels",
|
||||
"storybook": "storybook dev -p 6006 --ci",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
@ -20,7 +24,9 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.2",
|
||||
"lucide-react": "^0.544.0",
|
||||
"mongoose": "^8.18.2",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-circular-progressbar": "^2.2.0",
|
||||
|
||||
4
public/GuardIcon.svg
Normal file
4
public/GuardIcon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.5 0C9.6725 0 9.845 0.0373134 10.0025 0.108209L17.0637 3.08955C17.8887 3.43657 18.5037 4.24627 18.5 5.22388C18.4812 8.92537 16.9512 15.6978 10.49 18.7761C9.86375 19.0746 9.13625 19.0746 8.51 18.7761C2.04876 15.6978 0.518767 8.92537 0.500017 5.22388C0.496267 4.24627 1.11127 3.43657 1.93626 3.08955L9.00125 0.108209C9.155 0.0373134 9.3275 0 9.5 0Z" fill="#3F83F8"/>
|
||||
<path d="M8.87116 12.38C8.86942 12.38 8.86767 12.38 8.86614 12.38C8.72515 12.3785 8.59338 12.3106 8.51 12.1972L6.58711 9.58194C6.44046 9.38253 6.48336 9.10188 6.68278 8.95523C6.88219 8.80792 7.16306 8.85167 7.30948 9.05091L8.87968 11.1866L11.973 7.17469C12.1241 6.97855 12.4058 6.94201 12.602 7.09345C12.7979 7.24471 12.8345 7.52621 12.6832 7.72235L9.22623 12.2056C9.14128 12.3156 9.01014 12.38 8.87116 12.38Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 905 B |
662
public/funnels/funnel-test-variants.json
Normal file
662
public/funnels/funnel-test-variants.json
Normal file
@ -0,0 +1,662 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "funnel-test-variants",
|
||||
"title": "Relationship Portrait",
|
||||
"description": "Demo funnel mirroring design screens with branching by analysis target.",
|
||||
"firstScreenId": "intro-welcome"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Next",
|
||||
"continueButton": "Continue"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "intro-welcome",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Вы не одиноки в этом страхе",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Многие боятся повторить прошлый опыт. Мы поможем распознать верные сигналы и выбрать «своего» человека.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "default",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "❤️",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Next"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "intro-statistics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "intro-statistics",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "🔥❤️",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Next"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "intro-partner-traits"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "intro-partner-traits",
|
||||
"template": "info",
|
||||
"header": {
|
||||
"showBackButton": false
|
||||
},
|
||||
"title": {
|
||||
"text": "Такой партнёр умеет слышать и поддерживать, а вы — человек с глубокой душой, который ценит искренность и силу настоящих чувств.",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "💖",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Next"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "birth-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "birth-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда ты родился?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "В момент вашего рождения заложенны глубинные закономерности.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "MM",
|
||||
"dayPlaceholder": "DD",
|
||||
"yearPlaceholder": "YYYY",
|
||||
"monthLabel": "Month",
|
||||
"dayLabel": "Day",
|
||||
"yearLabel": "Year",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Выбранная дата:"
|
||||
},
|
||||
"infoMessage": {
|
||||
"text": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Next"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "address-form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "address-form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Which best represents your hair loss and goals?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Let's personalize your hair care journey",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"id": "address",
|
||||
"label": "Address",
|
||||
"placeholder": "Enter your full address",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"maxLength": 200
|
||||
}
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "${field} обязательно для заполнения",
|
||||
"maxLength": "Максимум ${maxLength} символов",
|
||||
"invalidFormat": "Неверный формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "statistics-text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "statistics-text",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Which best represents your hair loss and goals?",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "default",
|
||||
"align": "center"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gender",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какого ты пола?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Все начинается с тебя! Выбери свой пол.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "female",
|
||||
"label": "FEMALE",
|
||||
"emoji": "💗"
|
||||
},
|
||||
{
|
||||
"id": "male",
|
||||
"label": "MALE",
|
||||
"emoji": "💙"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "relationship-status"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "relationship-status",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Вы сейчас?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Это нужно, чтобы портрет и советы были точнее.",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "in-relationship",
|
||||
"label": "В отношениях"
|
||||
},
|
||||
{
|
||||
"id": "single",
|
||||
"label": "Свободны"
|
||||
},
|
||||
{
|
||||
"id": "after-breakup",
|
||||
"label": "После расставания"
|
||||
},
|
||||
{
|
||||
"id": "complicated",
|
||||
"label": "Все сложно"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "analysis-target"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "analysis-target",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Кого анализируем?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "current-partner",
|
||||
"label": "Текущего партнера"
|
||||
},
|
||||
{
|
||||
"id": "crush",
|
||||
"label": "Человека, который нравится"
|
||||
},
|
||||
{
|
||||
"id": "ex-partner",
|
||||
"label": "Бывшего"
|
||||
},
|
||||
{
|
||||
"id": "future-partner",
|
||||
"label": "Будущую встречу"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "partner-age"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "partner-age",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Возраст партнера",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Чтобы портрет был максимально точным, уточните возраст.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["current-partner"]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "Возраст текущего партнера",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["crush"]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "Возраст человека, который нравится",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"show": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["ex-partner"]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "Возраст бывшего",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["future-partner"]
|
||||
}
|
||||
],
|
||||
"overrides": {
|
||||
"title": {
|
||||
"text": "Возраст будущего партнера",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Чтобы мы не упустили важные нюансы будущей встречи.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "partner-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "age-refine",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Уточните чуть точнее",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Чтобы портрет был максимально похож.",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "18-21",
|
||||
"label": "18-21"
|
||||
},
|
||||
{
|
||||
"id": "22-25",
|
||||
"label": "22-25"
|
||||
},
|
||||
{
|
||||
"id": "26-29",
|
||||
"label": "26-29"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "partner-ethnicity",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Этническая принадлежность твоей второй половинки?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "white",
|
||||
"label": "White"
|
||||
},
|
||||
{
|
||||
"id": "hispanic",
|
||||
"label": "Hispanic / Latino"
|
||||
},
|
||||
{
|
||||
"id": "african",
|
||||
"label": "African / African-American"
|
||||
},
|
||||
{
|
||||
"id": "asian",
|
||||
"label": "Asian"
|
||||
},
|
||||
{
|
||||
"id": "south-asian",
|
||||
"label": "Indian / South Asian"
|
||||
},
|
||||
{
|
||||
"id": "middle-eastern",
|
||||
"label": "Middle Eastern / Arab"
|
||||
},
|
||||
{
|
||||
"id": "indigenous",
|
||||
"label": "Native American / Indigenous"
|
||||
},
|
||||
{
|
||||
"id": "no-preference",
|
||||
"label": "No preference"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "partner-eyes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "partner-eyes",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что из этого «про глаза»?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "warm-glow",
|
||||
"label": "Тёплые искры на свету"
|
||||
},
|
||||
{
|
||||
"id": "clear-depth",
|
||||
"label": "Прозрачная глубина"
|
||||
},
|
||||
{
|
||||
"id": "green-sheen",
|
||||
"label": "Зелёный отлив на границе зрачка"
|
||||
},
|
||||
{
|
||||
"id": "steel-glint",
|
||||
"label": "Холодный стальной отблеск"
|
||||
},
|
||||
{
|
||||
"id": "deep-shadow",
|
||||
"label": "Насыщенная темнота"
|
||||
},
|
||||
{
|
||||
"id": "dont-know",
|
||||
"label": "Не знаю"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "partner-hair-length"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "partner-hair-length",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите длину волос",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "От неё зависит форма и настроение портрета.",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "short",
|
||||
"label": "Короткие"
|
||||
},
|
||||
{
|
||||
"id": "medium",
|
||||
"label": "Средние"
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"label": "Длинные"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "burnout-support"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "burnout-support",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Когда ты выгораешь, тебе нужно чтобы партнёр...",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{
|
||||
"id": "reassure",
|
||||
"label": "Признал ваше разочарование и успокоил"
|
||||
},
|
||||
{
|
||||
"id": "emotional-support",
|
||||
"label": "Дал эмоциональную опору и безопасное пространство"
|
||||
},
|
||||
{
|
||||
"id": "take-over",
|
||||
"label": "Перехватил быт/дела, чтобы вы восстановились"
|
||||
},
|
||||
{
|
||||
"id": "energize",
|
||||
"label": "Вдохнул энергию через цель и короткий план действий"
|
||||
},
|
||||
{
|
||||
"id": "switch-positive",
|
||||
"label": "Переключил на позитив: прогулка, кино, смешные истории"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"show": false
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "special-offer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "special-offer",
|
||||
"template": "coupon",
|
||||
"header": {
|
||||
"show": false
|
||||
},
|
||||
"title": {
|
||||
"text": "Тебе повезло!",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Ты получил специальную эксклюзивную скидку на 94%",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted",
|
||||
"align": "center"
|
||||
},
|
||||
"copiedMessage": "Промокод \"{code}\" скопирован!",
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "Special Offer",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "94% OFF",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"color": "card",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Одноразовая эксклюзивная скидка",
|
||||
"font": "inter",
|
||||
"weight": "semiBold",
|
||||
"color": "card"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "HAIR50",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Скопируйте или нажмите Continue",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Continue"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
773
public/funnels/funnel-test.json
Normal file
773
public/funnels/funnel-test.json
Normal file
@ -0,0 +1,773 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "funnel-test",
|
||||
"title": "Relationship Portrait",
|
||||
"description": "Demo funnel mirroring design screens with branching by analysis target.",
|
||||
"firstScreenId": "intro-welcome"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Next",
|
||||
"continueButton": "Continue"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "intro-welcome",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Вы не одиноки в этом страхе",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Многие боятся повторить прошлый опыт. Мы поможем распознать верные сигналы и выбрать «своего» человека.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "default",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "❤️",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Next"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "intro-statistics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "intro-statistics",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "🔥❤️",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Next"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "intro-partner-traits"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "intro-partner-traits",
|
||||
"template": "info",
|
||||
"header": {
|
||||
"showBackButton": false
|
||||
},
|
||||
"title": {
|
||||
"text": "Такой партнёр умеет слышать и поддерживать, а вы — человек с глубокой душой, который ценит искренность и силу настоящих чувств.",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "💖",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Next"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "birth-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "birth-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда ты родился?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "В момент вашего рождения заложенны глубинные закономерности.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "MM",
|
||||
"dayPlaceholder": "DD",
|
||||
"yearPlaceholder": "YYYY",
|
||||
"monthLabel": "Month",
|
||||
"dayLabel": "Day",
|
||||
"yearLabel": "Year",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Выбранная дата:"
|
||||
},
|
||||
"infoMessage": {
|
||||
"text": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Next"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "address-form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "address-form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Which best represents your hair loss and goals?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Let's personalize your hair care journey",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"id": "address",
|
||||
"label": "Address",
|
||||
"placeholder": "Enter your full address",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"maxLength": 200
|
||||
}
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "${field} обязательно для заполнения",
|
||||
"maxLength": "Максимум ${maxLength} символов",
|
||||
"invalidFormat": "Неверный формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "statistics-text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "statistics-text",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Which best represents your hair loss and goals?",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "default",
|
||||
"align": "center"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "gender",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какого ты пола?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Все начинается с тебя! Выбери свой пол.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "female",
|
||||
"label": "FEMALE",
|
||||
"emoji": "💗"
|
||||
},
|
||||
{
|
||||
"id": "male",
|
||||
"label": "MALE",
|
||||
"emoji": "💙"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "relationship-status"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "relationship-status",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Вы сейчас?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Это нужно, чтобы портрет и советы были точнее.",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "in-relationship",
|
||||
"label": "В отношениях"
|
||||
},
|
||||
{
|
||||
"id": "single",
|
||||
"label": "Свободны"
|
||||
},
|
||||
{
|
||||
"id": "after-breakup",
|
||||
"label": "После расставания"
|
||||
},
|
||||
{
|
||||
"id": "complicated",
|
||||
"label": "Все сложно"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "analysis-target"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "analysis-target",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Кого анализируем?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "current-partner",
|
||||
"label": "Текущего партнера"
|
||||
},
|
||||
{
|
||||
"id": "crush",
|
||||
"label": "Человека, который нравится"
|
||||
},
|
||||
{
|
||||
"id": "ex-partner",
|
||||
"label": "Бывшего"
|
||||
},
|
||||
{
|
||||
"id": "future-partner",
|
||||
"label": "Будущую встречу"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["current-partner"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "current-partner-age"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["crush"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "crush-age"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["ex-partner"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "ex-partner-age"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["future-partner"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "future-partner-age"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "current-partner-age"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "current-partner-age",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Возраст текущего партнера",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "current-partner-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "crush-age",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Возраст человека, который нравится",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"show": false
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "crush-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ex-partner-age",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Возраст бывшего",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "ex-partner-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "future-partner-age",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Возраст будущего партнера",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "future-partner-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "age-refine",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Уточните чуть точнее",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Чтобы портрет был максимально похож.",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "18-21",
|
||||
"label": "18-21"
|
||||
},
|
||||
{
|
||||
"id": "22-25",
|
||||
"label": "22-25"
|
||||
},
|
||||
{
|
||||
"id": "26-29",
|
||||
"label": "26-29"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "partner-ethnicity",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Этническая принадлежность твоей второй половинки?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "white",
|
||||
"label": "White"
|
||||
},
|
||||
{
|
||||
"id": "hispanic",
|
||||
"label": "Hispanic / Latino"
|
||||
},
|
||||
{
|
||||
"id": "african",
|
||||
"label": "African / African-American"
|
||||
},
|
||||
{
|
||||
"id": "asian",
|
||||
"label": "Asian"
|
||||
},
|
||||
{
|
||||
"id": "south-asian",
|
||||
"label": "Indian / South Asian"
|
||||
},
|
||||
{
|
||||
"id": "middle-eastern",
|
||||
"label": "Middle Eastern / Arab"
|
||||
},
|
||||
{
|
||||
"id": "indigenous",
|
||||
"label": "Native American / Indigenous"
|
||||
},
|
||||
{
|
||||
"id": "no-preference",
|
||||
"label": "No preference"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "partner-eyes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "partner-eyes",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что из этого «про глаза»?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "warm-glow",
|
||||
"label": "Тёплые искры на свету"
|
||||
},
|
||||
{
|
||||
"id": "clear-depth",
|
||||
"label": "Прозрачная глубина"
|
||||
},
|
||||
{
|
||||
"id": "green-sheen",
|
||||
"label": "Зелёный отлив на границе зрачка"
|
||||
},
|
||||
{
|
||||
"id": "steel-glint",
|
||||
"label": "Холодный стальной отблеск"
|
||||
},
|
||||
{
|
||||
"id": "deep-shadow",
|
||||
"label": "Насыщенная темнота"
|
||||
},
|
||||
{
|
||||
"id": "dont-know",
|
||||
"label": "Не знаю"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "partner-hair-length"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "partner-hair-length",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите длину волос",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "От неё зависит форма и настроение портрета.",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "short",
|
||||
"label": "Короткие"
|
||||
},
|
||||
{
|
||||
"id": "medium",
|
||||
"label": "Средние"
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"label": "Длинные"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "burnout-support"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "burnout-support",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Когда ты выгораешь, тебе нужно чтобы партнёр...",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{
|
||||
"id": "reassure",
|
||||
"label": "Признал ваше разочарование и успокоил"
|
||||
},
|
||||
{
|
||||
"id": "emotional-support",
|
||||
"label": "Дал эмоциональную опору и безопасное пространство"
|
||||
},
|
||||
{
|
||||
"id": "take-over",
|
||||
"label": "Перехватил быт/дела, чтобы вы восстановились"
|
||||
},
|
||||
{
|
||||
"id": "energize",
|
||||
"label": "Вдохнул энергию через цель и короткий план действий"
|
||||
},
|
||||
{
|
||||
"id": "switch-positive",
|
||||
"label": "Переключил на позитив: прогулка, кино, смешные истории"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"show": false
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "special-offer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "special-offer",
|
||||
"template": "coupon",
|
||||
"header": {
|
||||
"show": false
|
||||
},
|
||||
"title": {
|
||||
"text": "Тебе повезло!",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Ты получил специальную эксклюзивную скидку на 94%",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted",
|
||||
"align": "center"
|
||||
},
|
||||
"copiedMessage": "Промокод \"{code}\" скопирован!",
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "Special Offer",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "94% OFF",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"color": "card",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Одноразовая эксклюзивная скидка",
|
||||
"font": "inter",
|
||||
"weight": "semiBold",
|
||||
"color": "card"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "HAIR50",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Скопируйте или нажмите Continue",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Continue"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
313
public/funnels/ru-career-accelerator.json
Normal file
313
public/funnels/ru-career-accelerator.json
Normal file
@ -0,0 +1,313 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-career-accelerator",
|
||||
"title": "CareerUp: рывок в карьере",
|
||||
"description": "Воронка карьерного акселератора для специалистов и руководителей.",
|
||||
"firstScreenId": "welcome"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Повысь доход и статус за 12 недель",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Коуч, карьерный стратег и HR-директор ведут тебя к новой должности или росту дохода.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "🚀",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Пройти диагностику"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "pain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pain",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Почему карьера застопорилась?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Нет стратегии, страх переговоров и слабый личный бренд. Мы закрываем каждый пробел.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "goal-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "goal-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда хочешь выйти на новую позицию?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Сформируем спринты под конкретный дедлайн.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Цель к:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "current-role"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "current-role",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Текущая роль",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "specialist", "label": "Специалист" },
|
||||
{ "id": "lead", "label": "Тимлид" },
|
||||
{ "id": "manager", "label": "Руководитель отдела" },
|
||||
{ "id": "c-level", "label": "C-level" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "target"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "target",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Желаемая цель",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "promotion", "label": "Повышение внутри компании" },
|
||||
{ "id": "newjob", "label": "Переход в топ-компанию" },
|
||||
{ "id": "salary", "label": "Рост дохода на 50%" },
|
||||
{ "id": "relocate", "label": "Релокация" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "case",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "История Марии: +85% к доходу",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "За 9 недель она прошла программу, обновила резюме, договорилась о relocation и заняла позицию руководителя продукта.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bottlenecks"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bottlenecks",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Где нужна поддержка?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "resume", "label": "Резюме и LinkedIn" },
|
||||
{ "id": "network", "label": "Нетворкинг" },
|
||||
{ "id": "interview", "label": "Интервью" },
|
||||
{ "id": "negotiation", "label": "Переговоры о зарплате" },
|
||||
{ "id": "leadership", "label": "Лидерские навыки" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "program-format"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "program-format",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой формат подходит?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "group", "label": "Групповой акселератор" },
|
||||
{ "id": "1on1", "label": "Индивидуальное сопровождение" },
|
||||
{ "id": "vip", "label": "Executive программа" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получить план роста",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
|
||||
{ "id": "email", "label": "Email", "placeholder": "Получить карьерный план", "type": "email", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "mentor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mentor",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Твой наставник",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Ex-HR Director из Microsoft поможет построить стратегию и проведёт ролевые интервью.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "packages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "packages",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите пакет",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "start", "label": "Start — 6 недель" },
|
||||
{ "id": "pro", "label": "Pro — 12 недель" },
|
||||
{ "id": "elite", "label": "Elite — 16 недель + наставник" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Бонусы при оплате сегодня",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Шаблоны писем рекрутерам, библиотека резюме и доступ к закрытому карьерному клубу.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Зафиксируй скидку и бонусы",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Скидка 20% и два дополнительных карьерных созвона.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "CareerUp",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-20%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Программа + 2 коуч-сессии",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "CAREER20",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы активировать предложение",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
314
public/funnels/ru-finance-freedom.json
Normal file
314
public/funnels/ru-finance-freedom.json
Normal file
@ -0,0 +1,314 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-finance-freedom",
|
||||
"title": "Capital Sense: финансовая свобода",
|
||||
"description": "Воронка для консультаций по инвестициям и личному финансовому планированию.",
|
||||
"firstScreenId": "intro"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "intro",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Сформируй капитал, который работает за тебя",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Персональный финансовый план, подбор инструментов и сопровождение на каждом шаге.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "💼",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Начать"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "fear"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fear",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Почему деньги не приносят свободу?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Разные цели, хаотичные инвестиции и страх потерять. Мы создаём стратегию с защитой и ростом.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "goal-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "goal-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда хочешь достичь финансовой цели?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Укажи дату, чтобы рассчитать необходимые шаги.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Цель к дате:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "current-income"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "current-income",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой у тебя ежемесячный доход?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "lt100k", "label": "До 100 000 ₽" },
|
||||
{ "id": "100-250", "label": "100 000 – 250 000 ₽" },
|
||||
{ "id": "250-500", "label": "250 000 – 500 000 ₽" },
|
||||
{ "id": "500plus", "label": "Свыше 500 000 ₽" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "savings"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "savings",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Как распределяются накопления?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "deposit", "label": "Банковские вклады" },
|
||||
{ "id": "stocks", "label": "Акции и фонды" },
|
||||
{ "id": "realty", "label": "Недвижимость" },
|
||||
{ "id": "business", "label": "Собственный бизнес" },
|
||||
{ "id": "cash", "label": "Храню в наличных" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "risk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "risk",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Готовность к риску",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "conservative", "label": "Консервативная стратегия" },
|
||||
{ "id": "balanced", "label": "Сбалансированный портфель" },
|
||||
{ "id": "aggressive", "label": "Готов к высоким рискам ради роста" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "case",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "История Александра: капитал 12 млн за 5 лет",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Использовали облигации, дивидендные акции и страхование. Доходность 18% при низком риске.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "priorities"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "priorities",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выбери финансовые приоритеты",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "capital", "label": "Долгосрочный капитал" },
|
||||
{ "id": "passive", "label": "Пассивный доход" },
|
||||
{ "id": "education", "label": "Образование детей" },
|
||||
{ "id": "pension", "label": "Пенсия без тревог" },
|
||||
{ "id": "protection", "label": "Страхование и защита" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получить расчёт стратегии",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "name", "label": "Имя", "placeholder": "Как вас зовут", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
|
||||
{ "id": "email", "label": "Email", "placeholder": "Получить PDF-план", "type": "email", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "advisor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "advisor",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Ваш персональный советник",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Сертифицированный финансовый консультант составит портфель и будет сопровождать на ежемесячных созвонах.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "packages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "packages",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите пакет сопровождения",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "start", "label": "Start — до 2 млн ₽" },
|
||||
{ "id": "growth", "label": "Growth — до 10 млн ₽" },
|
||||
{ "id": "elite", "label": "Elite — от 10 млн ₽ и Family Office" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Бонусы к записи сегодня",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Инвестиционный чек-лист и бесплатный аудит страховок от партнёра.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Забронируйте условия",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Скидка 25% на первый месяц сопровождения и аудит портфеля.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "Capital Sense",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-25%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Первый месяц и аудит портфеля",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "FIN25",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы активировать промокод",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
356
public/funnels/ru-fitness-transform.json
Normal file
356
public/funnels/ru-fitness-transform.json
Normal file
@ -0,0 +1,356 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-fitness-transform",
|
||||
"title": "Фитнес-вызов: Тело мечты за 12 недель",
|
||||
"description": "Воронка для продажи онлайн-программы персональных тренировок и питания.",
|
||||
"firstScreenId": "intro-hero"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "intro-hero",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Создай тело, которое будет восхищать",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Личный куратор, готовые тренировки и поддержка нутрициолога для стремительного результата.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "💪",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Начать диагностику"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "pain-check"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pain-check",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Почему результат не держится?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "92% наших клиентов приходят после десятков попыток похудеть. Мы устраняем коренные причины: гормональный фон, сон, питание.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "target-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "target-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда планируешь увидеть первые изменения?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Укажи желаемую дату — мы построим обратный план.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Целевая дата:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "current-state"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "current-state",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что больше всего мешает сейчас?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "time", "label": "Нет времени на зал" },
|
||||
{ "id": "food", "label": "Срывы в питании" },
|
||||
{ "id": "motivation", "label": "Не хватает мотивации" },
|
||||
{ "id": "health", "label": "Боли в спине/суставах" },
|
||||
{ "id": "plateau", "label": "Вес стоит на месте" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "goal-selection"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "goal-selection",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какая цель приоритетна?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Выбери один вариант — мы адаптируем программу.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "fat-loss", "label": "Снижение веса" },
|
||||
{ "id": "tone", "label": "Упругость и рельеф" },
|
||||
{ "id": "health", "label": "Самочувствие и энергия" },
|
||||
{ "id": "postpartum", "label": "Восстановление после родов" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "success-story"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "success-story",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Света минус 14 кг за 12 недель",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Она работала по 12 часов в офисе. Мы составили план из 30-минутных тренировок и настроили питание без голода. Теперь она ведёт блог и вдохновляет подруг.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "lifestyle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lifestyle",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Сколько времени готов(а) уделять?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "15min", "label": "15–20 минут в день" },
|
||||
{ "id": "30min", "label": "30–40 минут" },
|
||||
{ "id": "60min", "label": "60 минут и более" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "nutrition"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nutrition",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Питание без жёстких запретов",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Балансируем рацион под твои привычки: любимые блюда остаются, меняются только пропорции.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "support-format"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "support-format",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой формат поддержки комфортен?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "chat", "label": "Чат с куратором ежедневно" },
|
||||
{ "id": "calls", "label": "Созвоны раз в неделю" },
|
||||
{ "id": "video", "label": "Видеоразбор техники" },
|
||||
{ "id": "community", "label": "Группа единомышленников" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "contact-form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contact-form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Почти готово! Оставь контакты для персональной стратегии",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"id": "name",
|
||||
"label": "Имя",
|
||||
"placeholder": "Как к тебе обращаться",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"id": "phone",
|
||||
"label": "Телефон",
|
||||
"placeholder": "+7 (___) ___-__-__",
|
||||
"type": "tel",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "email",
|
||||
"label": "Email",
|
||||
"placeholder": "Для отправки материалов",
|
||||
"type": "email",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"maxLength": "Максимум ${maxLength} символов",
|
||||
"invalidFormat": "Проверь формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coach-match"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coach-match",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Подбираем наставника",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Мы нашли тренера, который специализируется на твоём запросе и будет на связи 24/7.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus-overview"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus-overview",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Что входит в программу",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Получишь 36 адаптивных тренировок, 3 чек-листа питания, психологическую поддержку и доступ к закрытым эфиром.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "package-choice"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "package-choice",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выбери формат участия",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "online", "label": "Онлайн-куратор и видеоуроки" },
|
||||
{ "id": "vip", "label": "VIP: личные созвоны и чат 24/7" },
|
||||
{ "id": "studio", "label": "Комбо: онлайн + студийные тренировки" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "final-offer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "final-offer",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Зафиксируй место и подарок",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Скидка действует 24 часа после прохождения диагностики.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "Фитнес-вызов",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-35%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Персональная программа и чат с тренером",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "BODY35",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажми \"Продолжить\" чтобы закрепить скидку",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
330
public/funnels/ru-interior-signature.json
Normal file
330
public/funnels/ru-interior-signature.json
Normal file
@ -0,0 +1,330 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-interior-signature",
|
||||
"title": "Design Bureau: интерьер под ключ",
|
||||
"description": "Воронка студии дизайна интерьера с авторским сопровождением ремонта.",
|
||||
"firstScreenId": "intro"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "intro",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Интерьер, который отражает ваш характер",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Создаём дизайн-проекты премиум-класса с полным контролем ремонта и экономией бюджета до 18%.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "🏡",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Начать проект"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "problem"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "problem",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Типовая планировка крадёт эмоции",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Мы превращаем квадратные метры в пространство, где хочется жить, а не просто находиться.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "finish-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "finish-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда планируете переезд?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Укажи сроки, чтобы мы составили реалистичный план работ.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Переезд:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "property-type"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "property-type",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой объект оформляете?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "apartment", "label": "Квартира" },
|
||||
{ "id": "house", "label": "Дом" },
|
||||
{ "id": "office", "label": "Коммерческое пространство" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "style"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "style",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Стиль, который вдохновляет",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "minimal", "label": "Минимализм" },
|
||||
{ "id": "loft", "label": "Лофт" },
|
||||
{ "id": "classic", "label": "Современная классика" },
|
||||
{ "id": "eco", "label": "Эко" },
|
||||
{ "id": "mix", "label": "Эклектика" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "pain-points"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pain-points",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что вызывает наибольшие сложности?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "planning", "label": "Планировка" },
|
||||
{ "id": "contractors", "label": "Поиск подрядчиков" },
|
||||
{ "id": "budget", "label": "Контроль бюджета" },
|
||||
{ "id": "decor", "label": "Подбор мебели и декора" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "case",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Квартира в ЖК CITY PARK",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Мы оптимизировали планировку, сэкономили 2,4 млн ₽ на поставщиках и завершили ремонт на 3 недели раньше срока.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "services"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "services",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Что входит в нашу работу",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "3D-визуализации, рабочие чертежи, авторский надзор, логистика материалов и финансовый контроль.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "budget"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "budget",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Планируемый бюджет проекта",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "2m", "label": "до 2 млн ₽" },
|
||||
{ "id": "5m", "label": "2 – 5 млн ₽" },
|
||||
{ "id": "10m", "label": "5 – 10 млн ₽" },
|
||||
{ "id": "10mplus", "label": "Более 10 млн ₽" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получите концепцию и смету",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
|
||||
{ "id": "email", "label": "Email", "placeholder": "Получить презентацию", "type": "email", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "designer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "designer",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Персональный дизайнер",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Автор проектов для бизнес-элиты. Ведёт максимум 5 объектов, чтобы уделять максимум внимания.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "packages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "packages",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите формат работы",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "concept", "label": "Concept — планировка и визуализации" },
|
||||
{ "id": "supervision", "label": "Control — авторский надзор" },
|
||||
{ "id": "turnkey", "label": "Turnkey — ремонт под ключ" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Бонусы при бронировании сегодня",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Авторский колорит и подбор мебели от итальянских брендов со скидкой до 30%.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Зафиксируйте привилегии",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Скидка 20% на дизайн-проект и доступ к базе подрядчиков.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "Design Bureau",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-20%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Дизайн-проект + база подрядчиков",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "DESIGN20",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы получить предложение",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
311
public/funnels/ru-kids-robotics.json
Normal file
311
public/funnels/ru-kids-robotics.json
Normal file
@ -0,0 +1,311 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-kids-robotics",
|
||||
"title": "RoboKids: будущее ребёнка",
|
||||
"description": "Воронка для школы робототехники и программирования для детей 6-14 лет.",
|
||||
"firstScreenId": "welcome"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Подарите ребёнку навыки будущего",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Проектные занятия по робототехнике, программированию и soft skills в игровой форме.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "🤖",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Узнать программу"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "pain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pain",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Почему важно развивать навыки сейчас",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "90% современных профессий требуют технического мышления. Мы даём ребёнку уверенность и любовь к обучению.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "birth-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "birth-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда родился ваш ребёнок?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Возраст помогает подобрать подходящую программу.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Возраст:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "interest"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "interest",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что нравится ребёнку?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "lego", "label": "Конструировать" },
|
||||
{ "id": "games", "label": "Компьютерные игры" },
|
||||
{ "id": "science", "label": "Экспериментировать" },
|
||||
{ "id": "art", "label": "Рисовать и создавать истории" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "skills"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "skills",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какие навыки хотите усилить?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "logic", "label": "Логика и математика" },
|
||||
{ "id": "team", "label": "Командная работа" },
|
||||
{ "id": "presentation", "label": "Презентация проектов" },
|
||||
{ "id": "creativity", "label": "Креативность" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "case",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Кейс семьи Еремовых",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Сын собрал робота-доставщика и выиграл региональный конкурс. Теперь учится в технопарке.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "format"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "format",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой формат занятий удобен?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "offline", "label": "Очно в технопарке" },
|
||||
{ "id": "online", "label": "Онлайн-лаборатория" },
|
||||
{ "id": "hybrid", "label": "Комбо: онлайн + офлайн" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "schedule"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "schedule",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите расписание",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "weekend", "label": "Выходные" },
|
||||
{ "id": "weekday", "label": "Будни после школы" },
|
||||
{ "id": "intensive", "label": "Интенсивные каникулы" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получите бесплатный пробный урок",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "parentName", "label": "Имя родителя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
|
||||
{ "id": "childName", "label": "Имя ребёнка", "placeholder": "Имя ребёнка", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте корректность"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "mentor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mentor",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Ваш наставник",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Педагог MIT и финалист World Robot Olympiad проведёт вводную встречу и вовлечёт ребёнка в проект.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "packages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "packages",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите программу",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "start", "label": "Start — 2 месяца" },
|
||||
{ "id": "pro", "label": "Pro — 6 месяцев" },
|
||||
{ "id": "elite", "label": "Elite — 12 месяцев + наставник" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Бонусы для новых семей",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Сертификат на 3D-печать проекта и доступ к киберспортивной студии.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Забронируйте место",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Скидка 15% и подарок на первый месяц обучения.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "RoboKids",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-15%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Первый месяц + подарок",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "ROBO15",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы активировать скидку",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
330
public/funnels/ru-language-immersion.json
Normal file
330
public/funnels/ru-language-immersion.json
Normal file
@ -0,0 +1,330 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-language-immersion",
|
||||
"title": "LinguaPro: английский за 3 месяца",
|
||||
"description": "Воронка онлайн-школы английского языка для взрослых.",
|
||||
"firstScreenId": "start"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "start",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Говори уверенно через 12 недель",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Живые уроки с преподавателем, ежедневная практика и контроль прогресса.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "🌍",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Диагностика уровня"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "pain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pain",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Почему 4 из 5 студентов не доходят до результата?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Нерегулярность, отсутствие практики и скучные уроки. Мы исправили каждую точку.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "goal-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "goal-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда предстоит важное событие на английском?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Сформируем план подготовки под конкретную дату.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Событие:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "current-level"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "current-level",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Оцени свой текущий уровень",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "starter", "label": "Начинаю с нуля" },
|
||||
{ "id": "elementary", "label": "Могу поддержать простую беседу" },
|
||||
{ "id": "intermediate", "label": "Хочу говорить свободно" },
|
||||
{ "id": "advanced", "label": "Нужен профессиональный английский" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "difficulties"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "difficulties",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что даётся сложнее всего?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "speaking", "label": "Разговорная речь" },
|
||||
{ "id": "listening", "label": "Понимание на слух" },
|
||||
{ "id": "grammar", "label": "Грамматика" },
|
||||
{ "id": "vocabulary", "label": "Словарный запас" },
|
||||
{ "id": "confidence", "label": "Стеснение" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "success-story"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "success-story",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Кейс Максима: оффер в международной компании",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "За 10 недель он прокачал разговорный до Upper-Intermediate, прошёл интервью и удвоил доход.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "study-format"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "study-format",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Как удобнее заниматься?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "individual", "label": "Индивидуально с преподавателем" },
|
||||
{ "id": "mini-group", "label": "Мини-группа до 4 человек" },
|
||||
{ "id": "intensive", "label": "Интенсив по выходным" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "practice"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "practice",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Практика каждый день",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Марафоны спикинга, разговорные клубы с носителями и тренажёр произношения в приложении.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "support"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "support",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что важно в поддержке?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "mentor", "label": "Личный куратор" },
|
||||
{ "id": "feedback", "label": "Еженедельный фидбек" },
|
||||
{ "id": "chat", "label": "Чат 24/7" },
|
||||
{ "id": "reports", "label": "Отчёт о прогрессе" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "contact-form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "contact-form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получите индивидуальный учебный план",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
|
||||
{ "id": "email", "label": "Email", "placeholder": "Получите PDF-план", "type": "email", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте ввод"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "mentor-match"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mentor-match",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Мы подобрали вам преподавателя",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Сертифицированный CELTA преподаватель с опытом подготовки к собеседованиям.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "programs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "programs",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите программу",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "starter", "label": "Start Now — 8 недель" },
|
||||
{ "id": "pro", "label": "Career Boost — 12 недель" },
|
||||
{ "id": "vip", "label": "Executive — 16 недель + коуч" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Бонусы для тех, кто оплачивает сегодня",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Доступ к библиотеке TED-тренажёров и разговорный клуб в подарок.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Закрепите скидку",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Скидка 30% на первый модуль и бонусный урок с носителем.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "LinguaPro",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-30%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Курс и разговорный клуб",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "LINGUA30",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы использовать промокод",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
313
public/funnels/ru-mind-balance.json
Normal file
313
public/funnels/ru-mind-balance.json
Normal file
@ -0,0 +1,313 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-mind-balance",
|
||||
"title": "MindBalance: психотерапия для результата",
|
||||
"description": "Воронка сервиса подбора психолога с поддержкой и пакетами сопровождения.",
|
||||
"firstScreenId": "welcome"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Верни устойчивость за 8 недель",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Персональный подбор терапевта, структурные сессии и поддержка между встречами.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "🧠",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Пройти тест"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "pain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pain",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Ты не обязан справляться в одиночку",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Выгорание, тревога, сложности в отношениях — наши клиенты чувствовали то же. Сейчас живут без этого тяжёлого груза.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "stress-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "stress-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда ты последний раз отдыхал(а) без тревог?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Это помогает оценить уровень стресса и подобрать ритм терапии.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Дата отдыха:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "state"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что чувствуешь чаще всего?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "anxiety", "label": "Тревога" },
|
||||
{ "id": "apathy", "label": "Апатия" },
|
||||
{ "id": "anger", "label": "Раздражительность" },
|
||||
{ "id": "insomnia", "label": "Проблемы со сном" },
|
||||
{ "id": "relationships", "label": "Конфликты" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "goals"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "goals",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "К чему хочешь прийти?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "energy", "label": "Больше энергии" },
|
||||
{ "id": "confidence", "label": "Уверенность в решениях" },
|
||||
{ "id": "relations", "label": "Гармония в отношениях" },
|
||||
{ "id": "selfcare", "label": "Ценность себя" },
|
||||
{ "id": "career", "label": "Сфокусированность в работе" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "success",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "История Ани: спокойствие вместо паники",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Через 7 сессий она перестала просыпаться ночью, получила повышение и наладила отношения с мужем.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "format"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "format",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой формат терапии удобен?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "online", "label": "Онлайн-видеосессии" },
|
||||
{ "id": "audio", "label": "Аудио и чат-поддержка" },
|
||||
{ "id": "offline", "label": "Офлайн в кабинете" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "frequency"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "frequency",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "С какой частотой готовы встречаться?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "weekly", "label": "Раз в неделю" },
|
||||
{ "id": "twice", "label": "Дважды в неделю" },
|
||||
{ "id": "flex", "label": "Гибкий график" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получить подбор психолога",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "name", "label": "Имя", "placeholder": "Ваше имя", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
|
||||
{ "id": "email", "label": "Email", "placeholder": "Для плана терапии", "type": "email", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте ввод"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "therapist"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "therapist",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Мы нашли специалиста",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Психолог с 9-летним опытом CBT, работает с тревогой и выгоранием. Первичная консультация — завтра.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "packages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "packages",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите пакет",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "start", "label": "Start — 4 сессии" },
|
||||
{ "id": "focus", "label": "Focus — 8 сессий + чат" },
|
||||
{ "id": "deep", "label": "Deep — 12 сессий + коуч" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Подарок к старту",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Медитации MindBalance и ежедневный трекер настроения бесплатно.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Закрепите скидку",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Скидка 20% на первый пакет и бонусный аудио-курс.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "MindBalance",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-20%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Первый пакет + аудио-курс",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "MIND20",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы применить промокод",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
314
public/funnels/ru-skin-renewal.json
Normal file
314
public/funnels/ru-skin-renewal.json
Normal file
@ -0,0 +1,314 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-skin-renewal",
|
||||
"title": "Glow Clinic: омоложение без боли",
|
||||
"description": "Воронка для клиники косметологии с диагностикой кожи и продажей курса процедур.",
|
||||
"firstScreenId": "welcome"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Верни коже сияние за 28 дней",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Лицо свежее, овал подтянутый, поры незаметны — результат подтверждён 418 клиентками.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "✨",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Пройти диагностику"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "problem"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "problem",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "85% женщин старят три фактора",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Обезвоженность, пигментация и потеря тонуса. Находим источник и устраняем его комплексно.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "skin-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "skin-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда была последняя профессиональная чистка?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Дата поможет подобрать интенсивность и глубину процедур.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Последний визит:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "skin-type"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "skin-type",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой у тебя тип кожи?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "dry", "label": "Сухая" },
|
||||
{ "id": "combination", "label": "Комбинированная" },
|
||||
{ "id": "oily", "label": "Жирная" },
|
||||
{ "id": "sensitive", "label": "Чувствительная" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "primary-concern"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "primary-concern",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что беспокоит больше всего?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "wrinkles", "label": "Морщины" },
|
||||
{ "id": "pigmentation", "label": "Пигментация" },
|
||||
{ "id": "pores", "label": "Расширенные поры" },
|
||||
{ "id": "acne", "label": "Воспаления" },
|
||||
{ "id": "dryness", "label": "Сухость и шелушение" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "success",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "История Нади: минус 7 лет визуально",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Через 3 сеанса HydraGlow кожа стала плотной, контур подтянулся, ушла желтизна. Её фото попало в наш кейсбук.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "home-care"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "home-care",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Как ухаживаешь дома?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "basic", "label": "Только базовый уход" },
|
||||
{ "id": "active", "label": "Активные сыворотки" },
|
||||
{ "id": "spapro", "label": "Домашние аппараты" },
|
||||
{ "id": "none", "label": "Практически не ухаживаю" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "allergy"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "allergy",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Есть ли ограничения?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "pregnancy", "label": "Беременность/ГВ" },
|
||||
{ "id": "allergy", "label": "Аллергия на кислоты" },
|
||||
{ "id": "derm", "label": "Дерматологические заболевания" },
|
||||
{ "id": "no", "label": "Нет ограничений" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "diagnostic-form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "diagnostic-form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получить персональный план ухода",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
|
||||
{ "id": "email", "label": "Email", "placeholder": "Получите чек-лист ухода", "type": "email", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "expert"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "expert",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Ваш персональный эксперт",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Врач-косметолог с 12-летним опытом проведёт диагностику, составит план процедур и будет на связи между визитами.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "plan-options"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "plan-options",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите программу",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "express", "label": "Express Glow — 2 визита" },
|
||||
{ "id": "course", "label": "Total Lift — 4 визита" },
|
||||
{ "id": "vip", "label": "VIP Anti-Age — 6 визитов" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Подарок к записи сегодня",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Профессиональная сыворотка Medik8 и массаж шеи в подарок на первом приёме.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Забронируй курс со скидкой",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Только сегодня — до 40% на программу и подарок.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "Glow Clinic",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-40%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Курс омоложения + сыворотка",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "GLOW40",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы закрепить предложение",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
315
public/funnels/ru-travel-signature.json
Normal file
315
public/funnels/ru-travel-signature.json
Normal file
@ -0,0 +1,315 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-travel-signature",
|
||||
"title": "Signature Trips: путешествие мечты",
|
||||
"description": "Воронка для премиального турагентства по созданию индивидуальных путешествий.",
|
||||
"firstScreenId": "hero"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "hero",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Создадим путешествие, о котором будут говорить",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Личный тревел-архитектор, закрытые локации и полный сервис 24/7.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "✈️",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Начать проект"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "inspiration"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "inspiration",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Премиальный отдых начинается с мечты",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Мы создаём маршруты для Forbes, топ-менеджеров и семей, которые ценят приватность.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "travel-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "travel-date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "Когда планируете отправиться?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Дата позволяет нам зарезервировать лучшие отели и гидов заранее.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Старт путешествия:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "companions"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "companions",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "С кем летите?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "solo", "label": "Соло" },
|
||||
{ "id": "couple", "label": "Пара" },
|
||||
{ "id": "family", "label": "Семья" },
|
||||
{ "id": "friends", "label": "Компания друзей" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "style"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "style",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой стиль отдыха хотите?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "beach", "label": "Пляжный релакс" },
|
||||
{ "id": "city", "label": "Городской lifestyle" },
|
||||
{ "id": "adventure", "label": "Приключения" },
|
||||
{ "id": "culture", "label": "Культура и гастрономия" },
|
||||
{ "id": "wellness", "label": "Wellness & spa" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "case",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Кейс семьи Морозовых",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "10 дней на Бали: вилла на скале, частный шеф, экскурсии на вертолёте. Экономия времени — 60 часов.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "wishlist"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wishlist",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что должно быть обязательно?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "private", "label": "Приватные перелёты" },
|
||||
{ "id": "events", "label": "Закрытые мероприятия" },
|
||||
{ "id": "photographer", "label": "Личный фотограф" },
|
||||
{ "id": "kids", "label": "Детский клуб" },
|
||||
{ "id": "chef", "label": "Шеф-повар" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "budget"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "budget",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой бюджет готовы инвестировать?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "5k", "label": "до $5 000" },
|
||||
{ "id": "10k", "label": "$5 000 – $10 000" },
|
||||
{ "id": "20k", "label": "$10 000 – $20 000" },
|
||||
{ "id": "20kplus", "label": "Более $20 000" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получите концепт путешествия",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
|
||||
{ "id": "email", "label": "Email", "placeholder": "Получить концепт", "type": "email", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "concierge"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "concierge",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Ваш персональный консьерж",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Будет на связи 24/7, бронирует рестораны, решает любые вопросы во время поездки.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "packages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "packages",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите формат сервиса",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "concept", "label": "Concept — разработка маршрута" },
|
||||
{ "id": "full", "label": "Full Care — сопровождение 24/7" },
|
||||
{ "id": "ultra", "label": "Ultra Lux — частный самолёт и охрана" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Специальный бонус",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "При бронировании сегодня — апгрейд номера и приватная фотосессия.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Забронируйте бонус",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Подарочный апгрейд и персональный гид входят в промо",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "Signature Trips",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "Premium Bonus",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "3xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Апгрейд номера + личный гид",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "SIGNATURE",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы зафиксировать бонус",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
315
public/funnels/ru-wedding-dream.json
Normal file
315
public/funnels/ru-wedding-dream.json
Normal file
@ -0,0 +1,315 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "ru-wedding-dream",
|
||||
"title": "DreamDay: свадьба без стресса",
|
||||
"description": "Воронка агентства свадебного продюсирования.",
|
||||
"firstScreenId": "welcome"
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Далее",
|
||||
"continueButton": "Продолжить"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Создадим свадьбу, о которой мечтаете",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"description": {
|
||||
"text": "Команда продюсеров возьмёт на себя всё: от концепции до финального танца.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "💍",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Начать план"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "vision"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vision",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Каждая история любви уникальна",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Мы создаём сценарии, которые отражают вашу пару, а не Pinterest-копию.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "date",
|
||||
"template": "date",
|
||||
"title": {
|
||||
"text": "На какую дату планируется свадьба?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Мы проверим занятость площадок и команд.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted"
|
||||
},
|
||||
"dateInput": {
|
||||
"monthPlaceholder": "ММ",
|
||||
"dayPlaceholder": "ДД",
|
||||
"yearPlaceholder": "ГГГГ",
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Дата свадьбы:"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "guests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "guests",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Сколько гостей ожидаете?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "small", "label": "До 30 гостей" },
|
||||
{ "id": "medium", "label": "30-70 гостей" },
|
||||
{ "id": "large", "label": "70-120 гостей" },
|
||||
{ "id": "xl", "label": "Более 120 гостей" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "style"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "style",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Опишите стиль праздника",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "classic", "label": "Классика" },
|
||||
{ "id": "modern", "label": "Современный шик" },
|
||||
{ "id": "boho", "label": "Бохо" },
|
||||
{ "id": "destination", "label": "Destination wedding" },
|
||||
{ "id": "party", "label": "Ночной клуб" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "case"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "case",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Свадьба Кати и Максима",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Горная Швейцария, закрытая вилла и живой оркестр. Сэкономили 18 часов подготовки еженедельно.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "priorities"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "priorities",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Что важнее всего?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "multi",
|
||||
"options": [
|
||||
{ "id": "venue", "label": "Локация мечты" },
|
||||
{ "id": "show", "label": "Вау-программа" },
|
||||
{ "id": "decor", "label": "Дизайн и флористика" },
|
||||
{ "id": "photo", "label": "Фото и видео" },
|
||||
{ "id": "care", "label": "Отсутствие стресса" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "budget"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "budget",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Какой бюджет планируете?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "3m", "label": "До 3 млн ₽" },
|
||||
{ "id": "5m", "label": "3-5 млн ₽" },
|
||||
{ "id": "8m", "label": "5-8 млн ₽" },
|
||||
{ "id": "8mplus", "label": "Более 8 млн ₽" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "form"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "form",
|
||||
"template": "form",
|
||||
"title": {
|
||||
"text": "Получите концепцию свадьбы",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"fields": [
|
||||
{ "id": "names", "label": "Имена пары", "placeholder": "Имена", "type": "text", "required": true },
|
||||
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
|
||||
{ "id": "email", "label": "Email", "placeholder": "Получить презентацию", "type": "email", "required": true }
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Проверьте формат"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "team"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "team",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Команда под вашу историю",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Продюсер, стилист, режиссёр и координатор. Каждую неделю — отчёт и контроль бюджета.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "packages"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "packages",
|
||||
"template": "list",
|
||||
"title": {
|
||||
"text": "Выберите формат сопровождения",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{ "id": "concept", "label": "Concept — идея и сценарий" },
|
||||
{ "id": "production", "label": "Production — организация под ключ" },
|
||||
{ "id": "lux", "label": "Luxury — destination + премиум команда" }
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "bonus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bonus",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Бонусы при бронировании сегодня",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Пробная встреча с ведущим и авторские клятвы, подготовленные нашим спичрайтером.",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "coupon"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": {
|
||||
"text": "Зафиксируйте дату и бонус",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Скидка 15% на продюсирование и бесплатная love-story съёмка.",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"coupon": {
|
||||
"title": {
|
||||
"text": "DreamDay",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"color": "primary"
|
||||
},
|
||||
"offer": {
|
||||
"title": {
|
||||
"text": "-15%",
|
||||
"font": "manrope",
|
||||
"weight": "black",
|
||||
"size": "4xl"
|
||||
},
|
||||
"description": {
|
||||
"text": "Продюсирование + love-story",
|
||||
"font": "inter",
|
||||
"weight": "medium"
|
||||
}
|
||||
},
|
||||
"promoCode": {
|
||||
"text": "DREAM15",
|
||||
"font": "inter",
|
||||
"weight": "semiBold"
|
||||
},
|
||||
"footer": {
|
||||
"text": "Нажмите, чтобы закрепить предложение",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"size": "sm",
|
||||
"color": "muted"
|
||||
}
|
||||
},
|
||||
"copiedMessage": "Промокод {code} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
79
scripts/bake-funnels.mjs
Normal file
79
scripts/bake-funnels.mjs
Normal file
@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
const funnelsDir = path.join(projectRoot, "public", "funnels");
|
||||
const outputFile = path.join(projectRoot, "src", "lib", "funnel", "bakedFunnels.ts");
|
||||
|
||||
function formatFunnelRecord(funnels) {
|
||||
const entries = Object.entries(funnels)
|
||||
.map(([funnelId, definition]) => {
|
||||
const serialized = JSON.stringify(definition, null, 2);
|
||||
const indented = serialized
|
||||
.split("\n")
|
||||
.map((line, index) => (index === 0 ? line : ` ${line}`))
|
||||
.join("\n");
|
||||
return ` "${funnelId}": ${indented}`;
|
||||
})
|
||||
.join(",\n\n");
|
||||
|
||||
return `{
|
||||
${entries}\n}`;
|
||||
}
|
||||
|
||||
async function bakeFunnels() {
|
||||
const dirExists = await fs
|
||||
.access(funnelsDir)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!dirExists) {
|
||||
throw new Error(`Funnels directory not found: ${funnelsDir}`);
|
||||
}
|
||||
|
||||
const files = (await fs.readdir(funnelsDir)).sort((a, b) => a.localeCompare(b));
|
||||
const funnels = {};
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
|
||||
const filePath = path.join(funnelsDir, file);
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${file}: ${error.message}`);
|
||||
}
|
||||
|
||||
const funnelId = parsed?.meta?.id ?? parsed?.id ?? file.replace(/\.json$/, "");
|
||||
|
||||
if (!funnelId || typeof funnelId !== "string") {
|
||||
throw new Error(
|
||||
`Unable to determine funnel id for '${file}'. Ensure the file contains an 'id' or 'meta.id' field.`
|
||||
);
|
||||
}
|
||||
|
||||
funnels[funnelId] = 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 */`;
|
||||
|
||||
const recordLiteral = formatFunnelRecord(funnels);
|
||||
const contents = `${headerComment}\n\nimport type { FunnelDefinition } from "./types";\n\nexport const BAKED_FUNNELS: Record<string, FunnelDefinition> = ${recordLiteral};\n`;
|
||||
|
||||
await fs.mkdir(path.dirname(outputFile), { recursive: true });
|
||||
await fs.writeFile(outputFile, contents, "utf8");
|
||||
|
||||
console.log(`Baked ${Object.keys(funnels).length} funnel(s) into ${path.relative(projectRoot, outputFile)}`);
|
||||
}
|
||||
|
||||
bakeFunnels().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
354
scripts/import-funnels-to-db.mjs
Normal file
354
scripts/import-funnels-to-db.mjs
Normal file
@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import mongoose from 'mongoose';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: '.env.local' });
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// MongoDB connection URI
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/witlab-funnel';
|
||||
|
||||
// Mongoose schemas (inline for the script)
|
||||
const FunnelDataSchema = new mongoose.Schema({
|
||||
meta: {
|
||||
id: { type: String, required: true },
|
||||
version: String,
|
||||
title: String,
|
||||
description: String,
|
||||
firstScreenId: String
|
||||
},
|
||||
defaultTexts: {
|
||||
nextButton: { type: String, default: 'Next' },
|
||||
continueButton: { type: String, default: 'Continue' }
|
||||
},
|
||||
screens: [mongoose.Schema.Types.Mixed]
|
||||
}, { _id: false });
|
||||
|
||||
const FunnelSchema = new mongoose.Schema({
|
||||
// Основные данные воронки
|
||||
funnelData: {
|
||||
type: FunnelDataSchema,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Метаданные для админки
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 200
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 1000
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
default: 'published', // Импортированные воронки считаем опубликованными
|
||||
required: true
|
||||
},
|
||||
|
||||
// Система версий
|
||||
version: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
min: 1
|
||||
},
|
||||
|
||||
// Пользовательские данные
|
||||
createdBy: { type: String, default: 'import-script' },
|
||||
lastModifiedBy: { type: String, default: 'import-script' },
|
||||
|
||||
// Статистика
|
||||
usage: {
|
||||
totalViews: { type: Number, default: 0, min: 0 },
|
||||
totalCompletions: { type: Number, default: 0, min: 0 },
|
||||
lastUsed: Date
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
publishedAt: { type: Date, default: Date.now }
|
||||
}, {
|
||||
timestamps: true,
|
||||
collection: 'funnels'
|
||||
});
|
||||
|
||||
// Индексы
|
||||
FunnelSchema.index({ 'funnelData.meta.id': 1 }, { unique: true });
|
||||
FunnelSchema.index({ status: 1, updatedAt: -1 });
|
||||
|
||||
const FunnelModel = mongoose.models.Funnel || mongoose.model('Funnel', FunnelSchema);
|
||||
|
||||
// Utility functions
|
||||
function generateFunnelName(funnelData) {
|
||||
// Пытаемся получить имя из разных источников
|
||||
if (funnelData.meta?.title) {
|
||||
return funnelData.meta.title;
|
||||
}
|
||||
|
||||
if (funnelData.meta?.id) {
|
||||
// Преобразуем ID в читаемое название
|
||||
return funnelData.meta.id
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return 'Imported Funnel';
|
||||
}
|
||||
|
||||
function generateFunnelDescription(funnelData) {
|
||||
if (funnelData.meta?.description) {
|
||||
return funnelData.meta.description;
|
||||
}
|
||||
|
||||
// Генерируем описание на основе количества экранов
|
||||
const screenCount = funnelData.screens?.length || 0;
|
||||
const templates = funnelData.screens?.map(s => s.template).filter(Boolean) || [];
|
||||
const uniqueTemplates = [...new Set(templates)];
|
||||
|
||||
return `Воронка с ${screenCount} экран${screenCount === 1 ? 'ом' : screenCount < 5 ? 'ами' : 'ами'}.${
|
||||
uniqueTemplates.length > 0 ? ` Типы: ${uniqueTemplates.join(', ')}.` : ''
|
||||
} Импортирована из JSON файла.`;
|
||||
}
|
||||
|
||||
async function connectToDatabase() {
|
||||
try {
|
||||
await mongoose.connect(MONGODB_URI, {
|
||||
bufferCommands: false,
|
||||
maxPoolSize: 10,
|
||||
serverSelectionTimeoutMS: 5000,
|
||||
socketTimeoutMS: 45000,
|
||||
});
|
||||
console.log('✅ Connected to MongoDB');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to connect to MongoDB:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function findFunnelFiles() {
|
||||
const funnelsDir = path.join(__dirname, '..', 'public', 'funnels');
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(funnelsDir);
|
||||
const jsonFiles = files.filter(file => file.endsWith('.json'));
|
||||
|
||||
console.log(`📁 Found ${jsonFiles.length} funnel files in public/funnels/`);
|
||||
|
||||
return jsonFiles.map(file => ({
|
||||
filename: file,
|
||||
filepath: path.join(funnelsDir, file)
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to read funnels directory:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateFunnelData(funnelData, filename) {
|
||||
const errors = [];
|
||||
|
||||
if (!funnelData) {
|
||||
errors.push('Empty funnel data');
|
||||
}
|
||||
|
||||
if (!funnelData.meta) {
|
||||
errors.push('Missing meta object');
|
||||
} else {
|
||||
if (!funnelData.meta.id) {
|
||||
errors.push('Missing meta.id');
|
||||
}
|
||||
}
|
||||
|
||||
if (!funnelData.screens) {
|
||||
errors.push('Missing screens array');
|
||||
} else if (!Array.isArray(funnelData.screens)) {
|
||||
errors.push('screens is not an array');
|
||||
} else if (funnelData.screens.length === 0) {
|
||||
errors.push('Empty screens array');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn(`⚠️ Validation warnings for ${filename}:`, errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function importFunnel(funnelFile) {
|
||||
const { filename, filepath } = funnelFile;
|
||||
|
||||
try {
|
||||
// Читаем и парсим JSON файл
|
||||
const fileContent = await fs.readFile(filepath, 'utf-8');
|
||||
const funnelData = JSON.parse(fileContent);
|
||||
|
||||
// Валидируем структуру данных
|
||||
if (!validateFunnelData(funnelData, filename)) {
|
||||
return { filename, status: 'skipped', reason: 'Invalid data structure' };
|
||||
}
|
||||
|
||||
// Проверяем, существует ли уже воронка с таким ID
|
||||
const existingFunnel = await FunnelModel.findOne({
|
||||
'funnelData.meta.id': funnelData.meta.id
|
||||
});
|
||||
|
||||
if (existingFunnel) {
|
||||
return {
|
||||
filename,
|
||||
status: 'exists',
|
||||
funnelId: funnelData.meta.id,
|
||||
reason: 'Funnel with this ID already exists in database'
|
||||
};
|
||||
}
|
||||
|
||||
// Создаем новую запись в базе данных
|
||||
const funnel = new FunnelModel({
|
||||
funnelData: funnelData,
|
||||
name: generateFunnelName(funnelData),
|
||||
description: generateFunnelDescription(funnelData),
|
||||
status: 'published',
|
||||
version: 1,
|
||||
createdBy: 'import-script',
|
||||
lastModifiedBy: 'import-script',
|
||||
usage: {
|
||||
totalViews: 0,
|
||||
totalCompletions: 0
|
||||
},
|
||||
publishedAt: new Date()
|
||||
});
|
||||
|
||||
const savedFunnel = await funnel.save();
|
||||
|
||||
return {
|
||||
filename,
|
||||
status: 'imported',
|
||||
funnelId: funnelData.meta.id,
|
||||
databaseId: savedFunnel._id.toString(),
|
||||
name: savedFunnel.name
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
filename,
|
||||
status: 'error',
|
||||
reason: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Starting funnel import process...\n');
|
||||
|
||||
// Подключаемся к базе данных
|
||||
await connectToDatabase();
|
||||
|
||||
// Находим все JSON файлы воронок
|
||||
const funnelFiles = await findFunnelFiles();
|
||||
|
||||
if (funnelFiles.length === 0) {
|
||||
console.log('📭 No funnel files found to import.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\n📥 Starting import of ${funnelFiles.length} funnels...\n`);
|
||||
|
||||
// Импортируем каждую воронку
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < funnelFiles.length; i++) {
|
||||
const funnelFile = funnelFiles[i];
|
||||
const progress = `[${i + 1}/${funnelFiles.length}]`;
|
||||
|
||||
console.log(`${progress} Processing ${funnelFile.filename}...`);
|
||||
|
||||
const result = await importFunnel(funnelFile);
|
||||
results.push(result);
|
||||
|
||||
// Показываем результат
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
console.log(` ✅ Imported as "${result.name}" (ID: ${result.funnelId})`);
|
||||
break;
|
||||
case 'exists':
|
||||
console.log(` ⏭️ Skipped - already exists (ID: ${result.funnelId})`);
|
||||
break;
|
||||
case 'skipped':
|
||||
console.log(` ⚠️ Skipped - ${result.reason}`);
|
||||
break;
|
||||
case 'error':
|
||||
console.log(` ❌ Error - ${result.reason}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Показываем сводку
|
||||
console.log('\n📊 Import Summary:');
|
||||
console.log('==================');
|
||||
|
||||
const imported = results.filter(r => r.status === 'imported');
|
||||
const existing = results.filter(r => r.status === 'exists');
|
||||
const skipped = results.filter(r => r.status === 'skipped');
|
||||
const errors = results.filter(r => r.status === 'error');
|
||||
|
||||
console.log(`✅ Successfully imported: ${imported.length}`);
|
||||
console.log(`⏭️ Already existed: ${existing.length}`);
|
||||
console.log(`⚠️ Skipped (invalid): ${skipped.length}`);
|
||||
console.log(`❌ Errors: ${errors.length}`);
|
||||
console.log(`📁 Total processed: ${results.length}`);
|
||||
|
||||
// Показываем детальную информацию об импортированных воронках
|
||||
if (imported.length > 0) {
|
||||
console.log('\n📋 Imported Funnels:');
|
||||
imported.forEach(result => {
|
||||
console.log(` • ${result.name} (${result.funnelId}) - ${result.filename}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Показываем ошибки
|
||||
if (errors.length > 0) {
|
||||
console.log('\n❌ Errors:');
|
||||
errors.forEach(result => {
|
||||
console.log(` • ${result.filename}: ${result.reason}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Показываем пропущенные
|
||||
if (skipped.length > 0) {
|
||||
console.log('\n⚠️ Skipped:');
|
||||
skipped.forEach(result => {
|
||||
console.log(` • ${result.filename}: ${result.reason}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n🎉 Import process completed!');
|
||||
|
||||
// Показываем следующие шаги
|
||||
if (imported.length > 0) {
|
||||
console.log('\n📌 Next Steps:');
|
||||
console.log('• Visit /admin to manage your funnels');
|
||||
console.log('• Imported funnels are marked as "published"');
|
||||
console.log('• You can edit them in /admin/builder/[id]');
|
||||
console.log('• Original JSON files remain unchanged');
|
||||
}
|
||||
|
||||
await mongoose.connection.close();
|
||||
console.log('\n👋 Database connection closed.');
|
||||
}
|
||||
|
||||
// Запускаем скрипт
|
||||
main().catch(error => {
|
||||
console.error('\n💥 Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
98
src/app/[funnelId]/[screenId]/page.tsx
Normal file
98
src/app/[funnelId]/[screenId]/page.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import {
|
||||
listBakedFunnelScreenParams,
|
||||
peekBakedFunnelDefinition,
|
||||
loadFunnelDefinition,
|
||||
} from "@/lib/funnel/loadFunnelDefinition";
|
||||
import { FunnelRuntime } from "@/components/funnel/FunnelRuntime";
|
||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Функция для загрузки воронки из базы данных напрямую
|
||||
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
|
||||
try {
|
||||
// Импортируем модели напрямую вместо HTTP запроса
|
||||
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 FunnelScreenPageProps {
|
||||
params: Promise<{
|
||||
funnelId: string;
|
||||
screenId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"; // Изменено для поддержки базы данных
|
||||
|
||||
export function generateStaticParams() {
|
||||
return listBakedFunnelScreenParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: FunnelScreenPageProps): Promise<Metadata> {
|
||||
const { funnelId } = await params;
|
||||
let funnel: ReturnType<typeof peekBakedFunnelDefinition>;
|
||||
try {
|
||||
funnel = peekBakedFunnelDefinition(funnelId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load funnel '${funnelId}' for metadata:`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
title: funnel.meta.title ?? "Funnel",
|
||||
description: funnel.meta.description ?? undefined,
|
||||
} satisfies Metadata;
|
||||
}
|
||||
|
||||
export default async function FunnelScreenPage({
|
||||
params,
|
||||
}: FunnelScreenPageProps) {
|
||||
const { funnelId, screenId } = await params;
|
||||
|
||||
let funnel: FunnelDefinition | null = null;
|
||||
|
||||
// Сначала пытаемся загрузить из базы данных
|
||||
funnel = await loadFunnelFromDatabase(funnelId);
|
||||
|
||||
// Если не найдено в базе, пытаемся загрузить из JSON файлов
|
||||
if (!funnel) {
|
||||
try {
|
||||
funnel = await loadFunnelDefinition(funnelId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load funnel '${funnelId}' from files:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Если воронка не найдена ни в базе, ни в файлах
|
||||
if (!funnel) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const screen = funnel.screens.find((item) => item.id === screenId);
|
||||
if (!screen) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <FunnelRuntime funnel={funnel} initialScreenId={screenId} />;
|
||||
}
|
||||
71
src/app/[funnelId]/page.tsx
Normal file
71
src/app/[funnelId]/page.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
listBakedFunnelIds,
|
||||
peekBakedFunnelDefinition,
|
||||
} from "@/lib/funnel/loadFunnelDefinition";
|
||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Функция для загрузки воронки из базы данных
|
||||
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
|
||||
try {
|
||||
// Пытаемся загрузить из базы данных через API
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/funnels/by-funnel-id/${funnelId}`, {
|
||||
cache: 'no-store' // Не кешируем, т.к. воронки могут обновляться
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load funnel '${funnelId}' from database:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"; // Изменено на dynamic для поддержки базы данных
|
||||
|
||||
export function generateStaticParams() {
|
||||
// Генерируем только для статических JSON файлов
|
||||
return listBakedFunnelIds().map((funnelId) => ({ funnelId }));
|
||||
}
|
||||
|
||||
interface FunnelRootPageProps {
|
||||
params: Promise<{
|
||||
funnelId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function FunnelRootPage({ params }: FunnelRootPageProps) {
|
||||
const { funnelId } = await params;
|
||||
|
||||
let funnel: FunnelDefinition | null = null;
|
||||
|
||||
// Сначала пытаемся загрузить из базы данных
|
||||
funnel = await loadFunnelFromDatabase(funnelId);
|
||||
|
||||
// Если не найдено в базе, пытаемся загрузить из JSON файлов
|
||||
if (!funnel) {
|
||||
try {
|
||||
funnel = peekBakedFunnelDefinition(funnelId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load funnel '${funnelId}' from files:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Если воронка не найдена ни в базе, ни в файлах
|
||||
if (!funnel) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const firstScreenId =
|
||||
funnel.meta.firstScreenId ?? funnel.screens.at(0)?.id ?? "";
|
||||
|
||||
if (!firstScreenId) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
redirect(`/${funnel.meta.id}/${firstScreenId}`);
|
||||
}
|
||||
287
src/app/admin/builder/[id]/page.tsx
Normal file
287
src/app/admin/builder/[id]/page.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { BuilderProvider } from "@/lib/admin/builder/context";
|
||||
import { BuilderUndoRedoProvider } from "@/components/admin/builder/BuilderUndoRedoProvider";
|
||||
import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar";
|
||||
import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar";
|
||||
import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas";
|
||||
import { BuilderPreview } from "@/components/admin/builder/BuilderPreview";
|
||||
import type { BuilderState } from '@/lib/admin/builder/context';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils';
|
||||
|
||||
interface FunnelData {
|
||||
_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
version: number;
|
||||
funnelData: FunnelDefinition;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function FunnelBuilderPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const funnelId = params.id as string;
|
||||
|
||||
const [funnelData, setFunnelData] = useState<FunnelData | null>(null);
|
||||
const [initialBuilderState, setInitialBuilderState] = useState<BuilderState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Генерируем уникальный sessionId для истории изменений
|
||||
const [sessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
||||
|
||||
// Загрузка воронки из базы данных
|
||||
const loadFunnel = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/funnels/${funnelId}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Воронка не найдена');
|
||||
}
|
||||
throw new Error('Ошибка загрузки воронки');
|
||||
}
|
||||
|
||||
const data: FunnelData = await response.json();
|
||||
setFunnelData(data);
|
||||
|
||||
// Конвертируем данные воронки в состояние билдера
|
||||
const builderState = deserializeFunnelDefinition(data.funnelData);
|
||||
setInitialBuilderState({
|
||||
...builderState,
|
||||
selectedScreenId: builderState.screens[0]?.id || null,
|
||||
isDirty: false
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Сохранение воронки
|
||||
const saveFunnel = async (builderState: BuilderState, publish: boolean = false) => {
|
||||
if (!funnelData || saving) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Конвертируем состояние билдера обратно в FunnelDefinition
|
||||
const updatedFunnelData: FunnelDefinition = {
|
||||
meta: builderState.meta,
|
||||
defaultTexts: {
|
||||
nextButton: 'Далее',
|
||||
continueButton: 'Продолжить'
|
||||
},
|
||||
screens: builderState.screens.map(screen => {
|
||||
// Убираем position из экрана при сохранении
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { position, ...screenWithoutPosition } = screen;
|
||||
return screenWithoutPosition;
|
||||
})
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/funnels/${funnelId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: builderState.meta.title || funnelData.name,
|
||||
description: builderState.meta.description || funnelData.description,
|
||||
funnelData: updatedFunnelData,
|
||||
status: publish ? 'published' : funnelData.status,
|
||||
sessionId,
|
||||
actionDescription: publish ? 'Воронка опубликована' : 'Воронка сохранена'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка сохранения воронки');
|
||||
}
|
||||
|
||||
const updatedFunnel = await response.json();
|
||||
setFunnelData(updatedFunnel);
|
||||
|
||||
// Показываем уведомление об успешном сохранении
|
||||
// TODO: Добавить toast уведомления
|
||||
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка сохранения');
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Создание записи в истории для текущего изменения
|
||||
const createHistoryEntry = async (
|
||||
builderState: BuilderState,
|
||||
actionType: string,
|
||||
description: string
|
||||
) => {
|
||||
try {
|
||||
const funnelSnapshot: FunnelDefinition = {
|
||||
meta: builderState.meta,
|
||||
defaultTexts: {
|
||||
nextButton: 'Далее',
|
||||
continueButton: 'Продолжить'
|
||||
},
|
||||
screens: builderState.screens.map(screen => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { position, ...screenWithoutPosition } = screen;
|
||||
return screenWithoutPosition;
|
||||
})
|
||||
};
|
||||
|
||||
await fetch(`/api/funnels/${funnelId}/history`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
funnelSnapshot,
|
||||
actionType,
|
||||
description
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create history entry:', error);
|
||||
// Не прерываем работу, если не удалось создать запись в истории
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчики для топ бара
|
||||
const handleSave = async (builderState: BuilderState): Promise<boolean> => {
|
||||
const success = await saveFunnel(builderState, false);
|
||||
if (success) {
|
||||
// Создаем запись в истории как базовую точку
|
||||
await createHistoryEntry(builderState, 'save', 'Изменения сохранены');
|
||||
}
|
||||
return success || false;
|
||||
};
|
||||
|
||||
const handlePublish = async (builderState: BuilderState): Promise<boolean> => {
|
||||
const success = await saveFunnel(builderState, true);
|
||||
if (success) {
|
||||
await createHistoryEntry(builderState, 'publish', 'Воронка опубликована');
|
||||
}
|
||||
return success || false;
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
router.push('/admin');
|
||||
};
|
||||
|
||||
const handleBackToCatalog = () => {
|
||||
router.push('/admin');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFunnel();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // loadFunnel создается заново при каждом рендере, но нам нужен только первый вызов
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600">Загрузка воронки...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-600 mb-4">{error}</div>
|
||||
<button
|
||||
onClick={handleBackToCatalog}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Вернуться к каталогу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main render
|
||||
if (!initialBuilderState || !funnelData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BuilderProvider initialState={initialBuilderState}>
|
||||
<BuilderUndoRedoProvider>
|
||||
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
|
||||
|
||||
{/* Top Bar */}
|
||||
<BuilderTopBar
|
||||
onNew={handleNew}
|
||||
onSave={handleSave}
|
||||
onPublish={handlePublish}
|
||||
onBackToCatalog={handleBackToCatalog}
|
||||
saving={saving}
|
||||
funnelInfo={{
|
||||
name: funnelData.name,
|
||||
status: funnelData.status,
|
||||
version: funnelData.version,
|
||||
lastSaved: funnelData.updatedAt
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="w-[360px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95">
|
||||
<BuilderSidebar />
|
||||
</aside>
|
||||
|
||||
{/* Canvas Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BuilderCanvas />
|
||||
</div>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<div className="w-[360px] shrink-0 border-l border-border/60 bg-background overflow-y-auto">
|
||||
<div className="p-4 border-b border-border/60">
|
||||
<h3 className="font-semibold text-sm">Предпросмотр</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Как выглядит экран в браузере
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<BuilderPreview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BuilderUndoRedoProvider>
|
||||
</BuilderProvider>
|
||||
);
|
||||
}
|
||||
496
src/app/admin/page.tsx
Normal file
496
src/app/admin/page.tsx
Normal file
@ -0,0 +1,496 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TextInput } from '@/components/ui/TextInput/TextInput';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Copy,
|
||||
Trash2,
|
||||
Edit,
|
||||
Eye,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FunnelListItem {
|
||||
_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt?: string;
|
||||
usage: {
|
||||
totalViews: number;
|
||||
totalCompletions: number;
|
||||
lastUsed?: string;
|
||||
};
|
||||
funnelData?: {
|
||||
meta?: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
current: number;
|
||||
total: number;
|
||||
count: number;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export default function AdminCatalogPage() {
|
||||
const [funnels, setFunnels] = useState<FunnelListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Фильтры и поиск
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState('updatedAt');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Пагинация
|
||||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||
current: 1,
|
||||
total: 1,
|
||||
count: 0,
|
||||
totalItems: 0
|
||||
});
|
||||
|
||||
// Выделенные элементы - TODO: реализовать в будущем
|
||||
// const [selectedFunnels, setSelectedFunnels] = useState<Set<string>>(new Set());
|
||||
|
||||
// Загрузка данных
|
||||
const loadFunnels = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: '20',
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...(searchQuery && { search: searchQuery }),
|
||||
...(statusFilter !== 'all' && { status: statusFilter })
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/funnels?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch funnels');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setFunnels(data.funnels);
|
||||
setPagination({
|
||||
current: data.pagination.current,
|
||||
total: data.pagination.total,
|
||||
count: data.pagination.count,
|
||||
totalItems: data.pagination.totalItems
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQuery, statusFilter, sortBy, sortOrder]);
|
||||
|
||||
// Эффекты
|
||||
useEffect(() => {
|
||||
loadFunnels(1);
|
||||
}, [loadFunnels]);
|
||||
|
||||
// Создание новой воронки
|
||||
const handleCreateFunnel = async () => {
|
||||
try {
|
||||
const newFunnelData = {
|
||||
name: 'Новая воронка',
|
||||
description: 'Описание новой воронки',
|
||||
funnelData: {
|
||||
meta: {
|
||||
id: `funnel-${Date.now()}`,
|
||||
title: 'Новая воронка',
|
||||
description: 'Описание новой воронки',
|
||||
firstScreenId: 'screen-1'
|
||||
},
|
||||
defaultTexts: {
|
||||
nextButton: 'Далее',
|
||||
continueButton: 'Продолжить'
|
||||
},
|
||||
screens: [
|
||||
{
|
||||
id: 'screen-1',
|
||||
template: 'info',
|
||||
title: {
|
||||
text: 'Добро пожаловать!',
|
||||
font: 'manrope',
|
||||
weight: 'bold'
|
||||
},
|
||||
description: {
|
||||
text: 'Это ваша новая воронка. Начните редактирование.',
|
||||
color: 'muted'
|
||||
},
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
value: '🎯',
|
||||
size: 'lg'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch('/api/funnels', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newFunnelData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create funnel');
|
||||
}
|
||||
|
||||
const createdFunnel = await response.json();
|
||||
|
||||
// Переходим к редактированию новой воронки
|
||||
router.push(`/admin/builder/${createdFunnel._id}`);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create funnel');
|
||||
}
|
||||
};
|
||||
|
||||
// Дублирование воронки
|
||||
const handleDuplicateFunnel = async (funnelId: string, funnelName: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/funnels/${funnelId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `${funnelName} (копия)`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to duplicate funnel');
|
||||
}
|
||||
|
||||
// Обновляем список
|
||||
loadFunnels(pagination.current);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to duplicate funnel');
|
||||
}
|
||||
};
|
||||
|
||||
// Удаление воронки
|
||||
const handleDeleteFunnel = async (funnelId: string, funnelName: string) => {
|
||||
if (!confirm(`Вы уверены, что хотите удалить воронку "${funnelName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/funnels/${funnelId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete funnel');
|
||||
}
|
||||
|
||||
// Обновляем список
|
||||
loadFunnels(pagination.current);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete funnel');
|
||||
}
|
||||
};
|
||||
|
||||
// Статус badges
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
draft: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
published: 'bg-green-100 text-green-800 border-green-200',
|
||||
archived: 'bg-gray-100 text-gray-800 border-gray-200'
|
||||
};
|
||||
|
||||
const labels = {
|
||||
draft: 'Черновик',
|
||||
published: 'Опубликована',
|
||||
archived: 'Архивирована'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||
variants[status as keyof typeof variants]
|
||||
)}>
|
||||
{labels[status as keyof typeof labels]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Форматирование дат
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Каталог воронок</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Управляйте своими воронками и создавайте новые
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateFunnel} className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Создать воронку
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтры и поиск */}
|
||||
<div className="mb-6 bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<TextInput
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Поиск по названию, описанию..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтр статуса */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="draft">Черновики</option>
|
||||
<option value="published">Опубликованные</option>
|
||||
<option value="archived">Архивированные</option>
|
||||
</select>
|
||||
|
||||
{/* Сортировка */}
|
||||
<select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [field, order] = e.target.value.split('-');
|
||||
setSortBy(field);
|
||||
setSortOrder(order as 'asc' | 'desc');
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="updatedAt-desc">Сначала новые</option>
|
||||
<option value="updatedAt-asc">Сначала старые</option>
|
||||
<option value="name-asc">По названию А-Я</option>
|
||||
<option value="name-desc">По названию Я-А</option>
|
||||
<option value="usage.totalViews-desc">По популярности</option>
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => loadFunnels(pagination.current)}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ошибка */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-red-800">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список воронок */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" />
|
||||
Загружается...
|
||||
</div>
|
||||
) : funnels.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="mb-4">Воронки не найдены</div>
|
||||
<Button onClick={handleCreateFunnel} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать первую воронку
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Название
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Статус
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Статистика
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Обновлена
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{funnels.map((funnel) => (
|
||||
<tr key={funnel._id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{funnel.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {funnel.funnelData?.meta?.id || 'N/A'}
|
||||
</div>
|
||||
{funnel.description && (
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{funnel.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(funnel.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900">
|
||||
{funnel.usage.totalViews} просмотров
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{funnel.usage.totalCompletions} завершений
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatDate(funnel.updatedAt)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
v{funnel.version}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
{/* Просмотр воронки */}
|
||||
<Link href={`/${funnel.funnelData?.meta?.id || funnel._id}`}>
|
||||
<Button variant="ghost" title="Просмотр" className="h-8 w-8 p-0">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Редактирование */}
|
||||
<Link href={`/admin/builder/${funnel._id}`}>
|
||||
<Button variant="ghost" title="Редактировать" className="h-8 w-8 p-0">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Дублировать */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Дублировать"
|
||||
onClick={() => handleDuplicateFunnel(funnel._id, funnel.name)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Удалить (только черновики) */}
|
||||
{funnel.status === 'draft' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Удалить"
|
||||
onClick={() => handleDeleteFunnel(funnel._id, funnel.name)}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Пагинация */}
|
||||
{pagination.total > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Показано {pagination.count} из {pagination.totalItems} воронок
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pagination.current <= 1}
|
||||
onClick={() => loadFunnels(pagination.current - 1)}
|
||||
>
|
||||
Предыдущая
|
||||
</Button>
|
||||
<span className="px-3 py-1 bg-gray-100 rounded text-sm">
|
||||
{pagination.current} / {pagination.total}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={pagination.current >= pagination.total}
|
||||
onClick={() => loadFunnels(pagination.current + 1)}
|
||||
>
|
||||
Следующая
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/app/api/funnels/[id]/duplicate/route.ts
Normal file
113
src/app/api/funnels/[id]/duplicate/route.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import FunnelModel from '@/lib/models/Funnel';
|
||||
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// POST /api/funnels/[id]/duplicate - создать копию воронки
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await connectMongoDB();
|
||||
|
||||
const body = await request.json();
|
||||
const { name, newFunnelId } = body;
|
||||
|
||||
// Находим исходную воронку
|
||||
const sourceFunnel = await FunnelModel.findById(id);
|
||||
if (!sourceFunnel) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Source funnel not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Подготавливаем данные для копии
|
||||
const duplicatedFunnelData = {
|
||||
...sourceFunnel.funnelData,
|
||||
meta: {
|
||||
...sourceFunnel.funnelData.meta,
|
||||
id: newFunnelId || `${sourceFunnel.funnelData.meta.id}-copy-${Date.now()}`,
|
||||
title: `${sourceFunnel.funnelData.meta.title || sourceFunnel.name} (копия)`,
|
||||
description: sourceFunnel.funnelData.meta.description
|
||||
}
|
||||
};
|
||||
|
||||
// Проверяем уникальность нового ID
|
||||
const existingFunnel = await FunnelModel.findOne({
|
||||
'funnelData.meta.id': duplicatedFunnelData.meta.id
|
||||
});
|
||||
|
||||
if (existingFunnel) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel with this ID already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Создаем копию
|
||||
const duplicatedFunnel = new FunnelModel({
|
||||
name: name || `${sourceFunnel.name} (копия)`,
|
||||
description: sourceFunnel.description,
|
||||
funnelData: duplicatedFunnelData,
|
||||
status: 'draft', // Копии всегда создаются как черновики
|
||||
version: 1,
|
||||
parentFunnelId: sourceFunnel._id, // Связываем с исходной воронкой
|
||||
usage: {
|
||||
totalViews: 0,
|
||||
totalCompletions: 0
|
||||
}
|
||||
});
|
||||
|
||||
const savedFunnel = await duplicatedFunnel.save();
|
||||
|
||||
// Создаем базовую точку в истории для копии
|
||||
const sessionId = `duplicate-${Date.now()}`;
|
||||
await FunnelHistoryModel.create({
|
||||
funnelId: String(savedFunnel._id),
|
||||
sessionId,
|
||||
funnelSnapshot: duplicatedFunnelData,
|
||||
actionType: 'create',
|
||||
sequenceNumber: 0,
|
||||
description: `Создана копия воронки "${sourceFunnel.name}"`,
|
||||
isBaseline: true,
|
||||
changeDetails: {
|
||||
action: 'duplicate',
|
||||
previousValue: null,
|
||||
newValue: { sourceId: sourceFunnel._id }
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
_id: savedFunnel._id,
|
||||
name: savedFunnel.name,
|
||||
description: savedFunnel.description,
|
||||
status: savedFunnel.status,
|
||||
version: savedFunnel.version,
|
||||
createdAt: savedFunnel.createdAt,
|
||||
updatedAt: savedFunnel.updatedAt,
|
||||
usage: savedFunnel.usage,
|
||||
funnelData: savedFunnel.funnelData
|
||||
}, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('POST /api/funnels/[id]/duplicate error:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('duplicate key')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel with this name already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to duplicate funnel' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
127
src/app/api/funnels/[id]/history/route.ts
Normal file
127
src/app/api/funnels/[id]/history/route.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// GET /api/funnels/[id]/history - получить историю изменений воронки
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await connectMongoDB();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
const limit = parseInt(searchParams.get('limit') || '50');
|
||||
const includeSnapshots = searchParams.get('snapshots') === 'true';
|
||||
|
||||
const filter: Record<string, unknown> = { funnelId: id };
|
||||
if (sessionId) {
|
||||
filter.sessionId = sessionId;
|
||||
}
|
||||
|
||||
const historyQuery = FunnelHistoryModel
|
||||
.find(filter)
|
||||
.sort({ createdAt: -1, sequenceNumber: -1 })
|
||||
.limit(limit);
|
||||
|
||||
// Включать снимки данных или только метаданные
|
||||
if (!includeSnapshots) {
|
||||
historyQuery.select('-funnelSnapshot');
|
||||
}
|
||||
|
||||
const history = await historyQuery.lean();
|
||||
|
||||
return NextResponse.json({
|
||||
history: history.map(entry => ({
|
||||
...entry,
|
||||
_id: entry._id.toString(),
|
||||
funnelId: entry.funnelId.toString()
|
||||
}))
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('GET /api/funnels/[id]/history error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch funnel history' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/funnels/[id]/history - создать новую запись в истории
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await connectMongoDB();
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
sessionId,
|
||||
funnelSnapshot,
|
||||
actionType,
|
||||
description,
|
||||
changeDetails
|
||||
} = body;
|
||||
|
||||
// Валидация
|
||||
if (!sessionId || !funnelSnapshot || !actionType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId, funnelSnapshot and actionType are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Получаем следующий номер последовательности
|
||||
const lastEntry = await FunnelHistoryModel
|
||||
.findOne({ funnelId: id, sessionId })
|
||||
.sort({ sequenceNumber: -1 });
|
||||
|
||||
const nextSequenceNumber = (lastEntry?.sequenceNumber || -1) + 1;
|
||||
|
||||
// Создаем запись
|
||||
const historyEntry = await FunnelHistoryModel.create({
|
||||
funnelId: id,
|
||||
sessionId,
|
||||
funnelSnapshot,
|
||||
actionType,
|
||||
sequenceNumber: nextSequenceNumber,
|
||||
description,
|
||||
changeDetails
|
||||
});
|
||||
|
||||
// Очищаем старые записи истории (оставляем последние 100)
|
||||
const KEEP_ENTRIES = 100;
|
||||
const entriesToDelete = await FunnelHistoryModel
|
||||
.find({ funnelId: id, sessionId })
|
||||
.sort({ sequenceNumber: -1 })
|
||||
.skip(KEEP_ENTRIES)
|
||||
.select('_id');
|
||||
|
||||
if (entriesToDelete.length > 0) {
|
||||
const idsToDelete = entriesToDelete.map((entry: { _id: unknown }) => entry._id);
|
||||
await FunnelHistoryModel.deleteMany({ _id: { $in: idsToDelete } });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
_id: historyEntry._id,
|
||||
actionType: historyEntry.actionType,
|
||||
description: historyEntry.description,
|
||||
sequenceNumber: historyEntry.sequenceNumber,
|
||||
isBaseline: historyEntry.isBaseline,
|
||||
createdAt: historyEntry.createdAt,
|
||||
changeDetails: historyEntry.changeDetails
|
||||
}, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('POST /api/funnels/[id]/history error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create history entry' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
201
src/app/api/funnels/[id]/route.ts
Normal file
201
src/app/api/funnels/[id]/route.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import FunnelModel from '@/lib/models/Funnel';
|
||||
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// GET /api/funnels/[id] - получить конкретную воронку
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await connectMongoDB();
|
||||
|
||||
const funnel = await FunnelModel.findById(id);
|
||||
|
||||
if (!funnel) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
_id: funnel._id,
|
||||
name: funnel.name,
|
||||
description: funnel.description,
|
||||
status: funnel.status,
|
||||
version: funnel.version,
|
||||
createdAt: funnel.createdAt,
|
||||
updatedAt: funnel.updatedAt,
|
||||
publishedAt: funnel.publishedAt,
|
||||
usage: funnel.usage,
|
||||
funnelData: funnel.funnelData
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('GET /api/funnels/[id] error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch funnel' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/funnels/[id] - обновить воронку
|
||||
export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await connectMongoDB();
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description, funnelData, status, sessionId, actionDescription } = body;
|
||||
|
||||
// Валидация
|
||||
if (funnelData && (!funnelData.meta || !funnelData.meta.id || !Array.isArray(funnelData.screens))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid funnel data structure' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const funnel = await FunnelModel.findById(id);
|
||||
if (!funnel) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Сохраняем предыдущее состояние для истории
|
||||
const previousData = funnel.funnelData;
|
||||
|
||||
// Обновляем поля
|
||||
if (name !== undefined) funnel.name = name;
|
||||
if (description !== undefined) funnel.description = description;
|
||||
|
||||
// Логика версионирования:
|
||||
// - При сохранении (без смены статуса) - версия НЕ увеличивается
|
||||
// - При публикации - версия увеличивается и меняется статус
|
||||
const isPublishing = status === 'published' && funnel.status !== 'published';
|
||||
|
||||
if (status !== undefined) funnel.status = status;
|
||||
if (funnelData !== undefined) {
|
||||
funnel.funnelData = funnelData as FunnelDefinition;
|
||||
|
||||
// Увеличиваем версию только при публикации
|
||||
if (isPublishing) {
|
||||
funnel.version += 1;
|
||||
funnel.publishedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
funnel.lastModifiedBy = 'current-user'; // TODO: заменить на реального пользователя
|
||||
|
||||
const savedFunnel = await funnel.save();
|
||||
|
||||
// Создаем запись в истории, если обновлялась структура воронки
|
||||
if (funnelData && sessionId) {
|
||||
// Получаем текущий номер последовательности
|
||||
const lastHistoryEntry = await FunnelHistoryModel
|
||||
.findOne({ funnelId: id, sessionId })
|
||||
.sort({ sequenceNumber: -1 });
|
||||
|
||||
const nextSequenceNumber = (lastHistoryEntry?.sequenceNumber || -1) + 1;
|
||||
|
||||
await FunnelHistoryModel.create({
|
||||
funnelId: id,
|
||||
sessionId,
|
||||
funnelSnapshot: funnelData,
|
||||
actionType: status === 'published' ? 'publish' : 'update',
|
||||
sequenceNumber: nextSequenceNumber,
|
||||
description: actionDescription || 'Воронка обновлена',
|
||||
isBaseline: status === 'published',
|
||||
changeDetails: {
|
||||
action: 'update-funnel',
|
||||
previousValue: previousData,
|
||||
newValue: funnelData
|
||||
}
|
||||
});
|
||||
|
||||
// Очищаем старые записи истории (оставляем 100)
|
||||
const KEEP_ENTRIES = 100;
|
||||
const entriesToDelete = await FunnelHistoryModel
|
||||
.find({ funnelId: id, sessionId })
|
||||
.sort({ sequenceNumber: -1 })
|
||||
.skip(KEEP_ENTRIES)
|
||||
.select('_id');
|
||||
|
||||
if (entriesToDelete.length > 0) {
|
||||
const idsToDelete = entriesToDelete.map((entry: { _id: unknown }) => entry._id);
|
||||
await FunnelHistoryModel.deleteMany({ _id: { $in: idsToDelete } });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
_id: savedFunnel._id,
|
||||
name: savedFunnel.name,
|
||||
description: savedFunnel.description,
|
||||
status: savedFunnel.status,
|
||||
version: savedFunnel.version,
|
||||
createdAt: savedFunnel.createdAt,
|
||||
updatedAt: savedFunnel.updatedAt,
|
||||
publishedAt: savedFunnel.publishedAt,
|
||||
usage: savedFunnel.usage,
|
||||
funnelData: savedFunnel.funnelData
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('PUT /api/funnels/[id] error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update funnel' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/funnels/[id] - удалить воронку
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
await connectMongoDB();
|
||||
|
||||
const funnel = await FunnelModel.findById(id);
|
||||
if (!funnel) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяем статус - опубликованные воронки нельзя удалять напрямую
|
||||
if (funnel.status === 'published') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete published funnel. Archive it first.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Удаляем воронку и всю связанную историю
|
||||
await Promise.all([
|
||||
FunnelModel.findByIdAndDelete(id),
|
||||
FunnelHistoryModel.deleteMany({ funnelId: id })
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Funnel deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('DELETE /api/funnels/[id] error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete funnel' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
108
src/app/api/funnels/by-funnel-id/[funnelId]/route.ts
Normal file
108
src/app/api/funnels/by-funnel-id/[funnelId]/route.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import FunnelModel from '@/lib/models/Funnel';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
funnelId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// GET /api/funnels/by-funnel-id/[funnelId] - получить воронку по funnelData.meta.id
|
||||
// Этот endpoint обеспечивает совместимость с существующим кодом, который ожидает
|
||||
// загрузку воронки по funnel ID из JSON файлов
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { funnelId } = await params;
|
||||
await connectMongoDB();
|
||||
|
||||
const funnel = await FunnelModel.findOne({
|
||||
'funnelData.meta.id': funnelId,
|
||||
status: { $in: ['draft', 'published'] } // Исключаем архивированные
|
||||
});
|
||||
|
||||
if (!funnel) {
|
||||
// Если воронка не найдена в БД, возвращаем 404
|
||||
return NextResponse.json(
|
||||
{ error: `Funnel with ID "${funnelId}" not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик просмотров
|
||||
funnel.usage.totalViews += 1;
|
||||
funnel.usage.lastUsed = new Date();
|
||||
await funnel.save();
|
||||
|
||||
// Возвращаем только данные воронки (для совместимости с FunnelRuntime)
|
||||
return NextResponse.json(funnel.funnelData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('GET /api/funnels/by-funnel-id/[funnelId] error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch funnel' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/funnels/by-funnel-id/[funnelId] - обновить воронку по funnel ID
|
||||
export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const { funnelId } = await params;
|
||||
await connectMongoDB();
|
||||
|
||||
const funnelData = await request.json();
|
||||
|
||||
// Валидация
|
||||
if (!funnelData.meta || !funnelData.meta.id || !Array.isArray(funnelData.screens)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid funnel data structure' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяем что ID в данных соответствует ID в URL
|
||||
if (funnelData.meta.id !== funnelId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel ID mismatch' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const funnel = await FunnelModel.findOne({
|
||||
'funnelData.meta.id': funnelId
|
||||
});
|
||||
|
||||
if (!funnel) {
|
||||
return NextResponse.json(
|
||||
{ error: `Funnel with ID "${funnelId}" not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Обновляем данные воронки
|
||||
funnel.funnelData = funnelData;
|
||||
funnel.version += 1;
|
||||
funnel.lastModifiedBy = 'api-update';
|
||||
|
||||
// Обновляем мета-информацию если она изменилась
|
||||
if (funnelData.meta.title && funnelData.meta.title !== funnel.name) {
|
||||
funnel.name = funnelData.meta.title;
|
||||
}
|
||||
if (funnelData.meta.description) {
|
||||
funnel.description = funnelData.meta.description;
|
||||
}
|
||||
|
||||
const savedFunnel = await funnel.save();
|
||||
|
||||
return NextResponse.json(savedFunnel.funnelData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('PUT /api/funnels/by-funnel-id/[funnelId] error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update funnel' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
162
src/app/api/funnels/route.ts
Normal file
162
src/app/api/funnels/route.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import connectMongoDB from '@/lib/mongodb';
|
||||
import FunnelModel from '@/lib/models/Funnel';
|
||||
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
|
||||
// GET /api/funnels - получить список всех воронок
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await connectMongoDB();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('search');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const sortBy = searchParams.get('sortBy') || 'updatedAt';
|
||||
const sortOrder = searchParams.get('sortOrder') || 'desc';
|
||||
|
||||
// Строим фильтр
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
if (status && ['draft', 'published', 'archived'].includes(status)) {
|
||||
filter.status = status;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ name: { $regex: search, $options: 'i' } },
|
||||
{ description: { $regex: search, $options: 'i' } },
|
||||
{ 'funnelData.meta.title': { $regex: search, $options: 'i' } },
|
||||
{ 'funnelData.meta.description': { $regex: search, $options: 'i' } }
|
||||
];
|
||||
}
|
||||
|
||||
// Строим сортировку
|
||||
const sort: Record<string, 1 | -1> = {};
|
||||
sort[sortBy] = sortOrder === 'desc' ? -1 : 1;
|
||||
|
||||
// Выполняем запрос с пагинацией
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [funnels, total] = await Promise.all([
|
||||
FunnelModel
|
||||
.find(filter)
|
||||
.select('-funnelData.screens -funnelData.defaultTexts') // Исключаем тяжелые данные, но оставляем meta
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean(),
|
||||
FunnelModel.countDocuments(filter)
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
funnels,
|
||||
pagination: {
|
||||
current: page,
|
||||
total: Math.ceil(total / limit),
|
||||
count: funnels.length,
|
||||
totalItems: total
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('GET /api/funnels error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch funnels' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/funnels - создать новую воронку
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await connectMongoDB();
|
||||
|
||||
const body = await request.json();
|
||||
const { name, description, funnelData, status = 'draft' } = body;
|
||||
|
||||
// Валидация
|
||||
if (!name || !funnelData) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name and funnel data are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!funnelData.meta || !funnelData.meta.id || !Array.isArray(funnelData.screens)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid funnel data structure' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяем уникальность funnelData.meta.id
|
||||
const existingFunnel = await FunnelModel.findOne({
|
||||
'funnelData.meta.id': funnelData.meta.id
|
||||
});
|
||||
|
||||
if (existingFunnel) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel with this ID already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Создаем воронку
|
||||
const funnel = new FunnelModel({
|
||||
name,
|
||||
description,
|
||||
funnelData: funnelData as FunnelDefinition,
|
||||
status,
|
||||
version: 1,
|
||||
usage: {
|
||||
totalViews: 0,
|
||||
totalCompletions: 0
|
||||
}
|
||||
});
|
||||
|
||||
const savedFunnel = await funnel.save();
|
||||
|
||||
// Создаем базовую точку в истории
|
||||
const sessionId = `create-${Date.now()}`;
|
||||
await FunnelHistoryModel.create({
|
||||
funnelId: String(savedFunnel._id),
|
||||
sessionId,
|
||||
funnelSnapshot: funnelData,
|
||||
actionType: 'create',
|
||||
sequenceNumber: 0,
|
||||
description: 'Воронка создана',
|
||||
isBaseline: true
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
_id: savedFunnel._id,
|
||||
name: savedFunnel.name,
|
||||
description: savedFunnel.description,
|
||||
status: savedFunnel.status,
|
||||
version: savedFunnel.version,
|
||||
createdAt: savedFunnel.createdAt,
|
||||
updatedAt: savedFunnel.updatedAt,
|
||||
usage: savedFunnel.usage,
|
||||
funnelData: savedFunnel.funnelData
|
||||
}, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('POST /api/funnels error:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('duplicate key')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel with this name already exists' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create funnel' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Inter, Manrope } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AppProviders } from "@/components/providers/AppProviders";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -39,7 +40,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${manrope.variable} ${inter.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
130
src/components/admin/builder/AddScreenDialog.tsx
Normal file
130
src/components/admin/builder/AddScreenDialog.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
List,
|
||||
FormInput,
|
||||
Info,
|
||||
Calendar,
|
||||
Ticket
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { ScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface AddScreenDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAddScreen: (template: ScreenDefinition["template"]) => void;
|
||||
}
|
||||
|
||||
const TEMPLATE_OPTIONS = [
|
||||
{
|
||||
template: "list" as const,
|
||||
title: "Список",
|
||||
description: "Выбор из списка вариантов (single/multi)",
|
||||
icon: List,
|
||||
color: "bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400",
|
||||
},
|
||||
{
|
||||
template: "form" as const,
|
||||
title: "Форма",
|
||||
description: "Ввод текстовых данных в поля",
|
||||
icon: FormInput,
|
||||
color: "bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400",
|
||||
},
|
||||
{
|
||||
template: "info" as const,
|
||||
title: "Информация",
|
||||
description: "Отображение информации с кнопкой продолжить",
|
||||
icon: Info,
|
||||
color: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||
},
|
||||
{
|
||||
template: "date" as const,
|
||||
title: "Дата",
|
||||
description: "Выбор даты (месяц, день, год)",
|
||||
icon: Calendar,
|
||||
color: "bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400",
|
||||
},
|
||||
{
|
||||
template: "coupon" as const,
|
||||
title: "Купон",
|
||||
description: "Отображение промокода и предложения",
|
||||
icon: Ticket,
|
||||
color: "bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDialogProps) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ScreenDefinition["template"] | null>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (selectedTemplate) {
|
||||
onAddScreen(selectedTemplate);
|
||||
setSelectedTemplate(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedTemplate(null);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Выберите тип экрана</DialogTitle>
|
||||
<DialogDescription>
|
||||
Выберите шаблон для нового экрана воронки
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 py-4">
|
||||
{TEMPLATE_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = selectedTemplate === option.template;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.template}
|
||||
onClick={() => setSelectedTemplate(option.template)}
|
||||
className={`flex items-start gap-4 rounded-lg border-2 p-4 text-left transition-all hover:bg-muted/50 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${option.color}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{option.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleAdd} disabled={!selectedTemplate}>
|
||||
Добавить экран
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
609
src/components/admin/builder/BuilderCanvas.tsx
Normal file
609
src/components/admin/builder/BuilderCanvas.tsx
Normal file
@ -0,0 +1,609 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
|
||||
import { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog";
|
||||
import type {
|
||||
ListOptionDefinition,
|
||||
NavigationConditionDefinition,
|
||||
ScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropIndicator({ isActive }: { isActive: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 h-9 rounded-xl border-2 border-dashed border-primary/50 bg-primary/10 transition-all",
|
||||
isActive ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
|
||||
list: "Список",
|
||||
form: "Форма",
|
||||
info: "Инфо",
|
||||
date: "Дата",
|
||||
coupon: "Купон",
|
||||
};
|
||||
|
||||
const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
|
||||
includesAny: "любой из",
|
||||
includesAll: "все из",
|
||||
includesExactly: "точное совпадение",
|
||||
};
|
||||
|
||||
interface TransitionRowProps {
|
||||
type: "default" | "branch" | "end";
|
||||
label: string;
|
||||
targetLabel?: string;
|
||||
targetIndex?: number | null;
|
||||
optionSummaries?: { id: string; label: string }[];
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
function TransitionRow({
|
||||
type,
|
||||
label,
|
||||
targetLabel,
|
||||
targetIndex,
|
||||
optionSummaries = [],
|
||||
operator,
|
||||
}: TransitionRowProps) {
|
||||
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
|
||||
type === "branch"
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border/60 bg-background/90"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
|
||||
type === "branch" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-wide",
|
||||
type === "branch" ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{operator && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||
{operator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{optionSummaries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{optionSummaries.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
{type === "end" ? (
|
||||
<span className="text-muted-foreground">Завершение воронки</span>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
{typeof targetIndex === "number" && (
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
#{targetIndex + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{targetLabel ?? "Не выбрано"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
|
||||
switch (screen.template) {
|
||||
case "list": {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
|
||||
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Варианты ({screen.list.options.length})</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{screen.list.options.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-primary/5 px-2 py-1 text-[11px] text-primary"
|
||||
>
|
||||
{option.emoji && <span className="text-base leading-none">{option.emoji}</span>}
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "form": {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
|
||||
Полей: {screen.fields.length}
|
||||
</span>
|
||||
{screen.bottomActionButton?.text && (
|
||||
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
|
||||
{screen.bottomActionButton.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{screen.validationMessages && (
|
||||
<div className="rounded-lg border border-border/50 bg-background/80 p-2">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Настроены пользовательские сообщения валидации
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "coupon": {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<p>
|
||||
<span className="font-medium">Промо:</span> {screen.coupon.promoCode.text}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80">{screen.coupon.offer.title.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "date": {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<p className="font-medium">Формат даты:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{screen.dateInput.monthLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.monthLabel}</span>}
|
||||
{screen.dateInput.dayLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.dayLabel}</span>}
|
||||
{screen.dateInput.yearLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.yearLabel}</span>}
|
||||
</div>
|
||||
{screen.dateInput.validationMessage && (
|
||||
<p className="text-[11px] text-destructive">{screen.dateInput.validationMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "info": {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
{screen.description?.text && <p>{screen.description.text}</p>}
|
||||
{screen.icon?.value && (
|
||||
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
|
||||
<span className="text-base">{screen.icon.value}</span>
|
||||
<span className="text-[11px] uppercase text-muted-foreground">Иконка</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function VariantSummary({
|
||||
screen,
|
||||
screenTitleMap,
|
||||
listOptionsMap,
|
||||
}: {
|
||||
screen: ScreenDefinition;
|
||||
screenTitleMap: Record<string, string>;
|
||||
listOptionsMap: Record<string, ListOptionDefinition[]>;
|
||||
}) {
|
||||
const variants = (
|
||||
screen as ScreenDefinition & {
|
||||
variants?: ScreenVariantDefinition<ScreenDefinition>[];
|
||||
}
|
||||
).variants;
|
||||
|
||||
if (!variants || variants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Варианты</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
<span className="text-[11px] uppercase text-muted-foreground/70">{variants.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{variants.map((variant, index) => {
|
||||
const [condition] = variant.conditions ?? [];
|
||||
const controllingScreenId = condition?.screenId;
|
||||
const controllingScreenTitle = controllingScreenId
|
||||
? screenTitleMap[controllingScreenId] ?? controllingScreenId
|
||||
: "Не выбрано";
|
||||
|
||||
const options = controllingScreenId ? listOptionsMap[controllingScreenId] ?? [] : [];
|
||||
const optionSummaries = (condition?.optionIds ?? []).map((optionId) => ({
|
||||
id: optionId,
|
||||
label: getOptionLabel(options, optionId),
|
||||
}));
|
||||
|
||||
const operatorKey = condition?.operator as
|
||||
| Exclude<NavigationConditionDefinition["operator"], undefined>
|
||||
| undefined;
|
||||
const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
|
||||
|
||||
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${index}-${controllingScreenId ?? "none"}`}
|
||||
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Вариант {index + 1}</span>
|
||||
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||
{operatorLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-primary/90">
|
||||
<div>
|
||||
<span className="font-semibold">Экран:</span> {controllingScreenTitle}
|
||||
</div>
|
||||
{optionSummaries.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{optionSummaries.map((option) => (
|
||||
<span key={option.id} className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium">
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-primary/70">Нет выбранных ответов</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-primary/90">
|
||||
<span className="font-semibold">Изменяет:</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
|
||||
<span
|
||||
key={highlight}
|
||||
className="rounded-lg bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||
>
|
||||
{highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
|
||||
const option = options.find((item) => item.id === optionId);
|
||||
return option ? option.label : optionId;
|
||||
}
|
||||
|
||||
export function BuilderCanvas() {
|
||||
const { screens, selectedScreenId } = useBuilderState();
|
||||
const dispatch = useBuilderDispatch();
|
||||
|
||||
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false);
|
||||
|
||||
const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", screenId);
|
||||
dragStateRef.current = { screenId, dragStartIndex: index };
|
||||
setDropIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOverCard = useCallback((event: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
event.preventDefault();
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const offsetY = event.clientY - rect.top;
|
||||
const nextIndex = offsetY > rect.height / 2 ? index + 1 : index;
|
||||
setDropIndex(nextIndex);
|
||||
}, []);
|
||||
|
||||
const handleDragOverList = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.target === event.currentTarget) {
|
||||
setDropIndex(screens.length);
|
||||
}
|
||||
},
|
||||
[screens.length]
|
||||
);
|
||||
|
||||
const finalizeDrop = useCallback(
|
||||
(insertionIndex: number | null) => {
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dragStartIndex } = dragStateRef.current;
|
||||
const boundedIndex = Math.max(0, Math.min(insertionIndex ?? dragStartIndex, screens.length));
|
||||
let targetIndex = boundedIndex;
|
||||
|
||||
if (targetIndex > dragStartIndex) {
|
||||
targetIndex -= 1;
|
||||
}
|
||||
|
||||
if (dragStartIndex !== targetIndex) {
|
||||
dispatch({
|
||||
type: "reorder-screens",
|
||||
payload: {
|
||||
fromIndex: dragStartIndex,
|
||||
toIndex: targetIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
setDropIndex(null);
|
||||
},
|
||||
[dispatch, screens.length]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
finalizeDrop(dropIndex);
|
||||
},
|
||||
[dropIndex, finalizeDrop]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
setDropIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectScreen = useCallback(
|
||||
(screenId: string) => {
|
||||
dispatch({ type: "set-selected-screen", payload: { screenId } });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleAddScreen = useCallback(() => {
|
||||
setAddScreenDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleAddScreenWithTemplate = useCallback((template: ScreenDefinition["template"]) => {
|
||||
dispatch({ type: "add-screen", payload: { template } });
|
||||
}, [dispatch]);
|
||||
|
||||
const screenTitleMap = useMemo(() => {
|
||||
return screens.reduce<Record<string, string>>((accumulator, screen) => {
|
||||
accumulator[screen.id] = screen.title.text || screen.id;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [screens]);
|
||||
|
||||
const listOptionsMap = useMemo(() => {
|
||||
return screens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, screen) => {
|
||||
if (screen.template === "list") {
|
||||
accumulator[screen.id] = screen.list.options;
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [screens]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-border/60 bg-background/95 px-6 py-4 backdrop-blur-sm">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Экраны воронки</h2>
|
||||
</div>
|
||||
<Button variant="outline" className="w-8 h-8 p-0 flex items-center justify-center" onClick={handleAddScreen}>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-900">
|
||||
<div className="relative mx-auto max-w-4xl">
|
||||
<div className="absolute left-6 top-0 bottom-0 hidden w-px bg-border md:block" aria-hidden />
|
||||
<div
|
||||
className="space-y-6 pl-0 md:pl-12"
|
||||
onDragOver={handleDragOverList}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{screens.map((screen, index) => {
|
||||
const isSelected = screen.id === selectedScreenId;
|
||||
const isDropBefore = dropIndex === index;
|
||||
const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const defaultNext = screen.navigation?.defaultNextScreenId;
|
||||
const isLast = index === screens.length - 1;
|
||||
const defaultTargetIndex = defaultNext
|
||||
? screens.findIndex((candidate) => candidate.id === defaultNext)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={screen.id} className="relative">
|
||||
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
|
||||
<div className="flex items-start gap-4 md:gap-6">
|
||||
<div className="relative hidden w-8 flex-shrink-0 md:flex md:flex-col md:items-center">
|
||||
<span className="mt-1 h-3 w-3 rounded-full border-2 border-background bg-primary shadow" />
|
||||
{!isLast && (
|
||||
<div className="mt-2 flex h-full flex-col items-center">
|
||||
<div className="flex-1 w-px bg-gradient-to-b from-primary/40 via-border/40 to-transparent" />
|
||||
<ArrowDown className="mt-1 h-4 w-4 text-border/70" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
|
||||
isSelected && "border-primary/50 ring-2 ring-primary",
|
||||
dragStateRef.current?.screenId === screen.id && "cursor-grabbing opacity-90"
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(event) => handleDragStart(event, screen.id, index)}
|
||||
onDragOver={(event) => handleDragOverCard(event, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => handleSelectScreen(screen.id)}
|
||||
>
|
||||
<span className="absolute right-5 top-5 inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{TEMPLATE_TITLES[screen.template] ?? screen.template}
|
||||
</span>
|
||||
<div className="pr-28">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
#{screen.id}
|
||||
</span>
|
||||
<span className="max-h-14 overflow-hidden break-words text-lg font-semibold leading-tight text-foreground">
|
||||
{screen.title.text || "Без названия"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{("subtitle" in screen && screen.subtitle?.text) && (
|
||||
<p className="mt-3 max-h-12 overflow-hidden text-sm leading-snug text-muted-foreground">
|
||||
{screen.subtitle.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
<TemplateSummary screen={screen} />
|
||||
|
||||
<VariantSummary
|
||||
screen={screen}
|
||||
screenTitleMap={screenTitleMap}
|
||||
listOptionsMap={listOptionsMap}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<TransitionRow
|
||||
type={defaultNext ? "default" : "end"}
|
||||
label={defaultNext ? "По умолчанию" : "Завершение"}
|
||||
targetLabel={defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : undefined}
|
||||
targetIndex={defaultNext && defaultTargetIndex !== -1 ? defaultTargetIndex : null}
|
||||
/>
|
||||
|
||||
{rules.map((rule, ruleIndex) => {
|
||||
const condition = rule.conditions[0];
|
||||
const optionSummaries =
|
||||
screen.template === "list" && condition?.optionIds
|
||||
? condition.optionIds.map((optionId) => ({
|
||||
id: optionId,
|
||||
label: getOptionLabel(screen.list.options, optionId),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const operatorKey = condition?.operator as
|
||||
| Exclude<NavigationConditionDefinition["operator"], undefined>
|
||||
| undefined;
|
||||
const operatorLabel = operatorKey
|
||||
? OPERATOR_LABELS[operatorKey] ?? operatorKey
|
||||
: undefined;
|
||||
|
||||
const ruleTargetIndex = screens.findIndex(
|
||||
(candidate) => candidate.id === rule.nextScreenId
|
||||
);
|
||||
const ruleTargetLabel = screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId;
|
||||
|
||||
return (
|
||||
<TransitionRow
|
||||
key={`${ruleIndex}-${rule.nextScreenId}`}
|
||||
type="branch"
|
||||
label="Вариативность"
|
||||
targetLabel={ruleTargetLabel}
|
||||
targetIndex={ruleTargetIndex !== -1 ? ruleTargetIndex : null}
|
||||
optionSummaries={optionSummaries}
|
||||
operator={operatorLabel}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{screens.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-background/80 p-8 text-center text-sm text-muted-foreground">
|
||||
Добавьте первый экран, чтобы начать строить воронку.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button variant="ghost" onClick={handleAddScreen} className="w-8 h-8 p-0 mx-auto flex items-center justify-center">
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddScreenDialog
|
||||
open={addScreenDialogOpen}
|
||||
onOpenChange={setAddScreenDialogOpen}
|
||||
onAddScreen={handleAddScreenWithTemplate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
155
src/components/admin/builder/BuilderPreview.tsx
Normal file
155
src/components/admin/builder/BuilderPreview.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context";
|
||||
import { renderScreen } from "@/lib/funnel/screenRenderer";
|
||||
import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants";
|
||||
|
||||
export function BuilderPreview() {
|
||||
const selectedScreen = useBuilderSelectedScreen();
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedScreen) {
|
||||
setSelectedIds([]);
|
||||
setPreviewVariantIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, [selectedScreen]);
|
||||
|
||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.length === ids.length && prev.every((value, index) => value === ids[index])) {
|
||||
return prev;
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
const variants = useMemo(() => selectedScreen?.variants ?? [], [selectedScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewVariantIndex(null);
|
||||
}, [selectedScreen]);
|
||||
|
||||
const previewScreen = useMemo(() => {
|
||||
if (!selectedScreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (previewVariantIndex === null) {
|
||||
return selectedScreen;
|
||||
}
|
||||
|
||||
const variant = variants[previewVariantIndex];
|
||||
if (!variant) {
|
||||
return selectedScreen;
|
||||
}
|
||||
|
||||
return mergeScreenWithOverrides(selectedScreen, variant.overrides ?? {});
|
||||
}, [previewVariantIndex, selectedScreen, variants]);
|
||||
|
||||
const renderScreenPreview = useCallback(() => {
|
||||
if (!previewScreen) return null;
|
||||
|
||||
try {
|
||||
// Use the same renderer as FunnelRuntime for 1:1 accuracy
|
||||
return renderScreen({
|
||||
screen: previewScreen,
|
||||
selectedOptionIds: selectedIds,
|
||||
onSelectionChange: handleSelectionChange,
|
||||
onContinue: () => {}, // Mock continue handler for preview
|
||||
canGoBack: true, // Show back button in preview
|
||||
onBack: () => {}, // Mock back handler for preview
|
||||
screenProgress: { current: 1, total: 10 }, // Mock progress for preview
|
||||
defaultTexts: { nextButton: "Next", continueButton: "Continue" }, // Mock texts
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rendering preview:', error);
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border text-sm text-muted-foreground">
|
||||
Ошибка при отображении превью: {error instanceof Error ? error.message : 'Неизвестная ошибка'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [previewScreen, selectedIds, handleSelectionChange]);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (!previewScreen) {
|
||||
return (
|
||||
<div className="flex items-center justify-center mx-auto" style={{ height: '600px', width: '320px' }}>
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground w-full h-full">
|
||||
Выберите экран для предпросмотра
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Увеличим высоту чтобы кнопка поместилась полностью
|
||||
const PREVIEW_WIDTH = 320;
|
||||
const PREVIEW_HEIGHT = 750; // Увеличено с ~694px до 750px для BottomActionButton
|
||||
|
||||
return (
|
||||
<div className="mx-auto space-y-3" style={{ width: PREVIEW_WIDTH }}>
|
||||
{variants.length > 0 && (
|
||||
<div className="rounded-lg border border-border/60 bg-background/90 p-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold uppercase tracking-wide text-muted-foreground/80">
|
||||
Превью варианта
|
||||
</span>
|
||||
<select
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-xs"
|
||||
value={previewVariantIndex === null ? "base" : String(previewVariantIndex)}
|
||||
onChange={(event) =>
|
||||
setPreviewVariantIndex(event.target.value === "base" ? null : Number(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="base">Основной экран</option>
|
||||
{variants.map((variant, index) => (
|
||||
<option key={index} value={index}>
|
||||
Вариант {index + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{previewVariantIndex !== null && (
|
||||
<div className="mt-2 rounded border border-blue-200 bg-blue-50 px-2 py-1 text-[11px] text-blue-700 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
⚠️ Превью принудительно показывает вариант. В реальной воронке он показывается только при выполнении условий.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Frame - Simple Border */}
|
||||
<div
|
||||
className="relative bg-white rounded-2xl border-4 border-gray-300 shadow-lg mx-auto"
|
||||
style={{
|
||||
height: PREVIEW_HEIGHT,
|
||||
width: PREVIEW_WIDTH,
|
||||
overflow: 'hidden', // Hide anything that goes outside
|
||||
contain: 'layout style paint', // CSS containment
|
||||
isolation: 'isolate', // Create new stacking context
|
||||
transform: 'translateZ(0)' // Force new layer
|
||||
}}
|
||||
>
|
||||
{/* Screen Content with scroll */}
|
||||
<div className="w-full h-full overflow-y-auto overflow-x-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{renderScreenPreview()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [previewScreen, renderScreenPreview, variants, previewVariantIndex]);
|
||||
|
||||
return preview;
|
||||
}
|
||||
656
src/components/admin/builder/BuilderSidebar.tsx
Normal file
656
src/components/admin/builder/BuilderSidebar.tsx
Normal file
@ -0,0 +1,656 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||||
import { ScreenVariantsConfig } from "@/components/admin/builder/ScreenVariantsConfig";
|
||||
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type {
|
||||
NavigationRuleDefinition,
|
||||
ScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { validateBuilderState } from "@/lib/admin/builder/validation";
|
||||
|
||||
type ValidationIssues = ReturnType<typeof validateBuilderState>["issues"];
|
||||
|
||||
function isListScreen(
|
||||
screen: BuilderScreen
|
||||
): screen is BuilderScreen & {
|
||||
list: {
|
||||
selectionType: "single" | "multi";
|
||||
options: Array<{ id: string; label: string; description?: string; emoji?: string }>;
|
||||
};
|
||||
} {
|
||||
return screen.template === "list" && "list" in screen;
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
alwaysExpanded = false,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
alwaysExpanded?: boolean;
|
||||
}) {
|
||||
const storageKey = `section-${title.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (alwaysExpanded) {
|
||||
setIsExpanded(true);
|
||||
setIsHydrated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = sessionStorage.getItem(storageKey);
|
||||
if (stored !== null) {
|
||||
setIsExpanded(JSON.parse(stored));
|
||||
}
|
||||
setIsHydrated(true);
|
||||
}, [alwaysExpanded, storageKey]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (alwaysExpanded) return;
|
||||
|
||||
const newExpanded = !isExpanded;
|
||||
setIsExpanded(newExpanded);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveExpanded = alwaysExpanded || (isHydrated ? isExpanded : defaultExpanded);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 cursor-pointer",
|
||||
!alwaysExpanded && "hover:text-foreground transition-colors"
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{!alwaysExpanded && (
|
||||
effectiveExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)
|
||||
)}
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h2>
|
||||
{description && <p className="text-xs text-muted-foreground/80">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{effectiveExpanded && (
|
||||
<div className="flex flex-col gap-2 ml-2 pl-2 border-l-2 border-border/30">{children}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidationSummary({ issues }: { issues: ValidationIssues }) {
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/30 bg-background/40 p-2 text-xs text-muted-foreground">
|
||||
Всё хорошо — воронка валидна.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{issues.map((issue, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-destructive/20 bg-destructive/5 p-2 text-xs text-destructive"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-destructive/80">⚠</span>
|
||||
<div>
|
||||
<p className="font-medium">{issue.message}</p>
|
||||
{issue.screenId && <p className="mt-1 text-destructive/80">Экран: {issue.screenId}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuilderSidebar() {
|
||||
const state = useBuilderState();
|
||||
const dispatch = useBuilderDispatch();
|
||||
const selectedScreen = useBuilderSelectedScreen();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel");
|
||||
const selectedScreenId = selectedScreen?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab((previous) => {
|
||||
if (selectedScreenId) {
|
||||
return "screen";
|
||||
}
|
||||
return previous === "screen" ? "funnel" : previous;
|
||||
});
|
||||
}, [selectedScreenId]);
|
||||
|
||||
const validation = useMemo(() => validateBuilderState(state), [state]);
|
||||
const screenValidationIssues = useMemo(() => {
|
||||
if (!selectedScreenId) {
|
||||
return [] as ValidationIssues;
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const handleMetaChange = (field: keyof typeof state.meta, value: string) => {
|
||||
dispatch({ type: "set-meta", payload: { [field]: value } });
|
||||
};
|
||||
|
||||
const handleFirstScreenChange = (value: string) => {
|
||||
dispatch({ type: "set-meta", payload: { firstScreenId: value } });
|
||||
};
|
||||
|
||||
const handleScreenIdChange = (currentId: string, newId: string) => {
|
||||
if (newId.trim() === "" || newId === currentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем ID экрана
|
||||
dispatch({
|
||||
type: "update-screen",
|
||||
payload: {
|
||||
screenId: currentId,
|
||||
screen: { id: newId }
|
||||
}
|
||||
});
|
||||
|
||||
// Если это был первый экран в мета данных, обновляем и там
|
||||
if (state.meta.firstScreenId === currentId) {
|
||||
dispatch({ type: "set-meta", payload: { firstScreenId: newId } });
|
||||
}
|
||||
};
|
||||
|
||||
const getScreenById = (screenId: string): BuilderScreen | undefined =>
|
||||
state.screens.find((item) => item.id === screenId);
|
||||
|
||||
const updateNavigation = (
|
||||
screen: BuilderScreen,
|
||||
navigationUpdates: Partial<BuilderScreen["navigation"]> = {}
|
||||
) => {
|
||||
dispatch({
|
||||
type: "update-navigation",
|
||||
payload: {
|
||||
screenId: screen.id,
|
||||
navigation: {
|
||||
defaultNextScreenId:
|
||||
navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId,
|
||||
rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateNavigation(screen, {
|
||||
defaultNextScreenId: nextScreenId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const updateRules = (screenId: string, rules: NavigationRuleDefinition[]) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateNavigation(screen, { rules });
|
||||
};
|
||||
|
||||
const handleRuleOperatorChange = (
|
||||
screenId: string,
|
||||
index: number,
|
||||
operator: NavigationRuleDefinition["conditions"][0]["operator"]
|
||||
) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, ruleIndex) =>
|
||||
ruleIndex === index
|
||||
? {
|
||||
...rule,
|
||||
conditions: rule.conditions.map((condition, conditionIndex) =>
|
||||
conditionIndex === 0
|
||||
? {
|
||||
...condition,
|
||||
operator,
|
||||
}
|
||||
: condition
|
||||
),
|
||||
}
|
||||
: rule
|
||||
);
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleRuleOptionToggle = (screenId: string, ruleIndex: number, optionId: string) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, currentIndex) => {
|
||||
if (currentIndex !== ruleIndex) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
const [condition] = rule.conditions;
|
||||
const optionIds = new Set(condition.optionIds ?? []);
|
||||
if (optionIds.has(optionId)) {
|
||||
optionIds.delete(optionId);
|
||||
} else {
|
||||
optionIds.add(optionId);
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
conditions: [
|
||||
{
|
||||
...condition,
|
||||
optionIds: Array.from(optionIds),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleRuleNextScreenChange = (screenId: string, ruleIndex: number, nextScreenId: string) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, currentIndex) =>
|
||||
currentIndex === ruleIndex ? { ...rule, nextScreenId } : rule
|
||||
);
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleAddRule = (screen: BuilderScreen) => {
|
||||
if (!isListScreen(screen)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultCondition: NavigationRuleDefinition["conditions"][number] = {
|
||||
screenId: screen.id,
|
||||
operator: "includesAny",
|
||||
optionIds: screen.list.options.slice(0, 1).map((option) => option.id),
|
||||
};
|
||||
|
||||
const nextRules = [
|
||||
...(screen.navigation?.rules ?? []),
|
||||
{ nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] },
|
||||
];
|
||||
updateNavigation(screen, { rules: nextRules });
|
||||
};
|
||||
|
||||
const handleRemoveRule = (screenId: string, ruleIndex: number) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.filter((_, index) => index !== ruleIndex);
|
||||
updateNavigation(screen, { rules: nextRules });
|
||||
};
|
||||
|
||||
const handleDeleteScreen = (screenId: string) => {
|
||||
if (state.screens.length <= 1) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "remove-screen", payload: { screenId } });
|
||||
};
|
||||
|
||||
const handleTemplateUpdate = (screenId: string, updates: Partial<ScreenDefinition>) => {
|
||||
dispatch({
|
||||
type: "update-screen",
|
||||
payload: {
|
||||
screenId,
|
||||
screen: updates as Partial<BuilderScreen>,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleVariantsChange = (
|
||||
screenId: string,
|
||||
variants: ScreenVariantDefinition<ScreenDefinition>[]
|
||||
) => {
|
||||
dispatch({
|
||||
type: "update-screen",
|
||||
payload: {
|
||||
screenId,
|
||||
screen: {
|
||||
variants: variants.length > 0 ? variants : undefined,
|
||||
} as Partial<BuilderScreen>,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="sticky top-0 z-20 border-b border-border/60 bg-background/95 px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-base font-semibold">Настройки</h1>
|
||||
</div>
|
||||
<div className="mt-3 flex rounded-lg bg-muted/40 p-1 text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 transition",
|
||||
activeTab === "funnel"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab("funnel")}
|
||||
>
|
||||
Воронка
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 transition",
|
||||
activeTab === "screen"
|
||||
? "bg-background text-foreground shadow"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
!selectedScreen && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
onClick={() => selectedScreen && setActiveTab("screen")}
|
||||
>
|
||||
Экран
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{activeTab === "funnel" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Section title="Валидация">
|
||||
<ValidationSummary issues={validation.issues} />
|
||||
</Section>
|
||||
|
||||
<Section title="Настройки воронки" description="Общие параметры">
|
||||
<TextInput
|
||||
label="ID воронки"
|
||||
value={state.meta.id}
|
||||
onChange={(event) => handleMetaChange("id", event.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Название"
|
||||
value={state.meta.title ?? ""}
|
||||
onChange={(event) => handleMetaChange("title", event.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Описание"
|
||||
value={state.meta.description ?? ""}
|
||||
onChange={(event) => handleMetaChange("description", event.target.value)}
|
||||
/>
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
|
||||
onChange={(event) => handleFirstScreenChange(event.target.value)}
|
||||
>
|
||||
{screenOptions.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Section>
|
||||
|
||||
<Section title="Экраны">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Всего экранов</span>
|
||||
<span className="font-semibold text-foreground">{state.screens.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
{state.screens.map((screen, index) => (
|
||||
<span key={screen.id} className="flex items-center justify-between">
|
||||
<span className="truncate">{index + 1}. {screen.title.text}</span>
|
||||
<span className="uppercase text-muted-foreground/80">{screen.template}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : selectedScreen ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">#{selectedScreen.id}</span>
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary uppercase">
|
||||
{selectedScreen.template}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section title="Общие данные">
|
||||
<TextInput
|
||||
label="ID экрана"
|
||||
value={selectedScreen.id}
|
||||
onChange={(event) => handleScreenIdChange(selectedScreen.id, event.target.value)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Контент и оформление">
|
||||
<TemplateConfig
|
||||
screen={selectedScreen}
|
||||
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Вариативность">
|
||||
<ScreenVariantsConfig
|
||||
screen={selectedScreen}
|
||||
allScreens={state.screens}
|
||||
onChange={(variants) => handleVariantsChange(selectedScreen.id, variants)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Навигация">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
|
||||
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{screenOptions
|
||||
.filter((screen) => screen.id !== selectedScreen.id)
|
||||
.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Section>
|
||||
|
||||
{selectedScreenIsListType && (
|
||||
<Section title="Правила переходов" description="Условная навигация">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Направляйте пользователей на разные экраны в зависимости от выбора.
|
||||
</p>
|
||||
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={() => handleAddRule(selectedScreen)}>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
|
||||
Правил пока нет
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={ruleIndex}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
|
||||
>
|
||||
<span className="text-xs">Удалить</span>
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={rule.conditions[0]?.operator ?? "includesAny"}
|
||||
onChange={(event) =>
|
||||
handleRuleOperatorChange(
|
||||
selectedScreen.id,
|
||||
ruleIndex,
|
||||
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="includesAny">contains any</option>
|
||||
<option value="includesAll">contains all</option>
|
||||
<option value="includesExactly">exact match</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedScreen.template === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
|
||||
{selectedScreen.list.options.map((option) => {
|
||||
const condition = rule.conditions[0];
|
||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||
return (
|
||||
<label key={option.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground"> ({option.id})</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
Навигационные правила с вариантами ответа доступны только для экранов со списком.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={rule.nextScreenId}
|
||||
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
|
||||
>
|
||||
{screenOptions
|
||||
.filter((screen) => screen.id !== selectedScreen.id)
|
||||
.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Валидация">
|
||||
<ValidationSummary issues={screenValidationIssues} />
|
||||
</Section>
|
||||
|
||||
<Section title="Управление">
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-9 text-sm"
|
||||
disabled={state.screens.length <= 1}
|
||||
onClick={() => handleDeleteScreen(selectedScreen.id)}
|
||||
>
|
||||
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/20 p-6 text-center text-sm text-muted-foreground">
|
||||
Выберите экран в списке слева, чтобы настроить его параметры.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
src/components/admin/builder/BuilderTopBar.tsx
Normal file
258
src/components/admin/builder/BuilderTopBar.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { ArrowLeft, Save, Globe, Download, Upload, Undo, Redo } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { serializeBuilderState, deserializeFunnelDefinition } from "@/lib/admin/builder/utils";
|
||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import { useBuilderUndoRedo } from "@/components/admin/builder/BuilderUndoRedoProvider";
|
||||
import type { BuilderState } from "@/lib/admin/builder/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FunnelInfo {
|
||||
name: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
version: number;
|
||||
lastSaved: string;
|
||||
}
|
||||
|
||||
interface BuilderTopBarProps {
|
||||
onNew?: () => void;
|
||||
onSave?: (state: BuilderState) => Promise<boolean>;
|
||||
onPublish?: (state: BuilderState) => Promise<boolean>;
|
||||
onBackToCatalog?: () => void;
|
||||
saving?: boolean;
|
||||
funnelInfo?: FunnelInfo;
|
||||
onLoadError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function BuilderTopBar({
|
||||
onNew,
|
||||
onSave,
|
||||
onPublish,
|
||||
onBackToCatalog,
|
||||
saving,
|
||||
funnelInfo,
|
||||
onLoadError
|
||||
}: BuilderTopBarProps) {
|
||||
const dispatch = useBuilderDispatch();
|
||||
const state = useBuilderState();
|
||||
const fileInputId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
|
||||
// Use undo/redo from context
|
||||
const undoRedo = useBuilderUndoRedo();
|
||||
|
||||
const handleExport = () => {
|
||||
const json = JSON.stringify(serializeBuilderState(state), null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `funnel-${state.meta.id || 'export'}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
const builderState = deserializeFunnelDefinition(parsed);
|
||||
dispatch({ type: "reset", payload: builderState as BuilderState });
|
||||
} catch (error) {
|
||||
onLoadError?.(error instanceof Error ? error.message : "Не удалось загрузить JSON");
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave && !saving) {
|
||||
const success = await onSave(state);
|
||||
if (success) {
|
||||
undoRedo.resetDirty();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (onPublish && !publishing && !saving) {
|
||||
setPublishing(true);
|
||||
try {
|
||||
const success = await onPublish(state);
|
||||
if (success) {
|
||||
undoRedo.resetDirty();
|
||||
}
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Статус badge
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
draft: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
published: 'bg-green-100 text-green-800 border-green-200',
|
||||
archived: 'bg-gray-100 text-gray-800 border-gray-200'
|
||||
};
|
||||
|
||||
const labels = {
|
||||
draft: 'Черновик',
|
||||
published: 'Опубликована',
|
||||
archived: 'Архивирована'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium border',
|
||||
variants[status as keyof typeof variants]
|
||||
)}>
|
||||
{labels[status as keyof typeof labels]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-white border-b border-gray-200">
|
||||
|
||||
{/* Left section */}
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
{/* Back to catalog */}
|
||||
{onBackToCatalog && (
|
||||
<Button variant="ghost" onClick={onBackToCatalog} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Каталог
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Funnel info */}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
{funnelInfo?.name || state.meta.title || 'Новая воронка'}
|
||||
</h1>
|
||||
{funnelInfo && getStatusBadge(funnelInfo.status)}
|
||||
{state.isDirty && (
|
||||
<span className="text-xs text-orange-600 font-medium">
|
||||
• Несохраненные изменения
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{funnelInfo && (
|
||||
<div className="text-xs text-gray-500">
|
||||
v{funnelInfo.version} • Сохранено {formatDate(funnelInfo.lastSaved)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!undoRedo.canUndo}
|
||||
onClick={undoRedo.undo}
|
||||
title="Отменить (Ctrl+Z)"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!undoRedo.canRedo}
|
||||
onClick={undoRedo.redo}
|
||||
title="Повторить (Ctrl+Y)"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Import/Export */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Загрузить JSON"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleExport}
|
||||
title="Экспорт JSON"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* Save/Publish */}
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={saving || publishing || !state.isDirty}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onPublish && (
|
||||
<Button
|
||||
onClick={handlePublish}
|
||||
disabled={saving || publishing}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
{publishing ? 'Публикация...' : 'Опубликовать'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Create new */}
|
||||
<Button variant="ghost" onClick={onNew}>
|
||||
Создать заново
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/components/admin/builder/BuilderUndoRedoProvider.tsx
Normal file
118
src/components/admin/builder/BuilderUndoRedoProvider.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Provider that wraps the builder and adds undo/redo functionality
|
||||
* Automatically stores state snapshots when significant changes occur
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useBuilderState, useBuilderDispatch } from '@/lib/admin/builder/context';
|
||||
import { useSimpleUndoRedo } from '@/lib/admin/builder/useSimpleUndoRedo';
|
||||
import type { BuilderState } from '@/lib/admin/builder/context';
|
||||
|
||||
interface UndoRedoContextValue {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
store: () => void;
|
||||
clear: () => void;
|
||||
resetDirty: () => void; // Сброс isDirty флага
|
||||
}
|
||||
|
||||
const UndoRedoContext = createContext<UndoRedoContextValue | undefined>(undefined);
|
||||
|
||||
interface BuilderUndoRedoProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function BuilderUndoRedoProvider({ children }: BuilderUndoRedoProviderProps) {
|
||||
const state = useBuilderState();
|
||||
const dispatch = useBuilderDispatch();
|
||||
const previousStateRef = useRef<BuilderState>(state);
|
||||
const isRestoringRef = useRef(false);
|
||||
|
||||
// Функция для сброса isDirty
|
||||
const resetDirty = () => {
|
||||
dispatch({ type: 'reset', payload: { ...state, isDirty: false } });
|
||||
};
|
||||
|
||||
const undoRedo = useSimpleUndoRedo(
|
||||
state,
|
||||
(newState) => {
|
||||
isRestoringRef.current = true;
|
||||
dispatch({ type: 'reset', payload: newState });
|
||||
}
|
||||
);
|
||||
|
||||
// Auto-store state when significant changes occur
|
||||
useEffect(() => {
|
||||
// Don't store if we're in the middle of restoring from undo/redo
|
||||
if (isRestoringRef.current) {
|
||||
isRestoringRef.current = false;
|
||||
previousStateRef.current = state;
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = previousStateRef.current;
|
||||
|
||||
// Check for significant changes that should trigger a store
|
||||
const shouldStore = (
|
||||
// Screen count changed
|
||||
prev.screens.length !== state.screens.length ||
|
||||
|
||||
// Selected screen changed
|
||||
prev.selectedScreenId !== state.selectedScreenId ||
|
||||
|
||||
// Meta data changed
|
||||
JSON.stringify(prev.meta) !== JSON.stringify(state.meta) ||
|
||||
|
||||
// Screen structure changed (templates, navigation, etc.)
|
||||
prev.screens.some((prevScreen, index) => {
|
||||
const currentScreen = state.screens[index];
|
||||
if (!currentScreen || prevScreen.id !== currentScreen.id) return true;
|
||||
|
||||
return (
|
||||
prevScreen.template !== currentScreen.template ||
|
||||
JSON.stringify(prevScreen.navigation) !== JSON.stringify(currentScreen.navigation) ||
|
||||
JSON.stringify(prevScreen.title) !== JSON.stringify(currentScreen.title)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
if (shouldStore) {
|
||||
// Store the previous state (not current) so we can undo to it
|
||||
undoRedo.store();
|
||||
previousStateRef.current = state;
|
||||
}
|
||||
}, [state, undoRedo]);
|
||||
|
||||
// Store initial state
|
||||
useEffect(() => {
|
||||
undoRedo.store();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const contextValue: UndoRedoContextValue = {
|
||||
canUndo: undoRedo.canUndo,
|
||||
canRedo: undoRedo.canRedo,
|
||||
undo: undoRedo.undo,
|
||||
redo: undoRedo.redo,
|
||||
store: undoRedo.store,
|
||||
clear: undoRedo.clear,
|
||||
resetDirty,
|
||||
};
|
||||
|
||||
return (
|
||||
<UndoRedoContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</UndoRedoContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBuilderUndoRedo(): UndoRedoContextValue {
|
||||
const context = useContext(UndoRedoContext);
|
||||
if (!context) {
|
||||
throw new Error('useBuilderUndoRedo must be used within BuilderUndoRedoProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
429
src/components/admin/builder/ScreenVariantsConfig.tsx
Normal file
429
src/components/admin/builder/ScreenVariantsConfig.tsx
Normal file
@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import {
|
||||
extractVariantOverrides,
|
||||
formatOverridePath,
|
||||
listOverridePaths,
|
||||
mergeScreenWithOverrides,
|
||||
} from "@/lib/admin/builder/variants";
|
||||
import type {
|
||||
ListOptionDefinition,
|
||||
NavigationConditionDefinition,
|
||||
ScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
|
||||
interface ScreenVariantsConfigProps {
|
||||
screen: BuilderScreen;
|
||||
allScreens: BuilderScreen[];
|
||||
onChange: (variants: ScreenVariantDefinition<ScreenDefinition>[]) => void;
|
||||
}
|
||||
|
||||
type ListBuilderScreen = BuilderScreen & { template: "list" };
|
||||
|
||||
type VariantDefinition = ScreenVariantDefinition<ScreenDefinition>;
|
||||
|
||||
type VariantCondition = NavigationConditionDefinition;
|
||||
|
||||
function ensureCondition(variant: VariantDefinition, fallbackScreenId: string): VariantCondition {
|
||||
const [condition] = variant.conditions;
|
||||
|
||||
if (!condition) {
|
||||
return {
|
||||
screenId: fallbackScreenId,
|
||||
operator: "includesAny",
|
||||
optionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
return condition;
|
||||
}
|
||||
|
||||
function VariantOverridesEditor({
|
||||
baseScreen,
|
||||
overrides,
|
||||
onChange,
|
||||
}: {
|
||||
baseScreen: BuilderScreen;
|
||||
overrides: VariantDefinition["overrides"];
|
||||
onChange: (overrides: VariantDefinition["overrides"]) => void;
|
||||
}) {
|
||||
const baseWithoutVariants = useMemo(() => {
|
||||
const clone = mergeScreenWithOverrides(baseScreen, {});
|
||||
const sanitized = { ...clone } as BuilderScreen;
|
||||
if ("variants" in sanitized) {
|
||||
delete (sanitized as Partial<BuilderScreen>).variants;
|
||||
}
|
||||
return sanitized;
|
||||
}, [baseScreen]);
|
||||
|
||||
const mergedScreen = useMemo(
|
||||
() => mergeScreenWithOverrides<BuilderScreen>(baseWithoutVariants, overrides) as BuilderScreen,
|
||||
[baseWithoutVariants, overrides]
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ScreenDefinition>) => {
|
||||
const nextScreen = mergeScreenWithOverrides<BuilderScreen>(
|
||||
mergedScreen,
|
||||
updates as Partial<BuilderScreen>
|
||||
);
|
||||
const nextOverrides = extractVariantOverrides(baseWithoutVariants, nextScreen);
|
||||
onChange(nextOverrides);
|
||||
},
|
||||
[baseWithoutVariants, mergedScreen, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TemplateConfig screen={mergedScreen} onUpdate={handleUpdate} />
|
||||
<Button variant="outline" className="h-8 px-3 text-xs" onClick={() => onChange({})}>
|
||||
Сбросить переопределения
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVariantsConfigProps) {
|
||||
const variants = useMemo(
|
||||
() => ((screen.variants ?? []) as VariantDefinition[]),
|
||||
[screen.variants]
|
||||
);
|
||||
const [expandedVariant, setExpandedVariant] = useState<number | null>(() => (variants.length > 0 ? 0 : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (variants.length === 0) {
|
||||
setExpandedVariant(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expandedVariant === null) {
|
||||
setExpandedVariant(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expandedVariant >= variants.length) {
|
||||
setExpandedVariant(variants.length - 1);
|
||||
}
|
||||
}, [expandedVariant, variants]);
|
||||
|
||||
const listScreens = useMemo(
|
||||
() => allScreens.filter((candidate): candidate is ListBuilderScreen => candidate.template === "list"),
|
||||
[allScreens]
|
||||
);
|
||||
|
||||
const optionMap = useMemo(() => {
|
||||
return listScreens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, listScreen) => {
|
||||
accumulator[listScreen.id] = listScreen.list.options;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [listScreens]);
|
||||
|
||||
const handleVariantsUpdate = useCallback(
|
||||
(nextVariants: VariantDefinition[]) => {
|
||||
onChange(nextVariants);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const addVariant = useCallback(() => {
|
||||
const fallbackScreen = listScreens[0] ?? (screen.template === "list" ? (screen as ListBuilderScreen) : undefined);
|
||||
|
||||
if (!fallbackScreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstOptionId = fallbackScreen.list.options[0]?.id;
|
||||
|
||||
const newVariant: VariantDefinition = {
|
||||
conditions: [
|
||||
{
|
||||
screenId: fallbackScreen.id,
|
||||
operator: "includesAny",
|
||||
optionIds: firstOptionId ? [firstOptionId] : [],
|
||||
},
|
||||
],
|
||||
overrides: {},
|
||||
};
|
||||
|
||||
handleVariantsUpdate([...variants, newVariant]);
|
||||
setExpandedVariant(variants.length);
|
||||
}, [handleVariantsUpdate, listScreens, screen, variants]);
|
||||
|
||||
const removeVariant = useCallback(
|
||||
(index: number) => {
|
||||
handleVariantsUpdate(variants.filter((_, variantIndex) => variantIndex !== index));
|
||||
},
|
||||
[handleVariantsUpdate, variants]
|
||||
);
|
||||
|
||||
const updateVariant = useCallback(
|
||||
(index: number, patch: Partial<VariantDefinition>) => {
|
||||
handleVariantsUpdate(
|
||||
variants.map((variant, variantIndex) =>
|
||||
variantIndex === index
|
||||
? {
|
||||
...variant,
|
||||
...patch,
|
||||
conditions: patch.conditions ?? variant.conditions,
|
||||
overrides: patch.overrides ?? variant.overrides,
|
||||
}
|
||||
: variant
|
||||
)
|
||||
);
|
||||
},
|
||||
[handleVariantsUpdate, variants]
|
||||
);
|
||||
|
||||
const updateCondition = useCallback(
|
||||
(index: number, updates: Partial<VariantCondition>) => {
|
||||
updateVariant(index, {
|
||||
conditions: [
|
||||
{
|
||||
...ensureCondition(variants[index], screen.id),
|
||||
...updates,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
[screen.id, updateVariant, variants]
|
||||
);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(index: number, optionId: string) => {
|
||||
const condition = ensureCondition(variants[index], screen.id);
|
||||
const optionIds = new Set(condition.optionIds ?? []);
|
||||
if (optionIds.has(optionId)) {
|
||||
optionIds.delete(optionId);
|
||||
} else {
|
||||
optionIds.add(optionId);
|
||||
}
|
||||
|
||||
updateCondition(index, { optionIds: Array.from(optionIds) });
|
||||
},
|
||||
[screen.id, updateCondition, variants]
|
||||
);
|
||||
|
||||
const handleScreenChange = useCallback(
|
||||
(variantIndex: number, screenId: string) => {
|
||||
const listScreen = listScreens.find((candidate) => candidate.id === screenId);
|
||||
const defaultOption = listScreen?.list.options[0]?.id;
|
||||
updateCondition(variantIndex, {
|
||||
screenId,
|
||||
optionIds: defaultOption ? [defaultOption] : [],
|
||||
});
|
||||
},
|
||||
[listScreens, updateCondition]
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(variantIndex: number, operator: VariantCondition["operator"]) => {
|
||||
updateCondition(variantIndex, { operator });
|
||||
},
|
||||
[updateCondition]
|
||||
);
|
||||
|
||||
const handleOverridesChange = useCallback(
|
||||
(index: number, overrides: VariantDefinition["overrides"]) => {
|
||||
updateVariant(index, { overrides });
|
||||
},
|
||||
[updateVariant]
|
||||
);
|
||||
|
||||
const renderVariantSummary = useCallback(
|
||||
(variant: VariantDefinition) => {
|
||||
const condition = ensureCondition(variant, screen.id);
|
||||
const optionSummaries = (condition.optionIds ?? []).map((optionId) => {
|
||||
const options = optionMap[condition.screenId] ?? [];
|
||||
const option = options.find((item) => item.id === optionId);
|
||||
return option?.label ?? optionId;
|
||||
});
|
||||
|
||||
const listScreenTitle = listScreens.find((candidate) => candidate.id === condition.screenId)?.title.text;
|
||||
const operatorLabel = (() => {
|
||||
switch (condition.operator) {
|
||||
case "includesAll":
|
||||
return "все из";
|
||||
case "includesExactly":
|
||||
return "точное совпадение";
|
||||
default:
|
||||
return "любой из";
|
||||
}
|
||||
})();
|
||||
|
||||
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-foreground">Экран условий:</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{listScreenTitle ?? condition.screenId}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground/80">{operatorLabel}</span>
|
||||
</div>
|
||||
{optionSummaries.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{optionSummaries.map((label) => (
|
||||
<span key={label} className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground/80">Пока нет выбранных ответов</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((item) => (
|
||||
<span key={item} className="rounded-md bg-muted px-2 py-0.5 text-[11px]">
|
||||
{item === "Без изменений" ? item : formatOverridePath(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[listScreens, optionMap, screen.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Настройте альтернативные варианты контента без изменения переходов.
|
||||
</p>
|
||||
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={addVariant} disabled={listScreens.length === 0}>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listScreens.length === 0 ? (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Добавьте экран со списком, чтобы настроить вариативность.
|
||||
</div>
|
||||
) : variants.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-4 text-center text-xs text-muted-foreground">
|
||||
Пока нет дополнительных вариантов.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{variants.map((variant, index) => {
|
||||
const condition = ensureCondition(variant, screen.id);
|
||||
const isExpanded = expandedVariant === index;
|
||||
const availableOptions = optionMap[condition.screenId] ?? [];
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-3 rounded-xl border border-border/70 bg-background/80 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Вариант {index + 1}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{renderVariantSummary(variant)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => setExpandedVariant(isExpanded ? null : index)}
|
||||
>
|
||||
{isExpanded ? "Свернуть" : "Редактировать"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-3 text-xs text-destructive"
|
||||
onClick={() => removeVariant(index)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-4 border-t border-border/60 pt-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
<p><strong>Ограничение:</strong> Текущая версия админки поддерживает только одно условие на вариант. Реальная система поддерживает множественные условия через JSON.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Экран условий</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={condition.screenId}
|
||||
onChange={(event) => handleScreenChange(index, event.target.value)}
|
||||
>
|
||||
{listScreens.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.title.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Оператор</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={condition.operator ?? "includesAny"}
|
||||
onChange={(event) =>
|
||||
handleOperatorChange(index, event.target.value as VariantCondition["operator"])
|
||||
}
|
||||
>
|
||||
<option value="includesAny">любой из</option>
|
||||
<option value="includesAll">все из</option>
|
||||
<option value="includesExactly">точное совпадение</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Ответы</span>
|
||||
{availableOptions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
В выбранном экране пока нет вариантов ответа.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{availableOptions.map((option) => {
|
||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||
return (
|
||||
<label key={option.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleOption(index, option.id)}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground"> ({option.id})</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Настройка контента</span>
|
||||
<VariantOverridesEditor
|
||||
baseScreen={screen}
|
||||
overrides={variant.overrides ?? {}}
|
||||
onChange={(overrides) => handleOverridesChange(index, overrides)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/admin/builder/templates/CouponScreenConfig.tsx
Normal file
107
src/components/admin/builder/templates/CouponScreenConfig.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { CouponScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface CouponScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "coupon" };
|
||||
onUpdate: (updates: Partial<CouponScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) {
|
||||
const couponScreen = screen as CouponScreenDefinition & { position: { x: number; y: number } };
|
||||
|
||||
const handleCouponUpdate = <T extends keyof CouponScreenDefinition["coupon"]>(
|
||||
field: T,
|
||||
value: CouponScreenDefinition["coupon"][T]
|
||||
) => {
|
||||
onUpdate({
|
||||
coupon: {
|
||||
...couponScreen.coupon,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Настройки оффера
|
||||
</h3>
|
||||
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
|
||||
Заголовок оффера
|
||||
<TextInput
|
||||
placeholder="-50% на первый заказ"
|
||||
value={couponScreen.coupon?.offer?.title?.text ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCouponUpdate("offer", {
|
||||
...couponScreen.coupon.offer,
|
||||
title: {
|
||||
...(couponScreen.coupon.offer?.title ?? {}),
|
||||
text: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
|
||||
Подзаголовок/описание
|
||||
<TextInput
|
||||
placeholder="Персональная акция только сегодня"
|
||||
value={couponScreen.coupon?.offer?.description?.text ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCouponUpdate("offer", {
|
||||
...couponScreen.coupon.offer,
|
||||
description: {
|
||||
...(couponScreen.coupon.offer?.description ?? {}),
|
||||
text: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">Промокод</h4>
|
||||
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
|
||||
Текст промокода
|
||||
<TextInput
|
||||
placeholder="SALE50"
|
||||
value={couponScreen.coupon?.promoCode?.text ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCouponUpdate("promoCode", {
|
||||
...(couponScreen.coupon.promoCode ?? {}),
|
||||
text: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
|
||||
Подпись под промокодом
|
||||
<TextInput
|
||||
placeholder="Нажмите, чтобы скопировать"
|
||||
value={couponScreen.coupon?.footer?.text ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCouponUpdate("footer", {
|
||||
...(couponScreen.coupon.footer ?? {}),
|
||||
text: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">Сообщение об успехе</h4>
|
||||
<TextInput
|
||||
placeholder="Промокод скопирован!"
|
||||
value={couponScreen.copiedMessage ?? ""}
|
||||
onChange={(event) => onUpdate({ copiedMessage: event.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
src/components/admin/builder/templates/DateScreenConfig.tsx
Normal file
209
src/components/admin/builder/templates/DateScreenConfig.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { DateScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface DateScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "date" };
|
||||
onUpdate: (updates: Partial<DateScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
|
||||
const dateScreen = screen as DateScreenDefinition & { position: { x: number; y: number } };
|
||||
|
||||
const handleDateInputChange = <T extends keyof DateScreenDefinition["dateInput"]>(
|
||||
field: T,
|
||||
value: DateScreenDefinition["dateInput"][T]
|
||||
) => {
|
||||
onUpdate({
|
||||
dateInput: {
|
||||
...dateScreen.dateInput,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleZodiacSettingsChange = (
|
||||
updates: Partial<NonNullable<DateScreenDefinition["dateInput"]["zodiac"]>>
|
||||
) => {
|
||||
const currentZodiac = dateScreen.dateInput?.zodiac ?? {
|
||||
enabled: false,
|
||||
storageKey: "",
|
||||
};
|
||||
|
||||
const nextZodiac = {
|
||||
...currentZodiac,
|
||||
...updates,
|
||||
};
|
||||
|
||||
const shouldRemove =
|
||||
(nextZodiac.enabled ?? false) === false && (nextZodiac.storageKey ?? "") === "";
|
||||
|
||||
onUpdate({
|
||||
dateInput: {
|
||||
...dateScreen.dateInput,
|
||||
zodiac: shouldRemove ? undefined : nextZodiac,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleInfoMessageChange = (field: "text" | "icon", value: string) => {
|
||||
const baseInfo = dateScreen.infoMessage ?? { text: "", icon: "ℹ️" };
|
||||
const nextInfo = { ...baseInfo, [field]: value };
|
||||
|
||||
if (!nextInfo.text) {
|
||||
onUpdate({ infoMessage: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate({ infoMessage: nextInfo });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Поля ввода даты
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Подпись месяца
|
||||
<TextInput
|
||||
value={dateScreen.dateInput?.monthLabel ?? ""}
|
||||
onChange={(event) => handleDateInputChange("monthLabel", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Подпись дня
|
||||
<TextInput
|
||||
value={dateScreen.dateInput?.dayLabel ?? ""}
|
||||
onChange={(event) => handleDateInputChange("dayLabel", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Подпись года
|
||||
<TextInput
|
||||
value={dateScreen.dateInput?.yearLabel ?? ""}
|
||||
onChange={(event) => handleDateInputChange("yearLabel", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Placeholder месяца
|
||||
<TextInput
|
||||
value={dateScreen.dateInput?.monthPlaceholder ?? ""}
|
||||
onChange={(event) => handleDateInputChange("monthPlaceholder", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Placeholder дня
|
||||
<TextInput
|
||||
value={dateScreen.dateInput?.dayPlaceholder ?? ""}
|
||||
onChange={(event) => handleDateInputChange("dayPlaceholder", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Placeholder года
|
||||
<TextInput
|
||||
value={dateScreen.dateInput?.yearPlaceholder ?? ""}
|
||||
onChange={(event) => handleDateInputChange("yearPlaceholder", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">Поведение поля</h4>
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dateScreen.dateInput?.showSelectedDate === true}
|
||||
onChange={(event) => handleDateInputChange("showSelectedDate", event.target.checked)}
|
||||
/>
|
||||
Показывать выбранную дату под полем
|
||||
</label>
|
||||
|
||||
<div className="rounded-xl border border-border/60 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dateScreen.dateInput?.zodiac?.enabled === true}
|
||||
onChange={(event) =>
|
||||
handleZodiacSettingsChange({ enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
Автоматически определять знак зодиака
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Если включено, система вычислит знак зодиака по выбранной дате и сохранит его по
|
||||
указанному ключу. Значение можно использовать в правилах навигации и вариативности.
|
||||
</p>
|
||||
|
||||
{dateScreen.dateInput?.zodiac?.enabled && (
|
||||
<label className="mt-3 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
Ключ для сохранения знака зодиака
|
||||
<TextInput
|
||||
placeholder="Например, userZodiac"
|
||||
value={dateScreen.dateInput?.zodiac?.storageKey ?? ""}
|
||||
onChange={(event) =>
|
||||
handleZodiacSettingsChange({ storageKey: event.target.value.trim() })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Подпись выбранной даты
|
||||
<TextInput
|
||||
value={dateScreen.dateInput?.selectedDateLabel ?? ""}
|
||||
onChange={(event) => handleDateInputChange("selectedDateLabel", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Формат отображения (date-fns)
|
||||
<TextInput
|
||||
placeholder="MMMM d, yyyy"
|
||||
value={dateScreen.dateInput?.selectedDateFormat ?? ""}
|
||||
onChange={(event) => handleDateInputChange("selectedDateFormat", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
Текст ошибки валидации
|
||||
<TextInput
|
||||
placeholder="Пожалуйста, укажите корректную дату"
|
||||
value={dateScreen.dateInput?.validationMessage ?? ""}
|
||||
onChange={(event) => handleDateInputChange("validationMessage", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">Информационный блок</h4>
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Сообщение (оставьте пустым, чтобы скрыть)</span>
|
||||
<TextInput
|
||||
value={dateScreen.infoMessage?.text ?? ""}
|
||||
onChange={(event) => handleInfoMessageChange("text", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{dateScreen.infoMessage && (
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Emoji/иконка для сообщения</span>
|
||||
<TextInput
|
||||
value={dateScreen.infoMessage.icon ?? ""}
|
||||
onChange={(event) => handleInfoMessageChange("icon", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/components/admin/builder/templates/FormScreenConfig.tsx
Normal file
237
src/components/admin/builder/templates/FormScreenConfig.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { FormScreenDefinition, FormFieldDefinition, FormValidationMessages } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface FormScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "form" };
|
||||
onUpdate: (updates: Partial<FormScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
|
||||
const formScreen = screen as FormScreenDefinition & { position: { x: number; y: number } };
|
||||
|
||||
const updateField = (index: number, updates: Partial<FormFieldDefinition>) => {
|
||||
const newFields = [...(formScreen.fields || [])];
|
||||
newFields[index] = { ...newFields[index], ...updates };
|
||||
onUpdate({ fields: newFields });
|
||||
};
|
||||
|
||||
const updateValidationMessages = (updates: Partial<FormValidationMessages>) => {
|
||||
onUpdate({
|
||||
validationMessages: {
|
||||
...(formScreen.validationMessages ?? {}),
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
const newField: FormFieldDefinition = {
|
||||
id: `field_${Date.now()}`,
|
||||
label: "Новое поле",
|
||||
placeholder: "Введите значение",
|
||||
type: "text",
|
||||
required: true,
|
||||
};
|
||||
|
||||
onUpdate({
|
||||
fields: [...(formScreen.fields || []), newField],
|
||||
});
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
const newFields = formScreen.fields?.filter((_, i) => i !== index) || [];
|
||||
onUpdate({ fields: newFields });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Поля формы</h3>
|
||||
<Button onClick={addField} variant="outline" className="h-8 w-8 p-0 flex items-center justify-center">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formScreen.fields?.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2 rounded-lg border border-border/50 bg-muted/5 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Поле {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-3 text-xs text-destructive"
|
||||
onClick={() => removeField(index)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
ID поля
|
||||
<TextInput value={field.id} onChange={(event) => updateField(index, { id: event.target.value })} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Тип
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
value={field.type ?? "text"}
|
||||
onChange={(event) => updateField(index, { type: event.target.value as FormFieldDefinition["type"] })}
|
||||
>
|
||||
<option value="text">Текст</option>
|
||||
<option value="email">E-mail</option>
|
||||
<option value="tel">Телефон</option>
|
||||
<option value="url">Ссылка</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
Метка поля
|
||||
<TextInput
|
||||
value={field.label ?? ""}
|
||||
onChange={(event) => updateField(index, { label: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
Placeholder
|
||||
<TextInput
|
||||
value={field.placeholder ?? ""}
|
||||
onChange={(event) => updateField(index, { placeholder: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required ?? false}
|
||||
onChange={(event) => updateField(index, { required: event.target.checked })}
|
||||
/>
|
||||
Обязательно для заполнения
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Максимальная длина
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
value={field.maxLength ?? ""}
|
||||
onChange={(event) =>
|
||||
updateField(index, {
|
||||
maxLength: event.target.value ? Number(event.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Регулярное выражение (pattern)
|
||||
<TextInput
|
||||
placeholder="Например, ^\\d+$"
|
||||
value={field.validation?.pattern ?? ""}
|
||||
onChange={(event) =>
|
||||
updateField(index, {
|
||||
validation: {
|
||||
...(field.validation ?? {}),
|
||||
pattern: event.target.value || undefined,
|
||||
message: field.validation?.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Текст ошибки для pattern
|
||||
<TextInput
|
||||
placeholder="Неверный формат"
|
||||
value={field.validation?.message ?? ""}
|
||||
onChange={(event) =>
|
||||
updateField(index, {
|
||||
validation:
|
||||
field.validation || event.target.value
|
||||
? {
|
||||
...(field.validation ?? {}),
|
||||
message: event.target.value || undefined,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!formScreen.fields || formScreen.fields.length === 0) && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-4 text-center text-sm text-muted-foreground">
|
||||
Пока нет полей. Добавьте хотя бы одно, чтобы форма работала.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">Сообщения валидации</h4>
|
||||
<div className="space-y-4 text-xs">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
<label className="flex flex-col gap-2 text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">Обязательное поле</span>
|
||||
<p className="text-xs mt-1 text-muted-foreground/80">
|
||||
Доступна переменная: <code className="bg-muted px-1 rounded">{`{field}`}</code> - название поля
|
||||
</p>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Пример: {field} обязательно для заполнения"
|
||||
value={formScreen.validationMessages?.required ?? ""}
|
||||
onChange={(event) => updateValidationMessages({ required: event.target.value || undefined })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
<label className="flex flex-col gap-2 text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">Превышена длина</span>
|
||||
<p className="text-xs mt-1 text-muted-foreground/80">
|
||||
Доступны переменные: <code className="bg-muted px-1 rounded">{`{field}`}</code>, <code className="bg-muted px-1 rounded">{`{maxLength}`}</code>
|
||||
</p>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Пример: {field} не может быть длиннее {maxLength} символов"
|
||||
value={formScreen.validationMessages?.maxLength ?? ""}
|
||||
onChange={(event) => updateValidationMessages({ maxLength: event.target.value || undefined })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
<label className="flex flex-col gap-2 text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">Неверный формат</span>
|
||||
<p className="text-xs mt-1 text-muted-foreground/80">
|
||||
Доступна переменная: <code className="bg-muted px-1 rounded">{`{field}`}</code> - название поля
|
||||
</p>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Пример: Проверьте формат {field}"
|
||||
value={formScreen.validationMessages?.invalidFormat ?? ""}
|
||||
onChange={(event) => updateValidationMessages({ invalidFormat: event.target.value || undefined })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/components/admin/builder/templates/InfoScreenConfig.tsx
Normal file
104
src/components/admin/builder/templates/InfoScreenConfig.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface InfoScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "info" };
|
||||
onUpdate: (updates: Partial<InfoScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
|
||||
const infoScreen = screen as InfoScreenDefinition & { position: { x: number; y: number } };
|
||||
|
||||
const handleDescriptionChange = (text: string) => {
|
||||
onUpdate({
|
||||
description: text
|
||||
? {
|
||||
...(infoScreen.description ?? {}),
|
||||
text,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleIconChange = <T extends keyof NonNullable<InfoScreenDefinition["icon"]>>(
|
||||
field: T,
|
||||
value: NonNullable<InfoScreenDefinition["icon"]>[T] | undefined
|
||||
) => {
|
||||
const baseIcon = infoScreen.icon ?? { type: "emoji", value: "✨", size: "lg" };
|
||||
|
||||
if (field === "value") {
|
||||
if (!value) {
|
||||
onUpdate({ icon: undefined });
|
||||
} else {
|
||||
onUpdate({ icon: { ...baseIcon, value } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate({ icon: { ...baseIcon, [field]: value } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Информационный контент
|
||||
</h3>
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
|
||||
<TextInput
|
||||
placeholder="Введите пояснение для пользователя"
|
||||
value={infoScreen.description?.text ?? ""}
|
||||
onChange={(event) => handleDescriptionChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">Иконка</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Тип иконки
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
value={infoScreen.icon?.type ?? "emoji"}
|
||||
onChange={(event) => handleIconChange("type", event.target.value as "emoji" | "image")}
|
||||
>
|
||||
<option value="emoji">Emoji</option>
|
||||
<option value="image">Изображение</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Размер
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
value={infoScreen.icon?.size ?? "lg"}
|
||||
onChange={(event) =>
|
||||
handleIconChange("size", event.target.value as "sm" | "md" | "lg" | "xl")
|
||||
}
|
||||
>
|
||||
<option value="sm">Маленький</option>
|
||||
<option value="md">Средний</option>
|
||||
<option value="lg">Большой</option>
|
||||
<option value="xl">Огромный</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{infoScreen.icon?.type === "image" ? "Ссылка на изображение" : "Emoji символ"}
|
||||
</span>
|
||||
<TextInput
|
||||
placeholder={infoScreen.icon?.type === "image" ? "https://..." : "Например, ✨"}
|
||||
value={infoScreen.icon?.value ?? ""}
|
||||
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
src/components/admin/builder/templates/ListScreenConfig.tsx
Normal file
291
src/components/admin/builder/templates/ListScreenConfig.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import type {
|
||||
ListScreenDefinition,
|
||||
ListOptionDefinition,
|
||||
SelectionType,
|
||||
} from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface ListScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "list" };
|
||||
onUpdate: (updates: Partial<ListScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
function mutateOptions(
|
||||
options: ListOptionDefinition[],
|
||||
index: number,
|
||||
mutation: (option: ListOptionDefinition) => ListOptionDefinition
|
||||
): ListOptionDefinition[] {
|
||||
return options.map((option, currentIndex) => (currentIndex === index ? mutation(option) : option));
|
||||
}
|
||||
|
||||
export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
|
||||
const listScreen = screen as ListScreenDefinition & { position: { x: number; y: number } };
|
||||
const [expandedOptions, setExpandedOptions] = useState<Set<number>>(new Set());
|
||||
|
||||
const toggleOptionExpanded = (index: number) => {
|
||||
const newExpanded = new Set(expandedOptions);
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index);
|
||||
} else {
|
||||
newExpanded.add(index);
|
||||
}
|
||||
setExpandedOptions(newExpanded);
|
||||
};
|
||||
|
||||
const handleSelectionTypeChange = (selectionType: SelectionType) => {
|
||||
onUpdate({
|
||||
list: {
|
||||
...listScreen.list,
|
||||
selectionType,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleOptionChange = (
|
||||
index: number,
|
||||
field: keyof ListOptionDefinition,
|
||||
value: string | boolean | undefined
|
||||
) => {
|
||||
const nextOptions = mutateOptions(listScreen.list.options, index, (option) => ({
|
||||
...option,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
onUpdate({
|
||||
list: {
|
||||
...listScreen.list,
|
||||
options: nextOptions,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveOption = (index: number, direction: -1 | 1) => {
|
||||
const nextOptions = [...listScreen.list.options];
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= nextOptions.length) {
|
||||
return;
|
||||
}
|
||||
const [current] = nextOptions.splice(index, 1);
|
||||
nextOptions.splice(targetIndex, 0, current);
|
||||
|
||||
onUpdate({
|
||||
list: {
|
||||
...listScreen.list,
|
||||
options: nextOptions,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddOption = () => {
|
||||
const nextOptions = [
|
||||
...listScreen.list.options,
|
||||
{
|
||||
id: `option-${Date.now()}`,
|
||||
label: "Новый вариант",
|
||||
},
|
||||
];
|
||||
|
||||
onUpdate({
|
||||
list: {
|
||||
...listScreen.list,
|
||||
options: nextOptions,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
const nextOptions = listScreen.list.options.filter((_, currentIndex) => currentIndex !== index);
|
||||
|
||||
onUpdate({
|
||||
list: {
|
||||
...listScreen.list,
|
||||
options: nextOptions,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Варианты выбора
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-3 py-1 transition ${
|
||||
listScreen.list.selectionType === "single"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "border border-border/60"
|
||||
}`}
|
||||
onClick={() => handleSelectionTypeChange("single")}
|
||||
>
|
||||
Один ответ
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md px-3 py-1 transition ${
|
||||
listScreen.list.selectionType === "multi"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "border border-border/60"
|
||||
}`}
|
||||
onClick={() => handleSelectionTypeChange("multi")}
|
||||
>
|
||||
Несколько ответов
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<p><strong>Автопереход:</strong> Для single selection списков без кнопки (bottomActionButton.show: false) включается автоматически.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-foreground">Настройка вариантов</h4>
|
||||
<Button variant="outline" className="h-8 w-8 p-0 flex items-center justify-center" onClick={handleAddOption}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{listScreen.list.options.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className="space-y-2 rounded-lg border border-border/50 bg-muted/5 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer flex-1"
|
||||
onClick={() => toggleOptionExpanded(index)}
|
||||
>
|
||||
{expandedOptions.has(index) ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Вариант {index + 1}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.label || `(Пустой вариант)`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border/70 p-1 text-muted-foreground transition hover:bg-muted"
|
||||
onClick={() => handleMoveOption(index, -1)}
|
||||
disabled={index === 0}
|
||||
title="Переместить выше"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border/70 p-1 text-muted-foreground transition hover:bg-muted"
|
||||
onClick={() => handleMoveOption(index, 1)}
|
||||
disabled={index === listScreen.list.options.length - 1}
|
||||
title="Переместить ниже"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 px-3 text-xs text-destructive"
|
||||
onClick={() => handleRemoveOption(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedOptions.has(index) && (
|
||||
<div className="space-y-3 ml-6">
|
||||
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||
ID варианта
|
||||
<TextInput
|
||||
value={option.id}
|
||||
onChange={(event) => handleOptionChange(index, "id", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||
Подпись для пользователя
|
||||
<TextInput
|
||||
value={option.label}
|
||||
onChange={(event) => handleOptionChange(index, "label", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||
Emoji/иконка (необязательно)
|
||||
<TextInput
|
||||
value={option.emoji ?? ""}
|
||||
onChange={(event) =>
|
||||
handleOptionChange(index, "emoji", event.target.value || undefined)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||
Описание (необязательно)
|
||||
<TextInput
|
||||
placeholder="Дополнительное описание варианта"
|
||||
value={option.description ?? ""}
|
||||
onChange={(event) =>
|
||||
handleOptionChange(index, "description", event.target.value || undefined)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||
Значение (необязательно)
|
||||
<TextInput
|
||||
placeholder="Машиночитаемое значение (по умолчанию = ID)"
|
||||
value={option.value ?? ""}
|
||||
onChange={(event) =>
|
||||
handleOptionChange(index, "value", event.target.value || undefined)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={option.disabled === true}
|
||||
onChange={(event) =>
|
||||
handleOptionChange(index, "disabled", event.target.checked || undefined)
|
||||
}
|
||||
/>
|
||||
Сделать вариант неактивным
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{listScreen.list.options.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
|
||||
Добавьте хотя бы один вариант, чтобы экран работал корректно.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<p><strong>Автопереход:</strong> Для single selection списков без кнопки (bottomActionButton.show: false) включается автоматически.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
427
src/components/admin/builder/templates/TemplateConfig.tsx
Normal file
427
src/components/admin/builder/templates/TemplateConfig.tsx
Normal file
@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { InfoScreenConfig } from "./InfoScreenConfig";
|
||||
import { DateScreenConfig } from "./DateScreenConfig";
|
||||
import { CouponScreenConfig } from "./CouponScreenConfig";
|
||||
import { FormScreenConfig } from "./FormScreenConfig";
|
||||
import { ListScreenConfig } from "./ListScreenConfig";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type {
|
||||
ScreenDefinition,
|
||||
InfoScreenDefinition,
|
||||
DateScreenDefinition,
|
||||
CouponScreenDefinition,
|
||||
FormScreenDefinition,
|
||||
ListScreenDefinition,
|
||||
TypographyVariant,
|
||||
BottomActionButtonDefinition,
|
||||
HeaderDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
|
||||
const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"];
|
||||
|
||||
interface TemplateConfigProps {
|
||||
screen: BuilderScreen;
|
||||
onUpdate: (updates: Partial<ScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
}) {
|
||||
const storageKey = `template-section-${title.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(() => {
|
||||
if (typeof window === 'undefined') return defaultExpanded;
|
||||
|
||||
const stored = sessionStorage.getItem(storageKey);
|
||||
return stored !== null ? JSON.parse(stored) : defaultExpanded;
|
||||
});
|
||||
|
||||
const handleToggle = () => {
|
||||
const newExpanded = !isExpanded;
|
||||
setIsExpanded(newExpanded);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="flex w-full items-center gap-2 text-left text-sm font-medium text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{title}
|
||||
</button>
|
||||
{isExpanded && <div className="ml-6 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TypographyControlsProps {
|
||||
label: string;
|
||||
value: TypographyVariant | undefined;
|
||||
onChange: (value: TypographyVariant | undefined) => void;
|
||||
allowRemove?: boolean;
|
||||
}
|
||||
|
||||
function TypographyControls({ label, value, onChange, allowRemove = false }: TypographyControlsProps) {
|
||||
const storageKey = `typography-advanced-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem(storageKey);
|
||||
if (stored !== null) {
|
||||
setShowAdvanced(JSON.parse(stored));
|
||||
}
|
||||
setIsHydrated(true);
|
||||
}, [storageKey]);
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
if (text.trim() === "" && allowRemove) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем существующие настройки или используем минимальные дефолты
|
||||
onChange({
|
||||
...value,
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdvancedChange = (field: keyof TypographyVariant, fieldValue: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
text: value?.text || "",
|
||||
[field]: fieldValue || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||
<TextInput value={value?.text ?? ""} onChange={(event) => handleTextChange(event.target.value)} />
|
||||
</div>
|
||||
|
||||
{value?.text && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newShowAdvanced = !showAdvanced;
|
||||
setShowAdvanced(newShowAdvanced);
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(newShowAdvanced));
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition"
|
||||
>
|
||||
{showAdvanced ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Настройки оформления
|
||||
</button>
|
||||
|
||||
{(isHydrated ? showAdvanced : false) && (
|
||||
<div className="ml-4 grid grid-cols-2 gap-2 text-xs">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">Шрифт</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1"
|
||||
value={value?.font ?? ""}
|
||||
onChange={(e) => handleAdvancedChange("font", e.target.value)}
|
||||
>
|
||||
<option value="">По умолчанию</option>
|
||||
<option value="manrope">Manrope</option>
|
||||
<option value="inter">Inter</option>
|
||||
<option value="geistSans">Geist Sans</option>
|
||||
<option value="geistMono">Geist Mono</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">Толщина</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1"
|
||||
value={value?.weight ?? ""}
|
||||
onChange={(e) => handleAdvancedChange("weight", e.target.value)}
|
||||
>
|
||||
<option value="">По умолчанию</option>
|
||||
<option value="regular">Regular</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="semiBold">Semi Bold</option>
|
||||
<option value="bold">Bold</option>
|
||||
<option value="extraBold">Extra Bold</option>
|
||||
<option value="black">Black</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">Выравнивание</span>
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1"
|
||||
value={value?.align ?? ""}
|
||||
onChange={(e) => handleAdvancedChange("align", e.target.value)}
|
||||
>
|
||||
<option value="">По умолчанию</option>
|
||||
<option value="left">Слева</option>
|
||||
<option value="center">По центру</option>
|
||||
<option value="right">Справа</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderControlsProps {
|
||||
header: HeaderDefinition | undefined;
|
||||
onChange: (value: HeaderDefinition | undefined) => void;
|
||||
}
|
||||
|
||||
function HeaderControls({ header, onChange }: HeaderControlsProps) {
|
||||
const activeHeader = header ?? { show: true, showBackButton: true };
|
||||
|
||||
const handleToggle = (field: "show" | "showBackButton", checked: boolean) => {
|
||||
if (field === "show" && !checked) {
|
||||
onChange({
|
||||
...activeHeader,
|
||||
show: false,
|
||||
showBackButton: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
...activeHeader,
|
||||
[field]: checked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeHeader.show !== false}
|
||||
onChange={(event) => handleToggle("show", event.target.checked)}
|
||||
/>
|
||||
Показывать шапку с прогрессом
|
||||
</label>
|
||||
|
||||
{activeHeader.show !== false && (
|
||||
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeHeader.showBackButton !== false}
|
||||
onChange={(event) => handleToggle("showBackButton", event.target.checked)}
|
||||
/>
|
||||
Показывать кнопку «Назад»
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonControlsProps {
|
||||
label: string;
|
||||
value: BottomActionButtonDefinition | undefined;
|
||||
onChange: (value: BottomActionButtonDefinition | undefined) => void;
|
||||
}
|
||||
|
||||
function ActionButtonControls({ label, value, onChange }: ActionButtonControlsProps) {
|
||||
// По умолчанию кнопка включена (show !== false)
|
||||
const isEnabled = value?.show !== false;
|
||||
const buttonText = value?.text || '';
|
||||
const cornerRadius = value?.cornerRadius;
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
// Включаем кнопку - убираем show: false или создаем объект
|
||||
const newValue = value ? { ...value, show: true } : { show: true };
|
||||
// Если show: true по умолчанию, можем убрать это поле
|
||||
if (newValue.show === true && !newValue.text && !newValue.cornerRadius) {
|
||||
onChange(undefined); // Дефолтное состояние
|
||||
} else {
|
||||
const { show, ...rest } = newValue;
|
||||
onChange(Object.keys(rest).length > 0 ? { show, ...rest } : undefined);
|
||||
}
|
||||
} else {
|
||||
// Отключаем кнопку
|
||||
onChange({ show: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
const trimmedText = text.trim();
|
||||
const newValue = {
|
||||
...value,
|
||||
text: trimmedText || undefined,
|
||||
};
|
||||
|
||||
// Убираем undefined поля для чистоты
|
||||
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRadiusChange = (radius: string) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
const newRadius = (radius as "3xl" | "full") || undefined;
|
||||
const newValue = {
|
||||
...value,
|
||||
cornerRadius: newRadius,
|
||||
};
|
||||
|
||||
// Убираем undefined поля для чистоты
|
||||
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(event) => handleToggle(event.target.checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
|
||||
{isEnabled && (
|
||||
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-muted-foreground">Текст кнопки</span>
|
||||
<TextInput
|
||||
value={buttonText}
|
||||
onChange={(event) => handleTextChange(event.target.value)}
|
||||
placeholder="Оставьте пустым для дефолтного текста"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-medium text-muted-foreground">Скругление</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||
value={cornerRadius ?? ""}
|
||||
onChange={(event) => handleRadiusChange(event.target.value)}
|
||||
>
|
||||
<option value="">По умолчанию</option>
|
||||
{RADIUS_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
|
||||
const { template } = screen;
|
||||
|
||||
const handleTitleChange = (value: TypographyVariant | undefined) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onUpdate({ title: value });
|
||||
};
|
||||
|
||||
const handleSubtitleChange = (value: TypographyVariant | undefined) => {
|
||||
onUpdate({ subtitle: value });
|
||||
};
|
||||
|
||||
const handleHeaderChange = (value: HeaderDefinition | undefined) => {
|
||||
onUpdate({ header: value });
|
||||
};
|
||||
|
||||
const handleButtonChange = (value: BottomActionButtonDefinition | undefined) => {
|
||||
onUpdate({ bottomActionButton: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="Заголовок и подзаголовок">
|
||||
<TypographyControls label="Заголовок" value={screen.title} onChange={handleTitleChange} />
|
||||
<TypographyControls label="Подзаголовок" value={"subtitle" in screen ? screen.subtitle : undefined} onChange={handleSubtitleChange} allowRemove />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Шапка экрана">
|
||||
<HeaderControls header={screen.header} onChange={handleHeaderChange} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="Нижняя кнопка">
|
||||
<ActionButtonControls label="Показывать основную кнопку" value={screen.bottomActionButton} onChange={handleButtonChange} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{template === "info" && (
|
||||
<InfoScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "info" }}
|
||||
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "date" && (
|
||||
<DateScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "date" }}
|
||||
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "coupon" && (
|
||||
<CouponScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "coupon" }}
|
||||
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "form" && (
|
||||
<FormScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "form" }}
|
||||
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "list" && (
|
||||
<ListScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "list" }}
|
||||
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/components/admin/builder/templates/index.ts
Normal file
6
src/components/admin/builder/templates/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { InfoScreenConfig } from "./InfoScreenConfig";
|
||||
export { DateScreenConfig } from "./DateScreenConfig";
|
||||
export { CouponScreenConfig } from "./CouponScreenConfig";
|
||||
export { FormScreenConfig } from "./FormScreenConfig";
|
||||
export { ListScreenConfig } from "./ListScreenConfig";
|
||||
export { TemplateConfig } from "./TemplateConfig";
|
||||
217
src/components/funnel/FunnelRuntime.tsx
Normal file
217
src/components/funnel/FunnelRuntime.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { resolveNextScreenId } from "@/lib/funnel/navigation";
|
||||
import { resolveScreenVariant } from "@/lib/funnel/variants";
|
||||
import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider";
|
||||
import { renderScreen } from "@/lib/funnel/screenRenderer";
|
||||
import type {
|
||||
FunnelDefinition,
|
||||
FunnelAnswers,
|
||||
ListScreenDefinition,
|
||||
DateScreenDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
import { getZodiacSign } from "@/lib/funnel/zodiac";
|
||||
|
||||
// Функция для оценки длины пути пользователя на основе текущих ответов
|
||||
function estimatePathLength(funnel: FunnelDefinition, answers: FunnelAnswers): number {
|
||||
const visited = new Set<string>();
|
||||
let currentScreenId = funnel.meta.firstScreenId || funnel.screens[0]?.id;
|
||||
|
||||
// Симулируем прохождение воронки с текущими ответами
|
||||
while (currentScreenId && !visited.has(currentScreenId)) {
|
||||
visited.add(currentScreenId);
|
||||
|
||||
const currentScreen = funnel.screens.find((s) => s.id === currentScreenId);
|
||||
if (!currentScreen) break;
|
||||
|
||||
const resolvedScreen = resolveScreenVariant(currentScreen, answers);
|
||||
const nextScreenId = resolveNextScreenId(resolvedScreen, answers, funnel.screens);
|
||||
|
||||
// Если достигли конца или зацикливание
|
||||
if (!nextScreenId || visited.has(nextScreenId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentScreenId = nextScreenId;
|
||||
}
|
||||
|
||||
return visited.size;
|
||||
}
|
||||
|
||||
interface FunnelRuntimeProps {
|
||||
funnel: FunnelDefinition;
|
||||
initialScreenId: string;
|
||||
}
|
||||
|
||||
function getScreenById(funnel: FunnelDefinition, screenId: string) {
|
||||
return funnel.screens.find((screen) => screen.id === screenId);
|
||||
}
|
||||
|
||||
export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||
const router = useRouter();
|
||||
const { answers, registerScreen, setAnswers, history } = useFunnelRuntime(
|
||||
funnel.meta.id
|
||||
);
|
||||
|
||||
const baseScreen = useMemo(() => {
|
||||
const screen = getScreenById(funnel, initialScreenId) ?? funnel.screens[0];
|
||||
if (!screen) {
|
||||
throw new Error("Funnel definition does not contain any screens");
|
||||
}
|
||||
return screen;
|
||||
}, [funnel, initialScreenId]);
|
||||
|
||||
const currentScreen = useMemo(() => {
|
||||
return resolveScreenVariant(baseScreen, answers);
|
||||
}, [baseScreen, answers]);
|
||||
|
||||
const selectedOptionIds = answers[currentScreen.id] ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
registerScreen(currentScreen.id);
|
||||
}, [currentScreen.id, registerScreen]);
|
||||
|
||||
const historyWithCurrent = useMemo(() => {
|
||||
if (history.length === 0) {
|
||||
return [currentScreen.id];
|
||||
}
|
||||
|
||||
const last = history[history.length - 1];
|
||||
if (last === currentScreen.id) {
|
||||
return history;
|
||||
}
|
||||
|
||||
const existingIndex = history.lastIndexOf(currentScreen.id);
|
||||
if (existingIndex >= 0) {
|
||||
return history.slice(0, existingIndex + 1);
|
||||
}
|
||||
|
||||
return [...history, currentScreen.id];
|
||||
}, [history, currentScreen.id]);
|
||||
|
||||
// Calculate automatic progress based on user's actual path
|
||||
const screenProgress = useMemo(() => {
|
||||
const total = estimatePathLength(funnel, answers);
|
||||
const current = historyWithCurrent.length; // Номер текущего экрана = количество посещенных
|
||||
return { current, total };
|
||||
}, [historyWithCurrent.length, funnel, answers]);
|
||||
|
||||
const goToScreen = (screenId: string | undefined) => {
|
||||
if (!screenId) {
|
||||
return;
|
||||
}
|
||||
router.push(`/${funnel.meta.id}/${screenId}`);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
const nextScreenId = resolveNextScreenId(currentScreen, answers, funnel.screens);
|
||||
goToScreen(nextScreenId);
|
||||
};
|
||||
|
||||
const handleSelectionChange = (ids: string[]) => {
|
||||
const prevSelectedIds = selectedOptionIds;
|
||||
const hasChanged =
|
||||
prevSelectedIds.length !== ids.length ||
|
||||
prevSelectedIds.some((value, index) => value !== ids[index]);
|
||||
|
||||
|
||||
// Check if this is a single selection list without action button
|
||||
const shouldAutoAdvance = currentScreen.template === "list" && (() => {
|
||||
const listScreen = currentScreen as ListScreenDefinition;
|
||||
const selectionType = listScreen.list.selectionType;
|
||||
|
||||
// Простая логика: автопереход если single selection и кнопка отключена
|
||||
const bottomActionButton = listScreen.bottomActionButton;
|
||||
const isButtonExplicitlyDisabled = bottomActionButton?.show === false;
|
||||
|
||||
return selectionType === "single" && isButtonExplicitlyDisabled && ids.length > 0;
|
||||
})();
|
||||
|
||||
// ПРАВИЛЬНОЕ РЕШЕНИЕ: Автопереход ТОЛЬКО при изменении значения
|
||||
// Это исключает автопереход при возврате назад, когда компоненты
|
||||
// восстанавливают состояние и вызывают callbacks без реального изменения
|
||||
const shouldProceed = hasChanged;
|
||||
|
||||
|
||||
if (!shouldProceed) {
|
||||
return; // Блокируем программные вызовы useEffect без изменений
|
||||
}
|
||||
|
||||
const nextAnswers = {
|
||||
...answers,
|
||||
[currentScreen.id]: ids,
|
||||
} as typeof answers;
|
||||
|
||||
if (ids.length === 0) {
|
||||
delete nextAnswers[currentScreen.id];
|
||||
}
|
||||
|
||||
// Only save answers if they actually changed
|
||||
if (hasChanged) {
|
||||
setAnswers(currentScreen.id, ids);
|
||||
}
|
||||
|
||||
if (currentScreen.template === "date") {
|
||||
const dateScreen = currentScreen as DateScreenDefinition;
|
||||
const zodiacSettings = dateScreen.dateInput?.zodiac;
|
||||
const storageKey = zodiacSettings?.storageKey?.trim();
|
||||
|
||||
if (storageKey) {
|
||||
if (zodiacSettings?.enabled) {
|
||||
const [monthValue, dayValue] = ids;
|
||||
const month = parseInt(monthValue ?? "", 10);
|
||||
const day = parseInt(dayValue ?? "", 10);
|
||||
const zodiac = Number.isNaN(month) || Number.isNaN(day)
|
||||
? null
|
||||
: getZodiacSign(month, day);
|
||||
|
||||
if (zodiac) {
|
||||
setAnswers(storageKey, [zodiac]);
|
||||
} else {
|
||||
setAnswers(storageKey, []);
|
||||
}
|
||||
} else {
|
||||
setAnswers(storageKey, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-advance for single selection without action button
|
||||
if (shouldAutoAdvance) {
|
||||
const nextScreenId = resolveNextScreenId(currentScreen, nextAnswers, funnel.screens);
|
||||
goToScreen(nextScreenId);
|
||||
}
|
||||
};
|
||||
|
||||
const onBack = () => {
|
||||
const currentIndex = historyWithCurrent.lastIndexOf(currentScreen.id);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
goToScreen(historyWithCurrent[currentIndex - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (historyWithCurrent.length > 1) {
|
||||
goToScreen(historyWithCurrent[historyWithCurrent.length - 2]);
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
};
|
||||
|
||||
const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0;
|
||||
|
||||
return renderScreen({
|
||||
screen: currentScreen,
|
||||
selectedOptionIds,
|
||||
onSelectionChange: handleSelectionChange,
|
||||
onContinue: handleContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts: funnel.defaultTexts,
|
||||
});
|
||||
}
|
||||
151
src/components/funnel/templates/CouponTemplate.tsx
Normal file
151
src/components/funnel/templates/CouponTemplate.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import { Coupon } from "@/components/widgets/Coupon/Coupon";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import {
|
||||
buildLayoutQuestionProps,
|
||||
buildTypographyProps,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { CouponScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface CouponTemplateProps {
|
||||
screen: CouponScreenDefinition;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress?: { current: number; total: number };
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
export function CouponTemplate({
|
||||
screen,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: CouponTemplateProps) {
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
|
||||
|
||||
const handleCopyPromoCode = (code: string) => {
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedCode(code);
|
||||
|
||||
// Reset copied state after 2 seconds
|
||||
setTimeout(() => {
|
||||
setCopiedCode(null);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||
screen,
|
||||
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
|
||||
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "center" },
|
||||
canGoBack,
|
||||
onBack,
|
||||
actionButtonOptions: {
|
||||
defaultText: defaultTexts?.continueButton || "Continue",
|
||||
disabled: false,
|
||||
onClick: onContinue,
|
||||
},
|
||||
screenProgress,
|
||||
});
|
||||
|
||||
// Build coupon props from screen definition
|
||||
const couponProps = {
|
||||
title: buildTypographyProps(screen.coupon.title, {
|
||||
as: "h3" as const,
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
color: "primary",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h3" as const,
|
||||
children: screen.coupon.title.text,
|
||||
},
|
||||
offer: {
|
||||
title: buildTypographyProps(screen.coupon.offer.title, {
|
||||
as: "h3" as const,
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "black",
|
||||
color: "card",
|
||||
size: "4xl",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h3" as const,
|
||||
children: screen.coupon.offer.title.text,
|
||||
},
|
||||
description: buildTypographyProps(screen.coupon.offer.description, {
|
||||
as: "p" as const,
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "semiBold",
|
||||
color: "card",
|
||||
},
|
||||
}) ?? {
|
||||
as: "p" as const,
|
||||
children: screen.coupon.offer.description.text,
|
||||
},
|
||||
},
|
||||
promoCode: buildTypographyProps(screen.coupon.promoCode, {
|
||||
as: "span" as const,
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "semiBold",
|
||||
},
|
||||
}) ?? {
|
||||
as: "span" as const,
|
||||
children: screen.coupon.promoCode.text,
|
||||
},
|
||||
footer: buildTypographyProps(screen.coupon.footer, {
|
||||
as: "p" as const,
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "muted",
|
||||
size: "sm",
|
||||
},
|
||||
}) ?? {
|
||||
as: "p" as const,
|
||||
children: screen.coupon.footer.text,
|
||||
},
|
||||
onCopyPromoCode: handleCopyPromoCode,
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full flex flex-col items-center justify-center mt-[30px]">
|
||||
{/* Coupon Widget */}
|
||||
<div className="mb-8">
|
||||
<Coupon {...couponProps} />
|
||||
</div>
|
||||
|
||||
{/* Copy Success Message */}
|
||||
{copiedCode && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<Typography
|
||||
as="p"
|
||||
size="sm"
|
||||
color="success"
|
||||
weight="medium"
|
||||
align="center"
|
||||
>
|
||||
{screen.copiedMessage
|
||||
? screen.copiedMessage.replace("{code}", copiedCode || "")
|
||||
: `Промокод "${copiedCode}" скопирован!`
|
||||
}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
271
src/components/funnel/templates/DateTemplate.tsx
Normal file
271
src/components/funnel/templates/DateTemplate.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import NextImage from "next/image";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import {
|
||||
buildLayoutQuestionProps,
|
||||
buildTypographyProps,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { DateScreenDefinition } from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateTemplateProps {
|
||||
screen: DateScreenDefinition;
|
||||
selectedDate: { month?: string; day?: string; year?: string };
|
||||
onDateChange: (date: { month?: string; day?: string; year?: string }) => void;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress?: { current: number; total: number };
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
];
|
||||
|
||||
// Generate options for selects
|
||||
const generateMonthOptions = () => {
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const value = (i + 1).toString();
|
||||
return { value, label: value.padStart(2, '0') };
|
||||
});
|
||||
};
|
||||
|
||||
const generateDayOptions = (month: string, year: string) => {
|
||||
const monthNum = parseInt(month) || 1;
|
||||
const yearNum = parseInt(year) || new Date().getFullYear();
|
||||
const daysInMonth = new Date(yearNum, monthNum, 0).getDate();
|
||||
|
||||
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const value = (i + 1).toString();
|
||||
return { value, label: value.padStart(2, '0') };
|
||||
});
|
||||
};
|
||||
|
||||
const generateYearOptions = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startYear = 1920;
|
||||
const endYear = currentYear + 1;
|
||||
|
||||
const years = [];
|
||||
for (let year = endYear; year >= startYear; year--) {
|
||||
years.push({ value: year.toString(), label: year.toString() });
|
||||
}
|
||||
return years;
|
||||
};
|
||||
|
||||
export function DateTemplate({
|
||||
screen,
|
||||
selectedDate,
|
||||
onDateChange,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: DateTemplateProps) {
|
||||
const [month, setMonth] = useState(selectedDate.month || "");
|
||||
const [day, setDay] = useState(selectedDate.day || "");
|
||||
const [year, setYear] = useState(selectedDate.year || "");
|
||||
|
||||
// Generate options with memoization
|
||||
const monthOptions = useMemo(() => generateMonthOptions(), []);
|
||||
const dayOptions = useMemo(() => generateDayOptions(month, year), [month, year]);
|
||||
const yearOptions = useMemo(() => generateYearOptions(), []);
|
||||
|
||||
|
||||
// Custom Select component matching TextInput styling
|
||||
const SelectInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left",
|
||||
"bg-white border border-slate-200 rounded-xl",
|
||||
"text-slate-900 placeholder:text-slate-400",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
||||
"transition-colors duration-200",
|
||||
"appearance-none cursor-pointer",
|
||||
"bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNiIgdmlld0JveD0iMCAwIDEwIDYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik05IDFMNS4wMDAwNyA1TDEgMSIgc3Ryb2tlPSIjNjQ3NDhCIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=')] bg-no-repeat bg-right-3 bg-center",
|
||||
"pr-10"
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Update parent when local state changes
|
||||
useEffect(() => {
|
||||
onDateChange({ month, day, year });
|
||||
}, [month, day, year, onDateChange]);
|
||||
|
||||
// Reset day if it's invalid for the selected month/year
|
||||
useEffect(() => {
|
||||
if (month && year && day) {
|
||||
const monthNum = parseInt(month);
|
||||
const yearNum = parseInt(year);
|
||||
const dayNum = parseInt(day);
|
||||
const daysInMonth = new Date(yearNum, monthNum, 0).getDate();
|
||||
|
||||
if (dayNum > daysInMonth) {
|
||||
setDay("");
|
||||
}
|
||||
}
|
||||
}, [month, year, day]);
|
||||
|
||||
// Sync with external state
|
||||
useEffect(() => {
|
||||
setMonth(selectedDate.month || "");
|
||||
setDay(selectedDate.day || "");
|
||||
setYear(selectedDate.year || "");
|
||||
}, [selectedDate]);
|
||||
|
||||
const isComplete = month && day && year;
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!month || !day || !year) return null;
|
||||
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
const yearNum = parseInt(year);
|
||||
|
||||
if (monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31) {
|
||||
const monthName = MONTH_NAMES[monthNum - 1];
|
||||
return `${monthName} ${dayNum}, ${yearNum}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [month, day, year]);
|
||||
|
||||
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||
screen,
|
||||
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
|
||||
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||
canGoBack,
|
||||
onBack,
|
||||
actionButtonOptions: {
|
||||
defaultText: defaultTexts?.nextButton || "Next",
|
||||
disabled: !isComplete,
|
||||
onClick: onContinue,
|
||||
},
|
||||
screenProgress,
|
||||
});
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full mt-[30px] space-y-6">
|
||||
{/* Date Input Fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-[1fr_1fr_1.2fr] gap-3">
|
||||
<SelectInput
|
||||
label={screen.dateInput.monthLabel || "Month"}
|
||||
placeholder={screen.dateInput.monthPlaceholder || "MM"}
|
||||
value={month}
|
||||
onChange={setMonth}
|
||||
options={monthOptions}
|
||||
/>
|
||||
<SelectInput
|
||||
label={screen.dateInput.dayLabel || "Day"}
|
||||
placeholder={screen.dateInput.dayPlaceholder || "DD"}
|
||||
value={day}
|
||||
onChange={setDay}
|
||||
options={dayOptions}
|
||||
/>
|
||||
<SelectInput
|
||||
label={screen.dateInput.yearLabel || "Year"}
|
||||
placeholder={screen.dateInput.yearPlaceholder || "YYYY"}
|
||||
value={year}
|
||||
onChange={setYear}
|
||||
options={yearOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Message */}
|
||||
{screen.infoMessage && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<NextImage
|
||||
src="/GuardIcon.svg"
|
||||
alt="Security icon"
|
||||
width={20}
|
||||
height={20}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Typography
|
||||
as="p"
|
||||
size="sm"
|
||||
color="default"
|
||||
{...buildTypographyProps(screen.infoMessage, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "default",
|
||||
align: "left",
|
||||
},
|
||||
})}
|
||||
className={cn("text-slate-600 leading-relaxed", screen.infoMessage.className)}
|
||||
>
|
||||
{screen.infoMessage.text}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Selected Date Display - positioned 18px above button with high z-index */}
|
||||
{screen.dateInput.showSelectedDate && formattedDate && (
|
||||
<div className="fixed bottom-[98px] left-0 right-0 text-center z-50">
|
||||
<div className="max-w-[560px] mx-auto px-6">
|
||||
<Typography
|
||||
as="p"
|
||||
className="text-[#64748B] text-[16px] font-normal leading-normal mb-1"
|
||||
>
|
||||
{screen.dateInput.selectedDateLabel || "Selected date:"}
|
||||
</Typography>
|
||||
<Typography
|
||||
as="p"
|
||||
className="text-[#1E293B] text-[18px] font-semibold leading-normal"
|
||||
>
|
||||
{formattedDate}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
150
src/components/funnel/templates/FormTemplate.tsx
Normal file
150
src/components/funnel/templates/FormTemplate.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
|
||||
import {
|
||||
buildLayoutQuestionProps,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { FormScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface FormTemplateProps {
|
||||
screen: FormScreenDefinition;
|
||||
formData: Record<string, string>;
|
||||
onFormDataChange: (data: Record<string, string>) => void;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress?: { current: number; total: number };
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
export function FormTemplate({
|
||||
screen,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: FormTemplateProps) {
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Sync with external form data
|
||||
useEffect(() => {
|
||||
setLocalFormData(formData);
|
||||
}, [formData]);
|
||||
|
||||
// Update external form data when local data changes
|
||||
useEffect(() => {
|
||||
onFormDataChange(localFormData);
|
||||
}, [localFormData, onFormDataChange]);
|
||||
|
||||
const validateField = (fieldId: string, value: string): string | null => {
|
||||
const field = screen.fields.find(f => f.id === fieldId);
|
||||
if (!field) return null;
|
||||
|
||||
if (field.required && !value.trim()) {
|
||||
return screen.validationMessages?.required?.replace('${field}', field.label || field.id) || `${field.label || field.id} is required`;
|
||||
}
|
||||
|
||||
if (field.maxLength && value.length > field.maxLength) {
|
||||
return screen.validationMessages?.maxLength?.replace('${maxLength}', String(field.maxLength)) || `Maximum ${field.maxLength} characters allowed`;
|
||||
}
|
||||
|
||||
if (field.validation?.pattern) {
|
||||
const regex = new RegExp(field.validation.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return field.validation.message || screen.validationMessages?.invalidFormat || "Invalid format";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldId: string, value: string) => {
|
||||
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
|
||||
|
||||
// Clear error if field becomes valid
|
||||
if (errors[fieldId]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldId];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
screen.fields.forEach(field => {
|
||||
const value = localFormData[field.id] || "";
|
||||
const error = validateField(field.id, value);
|
||||
if (error) {
|
||||
newErrors[field.id] = error;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (validateForm()) {
|
||||
onContinue();
|
||||
}
|
||||
};
|
||||
|
||||
const isFormComplete = screen.fields.every(field => {
|
||||
const value = localFormData[field.id] || "";
|
||||
if (field.required) {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||
screen,
|
||||
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
|
||||
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||
canGoBack,
|
||||
onBack,
|
||||
actionButtonOptions: {
|
||||
defaultText: defaultTexts?.continueButton || "Continue",
|
||||
disabled: !isFormComplete,
|
||||
onClick: handleContinue,
|
||||
},
|
||||
screenProgress,
|
||||
});
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full mt-[30px] space-y-4">
|
||||
{screen.fields.map((field) => (
|
||||
<div key={field.id}>
|
||||
<TextInput
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
type={field.type || "text"}
|
||||
value={localFormData[field.id] || ""}
|
||||
onChange={(e) => handleFieldChange(field.id, e.target.value)}
|
||||
maxLength={field.maxLength}
|
||||
aria-invalid={!!errors[field.id]}
|
||||
aria-errormessage={errors[field.id]}
|
||||
/>
|
||||
{errors[field.id] && (
|
||||
<p className="text-destructive font-inter font-medium text-xs mt-1">
|
||||
{errors[field.id]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
121
src/components/funnel/templates/InfoTemplate.tsx
Normal file
121
src/components/funnel/templates/InfoTemplate.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import {
|
||||
buildLayoutQuestionProps,
|
||||
buildTypographyProps,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InfoTemplateProps {
|
||||
screen: InfoScreenDefinition;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress?: { current: number; total: number };
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
export function InfoTemplate({
|
||||
screen,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: InfoTemplateProps) {
|
||||
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||
screen,
|
||||
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
|
||||
canGoBack,
|
||||
onBack,
|
||||
actionButtonOptions: {
|
||||
defaultText: defaultTexts?.nextButton || "Next",
|
||||
disabled: false,
|
||||
onClick: onContinue,
|
||||
},
|
||||
screenProgress,
|
||||
});
|
||||
|
||||
const iconSizeClasses = useMemo(() => {
|
||||
const size = screen.icon?.size ?? "xl";
|
||||
switch (size) {
|
||||
case "sm":
|
||||
return "text-4xl"; // 36px
|
||||
case "md":
|
||||
return "text-5xl"; // 48px
|
||||
case "lg":
|
||||
return "text-6xl"; // 60px
|
||||
case "xl":
|
||||
default:
|
||||
return "text-8xl"; // 128px
|
||||
}
|
||||
}, [screen.icon?.size]);
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full flex flex-col items-center justify-center text-center mt-[60px]">
|
||||
{/* Icon */}
|
||||
{screen.icon && (
|
||||
<div className={cn("mb-8", screen.icon.className)}>
|
||||
{screen.icon.type === "emoji" ? (
|
||||
<div className={cn(iconSizeClasses, "leading-none")}>
|
||||
{screen.icon.value}
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src={screen.icon.value}
|
||||
alt=""
|
||||
width={
|
||||
iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}
|
||||
height={
|
||||
iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}
|
||||
className={cn("object-contain")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title - handled by LayoutQuestion */}
|
||||
|
||||
{/* Description */}
|
||||
{screen.description && (
|
||||
<div className="mt-6 max-w-[280px]">
|
||||
<Typography
|
||||
as="p"
|
||||
font="inter"
|
||||
weight="medium"
|
||||
color="default"
|
||||
size="lg"
|
||||
align="center"
|
||||
{...buildTypographyProps(screen.description, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "default",
|
||||
align: "center",
|
||||
},
|
||||
})}
|
||||
className={cn("leading-[26px]", screen.description.className)}
|
||||
>
|
||||
{screen.description.text}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
131
src/components/funnel/templates/ListTemplate.tsx
Normal file
131
src/components/funnel/templates/ListTemplate.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Question } from "@/components/templates/Question/Question";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
|
||||
import type { RadioAnswersListProps } from "@/components/widgets/RadioAnswersList/RadioAnswersList";
|
||||
import type { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
|
||||
|
||||
import {
|
||||
buildLayoutQuestionProps,
|
||||
mapListOptionsToButtons,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { ListScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface ListTemplateProps {
|
||||
screen: ListScreenDefinition;
|
||||
selectedOptionIds: string[];
|
||||
onSelectionChange: (selectedIds: string[]) => void;
|
||||
actionButtonProps?: ActionButtonProps;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress?: { current: number; total: number };
|
||||
}
|
||||
|
||||
function stringId(value: MainButtonProps["id"]): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function ListTemplate({
|
||||
screen,
|
||||
selectedOptionIds,
|
||||
onSelectionChange,
|
||||
actionButtonProps,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
}: ListTemplateProps) {
|
||||
|
||||
const buttons = useMemo(
|
||||
() => mapListOptionsToButtons(screen.list.options, screen.list.selectionType),
|
||||
[screen.list.options, screen.list.selectionType]
|
||||
);
|
||||
|
||||
const selectionSet = useMemo(
|
||||
() => new Set(selectedOptionIds.map((id) => String(id))),
|
||||
[selectedOptionIds]
|
||||
);
|
||||
|
||||
const contentType: "radio-answers-list" | "select-answers-list" =
|
||||
screen.list.selectionType === "multi"
|
||||
? "select-answers-list"
|
||||
: "radio-answers-list";
|
||||
|
||||
const activeAnswer: MainButtonProps | null =
|
||||
contentType === "radio-answers-list"
|
||||
? buttons.find((button) => selectionSet.has(String(button.id))) ?? null
|
||||
: null;
|
||||
|
||||
const activeAnswers: MainButtonProps[] | null =
|
||||
contentType === "select-answers-list"
|
||||
? buttons.filter((button) => selectionSet.has(String(button.id)))
|
||||
: null;
|
||||
|
||||
const handleRadioChange: RadioAnswersListProps["onChangeSelectedAnswer"] = (
|
||||
answer
|
||||
) => {
|
||||
const id = stringId(answer?.id);
|
||||
onSelectionChange(id ? [id] : []);
|
||||
};
|
||||
|
||||
const handleSelectChange: SelectAnswersListProps["onChangeSelectedAnswers"] = (
|
||||
answers
|
||||
) => {
|
||||
const ids = answers
|
||||
?.map((answer) => stringId(answer.id))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
onSelectionChange(ids ?? []);
|
||||
};
|
||||
|
||||
const radioContent: RadioAnswersListProps = {
|
||||
answers: buttons,
|
||||
activeAnswer,
|
||||
onChangeSelectedAnswer: handleRadioChange,
|
||||
};
|
||||
|
||||
const selectContent: SelectAnswersListProps = {
|
||||
answers: buttons,
|
||||
activeAnswers,
|
||||
onChangeSelectedAnswers: handleSelectChange,
|
||||
};
|
||||
|
||||
// Определяем action button options для centralized логики только если кнопка нужна
|
||||
const actionButtonOptions = actionButtonProps ? {
|
||||
defaultText: actionButtonProps.children as string || "Next",
|
||||
disabled: actionButtonProps.disabled || false,
|
||||
onClick: () => {
|
||||
if (actionButtonProps.onClick) {
|
||||
actionButtonProps.onClick({} as React.MouseEvent<HTMLButtonElement>);
|
||||
}
|
||||
},
|
||||
} : undefined;
|
||||
|
||||
|
||||
|
||||
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||
screen,
|
||||
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
|
||||
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||
canGoBack,
|
||||
onBack,
|
||||
actionButtonOptions: actionButtonOptions,
|
||||
screenProgress,
|
||||
});
|
||||
|
||||
const contentProps =
|
||||
contentType === "radio-answers-list" ? radioContent : selectContent;
|
||||
|
||||
return (
|
||||
<Question
|
||||
layoutQuestionProps={layoutQuestionProps}
|
||||
contentType={contentType}
|
||||
content={contentProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -8,22 +8,36 @@ import { Button } from "@/components/ui/button";
|
||||
interface HeaderProps extends React.ComponentProps<"header"> {
|
||||
progressProps?: React.ComponentProps<typeof Progress>;
|
||||
onBack?: () => void;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
function Header({ className, progressProps, onBack, ...props }: HeaderProps) {
|
||||
function Header({
|
||||
className,
|
||||
progressProps,
|
||||
onBack,
|
||||
showBackButton = true,
|
||||
...props
|
||||
}: HeaderProps) {
|
||||
const shouldRenderBackButton = showBackButton && typeof onBack === "function";
|
||||
|
||||
return (
|
||||
<header className={cn("w-full p-6 pb-3", className)} {...props}>
|
||||
<div className="w-full flex justify-left items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent rounded-full p-0! ml-[-13px] mb-[-9px]"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeft size={36} />
|
||||
</Button>
|
||||
<header className={cn("w-full p-6 pb-3 min-h-[96px]", className)} {...props}>
|
||||
<div className="w-full flex justify-start items-center min-h-9">
|
||||
{shouldRenderBackButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent rounded-full !p-0 -ml-[13px] -mb-[9px]"
|
||||
onClick={onBack}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ChevronLeft size={36} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{progressProps && (
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<div className="w-full flex justify-center items-center mt-3">
|
||||
<Progress {...progressProps} />
|
||||
</div>
|
||||
)}
|
||||
@ -31,4 +45,4 @@ function Header({ className, progressProps, onBack, ...props }: HeaderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Header };
|
||||
export { Header };
|
||||
@ -2,9 +2,7 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Header } from "@/components/layout/Header/Header";
|
||||
import Typography, {
|
||||
TypographyProps,
|
||||
} from "@/components/ui/Typography/Typography";
|
||||
import Typography, { TypographyProps } from "@/components/ui/Typography/Typography";
|
||||
|
||||
export interface LayoutQuestionProps
|
||||
extends Omit<React.ComponentProps<"section">, "title" | "content"> {
|
||||
@ -27,8 +25,17 @@ function LayoutQuestion({
|
||||
...props
|
||||
}: LayoutQuestionProps) {
|
||||
return (
|
||||
<section className={cn(`block min-h-dvh w-full`, className)} {...props}>
|
||||
<Header {...headerProps} />
|
||||
<section
|
||||
className={cn("block min-h-dvh w-full", className)}
|
||||
{...props}
|
||||
// Безопаснее, чем JS-константа: если CSS-переменная не задана — будет 0px
|
||||
style={{
|
||||
paddingBottom: "var(--bottom-action-button-height, 0px)",
|
||||
...(props.style ?? {}),
|
||||
}}
|
||||
>
|
||||
{headerProps && <Header {...headerProps} />}
|
||||
|
||||
<div
|
||||
{...contentProps}
|
||||
className={cn(
|
||||
@ -41,17 +48,18 @@ function LayoutQuestion({
|
||||
as="h2"
|
||||
font="manrope"
|
||||
weight="bold"
|
||||
align="left"
|
||||
{...title}
|
||||
className={cn(title.className, "text-[25px] leading-[38px]")}
|
||||
align={title.align ?? "left"}
|
||||
className={cn(title.className, "w-full text-[25px] leading-[38px]")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{subtitle && (
|
||||
<Typography
|
||||
as="p"
|
||||
weight="medium"
|
||||
align="left"
|
||||
{...subtitle}
|
||||
align={subtitle.align ?? "left"}
|
||||
className={cn(
|
||||
subtitle.className,
|
||||
"w-full mt-2.5 text-[17px] leading-[26px]"
|
||||
@ -70,4 +78,4 @@ function LayoutQuestion({
|
||||
);
|
||||
}
|
||||
|
||||
export { LayoutQuestion };
|
||||
export { LayoutQuestion };
|
||||
13
src/components/providers/AppProviders.tsx
Normal file
13
src/components/providers/AppProviders.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { FunnelProvider } from "@/lib/funnel/FunnelProvider";
|
||||
|
||||
interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
return <FunnelProvider>{children}</FunnelProvider>;
|
||||
}
|
||||
@ -28,12 +28,14 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
export type ActionButtonProps = React.ComponentProps<typeof Button> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
function ActionButton({
|
||||
className,
|
||||
cornerRadius,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> &
|
||||
VariantProps<typeof buttonVariants> & {}) {
|
||||
}: ActionButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="action-button"
|
||||
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@ -1,37 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import { GradientBlur } from "../GradientBlur/GradientBlur";
|
||||
import { ActionButton } from "@/components/ui/ActionButton/ActionButton";
|
||||
|
||||
export interface BottomActionButtonProps extends React.ComponentProps<"div"> {
|
||||
actionButtonProps?: React.ComponentProps<typeof ActionButton>;
|
||||
/** Контент над кнопкой (например подсказка) */
|
||||
childrenAboveButton?: React.ReactNode;
|
||||
/** Контент под кнопкой (например дисклеймер) */
|
||||
childrenUnderButton?: React.ReactNode;
|
||||
/** Управление блюром подложки */
|
||||
showGradientBlur?: boolean;
|
||||
/** Синхронизировать CSS-переменную --bottom-action-button-height на <html> */
|
||||
syncCssVar?: boolean;
|
||||
}
|
||||
|
||||
function BottomActionButton({
|
||||
actionButtonProps,
|
||||
className,
|
||||
childrenAboveButton,
|
||||
childrenUnderButton,
|
||||
...props
|
||||
}: BottomActionButtonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-0 left-[50%] translate-x-[-50%] w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<GradientBlur className="p-6 pt-11">
|
||||
{childrenAboveButton}
|
||||
<ActionButton {...actionButtonProps} />
|
||||
{childrenUnderButton}
|
||||
</GradientBlur>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
|
||||
function BottomActionButton(
|
||||
{
|
||||
actionButtonProps,
|
||||
childrenAboveButton,
|
||||
childrenUnderButton,
|
||||
showGradientBlur = true,
|
||||
className,
|
||||
syncCssVar = true,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
useImperativeHandle(ref, () => innerRef.current as HTMLDivElement, []);
|
||||
|
||||
export { BottomActionButton };
|
||||
const hasButton = Boolean(actionButtonProps);
|
||||
const hasExtra =
|
||||
Boolean(childrenAboveButton) || Boolean(childrenUnderButton);
|
||||
const hasContent = hasButton || hasExtra;
|
||||
|
||||
// Ничего не рендерим, если нет контента
|
||||
if (!hasContent) return null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncCssVar || typeof window === "undefined") return;
|
||||
const el = innerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const setVar = () => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--bottom-action-button-height",
|
||||
`${el.offsetHeight}px`
|
||||
);
|
||||
};
|
||||
|
||||
setVar();
|
||||
|
||||
if ("ResizeObserver" in window) {
|
||||
const ro = new ResizeObserver(setVar);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
} else {
|
||||
const onResize = () => setVar();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}
|
||||
}, [syncCssVar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-1/2 -translate-x-1/2 w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<GradientBlur className="p-6 pt-11" isActiveBlur={showGradientBlur}>
|
||||
{childrenAboveButton}
|
||||
{hasButton ? <ActionButton {...actionButtonProps} /> : null}
|
||||
{childrenUnderButton}
|
||||
</GradientBlur>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { BottomActionButton };
|
||||
@ -5,7 +5,7 @@ import {
|
||||
MainButton,
|
||||
MainButtonProps,
|
||||
} from "@/components/ui/MainButton/MainButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export interface RadioAnswersListProps extends React.ComponentProps<"div"> {
|
||||
answers: MainButtonProps[];
|
||||
@ -25,6 +25,11 @@ function RadioAnswersList({
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<MainButtonProps | null>(
|
||||
activeAnswer
|
||||
);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAnswer(activeAnswer ?? null);
|
||||
}, [activeAnswer]);
|
||||
|
||||
const handleAnswerClick = (answer: MainButtonProps) => {
|
||||
setSelectedAnswer(answer);
|
||||
@ -32,6 +37,12 @@ function RadioAnswersList({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// НЕ вызываем callback при первоначальной загрузке компонента
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeSelectedAnswer?.(selectedAnswer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAnswer]);
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
MainButton,
|
||||
MainButtonProps,
|
||||
} from "@/components/ui/MainButton/MainButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export interface SelectAnswersListProps extends React.ComponentProps<"div"> {
|
||||
answers: MainButtonProps[];
|
||||
@ -25,6 +25,11 @@ function SelectAnswersList({
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<
|
||||
MainButtonProps[] | null
|
||||
>(activeAnswers);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAnswers(activeAnswers ?? null);
|
||||
}, [activeAnswers]);
|
||||
|
||||
const handleAnswerClick = (answer: MainButtonProps) => {
|
||||
if (selectedAnswers?.some((a) => a.id === answer.id)) {
|
||||
@ -38,6 +43,12 @@ function SelectAnswersList({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// НЕ вызываем callback при первоначальной загрузке компонента
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeSelectedAnswers?.(selectedAnswers);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAnswers]);
|
||||
|
||||
577
src/lib/admin/builder/context.tsx
Normal file
577
src/lib/admin/builder/context.tsx
Normal file
@ -0,0 +1,577 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useReducer, type ReactNode } from "react";
|
||||
|
||||
import type {
|
||||
BuilderFunnelState,
|
||||
BuilderScreen,
|
||||
BuilderScreenPosition,
|
||||
} from "@/lib/admin/builder/types";
|
||||
import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface BuilderState extends BuilderFunnelState {
|
||||
selectedScreenId: string | null;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_META: BuilderFunnelState["meta"] = {
|
||||
id: "funnel-builder-draft",
|
||||
title: "New Funnel",
|
||||
description: "",
|
||||
firstScreenId: "screen-1",
|
||||
};
|
||||
|
||||
const INITIAL_SCREEN: BuilderScreen = {
|
||||
id: "screen-1",
|
||||
template: "list",
|
||||
header: {
|
||||
progress: {
|
||||
current: 1,
|
||||
total: 1,
|
||||
label: "1 of 1",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: "Новый экран",
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
},
|
||||
subtitle: {
|
||||
text: "Добавьте детали справа",
|
||||
color: "muted",
|
||||
font: "inter",
|
||||
},
|
||||
list: {
|
||||
selectionType: "single",
|
||||
options: [
|
||||
{
|
||||
id: "option-1",
|
||||
label: "Вариант 1",
|
||||
},
|
||||
{
|
||||
id: "option-2",
|
||||
label: "Вариант 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
navigation: {
|
||||
defaultNextScreenId: undefined,
|
||||
rules: [],
|
||||
},
|
||||
position: {
|
||||
x: 80,
|
||||
y: 120,
|
||||
},
|
||||
};
|
||||
|
||||
const INITIAL_STATE: BuilderState = {
|
||||
meta: INITIAL_META,
|
||||
screens: [INITIAL_SCREEN],
|
||||
selectedScreenId: INITIAL_SCREEN.id,
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
type BuilderAction =
|
||||
| { type: "set-meta"; payload: Partial<BuilderFunnelState["meta"]> }
|
||||
| { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> }
|
||||
| { type: "remove-screen"; payload: { screenId: string } }
|
||||
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
|
||||
| { type: "reposition-screen"; payload: { screenId: string; position: BuilderScreen["position"] } }
|
||||
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }
|
||||
| { type: "set-selected-screen"; payload: { screenId: string | null } }
|
||||
| { type: "set-screens"; payload: BuilderScreen[] }
|
||||
| {
|
||||
type: "update-navigation";
|
||||
payload: {
|
||||
screenId: string;
|
||||
navigation: {
|
||||
defaultNextScreenId?: string | null;
|
||||
rules?: NavigationRuleDefinition[];
|
||||
};
|
||||
};
|
||||
}
|
||||
| { type: "reset"; payload?: BuilderState };
|
||||
|
||||
function withDirty(state: BuilderState, next: BuilderState): BuilderState {
|
||||
if (next === state) {
|
||||
return state;
|
||||
}
|
||||
return { ...next, isDirty: true };
|
||||
}
|
||||
|
||||
function generateScreenId(existing: string[]): string {
|
||||
let index = existing.length + 1;
|
||||
let attempt = `screen-${index}`;
|
||||
while (existing.includes(attempt)) {
|
||||
index += 1;
|
||||
attempt = `screen-${index}`;
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
function createScreenByTemplate(template: ScreenDefinition["template"], id: string, position: BuilderScreenPosition): BuilderScreen {
|
||||
// ✅ Единые базовые настройки для ВСЕХ типов экранов
|
||||
const baseScreen = {
|
||||
id,
|
||||
position,
|
||||
// ✅ Современные настройки header (без устаревшего progress)
|
||||
header: {
|
||||
show: true,
|
||||
showBackButton: true,
|
||||
},
|
||||
// ✅ Базовые тексты
|
||||
title: {
|
||||
text: "Новый экран",
|
||||
font: "manrope" as const,
|
||||
weight: "bold" as const,
|
||||
},
|
||||
subtitle: {
|
||||
text: "Добавьте детали справа",
|
||||
color: "muted" as const,
|
||||
font: "inter" as const,
|
||||
},
|
||||
// ✅ Единые настройки нижней кнопки
|
||||
bottomActionButton: {
|
||||
text: "Продолжить",
|
||||
show: true,
|
||||
showGradientBlur: true,
|
||||
},
|
||||
// ✅ Навигация
|
||||
navigation: {
|
||||
defaultNextScreenId: undefined,
|
||||
rules: [],
|
||||
},
|
||||
};
|
||||
|
||||
switch (template) {
|
||||
case "info":
|
||||
return {
|
||||
...baseScreen,
|
||||
template: "info",
|
||||
description: {
|
||||
text: "Добавьте описание для информационного экрана",
|
||||
},
|
||||
icon: {
|
||||
type: "emoji" as const,
|
||||
value: "ℹ️",
|
||||
size: "md" as const,
|
||||
},
|
||||
};
|
||||
|
||||
case "list":
|
||||
return {
|
||||
...baseScreen,
|
||||
template: "list",
|
||||
list: {
|
||||
selectionType: "single" as const,
|
||||
options: [
|
||||
{ id: "option-1", label: "Вариант 1" },
|
||||
{ id: "option-2", label: "Вариант 2" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
case "form":
|
||||
return {
|
||||
...baseScreen,
|
||||
template: "form",
|
||||
fields: [
|
||||
{
|
||||
id: "field-1",
|
||||
label: "Имя",
|
||||
type: "text" as const,
|
||||
required: true
|
||||
},
|
||||
],
|
||||
validationMessages: {
|
||||
required: "Это поле обязательно для заполнения",
|
||||
},
|
||||
};
|
||||
|
||||
case "date":
|
||||
return {
|
||||
...baseScreen,
|
||||
template: "date",
|
||||
dateInput: {
|
||||
monthLabel: "Месяц",
|
||||
dayLabel: "День",
|
||||
yearLabel: "Год",
|
||||
monthPlaceholder: "ММ",
|
||||
dayPlaceholder: "ДД",
|
||||
yearPlaceholder: "ГГГГ",
|
||||
showSelectedDate: true,
|
||||
selectedDateFormat: "dd MMMM yyyy",
|
||||
selectedDateLabel: "Выбранная дата:",
|
||||
},
|
||||
infoMessage: {
|
||||
text: "Мы используем эту информацию только для анализа",
|
||||
icon: "🔒",
|
||||
},
|
||||
};
|
||||
|
||||
case "coupon":
|
||||
return {
|
||||
...baseScreen,
|
||||
template: "coupon",
|
||||
coupon: {
|
||||
title: {
|
||||
text: "Ваш промокод готов!",
|
||||
},
|
||||
promoCode: {
|
||||
text: "PROMO2024",
|
||||
},
|
||||
offer: {
|
||||
title: {
|
||||
text: "Специальное предложение!",
|
||||
},
|
||||
description: {
|
||||
text: "Получите скидку с промокодом",
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
text: "Промокод активен в течение 24 часов",
|
||||
},
|
||||
},
|
||||
copiedMessage: "Промокод скопирован!",
|
||||
};
|
||||
|
||||
default:
|
||||
// Fallback to info template
|
||||
return {
|
||||
...baseScreen,
|
||||
template: "info",
|
||||
description: {
|
||||
text: "Добавьте описание для информационного экрана",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function builderReducer(state: BuilderState, action: BuilderAction): BuilderState {
|
||||
switch (action.type) {
|
||||
case "set-meta": {
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
meta: {
|
||||
...state.meta,
|
||||
...action.payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "add-screen": {
|
||||
const nextId = generateScreenId(state.screens.map((s) => s.id));
|
||||
const template = action.payload?.template || "list";
|
||||
const position = {
|
||||
x: (action.payload?.position?.x ?? 120) + state.screens.length * 40,
|
||||
y: (action.payload?.position?.y ?? 120) + state.screens.length * 20,
|
||||
};
|
||||
|
||||
const newScreen = createScreenByTemplate(template, nextId, position);
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: [...state.screens, newScreen],
|
||||
selectedScreenId: newScreen.id,
|
||||
meta: {
|
||||
...state.meta,
|
||||
firstScreenId: state.meta.firstScreenId ?? newScreen.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "remove-screen": {
|
||||
const filtered = state.screens.filter((screen) => screen.id !== action.payload.screenId);
|
||||
const selectedScreenId =
|
||||
state.selectedScreenId === action.payload.screenId ? filtered[0]?.id ?? null : state.selectedScreenId;
|
||||
|
||||
const nextMeta = {
|
||||
...state.meta,
|
||||
firstScreenId:
|
||||
state.meta.firstScreenId === action.payload.screenId
|
||||
? filtered[0]?.id ?? null
|
||||
: state.meta.firstScreenId,
|
||||
};
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: filtered,
|
||||
selectedScreenId,
|
||||
meta: nextMeta,
|
||||
});
|
||||
}
|
||||
case "update-screen": {
|
||||
const { screenId, screen } = action.payload;
|
||||
let nextSelectedScreenId = state.selectedScreenId;
|
||||
|
||||
const nextScreens = state.screens.map((current) =>
|
||||
current.id === screenId
|
||||
? (() => {
|
||||
const nextScreen = {
|
||||
...current,
|
||||
...screen,
|
||||
title: screen.title ? { ...current.title, ...screen.title } : current.title,
|
||||
...(("subtitle" in screen && screen.subtitle !== undefined)
|
||||
? { subtitle: screen.subtitle }
|
||||
: "subtitle" in current
|
||||
? { subtitle: current.subtitle }
|
||||
: {}),
|
||||
...(current.template === "list" && "list" in screen && screen.list
|
||||
? {
|
||||
list: {
|
||||
...(current as ListScreenDefinition & { position: BuilderScreenPosition }).list,
|
||||
...screen.list,
|
||||
options:
|
||||
screen.list.options ??
|
||||
(current as ListScreenDefinition & { position: BuilderScreenPosition }).list.options,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as BuilderScreen;
|
||||
|
||||
if ("variants" in screen) {
|
||||
if (Array.isArray(screen.variants) && screen.variants.length > 0) {
|
||||
nextScreen.variants = screen.variants;
|
||||
} else if ("variants" in nextScreen) {
|
||||
delete (nextScreen as Partial<BuilderScreen>).variants;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.selectedScreenId === current.id && nextScreen.id !== current.id) {
|
||||
nextSelectedScreenId = nextScreen.id;
|
||||
}
|
||||
|
||||
return nextScreen;
|
||||
})()
|
||||
: current
|
||||
);
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: nextScreens,
|
||||
selectedScreenId: nextSelectedScreenId,
|
||||
});
|
||||
}
|
||||
case "reposition-screen": {
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: state.screens.map((screen) =>
|
||||
screen.id === action.payload.screenId
|
||||
? { ...screen, position: action.payload.position }
|
||||
: screen
|
||||
),
|
||||
});
|
||||
}
|
||||
case "reorder-screens": {
|
||||
const { fromIndex, toIndex } = action.payload;
|
||||
const previousScreens = state.screens;
|
||||
const newScreens = [...previousScreens];
|
||||
const [movedScreen] = newScreens.splice(fromIndex, 1);
|
||||
newScreens.splice(toIndex, 0, movedScreen);
|
||||
|
||||
const previousSequentialNext = new Map<string, string | undefined>();
|
||||
const previousIndexMap = new Map<string, number>();
|
||||
const newSequentialNext = new Map<string, string | undefined>();
|
||||
|
||||
previousScreens.forEach((screen, index) => {
|
||||
previousSequentialNext.set(screen.id, previousScreens[index + 1]?.id);
|
||||
previousIndexMap.set(screen.id, index);
|
||||
});
|
||||
|
||||
newScreens.forEach((screen, index) => {
|
||||
newSequentialNext.set(screen.id, newScreens[index + 1]?.id);
|
||||
});
|
||||
|
||||
const totalScreens = newScreens.length;
|
||||
|
||||
const rewiredScreens = newScreens.map((screen, index) => {
|
||||
const prevIndex = previousIndexMap.get(screen.id);
|
||||
const prevSequential = previousSequentialNext.get(screen.id);
|
||||
const nextSequential = newScreens[index + 1]?.id;
|
||||
const navigation = screen.navigation;
|
||||
const hasRules = Boolean(navigation?.rules && navigation.rules.length > 0);
|
||||
|
||||
let defaultNext = navigation?.defaultNextScreenId;
|
||||
if (!hasRules) {
|
||||
if (!defaultNext || defaultNext === prevSequential) {
|
||||
defaultNext = nextSequential;
|
||||
}
|
||||
} else if (defaultNext === prevSequential) {
|
||||
defaultNext = nextSequential;
|
||||
}
|
||||
|
||||
const updatedNavigation = (() => {
|
||||
if ((navigation?.rules && navigation.rules.length > 0) || defaultNext) {
|
||||
// Обновляем nextScreenId в правилах навигации при reorder
|
||||
const updatedRules = navigation?.rules?.map(rule => {
|
||||
let updatedNextScreenId = rule.nextScreenId;
|
||||
|
||||
// Обновляем ссылки если правило указывает на экран, который "следовал" за другим экраном
|
||||
// и эта последовательность изменилась
|
||||
for (const [screenId, oldNext] of previousSequentialNext.entries()) {
|
||||
const newNext = newSequentialNext.get(screenId);
|
||||
|
||||
// Если правило указывало на экран, который раньше был "следующим"
|
||||
// за каким-то экраном, но теперь следующим стал другой экран
|
||||
if (rule.nextScreenId === oldNext && newNext && oldNext !== newNext) {
|
||||
updatedNextScreenId = newNext;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
nextScreenId: updatedNextScreenId
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...(updatedRules ? { rules: updatedRules } : {}),
|
||||
...(defaultNext ? { defaultNextScreenId: defaultNext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
let updatedHeader = screen.header;
|
||||
if (screen.header?.progress) {
|
||||
const progress = { ...screen.header.progress };
|
||||
const previousProgress = prevIndex !== undefined ? previousScreens[prevIndex]?.header?.progress : undefined;
|
||||
|
||||
if (
|
||||
typeof progress.current === "number" &&
|
||||
prevIndex !== undefined &&
|
||||
(progress.current === prevIndex + 1 || previousProgress?.current === prevIndex + 1)
|
||||
) {
|
||||
progress.current = index + 1;
|
||||
}
|
||||
|
||||
if (typeof progress.total === "number") {
|
||||
const previousTotal = previousProgress?.total ?? progress.total;
|
||||
if (previousTotal === previousScreens.length) {
|
||||
progress.total = totalScreens;
|
||||
}
|
||||
}
|
||||
|
||||
updatedHeader = {
|
||||
...screen.header,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
const nextScreen: BuilderScreen = {
|
||||
...screen,
|
||||
...(updatedHeader ? { header: updatedHeader } : {}),
|
||||
};
|
||||
|
||||
if (updatedNavigation) {
|
||||
nextScreen.navigation = updatedNavigation;
|
||||
} else if ("navigation" in nextScreen) {
|
||||
delete nextScreen.navigation;
|
||||
}
|
||||
|
||||
return nextScreen;
|
||||
});
|
||||
|
||||
const nextMeta = {
|
||||
...state.meta,
|
||||
firstScreenId: rewiredScreens[0]?.id,
|
||||
};
|
||||
|
||||
const nextSelectedScreenId =
|
||||
movedScreen && state.selectedScreenId === movedScreen.id
|
||||
? movedScreen.id
|
||||
: state.selectedScreenId;
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: rewiredScreens,
|
||||
meta: nextMeta,
|
||||
selectedScreenId: nextSelectedScreenId,
|
||||
});
|
||||
}
|
||||
case "set-selected-screen": {
|
||||
return {
|
||||
...state,
|
||||
selectedScreenId: action.payload.screenId,
|
||||
};
|
||||
}
|
||||
case "set-screens": {
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: action.payload,
|
||||
selectedScreenId: action.payload[0]?.id ?? null,
|
||||
meta: {
|
||||
...state.meta,
|
||||
firstScreenId: state.meta.firstScreenId ?? action.payload[0]?.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "update-navigation": {
|
||||
const { screenId, navigation } = action.payload;
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: state.screens.map((screen) =>
|
||||
screen.id === screenId
|
||||
? {
|
||||
...screen,
|
||||
navigation: {
|
||||
defaultNextScreenId: navigation.defaultNextScreenId ?? undefined,
|
||||
rules: navigation.rules ?? [],
|
||||
},
|
||||
}
|
||||
: screen
|
||||
),
|
||||
});
|
||||
}
|
||||
case "reset": {
|
||||
return action.payload ? { ...action.payload, isDirty: false } : INITIAL_STATE;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
interface BuilderProviderProps {
|
||||
children: ReactNode;
|
||||
initialState?: BuilderState;
|
||||
}
|
||||
|
||||
const BuilderStateContext = createContext<BuilderState | undefined>(undefined);
|
||||
const BuilderDispatchContext = createContext<((action: BuilderAction) => void) | undefined>(undefined);
|
||||
|
||||
export function BuilderProvider({ children, initialState }: BuilderProviderProps) {
|
||||
const [state, dispatch] = useReducer(builderReducer, initialState ?? INITIAL_STATE);
|
||||
|
||||
const memoizedState = useMemo(() => state, [state]);
|
||||
const memoizedDispatch = useMemo(() => dispatch, []);
|
||||
|
||||
return (
|
||||
<BuilderStateContext.Provider value={memoizedState}>
|
||||
<BuilderDispatchContext.Provider value={memoizedDispatch}>{children}</BuilderDispatchContext.Provider>
|
||||
</BuilderStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBuilderState(): BuilderState {
|
||||
const ctx = useContext(BuilderStateContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useBuilderState must be used within BuilderProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useBuilderDispatch(): (action: BuilderAction) => void {
|
||||
const ctx = useContext(BuilderDispatchContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useBuilderDispatch must be used within BuilderProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useBuilderSelectedScreen(): BuilderScreen | undefined {
|
||||
const state = useBuilderState();
|
||||
return state.screens.find((screen) => screen.id === state.selectedScreenId);
|
||||
}
|
||||
|
||||
export type { BuilderState, BuilderAction };
|
||||
106
src/lib/admin/builder/templates.ts
Normal file
106
src/lib/admin/builder/templates.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { ListOptionDefinition } from "@/lib/funnel/types";
|
||||
|
||||
export interface CreateTemplateScreenOptions {
|
||||
templateId?: string;
|
||||
screenId: string;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface BuilderTemplateDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
create: (options: CreateTemplateScreenOptions, overrides?: Partial<BuilderScreen>) => BuilderScreen;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATE_ID = "list";
|
||||
|
||||
function cloneOptions(options: ListOptionDefinition[]): ListOptionDefinition[] {
|
||||
return options.map((option) => ({ ...option }));
|
||||
}
|
||||
|
||||
const LIST_TEMPLATE: BuilderTemplateDefinition = {
|
||||
id: "list",
|
||||
label: "Вопрос с вариантами",
|
||||
create: ({ screenId, position }, overrides) => {
|
||||
const base = {
|
||||
id: screenId,
|
||||
template: "list" as const,
|
||||
header: {
|
||||
progress: {
|
||||
current: 1,
|
||||
total: 1,
|
||||
label: "1 of 1",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: "Новый экран",
|
||||
font: "manrope" as const,
|
||||
weight: "bold" as const,
|
||||
},
|
||||
subtitle: {
|
||||
text: "Опишите вопрос справа",
|
||||
color: "muted" as const,
|
||||
font: "inter" as const,
|
||||
},
|
||||
list: {
|
||||
selectionType: "single" as const,
|
||||
options: cloneOptions([
|
||||
{ id: "option-1", label: "Вариант 1" },
|
||||
{ id: "option-2", label: "Вариант 2" },
|
||||
]),
|
||||
},
|
||||
navigation: {
|
||||
defaultNextScreenId: undefined,
|
||||
rules: [],
|
||||
},
|
||||
position,
|
||||
};
|
||||
|
||||
if (!overrides) {
|
||||
return base as BuilderScreen;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
list: ('list' in overrides && overrides.list)
|
||||
? {
|
||||
...base.list,
|
||||
...overrides.list,
|
||||
options: overrides.list.options ?? base.list.options,
|
||||
}
|
||||
: base.list,
|
||||
navigation: overrides.navigation
|
||||
? {
|
||||
defaultNextScreenId:
|
||||
overrides.navigation.defaultNextScreenId ?? base.navigation?.defaultNextScreenId,
|
||||
rules: overrides.navigation.rules ?? base.navigation?.rules ?? [],
|
||||
}
|
||||
: base.navigation,
|
||||
} as BuilderScreen;
|
||||
},
|
||||
};
|
||||
|
||||
const BUILDER_TEMPLATES: BuilderTemplateDefinition[] = [LIST_TEMPLATE];
|
||||
|
||||
export function getTemplateDefinition(templateId: string): BuilderTemplateDefinition {
|
||||
return BUILDER_TEMPLATES.find((template) => template.id === templateId) ?? LIST_TEMPLATE;
|
||||
}
|
||||
|
||||
export function createTemplateScreen(
|
||||
options: CreateTemplateScreenOptions,
|
||||
overrides?: Partial<BuilderScreen>
|
||||
): BuilderScreen {
|
||||
const definition = getTemplateDefinition(options.templateId ?? DEFAULT_TEMPLATE_ID);
|
||||
return definition.create(options, overrides);
|
||||
}
|
||||
|
||||
export function getTemplateOptions(): { id: string; label: string; description?: string }[] {
|
||||
return BUILDER_TEMPLATES.map((template) => ({
|
||||
id: template.id,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
}));
|
||||
}
|
||||
15
src/lib/admin/builder/types.ts
Normal file
15
src/lib/admin/builder/types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { FunnelDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
export type BuilderScreenPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type BuilderScreen = ScreenDefinition & {
|
||||
position: BuilderScreenPosition;
|
||||
};
|
||||
|
||||
export interface BuilderFunnelState {
|
||||
meta: FunnelDefinition["meta"];
|
||||
screens: BuilderScreen[];
|
||||
}
|
||||
147
src/lib/admin/builder/useSimpleUndoRedo.ts
Normal file
147
src/lib/admin/builder/useSimpleUndoRedo.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Simple Undo/Redo Hook for Builder State
|
||||
* Based on Memento pattern - stores complete state snapshots
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { BuilderState } from './context';
|
||||
|
||||
function cloneState<T>(value: T): T {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
interface UndoRedoHook {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
store: () => void; // Save current state to undo stack
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function useSimpleUndoRedo(
|
||||
currentState: BuilderState,
|
||||
onStateChange: (state: BuilderState) => void,
|
||||
maxHistorySize: number = 50
|
||||
): UndoRedoHook {
|
||||
const [undoStack, setUndoStack] = useState<BuilderState[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<BuilderState[]>([]);
|
||||
|
||||
const canUndo = undoStack.length > 0;
|
||||
const canRedo = redoStack.length > 0;
|
||||
|
||||
const store = useCallback(() => {
|
||||
// Deep clone the state to prevent mutations
|
||||
const stateSnapshot = cloneState(currentState);
|
||||
|
||||
setUndoStack(prev => {
|
||||
const newStack = [...prev, stateSnapshot];
|
||||
|
||||
// Limit history size
|
||||
if (newStack.length > maxHistorySize) {
|
||||
return newStack.slice(-maxHistorySize);
|
||||
}
|
||||
|
||||
return newStack;
|
||||
});
|
||||
|
||||
// Clear redo stack when new state is stored
|
||||
setRedoStack([]);
|
||||
}, [currentState, maxHistorySize]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (!canUndo) return;
|
||||
|
||||
const lastState = undoStack[undoStack.length - 1];
|
||||
|
||||
// Move current state to redo stack
|
||||
const currentSnapshot = cloneState(currentState);
|
||||
setRedoStack(prev => [...prev, currentSnapshot]);
|
||||
|
||||
// Remove last state from undo stack
|
||||
setUndoStack(prev => prev.slice(0, prev.length - 1));
|
||||
|
||||
// Apply the previous state
|
||||
onStateChange(lastState);
|
||||
}, [canUndo, undoStack, currentState, onStateChange]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (!canRedo) return;
|
||||
|
||||
const lastRedoState = redoStack[redoStack.length - 1];
|
||||
|
||||
// Move current state to undo stack
|
||||
const currentSnapshot = cloneState(currentState);
|
||||
setUndoStack(prev => [...prev, currentSnapshot]);
|
||||
|
||||
// Remove last state from redo stack
|
||||
setRedoStack(prev => prev.slice(0, prev.length - 1));
|
||||
|
||||
// Apply the redo state
|
||||
onStateChange(lastRedoState);
|
||||
}, [canRedo, redoStack, currentState, onStateChange]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setUndoStack([]);
|
||||
setRedoStack([]);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Only handle shortcuts when not in input elements
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.contentEditable === 'true'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (isCtrlOrCmd && event.key === 'z') {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Ctrl+Shift+Z or Cmd+Shift+Z for redo
|
||||
if (canRedo) {
|
||||
redo();
|
||||
}
|
||||
} else {
|
||||
// Ctrl+Z or Cmd+Z for undo
|
||||
if (canUndo) {
|
||||
undo();
|
||||
}
|
||||
}
|
||||
} else if (isCtrlOrCmd && event.key === 'y') {
|
||||
// Ctrl+Y for redo (Windows standard)
|
||||
event.preventDefault();
|
||||
if (canRedo) {
|
||||
redo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [canUndo, canRedo, undo, redo]);
|
||||
|
||||
return {
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo,
|
||||
redo,
|
||||
store,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
107
src/lib/admin/builder/utils.ts
Normal file
107
src/lib/admin/builder/utils.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import type { BuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen, BuilderFunnelState, BuilderScreenPosition } from "@/lib/admin/builder/types";
|
||||
import type {
|
||||
FunnelDefinition,
|
||||
ScreenDefinition,
|
||||
ListScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
|
||||
function deepCloneValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => deepCloneValue(item)) as unknown as T;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
|
||||
key,
|
||||
deepCloneValue(entryValue),
|
||||
]);
|
||||
return Object.fromEntries(entries) as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function withPositions(screens: ScreenDefinition[]): BuilderScreen[] {
|
||||
return screens.map((screen, index) => ({
|
||||
...screen,
|
||||
position: {
|
||||
x: 120 + (index % 4) * 240,
|
||||
y: 120 + Math.floor(index / 4) * 200,
|
||||
},
|
||||
})) as BuilderScreen[];
|
||||
}
|
||||
|
||||
export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderState {
|
||||
const builderScreens = withPositions(funnel.screens);
|
||||
|
||||
return {
|
||||
meta: funnel.meta,
|
||||
screens: builderScreens,
|
||||
selectedScreenId: builderScreens[0]?.id ?? null,
|
||||
isDirty: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeBuilderState(state: BuilderFunnelState): FunnelDefinition {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const screens = state.screens.map(({ position: _position, ...rest }) => rest);
|
||||
const meta: FunnelDefinition["meta"] = {
|
||||
...state.meta,
|
||||
firstScreenId: state.meta.firstScreenId ?? state.screens[0]?.id,
|
||||
};
|
||||
|
||||
return {
|
||||
meta,
|
||||
screens,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderScreen>): BuilderScreen {
|
||||
const copy = {
|
||||
...screen,
|
||||
position: { ...screen.position },
|
||||
...(screen.template === "list" && 'list' in screen ? {
|
||||
list: {
|
||||
...(screen as ListScreenDefinition & { position: BuilderScreenPosition }).list,
|
||||
options: (screen as ListScreenDefinition & { position: BuilderScreenPosition }).list.options.map((option) => ({ ...option })),
|
||||
}
|
||||
} : {}),
|
||||
...(Array.isArray(screen.variants)
|
||||
? {
|
||||
variants: screen.variants.map((variant) => ({
|
||||
conditions: variant.conditions.map((condition) => ({
|
||||
screenId: condition.screenId,
|
||||
operator: condition.operator,
|
||||
optionIds: [...condition.optionIds],
|
||||
})),
|
||||
...(variant.overrides
|
||||
? { overrides: deepCloneValue(variant.overrides) as ScreenVariantDefinition<ScreenDefinition>["overrides"] }
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
navigation: screen.navigation
|
||||
? {
|
||||
defaultNextScreenId: screen.navigation.defaultNextScreenId,
|
||||
rules: screen.navigation.rules?.map((rule) => ({
|
||||
nextScreenId: rule.nextScreenId,
|
||||
conditions: rule.conditions.map((condition) => ({
|
||||
screenId: condition.screenId,
|
||||
operator: condition.operator,
|
||||
optionIds: [...condition.optionIds],
|
||||
})),
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
} as BuilderScreen;
|
||||
|
||||
return overrides ? { ...copy, ...overrides } as BuilderScreen : copy;
|
||||
}
|
||||
|
||||
export function toBuilderFunnelState(state: BuilderState): BuilderFunnelState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { isDirty: _isDirty, selectedScreenId: _selectedScreenId, ...rest } = state;
|
||||
return rest;
|
||||
}
|
||||
175
src/lib/admin/builder/validation.ts
Normal file
175
src/lib/admin/builder/validation.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import type { BuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
export interface BuilderValidationIssue {
|
||||
severity: "error" | "warning";
|
||||
message: string;
|
||||
screenId?: string;
|
||||
optionId?: string;
|
||||
}
|
||||
|
||||
export interface BuilderValidationResult {
|
||||
issues: BuilderValidationIssue[];
|
||||
errors: BuilderValidationIssue[];
|
||||
warnings: BuilderValidationIssue[];
|
||||
}
|
||||
|
||||
function createIssue(
|
||||
severity: BuilderValidationIssue["severity"],
|
||||
message: string,
|
||||
context: Partial<Pick<BuilderValidationIssue, "screenId" | "optionId">> = {}
|
||||
): BuilderValidationIssue {
|
||||
return { severity, message, ...context };
|
||||
}
|
||||
|
||||
function collectDuplicateIds(values: string[]): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([value]) => value);
|
||||
}
|
||||
|
||||
function validateScreenIds(state: BuilderState, issues: BuilderValidationIssue[]) {
|
||||
const duplicates = collectDuplicateIds(state.screens.map((screen) => screen.id));
|
||||
for (const duplicateId of duplicates) {
|
||||
issues.push(createIssue("error", `Дублирующийся идентификатор экрана \`${duplicateId}\``, { screenId: duplicateId }));
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionIds(screen: BuilderScreen, issues: BuilderValidationIssue[]) {
|
||||
// Проверяем опции только для экранов типа 'list', у которых есть свойство list
|
||||
if (screen.template !== "list" || !("list" in screen)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screenWithList = screen as { list: { options: { id: string }[] } };
|
||||
const duplicates = collectDuplicateIds(screenWithList.list.options.map((option) => option.id));
|
||||
for (const duplicateId of duplicates) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\`: опция с идентификатором \`${duplicateId}\` повторяется несколько раз`,
|
||||
{ screenId: screen.id, optionId: duplicateId }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNavigation(screen: BuilderScreen, state: BuilderState, issues: BuilderValidationIssue[]) {
|
||||
const screenIds = new Set(state.screens.map((candidate) => candidate.id));
|
||||
|
||||
const navigation = screen.navigation;
|
||||
if (!navigation) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"warning",
|
||||
`Экран \`${screen.id}\` не имеет настроенной навигации (переход по умолчанию или правил)`,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigation.defaultNextScreenId && (!navigation.rules || navigation.rules.length === 0)) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"warning",
|
||||
`Экран \`${screen.id}\` не ведёт на следующий экран. Добавьте переход по умолчанию или правило.`,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (navigation.defaultNextScreenId && !screenIds.has(navigation.defaultNextScreenId)) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\` ссылается на несуществующий default next экран \`${navigation.defaultNextScreenId}\``,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const [ruleIndex, rule] of (navigation.rules ?? []).entries()) {
|
||||
if (!screenIds.has(rule.nextScreenId)) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\`, правило #${ruleIndex + 1}: следующий экран \`${rule.nextScreenId}\` не найден`,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const condition of rule.conditions) {
|
||||
if (!screenIds.has(condition.screenId)) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\`, правило #${ruleIndex + 1}: условие указывает на отсутствующий экран \`${condition.screenId}\``,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceScreen = state.screens.find((candidate) => candidate.id === condition.screenId);
|
||||
if (!referenceScreen) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем опции только для экранов типа 'list'
|
||||
if (referenceScreen.template !== "list" || !("list" in referenceScreen)) {
|
||||
// Если это не list экран, но правило ссылается на опции, это ошибка
|
||||
if (condition.optionIds && condition.optionIds.length > 0) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\`, правило #${ruleIndex + 1}: экран \`${referenceScreen.id}\` типа "${referenceScreen.template}" не имеет опций`,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceScreenWithList = referenceScreen as { list: { options: { id: string }[] } };
|
||||
const availableOptionIds = new Set(referenceScreenWithList.list.options.map((option) => option.id));
|
||||
const missingOptionIds = (condition.optionIds ?? []).filter((optionId) => !availableOptionIds.has(optionId));
|
||||
if (missingOptionIds.length > 0) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"warning",
|
||||
`Экран \`${screen.id}\`, правило #${ruleIndex + 1}: опции ${missingOptionIds
|
||||
.map((id) => `\`${id}\``)
|
||||
.join(", ")} не найдены на экране \`${referenceScreen.id}\``,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBuilderState(state: BuilderState): BuilderValidationResult {
|
||||
const issues: BuilderValidationIssue[] = [];
|
||||
|
||||
validateScreenIds(state, issues);
|
||||
|
||||
for (const screen of state.screens) {
|
||||
validateOptionIds(screen, issues);
|
||||
validateNavigation(screen, state, issues);
|
||||
}
|
||||
|
||||
const errors = issues.filter((issue) => issue.severity === "error");
|
||||
const warnings = issues.filter((issue) => issue.severity === "warning");
|
||||
|
||||
return {
|
||||
issues,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
191
src/lib/admin/builder/variants.ts
Normal file
191
src/lib/admin/builder/variants.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import type { ScreenDefinition, ScreenVariantDefinition } from "@/lib/funnel/types";
|
||||
|
||||
const EXCLUDED_KEYS = new Set(["id", "template", "variants", "position"]);
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
function isPlainObject(value: unknown): value is AnyRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => deepClone(item)) as unknown as T;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
const clonedEntries = Object.entries(value).map(([key, entry]) => [key, deepClone(entry)]);
|
||||
return Object.fromEntries(clonedEntries) as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function deepMerge<T>(base: T, patch?: Partial<T>): T {
|
||||
const result = deepClone(base);
|
||||
|
||||
if (!patch) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Object.entries(patch as AnyRecord).forEach(([key, patchValue]) => {
|
||||
if (patchValue === undefined) {
|
||||
(result as AnyRecord)[key] = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = (result as AnyRecord)[key];
|
||||
|
||||
if (isPlainObject(currentValue) && isPlainObject(patchValue)) {
|
||||
(result as AnyRecord)[key] = deepMerge(currentValue, patchValue);
|
||||
return;
|
||||
}
|
||||
|
||||
(result as AnyRecord)[key] = deepClone(patchValue);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a.every((item, index) => deepEqual(item, b[index]));
|
||||
}
|
||||
|
||||
if (isPlainObject(a) && isPlainObject(b)) {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
|
||||
for (const key of keys) {
|
||||
if (!deepEqual(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function diff(base: unknown, target: unknown): unknown {
|
||||
if (deepEqual(base, target)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(target) || Array.isArray(base)) {
|
||||
return deepClone(target);
|
||||
}
|
||||
|
||||
if (isPlainObject(target) && isPlainObject(base)) {
|
||||
const entries: [string, unknown][] = [];
|
||||
const keys = new Set([...Object.keys(target), ...Object.keys(base)]);
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (EXCLUDED_KEYS.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseValue = (base as AnyRecord)[key];
|
||||
const targetValue = (target as AnyRecord)[key];
|
||||
|
||||
const nestedDiff = diff(baseValue, targetValue);
|
||||
|
||||
if (nestedDiff !== undefined) {
|
||||
entries.push([key, nestedDiff]);
|
||||
}
|
||||
});
|
||||
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
return deepClone(target);
|
||||
}
|
||||
|
||||
export function mergeScreenWithOverrides<T extends ScreenDefinition>(
|
||||
base: T,
|
||||
overrides?: ScreenVariantDefinition<T>["overrides"]
|
||||
): T {
|
||||
return deepMerge(base, overrides as Partial<T> | undefined);
|
||||
}
|
||||
|
||||
export function applyScreenUpdates<T extends ScreenDefinition>(screen: T, updates: Partial<T>): T {
|
||||
return deepMerge(screen, updates);
|
||||
}
|
||||
|
||||
export function extractVariantOverrides<T extends ScreenDefinition>(
|
||||
base: T,
|
||||
target: T
|
||||
): ScreenVariantDefinition<T>["overrides"] {
|
||||
return diff(base, target) as ScreenVariantDefinition<T>["overrides"];
|
||||
}
|
||||
|
||||
export function listOverridePaths(value: unknown, prefix = ""): string[] {
|
||||
if (!value || typeof value !== "object") {
|
||||
return prefix ? [prefix.slice(0, -1)] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return [prefix ? `${prefix.slice(0, -1)}[]` : "[]"];
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
Object.entries(value).forEach(([key, entry]) => {
|
||||
const nextPrefix = `${prefix}${key}.`;
|
||||
if (!entry || typeof entry !== "object") {
|
||||
result.push(prefix ? `${prefix.slice(0, -1)} · ${key}` : key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(entry)) {
|
||||
result.push(prefix ? `${prefix.slice(0, -1)} · ${key}[]` : `${key}[]`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nested = listOverridePaths(entry, nextPrefix);
|
||||
if (nested.length === 0) {
|
||||
result.push(prefix ? `${prefix.slice(0, -1)} · ${key}` : key);
|
||||
return;
|
||||
}
|
||||
|
||||
result.push(...nested);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatOverridePath(path: string): string {
|
||||
if (!path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const [head, ...tail] = path.split(" · ");
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
title: "Заголовок",
|
||||
subtitle: "Подзаголовок",
|
||||
bottomActionButton: "Нижняя кнопка",
|
||||
list: "Список",
|
||||
header: "Хедер",
|
||||
dateInput: "Поле даты",
|
||||
coupon: "Купон",
|
||||
description: "Описание",
|
||||
infoMessage: "Инфо сообщение",
|
||||
};
|
||||
|
||||
const formattedHead = labelMap[head] ?? head;
|
||||
|
||||
if (tail.length === 0) {
|
||||
return formattedHead;
|
||||
}
|
||||
|
||||
return `${formattedHead} · ${tail.join(" · ")}`;
|
||||
}
|
||||
211
src/lib/funnel/FunnelProvider.tsx
Normal file
211
src/lib/funnel/FunnelProvider.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { FunnelAnswers } from "./types";
|
||||
|
||||
interface FunnelRuntimeState {
|
||||
answers: FunnelAnswers;
|
||||
history: string[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface FunnelContextValue {
|
||||
state: Record<string, FunnelRuntimeState>;
|
||||
registerScreenVisit: (funnelId: string, screenId: string) => void;
|
||||
updateScreenAnswers: (
|
||||
funnelId: string,
|
||||
screenId: string,
|
||||
answers: string[]
|
||||
) => void;
|
||||
resetFunnel: (funnelId: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_RUNTIME_STATE: FunnelRuntimeState = {
|
||||
answers: {},
|
||||
history: [],
|
||||
version: 0,
|
||||
};
|
||||
|
||||
function createInitialState(): FunnelRuntimeState {
|
||||
return {
|
||||
answers: {},
|
||||
history: [],
|
||||
version: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function arraysEqual(left: string[] | undefined, right: string[]): boolean {
|
||||
if (!left && right.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!left || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
const FunnelContext = createContext<FunnelContextValue | undefined>(undefined);
|
||||
|
||||
interface FunnelProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FunnelProvider({ children }: FunnelProviderProps) {
|
||||
const [state, setState] = useState<Record<string, FunnelRuntimeState>>({});
|
||||
|
||||
const registerScreenVisit = useCallback((funnelId: string, screenId: string) => {
|
||||
setState((prev) => {
|
||||
const previousState = prev[funnelId] ?? createInitialState();
|
||||
const history = previousState.history ?? [];
|
||||
|
||||
let nextHistory = history;
|
||||
|
||||
if (history.length === 0 || history[history.length - 1] !== screenId) {
|
||||
const existingIndex = history.indexOf(screenId);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
nextHistory = [...history, screenId];
|
||||
} else if (existingIndex !== history.length - 1) {
|
||||
nextHistory = history.slice(0, existingIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextHistory === history) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[funnelId]: {
|
||||
...previousState,
|
||||
history: nextHistory,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateScreenAnswers = useCallback(
|
||||
(funnelId: string, screenId: string, answers: string[]) => {
|
||||
setState((prev) => {
|
||||
const previousState = prev[funnelId] ?? createInitialState();
|
||||
const previousAnswers = previousState.answers ?? {};
|
||||
const existingAnswers = previousAnswers[screenId];
|
||||
|
||||
if (answers.length === 0) {
|
||||
if (!existingAnswers) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const rest = { ...previousAnswers };
|
||||
delete rest[screenId];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[funnelId]: {
|
||||
...previousState,
|
||||
answers: rest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (arraysEqual(existingAnswers, answers)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[funnelId]: {
|
||||
...previousState,
|
||||
answers: {
|
||||
...previousAnswers,
|
||||
[screenId]: answers,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resetFunnel = useCallback((funnelId: string) => {
|
||||
setState((prev) => {
|
||||
const previousState = prev[funnelId];
|
||||
if (!previousState) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (
|
||||
previousState.history.length === 0 &&
|
||||
Object.keys(previousState.answers).length === 0
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[funnelId]: {
|
||||
...createInitialState(),
|
||||
version: (previousState.version ?? 0) + 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<FunnelContextValue>(
|
||||
() => ({ state, registerScreenVisit, updateScreenAnswers, resetFunnel }),
|
||||
[state, registerScreenVisit, updateScreenAnswers, resetFunnel]
|
||||
);
|
||||
|
||||
return <FunnelContext.Provider value={value}>{children}</FunnelContext.Provider>;
|
||||
}
|
||||
|
||||
function useFunnelContext() {
|
||||
const context = useContext(FunnelContext);
|
||||
if (!context) {
|
||||
throw new Error("useFunnelContext must be used within a FunnelProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useFunnelRuntime(funnelId: string) {
|
||||
const { state, registerScreenVisit, updateScreenAnswers, resetFunnel } =
|
||||
useFunnelContext();
|
||||
|
||||
const runtime = state[funnelId] ?? DEFAULT_RUNTIME_STATE;
|
||||
|
||||
const setAnswers = useCallback(
|
||||
(screenId: string, answers: string[]) => {
|
||||
updateScreenAnswers(funnelId, screenId, answers);
|
||||
},
|
||||
[funnelId, updateScreenAnswers]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
(screenId: string) => {
|
||||
registerScreenVisit(funnelId, screenId);
|
||||
},
|
||||
[funnelId, registerScreenVisit]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
resetFunnel(funnelId);
|
||||
}, [funnelId, resetFunnel]);
|
||||
|
||||
return {
|
||||
answers: runtime.answers,
|
||||
history: runtime.history,
|
||||
version: runtime.version,
|
||||
setAnswers,
|
||||
registerScreen: register,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
5436
src/lib/funnel/bakedFunnels.ts
Normal file
5436
src/lib/funnel/bakedFunnels.ts
Normal file
File diff suppressed because it is too large
Load Diff
50
src/lib/funnel/loadFunnelDefinition.ts
Normal file
50
src/lib/funnel/loadFunnelDefinition.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { BAKED_FUNNELS } from "./bakedFunnels";
|
||||
import type { FunnelDefinition } from "./types";
|
||||
|
||||
function resolveBakedFunnel(funnelId: string): FunnelDefinition {
|
||||
const funnel = BAKED_FUNNELS[funnelId];
|
||||
|
||||
if (!funnel) {
|
||||
throw new Error(`Funnel '${funnelId}' is not baked.`);
|
||||
}
|
||||
|
||||
return funnel;
|
||||
}
|
||||
|
||||
function cloneFunnel<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function loadFunnelDefinition(
|
||||
funnelId: string
|
||||
): Promise<FunnelDefinition> {
|
||||
const funnel = resolveBakedFunnel(funnelId);
|
||||
|
||||
return cloneFunnel(funnel);
|
||||
}
|
||||
|
||||
export function peekBakedFunnelDefinition(
|
||||
funnelId: string
|
||||
): FunnelDefinition {
|
||||
return resolveBakedFunnel(funnelId);
|
||||
}
|
||||
|
||||
export function listBakedFunnelIds(): string[] {
|
||||
return Object.keys(BAKED_FUNNELS);
|
||||
}
|
||||
|
||||
export function listBakedFunnelScreenParams(): Array<{
|
||||
funnelId: string;
|
||||
screenId: string;
|
||||
}> {
|
||||
return Object.entries(BAKED_FUNNELS).flatMap(([funnelId, funnel]) =>
|
||||
funnel.screens.map((screen) => ({
|
||||
funnelId,
|
||||
screenId: screen.id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
213
src/lib/funnel/mappers.tsx
Normal file
213
src/lib/funnel/mappers.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import type { TypographyProps } from "@/components/ui/Typography/Typography";
|
||||
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
|
||||
|
||||
import type {
|
||||
HeaderDefinition,
|
||||
HeaderProgressDefinition,
|
||||
ListOptionDefinition,
|
||||
SelectionType,
|
||||
TypographyVariant,
|
||||
BottomActionButtonDefinition,
|
||||
ScreenDefinition,
|
||||
} from "./types";
|
||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
||||
|
||||
type TypographyAs = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div";
|
||||
|
||||
interface TypographyDefaults {
|
||||
font?: TypographyVariant["font"];
|
||||
weight?: TypographyVariant["weight"];
|
||||
size?: TypographyVariant["size"];
|
||||
align?: TypographyVariant["align"];
|
||||
color?: TypographyVariant["color"];
|
||||
}
|
||||
|
||||
interface BuildTypographyOptions<T extends TypographyAs> {
|
||||
as: T;
|
||||
defaults?: TypographyDefaults;
|
||||
}
|
||||
|
||||
export function buildTypographyProps<T extends TypographyAs>(
|
||||
variant: TypographyVariant | undefined,
|
||||
options: BuildTypographyOptions<T>
|
||||
): TypographyProps<T> | undefined {
|
||||
if (!variant) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { as, defaults } = options;
|
||||
|
||||
return {
|
||||
as,
|
||||
children: variant.text,
|
||||
font: variant.font ?? defaults?.font,
|
||||
weight: variant.weight ?? defaults?.weight,
|
||||
size: variant.size ?? defaults?.size,
|
||||
align: variant.align ?? defaults?.align,
|
||||
color: variant.color ?? defaults?.color,
|
||||
className: variant.className,
|
||||
} as TypographyProps<T>;
|
||||
}
|
||||
|
||||
export function buildHeaderProgress(progress?: HeaderProgressDefinition) {
|
||||
if (!progress) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { current, total, value, label, className } = progress;
|
||||
|
||||
const computedValue =
|
||||
value ?? (current !== undefined && total ? (current / total) * 100 : undefined);
|
||||
|
||||
return {
|
||||
value: computedValue,
|
||||
label,
|
||||
className,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutoHeaderProgress(
|
||||
currentScreenId: string,
|
||||
totalScreens: number,
|
||||
currentPosition: number,
|
||||
explicitProgress?: HeaderProgressDefinition
|
||||
) {
|
||||
// If explicit progress is provided, use it
|
||||
if (explicitProgress) {
|
||||
return buildHeaderProgress(explicitProgress);
|
||||
}
|
||||
|
||||
// Otherwise, auto-calculate
|
||||
const autoProgress: HeaderProgressDefinition = {
|
||||
current: currentPosition,
|
||||
total: totalScreens,
|
||||
label: `${currentPosition} of ${totalScreens}`,
|
||||
};
|
||||
|
||||
return buildHeaderProgress(autoProgress);
|
||||
}
|
||||
|
||||
export function mapListOptionsToButtons(
|
||||
options: ListOptionDefinition[],
|
||||
selectionType: SelectionType
|
||||
): MainButtonProps[] {
|
||||
return options.map((option) => ({
|
||||
id: option.id,
|
||||
children: option.label,
|
||||
emoji: option.emoji,
|
||||
isCheckbox: selectionType === "multi",
|
||||
disabled: option.disabled,
|
||||
}));
|
||||
}
|
||||
export function shouldShowBackButton(header?: HeaderDefinition, canGoBack?: boolean) {
|
||||
if (header?.showBackButton === false) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(canGoBack);
|
||||
}
|
||||
|
||||
export function shouldShowHeader(header?: HeaderDefinition) {
|
||||
return header?.show !== false;
|
||||
}
|
||||
|
||||
interface BuildActionButtonOptions {
|
||||
defaultText?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function buildActionButtonProps(
|
||||
options: BuildActionButtonOptions,
|
||||
buttonDef?: BottomActionButtonDefinition
|
||||
): ActionButtonProps {
|
||||
const { defaultText = "Continue", disabled = false, onClick } = options;
|
||||
|
||||
return {
|
||||
children: buttonDef?.text ?? defaultText,
|
||||
cornerRadius: buttonDef?.cornerRadius,
|
||||
disabled: disabled, // disabled управляется только логикой экрана, не админкой
|
||||
onClick: disabled ? undefined : onClick,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBottomActionButtonProps(
|
||||
options: BuildActionButtonOptions,
|
||||
buttonDef?: BottomActionButtonDefinition
|
||||
): BottomActionButtonProps | undefined {
|
||||
// Если кнопка отключена (show: false) - не показывать ничего
|
||||
if (buttonDef?.show === false) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// В остальных случаях показать кнопку с градиентом
|
||||
const actionButtonProps = buildActionButtonProps(options, buttonDef);
|
||||
|
||||
return {
|
||||
actionButtonProps,
|
||||
showGradientBlur: true, // Градиент всегда включен (как требовалось)
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildLayoutQuestionOptions {
|
||||
screen: ScreenDefinition;
|
||||
titleDefaults?: TypographyDefaults;
|
||||
subtitleDefaults?: TypographyDefaults;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
actionButtonOptions?: BuildActionButtonOptions;
|
||||
screenProgress?: { current: number; total: number };
|
||||
}
|
||||
|
||||
export function buildLayoutQuestionProps(
|
||||
options: BuildLayoutQuestionOptions
|
||||
): Omit<LayoutQuestionProps, "children"> {
|
||||
const {
|
||||
screen,
|
||||
titleDefaults = { font: "manrope", weight: "bold", align: "left" },
|
||||
subtitleDefaults = { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||
canGoBack,
|
||||
onBack,
|
||||
actionButtonOptions,
|
||||
screenProgress
|
||||
} = options;
|
||||
|
||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||
const showHeader = shouldShowHeader(screen.header);
|
||||
|
||||
const bottomActionButtonProps = actionButtonOptions ? buildBottomActionButtonProps(
|
||||
actionButtonOptions,
|
||||
// Если передаются actionButtonOptions, это означает что кнопка должна показываться
|
||||
// Если кнопка отключена (show: false), принудительно включаем её
|
||||
'bottomActionButton' in screen ?
|
||||
(screen.bottomActionButton?.show === false ? { ...screen.bottomActionButton, show: true } : screen.bottomActionButton)
|
||||
: undefined
|
||||
) : undefined;
|
||||
|
||||
|
||||
return {
|
||||
headerProps: showHeader ? {
|
||||
progressProps: screenProgress ? buildHeaderProgress({
|
||||
current: screenProgress.current,
|
||||
total: screenProgress.total,
|
||||
label: `${screenProgress.current} of ${screenProgress.total}`
|
||||
}) : buildHeaderProgress(screen.header?.progress),
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
} : undefined,
|
||||
title: buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: titleDefaults,
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
},
|
||||
subtitle: 'subtitle' in screen ? buildTypographyProps(screen.subtitle, {
|
||||
as: "p",
|
||||
defaults: subtitleDefaults,
|
||||
}) : undefined,
|
||||
bottomActionButtonProps,
|
||||
};
|
||||
}
|
||||
|
||||
85
src/lib/funnel/navigation.ts
Normal file
85
src/lib/funnel/navigation.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { FunnelAnswers, NavigationConditionDefinition, NavigationRuleDefinition, ScreenDefinition } from "./types";
|
||||
|
||||
function getScreenAnswers(answers: FunnelAnswers, screenId: string): string[] {
|
||||
return answers[screenId] ?? [];
|
||||
}
|
||||
|
||||
function satisfiesCondition(
|
||||
condition: NavigationConditionDefinition,
|
||||
answers: FunnelAnswers
|
||||
): boolean {
|
||||
const selected = new Set(getScreenAnswers(answers, condition.screenId));
|
||||
const expected = new Set(condition.optionIds ?? []);
|
||||
const operator = condition.operator ?? "includesAny";
|
||||
|
||||
if (expected.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "includesAny": {
|
||||
return condition.optionIds.some((id) => selected.has(id));
|
||||
}
|
||||
case "includesAll": {
|
||||
return condition.optionIds.every((id) => selected.has(id));
|
||||
}
|
||||
case "includesExactly": {
|
||||
if (selected.size !== expected.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const id of expected) {
|
||||
if (!selected.has(id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesNavigationConditions(
|
||||
conditions: NavigationConditionDefinition[] | undefined,
|
||||
answers: FunnelAnswers
|
||||
): boolean {
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return conditions.every((condition) => satisfiesCondition(condition, answers));
|
||||
}
|
||||
|
||||
function satisfiesRule(rule: NavigationRuleDefinition, answers: FunnelAnswers): boolean {
|
||||
return matchesNavigationConditions(rule.conditions, answers);
|
||||
}
|
||||
|
||||
export function resolveNextScreenId(
|
||||
currentScreen: ScreenDefinition,
|
||||
answers: FunnelAnswers,
|
||||
orderedScreens: ScreenDefinition[]
|
||||
): string | undefined {
|
||||
const navigation = currentScreen.navigation;
|
||||
|
||||
if (navigation?.rules) {
|
||||
for (const rule of navigation.rules) {
|
||||
if (satisfiesRule(rule, answers)) {
|
||||
return rule.nextScreenId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (navigation?.defaultNextScreenId) {
|
||||
return navigation.defaultNextScreenId;
|
||||
}
|
||||
|
||||
const currentIndex = orderedScreens.findIndex((screen) => screen.id === currentScreen.id);
|
||||
if (currentIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextScreen = orderedScreens[currentIndex + 1];
|
||||
return nextScreen?.id;
|
||||
}
|
||||
180
src/lib/funnel/screenRenderer.tsx
Normal file
180
src/lib/funnel/screenRenderer.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { ListTemplate } from "@/components/funnel/templates/ListTemplate";
|
||||
import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate";
|
||||
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
|
||||
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
||||
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
|
||||
import type {
|
||||
ListScreenDefinition,
|
||||
DateScreenDefinition,
|
||||
FormScreenDefinition,
|
||||
CouponScreenDefinition,
|
||||
InfoScreenDefinition,
|
||||
ScreenDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
|
||||
export interface ScreenRenderProps {
|
||||
screen: ScreenDefinition;
|
||||
selectedOptionIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress: { current: number; total: number };
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
export type TemplateRenderer = (props: ScreenRenderProps) => JSX.Element;
|
||||
|
||||
const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer> = {
|
||||
info: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
|
||||
const infoScreen = screen as InfoScreenDefinition;
|
||||
|
||||
return (
|
||||
<InfoTemplate
|
||||
screen={infoScreen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
date: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
|
||||
const dateScreen = screen as DateScreenDefinition;
|
||||
|
||||
// For date screens, we store date components as array: [month, day, year]
|
||||
const currentDateArray = selectedOptionIds;
|
||||
const selectedDate = {
|
||||
month: currentDateArray[0] || "",
|
||||
day: currentDateArray[1] || "",
|
||||
year: currentDateArray[2] || "",
|
||||
};
|
||||
|
||||
const handleDateChange = (date: { month?: string; day?: string; year?: string }) => {
|
||||
const dateArray = [date.month || "", date.day || "", date.year || ""];
|
||||
onSelectionChange(dateArray);
|
||||
};
|
||||
|
||||
return (
|
||||
<DateTemplate
|
||||
screen={dateScreen}
|
||||
selectedDate={selectedDate}
|
||||
onDateChange={handleDateChange}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
form: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
|
||||
const formScreen = screen as FormScreenDefinition;
|
||||
|
||||
// For form screens, we store form data as JSON string in the first element
|
||||
const formDataJson = selectedOptionIds[0] || "{}";
|
||||
let formData: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
formData = JSON.parse(formDataJson);
|
||||
} catch {
|
||||
formData = {};
|
||||
}
|
||||
|
||||
const handleFormDataChange = (data: Record<string, string>) => {
|
||||
const dataJson = JSON.stringify(data);
|
||||
onSelectionChange([dataJson]);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormTemplate
|
||||
screen={formScreen}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
coupon: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
|
||||
const couponScreen = screen as CouponScreenDefinition;
|
||||
|
||||
return (
|
||||
<CouponTemplate
|
||||
screen={couponScreen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
list: ({
|
||||
screen,
|
||||
selectedOptionIds,
|
||||
onSelectionChange,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}) => {
|
||||
const listScreen = screen as ListScreenDefinition;
|
||||
const isSelectionEmpty = selectedOptionIds.length === 0;
|
||||
|
||||
// Используем только общую кнопку экрана
|
||||
const bottomActionButton = listScreen.bottomActionButton;
|
||||
const isButtonDisabled = bottomActionButton?.show === false;
|
||||
|
||||
// Простая логика: кнопка есть если не отключена (show: false)
|
||||
const hasActionButton = !isButtonDisabled;
|
||||
|
||||
const actionConfig = hasActionButton
|
||||
? (bottomActionButton ?? { text: defaultTexts?.nextButton || "Next" })
|
||||
: undefined;
|
||||
const actionDisabled = hasActionButton && isSelectionEmpty;
|
||||
|
||||
return (
|
||||
<ListTemplate
|
||||
screen={listScreen}
|
||||
selectedOptionIds={selectedOptionIds}
|
||||
onSelectionChange={onSelectionChange}
|
||||
actionButtonProps={hasActionButton
|
||||
? {
|
||||
children: actionConfig?.text ?? "Next",
|
||||
disabled: actionDisabled,
|
||||
onClick: actionDisabled ? undefined : onContinue,
|
||||
}
|
||||
: undefined}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export function renderScreen(props: ScreenRenderProps): JSX.Element {
|
||||
const renderer = TEMPLATE_REGISTRY[props.screen.template];
|
||||
if (!renderer) {
|
||||
throw new Error(`Unsupported template: ${props.screen.template}`);
|
||||
}
|
||||
return renderer(props);
|
||||
}
|
||||
|
||||
export function getTemplateRenderer(screen: ScreenDefinition): TemplateRenderer {
|
||||
const renderer = TEMPLATE_REGISTRY[screen.template];
|
||||
if (!renderer) {
|
||||
throw new Error(`Unsupported template: ${screen.template}`);
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
227
src/lib/funnel/types.ts
Normal file
227
src/lib/funnel/types.ts
Normal file
@ -0,0 +1,227 @@
|
||||
export type TypographyVariant = {
|
||||
text: string;
|
||||
font?: "manrope" | "inter" | "geistSans" | "geistMono";
|
||||
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
|
||||
align?: "center" | "left" | "right";
|
||||
color?:
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "success"
|
||||
| "card"
|
||||
| "accent"
|
||||
| "muted";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export interface HeaderProgressDefinition {
|
||||
/** When both current and total provided, value is computed automatically (current / total * 100). */
|
||||
current?: number;
|
||||
total?: number;
|
||||
/** Explicit percentage override (0-100). */
|
||||
value?: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface HeaderDefinition {
|
||||
progress?: HeaderProgressDefinition;
|
||||
/** Controls whether back button should be displayed. Defaults to true. */
|
||||
showBackButton?: boolean;
|
||||
/** Controls whether header should be displayed at all. Defaults to true. */
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export type SelectionType = "single" | "multi";
|
||||
|
||||
export interface ListOptionDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
/** Optional machine-readable value; defaults to the option id. */
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface BottomActionButtonDefinition {
|
||||
/** Controls whether button should be displayed. Defaults to true. */
|
||||
show?: boolean;
|
||||
text?: string;
|
||||
cornerRadius?: "3xl" | "full";
|
||||
}
|
||||
|
||||
export interface DefaultTexts {
|
||||
nextButton?: string; // "Next"
|
||||
continueButton?: string; // "Continue"
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface NavigationConditionDefinition {
|
||||
screenId: string;
|
||||
/**
|
||||
* - includesAny: at least one option id is selected.
|
||||
* - includesAll: all option ids are selected.
|
||||
* - includesExactly: selection matches the provided set exactly (order-independent).
|
||||
*/
|
||||
operator?: "includesAny" | "includesAll" | "includesExactly";
|
||||
optionIds: string[];
|
||||
}
|
||||
|
||||
export interface NavigationRuleDefinition {
|
||||
conditions: NavigationConditionDefinition[];
|
||||
nextScreenId: string;
|
||||
}
|
||||
|
||||
export interface NavigationDefinition {
|
||||
rules?: NavigationRuleDefinition[];
|
||||
defaultNextScreenId?: string;
|
||||
}
|
||||
|
||||
type ScreenVariantOverrides<T> = Partial<Omit<T, "id" | "template" | "variants">>;
|
||||
|
||||
export interface ScreenVariantDefinition<T extends { id: string; template: string }> {
|
||||
conditions: NavigationConditionDefinition[];
|
||||
overrides: ScreenVariantOverrides<T>;
|
||||
}
|
||||
|
||||
export interface InfoScreenDefinition {
|
||||
id: string;
|
||||
template: "info";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
description?: TypographyVariant;
|
||||
icon?: {
|
||||
type: "emoji" | "image";
|
||||
value: string; // emoji character or image URL/path
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
className?: string;
|
||||
};
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<InfoScreenDefinition>[];
|
||||
}
|
||||
|
||||
export interface DateInputDefinition {
|
||||
monthPlaceholder?: string;
|
||||
dayPlaceholder?: string;
|
||||
yearPlaceholder?: string;
|
||||
monthLabel?: string;
|
||||
dayLabel?: string;
|
||||
yearLabel?: string;
|
||||
showSelectedDate?: boolean;
|
||||
selectedDateFormat?: string; // e.g., "MMMM d, yyyy" for "April 8, 1987"
|
||||
validationMessage?: string;
|
||||
selectedDateLabel?: string; // "Выбранная дата:" text
|
||||
zodiac?: {
|
||||
enabled?: boolean;
|
||||
storageKey?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DateScreenDefinition {
|
||||
id: string;
|
||||
template: "date";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
dateInput: DateInputDefinition;
|
||||
infoMessage?: TypographyVariant & {
|
||||
icon?: string; // emoji or icon
|
||||
};
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<DateScreenDefinition>[];
|
||||
}
|
||||
|
||||
export interface CouponDefinition {
|
||||
title: TypographyVariant;
|
||||
offer: {
|
||||
title: TypographyVariant;
|
||||
description: TypographyVariant;
|
||||
};
|
||||
promoCode: TypographyVariant;
|
||||
footer: TypographyVariant;
|
||||
}
|
||||
|
||||
export interface CouponScreenDefinition {
|
||||
id: string;
|
||||
template: "coupon";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
coupon: CouponDefinition;
|
||||
copiedMessage?: string; // "Промокод скопирован!" text
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<CouponScreenDefinition>[];
|
||||
}
|
||||
|
||||
export interface FormFieldDefinition {
|
||||
id: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: "text" | "email" | "tel" | "url";
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
validation?: {
|
||||
pattern?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FormValidationMessages {
|
||||
required?: string; // "${field} is required"
|
||||
maxLength?: string; // "Maximum ${maxLength} characters allowed"
|
||||
invalidFormat?: string; // "Invalid format"
|
||||
}
|
||||
|
||||
export interface FormScreenDefinition {
|
||||
id: string;
|
||||
template: "form";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
fields: FormFieldDefinition[];
|
||||
validationMessages?: FormValidationMessages;
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<FormScreenDefinition>[];
|
||||
}
|
||||
|
||||
|
||||
export interface ListScreenDefinition {
|
||||
id: string;
|
||||
template: "list";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
list: {
|
||||
selectionType: SelectionType;
|
||||
options: ListOptionDefinition[];
|
||||
};
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<ListScreenDefinition>[];
|
||||
}
|
||||
|
||||
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition;
|
||||
|
||||
export interface FunnelMetaDefinition {
|
||||
id: string;
|
||||
version?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
firstScreenId?: string;
|
||||
}
|
||||
|
||||
export interface FunnelDefinition {
|
||||
meta: FunnelMetaDefinition;
|
||||
defaultTexts?: DefaultTexts;
|
||||
screens: ScreenDefinition[];
|
||||
}
|
||||
|
||||
export type FunnelAnswers = Record<string, string[]>;
|
||||
78
src/lib/funnel/variants.ts
Normal file
78
src/lib/funnel/variants.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { matchesNavigationConditions } from "./navigation";
|
||||
import type {
|
||||
FunnelAnswers,
|
||||
ScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
} from "./types";
|
||||
|
||||
function cloneScreen<T>(screen: T): T {
|
||||
if (typeof globalThis.structuredClone === "function") {
|
||||
return globalThis.structuredClone(screen);
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(screen)) as T;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deepMerge<T>(target: T, source: Partial<T> | undefined): T {
|
||||
if (!source) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const result: T = Array.isArray(target)
|
||||
? ([...target] as unknown as T)
|
||||
: ({ ...(target as Record<string, unknown>) } as T);
|
||||
|
||||
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||
const sourceValue = source[key];
|
||||
|
||||
if (sourceValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetValue = (result as Record<string, unknown>)[key as unknown as string];
|
||||
|
||||
if (isPlainObject(sourceValue)) {
|
||||
const baseValue = isPlainObject(targetValue) ? targetValue : {};
|
||||
(result as Record<string, unknown>)[key as unknown as string] = deepMerge(
|
||||
baseValue,
|
||||
sourceValue as Record<string, unknown>
|
||||
) as unknown as T[keyof T];
|
||||
continue;
|
||||
}
|
||||
|
||||
(result as Record<string, unknown>)[key as unknown as string] = sourceValue as unknown as T[keyof T];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyScreenOverrides<T extends ScreenDefinition>(
|
||||
screen: T,
|
||||
overrides: ScreenVariantDefinition<T>["overrides"]
|
||||
): T {
|
||||
const cloned = cloneScreen(screen);
|
||||
return deepMerge(cloned, overrides as Partial<T>);
|
||||
}
|
||||
|
||||
export function resolveScreenVariant<T extends ScreenDefinition>(
|
||||
screen: T,
|
||||
answers: FunnelAnswers
|
||||
): T {
|
||||
const variants = (screen as T & { variants?: ScreenVariantDefinition<T>[] }).variants;
|
||||
|
||||
if (!variants || variants.length === 0) {
|
||||
return screen;
|
||||
}
|
||||
|
||||
for (const variant of variants) {
|
||||
if (matchesNavigationConditions(variant.conditions, answers)) {
|
||||
return applyScreenOverrides(screen, variant.overrides);
|
||||
}
|
||||
}
|
||||
|
||||
return screen;
|
||||
}
|
||||
103
src/lib/funnel/zodiac.ts
Normal file
103
src/lib/funnel/zodiac.ts
Normal file
@ -0,0 +1,103 @@
|
||||
export type ZodiacSign =
|
||||
| "capricorn"
|
||||
| "aquarius"
|
||||
| "pisces"
|
||||
| "aries"
|
||||
| "taurus"
|
||||
| "gemini"
|
||||
| "cancer"
|
||||
| "leo"
|
||||
| "virgo"
|
||||
| "libra"
|
||||
| "scorpio"
|
||||
| "sagittarius";
|
||||
|
||||
interface ZodiacBoundary {
|
||||
sign: ZodiacSign;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
const DAYS_BEFORE_MONTH = [
|
||||
0, // January
|
||||
31, // February
|
||||
59, // March
|
||||
90, // April
|
||||
120, // May
|
||||
151, // June
|
||||
181, // July
|
||||
212, // August
|
||||
243, // September
|
||||
273, // October
|
||||
304, // November
|
||||
334, // December
|
||||
];
|
||||
|
||||
const ZODIAC_BOUNDARIES: ZodiacBoundary[] = [
|
||||
{ sign: "capricorn", month: 1, day: 1 },
|
||||
{ sign: "aquarius", month: 1, day: 20 },
|
||||
{ sign: "pisces", month: 2, day: 19 },
|
||||
{ sign: "aries", month: 3, day: 21 },
|
||||
{ sign: "taurus", month: 4, day: 20 },
|
||||
{ sign: "gemini", month: 5, day: 21 },
|
||||
{ sign: "cancer", month: 6, day: 21 },
|
||||
{ sign: "leo", month: 7, day: 23 },
|
||||
{ sign: "virgo", month: 8, day: 23 },
|
||||
{ sign: "libra", month: 9, day: 23 },
|
||||
{ sign: "scorpio", month: 10, day: 23 },
|
||||
{ sign: "sagittarius", month: 11, day: 22 },
|
||||
{ sign: "capricorn", month: 12, day: 22 },
|
||||
];
|
||||
|
||||
function isValidMonth(month: number): boolean {
|
||||
return Number.isInteger(month) && month >= 1 && month <= 12;
|
||||
}
|
||||
|
||||
function getDaysInMonth(month: number): number {
|
||||
return new Date(2024, month, 0).getDate();
|
||||
}
|
||||
|
||||
function toDayOfYear(month: number, day: number): number | null {
|
||||
if (!isValidMonth(month)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxDay = getDaysInMonth(month);
|
||||
if (!Number.isInteger(day) || day < 1 || day > maxDay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DAYS_BEFORE_MONTH[month - 1] + day;
|
||||
}
|
||||
|
||||
function boundaryToDayOfYear(boundary: ZodiacBoundary): number {
|
||||
const dayOfYear = toDayOfYear(boundary.month, boundary.day);
|
||||
if (dayOfYear === null) {
|
||||
throw new Error(`Invalid zodiac boundary: ${boundary.sign}`);
|
||||
}
|
||||
return dayOfYear;
|
||||
}
|
||||
|
||||
const ZODIAC_BOUNDARIES_WITH_DAY = ZODIAC_BOUNDARIES.map((boundary) => ({
|
||||
...boundary,
|
||||
dayOfYear: boundaryToDayOfYear(boundary),
|
||||
}));
|
||||
|
||||
export function getZodiacSign(month: number, day: number): ZodiacSign | null {
|
||||
const dayOfYear = toDayOfYear(month, day);
|
||||
if (dayOfYear === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentSign: ZodiacSign = "capricorn";
|
||||
|
||||
for (const boundary of ZODIAC_BOUNDARIES_WITH_DAY) {
|
||||
if (dayOfYear >= boundary.dayOfYear) {
|
||||
currentSign = boundary.sign;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return currentSign;
|
||||
}
|
||||
296
src/lib/models/Funnel.ts
Normal file
296
src/lib/models/Funnel.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import mongoose, { Schema, Document, Model } from 'mongoose';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
|
||||
// Extend FunnelDefinition with MongoDB specific fields
|
||||
export interface IFunnel extends Document {
|
||||
// Основные данные воронки
|
||||
funnelData: FunnelDefinition;
|
||||
|
||||
// Метаданные для админки
|
||||
name: string; // Человеко-читаемое имя для каталога
|
||||
description?: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
|
||||
// Система версий и истории
|
||||
version: number;
|
||||
parentFunnelId?: string; // Для создания копий
|
||||
|
||||
// Timestamps
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt?: Date;
|
||||
|
||||
// Пользовательские данные
|
||||
createdBy?: string; // User ID in future
|
||||
lastModifiedBy?: string;
|
||||
|
||||
// Статистика использования
|
||||
usage: {
|
||||
totalViews: number;
|
||||
totalCompletions: number;
|
||||
lastUsed?: Date;
|
||||
};
|
||||
}
|
||||
|
||||
// Вложенные схемы для валидации структуры данных воронки
|
||||
const TypographyVariantSchema = new Schema({
|
||||
text: { type: String, required: true },
|
||||
font: {
|
||||
type: String,
|
||||
enum: ['manrope', 'inter', 'geistSans', 'geistMono'],
|
||||
default: 'manrope'
|
||||
},
|
||||
weight: {
|
||||
type: String,
|
||||
enum: ['regular', 'medium', 'semiBold', 'bold', 'extraBold', 'black'],
|
||||
default: 'regular'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
enum: ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'],
|
||||
default: 'md'
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
enum: ['center', 'left', 'right'],
|
||||
default: 'center'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
enum: ['default', 'primary', 'secondary', 'destructive', 'success', 'card', 'accent', 'muted'],
|
||||
default: 'default'
|
||||
},
|
||||
className: String
|
||||
}, { _id: false });
|
||||
|
||||
const HeaderDefinitionSchema = new Schema({
|
||||
progress: {
|
||||
current: Number,
|
||||
total: Number,
|
||||
value: Number,
|
||||
label: String,
|
||||
className: String
|
||||
},
|
||||
showBackButton: { type: Boolean, default: true },
|
||||
show: { type: Boolean, default: true }
|
||||
}, { _id: false });
|
||||
|
||||
const ListOptionDefinitionSchema = new Schema({
|
||||
id: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
description: String,
|
||||
emoji: String,
|
||||
value: String,
|
||||
disabled: { type: Boolean, default: false }
|
||||
}, { _id: false });
|
||||
|
||||
const NavigationConditionSchema = new Schema({
|
||||
screenId: { type: String, required: true },
|
||||
operator: {
|
||||
type: String,
|
||||
enum: ['includesAny', 'includesAll', 'includesExactly'],
|
||||
default: 'includesAny'
|
||||
},
|
||||
optionIds: [{ type: String, required: true }]
|
||||
}, { _id: false });
|
||||
|
||||
const NavigationRuleSchema = new Schema({
|
||||
conditions: [NavigationConditionSchema],
|
||||
nextScreenId: { type: String, required: true }
|
||||
}, { _id: false });
|
||||
|
||||
const NavigationDefinitionSchema = new Schema({
|
||||
rules: [NavigationRuleSchema],
|
||||
defaultNextScreenId: String
|
||||
}, { _id: false });
|
||||
|
||||
const BottomActionButtonSchema = new Schema({
|
||||
show: { type: Boolean, default: true },
|
||||
text: String,
|
||||
cornerRadius: {
|
||||
type: String,
|
||||
enum: ['3xl', 'full'],
|
||||
default: '3xl'
|
||||
}
|
||||
}, { _id: false });
|
||||
|
||||
// Схемы для различных типов экранов (используем Mixed для гибкости)
|
||||
const ScreenDefinitionSchema = new Schema({
|
||||
id: { type: String, required: true },
|
||||
template: {
|
||||
type: String,
|
||||
enum: ['info', 'date', 'coupon', 'form', 'list'],
|
||||
required: true
|
||||
},
|
||||
header: HeaderDefinitionSchema,
|
||||
title: { type: TypographyVariantSchema, required: true },
|
||||
subtitle: TypographyVariantSchema,
|
||||
bottomActionButton: BottomActionButtonSchema,
|
||||
navigation: NavigationDefinitionSchema,
|
||||
|
||||
// Специфичные для template поля (используем Mixed для максимальной гибкости)
|
||||
description: TypographyVariantSchema, // info
|
||||
icon: Schema.Types.Mixed, // info
|
||||
dateInput: Schema.Types.Mixed, // date
|
||||
infoMessage: Schema.Types.Mixed, // date
|
||||
coupon: Schema.Types.Mixed, // coupon
|
||||
copiedMessage: String, // coupon
|
||||
fields: [Schema.Types.Mixed], // form
|
||||
validationMessages: Schema.Types.Mixed, // form
|
||||
list: { // list
|
||||
selectionType: {
|
||||
type: String,
|
||||
enum: ['single', 'multi']
|
||||
},
|
||||
options: [ListOptionDefinitionSchema]
|
||||
},
|
||||
variants: [Schema.Types.Mixed] // variants для всех типов
|
||||
}, { _id: false });
|
||||
|
||||
const FunnelMetaSchema = new Schema({
|
||||
id: { type: String, required: true },
|
||||
version: String,
|
||||
title: String,
|
||||
description: String,
|
||||
firstScreenId: String
|
||||
}, { _id: false });
|
||||
|
||||
const DefaultTextsSchema = new Schema({
|
||||
nextButton: { type: String, default: 'Next' },
|
||||
continueButton: { type: String, default: 'Continue' }
|
||||
}, { _id: false });
|
||||
|
||||
const FunnelDataSchema = new Schema({
|
||||
meta: { type: FunnelMetaSchema, required: true },
|
||||
defaultTexts: DefaultTextsSchema,
|
||||
screens: [ScreenDefinitionSchema]
|
||||
}, { _id: false });
|
||||
|
||||
const FunnelSchema = new Schema<IFunnel>({
|
||||
// Основные данные воронки
|
||||
funnelData: {
|
||||
type: FunnelDataSchema,
|
||||
required: true,
|
||||
validate: {
|
||||
validator: function(v: FunnelDefinition): boolean {
|
||||
// Базовая валидация структуры
|
||||
return Boolean(v?.meta && v.meta.id && Array.isArray(v.screens));
|
||||
},
|
||||
message: 'Invalid funnel data structure'
|
||||
}
|
||||
},
|
||||
|
||||
// Метаданные для админки
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 200
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 1000
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
default: 'draft',
|
||||
required: true
|
||||
},
|
||||
|
||||
// Система версий
|
||||
version: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
min: 1
|
||||
},
|
||||
parentFunnelId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'Funnel'
|
||||
},
|
||||
|
||||
// Пользовательские данные
|
||||
createdBy: String, // В будущем можно заменить на ObjectId ref на User
|
||||
lastModifiedBy: String,
|
||||
|
||||
// Статистика
|
||||
usage: {
|
||||
totalViews: { type: Number, default: 0, min: 0 },
|
||||
totalCompletions: { type: Number, default: 0, min: 0 },
|
||||
lastUsed: Date
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
publishedAt: Date
|
||||
}, {
|
||||
timestamps: true, // Автоматически добавляет createdAt и updatedAt
|
||||
collection: 'funnels'
|
||||
});
|
||||
|
||||
// Индексы для производительности
|
||||
FunnelSchema.index({ 'funnelData.meta.id': 1 }); // Для поиска по ID воронки
|
||||
FunnelSchema.index({ status: 1, updatedAt: -1 }); // Для каталога воронок
|
||||
FunnelSchema.index({ name: 'text', description: 'text' }); // Для поиска по тексту
|
||||
FunnelSchema.index({ createdBy: 1 }); // Для фильтра по автору
|
||||
FunnelSchema.index({ 'usage.lastUsed': -1 }); // Для сортировки по использованию
|
||||
|
||||
// Методы модели
|
||||
FunnelSchema.methods.toPublicJSON = function(this: IFunnel) {
|
||||
return {
|
||||
_id: this._id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
status: this.status,
|
||||
version: this.version,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
publishedAt: this.publishedAt,
|
||||
usage: this.usage,
|
||||
funnelData: this.funnelData
|
||||
};
|
||||
};
|
||||
|
||||
FunnelSchema.methods.incrementUsage = function(this: IFunnel, type: 'view' | 'completion') {
|
||||
if (type === 'view') {
|
||||
this.usage.totalViews += 1;
|
||||
} else if (type === 'completion') {
|
||||
this.usage.totalCompletions += 1;
|
||||
}
|
||||
this.usage.lastUsed = new Date();
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Статические методы
|
||||
FunnelSchema.statics.findPublished = function() {
|
||||
return this.find({ status: 'published' }).sort({ publishedAt: -1 });
|
||||
};
|
||||
|
||||
FunnelSchema.statics.findByFunnelId = function(funnelId: string) {
|
||||
return this.findOne({ 'funnelData.meta.id': funnelId });
|
||||
};
|
||||
|
||||
// Pre-save хуки
|
||||
FunnelSchema.pre('save', function(next) {
|
||||
// Автоматически устанавливаем publishedAt при первой публикации
|
||||
if (this.status === 'published' && !this.publishedAt) {
|
||||
this.publishedAt = new Date();
|
||||
}
|
||||
|
||||
// Валидация: firstScreenId должен существовать в screens
|
||||
if (this.funnelData.meta.firstScreenId) {
|
||||
const firstScreenExists = this.funnelData.screens.some(
|
||||
screen => screen.id === this.funnelData.meta.firstScreenId
|
||||
);
|
||||
if (!firstScreenExists) {
|
||||
return next(new Error('firstScreenId must reference an existing screen'));
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Экспорт модели с проверкой на существование
|
||||
const FunnelModel: Model<IFunnel> = mongoose.models.Funnel || mongoose.model<IFunnel>('Funnel', FunnelSchema);
|
||||
|
||||
export default FunnelModel;
|
||||
196
src/lib/models/FunnelHistory.ts
Normal file
196
src/lib/models/FunnelHistory.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import mongoose, { Schema, Document, Model } from 'mongoose';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
|
||||
export interface IFunnelHistory extends Document {
|
||||
funnelId: string; // MongoDB ObjectId воронки
|
||||
sessionId: string; // Уникальный идентификатор сессии редактирования
|
||||
|
||||
// Снимок данных воронки
|
||||
funnelSnapshot: FunnelDefinition;
|
||||
|
||||
// Метаданные изменения
|
||||
actionType: 'create' | 'update' | 'save' | 'publish' | 'rollback';
|
||||
description?: string; // Описание изменения для пользователя
|
||||
|
||||
// Техническая информация
|
||||
changeDetails?: {
|
||||
field?: string; // Какое поле изменено
|
||||
previousValue?: unknown; // Предыдущее значение
|
||||
newValue?: unknown; // Новое значение
|
||||
action?: string; // Тип операции (add-screen, update-screen, etc.)
|
||||
};
|
||||
|
||||
// Пользователь и время
|
||||
createdBy?: string;
|
||||
createdAt: Date;
|
||||
|
||||
// Позиция в истории
|
||||
sequenceNumber: number; // Порядковый номер в рамках сессии
|
||||
isBaseline: boolean; // Является ли базовой точкой (сохранено в БД)
|
||||
}
|
||||
|
||||
const ChangeDetailsSchema = new Schema({
|
||||
field: String,
|
||||
previousValue: Schema.Types.Mixed,
|
||||
newValue: Schema.Types.Mixed,
|
||||
action: String
|
||||
}, { _id: false });
|
||||
|
||||
const FunnelHistorySchema = new Schema<IFunnelHistory>({
|
||||
funnelId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
sessionId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// Полный снимок данных воронки на момент изменения
|
||||
funnelSnapshot: {
|
||||
type: Schema.Types.Mixed,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Метаданные
|
||||
actionType: {
|
||||
type: String,
|
||||
enum: ['create', 'update', 'save', 'publish', 'rollback'],
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
maxlength: 500
|
||||
},
|
||||
|
||||
changeDetails: ChangeDetailsSchema,
|
||||
|
||||
// Пользователь
|
||||
createdBy: String,
|
||||
|
||||
// Позиция в истории
|
||||
sequenceNumber: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
isBaseline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
collection: 'funnel_history'
|
||||
});
|
||||
|
||||
// Составной индекс для быстрого поиска истории сессии
|
||||
FunnelHistorySchema.index({
|
||||
funnelId: 1,
|
||||
sessionId: 1,
|
||||
sequenceNumber: 1
|
||||
});
|
||||
|
||||
// Индекс для очистки старых записей
|
||||
FunnelHistorySchema.index({ createdAt: 1 });
|
||||
|
||||
// Индекс для поиска базовых точек
|
||||
FunnelHistorySchema.index({
|
||||
funnelId: 1,
|
||||
isBaseline: 1,
|
||||
createdAt: -1
|
||||
});
|
||||
|
||||
// Статические методы
|
||||
FunnelHistorySchema.statics = {
|
||||
getSessionHistory: function(funnelId: string, sessionId: string, limit: number = 50) {
|
||||
return this.find({
|
||||
funnelId,
|
||||
sessionId
|
||||
})
|
||||
.sort({ sequenceNumber: -1 })
|
||||
.limit(limit);
|
||||
},
|
||||
|
||||
getLatestBaseline: function(funnelId: string) {
|
||||
return this.findOne({
|
||||
funnelId,
|
||||
isBaseline: true
|
||||
})
|
||||
.sort({ createdAt: -1 });
|
||||
},
|
||||
|
||||
createHistoryEntry: function(
|
||||
funnelId: string,
|
||||
sessionId: string,
|
||||
funnelSnapshot: FunnelDefinition,
|
||||
actionType: string,
|
||||
sequenceNumber: number,
|
||||
options?: {
|
||||
description?: string;
|
||||
changeDetails?: unknown;
|
||||
createdBy?: string;
|
||||
isBaseline?: boolean;
|
||||
}
|
||||
) {
|
||||
return this.create({
|
||||
funnelId,
|
||||
sessionId,
|
||||
funnelSnapshot,
|
||||
actionType,
|
||||
sequenceNumber,
|
||||
description: options?.description,
|
||||
changeDetails: options?.changeDetails,
|
||||
createdBy: options?.createdBy,
|
||||
isBaseline: options?.isBaseline || false
|
||||
});
|
||||
},
|
||||
|
||||
// Автоматическая очистка старых записей (сохраняем только последние 100 записей на сессию)
|
||||
cleanupOldEntries: async function(funnelId: string, sessionId: string) {
|
||||
const KEEP_ENTRIES = 100;
|
||||
|
||||
const entries = await this.find({
|
||||
funnelId,
|
||||
sessionId
|
||||
})
|
||||
.sort({ sequenceNumber: -1 })
|
||||
.skip(KEEP_ENTRIES)
|
||||
.select('_id');
|
||||
|
||||
if (entries.length > 0) {
|
||||
const idsToDelete = entries.map((entry: { _id: unknown }) => entry._id);
|
||||
await this.deleteMany({ _id: { $in: idsToDelete } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Методы модели
|
||||
FunnelHistorySchema.methods.toPublicJSON = function() {
|
||||
return {
|
||||
_id: this._id,
|
||||
actionType: this.actionType,
|
||||
description: this.description,
|
||||
sequenceNumber: this.sequenceNumber,
|
||||
isBaseline: this.isBaseline,
|
||||
createdAt: this.createdAt,
|
||||
changeDetails: this.changeDetails
|
||||
};
|
||||
};
|
||||
|
||||
// Pre-save хук для валидации
|
||||
FunnelHistorySchema.pre('save', function(next) {
|
||||
// Валидируем что funnelSnapshot имеет правильную структуру
|
||||
if (!this.funnelSnapshot || !this.funnelSnapshot.meta || !this.funnelSnapshot.screens) {
|
||||
return next(new Error('funnelSnapshot must contain meta and screens'));
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
const FunnelHistoryModel: Model<IFunnelHistory> =
|
||||
mongoose.models.FunnelHistory ||
|
||||
mongoose.model<IFunnelHistory>('FunnelHistory', FunnelHistorySchema);
|
||||
|
||||
export default FunnelHistoryModel;
|
||||
56
src/lib/mongodb.ts
Normal file
56
src/lib/mongodb.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import mongoose, { Connection } from 'mongoose';
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/witlab-funnel';
|
||||
|
||||
interface GlobalMongoDB {
|
||||
mongoose: {
|
||||
conn: Connection | null;
|
||||
promise: Promise<Connection> | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Declare global variable for better TypeScript support
|
||||
declare global {
|
||||
var mongodb: GlobalMongoDB | undefined;
|
||||
}
|
||||
|
||||
let cached = global.mongodb;
|
||||
|
||||
if (!cached) {
|
||||
cached = global.mongodb = { mongoose: { conn: null, promise: null } };
|
||||
}
|
||||
|
||||
async function connectMongoDB(): Promise<Connection> {
|
||||
if (cached!.mongoose.conn) {
|
||||
return cached!.mongoose.conn;
|
||||
}
|
||||
|
||||
if (!cached!.mongoose.promise) {
|
||||
const opts = {
|
||||
bufferCommands: false,
|
||||
// Добавляем дополнительные опции для стабильности
|
||||
maxPoolSize: 10, // Maintain up to 10 socket connections
|
||||
serverSelectionTimeoutMS: 5000, // Keep trying to send operations for 5 seconds
|
||||
socketTimeoutMS: 45000, // Close sockets after 45 seconds of inactivity
|
||||
};
|
||||
|
||||
cached!.mongoose.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
|
||||
console.log('✅ MongoDB connected successfully');
|
||||
return mongoose.connection;
|
||||
}).catch((error) => {
|
||||
console.error('❌ MongoDB connection error:', error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
cached!.mongoose.conn = await cached!.mongoose.promise;
|
||||
return cached!.mongoose.conn;
|
||||
} catch (error) {
|
||||
// Reset the promise so next connection attempt can try again
|
||||
cached!.mongoose.promise = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default connectMongoDB;
|
||||
Loading…
Reference in New Issue
Block a user