218
README-ADMIN.md
Normal file
@ -0,0 +1,218 @@
|
||||
# WitLab Funnel Admin - Полноценная админка с MongoDB
|
||||
|
||||
## Что реализовано
|
||||
|
||||
### ✅ База данных MongoDB
|
||||
- **Подключение через Mongoose** с автоматическим переподключением
|
||||
- **Модели для воронок** с полной валидацией структуры данных
|
||||
- **История изменений** для системы undo/redo
|
||||
- **Индексы для производительности** поиска и фильтрации
|
||||
|
||||
### ✅ API Routes
|
||||
- `GET /api/funnels` - список воронок с пагинацией и фильтрами
|
||||
- `POST /api/funnels` - создание новой воронки
|
||||
- `GET /api/funnels/[id]` - получение конкретной воронки
|
||||
- `PUT /api/funnels/[id]` - обновление воронки
|
||||
- `DELETE /api/funnels/[id]` - удаление воронки (только черновики)
|
||||
- `POST /api/funnels/[id]/duplicate` - дублирование воронки
|
||||
- `GET/POST /api/funnels/[id]/history` - работа с историей изменений
|
||||
- `GET /api/funnels/by-funnel-id/[funnelId]` - загрузка по funnel ID (для совместимости)
|
||||
|
||||
### ✅ Каталог воронок `/admin`
|
||||
- **Список всех воронок** с поиском, фильтрацией и сортировкой
|
||||
- **Создание новых воронок** с базовым шаблоном
|
||||
- **Дублирование существующих** воронок
|
||||
- **Удаление черновиков** (опубликованные можно только архивировать)
|
||||
- **Статистика использования** (просмотры, завершения)
|
||||
- **Статусы**: draft, published, archived
|
||||
|
||||
### ✅ Редактор воронок `/admin/builder/[id]`
|
||||
- **Полноценный билдер** интегрированный с существующей архитектурой
|
||||
- **Автосохранение** изменений в базу данных
|
||||
- **Система публикации** с контролем версий
|
||||
- **Топ бар** с информацией о воронке и кнопками действий
|
||||
- **Экспорт/импорт JSON** для резервного копирования
|
||||
|
||||
### ✅ Система undo/redo
|
||||
- **История действий** с глубиной до 50 шагов
|
||||
- **Базовые точки** при сохранении в БД (после сохранения нельзя откатить)
|
||||
- **Несохраненные изменения** отслеживаются отдельно
|
||||
- **Автоматическая очистка** старых записей истории
|
||||
|
||||
### ✅ Интеграция с существующим кодом
|
||||
- **Обратная совместимость** с JSON файлами
|
||||
- **Приоритет базы данных** при загрузке воронок
|
||||
- **Автоматическое увеличение статистики** при просмотрах
|
||||
- **Единый API** для всех компонентов системы
|
||||
|
||||
## Настройка окружения
|
||||
|
||||
### 1. MongoDB Connection
|
||||
Создайте `.env.local` файл:
|
||||
```bash
|
||||
# MongoDB
|
||||
MONGODB_URI=mongodb://localhost:27017/witlab-funnel
|
||||
# или для MongoDB Atlas:
|
||||
# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/witlab-funnel
|
||||
|
||||
# Base URL (для server-side запросов)
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### 2. Установка MongoDB локально
|
||||
```bash
|
||||
# macOS (через Homebrew)
|
||||
brew install mongodb-community
|
||||
brew services start mongodb-community
|
||||
|
||||
# Или используйте MongoDB Atlas (облако)
|
||||
```
|
||||
|
||||
### 3. Запуск проекта
|
||||
```bash
|
||||
npm install
|
||||
npm run dev:full
|
||||
```
|
||||
|
||||
> ⚠️ Админка и API доступны только в режиме **Full system**. Для статичного фронта без админки используйте `npm run dev:frontend`, `npm run build` (или `npm run build:frontend`) и `npm run start` (или `npm run start:frontend`).
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание новой воронки
|
||||
1. Перейдите на `/admin`
|
||||
2. Нажмите "Создать воронку"
|
||||
3. Автоматически откроется билдер с базовым шаблоном
|
||||
4. Редактируйте экраны в правом сайдбаре
|
||||
5. Сохраняйте изменения кнопкой "Сохранить"
|
||||
6. Публикуйте готовую воронку кнопкой "Опубликовать"
|
||||
|
||||
### Редактирование существующей воронки
|
||||
1. В каталоге найдите нужную воронку
|
||||
2. Нажмите иконку "Редактировать" (карандаш)
|
||||
3. Внесите изменения в билдере
|
||||
4. Сохраните или опубликуйте
|
||||
|
||||
### Просмотр воронки
|
||||
1. Нажмите иконку "Просмотр" (глаз) в каталоге
|
||||
2. Или перейдите на `/{funnelId}` напрямую
|
||||
|
||||
### Дублирование воронки
|
||||
1. Нажмите иконку "Дублировать" (копия)
|
||||
2. Создастся копия со статусом "Черновик"
|
||||
3. Можете отредактировать и опубликовать
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Модели данных
|
||||
```typescript
|
||||
// Основная модель воронки
|
||||
interface IFunnel {
|
||||
funnelData: FunnelDefinition; // JSON структура воронки
|
||||
name: string; // Человеко-читаемое имя
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
version: number; // Автоинкремент при изменениях
|
||||
usage: { // Статистика
|
||||
totalViews: number;
|
||||
totalCompletions: number;
|
||||
};
|
||||
}
|
||||
|
||||
// История изменений
|
||||
interface IFunnelHistory {
|
||||
funnelId: string; // Связь с воронкой
|
||||
sessionId: string; // Сессия редактирования
|
||||
funnelSnapshot: FunnelDefinition; // Снимок состояния
|
||||
sequenceNumber: number; // Порядок в сессии
|
||||
isBaseline: boolean; // Сохранено в БД
|
||||
}
|
||||
```
|
||||
|
||||
### API Architecture
|
||||
- **RESTful API** с правильными HTTP методами
|
||||
- **Валидация данных** на уровне Mongoose схем
|
||||
- **Обработка ошибок** с понятными сообщениями
|
||||
- **Пагинация** для больших списков
|
||||
- **Фильтрация и поиск** по всем полям
|
||||
|
||||
### Frontend Architecture
|
||||
- **Server Components** для статической генерации
|
||||
- **Client Components** для интерактивности
|
||||
- **Единый API клиент** через fetch
|
||||
- **TypeScript типы** для всех данных
|
||||
- **Error Boundaries** для обработки ошибок
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Текущие меры
|
||||
- **Валидация входных данных** на всех уровнях
|
||||
- **Проверка существования** ресурсов перед операциями
|
||||
- **Ограничения на удаление** опубликованных воронок
|
||||
- **Санитизация пользовательского ввода**
|
||||
|
||||
### Будущие улучшения
|
||||
- Аутентификация пользователей
|
||||
- Авторизация по ролям
|
||||
- Аудит лог действий
|
||||
- Rate limiting для API
|
||||
|
||||
## Производительность
|
||||
|
||||
### Текущая оптимизация
|
||||
- **MongoDB индексы** для быстрого поиска
|
||||
- **Пагинация** вместо загрузки всех записей
|
||||
- **Selective loading** - только нужные поля
|
||||
- **Connection pooling** для базы данных
|
||||
|
||||
### Мониторинг
|
||||
- **Логирование ошибок** в консоль
|
||||
- **Время выполнения** запросов отслеживается
|
||||
- **Размер истории** ограничен (100 записей на сессию)
|
||||
|
||||
## Миграция с JSON
|
||||
|
||||
Существующие JSON воронки продолжают работать автоматически:
|
||||
1. **Приоритет базы данных** - сначала поиск в MongoDB
|
||||
2. **Fallback на JSON** - если не найдено в базе
|
||||
3. **Импорт из JSON** - можно загрузить JSON в билдере
|
||||
4. **Экспорт в JSON** - для резервного копирования
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Ближайшие планы
|
||||
- [x] Основная функциональность админки
|
||||
- [x] Система undo/redo
|
||||
- [x] Интеграция с существующим кодом
|
||||
- [ ] Аутентификация пользователей
|
||||
- [ ] Collaborative editing
|
||||
- [ ] Advanced аналитика
|
||||
|
||||
### Долгосрочные цели
|
||||
- [ ] Multi-tenant архитектура
|
||||
- [ ] A/B тестирование воронок
|
||||
- [ ] Интеграция с внешними сервисами
|
||||
- [ ] Mobile app для мониторинга
|
||||
|
||||
## Техническая поддержка
|
||||
|
||||
### Логи и отладка
|
||||
```bash
|
||||
# Проверка подключения к MongoDB
|
||||
curl http://localhost:3000/api/funnels
|
||||
|
||||
# Просмотр логов в консоли разработчика
|
||||
# MongoDB connection logs в терминале
|
||||
```
|
||||
|
||||
### Частые проблемы
|
||||
1. **MongoDB not connected** - проверьте MONGODB_URI в .env.local
|
||||
2. **API errors** - проверьте сетевое соединение
|
||||
3. **Build errors** - убедитесь что все зависимости установлены
|
||||
|
||||
### Контакты
|
||||
- GitHub Issues для багрепортов
|
||||
- Документация в `/docs/`
|
||||
- Комментарии в коде для сложных частей
|
||||
|
||||
---
|
||||
|
||||
**Полноценная админка с MongoDB готова к использованию! 🚀**
|
||||
53
README.md
@ -1,36 +1,31 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
## Build & runtime modes
|
||||
|
||||
## Getting Started
|
||||
The project can be built in two isolated configurations. The build scripts set the `FUNNEL_BUILD_VARIANT`/`NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` environment variables so that unused code is tree-shaken during compilation.
|
||||
|
||||
First, run the development server:
|
||||
- **Production (frontend-only)** – renders funnels using the baked JSON bundle. The admin UI and MongoDB access are not included in the bundle.
|
||||
- Development preview: `npm run dev:frontend`
|
||||
- Production build (default): `npm run build` or `npm run build:frontend`
|
||||
- Production start (default): `npm run start` or `npm run start:frontend`
|
||||
- **Development (full system)** – runs the public frontend together with the admin panel backed by MongoDB.
|
||||
- Development (default): `npm run dev` or `npm run dev:full`
|
||||
- Development build: `npm run build:full`
|
||||
- Development start: `npm run start:full`
|
||||
|
||||
## Local development
|
||||
|
||||
1. Install dependencies: `npm install`
|
||||
2. Choose the required mode:
|
||||
- Production preview (frontend-only): `npm run dev:frontend`
|
||||
- Full system development: `npm run dev`
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run build # frontend-only production bundle
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
npm run build:full # full system development bundle
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
After building, start the chosen bundle with `npm run start` (frontend-only) or `npm run start:full`.
|
||||
|
||||
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} />`).
|
||||
|
||||
Такой подход гарантирует, что конструктор и рантайм используют одну и ту же схему данных, а визуальные шаблоны ведут себя предсказуемо при изменении конфигурации.
|
||||
@ -1,7 +1,18 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const buildVariant =
|
||||
process.env.FUNNEL_BUILD_VARIANT ??
|
||||
process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT ??
|
||||
"frontend";
|
||||
|
||||
process.env.FUNNEL_BUILD_VARIANT = buildVariant;
|
||||
process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT = buildVariant;
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
env: {
|
||||
FUNNEL_BUILD_VARIANT: buildVariant,
|
||||
NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: buildVariant,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1150
package-lock.json
generated
25
package.json
@ -3,26 +3,43 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"dev": "npm run dev:frontend",
|
||||
"dev:frontend": "node ./scripts/run-with-variant.mjs dev frontend -- --turbopack",
|
||||
"dev:full": "node ./scripts/run-with-variant.mjs dev full -- --turbopack",
|
||||
"build": "npm run build:frontend",
|
||||
"build:frontend": "npm run bake:funnels && node ./scripts/run-with-variant.mjs build frontend -- --turbopack",
|
||||
"build:full": "npm run bake:funnels && node ./scripts/run-with-variant.mjs build full -- --turbopack",
|
||||
"start": "npm run start:frontend",
|
||||
"start:frontend": "node ./scripts/run-with-variant.mjs start frontend",
|
||||
"start:full": "node ./scripts/run-with-variant.mjs start full",
|
||||
"lint": "eslint",
|
||||
"bake:funnels": "node scripts/bake-funnels.mjs",
|
||||
"import:funnels": "node scripts/import-funnels-to-db.mjs",
|
||||
"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",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@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",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"react-hook-form": "^7.63.0",
|
||||
"recharts": "^2.15.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.1.1",
|
||||
|
||||
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 |
BIN
public/female-portrait.jpg
Normal file
|
After Width: | Height: | Size: 58 KiB |
@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
44
public/funnels/funnel-funnel-1759061433816.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"meta": {
|
||||
"id": "funnel-1759061433816",
|
||||
"title": "Новая воронка",
|
||||
"description": "Описание новой воронки",
|
||||
"firstScreenId": "screen-1"
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
"list": {
|
||||
"options": []
|
||||
},
|
||||
"id": "screen-1",
|
||||
"template": "info",
|
||||
"title": {
|
||||
"text": "Добро пожаловать!",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"size": "md",
|
||||
"align": "center",
|
||||
"color": "default"
|
||||
},
|
||||
"description": {
|
||||
"text": "Это ваша новая воронка. Начните редактирование.",
|
||||
"font": "manrope",
|
||||
"weight": "regular",
|
||||
"size": "md",
|
||||
"align": "center",
|
||||
"color": "muted"
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "🎯",
|
||||
"size": "lg"
|
||||
},
|
||||
"fields": [],
|
||||
"variants": [],
|
||||
"position": {
|
||||
"x": 120,
|
||||
"y": 120
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
826
public/funnels/funnel-test.json
Normal file
@ -0,0 +1,826 @@
|
||||
{
|
||||
"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",
|
||||
"privacyBanner": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
|
||||
},
|
||||
"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": "test-loaders"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "test-loaders",
|
||||
"template": "loaders",
|
||||
"title": {
|
||||
"text": "Анализируем ваши ответы",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"subtitle": {
|
||||
"text": "Пожалуйста, подождите...",
|
||||
"font": "inter",
|
||||
"weight": "medium",
|
||||
"color": "muted",
|
||||
"align": "center"
|
||||
},
|
||||
"progressbars": {
|
||||
"transitionDuration": 3000,
|
||||
"items": [
|
||||
{
|
||||
"title": "Анализ ответов",
|
||||
"processingTitle": "Анализируем ваши ответы...",
|
||||
"processingSubtitle": "Обрабатываем данные",
|
||||
"completedTitle": "Анализ завершен",
|
||||
"completedSubtitle": "Готово!"
|
||||
},
|
||||
{
|
||||
"title": "Поиск совпадений",
|
||||
"processingTitle": "Ищем идеальные совпадения...",
|
||||
"processingSubtitle": "Сравниваем профили",
|
||||
"completedTitle": "Совпадения найдены",
|
||||
"completedSubtitle": "Отлично!"
|
||||
},
|
||||
{
|
||||
"title": "Создание портрета",
|
||||
"processingTitle": "Создаем портрет партнера...",
|
||||
"processingSubtitle": "Финальный штрих",
|
||||
"completedTitle": "Портрет готов",
|
||||
"completedSubtitle": "Все готово!"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Продолжить"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "intro-statistics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "intro-statistics",
|
||||
"template": "info",
|
||||
"header": {
|
||||
"show": true,
|
||||
"showBackButton": false
|
||||
},
|
||||
"title": {
|
||||
"text": "Добро пожаловать в **WitLab**!",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"description": {
|
||||
"text": "Мы поможем вам найти **идеального партнера** на основе глубокого анализа ваших предпочтений и характера."
|
||||
},
|
||||
"icon": {
|
||||
"type": "emoji",
|
||||
"value": "❤️",
|
||||
"size": "xl"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Начать"
|
||||
},
|
||||
"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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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} скопирован!"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
213
public/heart-in-fire.svg
Normal file
@ -0,0 +1,213 @@
|
||||
<svg width="252" height="167" viewBox="0 0 252 167" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_109_2162)">
|
||||
<path d="M131.693 52.3306C131.693 52.3306 150.168 55.6056 163.131 64.1717C176.094 72.7378 182.204 61.5075 170.91 58.2512C170.91 58.2512 174.181 51.695 182.788 49.9344C191.396 48.1738 196.508 38.8629 191.618 32.1166C191.618 32.1166 200.888 42.8952 196.033 53.3434C191.177 63.7916 187.203 65.6207 191.177 76.0347C195.151 86.4486 209.274 110.757 198.168 129.338C189.492 143.853 192.737 148.035 192.737 148.035C192.737 148.035 194.047 137.478 209.274 138.138C209.274 138.138 200.666 143.124 202.433 153.828H129.357C129.357 153.828 113.262 68.3629 131.693 52.3306Z" fill="url(#paint0_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M131.693 52.3306C131.693 52.3306 150.168 55.6056 163.131 64.1717C176.094 72.7378 182.204 61.5075 170.91 58.2512C170.91 58.2512 174.181 51.695 182.788 49.9344C191.396 48.1738 196.417 38.7165 191.618 32.1166C191.618 32.1166 200.888 42.8952 196.033 53.3434C191.177 63.7916 187.203 65.6207 191.177 76.0347C195.151 86.4486 209.274 110.757 198.168 129.338C189.492 143.853 192.737 148.035 192.737 148.035C192.737 148.035 194.047 137.478 209.274 138.138C209.274 138.138 200.666 143.124 202.433 153.828H129.357C129.357 153.828 113.262 68.3629 131.693 52.3306Z" fill="url(#paint1_linear_109_2162)"/>
|
||||
<path d="M71.3808 153.828C71.3808 153.828 58.5804 150.023 61.8165 139.026C65.0525 128.03 50.5481 124.945 48.8691 120.311C46.1115 112.698 54.1656 110.125 49.8978 102.372C49.8978 102.372 53.5028 104.628 53.7248 112.028C53.9468 119.429 61.2287 120.557 70.0582 120.417C70.0582 120.417 51.5768 96.2362 57.4048 83.9059C62.8858 72.3109 78.8878 67.9765 79.3287 65.2966C79.7695 62.6168 73.4444 61.4919 73.4444 61.4919C73.4444 61.4919 80.5449 55.8175 86.5011 59.5194C92.4604 63.2213 101.287 67.2379 110.116 63.0094C110.116 63.0094 111.477 58.6033 104.489 53.8824C98.2509 49.6695 95.5495 44.9299 106.549 34.569C115.891 25.7692 111.111 21.0359 111.111 21.0359C111.111 21.0359 118.283 20.0855 117.842 32.771C117.401 45.4597 121.932 50.3208 131.693 52.3306C141.455 54.3405 148.74 64.3836 150.506 76.2247C152.273 88.0658 147.011 93.494 154.514 104.771C162.018 116.048 165.845 128.453 164.667 138.322C164.667 138.322 160.921 125.951 151.056 119.678C138.587 111.748 138.147 103.715 138.147 103.715C138.147 103.715 140.023 108.685 136.493 113.97C132.963 119.255 127.994 125.178 132.519 137.019L133.732 130.043C133.732 130.043 144.547 137.019 145.869 143.152C147.192 149.284 141.548 153.831 141.548 153.831H71.3839L71.3808 153.828Z" fill="url(#paint2_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M71.3808 153.828C71.3808 153.828 58.5804 150.023 61.8165 139.026C65.0525 128.03 50.5481 124.945 48.8691 120.311C46.1115 112.698 54.1656 110.125 49.8978 102.372C49.8978 102.372 53.5028 104.628 53.7248 112.028C53.9468 119.429 61.2287 120.557 70.0582 120.417C70.0582 120.417 51.5768 96.2362 57.4048 83.9059C62.8858 72.3109 78.8878 67.9765 79.3287 65.2966C79.7695 62.6168 73.4444 61.4919 73.4444 61.4919C73.4444 61.4919 80.5449 55.8175 86.5011 59.5194C92.4604 63.2213 101.287 67.2379 110.116 63.0094C110.116 63.0094 111.477 58.6033 104.489 53.8824C98.2509 49.6695 95.5495 44.9299 106.549 34.569C115.891 25.7692 111.111 21.0359 111.111 21.0359C111.111 21.0359 118.283 20.0855 117.842 32.771C117.401 45.4597 121.932 50.3208 131.693 52.3306C141.455 54.3405 148.74 64.3836 150.506 76.2247C152.273 88.0658 147.011 93.494 154.514 104.771C162.018 116.048 165.845 128.453 164.667 138.322C164.667 138.322 160.921 125.951 151.056 119.678C138.587 111.748 138.147 103.715 138.147 103.715C138.147 103.715 140.023 108.685 136.493 113.97C132.963 119.255 127.994 125.178 132.519 137.019L133.732 130.043C133.732 130.043 144.547 137.019 145.869 143.152C147.192 149.284 141.548 153.831 141.548 153.831H71.3839L71.3808 153.828Z" fill="url(#paint3_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M193.262 153.827C193.262 153.827 181.766 145.13 183.229 134.386C184.817 122.735 209.565 99.1614 182.701 77.567C159.073 58.5714 133.838 55.8698 133.838 55.8698C133.838 55.8698 160.495 61.4943 167.196 85.7717C172.877 106.35 156.781 124.202 157.828 138.134C157.828 138.134 160.968 135.165 163.713 140.225C166.455 145.286 159.767 150.895 159.767 150.895C159.767 150.895 155.874 131.475 143.399 126.776C143.399 126.776 149.408 143.671 141.545 153.827H193.262Z" fill="url(#paint4_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M169.769 153.833C169.769 153.833 170.935 137.078 180.906 119.207C190.877 101.336 183.095 79.4428 169.178 69.6334C169.178 69.6334 205.522 84.6653 196.398 120.566C196.398 120.566 187.734 125.688 185.289 134.385C181.647 147.336 190.733 153.827 190.733 153.827L169.766 153.833H169.769Z" fill="url(#paint5_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M71.3806 153.827C71.3806 153.827 63.7673 149.704 64.7585 140.505C65.7527 131.307 57.3734 126.935 52.9524 123.062C48.5313 119.185 50.967 112.454 50.967 112.454C50.967 112.454 48.869 119.995 57.4047 122.04C65.9372 124.084 77.2681 129.724 77.2681 129.724C77.2681 129.724 70.1675 137.124 74.5823 144.896C78.9971 152.667 104.048 153.83 104.048 153.83H71.3838L71.3806 153.827Z" fill="url(#paint6_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M104.044 153.826C104.044 153.826 78.7376 154.531 68.5855 142.266C68.5855 142.266 67.8508 129.496 81.8267 123.264C95.8058 117.032 91.8319 101.729 91.8319 101.729C91.8319 101.729 101.249 124.644 92.4197 135.781C92.4197 135.781 79.5443 131.833 76.6772 137.542C73.807 143.251 80.9794 152.343 104.044 153.823V153.826Z" fill="url(#paint7_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M148.977 153.827C148.977 153.827 154.495 143.678 145.456 130.285C136.417 116.892 144.015 108.516 144.631 94.0261C145.247 79.5364 139.803 71.0762 139.803 71.0762C139.803 71.0762 145.459 91.2154 134.066 109.13C124.186 124.663 116.704 138.034 135.539 147.199C135.539 147.199 136.68 143.266 141.554 144.625C147.42 146.264 148.983 153.823 148.983 153.823L148.977 153.827Z" fill="url(#paint8_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M139.825 126.093C139.825 126.093 150.005 129.49 153.492 133.369L151.553 133.348C151.553 133.348 153.826 139.072 150.653 147.111C150.653 147.111 147.479 135.731 139.825 126.093Z" fill="url(#paint9_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M124.13 144.31C112.433 142.512 108.35 140.608 108.35 140.608C108.35 140.608 111.22 145.578 120.931 148.432C120.931 148.432 113.318 154.777 99.3013 151.075C85.2878 147.373 80.4322 153.823 80.4322 153.823H71.3838C71.3838 153.823 108.903 141.219 122.585 118.552C136.267 95.886 127.882 76.9964 116.407 67.4082C104.929 57.8232 108.203 45.2934 112.139 38.6499C117.364 29.8408 113.612 22.437 113.612 22.437C113.612 22.437 117.733 24.6213 116.848 34.7704C115.966 44.9195 119.79 51.4757 128.838 54.2957C128.838 54.2957 119.165 58.6645 125.565 67.4425C131.965 76.2174 141.439 98.1608 133.131 114.53C124.824 130.898 116.626 139.795 124.13 144.307V144.31Z" fill="url(#paint10_linear_109_2162)"/>
|
||||
<path d="M103.898 26.461C103.898 26.461 106.808 21.0733 101.102 17.1003C97.957 14.9129 91.2285 15.8913 91.2285 15.8913C91.2285 15.8913 98.4823 24.1177 103.898 26.4579V26.461Z" fill="url(#paint11_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M89.9216 63.9596C89.9216 63.9596 101.95 69.1417 111.108 63.9596C111.108 63.9596 112.099 58.8742 104.592 53.069C97.0847 47.2637 100.111 39.9472 108.406 33.5623C108.406 33.5623 104.038 43.8641 108.975 52.1871C114.788 61.9841 115.153 73.1209 115.153 73.1209C115.153 73.1209 98.526 71.5691 89.9185 63.9565L89.9216 63.9596Z" fill="url(#paint12_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M111.992 21.456C111.992 21.456 114.863 25.7562 111.552 30.2652C108.24 34.7773 99.1827 41.7386 100.805 48.3353C102.428 54.9352 110.742 53.5953 112.286 64.1681C112.286 64.1681 110.889 54.1593 106.88 49.7905C102.872 45.4218 114.05 34.7991 115.157 30.689C115.157 30.689 115.672 24.0642 111.992 21.456Z" fill="url(#paint13_linear_109_2162)"/>
|
||||
<path d="M125.849 44.5742C125.849 44.5742 123.173 42.8261 125.849 38.8655C125.849 38.8655 128.888 41.078 125.849 44.5742Z" fill="url(#paint14_linear_109_2162)"/>
|
||||
<path d="M64.1956 62.862C64.1956 62.862 51.7517 67.6701 49.9883 60.3878C49.9883 60.3878 50.2634 59.3284 53.4838 58.5743C53.4838 58.5743 57.3202 62.7311 64.1956 62.862Z" fill="url(#paint15_linear_109_2162)"/>
|
||||
<path d="M47.8404 98.286C47.8404 98.286 43.5726 95.6779 44.3104 89.265C45.0421 82.877 42.9128 79.3278 42.9128 79.3278C42.9128 79.3278 46.8836 83.064 48.2812 89.4083C48.2812 89.4083 45.855 92.7924 47.8404 98.2891V98.286Z" fill="url(#paint16_linear_109_2162)"/>
|
||||
<path d="M65.5713 26.1212C65.5713 26.1212 63.3795 24.0926 64.527 21.017C64.527 21.017 67.5098 23.0674 68.1976 26.7319L65.5713 26.1212Z" fill="url(#paint17_linear_109_2162)"/>
|
||||
<path d="M189.632 37.9462C189.632 37.9462 191.177 33.3282 185.383 25.4071C181.569 20.1939 182.569 13.8589 182.569 13.8589C182.569 13.8589 178.595 20.7891 180.915 28.707C180.915 28.707 186.765 31.3463 189.632 37.9462Z" fill="url(#paint18_linear_109_2162)"/>
|
||||
<path d="M203.899 130.544C203.899 130.544 206.713 127.873 208.258 123.75C208.258 123.75 208.589 126.39 208.148 129.085L203.899 130.547V130.544Z" fill="url(#paint19_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M187.453 76.987C187.453 76.987 176.941 67.5297 179.368 60.9299C181.794 54.33 192.94 48.9424 195.148 43.4425C195.148 43.4425 193.86 54.4048 190.292 59.0602C185.658 65.1085 187.453 76.987 187.453 76.987Z" fill="url(#paint20_linear_109_2162)"/>
|
||||
<g filter="url(#filter0_d_109_2162)">
|
||||
<path d="M160.577 67.6125C142.162 54.1791 126.576 70.6071 126.576 70.6071C126.576 70.6071 110.992 54.176 92.5736 67.6125C74.9645 80.457 76.6028 128.367 126.576 148.391C176.545 128.367 178.187 80.457 160.577 67.6125Z" fill="url(#paint21_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:multiply" d="M126.579 148.391C176.548 128.367 178.19 80.4573 160.581 67.6128C154.518 63.1911 148.765 62.0039 143.772 62.4027C146.079 62.2563 155.741 62.4152 157.554 75.2067C159.614 89.7525 144.332 122.44 118.659 125.606C96.535 128.336 87.1552 113.12 86.8237 112.568C92.8331 126.323 105.336 139.878 126.582 148.391H126.579Z" fill="url(#paint22_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M100.368 66.0109C100.368 66.0109 88.734 69.2547 85.301 84.2773C81.868 99.2999 90.2379 114.369 90.2379 114.369C90.2379 114.369 88.5214 96.3895 100.368 66.0078V66.0109Z" fill="url(#paint23_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M118.915 142.999C118.915 142.999 140.004 142.043 158.051 119.704C158.051 119.704 148.28 135.016 127.673 146.851C127.673 146.851 122.33 145.76 118.915 142.999Z" fill="url(#paint24_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:multiply" d="M120.976 71.1864C126.813 79.7431 134.971 82.5663 134.971 82.5663C134.971 82.5663 139.917 61.9161 131.678 66.6057C128.458 68.6281 126.579 70.6068 126.579 70.6068C126.579 70.6068 117.868 61.4237 105.774 62.4053C105.918 62.4084 115.182 62.6951 120.976 71.1864Z" fill="url(#paint25_linear_109_2162)"/>
|
||||
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M104.939 66.0109C96.5693 66.0109 86.8268 71.6977 84.4724 88.135C82.3995 102.609 90.2254 128.416 116.708 122.131C143.193 115.846 157.904 75.3373 148.524 69.8374C139.144 64.3375 134.398 79.3508 135.061 89.3876C135.061 89.3876 119.722 66.0078 104.936 66.0078L104.939 66.0109Z" fill="url(#paint26_linear_109_2162)"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_109_2162" x="52.3962" y="32.3292" width="148.361" height="146.062" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="15"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_109_2162"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_109_2162" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_109_2162" x1="175.713" y1="47.3387" x2="149.622" y2="213.434" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_109_2162" x1="206.535" y1="42.0195" x2="162.603" y2="109.905" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_109_2162" x1="78.7002" y1="50.38" x2="139.683" y2="185.508" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_109_2162" x1="36.0969" y1="49.7505" x2="152.379" y2="129.299" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_109_2162" x1="237.341" y1="95.8989" x2="99.59" y2="112.722" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_109_2162" x1="221.505" y1="103.726" x2="129.052" y2="121.657" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint6_linear_109_2162" x1="56.4198" y1="90.1465" x2="110.346" y2="210.924" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint7_linear_109_2162" x1="54.7877" y1="87.6098" x2="117.816" y2="173.443" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint8_linear_109_2162" x1="119.946" y1="61.0051" x2="145.702" y2="148.701" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint9_linear_109_2162" x1="143.408" y1="117.25" x2="152.163" y2="151.111" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint10_linear_109_2162" x1="44.3948" y1="45.8792" x2="173.975" y2="160.396" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint11_linear_109_2162" x1="93.7392" y1="13.0027" x2="142.813" y2="76.5534" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint12_linear_109_2162" x1="82.0519" y1="25.6631" x2="132.445" y2="94.1456" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint13_linear_109_2162" x1="101.815" y1="18.0377" x2="119.667" y2="75.7349" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint14_linear_109_2162" x1="126.037" y1="31.7889" x2="125.804" y2="53.8103" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint15_linear_109_2162" x1="50.9035" y1="59.9929" x2="69.5698" y2="68.2737" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint16_linear_109_2162" x1="42.694" y1="82.2102" x2="52.4335" y2="105.582" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint17_linear_109_2162" x1="64.6145" y1="21.4751" x2="70.441" y2="34.4278" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint18_linear_109_2162" x1="179.858" y1="17.1401" x2="194.565" y2="49.7311" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint19_linear_109_2162" x1="205.319" y1="125.474" x2="208.167" y2="131.788" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500"/>
|
||||
<stop offset="0.36" stop-color="#FF8004"/>
|
||||
<stop offset="1" stop-color="#FF540D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint20_linear_109_2162" x1="177.711" y1="88.2266" x2="193.831" y2="33.3169" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9500" stop-opacity="0"/>
|
||||
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="1" stop-color="#FCEA10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint21_linear_109_2162" x1="163.567" y1="127.367" x2="114.633" y2="86.3865" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#EF4B9F"/>
|
||||
<stop offset="1" stop-color="#E6332A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint22_linear_109_2162" x1="105.909" y1="77.9052" x2="243.813" y2="201.203" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0C5DA5" stop-opacity="0"/>
|
||||
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
|
||||
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
|
||||
<stop offset="1" stop-color="#2F3485"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint23_linear_109_2162" x1="78.9946" y1="58.4357" x2="134.591" y2="167.436" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FCEA10"/>
|
||||
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint24_linear_109_2162" x1="94.6246" y1="175.756" x2="163.584" y2="106.894" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FCEA10"/>
|
||||
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint25_linear_109_2162" x1="150.016" y1="80.2978" x2="32.4088" y2="33.8186" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0C5DA5" stop-opacity="0"/>
|
||||
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
|
||||
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
|
||||
<stop offset="1" stop-color="#2F3485"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint26_linear_109_2162" x1="76.0368" y1="43.9023" x2="149.33" y2="125.17" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FCEA10"/>
|
||||
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
|
||||
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
|
||||
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
|
||||
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_109_2162">
|
||||
<rect width="251" height="167" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 22 KiB |
BIN
public/male-portrait.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
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
@ -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);
|
||||
});
|
||||
42
scripts/run-with-variant.mjs
Normal file
@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const [command, variant, ...rawArgs] = process.argv.slice(2);
|
||||
|
||||
if (!command || !variant) {
|
||||
console.error('Usage: node scripts/run-with-variant.mjs <command> <variant> [-- <next args...>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allowedVariants = new Set(['frontend', 'full']);
|
||||
if (!allowedVariants.has(variant)) {
|
||||
console.error(`Unknown build variant '${variant}'. Use one of: ${Array.from(allowedVariants).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const separatorIndex = rawArgs.indexOf('--');
|
||||
const nextArgs = separatorIndex === -1 ? rawArgs : rawArgs.slice(separatorIndex + 1);
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const nextBin = path.join(__dirname, '..', 'node_modules', '.bin', 'next');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
FUNNEL_BUILD_VARIANT: variant,
|
||||
NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: variant,
|
||||
};
|
||||
|
||||
const child = spawn(nextBin, [command, ...nextArgs], {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (typeof code === 'number') {
|
||||
process.exit(code);
|
||||
}
|
||||
process.kill(process.pid, signal ?? 'SIGTERM');
|
||||
});
|
||||
103
src/app/[funnelId]/[screenId]/page.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
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";
|
||||
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||
|
||||
// Функция для загрузки воронки из базы данных напрямую
|
||||
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
|
||||
if (!IS_FULL_SYSTEM_BUILD) {
|
||||
return 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} />;
|
||||
}
|
||||
76
src/app/[funnelId]/page.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import {
|
||||
listBakedFunnelIds,
|
||||
peekBakedFunnelDefinition,
|
||||
} from "@/lib/funnel/loadFunnelDefinition";
|
||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||
|
||||
// Функция для загрузки воронки из базы данных
|
||||
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
|
||||
if (!IS_FULL_SYSTEM_BUILD) {
|
||||
return 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}`);
|
||||
}
|
||||
496
src/app/admin/AdminCatalogPageClient.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>
|
||||
);
|
||||
}
|
||||
280
src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { BuilderProvider } from "@/lib/admin/builder/context";
|
||||
import {
|
||||
BuilderUndoRedoProvider,
|
||||
BuilderTopBar,
|
||||
BuilderSidebar,
|
||||
BuilderCanvas,
|
||||
BuilderPreview
|
||||
} from "@/components/admin/builder";
|
||||
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
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
14
src/app/admin/builder/[id]/page.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||
export default async function FunnelBuilderPage() {
|
||||
if (!IS_FULL_SYSTEM_BUILD) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { default: FunnelBuilderPageClient } = await import(
|
||||
"./FunnelBuilderPageClient"
|
||||
);
|
||||
|
||||
return <FunnelBuilderPageClient />;
|
||||
}
|
||||
14
src/app/admin/page.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||
export default async function AdminCatalogPage() {
|
||||
if (!IS_FULL_SYSTEM_BUILD) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { default: AdminCatalogPageClient } = await import(
|
||||
"./AdminCatalogPageClient"
|
||||
);
|
||||
|
||||
return <AdminCatalogPageClient />;
|
||||
}
|
||||
126
src/app/api/funnels/[id]/duplicate/route.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// POST /api/funnels/[id]/duplicate - создать копию воронки
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const [
|
||||
{ default: connectMongoDB },
|
||||
{ default: FunnelModel },
|
||||
{ default: FunnelHistoryModel },
|
||||
] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/Funnel'),
|
||||
import('@/lib/models/FunnelHistory'),
|
||||
]);
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
151
src/app/api/funnels/[id]/history/route.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// GET /api/funnels/[id]/history - получить историю изменений воронки
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const [
|
||||
{ default: connectMongoDB },
|
||||
{ default: FunnelHistoryModel },
|
||||
] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/FunnelHistory'),
|
||||
]);
|
||||
|
||||
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) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const [
|
||||
{ default: connectMongoDB },
|
||||
{ default: FunnelHistoryModel },
|
||||
] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/FunnelHistory'),
|
||||
]);
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
240
src/app/api/funnels/[id]/route.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// No normalization needed: we require `progressbars` for loaders
|
||||
|
||||
// GET /api/funnels/[id] - получить конкретную воронку
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/Funnel'),
|
||||
]);
|
||||
|
||||
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) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const [
|
||||
{ default: connectMongoDB },
|
||||
{ default: FunnelModel },
|
||||
{ default: FunnelHistoryModel },
|
||||
] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/Funnel'),
|
||||
import('@/lib/models/FunnelHistory'),
|
||||
]);
|
||||
|
||||
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) {
|
||||
// Save as-is; schema expects `progressbars` for loaders
|
||||
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 as FunnelDefinition,
|
||||
actionType: status === 'published' ? 'publish' : 'update',
|
||||
sequenceNumber: nextSequenceNumber,
|
||||
description: actionDescription || 'Воронка обновлена',
|
||||
isBaseline: status === 'published',
|
||||
changeDetails: {
|
||||
action: 'update-funnel',
|
||||
previousValue: previousData,
|
||||
newValue: funnelData as FunnelDefinition
|
||||
}
|
||||
});
|
||||
|
||||
// Очищаем старые записи истории (оставляем 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) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const [
|
||||
{ default: connectMongoDB },
|
||||
{ default: FunnelModel },
|
||||
{ default: FunnelHistoryModel },
|
||||
] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/Funnel'),
|
||||
import('@/lib/models/FunnelHistory'),
|
||||
]);
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
src/app/api/funnels/by-funnel-id/[funnelId]/route.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||
|
||||
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) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { funnelId } = await params;
|
||||
const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/Funnel'),
|
||||
]);
|
||||
|
||||
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) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { funnelId } = await params;
|
||||
const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/Funnel'),
|
||||
]);
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
184
src/app/api/funnels/route.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
|
||||
// GET /api/funnels - получить список всех воронок
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/Funnel'),
|
||||
]);
|
||||
|
||||
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) {
|
||||
if (!isAdminApiEnabled()) {
|
||||
return adminApiDisabledResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
{ default: connectMongoDB },
|
||||
{ default: FunnelModel },
|
||||
{ default: FunnelHistoryModel },
|
||||
] = await Promise.all([
|
||||
import('@/lib/mongodb'),
|
||||
import('@/lib/models/Funnel'),
|
||||
import('@/lib/models/FunnelHistory'),
|
||||
]);
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -25,7 +25,9 @@
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-secondary: var(--chart-secondary);
|
||||
--color-ring: var(--ring);
|
||||
--color-placeholder-foreground: var(--placeholder-foreground);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
@ -53,6 +55,7 @@
|
||||
0px 0px 0px 0px rgba(59, 130, 246, 0.2);
|
||||
--shadow-black-glow: 0px 8px 15px 0px #00000026, 0px 4px 6px 0px #00000014;
|
||||
--shadow-coupon: 0px 20px 40px 0px #0000004d, 0px 8px 16px 0px #00000033;
|
||||
--shadow-destructive: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
:root {
|
||||
@ -80,7 +83,7 @@
|
||||
|
||||
/* Muted - для второстепенного контента */
|
||||
--muted: oklch(0.97 0 0); /* Светло-серый фон */
|
||||
--muted-foreground: oklch(0.59 0.02 260.8); /* #64748B - серый текст */
|
||||
--muted-foreground: oklch(0.5544 0.0407 257.42); /* #64748B - серый текст */
|
||||
|
||||
/* Accent - для акцентов */
|
||||
--accent: oklch(0.97 0 0); /* Светло-серый фон */
|
||||
@ -94,8 +97,12 @@
|
||||
|
||||
/* Border и Input */
|
||||
--border: oklch(0.9288 0.0126 255.51); /* Светло-серая граница */
|
||||
--border-black: oklch(0 0 0); /* Черная граница */
|
||||
--input: oklch(0.922 0 0); /* Светло-серый фон инпутов */
|
||||
--ring: oklch(0.6231 0.188 259.81); /* Синий фокус */
|
||||
--placeholder-foreground: oklch(
|
||||
0.7544 0.0199 282.65
|
||||
); /* #ADAEBC - placeholder текст */
|
||||
|
||||
/* Chart цвета - можно оставить как есть или переопределить */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
@ -103,6 +110,7 @@
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--chart-secondary: oklch(0.881 0.0536 260.65) /* #C4D9FC */;
|
||||
|
||||
/* Sidebar - можно оставить как есть */
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
318
src/components/admin/builder/Canvas/BuilderCanvas.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import { AddScreenDialog } from "../dialogs/AddScreenDialog";
|
||||
import type {
|
||||
ListOptionDefinition,
|
||||
NavigationConditionDefinition,
|
||||
ScreenDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DropIndicator } from "./DropIndicator";
|
||||
import { TransitionRow } from "./TransitionRow";
|
||||
import { TemplateSummary } from "./TemplateSummary";
|
||||
import { VariantSummary } from "./VariantSummary";
|
||||
import { getOptionLabel } from "./utils";
|
||||
import { TEMPLATE_TITLES, OPERATOR_LABELS } from "./constants";
|
||||
|
||||
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={
|
||||
screen.navigation?.isEndScreen
|
||||
? "end"
|
||||
: defaultNext
|
||||
? "default"
|
||||
: "end"
|
||||
}
|
||||
label={
|
||||
screen.navigation?.isEndScreen
|
||||
? "🏁 Финальный экран"
|
||||
: 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/components/admin/builder/Canvas/DropIndicator.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DropIndicatorProps {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function DropIndicator({ isActive }: DropIndicatorProps) {
|
||||
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"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
98
src/components/admin/builder/Canvas/TemplateSummary.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import type { ScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
export interface TemplateSummaryProps {
|
||||
screen: ScreenDefinition;
|
||||
}
|
||||
|
||||
export function TemplateSummary({ screen }: TemplateSummaryProps) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
88
src/components/admin/builder/Canvas/TransitionRow.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TransitionRowProps {
|
||||
type: "default" | "branch" | "end";
|
||||
label: string;
|
||||
targetLabel?: string;
|
||||
targetIndex?: number | null;
|
||||
optionSummaries?: { id: string; label: string }[];
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
109
src/components/admin/builder/Canvas/VariantSummary.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import type {
|
||||
ScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
ListOptionDefinition,
|
||||
NavigationConditionDefinition
|
||||
} from "@/lib/funnel/types";
|
||||
import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
|
||||
import { getOptionLabel } from "./utils";
|
||||
import { OPERATOR_LABELS } from "./constants";
|
||||
|
||||
export interface VariantSummaryProps {
|
||||
screen: ScreenDefinition;
|
||||
screenTitleMap: Record<string, string>;
|
||||
listOptionsMap: Record<string, ListOptionDefinition[]>;
|
||||
}
|
||||
|
||||
export function VariantSummary({
|
||||
screen,
|
||||
screenTitleMap,
|
||||
listOptionsMap,
|
||||
}: VariantSummaryProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
19
src/components/admin/builder/Canvas/constants.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types";
|
||||
|
||||
export const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
|
||||
list: "Список",
|
||||
form: "Форма",
|
||||
info: "Инфо",
|
||||
date: "Дата",
|
||||
coupon: "Купон",
|
||||
email: "Email",
|
||||
loaders: "Загрузка",
|
||||
soulmate: "Портрет партнера",
|
||||
};
|
||||
|
||||
export const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
|
||||
includesAny: "любой из",
|
||||
includesAll: "все из",
|
||||
includesExactly: "точное совпадение",
|
||||
equals: "равно",
|
||||
};
|
||||
17
src/components/admin/builder/Canvas/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// Main component
|
||||
export { BuilderCanvas } from "./BuilderCanvas";
|
||||
|
||||
// Sub-components
|
||||
export { DropIndicator } from "./DropIndicator";
|
||||
export { TransitionRow } from "./TransitionRow";
|
||||
export { TemplateSummary } from "./TemplateSummary";
|
||||
export { VariantSummary } from "./VariantSummary";
|
||||
|
||||
// Types
|
||||
export type { TransitionRowProps } from "./TransitionRow";
|
||||
export type { TemplateSummaryProps } from "./TemplateSummary";
|
||||
export type { VariantSummaryProps } from "./VariantSummary";
|
||||
|
||||
// Utils and constants
|
||||
export { getOptionLabel } from "./utils";
|
||||
export { TEMPLATE_TITLES, OPERATOR_LABELS } from "./constants";
|
||||
9
src/components/admin/builder/Canvas/utils.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { ListOptionDefinition } from "@/lib/funnel/types";
|
||||
|
||||
/**
|
||||
* Получает лейбл опции по ID
|
||||
*/
|
||||
export function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
|
||||
const option = options.find((item) => item.id === optionId);
|
||||
return option ? option.label : optionId;
|
||||
}
|
||||
564
src/components/admin/builder/Sidebar/BuilderSidebar.tsx
Normal file
@ -0,0 +1,564 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||||
import { ScreenVariantsConfig } from "../forms/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";
|
||||
import { Section } from "./Section";
|
||||
import { ValidationSummary } from "./ValidationSummary";
|
||||
import { isListScreen, type ValidationIssues } from "./types";
|
||||
|
||||
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 ?? [],
|
||||
isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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 items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScreen.navigation?.isEndScreen ?? false}
|
||||
onChange={(e) => {
|
||||
updateNavigation(selectedScreen, { isEndScreen: e.target.checked });
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">Финальный экран</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Этот экран завершает воронку (переход не требуется)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
|
||||
{!selectedScreen.navigation?.isEndScreen && (
|
||||
<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 && !selectedScreen.navigation?.isEndScreen && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
78
src/components/admin/builder/Sidebar/Section.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
alwaysExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
alwaysExpanded = false,
|
||||
}: SectionProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
34
src/components/admin/builder/Sidebar/ValidationSummary.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { ValidationIssues } from "./types";
|
||||
|
||||
export interface ValidationSummaryProps {
|
||||
issues: ValidationIssues;
|
||||
}
|
||||
|
||||
export function ValidationSummary({ issues }: ValidationSummaryProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
11
src/components/admin/builder/Sidebar/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// Main component
|
||||
export { BuilderSidebar } from "./BuilderSidebar";
|
||||
|
||||
// Sub-components
|
||||
export { Section } from "./Section";
|
||||
export { ValidationSummary } from "./ValidationSummary";
|
||||
|
||||
// Types and utilities
|
||||
export { isListScreen } from "./types";
|
||||
export type { ValidationIssues, SectionProps } from "./types";
|
||||
export type { ValidationSummaryProps } from "./ValidationSummary";
|
||||
27
src/components/admin/builder/Sidebar/types.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { validateBuilderState } from "@/lib/admin/builder/validation";
|
||||
|
||||
export type ValidationIssues = ReturnType<typeof validateBuilderState>["issues"];
|
||||
|
||||
export interface SectionProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
alwaysExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard для проверки что экран является list экраном
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
154
src/components/admin/builder/dialogs/AddScreenDialog.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
List,
|
||||
FormInput,
|
||||
Info,
|
||||
Calendar,
|
||||
Ticket,
|
||||
Loader,
|
||||
Heart,
|
||||
Mail
|
||||
} 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: "email" as const,
|
||||
title: "Email",
|
||||
description: "Ввод и валидация email адреса",
|
||||
icon: Mail,
|
||||
color: "bg-teal-50 text-teal-600 dark:bg-teal-900/20 dark:text-teal-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: "loaders" as const,
|
||||
title: "Загрузка",
|
||||
description: "Анимированные прогресс-бары с этапами обработки",
|
||||
icon: Loader,
|
||||
color: "bg-cyan-50 text-cyan-600 dark:bg-cyan-900/20 dark:text-cyan-400",
|
||||
},
|
||||
{
|
||||
template: "soulmate" as const,
|
||||
title: "Портрет партнера",
|
||||
description: "Отображение результата анализа и портрета партнера",
|
||||
icon: Heart,
|
||||
color: "bg-pink-50 text-pink-600 dark:bg-pink-900/20 dark:text-pink-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>
|
||||
);
|
||||
}
|
||||
2
src/components/admin/builder/dialogs/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Dialog components for builder interface
|
||||
export { AddScreenDialog } from "./AddScreenDialog";
|
||||
241
src/components/admin/builder/forms/AgeSelector.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { AGE_GROUPS, GENERATION_GROUPS, parseAgeRange } from "@/lib/age-utils";
|
||||
|
||||
interface AgeSelectorProps {
|
||||
selectedValues: string[];
|
||||
onToggleValue: (value: string) => void;
|
||||
onAddCustomValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export function AgeSelector({ selectedValues, onToggleValue, onAddCustomValue }: AgeSelectorProps) {
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
const handleAddCustom = () => {
|
||||
if (customValue.trim()) {
|
||||
onAddCustomValue(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const isValueSelected = (value: string) => selectedValues.includes(value);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 🎂 ВОЗРАСТНЫЕ ГРУППЫ */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-foreground">🎂 Возрастные группы</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AGE_GROUPS.map((group) => {
|
||||
const isSelected = isValueSelected(group.id);
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => onToggleValue(group.id)}
|
||||
className={`
|
||||
relative group p-3 rounded-lg border-2 transition-all duration-200
|
||||
hover:shadow-md text-left
|
||||
${isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border hover:border-primary/50"
|
||||
}
|
||||
`}
|
||||
title={group.description}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Возрастной диапазон */}
|
||||
<span className="text-lg">🎂</span>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium text-sm ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{group.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор выбранного */}
|
||||
{isSelected && (
|
||||
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🚀 ПОКОЛЕНИЯ */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-foreground">🚀 Поколения</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{GENERATION_GROUPS.map((generation) => {
|
||||
const isSelected = isValueSelected(generation.id);
|
||||
return (
|
||||
<button
|
||||
key={generation.id}
|
||||
onClick={() => onToggleValue(generation.id)}
|
||||
className={`
|
||||
relative group p-3 rounded-lg border-2 transition-all duration-200
|
||||
hover:shadow-md text-left
|
||||
${isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border hover:border-primary/50"
|
||||
}
|
||||
`}
|
||||
title={`Родились ${generation.minYear}-${generation.maxYear}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Иконка поколения */}
|
||||
<span className="text-lg">
|
||||
{generation.id === 'gen-z' ? '📱' :
|
||||
generation.id === 'millennials' ? '💻' :
|
||||
generation.id === 'gen-x' ? '📺' :
|
||||
generation.id === 'boomers' ? '📻' : '📰'}
|
||||
</span>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium text-sm ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}>
|
||||
{generation.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{generation.minYear}-{generation.maxYear} • {generation.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор выбранного */}
|
||||
{isSelected && (
|
||||
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Или добавить точный возраст/диапазон:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
placeholder="25, 18-21, 30-35, 60+ и т.д."
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCustom}
|
||||
disabled={!customValue.trim()}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Подсказки по форматам */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<strong>Примеры:</strong> 25 (точный возраст), 18-21 (диапазон), 60+ (от 60 лет), age-25 (альтернативный формат)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */}
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Выбранные возрастные условия:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedValues.map((value) => {
|
||||
// Ищем в возрастных группах
|
||||
const ageGroup = AGE_GROUPS.find(group => group.id === value);
|
||||
if (ageGroup) {
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
|
||||
>
|
||||
🎂 {ageGroup.name}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Ищем в поколениях
|
||||
const generation = GENERATION_GROUPS.find(gen => gen.id === value);
|
||||
if (generation) {
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full"
|
||||
>
|
||||
🚀 {generation.name}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Кастомное значение
|
||||
const range = parseAgeRange(value);
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full"
|
||||
>
|
||||
🎯 {range ? `${range.min}-${range.max === 120 ? '+' : range.max}` : value}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 💡 ПОДСКАЗКА */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
<strong>💡 Как это работает:</strong> Система автоматически рассчитывает возраст из
|
||||
даты рождения пользователя. Выберите возрастные группы или поколения, при которых
|
||||
должен показываться этот вариант экрана. Можно комбинировать разные условия.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
src/components/admin/builder/forms/EmailDomainSelector.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
|
||||
// 📧 ПОПУЛЯРНЫЕ EMAIL ДОМЕНЫ
|
||||
const POPULAR_DOMAINS = [
|
||||
{ id: "@gmail.com", name: "Gmail", icon: "📧", description: "Google Mail" },
|
||||
{ id: "@yahoo.com", name: "Yahoo", icon: "🟣", description: "Yahoo Mail" },
|
||||
{ id: "@hotmail.com", name: "Hotmail", icon: "🔵", description: "Microsoft Hotmail" },
|
||||
{ id: "@outlook.com", name: "Outlook", icon: "📬", description: "Microsoft Outlook" },
|
||||
{ id: "@icloud.com", name: "iCloud", icon: "☁️", description: "Apple iCloud" },
|
||||
{ id: "@mail.ru", name: "Mail.ru", icon: "🔴", description: "Mail.ru" },
|
||||
{ id: "@yandex.ru", name: "Yandex", icon: "🟡", description: "Яндекс.Почта" },
|
||||
{ id: "@rambler.ru", name: "Rambler", icon: "🟢", description: "Rambler" },
|
||||
] as const;
|
||||
|
||||
interface EmailDomainSelectorProps {
|
||||
selectedValues: string[];
|
||||
onToggleValue: (value: string) => void;
|
||||
onAddCustomValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export function EmailDomainSelector({ selectedValues, onToggleValue, onAddCustomValue }: EmailDomainSelectorProps) {
|
||||
const [customDomain, setCustomDomain] = useState("");
|
||||
|
||||
const handleAddCustom = () => {
|
||||
let domain = customDomain.trim();
|
||||
if (domain) {
|
||||
// Автоматически добавляем @ если его нет
|
||||
if (!domain.startsWith("@")) {
|
||||
domain = "@" + domain;
|
||||
}
|
||||
onAddCustomValue(domain);
|
||||
setCustomDomain("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 📧 ПОПУЛЯРНЫЕ ДОМЕНЫ */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{POPULAR_DOMAINS.map((domain) => {
|
||||
const isSelected = selectedValues.includes(domain.id);
|
||||
return (
|
||||
<button
|
||||
key={domain.id}
|
||||
onClick={() => onToggleValue(domain.id)}
|
||||
className={`
|
||||
relative group p-3 rounded-lg border-2 transition-all duration-200
|
||||
hover:shadow-md text-left
|
||||
${isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border hover:border-primary/50"
|
||||
}
|
||||
`}
|
||||
title={domain.description}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Иконка */}
|
||||
<span className="text-lg">{domain.icon}</span>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium text-sm ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}>
|
||||
{domain.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{domain.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор выбранного */}
|
||||
{isSelected && (
|
||||
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ДОМЕНОВ */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Или добавить другой домен:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
placeholder="example.com (@ добавится автоматически)"
|
||||
value={customDomain}
|
||||
onChange={(e) => setCustomDomain(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCustom}
|
||||
disabled={!customDomain.trim()}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 📋 ВЫБРАННЫЕ ДОМЕНЫ */}
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Выбранные домены:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedValues.map((value) => {
|
||||
const popularDomain = POPULAR_DOMAINS.find(domain => domain.id === value);
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
|
||||
>
|
||||
{popularDomain ? (
|
||||
<>
|
||||
<span>{popularDomain.icon}</span>
|
||||
<span>{popularDomain.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>📧 {value}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 💡 ПОДСКАЗКА */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
<strong>💡 Как это работает:</strong> Система проверяет домен email адреса пользователя.
|
||||
Например, если пользователь ввел “user@gmail.com”, то значение будет “@gmail.com”.
|
||||
Выберите домены, при которых должен показываться этот вариант экрана.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
692
src/components/admin/builder/forms/ScreenVariantsConfig.tsx
Normal file
@ -0,0 +1,692 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ZodiacSelector } from "./ZodiacSelector";
|
||||
import { EmailDomainSelector } from "./EmailDomainSelector";
|
||||
import { AgeSelector } from "./AgeSelector";
|
||||
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]);
|
||||
|
||||
// 🎯 ПОКАЗЫВАЕМ ВСЕ ЭКРАНЫ, не только list
|
||||
const availableScreens = useMemo(
|
||||
() => allScreens.filter((candidate) => candidate.id !== screen.id), // Исключаем сам экран
|
||||
[allScreens, screen.id]
|
||||
);
|
||||
|
||||
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(
|
||||
(variantIndex: number, conditionIndex: number, updates: Partial<VariantCondition>) => {
|
||||
const variant = variants[variantIndex];
|
||||
const updatedConditions = [...variant.conditions];
|
||||
updatedConditions[conditionIndex] = {
|
||||
...ensureCondition(variant, screen.id),
|
||||
...variant.conditions[conditionIndex],
|
||||
...updates,
|
||||
};
|
||||
updateVariant(variantIndex, { conditions: updatedConditions });
|
||||
},
|
||||
[screen.id, updateVariant, variants]
|
||||
);
|
||||
|
||||
const addCondition = useCallback(
|
||||
(variantIndex: number) => {
|
||||
const variant = variants[variantIndex];
|
||||
const fallbackScreen = listScreens[0] ?? (screen.template === "list" ? (screen as ListBuilderScreen) : undefined);
|
||||
|
||||
if (!fallbackScreen) return;
|
||||
|
||||
const firstOptionId = fallbackScreen.list.options[0]?.id;
|
||||
const newCondition: VariantCondition = {
|
||||
screenId: fallbackScreen.id,
|
||||
operator: "includesAny",
|
||||
optionIds: firstOptionId ? [firstOptionId] : [],
|
||||
};
|
||||
|
||||
updateVariant(variantIndex, {
|
||||
conditions: [...variant.conditions, newCondition],
|
||||
});
|
||||
},
|
||||
[variants, listScreens, screen, updateVariant]
|
||||
);
|
||||
|
||||
const removeCondition = useCallback(
|
||||
(variantIndex: number, conditionIndex: number) => {
|
||||
const variant = variants[variantIndex];
|
||||
if (variant.conditions.length <= 1) return; // Минимум одно условие должно остаться
|
||||
|
||||
const updatedConditions = variant.conditions.filter((_, index) => index !== conditionIndex);
|
||||
updateVariant(variantIndex, { conditions: updatedConditions });
|
||||
},
|
||||
[variants, updateVariant]
|
||||
);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(variantIndex: number, conditionIndex: number, optionId: string) => {
|
||||
const variant = variants[variantIndex];
|
||||
const condition = variant.conditions[conditionIndex] || ensureCondition(variant, screen.id);
|
||||
const optionIds = new Set(condition.optionIds ?? []);
|
||||
if (optionIds.has(optionId)) {
|
||||
optionIds.delete(optionId);
|
||||
} else {
|
||||
optionIds.add(optionId);
|
||||
}
|
||||
|
||||
updateCondition(variantIndex, conditionIndex, { optionIds: Array.from(optionIds) });
|
||||
},
|
||||
[screen.id, updateCondition, variants]
|
||||
);
|
||||
|
||||
// 🎯 НОВАЯ ЛОГИКА: поддержка всех экранов и типов условий
|
||||
const handleScreenChange = useCallback(
|
||||
(variantIndex: number, conditionIndex: number, screenId: string) => {
|
||||
const targetScreen = availableScreens.find((candidate) => candidate.id === screenId);
|
||||
if (!targetScreen) return;
|
||||
|
||||
// Определяем тип условия по типу экрана
|
||||
if (targetScreen.template === "list") {
|
||||
const listScreen = targetScreen as ListBuilderScreen;
|
||||
const defaultOption = listScreen.list.options[0]?.id;
|
||||
updateCondition(variantIndex, conditionIndex, {
|
||||
screenId,
|
||||
conditionType: "options",
|
||||
optionIds: defaultOption ? [defaultOption] : [],
|
||||
values: undefined, // Очищаем values при переключении на options
|
||||
});
|
||||
} else {
|
||||
// Для всех остальных экранов используем values
|
||||
updateCondition(variantIndex, conditionIndex, {
|
||||
screenId,
|
||||
conditionType: "values",
|
||||
values: [],
|
||||
optionIds: undefined, // Очищаем optionIds при переключении на values
|
||||
});
|
||||
}
|
||||
},
|
||||
[availableScreens, updateCondition]
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(variantIndex: number, conditionIndex: number, operator: VariantCondition["operator"]) => {
|
||||
updateCondition(variantIndex, conditionIndex, { operator });
|
||||
},
|
||||
[updateCondition]
|
||||
);
|
||||
|
||||
// 🎯 НОВЫЕ ФУНКЦИИ для работы с values
|
||||
const toggleValue = useCallback(
|
||||
(variantIndex: number, conditionIndex: number, value: string) => {
|
||||
const variant = variants[variantIndex];
|
||||
const condition = variant.conditions[conditionIndex] || ensureCondition(variant, screen.id);
|
||||
const values = new Set(condition.values ?? []);
|
||||
if (values.has(value)) {
|
||||
values.delete(value);
|
||||
} else {
|
||||
values.add(value);
|
||||
}
|
||||
updateCondition(variantIndex, conditionIndex, { values: Array.from(values) });
|
||||
},
|
||||
[screen.id, updateCondition, variants]
|
||||
);
|
||||
|
||||
const addCustomValue = useCallback(
|
||||
(variantIndex: number, conditionIndex: number, value: string) => {
|
||||
if (!value.trim()) return;
|
||||
const variant = variants[variantIndex];
|
||||
const condition = variant.conditions[conditionIndex] || ensureCondition(variant, screen.id);
|
||||
const values = new Set(condition.values ?? []);
|
||||
values.add(value.trim());
|
||||
updateCondition(variantIndex, conditionIndex, { values: Array.from(values) });
|
||||
},
|
||||
[screen.id, updateCondition, variants]
|
||||
);
|
||||
|
||||
const handleOverridesChange = useCallback(
|
||||
(index: number, overrides: VariantDefinition["overrides"]) => {
|
||||
updateVariant(index, { overrides });
|
||||
},
|
||||
[updateVariant]
|
||||
);
|
||||
|
||||
// 🎯 НОВАЯ ФУНКЦИЯ: определение типа экрана для красивого отображения
|
||||
const getScreenTypeLabel = useCallback((screenId: string) => {
|
||||
const targetScreen = availableScreens.find(s => s.id === screenId);
|
||||
if (!targetScreen) return "Неизвестный";
|
||||
|
||||
const templateLabels: Record<ScreenDefinition["template"], string> = {
|
||||
list: "📝 Список",
|
||||
date: "📅 Дата рождения",
|
||||
email: "📧 Email",
|
||||
form: "📋 Форма",
|
||||
info: "ℹ️ Информация",
|
||||
coupon: "🎟️ Купон",
|
||||
loaders: "⏳ Загрузка",
|
||||
soulmate: "💖 Портрет",
|
||||
};
|
||||
|
||||
return templateLabels[targetScreen.template] || targetScreen.template;
|
||||
}, [availableScreens]);
|
||||
|
||||
const renderVariantSummary = useCallback(
|
||||
(variant: VariantDefinition) => {
|
||||
const condition = ensureCondition(variant, screen.id);
|
||||
const conditionType = condition.conditionType ?? "options";
|
||||
|
||||
// Получаем данные в зависимости от типа условия
|
||||
const summaries = conditionType === "values"
|
||||
? (condition.values ?? [])
|
||||
: (condition.optionIds ?? []).map((optionId) => {
|
||||
const options = optionMap[condition.screenId] ?? [];
|
||||
const option = options.find((item) => item.id === optionId);
|
||||
return option?.label ?? optionId;
|
||||
});
|
||||
|
||||
const screenTitle = availableScreens.find((candidate) => candidate.id === condition.screenId)?.title.text;
|
||||
const screenTypeLabel = getScreenTypeLabel(condition.screenId);
|
||||
|
||||
const operatorLabel = (() => {
|
||||
switch (condition.operator) {
|
||||
case "includesAll":
|
||||
return "все из";
|
||||
case "includesExactly":
|
||||
return "точное совпадение";
|
||||
case "equals":
|
||||
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-blue-50 border border-blue-200 px-2 py-0.5 text-[11px] text-blue-700">
|
||||
{screenTypeLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground/80">{operatorLabel}</span>
|
||||
</div>
|
||||
<div className="text-[11px]">
|
||||
<span className="text-muted-foreground">Экран: </span>
|
||||
<span className="text-foreground font-medium">{screenTitle ?? condition.screenId}</span>
|
||||
</div>
|
||||
{summaries.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{summaries.map((item) => (
|
||||
<span key={item} className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
|
||||
{item}
|
||||
</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>
|
||||
);
|
||||
},
|
||||
[availableScreens, optionMap, screen.id, getScreenTypeLabel]
|
||||
);
|
||||
|
||||
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={availableScreens.length === 0}>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{availableScreens.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-green-200 bg-green-50 p-3 text-xs text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200">
|
||||
<p><strong>✨ Поддержка множественных условий:</strong> Теперь вы можете добавить несколько условий для одного варианта. Все условия должны выполняться одновременно (логическое И).</p>
|
||||
</div>
|
||||
|
||||
{/* 🎯 МНОЖЕСТВЕННЫЕ УСЛОВИЯ */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Условия ({variant.conditions.length})
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => addCondition(index)}
|
||||
disabled={availableScreens.length === 0}
|
||||
>
|
||||
+ Добавить условие
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{variant.conditions.map((condition, conditionIndex) => (
|
||||
<div key={conditionIndex} className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Условие #{conditionIndex + 1}
|
||||
</span>
|
||||
{variant.conditions.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-destructive"
|
||||
onClick={() => removeCondition(index, conditionIndex)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
)}
|
||||
</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, conditionIndex, event.target.value)}
|
||||
>
|
||||
{availableScreens.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{getScreenTypeLabel(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, conditionIndex, event.target.value as VariantCondition["operator"])
|
||||
}
|
||||
>
|
||||
<option value="includesAny">любой из</option>
|
||||
<option value="includesAll">все из</option>
|
||||
<option value="includesExactly">точное совпадение</option>
|
||||
<option value="equals">равно (для одиночных значений)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 🎯 НОВЫЙ UI: поддержка разных типов экранов */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Условия для {getScreenTypeLabel(condition.screenId)}
|
||||
</span>
|
||||
|
||||
{(() => {
|
||||
const targetScreen = availableScreens.find(s => s.id === condition.screenId);
|
||||
|
||||
if (targetScreen?.template === "list") {
|
||||
// 📝 LIST ЭКРАНЫ - показываем опции
|
||||
return 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, conditionIndex, option.id)}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground"> ({option.id})</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
} else if (targetScreen?.template === "date") {
|
||||
// 📅 DATE ЭКРАНЫ - показываем селекторы возраста и знаков зодиака
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 🎂 СЕЛЕКТОР ВОЗРАСТА */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-foreground mb-3">🎂 Возрастные условия</h5>
|
||||
<AgeSelector
|
||||
selectedValues={condition.values?.filter(v =>
|
||||
v.includes('age-') || v.includes('-') || ['gen-z', 'millennials', 'gen-x', 'boomers', 'silent'].includes(v)
|
||||
) ?? []}
|
||||
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
|
||||
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ♈ СЕЛЕКТОР ЗНАКОВ ЗОДИАКА */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-foreground mb-3">♈ Знаки зодиака</h5>
|
||||
<ZodiacSelector
|
||||
selectedValues={condition.values?.filter(v =>
|
||||
!v.includes('age-') && !v.includes('-') && !['gen-z', 'millennials', 'gen-x', 'boomers', 'silent'].includes(v)
|
||||
) ?? []}
|
||||
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
|
||||
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (targetScreen?.template === "email") {
|
||||
// 📧 EMAIL ЭКРАНЫ - показываем селектор доменов
|
||||
return (
|
||||
<EmailDomainSelector
|
||||
selectedValues={condition.values ?? []}
|
||||
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
|
||||
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// 🎯 ОБЩИЕ ЭКРАНЫ - простой ввод значений
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-muted-foreground bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<strong>💡 Как работает:</strong> Для экранов типа “{targetScreen?.template}”
|
||||
система сравнивает сохраненные ответы пользователя с указанными значениями.
|
||||
</div>
|
||||
|
||||
{/* Показываем выбранные значения */}
|
||||
{(condition.values ?? []).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Выбранные значения:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(condition.values ?? []).map((value) => (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
|
||||
>
|
||||
{value}
|
||||
<button
|
||||
onClick={() => toggleValue(index, conditionIndex, value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Поле для добавления новых значений */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Добавить значение:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Введите значение для сравнения..."
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
if (value) {
|
||||
addCustomValue(index, conditionIndex, value);
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
155
src/components/admin/builder/forms/ZodiacSelector.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
|
||||
// 🔮 ЗНАКИ ЗОДИАКА С КРАСИВЫМИ ИКОНКАМИ
|
||||
const ZODIAC_SIGNS = [
|
||||
{ id: "aries", name: "Овен", icon: "♈", dates: "21 марта - 19 апреля" },
|
||||
{ id: "taurus", name: "Телец", icon: "♉", dates: "20 апреля - 20 мая" },
|
||||
{ id: "gemini", name: "Близнецы", icon: "♊", dates: "21 мая - 20 июня" },
|
||||
{ id: "cancer", name: "Рак", icon: "♋", dates: "21 июня - 22 июля" },
|
||||
{ id: "leo", name: "Лев", icon: "♌", dates: "23 июля - 22 августа" },
|
||||
{ id: "virgo", name: "Дева", icon: "♍", dates: "23 августа - 22 сентября" },
|
||||
{ id: "libra", name: "Весы", icon: "♎", dates: "23 сентября - 22 октября" },
|
||||
{ id: "scorpio", name: "Скорпион", icon: "♏", dates: "23 октября - 21 ноября" },
|
||||
{ id: "sagittarius", name: "Стрелец", icon: "♐", dates: "22 ноября - 21 декабря" },
|
||||
{ id: "capricorn", name: "Козерог", icon: "♑", dates: "22 декабря - 19 января" },
|
||||
{ id: "aquarius", name: "Водолей", icon: "♒", dates: "20 января - 18 февраля" },
|
||||
{ id: "pisces", name: "Рыбы", icon: "♓", dates: "19 февраля - 20 марта" },
|
||||
] as const;
|
||||
|
||||
interface ZodiacSelectorProps {
|
||||
selectedValues: string[];
|
||||
onToggleValue: (value: string) => void;
|
||||
onAddCustomValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ZodiacSelector({ selectedValues, onToggleValue, onAddCustomValue }: ZodiacSelectorProps) {
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
const handleAddCustom = () => {
|
||||
if (customValue.trim()) {
|
||||
onAddCustomValue(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 🔮 КРАСИВАЯ СЕТКА ЗНАКОВ ЗОДИАКА */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ZODIAC_SIGNS.map((sign) => {
|
||||
const isSelected = selectedValues.includes(sign.id);
|
||||
return (
|
||||
<button
|
||||
key={sign.id}
|
||||
onClick={() => onToggleValue(sign.id)}
|
||||
className={`
|
||||
relative group p-3 rounded-lg border-2 transition-all duration-200
|
||||
hover:shadow-md hover:scale-105 text-center
|
||||
${isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border hover:border-primary/50"
|
||||
}
|
||||
`}
|
||||
title={sign.dates}
|
||||
>
|
||||
{/* Иконка знака */}
|
||||
<div className={`text-2xl mb-1 transition-all duration-200 ${
|
||||
isSelected ? "scale-110" : "group-hover:scale-105"
|
||||
}`}>
|
||||
{sign.icon}
|
||||
</div>
|
||||
|
||||
{/* Название */}
|
||||
<div className={`text-xs font-medium transition-colors duration-200 ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}>
|
||||
{sign.name}
|
||||
</div>
|
||||
|
||||
{/* Индикатор выбранного */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center">
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Или добавить пользовательское значение:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
placeholder="Например: virgo или другое значение..."
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCustom}
|
||||
disabled={!customValue.trim()}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */}
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Выбранные значения:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedValues.map((value) => {
|
||||
const zodiacSign = ZODIAC_SIGNS.find(sign => sign.id === value);
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
|
||||
>
|
||||
{zodiacSign ? (
|
||||
<>
|
||||
<span>{zodiacSign.icon}</span>
|
||||
<span>{zodiacSign.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 💡 ПОДСКАЗКА */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
<strong>💡 Как это работает:</strong> Знак зодиака автоматически определяется из
|
||||
даты рождения пользователя. Выберите знаки, при которых должен показываться
|
||||
этот вариант экрана.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/components/admin/builder/forms/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Form components and selectors for builder interface
|
||||
export { AgeSelector } from "./AgeSelector";
|
||||
export { EmailDomainSelector } from "./EmailDomainSelector";
|
||||
export { ZodiacSelector } from "./ZodiacSelector";
|
||||
export { ScreenVariantsConfig } from "./ScreenVariantsConfig";
|
||||
22
src/components/admin/builder/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// Builder interface components organized by category
|
||||
|
||||
// Layout components (main UI blocks)
|
||||
export * from "./layout";
|
||||
|
||||
// Canvas components (screen flow visualization)
|
||||
export * from "./Canvas";
|
||||
|
||||
// Sidebar components (screen configuration)
|
||||
export * from "./Sidebar";
|
||||
|
||||
// Dialog components (modal windows)
|
||||
export * from "./dialogs";
|
||||
|
||||
// Form components (selectors and configuration forms)
|
||||
export * from "./forms";
|
||||
|
||||
// Template configuration components
|
||||
export * from "./templates";
|
||||
|
||||
// Provider components (state management)
|
||||
export * from "./providers";
|
||||
155
src/components/admin/builder/layout/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;
|
||||
}
|
||||
258
src/components/admin/builder/layout/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 "../providers/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>
|
||||
);
|
||||
}
|
||||
3
src/components/admin/builder/layout/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// Layout components for builder interface
|
||||
export { BuilderTopBar } from "./BuilderTopBar";
|
||||
export { BuilderPreview } from "./BuilderPreview";
|
||||
@ -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;
|
||||
}
|
||||
2
src/components/admin/builder/providers/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Provider components for builder state management
|
||||
export { BuilderUndoRedoProvider } from "./BuilderUndoRedoProvider";
|
||||
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;
|
||||
|
||||
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
@ -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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
79
src/components/admin/builder/templates/EmailScreenConfig.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { EmailScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface EmailScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "email" };
|
||||
onUpdate: (updates: Partial<EmailScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function EmailScreenConfig({ screen, onUpdate }: EmailScreenConfigProps) {
|
||||
const updateEmailInput = (updates: Partial<EmailScreenDefinition["emailInput"]>) => {
|
||||
onUpdate({
|
||||
emailInput: {
|
||||
...screen.emailInput,
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateImage = (updates: Partial<EmailScreenDefinition["image"]>) => {
|
||||
onUpdate({
|
||||
image: screen.image ? {
|
||||
...screen.image,
|
||||
...updates,
|
||||
} : { src: "", ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Настройки поля Email</h4>
|
||||
<div className="space-y-3">
|
||||
<TextInput
|
||||
label="Подпись поля"
|
||||
placeholder="Email"
|
||||
value={screen.emailInput?.label || ""}
|
||||
onChange={(e) => updateEmailInput({ label: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Плейсхолдер"
|
||||
placeholder="Enter your email"
|
||||
value={screen.emailInput?.placeholder || ""}
|
||||
onChange={(e) => updateEmailInput({ placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Изображение (вариативное)</h4>
|
||||
<div className="space-y-3">
|
||||
<TextInput
|
||||
label="URL изображения"
|
||||
placeholder="/female-portrait.jpg"
|
||||
value={screen.image?.src || ""}
|
||||
onChange={(e) => updateImage({ src: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground bg-blue-50 border border-blue-200 rounded-lg p-3 mt-3">
|
||||
<strong>💡 Вариация изображений:</strong> Базовое изображение настраивается здесь.
|
||||
Alt текст, размеры (164x245) и стили зашиты в верстку согласно дизайну.
|
||||
Альтернативные варианты настраиваются в секции “Вариативность” → добавить вариант → выбрать условие “gender = male” → переопределить поле image.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Информация</h4>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>• Банер безопасности отображается автоматически с общим текстом для воронки</p>
|
||||
<p>• PrivacyTermsConsent настраивается через bottomActionButton.showPrivacyTermsConsent</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
112
src/components/admin/builder/templates/InfoScreenConfig.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { MarkupPreview } from "@/components/ui/MarkupText/MarkupText";
|
||||
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;
|
||||
|
||||
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>
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
|
||||
{/* 🎨 ПРЕВЬЮ РАЗМЕТКИ */}
|
||||
{infoScreen.description?.text && (
|
||||
<MarkupPreview text={infoScreen.description.text} />
|
||||
)}
|
||||
</div>
|
||||
</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
@ -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;
|
||||
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>
|
||||
);
|
||||
}
|
||||
167
src/components/admin/builder/templates/LoadersScreenConfig.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { LoadersScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface LoadersScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "loaders" };
|
||||
onUpdate: (updates: Partial<LoadersScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigProps) {
|
||||
const updateProgressbars = (updates: Partial<LoadersScreenDefinition["progressbars"]>) => {
|
||||
onUpdate({
|
||||
progressbars: {
|
||||
items: screen.progressbars?.items || [],
|
||||
transitionDuration: screen.progressbars?.transitionDuration || 5000,
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addProgressbarItem = () => {
|
||||
const currentItems = screen.progressbars?.items || [];
|
||||
updateProgressbars({
|
||||
items: [
|
||||
...currentItems,
|
||||
{
|
||||
title: `Step ${currentItems.length + 1}`,
|
||||
subtitle: "",
|
||||
processingTitle: `Processing step ${currentItems.length + 1}...`,
|
||||
processingSubtitle: "",
|
||||
completedTitle: `Step ${currentItems.length + 1} completed`,
|
||||
completedSubtitle: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeProgressbarItem = (index: number) => {
|
||||
const currentItems = screen.progressbars?.items || [];
|
||||
updateProgressbars({
|
||||
items: currentItems.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
const updateProgressbarItem = (
|
||||
index: number,
|
||||
updates: Partial<LoadersScreenDefinition["progressbars"]["items"][0]>
|
||||
) => {
|
||||
const currentItems = screen.progressbars?.items || [];
|
||||
const updatedItems = currentItems.map((item, i) =>
|
||||
i === index ? { ...item, ...updates } : item
|
||||
);
|
||||
updateProgressbars({ items: updatedItems });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Настройки анимации</h4>
|
||||
<TextInput
|
||||
label="Длительность анимации (мс)"
|
||||
type="number"
|
||||
placeholder="5000"
|
||||
value={screen.progressbars?.transitionDuration?.toString() || "5000"}
|
||||
onChange={(e) => updateProgressbars({ transitionDuration: parseInt(e.target.value) || 5000 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Шаги загрузки</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addProgressbarItem}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить шаг
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(screen.progressbars?.items || []).map((item, index) => (
|
||||
<div key={index} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-slate-600">Шаг {index + 1}</h5>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removeProgressbarItem(index)}
|
||||
className="text-red-600 hover:text-red-700 text-sm px-2 py-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput
|
||||
label="Заголовок"
|
||||
placeholder="Step 1"
|
||||
value={item.title || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { title: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Подзаголовок"
|
||||
placeholder="Описание шага"
|
||||
value={item.subtitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { subtitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput
|
||||
label="Текст во время обработки"
|
||||
placeholder="Processing..."
|
||||
value={item.processingTitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { processingTitle: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Подтекст во время обработки"
|
||||
placeholder=""
|
||||
value={item.processingSubtitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { processingSubtitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput
|
||||
label="Текст при завершении"
|
||||
placeholder="Completed!"
|
||||
value={item.completedTitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { completedTitle: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Подтекст при завершении"
|
||||
placeholder=""
|
||||
value={item.completedSubtitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { completedSubtitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(screen.progressbars?.items || []).length === 0 && (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<p>Нет шагов загрузки</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addProgressbarItem}
|
||||
className="mt-2 text-sm px-3 py-1"
|
||||
>
|
||||
Добавить первый шаг
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface SoulmatePortraitScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "soulmate" };
|
||||
onUpdate: (updates: Partial<SoulmatePortraitScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortraitScreenConfigProps) {
|
||||
const updateDescription = (updates: Partial<SoulmatePortraitScreenDefinition["description"]>) => {
|
||||
onUpdate({
|
||||
description: screen.description ? {
|
||||
...screen.description,
|
||||
...updates,
|
||||
} : { text: "", ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Описание портрета</h4>
|
||||
<TextInput
|
||||
label="Текст описания"
|
||||
placeholder="Ваш идеальный партнер найден на основе анализа ваших ответов"
|
||||
value={screen.description?.text || ""}
|
||||
onChange={(e) => updateDescription({ text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Информация</h4>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>• PrivacyTermsConsent настраивается через bottomActionButton.showPrivacyTermsConsent</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-2">💡 Назначение экрана</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
Экран “Soulmate Portrait” предназначен для отображения результатов анализа совместимости
|
||||
или характеристик идеального партнера на основе ответов пользователя в воронке.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
477
src/components/admin/builder/templates/TemplateConfig.tsx
Normal file
@ -0,0 +1,477 @@
|
||||
"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 { EmailScreenConfig } from "./EmailScreenConfig";
|
||||
import { LoadersScreenConfig } from "./LoadersScreenConfig";
|
||||
import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type {
|
||||
ScreenDefinition,
|
||||
InfoScreenDefinition,
|
||||
DateScreenDefinition,
|
||||
CouponScreenDefinition,
|
||||
FormScreenDefinition,
|
||||
ListScreenDefinition,
|
||||
EmailScreenDefinition,
|
||||
LoadersScreenDefinition,
|
||||
SoulmatePortraitScreenDefinition,
|
||||
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 showPrivacyTermsConsent = value?.showPrivacyTermsConsent ?? false;
|
||||
|
||||
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 && !newValue.showPrivacyTermsConsent) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivacyTermsToggle = (checked: boolean) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
const newValue = {
|
||||
...value,
|
||||
showPrivacyTermsConsent: checked || undefined,
|
||||
};
|
||||
|
||||
// Убираем undefined поля для чистоты
|
||||
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) {
|
||||
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>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPrivacyTermsConsent}
|
||||
onChange={(event) => handlePrivacyTermsToggle(event.target.checked)}
|
||||
/>
|
||||
Показывать PrivacyTermsConsent под кнопкой
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
{template === "email" && (
|
||||
<EmailScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "email" }}
|
||||
onUpdate={onUpdate as (updates: Partial<EmailScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "loaders" && (
|
||||
<LoadersScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "loaders" }}
|
||||
onUpdate={onUpdate as (updates: Partial<LoadersScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "soulmate" && (
|
||||
<SoulmatePortraitScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "soulmate" }}
|
||||
onUpdate={onUpdate as (updates: Partial<SoulmatePortraitScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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
@ -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,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { CouponTemplate } from "./CouponTemplate";
|
||||
import { fn } from "storybook/test";
|
||||
import { buildCouponDefaults } from "@/lib/admin/builder/state/defaults/coupon";
|
||||
import type { CouponScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Получаем дефолтные значения из builder
|
||||
const defaultScreen = buildCouponDefaults("coupon-screen-story") as CouponScreenDefinition;
|
||||
|
||||
/** CouponTemplate - экраны с купонами и промокодами */
|
||||
const meta: Meta<typeof CouponTemplate> = {
|
||||
title: "Funnel Templates/CouponTemplate",
|
||||
component: CouponTemplate,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
args: {
|
||||
screen: defaultScreen,
|
||||
onContinue: fn(),
|
||||
canGoBack: true,
|
||||
onBack: fn(),
|
||||
screenProgress: { current: 8, total: 10 },
|
||||
defaultTexts: {
|
||||
nextButton: "Next",
|
||||
continueButton: "Continue"
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
screen: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
screenProgress: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
onContinue: { action: "continue" },
|
||||
onBack: { action: "back" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Дефолтный купон экран */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Купон с показом прогресса */
|
||||
export const WithProgress: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
header: {
|
||||
show: true,
|
||||
showBackButton: true,
|
||||
showProgress: true, // Показываем прогресс
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран без header */
|
||||
export const WithoutHeader: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
header: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Купон с другой скидкой */
|
||||
export const CustomDiscount: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
coupon: {
|
||||
...defaultScreen.coupon,
|
||||
offer: {
|
||||
title: {
|
||||
text: "50% OFF",
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
align: "center",
|
||||
size: "3xl",
|
||||
color: "primary",
|
||||
},
|
||||
description: {
|
||||
text: "Скидка на первую покупку",
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "muted",
|
||||
align: "center",
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
promoCode: {
|
||||
text: "FIRST50",
|
||||
font: "geistMono",
|
||||
weight: "bold",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
color: "accent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Coupon } from "@/components/widgets/Coupon/Coupon";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import { buildTypographyProps } from "@/lib/funnel/mappers";
|
||||
import type { CouponScreenDefinition } from "@/lib/funnel/types";
|
||||
import { TemplateLayout } from "../layouts/TemplateLayout";
|
||||
|
||||
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) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedCode(code);
|
||||
|
||||
setTimeout(() => {
|
||||
setCopiedCode(null);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
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 (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.continueButton || "Continue",
|
||||
disabled: false,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex flex-col items-center justify-center mt-[22px]">
|
||||
<div className="mb-8">
|
||||
<Coupon {...couponProps} />
|
||||
</div>
|
||||
|
||||
{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>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
1
src/components/funnel/templates/CouponTemplate/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CouponTemplate } from "./CouponTemplate";
|
||||
@ -0,0 +1,133 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { DateTemplate } from "./DateTemplate";
|
||||
import { fn } from "storybook/test";
|
||||
import { buildDateDefaults } from "@/lib/admin/builder/state/defaults/date";
|
||||
import type { DateScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Получаем дефолтные значения из builder
|
||||
const defaultScreen = buildDateDefaults("date-screen-story") as DateScreenDefinition;
|
||||
|
||||
/** DateTemplate - экраны с выбором даты рождения */
|
||||
const meta: Meta<typeof DateTemplate> = {
|
||||
title: "Funnel Templates/DateTemplate",
|
||||
component: DateTemplate,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
args: {
|
||||
screen: defaultScreen,
|
||||
selectedDate: {},
|
||||
onDateChange: fn(),
|
||||
onContinue: fn(),
|
||||
canGoBack: true,
|
||||
onBack: fn(),
|
||||
screenProgress: { current: 4, total: 10 },
|
||||
defaultTexts: {
|
||||
nextButton: "Next",
|
||||
continueButton: "Continue"
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
screen: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
selectedDate: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
screenProgress: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
onDateChange: { action: "date changed" },
|
||||
onContinue: { action: "continue" },
|
||||
onBack: { action: "back" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Дефолтный экран выбора даты */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Экран с предзаполненной датой */
|
||||
export const WithPrefilledDate: Story = {
|
||||
args: {
|
||||
selectedDate: {
|
||||
month: "4",
|
||||
day: "8",
|
||||
year: "1987",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран без показа выбранной даты */
|
||||
export const WithoutSelectedDate: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
dateInput: {
|
||||
...defaultScreen.dateInput,
|
||||
showSelectedDate: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран с кастомными лейблами */
|
||||
export const CustomLabels: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
dateInput: {
|
||||
...defaultScreen.dateInput,
|
||||
monthLabel: "Month",
|
||||
dayLabel: "Day",
|
||||
yearLabel: "Year",
|
||||
monthPlaceholder: "MM",
|
||||
dayPlaceholder: "DD",
|
||||
yearPlaceholder: "YYYY",
|
||||
selectedDateLabel: "Selected date:",
|
||||
selectedDateFormat: "MMMM d, yyyy",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран без информационного сообщения */
|
||||
export const WithoutInfoMessage: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
infoMessage: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран с включенным зодиаком */
|
||||
export const WithZodiac: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
dateInput: {
|
||||
...defaultScreen.dateInput,
|
||||
zodiac: {
|
||||
enabled: true,
|
||||
storageKey: "zodiac_sign",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран без header */
|
||||
export const WithoutHeader: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
header: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
176
src/components/funnel/templates/DateTemplate/DateTemplate.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import DateInput from "@/components/widgets/DateInput/DateInput";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
import { buildTypographyProps } from "@/lib/funnel/mappers";
|
||||
import type { DateScreenDefinition } from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TemplateLayout } from "../layouts/TemplateLayout";
|
||||
|
||||
// Утилита для форматирования даты на основе паттерна
|
||||
function formatDateByPattern(date: Date, pattern: string): string {
|
||||
const monthNames = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
];
|
||||
|
||||
return pattern
|
||||
.replace("MMMM", monthNames[date.getMonth()])
|
||||
.replace("MMM", monthNames[date.getMonth()].substring(0, 3))
|
||||
.replace("yyyy", date.getFullYear().toString())
|
||||
.replace("dd", date.getDate().toString().padStart(2, '0'))
|
||||
.replace("d", date.getDate().toString());
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function DateTemplate({
|
||||
screen,
|
||||
selectedDate,
|
||||
onDateChange,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: DateTemplateProps) {
|
||||
const isoDate = useMemo(() => {
|
||||
const { month, day, year } = selectedDate;
|
||||
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 && yearNum > 1900) {
|
||||
return `${yearNum}-${monthNum.toString().padStart(2, '0')}-${dayNum.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [selectedDate]);
|
||||
|
||||
const handleDateChange = (newIsoDate: string | null) => {
|
||||
if (!newIsoDate) {
|
||||
onDateChange({ month: "", day: "", year: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const match = newIsoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
onDateChange({ month: "", day: "", year: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [, year, month, day] = match;
|
||||
onDateChange({
|
||||
month: parseInt(month).toString(),
|
||||
day: parseInt(day).toString(),
|
||||
year: year
|
||||
});
|
||||
};
|
||||
|
||||
const isFormValid = Boolean(isoDate);
|
||||
|
||||
// Форматированная дата для отображения
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!isoDate) return null;
|
||||
|
||||
const date = new Date(isoDate);
|
||||
const pattern = screen.dateInput?.selectedDateFormat || "MMMM d, yyyy";
|
||||
return formatDateByPattern(date, pattern);
|
||||
}, [isoDate, screen.dateInput?.selectedDateFormat]);
|
||||
|
||||
// Компонент отображения выбранной даты над кнопкой
|
||||
const selectedDateDisplay = formattedDate && screen.dateInput?.showSelectedDate !== false ? (
|
||||
<div className="text-center space-y-1 mb-4">
|
||||
<Typography
|
||||
as="p"
|
||||
size="sm"
|
||||
color="muted"
|
||||
className="font-medium"
|
||||
>
|
||||
{screen.dateInput?.selectedDateLabel || "Выбранная дата:"}
|
||||
</Typography>
|
||||
<Typography
|
||||
as="p"
|
||||
size="xl"
|
||||
weight="bold"
|
||||
color="default"
|
||||
className="font-semibold"
|
||||
>
|
||||
{formattedDate}
|
||||
</Typography>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.nextButton || "Next",
|
||||
disabled: !isFormValid,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
childrenUnderButton={selectedDateDisplay}
|
||||
>
|
||||
<div className="w-full mt-[22px] space-y-6">
|
||||
<DateInput
|
||||
value={isoDate}
|
||||
onChange={handleDateChange}
|
||||
maxYear={new Date().getFullYear() - 11}
|
||||
yearsRange={100}
|
||||
locale="en"
|
||||
/>
|
||||
|
||||
{screen.infoMessage && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<Image
|
||||
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>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
1
src/components/funnel/templates/DateTemplate/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { DateTemplate } from "./DateTemplate";
|
||||
@ -0,0 +1,93 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { EmailTemplate } from "./EmailTemplate";
|
||||
import { fn } from "storybook/test";
|
||||
import { buildEmailDefaults } from "@/lib/admin/builder/state/defaults/email";
|
||||
import type { EmailScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Получаем дефолтные значения из builder
|
||||
const defaultScreen = buildEmailDefaults("email-screen-story") as EmailScreenDefinition;
|
||||
|
||||
/** EmailTemplate - экраны сбора email адреса */
|
||||
const meta: Meta<typeof EmailTemplate> = {
|
||||
title: "Funnel Templates/EmailTemplate",
|
||||
component: EmailTemplate,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
args: {
|
||||
screen: defaultScreen,
|
||||
onContinue: fn(),
|
||||
canGoBack: true,
|
||||
onBack: fn(),
|
||||
screenProgress: { current: 9, total: 10 },
|
||||
defaultTexts: {
|
||||
nextButton: "Next",
|
||||
continueButton: "Continue"
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
screen: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
screenProgress: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
onContinue: { action: "continue" },
|
||||
onBack: { action: "back" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Дефолтный email экран */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Экран без изображения */
|
||||
export const WithoutImage: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
image: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран с consent */
|
||||
export const WithConsent: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
bottomActionButton: {
|
||||
...defaultScreen.bottomActionButton,
|
||||
showPrivacyTermsConsent: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран без header */
|
||||
export const WithoutHeader: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
header: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран с кастомными лейблами */
|
||||
export const CustomLabels: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
emailInput: {
|
||||
label: "Your Email Address",
|
||||
placeholder: "Enter your email here...",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
112
src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { EmailScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
|
||||
import { TemplateLayout } from "../layouts/TemplateLayout";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address",
|
||||
}),
|
||||
});
|
||||
|
||||
interface EmailTemplateProps {
|
||||
screen: EmailScreenDefinition;
|
||||
selectedEmail: string;
|
||||
onEmailChange: (email: string) => void;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress?: { current: number; total: number };
|
||||
defaultTexts?: DefaultTexts;
|
||||
}
|
||||
|
||||
export function EmailTemplate({
|
||||
screen,
|
||||
selectedEmail,
|
||||
onEmailChange,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: EmailTemplateProps) {
|
||||
const [isTouched, setIsTouched] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: selectedEmail || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("email", selectedEmail || "");
|
||||
}, [selectedEmail, form]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
form.setValue("email", value);
|
||||
form.trigger("email");
|
||||
onEmailChange(value);
|
||||
};
|
||||
|
||||
const isFormValid = form.formState.isValid && form.getValues("email");
|
||||
|
||||
return (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.nextButton || "Continue",
|
||||
disabled: !isFormValid,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex flex-col items-center gap-[26px]">
|
||||
<TextInput
|
||||
label={screen.emailInput?.label || "Email"}
|
||||
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
|
||||
type="email"
|
||||
value={selectedEmail}
|
||||
onChange={handleChange}
|
||||
onBlur={() => {
|
||||
setIsTouched(true);
|
||||
form.trigger("email");
|
||||
}}
|
||||
aria-invalid={isTouched && !!form.formState.errors.email}
|
||||
aria-errormessage={
|
||||
isTouched ? form.formState.errors.email?.message : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{screen.image && (
|
||||
<Image
|
||||
src={screen.image.src}
|
||||
alt="portrait"
|
||||
width={164}
|
||||
height={245}
|
||||
className="mt-3.5 rounded-[50px] blur-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
<PrivacySecurityBanner
|
||||
className="mt-[26px]"
|
||||
text={{
|
||||
children: defaultTexts?.privacyBanner || "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
1
src/components/funnel/templates/EmailTemplate/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { EmailTemplate } from "./EmailTemplate";
|
||||
@ -0,0 +1,152 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { FormTemplate } from "./FormTemplate";
|
||||
import { fn } from "storybook/test";
|
||||
import { buildFormDefaults } from "@/lib/admin/builder/state/defaults/form";
|
||||
import type { FormScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Получаем дефолтные значения из builder
|
||||
const defaultScreen = buildFormDefaults("form-screen-story") as FormScreenDefinition;
|
||||
|
||||
// Создаем более богатую форму для демонстрации
|
||||
const richFormScreen: FormScreenDefinition = {
|
||||
...defaultScreen,
|
||||
title: {
|
||||
...defaultScreen.title,
|
||||
text: "Расскажите о себе",
|
||||
},
|
||||
subtitle: {
|
||||
...defaultScreen.subtitle,
|
||||
text: "Заполните форму для персонализированного анализа",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
id: "name",
|
||||
label: "Полное имя",
|
||||
placeholder: "Введите ваше имя",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 50,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: "Email адрес",
|
||||
placeholder: "example@email.com",
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
label: "Телефон",
|
||||
placeholder: "+7 (999) 123-45-67",
|
||||
type: "tel",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
label: "Веб-сайт",
|
||||
placeholder: "https://example.com",
|
||||
type: "url",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** FormTemplate - экраны с формами ввода */
|
||||
const meta: Meta<typeof FormTemplate> = {
|
||||
title: "Funnel Templates/FormTemplate",
|
||||
component: FormTemplate,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
args: {
|
||||
screen: richFormScreen,
|
||||
formData: {},
|
||||
onFormDataChange: fn(),
|
||||
onContinue: fn(),
|
||||
canGoBack: true,
|
||||
onBack: fn(),
|
||||
screenProgress: { current: 6, total: 10 },
|
||||
defaultTexts: {
|
||||
nextButton: "Next",
|
||||
continueButton: "Continue"
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
screen: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
formData: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
screenProgress: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
onFormDataChange: { action: "form data changed" },
|
||||
onContinue: { action: "continue" },
|
||||
onBack: { action: "back" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Дефолтная форма */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Простая форма с одним полем */
|
||||
export const SimpleForm: Story = {
|
||||
args: {
|
||||
screen: defaultScreen, // Используем базовые дефолты
|
||||
},
|
||||
};
|
||||
|
||||
/** Форма только с обязательными полями */
|
||||
export const RequiredFieldsOnly: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richFormScreen,
|
||||
fields: richFormScreen.fields.filter(field => field.required),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Форма с кастомными сообщениями валидации */
|
||||
export const CustomValidation: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richFormScreen,
|
||||
validationMessages: {
|
||||
required: "Пожалуйста, заполните это поле",
|
||||
maxLength: "Слишком длинное значение",
|
||||
invalidFormat: "Неправильный формат данных",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Форма без header */
|
||||
export const WithoutHeader: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richFormScreen,
|
||||
header: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Форма без subtitle */
|
||||
export const WithoutSubtitle: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richFormScreen,
|
||||
subtitle: {
|
||||
...richFormScreen.subtitle,
|
||||
show: false,
|
||||
text: richFormScreen.subtitle?.text || "",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
142
src/components/funnel/templates/FormTemplate/FormTemplate.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
|
||||
import type { FormScreenDefinition } from "@/lib/funnel/types";
|
||||
import { TemplateLayout } from "../layouts/TemplateLayout";
|
||||
|
||||
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>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalFormData(formData);
|
||||
}, [formData]);
|
||||
|
||||
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 }));
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
return (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.continueButton || "Continue",
|
||||
disabled: !isFormComplete,
|
||||
onClick: handleContinue,
|
||||
}}
|
||||
>
|
||||
<div className="w-full mt-[22px] 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>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
1
src/components/funnel/templates/FormTemplate/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { FormTemplate } from "./FormTemplate";
|
||||
@ -0,0 +1,95 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { InfoTemplate } from "./InfoTemplate";
|
||||
import { fn } from "storybook/test";
|
||||
import { buildInfoDefaults } from "@/lib/admin/builder/state/defaults/info";
|
||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Получаем дефолтные значения из builder
|
||||
const defaultScreen = buildInfoDefaults("info-screen-story") as InfoScreenDefinition;
|
||||
|
||||
/** InfoTemplate - информационные экраны с иконкой и описанием */
|
||||
const meta: Meta<typeof InfoTemplate> = {
|
||||
title: "Funnel Templates/InfoTemplate",
|
||||
component: InfoTemplate,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
args: {
|
||||
screen: defaultScreen,
|
||||
onContinue: fn(),
|
||||
canGoBack: true,
|
||||
onBack: fn(),
|
||||
screenProgress: { current: 3, total: 10 },
|
||||
defaultTexts: {
|
||||
nextButton: "Next",
|
||||
continueButton: "Continue"
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
screen: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
screenProgress: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
onContinue: { action: "continue" },
|
||||
onBack: { action: "back" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Дефолтный информационный экран */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Экран без иконки */
|
||||
export const WithoutIcon: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
icon: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран с кастомной иконкой */
|
||||
export const WithCustomIcon: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
icon: {
|
||||
type: "emoji",
|
||||
value: "🎯",
|
||||
size: "lg",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран без header */
|
||||
export const WithoutHeader: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
header: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Экран с скрытым прогрессом */
|
||||
export const WithoutProgress: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
header: {
|
||||
show: true,
|
||||
showBackButton: true,
|
||||
showProgress: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
119
src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
import { buildTypographyProps } from "@/lib/funnel/mappers";
|
||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||
import { TemplateLayout } from "../layouts/TemplateLayout";
|
||||
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 iconSizeClasses = useMemo(() => {
|
||||
const size = screen.icon?.size ?? "xl";
|
||||
switch (size) {
|
||||
case "sm":
|
||||
return "text-4xl";
|
||||
case "md":
|
||||
return "text-5xl";
|
||||
case "lg":
|
||||
return "text-6xl";
|
||||
case "xl":
|
||||
default:
|
||||
return "text-8xl";
|
||||
}
|
||||
}, [screen.icon?.size]);
|
||||
|
||||
return (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "center" }}
|
||||
subtitleDefaults={{ font: "inter", weight: "medium", color: "muted", align: "center" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.nextButton || "Next",
|
||||
disabled: false,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-full flex flex-col items-center justify-center text-center",
|
||||
screen.icon ? "mt-[60px]" : "-mt-[20px]"
|
||||
)}>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{screen.description && (
|
||||
<div className={cn(
|
||||
"max-w-[280px]",
|
||||
screen.icon ? "mt-6" : "mt-0"
|
||||
)}>
|
||||
<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>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
1
src/components/funnel/templates/InfoTemplate/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { InfoTemplate } from "./InfoTemplate";
|
||||
@ -0,0 +1,144 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { ListTemplate } from "./ListTemplate";
|
||||
import { fn } from "storybook/test";
|
||||
import { buildListDefaults } from "@/lib/admin/builder/state/defaults/list";
|
||||
import type { ListScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Получаем дефолтные значения из builder
|
||||
const defaultScreen = buildListDefaults("list-screen-story") as ListScreenDefinition;
|
||||
|
||||
// Более богатый список опций для демонстрации
|
||||
const richOptionsScreen: ListScreenDefinition = {
|
||||
...defaultScreen,
|
||||
title: {
|
||||
...defaultScreen.title,
|
||||
text: "Выберите ваш знак зодиака",
|
||||
},
|
||||
list: {
|
||||
...defaultScreen.list,
|
||||
options: [
|
||||
{ id: "aries", label: "Овен", emoji: "♈" },
|
||||
{ id: "taurus", label: "Телец", emoji: "♉" },
|
||||
{ id: "gemini", label: "Близнецы", emoji: "♊" },
|
||||
{ id: "cancer", label: "Рак", emoji: "♋" },
|
||||
{ id: "leo", label: "Лев", emoji: "♌" },
|
||||
{ id: "virgo", label: "Дева", emoji: "♍" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** ListTemplate - экраны с выбором опций (single/multi selection) */
|
||||
const meta: Meta<typeof ListTemplate> = {
|
||||
title: "Funnel Templates/ListTemplate",
|
||||
component: ListTemplate,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
args: {
|
||||
screen: richOptionsScreen,
|
||||
selectedOptionIds: [],
|
||||
onSelectionChange: fn(),
|
||||
actionButtonProps: {
|
||||
children: "Continue",
|
||||
onClick: fn(),
|
||||
},
|
||||
canGoBack: true,
|
||||
onBack: fn(),
|
||||
screenProgress: { current: 5, total: 10 },
|
||||
},
|
||||
argTypes: {
|
||||
screen: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
selectedOptionIds: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
actionButtonProps: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
screenProgress: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
onSelectionChange: { action: "selection changed" },
|
||||
onBack: { action: "back" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Дефолтный single selection список (кнопка disabled пока не выбрано) */
|
||||
export const SingleSelection: Story = {};
|
||||
|
||||
/** Multi selection список (кнопка disabled пока не выбрано) */
|
||||
export const MultiSelection: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richOptionsScreen,
|
||||
title: {
|
||||
...richOptionsScreen.title,
|
||||
text: "Выберите несколько вариантов",
|
||||
},
|
||||
list: {
|
||||
...richOptionsScreen.list,
|
||||
selectionType: "multi",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Single selection с предвыбранным значением (кнопка активна) */
|
||||
export const WithPreselection: Story = {
|
||||
args: {
|
||||
selectedOptionIds: ["leo"],
|
||||
},
|
||||
};
|
||||
|
||||
/** Multi selection с несколькими выбранными (кнопка активна) */
|
||||
export const MultiWithPreselection: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richOptionsScreen,
|
||||
list: {
|
||||
...richOptionsScreen.list,
|
||||
selectionType: "multi",
|
||||
},
|
||||
},
|
||||
selectedOptionIds: ["aries", "leo", "virgo"],
|
||||
},
|
||||
};
|
||||
|
||||
/** Single selection с автопереходом (без кнопки) */
|
||||
export const SingleAutoAdvance: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richOptionsScreen,
|
||||
bottomActionButton: {
|
||||
show: false, // Автопереход при выборе
|
||||
},
|
||||
},
|
||||
actionButtonProps: undefined, // Нет кнопки
|
||||
},
|
||||
};
|
||||
|
||||
/** Список без header */
|
||||
export const WithoutHeader: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richOptionsScreen,
|
||||
header: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Простой список без эмодзи */
|
||||
export const SimpleList: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen, // Используем базовые дефолты без эмодзи
|
||||
},
|
||||
},
|
||||
};
|
||||
124
src/components/funnel/templates/ListTemplate/ListTemplate.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { RadioAnswersList } from "@/components/widgets/RadioAnswersList/RadioAnswersList";
|
||||
import { SelectAnswersList } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
|
||||
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 { mapListOptionsToButtons } from "@/lib/funnel/mappers";
|
||||
import type { ListScreenDefinition } from "@/lib/funnel/types";
|
||||
import { TemplateLayout } from "../layouts/TemplateLayout";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const actionButtonOptions = actionButtonProps ? {
|
||||
defaultText: actionButtonProps.children as string || "Next",
|
||||
// Кнопка неактивна если: 1) disabled из props ИЛИ 2) ничего не выбрано
|
||||
disabled: actionButtonProps.disabled || selectedOptionIds.length === 0,
|
||||
onClick: () => {
|
||||
if (actionButtonProps.onClick) {
|
||||
actionButtonProps.onClick({} as React.MouseEvent<HTMLButtonElement>);
|
||||
}
|
||||
},
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
|
||||
actionButtonOptions={actionButtonOptions}
|
||||
>
|
||||
<div className="w-full mt-[22px]">
|
||||
{contentType === "radio-answers-list" ? (
|
||||
<RadioAnswersList {...radioContent} />
|
||||
) : (
|
||||
<SelectAnswersList {...selectContent} />
|
||||
)}
|
||||
</div>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
1
src/components/funnel/templates/ListTemplate/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ListTemplate } from "./ListTemplate";
|
||||
@ -0,0 +1,112 @@
|
||||
import { Meta, StoryObj } from "@storybook/nextjs-vite";
|
||||
import { LoadersTemplate } from "./LoadersTemplate";
|
||||
import { fn } from "storybook/test";
|
||||
import { buildLoadersDefaults } from "@/lib/admin/builder/state/defaults/loaders";
|
||||
import type { LoadersScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
// Получаем дефолтные значения из builder
|
||||
const defaultScreen = buildLoadersDefaults("loaders-screen-story") as LoadersScreenDefinition;
|
||||
|
||||
/** LoadersTemplate - экраны с анимированными загрузчиками */
|
||||
const meta: Meta<typeof LoadersTemplate> = {
|
||||
title: "Funnel Templates/LoadersTemplate",
|
||||
component: LoadersTemplate,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
args: {
|
||||
screen: defaultScreen,
|
||||
onContinue: fn(),
|
||||
canGoBack: false, // Обычно на лоадерах нет кнопки назад
|
||||
onBack: fn(),
|
||||
screenProgress: undefined, // У лоадеров обычно нет прогресса
|
||||
defaultTexts: {
|
||||
nextButton: "Next",
|
||||
continueButton: "Continue"
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
screen: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
screenProgress: {
|
||||
control: { type: "object" },
|
||||
},
|
||||
onContinue: { action: "continue" },
|
||||
onBack: { action: "back" },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Дефолтный loaders экран */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Лоадеры с быстрой анимацией */
|
||||
export const FastAnimation: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
progressbars: {
|
||||
...defaultScreen.progressbars,
|
||||
transitionDuration: 1000, // Быстрее
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Лоадеры с медленной анимацией */
|
||||
export const SlowAnimation: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
progressbars: {
|
||||
...defaultScreen.progressbars,
|
||||
transitionDuration: 5000, // Медленнее
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Лоадеры с кастомными сообщениями */
|
||||
export const CustomMessages: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
progressbars: {
|
||||
...defaultScreen.progressbars,
|
||||
items: [
|
||||
{
|
||||
processingTitle: "Анализируем ваши ответы...",
|
||||
processingSubtitle: "Обработка данных",
|
||||
completedTitle: "Анализ завершен",
|
||||
completedSubtitle: "Готово!",
|
||||
},
|
||||
{
|
||||
processingTitle: "Создаем персональный портрет...",
|
||||
processingSubtitle: "Генерация",
|
||||
completedTitle: "Портрет готов",
|
||||
completedSubtitle: "Завершено",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Лоадеры с header (необычно, но возможно) */
|
||||
export const WithHeader: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
header: {
|
||||
show: true,
|
||||
showBackButton: false,
|
||||
showProgress: true,
|
||||
},
|
||||
},
|
||||
screenProgress: { current: 7, total: 10 },
|
||||
},
|
||||
};
|
||||