diff --git a/README-ADMIN.md b/README-ADMIN.md new file mode 100644 index 0000000..df8d2e4 --- /dev/null +++ b/README-ADMIN.md @@ -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 готова к использованию! 🚀** diff --git a/README.md b/README.md index e215bc4..ef147e0 100644 --- a/README.md +++ b/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`. diff --git a/docs/templates-and-builder.md b/docs/templates-and-builder.md new file mode 100644 index 0000000..3e1130b --- /dev/null +++ b/docs/templates-and-builder.md @@ -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`, где ключ — `id` экрана, а значение — массив выбранных значений (опций, компонентов даты или сериализованные данные формы).【F:src/lib/admin/builder/validation.ts†L1-L168】【F:src/lib/admin/builder/utils.ts†L1-L86】【F:src/components/funnel/FunnelRuntime.tsx†L1-L215】 + +## Рабочий процесс + +1. Создайте экраны через верхнюю панель или кнопку на канвасе. Каждый новый экран получает уникальный ID (`screen-{n}`). +2. Настройте порядок переходов drag-and-drop и установите `firstScreenId`, если стартовать нужно не с первого элемента. +3. Заполните контент для каждого шаблона, настройте условия в `navigation.rules` и убедитесь, что `defaultNextScreenId` указан для веток без правил. +4. Проверьте сводку валидации — при ошибках экспорт JSON будет возможен, но рантайм может не смочь построить маршрут. +5. Экспортируйте JSON и передайте его рантайму (``). + +Такой подход гарантирует, что конструктор и рантайм используют одну и ту же схему данных, а визуальные шаблоны ведут себя предсказуемо при изменении конфигурации. diff --git a/next.config.ts b/next.config.ts index e9ffa30..c8c9573 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index eb818bb..6405da2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,27 @@ "name": "witlab-funnel", "version": "0.1.0", "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", @@ -310,7 +319,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1011,6 +1019,56 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1670,6 +1728,15 @@ "react": ">=16" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz", + "integrity": "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1900,12 +1967,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", @@ -1936,6 +2032,32 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1966,6 +2088,142 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-label": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", @@ -1989,6 +2247,62 @@ } } }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-presence": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", @@ -2060,6 +2374,49 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", @@ -2101,6 +2458,21 @@ } } }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -2138,6 +2510,24 @@ } } }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -2168,6 +2558,24 @@ } } }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-size": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", @@ -2186,6 +2594,35 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -2537,6 +2974,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@storybook/addon-a11y": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-9.1.6.tgz", @@ -3384,6 +3827,69 @@ "@types/deep-eql": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3487,6 +3993,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.43.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", @@ -4553,6 +5074,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -4906,6 +5439,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5205,9 +5747,129 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5273,7 +5935,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5287,6 +5948,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -5370,6 +6037,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5390,6 +6063,28 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6169,6 +6864,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6197,6 +6898,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz", + "integrity": "sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -6467,6 +7177,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -6773,6 +7492,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7385,7 +8113,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -7485,6 +8212,15 @@ "node": ">=4.0" } }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7805,6 +8541,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7816,7 +8558,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -7900,6 +8641,12 @@ "node": ">= 0.4" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8036,6 +8783,105 @@ "dev": true, "license": "MIT" }, + "node_modules/mongodb": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", + "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.18.2.tgz", + "integrity": "sha512-gA6GFlshOHUdNyw9OQTmMLSGzVOPbcbjaSZ1dvR5iMp668N2UUznTuzgTY6V6Q41VtBc4kmL/qqML1RNgXB5Fg==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.18.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -8050,7 +8896,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -8193,7 +9038,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8654,7 +9498,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -8666,7 +9509,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8713,6 +9555,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-circular-progressbar": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-circular-progressbar/-/react-circular-progressbar-2.2.0.tgz", + "integrity": "sha512-cgyqEHOzB0nWMZjKfWN3MfSa1LV3OatcDjPz68lchXQUEiBD5O1WsAtoVK4/DSL0B4USR//cTdok4zCBkq8X5g==", + "license": "MIT", + "peerDependencies": { + "react": ">=0.14.0" + } + }, "node_modules/react-docgen-typescript": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", @@ -8735,13 +9586,128 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.63.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", + "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -8769,6 +9735,44 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -9327,6 +10331,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -9411,6 +10421,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -9976,7 +10995,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -10094,6 +11112,18 @@ "node": ">=6" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -10411,6 +11441,71 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", @@ -10681,6 +11776,15 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/webpack": { "version": "5.101.3", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", @@ -10775,6 +11879,19 @@ "node": ">=4.0" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -11051,6 +12168,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index d0bfb5c..fc40d79 100644 --- a/package.json +++ b/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", diff --git a/public/GuardIcon.svg b/public/GuardIcon.svg new file mode 100644 index 0000000..7a3a219 --- /dev/null +++ b/public/GuardIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/female-portrait.jpg b/public/female-portrait.jpg new file mode 100644 index 0000000..0fcc4a9 Binary files /dev/null and b/public/female-portrait.jpg differ diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/funnels/funnel-funnel-1759061433816.json b/public/funnels/funnel-funnel-1759061433816.json new file mode 100644 index 0000000..be13f8a --- /dev/null +++ b/public/funnels/funnel-funnel-1759061433816.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/public/funnels/funnel-test-variants.json b/public/funnels/funnel-test-variants.json new file mode 100644 index 0000000..9ac5ec9 --- /dev/null +++ b/public/funnels/funnel-test-variants.json @@ -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" + } + } + ] +} diff --git a/public/funnels/funnel-test.json b/public/funnels/funnel-test.json new file mode 100644 index 0000000..09a9f04 --- /dev/null +++ b/public/funnels/funnel-test.json @@ -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" + } + } + ] +} diff --git a/public/funnels/ru-career-accelerator.json b/public/funnels/ru-career-accelerator.json new file mode 100644 index 0000000..5c538d0 --- /dev/null +++ b/public/funnels/ru-career-accelerator.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-finance-freedom.json b/public/funnels/ru-finance-freedom.json new file mode 100644 index 0000000..9dbf993 --- /dev/null +++ b/public/funnels/ru-finance-freedom.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-fitness-transform.json b/public/funnels/ru-fitness-transform.json new file mode 100644 index 0000000..c992552 --- /dev/null +++ b/public/funnels/ru-fitness-transform.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-interior-signature.json b/public/funnels/ru-interior-signature.json new file mode 100644 index 0000000..9790026 --- /dev/null +++ b/public/funnels/ru-interior-signature.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-kids-robotics.json b/public/funnels/ru-kids-robotics.json new file mode 100644 index 0000000..e4508f6 --- /dev/null +++ b/public/funnels/ru-kids-robotics.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-language-immersion.json b/public/funnels/ru-language-immersion.json new file mode 100644 index 0000000..438c440 --- /dev/null +++ b/public/funnels/ru-language-immersion.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-mind-balance.json b/public/funnels/ru-mind-balance.json new file mode 100644 index 0000000..af21137 --- /dev/null +++ b/public/funnels/ru-mind-balance.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-skin-renewal.json b/public/funnels/ru-skin-renewal.json new file mode 100644 index 0000000..f700858 --- /dev/null +++ b/public/funnels/ru-skin-renewal.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-travel-signature.json b/public/funnels/ru-travel-signature.json new file mode 100644 index 0000000..3c419a6 --- /dev/null +++ b/public/funnels/ru-travel-signature.json @@ -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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-wedding-dream.json b/public/funnels/ru-wedding-dream.json new file mode 100644 index 0000000..f3c5bd5 --- /dev/null +++ b/public/funnels/ru-wedding-dream.json @@ -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} скопирован!" + } + ] +} diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/heart-in-fire.svg b/public/heart-in-fire.svg new file mode 100644 index 0000000..00c0857 --- /dev/null +++ b/public/heart-in-fire.svg @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/male-portrait.jpg b/public/male-portrait.jpg new file mode 100644 index 0000000..43aa969 Binary files /dev/null and b/public/male-portrait.jpg differ diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/bake-funnels.mjs b/scripts/bake-funnels.mjs new file mode 100644 index 0000000..0cdd50d --- /dev/null +++ b/scripts/bake-funnels.mjs @@ -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 = ${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); +}); diff --git a/scripts/import-funnels-to-db.mjs b/scripts/import-funnels-to-db.mjs new file mode 100644 index 0000000..c6e7d15 --- /dev/null +++ b/scripts/import-funnels-to-db.mjs @@ -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); +}); diff --git a/scripts/run-with-variant.mjs b/scripts/run-with-variant.mjs new file mode 100644 index 0000000..2eaabc7 --- /dev/null +++ b/scripts/run-with-variant.mjs @@ -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 [-- ]'); + 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'); +}); diff --git a/src/app/[funnelId]/[screenId]/page.tsx b/src/app/[funnelId]/[screenId]/page.tsx new file mode 100644 index 0000000..006e0be --- /dev/null +++ b/src/app/[funnelId]/[screenId]/page.tsx @@ -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 { + 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 { + const { funnelId } = await params; + let funnel: ReturnType; + 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 ; +} diff --git a/src/app/[funnelId]/page.tsx b/src/app/[funnelId]/page.tsx new file mode 100644 index 0000000..1a8d24c --- /dev/null +++ b/src/app/[funnelId]/page.tsx @@ -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 { + 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}`); +} diff --git a/src/app/admin/AdminCatalogPageClient.tsx b/src/app/admin/AdminCatalogPageClient.tsx new file mode 100644 index 0000000..c165f1f --- /dev/null +++ b/src/app/admin/AdminCatalogPageClient.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const router = useRouter(); + + // Фильтры и поиск + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortBy, setSortBy] = useState('updatedAt'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + // Пагинация + const [pagination, setPagination] = useState({ + current: 1, + total: 1, + count: 0, + totalItems: 0 + }); + + // Выделенные элементы - TODO: реализовать в будущем + // const [selectedFunnels, setSelectedFunnels] = useState>(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 ( + + {labels[status as keyof typeof labels]} + + ); + }; + + // Форматирование дат + 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 ( +
+
+ + {/* Header */} +
+
+
+

Каталог воронок

+

+ Управляйте своими воронками и создавайте новые +

+
+ +
+
+ + {/* Фильтры и поиск */} +
+
+ + {/* Поиск */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Поиск по названию, описанию..." + className="pl-10" + /> +
+
+ + {/* Фильтр статуса */} + + + {/* Сортировка */} + + + +
+
+ + {/* Ошибка */} + {error && ( +
+
{error}
+
+ )} + + {/* Список воронок */} +
+ + {loading ? ( +
+ + Загружается... +
+ ) : funnels.length === 0 ? ( +
+
Воронки не найдены
+ +
+ ) : ( +
+ + + + + + + + + + + + {funnels.map((funnel) => ( + + + + + + + + ))} + +
+ Название + + Статус + + Статистика + + Обновлена + + Действия +
+
+
+ {funnel.name} +
+
+ ID: {funnel.funnelData?.meta?.id || 'N/A'} +
+ {funnel.description && ( +
+ {funnel.description} +
+ )} +
+
+ {getStatusBadge(funnel.status)} + +
+ {funnel.usage.totalViews} просмотров +
+
+ {funnel.usage.totalCompletions} завершений +
+
+
+ {formatDate(funnel.updatedAt)} +
+
+ v{funnel.version} +
+
+
+ + {/* Просмотр воронки */} + + + + + {/* Редактирование */} + + + + + {/* Дублировать */} + + + {/* Удалить (только черновики) */} + {funnel.status === 'draft' && ( + + )} +
+
+
+ )} +
+ + {/* Пагинация */} + {pagination.total > 1 && ( +
+
+ Показано {pagination.count} из {pagination.totalItems} воронок +
+
+ + + {pagination.current} / {pagination.total} + + +
+
+ )} + +
+
+ ); +} diff --git a/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx new file mode 100644 index 0000000..fe82244 --- /dev/null +++ b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx @@ -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(null); + const [initialBuilderState, setInitialBuilderState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 => { + const success = await saveFunnel(builderState, false); + if (success) { + // Создаем запись в истории как базовую точку + await createHistoryEntry(builderState, 'save', 'Изменения сохранены'); + } + return success || false; + }; + + const handlePublish = async (builderState: BuilderState): Promise => { + 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 ( +
+
+
+
Загрузка воронки...
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+
{error}
+ +
+
+ ); + } + + // Main render + if (!initialBuilderState || !funnelData) { + return null; + } + + return ( + + +
+ + {/* Top Bar */} + + + {/* Main Content */} +
+ + {/* Sidebar */} + + + {/* Canvas Area */} +
+ + {/* Canvas */} +
+ +
+ + {/* Preview Panel */} +
+
+

Предпросмотр

+

+ Как выглядит экран в браузере +

+
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/src/app/admin/builder/[id]/page.tsx b/src/app/admin/builder/[id]/page.tsx new file mode 100644 index 0000000..158fd37 --- /dev/null +++ b/src/app/admin/builder/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..5470b23 --- /dev/null +++ b/src/app/admin/page.tsx @@ -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 ; +} diff --git a/src/app/api/funnels/[id]/duplicate/route.ts b/src/app/api/funnels/[id]/duplicate/route.ts new file mode 100644 index 0000000..d9b4f1e --- /dev/null +++ b/src/app/api/funnels/[id]/duplicate/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/funnels/[id]/history/route.ts b/src/app/api/funnels/[id]/history/route.ts new file mode 100644 index 0000000..ef533cb --- /dev/null +++ b/src/app/api/funnels/[id]/history/route.ts @@ -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 = { 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 } + ); + } +} diff --git a/src/app/api/funnels/[id]/route.ts b/src/app/api/funnels/[id]/route.ts new file mode 100644 index 0000000..afab2c1 --- /dev/null +++ b/src/app/api/funnels/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts b/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts new file mode 100644 index 0000000..42d826c --- /dev/null +++ b/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/funnels/route.ts b/src/app/api/funnels/route.ts new file mode 100644 index 0000000..cf309b7 --- /dev/null +++ b/src/app/api/funnels/route.ts @@ -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 = {}; + + 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 = {}; + 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 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index dc3d160..a2c2082 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 512379c..92bc6c6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - {children} + {children} ); diff --git a/src/components/admin/builder/Canvas/BuilderCanvas.tsx b/src/components/admin/builder/Canvas/BuilderCanvas.tsx new file mode 100644 index 0000000..d8e461b --- /dev/null +++ b/src/components/admin/builder/Canvas/BuilderCanvas.tsx @@ -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(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, 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) => { + 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) => { + 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>((accumulator, screen) => { + accumulator[screen.id] = screen.title.text || screen.id; + return accumulator; + }, {}); + }, [screens]); + + const listOptionsMap = useMemo(() => { + return screens.reduce>((accumulator, screen) => { + if (screen.template === "list") { + accumulator[screen.id] = screen.list.options; + } + return accumulator; + }, {}); + }, [screens]); + + return ( + <> +
+
+
+

Экраны воронки

+
+ +
+ +
+
+
+
+ {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 ( +
+ {isDropBefore && } +
+
+ + {!isLast && ( +
+
+ +
+ )} +
+
handleDragStart(event, screen.id, index)} + onDragOver={(event) => handleDragOverCard(event, index)} + onDragEnd={handleDragEnd} + onClick={() => handleSelectScreen(screen.id)} + > + + {TEMPLATE_TITLES[screen.template] ?? screen.template} + +
+
+
+ {index + 1} +
+
+ + #{screen.id} + + + {screen.title.text || "Без названия"} + +
+
+
+ + {("subtitle" in screen && screen.subtitle?.text) && ( +

+ {screen.subtitle.text} +

+ )} + +
+ + + + +
+
+ Переходы +
+
+ +
+ + + {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 + | 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 ( + + ); + })} +
+
+
+
+
+ {isDropAfter && } +
+ ); + })} + + {screens.length === 0 && ( +
+ Добавьте первый экран, чтобы начать строить воронку. +
+ )} + +
+ +
+
+
+
+
+ + + + ); +} diff --git a/src/components/admin/builder/Canvas/DropIndicator.tsx b/src/components/admin/builder/Canvas/DropIndicator.tsx new file mode 100644 index 0000000..ee0048a --- /dev/null +++ b/src/components/admin/builder/Canvas/DropIndicator.tsx @@ -0,0 +1,16 @@ +import { cn } from "@/lib/utils"; + +interface DropIndicatorProps { + isActive: boolean; +} + +export function DropIndicator({ isActive }: DropIndicatorProps) { + return ( +
+ ); +} diff --git a/src/components/admin/builder/Canvas/TemplateSummary.tsx b/src/components/admin/builder/Canvas/TemplateSummary.tsx new file mode 100644 index 0000000..c29dd37 --- /dev/null +++ b/src/components/admin/builder/Canvas/TemplateSummary.tsx @@ -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 ( +
+
+ + Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"} + +
+
+

Варианты ({screen.list.options.length})

+
+ {screen.list.options.map((option) => ( + + {option.emoji && {option.emoji}} + {option.label} + + ))} +
+
+
+ ); + } + case "form": { + return ( +
+
+ + Полей: {screen.fields.length} + + {screen.bottomActionButton?.text && ( + + {screen.bottomActionButton.text} + + )} +
+ {screen.validationMessages && ( +
+

+ Настроены пользовательские сообщения валидации +

+
+ )} +
+ ); + } + case "coupon": { + return ( +
+

+ Промо: {screen.coupon.promoCode.text} +

+

{screen.coupon.offer.title.text}

+
+ ); + } + case "date": { + return ( +
+

Формат даты:

+
+ {screen.dateInput.monthLabel && {screen.dateInput.monthLabel}} + {screen.dateInput.dayLabel && {screen.dateInput.dayLabel}} + {screen.dateInput.yearLabel && {screen.dateInput.yearLabel}} +
+ {screen.dateInput.validationMessage && ( +

{screen.dateInput.validationMessage}

+ )} +
+ ); + } + case "info": { + return ( +
+ {screen.description?.text &&

{screen.description.text}

} + {screen.icon?.value && ( +
+ {screen.icon.value} + Иконка +
+ )} +
+ ); + } + default: + return null; + } +} diff --git a/src/components/admin/builder/Canvas/TransitionRow.tsx b/src/components/admin/builder/Canvas/TransitionRow.tsx new file mode 100644 index 0000000..98c43fb --- /dev/null +++ b/src/components/admin/builder/Canvas/TransitionRow.tsx @@ -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 ( +
+
+ +
+
+
+ + {label} + + {operator && ( + + {operator} + + )} +
+ {optionSummaries.length > 0 && ( +
+ {optionSummaries.map((option) => ( + + {option.label} + + ))} +
+ )} +
+ {type === "end" ? ( + Завершение воронки + ) : ( + <> + + {typeof targetIndex === "number" && ( + + #{targetIndex + 1} + + )} + + {targetLabel ?? "Не выбрано"} + + + )} +
+
+
+ ); +} diff --git a/src/components/admin/builder/Canvas/VariantSummary.tsx b/src/components/admin/builder/Canvas/VariantSummary.tsx new file mode 100644 index 0000000..4d88f06 --- /dev/null +++ b/src/components/admin/builder/Canvas/VariantSummary.tsx @@ -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; + listOptionsMap: Record; +} + +export function VariantSummary({ + screen, + screenTitleMap, + listOptionsMap, +}: VariantSummaryProps) { + const variants = ( + screen as ScreenDefinition & { + variants?: ScreenVariantDefinition[]; + } + ).variants; + + if (!variants || variants.length === 0) { + return null; + } + + return ( +
+
+ Варианты +
+ {variants.length} +
+ +
+ {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 + | undefined; + const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny"; + + const overrideHighlights = listOverridePaths(variant.overrides ?? {}); + + return ( +
+
+ Вариант {index + 1} + + {operatorLabel} + +
+ +
+
+ Экран: {controllingScreenTitle} +
+ {optionSummaries.length > 0 ? ( +
+ {optionSummaries.map((option) => ( + + {option.label} + + ))} +
+ ) : ( +
Нет выбранных ответов
+ )} +
+ +
+ Изменяет: +
+ {(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => ( + + {highlight === "Без изменений" ? highlight : formatOverridePath(highlight)} + + ))} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/admin/builder/Canvas/constants.ts b/src/components/admin/builder/Canvas/constants.ts new file mode 100644 index 0000000..c72d4e0 --- /dev/null +++ b/src/components/admin/builder/Canvas/constants.ts @@ -0,0 +1,19 @@ +import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types"; + +export const TEMPLATE_TITLES: Record = { + list: "Список", + form: "Форма", + info: "Инфо", + date: "Дата", + coupon: "Купон", + email: "Email", + loaders: "Загрузка", + soulmate: "Портрет партнера", +}; + +export const OPERATOR_LABELS: Record, string> = { + includesAny: "любой из", + includesAll: "все из", + includesExactly: "точное совпадение", + equals: "равно", +}; diff --git a/src/components/admin/builder/Canvas/index.ts b/src/components/admin/builder/Canvas/index.ts new file mode 100644 index 0000000..8f409a3 --- /dev/null +++ b/src/components/admin/builder/Canvas/index.ts @@ -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"; diff --git a/src/components/admin/builder/Canvas/utils.ts b/src/components/admin/builder/Canvas/utils.ts new file mode 100644 index 0000000..c5239e9 --- /dev/null +++ b/src/components/admin/builder/Canvas/utils.ts @@ -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; +} diff --git a/src/components/admin/builder/Sidebar/BuilderSidebar.tsx b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx new file mode 100644 index 0000000..5a7d943 --- /dev/null +++ b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx @@ -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 = {} + ) => { + 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) => { + dispatch({ + type: "update-screen", + payload: { + screenId, + screen: updates as Partial, + }, + }); + }; + + const handleVariantsChange = ( + screenId: string, + variants: ScreenVariantDefinition[] + ) => { + dispatch({ + type: "update-screen", + payload: { + screenId, + screen: { + variants: variants.length > 0 ? variants : undefined, + } as Partial, + }, + }); + }; + + const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false; + + return ( +
+
+
+

Настройки

+
+
+ + +
+
+ +
+ {activeTab === "funnel" ? ( +
+
+ +
+ +
+ handleMetaChange("id", event.target.value)} + /> + handleMetaChange("title", event.target.value)} + /> + handleMetaChange("description", event.target.value)} + /> + +
+ +
+
+
+ Всего экранов + {state.screens.length} +
+
+ {state.screens.map((screen, index) => ( + + {index + 1}. {screen.title.text} + {screen.template} + + ))} +
+
+
+
+ ) : selectedScreen ? ( +
+
+
+
+ #{selectedScreen.id} + + {selectedScreen.template} + +
+ + {state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length} + +
+
+ +
+ handleScreenIdChange(selectedScreen.id, event.target.value)} + /> +
+ +
+ handleTemplateUpdate(selectedScreen.id, updates)} + /> +
+ +
+ handleVariantsChange(selectedScreen.id, variants)} + /> +
+ +
+ {/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */} + + + {/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */} + {!selectedScreen.navigation?.isEndScreen && ( + + )} +
+ + {selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && ( +
+
+
+

+ Направляйте пользователей на разные экраны в зависимости от выбора. +

+ +
+ + {(selectedScreen.navigation?.rules ?? []).length === 0 && ( +
+ Правил пока нет +
+ )} + + {(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => ( +
+
+ Правило {ruleIndex + 1} + +
+ + + {selectedScreen.template === "list" ? ( +
+ Варианты ответа +
+ {selectedScreen.list.options.map((option) => { + const condition = rule.conditions[0]; + const isChecked = condition.optionIds?.includes(option.id) ?? false; + return ( + + ); + })} +
+
+ ) : ( +
+ Навигационные правила с вариантами ответа доступны только для экранов со списком. +
+ )} + + +
+ ))} +
+
+ )} + +
+ +
+ +
+
+

+ Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны. +

+ +
+
+
+ ) : ( +
+ Выберите экран в списке слева, чтобы настроить его параметры. +
+ )} +
+
+ ); +} diff --git a/src/components/admin/builder/Sidebar/Section.tsx b/src/components/admin/builder/Sidebar/Section.tsx new file mode 100644 index 0000000..d02b731 --- /dev/null +++ b/src/components/admin/builder/Sidebar/Section.tsx @@ -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 ( +
+
+ {!alwaysExpanded && ( + effectiveExpanded ? ( + + ) : ( + + ) + )} +
+

{title}

+ {description &&

{description}

} +
+
+ {effectiveExpanded && ( +
{children}
+ )} +
+ ); +} diff --git a/src/components/admin/builder/Sidebar/ValidationSummary.tsx b/src/components/admin/builder/Sidebar/ValidationSummary.tsx new file mode 100644 index 0000000..116dcf8 --- /dev/null +++ b/src/components/admin/builder/Sidebar/ValidationSummary.tsx @@ -0,0 +1,34 @@ +import type { ValidationIssues } from "./types"; + +export interface ValidationSummaryProps { + issues: ValidationIssues; +} + +export function ValidationSummary({ issues }: ValidationSummaryProps) { + if (issues.length === 0) { + return ( +
+ Всё хорошо — воронка валидна. +
+ ); + } + + return ( +
+ {issues.map((issue, index) => ( +
+
+ +
+

{issue.message}

+ {issue.screenId &&

Экран: {issue.screenId}

} +
+
+
+ ))} +
+ ); +} diff --git a/src/components/admin/builder/Sidebar/index.ts b/src/components/admin/builder/Sidebar/index.ts new file mode 100644 index 0000000..fda2344 --- /dev/null +++ b/src/components/admin/builder/Sidebar/index.ts @@ -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"; diff --git a/src/components/admin/builder/Sidebar/types.ts b/src/components/admin/builder/Sidebar/types.ts new file mode 100644 index 0000000..9d7fe27 --- /dev/null +++ b/src/components/admin/builder/Sidebar/types.ts @@ -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["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; +} diff --git a/src/components/admin/builder/dialogs/AddScreenDialog.tsx b/src/components/admin/builder/dialogs/AddScreenDialog.tsx new file mode 100644 index 0000000..bba41bf --- /dev/null +++ b/src/components/admin/builder/dialogs/AddScreenDialog.tsx @@ -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(null); + + const handleAdd = () => { + if (selectedTemplate) { + onAddScreen(selectedTemplate); + setSelectedTemplate(null); + onOpenChange(false); + } + }; + + const handleCancel = () => { + setSelectedTemplate(null); + onOpenChange(false); + }; + + return ( + + + + Выберите тип экрана + + Выберите шаблон для нового экрана воронки + + + +
+ {TEMPLATE_OPTIONS.map((option) => { + const Icon = option.icon; + const isSelected = selectedTemplate === option.template; + + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/admin/builder/dialogs/index.ts b/src/components/admin/builder/dialogs/index.ts new file mode 100644 index 0000000..5977562 --- /dev/null +++ b/src/components/admin/builder/dialogs/index.ts @@ -0,0 +1,2 @@ +// Dialog components for builder interface +export { AddScreenDialog } from "./AddScreenDialog"; diff --git a/src/components/admin/builder/forms/AgeSelector.tsx b/src/components/admin/builder/forms/AgeSelector.tsx new file mode 100644 index 0000000..09870d2 --- /dev/null +++ b/src/components/admin/builder/forms/AgeSelector.tsx @@ -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 ( +
+ {/* 🎂 ВОЗРАСТНЫЕ ГРУППЫ */} +
+

🎂 Возрастные группы

+
+ {AGE_GROUPS.map((group) => { + const isSelected = isValueSelected(group.id); + return ( + + ); + })} +
+
+ + {/* 🚀 ПОКОЛЕНИЯ */} +
+

🚀 Поколения

+
+ {GENERATION_GROUPS.map((generation) => { + const isSelected = isValueSelected(generation.id); + return ( + + ); + })} +
+
+ + {/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */} +
+ +
+ setCustomValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustom(); + } + }} + /> + +
+ + {/* Подсказки по форматам */} +
+ Примеры: 25 (точный возраст), 18-21 (диапазон), 60+ (от 60 лет), age-25 (альтернативный формат) +
+
+ + {/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */} + {selectedValues.length > 0 && ( +
+ +
+ {selectedValues.map((value) => { + // Ищем в возрастных группах + const ageGroup = AGE_GROUPS.find(group => group.id === value); + if (ageGroup) { + return ( + + 🎂 {ageGroup.name} + + + ); + } + + // Ищем в поколениях + const generation = GENERATION_GROUPS.find(gen => gen.id === value); + if (generation) { + return ( + + 🚀 {generation.name} + + + ); + } + + // Кастомное значение + const range = parseAgeRange(value); + return ( + + 🎯 {range ? `${range.min}-${range.max === 120 ? '+' : range.max}` : value} + + + ); + })} +
+
+ )} + + {/* 💡 ПОДСКАЗКА */} +
+ 💡 Как это работает: Система автоматически рассчитывает возраст из + даты рождения пользователя. Выберите возрастные группы или поколения, при которых + должен показываться этот вариант экрана. Можно комбинировать разные условия. +
+
+ ); +} diff --git a/src/components/admin/builder/forms/EmailDomainSelector.tsx b/src/components/admin/builder/forms/EmailDomainSelector.tsx new file mode 100644 index 0000000..b83818a --- /dev/null +++ b/src/components/admin/builder/forms/EmailDomainSelector.tsx @@ -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 ( +
+ {/* 📧 ПОПУЛЯРНЫЕ ДОМЕНЫ */} +
+ {POPULAR_DOMAINS.map((domain) => { + const isSelected = selectedValues.includes(domain.id); + return ( + + ); + })} +
+ + {/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ДОМЕНОВ */} +
+ +
+ setCustomDomain(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustom(); + } + }} + /> + +
+
+ + {/* 📋 ВЫБРАННЫЕ ДОМЕНЫ */} + {selectedValues.length > 0 && ( +
+ +
+ {selectedValues.map((value) => { + const popularDomain = POPULAR_DOMAINS.find(domain => domain.id === value); + return ( + + {popularDomain ? ( + <> + {popularDomain.icon} + {popularDomain.name} + + ) : ( + 📧 {value} + )} + + + ); + })} +
+
+ )} + + {/* 💡 ПОДСКАЗКА */} +
+ 💡 Как это работает: Система проверяет домен email адреса пользователя. + Например, если пользователь ввел “user@gmail.com”, то значение будет “@gmail.com”. + Выберите домены, при которых должен показываться этот вариант экрана. +
+
+ ); +} diff --git a/src/components/admin/builder/forms/ScreenVariantsConfig.tsx b/src/components/admin/builder/forms/ScreenVariantsConfig.tsx new file mode 100644 index 0000000..5094bac --- /dev/null +++ b/src/components/admin/builder/forms/ScreenVariantsConfig.tsx @@ -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[]) => void; +} + +type ListBuilderScreen = BuilderScreen & { template: "list" }; + +type VariantDefinition = ScreenVariantDefinition; + +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).variants; + } + return sanitized; + }, [baseScreen]); + + const mergedScreen = useMemo( + () => mergeScreenWithOverrides(baseWithoutVariants, overrides) as BuilderScreen, + [baseWithoutVariants, overrides] + ); + + const handleUpdate = useCallback( + (updates: Partial) => { + const nextScreen = mergeScreenWithOverrides( + mergedScreen, + updates as Partial + ); + const nextOverrides = extractVariantOverrides(baseWithoutVariants, nextScreen); + onChange(nextOverrides); + }, + [baseWithoutVariants, mergedScreen, onChange] + ); + + return ( +
+ + +
+ ); +} + +export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVariantsConfigProps) { + const variants = useMemo( + () => ((screen.variants ?? []) as VariantDefinition[]), + [screen.variants] + ); + const [expandedVariant, setExpandedVariant] = useState(() => (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>((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) => { + 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) => { + 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 = { + 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 ( +
+
+ Условие: + + {screenTypeLabel} + + {operatorLabel} +
+
+ Экран: + {screenTitle ?? condition.screenId} +
+ {summaries.length > 0 ? ( +
+ {summaries.map((item) => ( + + {item} + + ))} +
+ ) : ( +
Пока нет выбранных значений
+ )} +
+ {(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((item) => ( + + {item === "Без изменений" ? item : formatOverridePath(item)} + + ))} +
+
+ ); + }, + [availableScreens, optionMap, screen.id, getScreenTypeLabel] + ); + + return ( +
+
+

+ Настройте альтернативные варианты контента без изменения переходов. +

+ +
+ + {availableScreens.length === 0 ? ( +
+ Добавьте другие экраны в воронку, чтобы настроить вариативность. +
+ ) : variants.length === 0 ? ( +
+ Пока нет дополнительных вариантов. +
+ ) : ( +
+ {variants.map((variant, index) => { + const condition = ensureCondition(variant, screen.id); + const isExpanded = expandedVariant === index; + const availableOptions = optionMap[condition.screenId] ?? []; + + return ( +
+
+
+
+ Вариант {index + 1} +
+
{renderVariantSummary(variant)}
+
+
+ + +
+
+ + {isExpanded && ( +
+
+

✨ Поддержка множественных условий: Теперь вы можете добавить несколько условий для одного варианта. Все условия должны выполняться одновременно (логическое И).

+
+ + {/* 🎯 МНОЖЕСТВЕННЫЕ УСЛОВИЯ */} +
+
+ + Условия ({variant.conditions.length}) + + +
+ + {variant.conditions.map((condition, conditionIndex) => ( +
+
+ + Условие #{conditionIndex + 1} + + {variant.conditions.length > 1 && ( + + )} +
+ +
+ + +
+ + {/* 🎯 НОВЫЙ UI: поддержка разных типов экранов */} +
+ + Условия для {getScreenTypeLabel(condition.screenId)} + + + {(() => { + const targetScreen = availableScreens.find(s => s.id === condition.screenId); + + if (targetScreen?.template === "list") { + // 📝 LIST ЭКРАНЫ - показываем опции + return availableOptions.length === 0 ? ( +
+ В выбранном экране пока нет вариантов ответа. +
+ ) : ( +
+ {availableOptions.map((option) => { + const isChecked = condition.optionIds?.includes(option.id) ?? false; + return ( + + ); + })} +
+ ); + } else if (targetScreen?.template === "date") { + // 📅 DATE ЭКРАНЫ - показываем селекторы возраста и знаков зодиака + return ( +
+ {/* 🎂 СЕЛЕКТОР ВОЗРАСТА */} +
+
🎂 Возрастные условия
+ + 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)} + /> +
+ + {/* ♈ СЕЛЕКТОР ЗНАКОВ ЗОДИАКА */} +
+
♈ Знаки зодиака
+ + !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)} + /> +
+
+ ); + } else if (targetScreen?.template === "email") { + // 📧 EMAIL ЭКРАНЫ - показываем селектор доменов + return ( + toggleValue(index, conditionIndex, value)} + onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)} + /> + ); + } else { + // 🎯 ОБЩИЕ ЭКРАНЫ - простой ввод значений + return ( +
+
+ 💡 Как работает: Для экранов типа “{targetScreen?.template}” + система сравнивает сохраненные ответы пользователя с указанными значениями. +
+ + {/* Показываем выбранные значения */} + {(condition.values ?? []).length > 0 && ( +
+ +
+ {(condition.values ?? []).map((value) => ( + + {value} + + + ))} +
+
+ )} + + {/* Поле для добавления новых значений */} +
+ + { + 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 = ""; + } + } + }} + /> +
+
+ ); + } + })()} +
+
+ ))} +
+ +
+ Настройка контента + handleOverridesChange(index, overrides)} + /> +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/admin/builder/forms/ZodiacSelector.tsx b/src/components/admin/builder/forms/ZodiacSelector.tsx new file mode 100644 index 0000000..0d7f259 --- /dev/null +++ b/src/components/admin/builder/forms/ZodiacSelector.tsx @@ -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 ( +
+ {/* 🔮 КРАСИВАЯ СЕТКА ЗНАКОВ ЗОДИАКА */} +
+ {ZODIAC_SIGNS.map((sign) => { + const isSelected = selectedValues.includes(sign.id); + return ( + + ); + })} +
+ + {/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */} +
+ +
+ setCustomValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustom(); + } + }} + /> + +
+
+ + {/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */} + {selectedValues.length > 0 && ( +
+ +
+ {selectedValues.map((value) => { + const zodiacSign = ZODIAC_SIGNS.find(sign => sign.id === value); + return ( + + {zodiacSign ? ( + <> + {zodiacSign.icon} + {zodiacSign.name} + + ) : ( + {value} + )} + + + ); + })} +
+
+ )} + + {/* 💡 ПОДСКАЗКА */} +
+ 💡 Как это работает: Знак зодиака автоматически определяется из + даты рождения пользователя. Выберите знаки, при которых должен показываться + этот вариант экрана. +
+
+ ); +} diff --git a/src/components/admin/builder/forms/index.ts b/src/components/admin/builder/forms/index.ts new file mode 100644 index 0000000..415095e --- /dev/null +++ b/src/components/admin/builder/forms/index.ts @@ -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"; diff --git a/src/components/admin/builder/index.ts b/src/components/admin/builder/index.ts new file mode 100644 index 0000000..96167b9 --- /dev/null +++ b/src/components/admin/builder/index.ts @@ -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"; diff --git a/src/components/admin/builder/layout/BuilderPreview.tsx b/src/components/admin/builder/layout/BuilderPreview.tsx new file mode 100644 index 0000000..7494a33 --- /dev/null +++ b/src/components/admin/builder/layout/BuilderPreview.tsx @@ -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([]); + const [previewVariantIndex, setPreviewVariantIndex] = useState(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 ( +
+ Ошибка при отображении превью: {error instanceof Error ? error.message : 'Неизвестная ошибка'} +
+ ); + } + }, [previewScreen, selectedIds, handleSelectionChange]); + + const preview = useMemo(() => { + if (!previewScreen) { + return ( +
+
+ Выберите экран для предпросмотра +
+
+ ); + } + + // Увеличим высоту чтобы кнопка поместилась полностью + const PREVIEW_WIDTH = 320; + const PREVIEW_HEIGHT = 750; // Увеличено с ~694px до 750px для BottomActionButton + + return ( +
+ {variants.length > 0 && ( +
+
+ + Превью варианта + + +
+ {previewVariantIndex !== null && ( +
+ ⚠️ Превью принудительно показывает вариант. В реальной воронке он показывается только при выполнении условий. +
+ )} +
+ )} + + {/* Mobile Frame - Simple Border */} +
+ {/* Screen Content with scroll */} +
+ {renderScreenPreview()} +
+
+
+ ); + }, [previewScreen, renderScreenPreview, variants, previewVariantIndex]); + + return preview; +} diff --git a/src/components/admin/builder/layout/BuilderTopBar.tsx b/src/components/admin/builder/layout/BuilderTopBar.tsx new file mode 100644 index 0000000..0fac2ae --- /dev/null +++ b/src/components/admin/builder/layout/BuilderTopBar.tsx @@ -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; + onPublish?: (state: BuilderState) => Promise; + 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(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) => { + 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 ( + + {labels[status as keyof typeof labels]} + + ); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+ + {/* Left section */} +
+ + {/* Back to catalog */} + {onBackToCatalog && ( + + )} + + {/* Funnel info */} +
+
+

+ {funnelInfo?.name || state.meta.title || 'Новая воронка'} +

+ {funnelInfo && getStatusBadge(funnelInfo.status)} + {state.isDirty && ( + + • Несохраненные изменения + + )} +
+ {funnelInfo && ( +
+ v{funnelInfo.version} • Сохранено {formatDate(funnelInfo.lastSaved)} +
+ )} +
+
+ + {/* Right section */} +
+ + {/* Undo/Redo */} +
+ + +
+ + {/* Import/Export */} +
+ + +
+ + + + {/* Save/Publish */} + {onSave && ( + + )} + + {onPublish && ( + + )} + + {/* Create new */} + +
+
+ ); +} diff --git a/src/components/admin/builder/layout/index.ts b/src/components/admin/builder/layout/index.ts new file mode 100644 index 0000000..25db624 --- /dev/null +++ b/src/components/admin/builder/layout/index.ts @@ -0,0 +1,3 @@ +// Layout components for builder interface +export { BuilderTopBar } from "./BuilderTopBar"; +export { BuilderPreview } from "./BuilderPreview"; diff --git a/src/components/admin/builder/providers/BuilderUndoRedoProvider.tsx b/src/components/admin/builder/providers/BuilderUndoRedoProvider.tsx new file mode 100644 index 0000000..d2aa5ed --- /dev/null +++ b/src/components/admin/builder/providers/BuilderUndoRedoProvider.tsx @@ -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(undefined); + +interface BuilderUndoRedoProviderProps { + children: ReactNode; +} + +export function BuilderUndoRedoProvider({ children }: BuilderUndoRedoProviderProps) { + const state = useBuilderState(); + const dispatch = useBuilderDispatch(); + const previousStateRef = useRef(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 ( + + {children} + + ); +} + +export function useBuilderUndoRedo(): UndoRedoContextValue { + const context = useContext(UndoRedoContext); + if (!context) { + throw new Error('useBuilderUndoRedo must be used within BuilderUndoRedoProvider'); + } + return context; +} diff --git a/src/components/admin/builder/providers/index.ts b/src/components/admin/builder/providers/index.ts new file mode 100644 index 0000000..8e6e7d4 --- /dev/null +++ b/src/components/admin/builder/providers/index.ts @@ -0,0 +1,2 @@ +// Provider components for builder state management +export { BuilderUndoRedoProvider } from "./BuilderUndoRedoProvider"; diff --git a/src/components/admin/builder/templates/CouponScreenConfig.tsx b/src/components/admin/builder/templates/CouponScreenConfig.tsx new file mode 100644 index 0000000..105640d --- /dev/null +++ b/src/components/admin/builder/templates/CouponScreenConfig.tsx @@ -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) => void; +} + +export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) { + const couponScreen = screen as CouponScreenDefinition; + + const handleCouponUpdate = ( + field: T, + value: CouponScreenDefinition["coupon"][T] + ) => { + onUpdate({ + coupon: { + ...couponScreen.coupon, + [field]: value, + }, + }); + }; + + return ( +
+
+

+ Настройки оффера +

+ + +
+ +
+

Промокод

+ + +
+ +
+

Сообщение об успехе

+ onUpdate({ copiedMessage: event.target.value || undefined })} + /> +
+
+ ); +} diff --git a/src/components/admin/builder/templates/DateScreenConfig.tsx b/src/components/admin/builder/templates/DateScreenConfig.tsx new file mode 100644 index 0000000..06fc26c --- /dev/null +++ b/src/components/admin/builder/templates/DateScreenConfig.tsx @@ -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) => void; +} + +export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) { + const dateScreen = screen as DateScreenDefinition; + + const handleDateInputChange = ( + field: T, + value: DateScreenDefinition["dateInput"][T] + ) => { + onUpdate({ + dateInput: { + ...dateScreen.dateInput, + [field]: value, + }, + }); + }; + + const handleZodiacSettingsChange = ( + updates: Partial> + ) => { + 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 ( +
+
+

+ Поля ввода даты +

+
+ + + +
+ +
+ + + +
+
+ +
+

Поведение поля

+ + +
+
+ + handleZodiacSettingsChange({ enabled: event.target.checked }) + } + /> + Автоматически определять знак зодиака +
+ +

+ Если включено, система вычислит знак зодиака по выбранной дате и сохранит его по + указанному ключу. Значение можно использовать в правилах навигации и вариативности. +

+ + {dateScreen.dateInput?.zodiac?.enabled && ( + + )} +
+ +
+ + +
+ + +
+ +
+

Информационный блок

+ + {dateScreen.infoMessage && ( + + )} +
+
+ ); +} diff --git a/src/components/admin/builder/templates/EmailScreenConfig.tsx b/src/components/admin/builder/templates/EmailScreenConfig.tsx new file mode 100644 index 0000000..9ff00c5 --- /dev/null +++ b/src/components/admin/builder/templates/EmailScreenConfig.tsx @@ -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) => void; +} + +export function EmailScreenConfig({ screen, onUpdate }: EmailScreenConfigProps) { + const updateEmailInput = (updates: Partial) => { + onUpdate({ + emailInput: { + ...screen.emailInput, + ...updates, + }, + }); + }; + + const updateImage = (updates: Partial) => { + onUpdate({ + image: screen.image ? { + ...screen.image, + ...updates, + } : { src: "", ...updates }, + }); + }; + + + return ( +
+
+

Настройки поля Email

+
+ updateEmailInput({ label: e.target.value })} + /> + updateEmailInput({ placeholder: e.target.value })} + /> +
+
+ +
+

Изображение (вариативное)

+
+ updateImage({ src: e.target.value })} + /> +
+
+ 💡 Вариация изображений: Базовое изображение настраивается здесь. + Alt текст, размеры (164x245) и стили зашиты в верстку согласно дизайну. + Альтернативные варианты настраиваются в секции “Вариативность” → добавить вариант → выбрать условие “gender = male” → переопределить поле image. +
+
+ +
+

Информация

+
+

• Банер безопасности отображается автоматически с общим текстом для воронки

+

• PrivacyTermsConsent настраивается через bottomActionButton.showPrivacyTermsConsent

+
+
+
+ ); +} diff --git a/src/components/admin/builder/templates/FormScreenConfig.tsx b/src/components/admin/builder/templates/FormScreenConfig.tsx new file mode 100644 index 0000000..81e26a3 --- /dev/null +++ b/src/components/admin/builder/templates/FormScreenConfig.tsx @@ -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) => void; +} + +export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) { + const formScreen = screen as FormScreenDefinition; + + const updateField = (index: number, updates: Partial) => { + const newFields = [...(formScreen.fields || [])]; + newFields[index] = { ...newFields[index], ...updates }; + onUpdate({ fields: newFields }); + }; + + const updateValidationMessages = (updates: Partial) => { + 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 ( +
+
+
+

Поля формы

+ +
+ + {formScreen.fields?.map((field, index) => ( +
+
+ + Поле {index + 1} + + +
+ +
+ + +
+ + + + + +
+ + +
+ +
+ + +
+
+ ))} + + {(!formScreen.fields || formScreen.fields.length === 0) && ( +
+ Пока нет полей. Добавьте хотя бы одно, чтобы форма работала. +
+ )} +
+ +
+

Сообщения валидации

+
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/src/components/admin/builder/templates/InfoScreenConfig.tsx b/src/components/admin/builder/templates/InfoScreenConfig.tsx new file mode 100644 index 0000000..53f7783 --- /dev/null +++ b/src/components/admin/builder/templates/InfoScreenConfig.tsx @@ -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) => 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 = >( + field: T, + value: NonNullable[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 ( +
+
+

+ Информационный контент +

+
+ + + {/* 🎨 ПРЕВЬЮ РАЗМЕТКИ */} + {infoScreen.description?.text && ( + + )} +
+
+ +
+

Иконка

+
+ + +
+ + +
+
+ ); +} diff --git a/src/components/admin/builder/templates/ListScreenConfig.tsx b/src/components/admin/builder/templates/ListScreenConfig.tsx new file mode 100644 index 0000000..08e38e0 --- /dev/null +++ b/src/components/admin/builder/templates/ListScreenConfig.tsx @@ -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) => 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>(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 ( +
+
+
+

+ Варианты выбора +

+
+ + +
+
+ +
+

Автопереход: Для single selection списков без кнопки (bottomActionButton.show: false) включается автоматически.

+
+
+ +
+
+

Настройка вариантов

+ +
+ +
+ {listScreen.list.options.map((option, index) => ( +
+
+
toggleOptionExpanded(index)} + > + {expandedOptions.has(index) ? ( + + ) : ( + + )} + + Вариант {index + 1} + + + {option.label || `(Пустой вариант)`} + +
+
+ + + +
+
+ + {expandedOptions.has(index) && ( +
+ + + + + + + + + + + +
+ )} +
+ ))} +
+ + {listScreen.list.options.length === 0 && ( +
+ Добавьте хотя бы один вариант, чтобы экран работал корректно. +
+ )} +
+ +
+

Автопереход: Для single selection списков без кнопки (bottomActionButton.show: false) включается автоматически.

+
+
+ ); +} diff --git a/src/components/admin/builder/templates/LoadersScreenConfig.tsx b/src/components/admin/builder/templates/LoadersScreenConfig.tsx new file mode 100644 index 0000000..96dd99f --- /dev/null +++ b/src/components/admin/builder/templates/LoadersScreenConfig.tsx @@ -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) => void; +} + +export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigProps) { + const updateProgressbars = (updates: Partial) => { + 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 + ) => { + const currentItems = screen.progressbars?.items || []; + const updatedItems = currentItems.map((item, i) => + i === index ? { ...item, ...updates } : item + ); + updateProgressbars({ items: updatedItems }); + }; + + return ( +
+
+

Настройки анимации

+ updateProgressbars({ transitionDuration: parseInt(e.target.value) || 5000 })} + /> +
+ +
+
+

Шаги загрузки

+ +
+ +
+ {(screen.progressbars?.items || []).map((item, index) => ( +
+
+
Шаг {index + 1}
+ +
+ +
+ updateProgressbarItem(index, { title: e.target.value })} + /> + updateProgressbarItem(index, { subtitle: e.target.value })} + /> +
+ +
+ updateProgressbarItem(index, { processingTitle: e.target.value })} + /> + updateProgressbarItem(index, { processingSubtitle: e.target.value })} + /> +
+ +
+ updateProgressbarItem(index, { completedTitle: e.target.value })} + /> + updateProgressbarItem(index, { completedSubtitle: e.target.value })} + /> +
+
+ ))} +
+ + {(screen.progressbars?.items || []).length === 0 && ( +
+

Нет шагов загрузки

+ +
+ )} +
+
+ ); +} diff --git a/src/components/admin/builder/templates/SoulmatePortraitScreenConfig.tsx b/src/components/admin/builder/templates/SoulmatePortraitScreenConfig.tsx new file mode 100644 index 0000000..7fbe541 --- /dev/null +++ b/src/components/admin/builder/templates/SoulmatePortraitScreenConfig.tsx @@ -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) => void; +} + +export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortraitScreenConfigProps) { + const updateDescription = (updates: Partial) => { + onUpdate({ + description: screen.description ? { + ...screen.description, + ...updates, + } : { text: "", ...updates }, + }); + }; + + return ( +
+
+

Описание портрета

+ updateDescription({ text: e.target.value })} + /> +
+ +
+

Информация

+
+

• PrivacyTermsConsent настраивается через bottomActionButton.showPrivacyTermsConsent

+
+
+ +
+

💡 Назначение экрана

+

+ Экран “Soulmate Portrait” предназначен для отображения результатов анализа совместимости + или характеристик идеального партнера на основе ответов пользователя в воронке. +

+
+
+ ); +} diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx new file mode 100644 index 0000000..f7aec55 --- /dev/null +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -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) => 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 ( +
+ + {isExpanded &&
{children}
} +
+ ); +} + +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 ( +
+
+ + handleTextChange(event.target.value)} /> +
+ + {value?.text && ( +
+ + + {(isHydrated ? showAdvanced : false) && ( +
+ + + + + + +
+ )} +
+ )} +
+ ); +} + +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 ( +
+ + + {activeHeader.show !== false && ( +
+ +
+ )} +
+ ); +} + +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 ( +
+ + + {isEnabled && ( +
+ + + + + +
+ )} +
+ ); +} + +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 ( +
+ + + + + + + + + + + + + + {template === "info" && ( + ) => void} + /> + )} + {template === "date" && ( + ) => void} + /> + )} + {template === "coupon" && ( + ) => void} + /> + )} + {template === "form" && ( + ) => void} + /> + )} + {template === "list" && ( + ) => void} + /> + )} + {template === "email" && ( + ) => void} + /> + )} + {template === "loaders" && ( + ) => void} + /> + )} + {template === "soulmate" && ( + ) => void} + /> + )} +
+ ); +} diff --git a/src/components/admin/builder/templates/index.ts b/src/components/admin/builder/templates/index.ts new file mode 100644 index 0000000..3070441 --- /dev/null +++ b/src/components/admin/builder/templates/index.ts @@ -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"; diff --git a/src/components/funnel/FunnelRuntime.tsx b/src/components/funnel/FunnelRuntime.tsx new file mode 100644 index 0000000..6042dd7 --- /dev/null +++ b/src/components/funnel/FunnelRuntime.tsx @@ -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(); + 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, + }); +} diff --git a/src/components/funnel/templates/CouponTemplate/CouponTemplate.stories.tsx b/src/components/funnel/templates/CouponTemplate/CouponTemplate.stories.tsx new file mode 100644 index 0000000..6355cb8 --- /dev/null +++ b/src/components/funnel/templates/CouponTemplate/CouponTemplate.stories.tsx @@ -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 = { + 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; + +/** Дефолтный купон экран */ +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", + }, + }, + }, + }, +}; diff --git a/src/components/funnel/templates/CouponTemplate/CouponTemplate.tsx b/src/components/funnel/templates/CouponTemplate/CouponTemplate.tsx new file mode 100644 index 0000000..c3e768c --- /dev/null +++ b/src/components/funnel/templates/CouponTemplate/CouponTemplate.tsx @@ -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(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 ( + +
+
+ +
+ + {copiedCode && ( +
+ + {screen.copiedMessage + ? screen.copiedMessage.replace("{code}", copiedCode || "") + : `Промокод "${copiedCode}" скопирован!` + } + +
+ )} +
+
+ ); +} diff --git a/src/components/funnel/templates/CouponTemplate/index.ts b/src/components/funnel/templates/CouponTemplate/index.ts new file mode 100644 index 0000000..8d4f114 --- /dev/null +++ b/src/components/funnel/templates/CouponTemplate/index.ts @@ -0,0 +1 @@ +export { CouponTemplate } from "./CouponTemplate"; diff --git a/src/components/funnel/templates/DateTemplate/DateTemplate.stories.tsx b/src/components/funnel/templates/DateTemplate/DateTemplate.stories.tsx new file mode 100644 index 0000000..817830c --- /dev/null +++ b/src/components/funnel/templates/DateTemplate/DateTemplate.stories.tsx @@ -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 = { + 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; + +/** Дефолтный экран выбора даты */ +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, + }, + }, + }, +}; diff --git a/src/components/funnel/templates/DateTemplate/DateTemplate.tsx b/src/components/funnel/templates/DateTemplate/DateTemplate.tsx new file mode 100644 index 0000000..995ad34 --- /dev/null +++ b/src/components/funnel/templates/DateTemplate/DateTemplate.tsx @@ -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 ? ( +
+ + {screen.dateInput?.selectedDateLabel || "Выбранная дата:"} + + + {formattedDate} + +
+ ) : null; + + return ( + +
+ + + {screen.infoMessage && ( +
+
+
+ Security icon +
+ + {screen.infoMessage.text} + +
+
+ )} +
+
+ ); +} diff --git a/src/components/funnel/templates/DateTemplate/index.ts b/src/components/funnel/templates/DateTemplate/index.ts new file mode 100644 index 0000000..e4d4930 --- /dev/null +++ b/src/components/funnel/templates/DateTemplate/index.ts @@ -0,0 +1 @@ +export { DateTemplate } from "./DateTemplate"; diff --git a/src/components/funnel/templates/EmailTemplate/EmailTemplate.stories.tsx b/src/components/funnel/templates/EmailTemplate/EmailTemplate.stories.tsx new file mode 100644 index 0000000..5abbd75 --- /dev/null +++ b/src/components/funnel/templates/EmailTemplate/EmailTemplate.stories.tsx @@ -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 = { + 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; + +/** Дефолтный 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...", + }, + }, + }, +}; diff --git a/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx new file mode 100644 index 0000000..b83d5c8 --- /dev/null +++ b/src/components/funnel/templates/EmailTemplate/EmailTemplate.tsx @@ -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>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: selectedEmail || "", + }, + }); + + useEffect(() => { + form.setValue("email", selectedEmail || ""); + }, [selectedEmail, form]); + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + form.setValue("email", value); + form.trigger("email"); + onEmailChange(value); + }; + + const isFormValid = form.formState.isValid && form.getValues("email"); + + return ( + +
+ { + setIsTouched(true); + form.trigger("email"); + }} + aria-invalid={isTouched && !!form.formState.errors.email} + aria-errormessage={ + isTouched ? form.formState.errors.email?.message : undefined + } + /> + + {screen.image && ( + portrait + )} + + +
+
+ ); +} diff --git a/src/components/funnel/templates/EmailTemplate/index.ts b/src/components/funnel/templates/EmailTemplate/index.ts new file mode 100644 index 0000000..047457d --- /dev/null +++ b/src/components/funnel/templates/EmailTemplate/index.ts @@ -0,0 +1 @@ +export { EmailTemplate } from "./EmailTemplate"; diff --git a/src/components/funnel/templates/FormTemplate/FormTemplate.stories.tsx b/src/components/funnel/templates/FormTemplate/FormTemplate.stories.tsx new file mode 100644 index 0000000..7929f13 --- /dev/null +++ b/src/components/funnel/templates/FormTemplate/FormTemplate.stories.tsx @@ -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 = { + 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; + +/** Дефолтная форма */ +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 || "", + }, + }, + }, +}; diff --git a/src/components/funnel/templates/FormTemplate/FormTemplate.tsx b/src/components/funnel/templates/FormTemplate/FormTemplate.tsx new file mode 100644 index 0000000..69b903b --- /dev/null +++ b/src/components/funnel/templates/FormTemplate/FormTemplate.tsx @@ -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; + onFormDataChange: (data: Record) => 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>(formData); + const [errors, setErrors] = useState>({}); + + 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 = {}; + + 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 ( + +
+ {screen.fields.map((field) => ( +
+ handleFieldChange(field.id, e.target.value)} + maxLength={field.maxLength} + aria-invalid={!!errors[field.id]} + aria-errormessage={errors[field.id]} + /> + {errors[field.id] && ( +

+ {errors[field.id]} +

+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/funnel/templates/FormTemplate/index.ts b/src/components/funnel/templates/FormTemplate/index.ts new file mode 100644 index 0000000..0ed0701 --- /dev/null +++ b/src/components/funnel/templates/FormTemplate/index.ts @@ -0,0 +1 @@ +export { FormTemplate } from "./FormTemplate"; diff --git a/src/components/funnel/templates/InfoTemplate/InfoTemplate.stories.tsx b/src/components/funnel/templates/InfoTemplate/InfoTemplate.stories.tsx new file mode 100644 index 0000000..6f1d653 --- /dev/null +++ b/src/components/funnel/templates/InfoTemplate/InfoTemplate.stories.tsx @@ -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 = { + 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; + +/** Дефолтный информационный экран */ +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, + }, + }, + }, +}; diff --git a/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx b/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx new file mode 100644 index 0000000..17ecb61 --- /dev/null +++ b/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx @@ -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 ( + +
+ {/* Icon */} + {screen.icon && ( +
+ {screen.icon.type === "emoji" ? ( +
+ {screen.icon.value} +
+ ) : ( + + )} +
+ )} + + {/* Description */} + {screen.description && ( +
+ + {screen.description.text} + +
+ )} +
+
+ ); +} diff --git a/src/components/funnel/templates/InfoTemplate/index.ts b/src/components/funnel/templates/InfoTemplate/index.ts new file mode 100644 index 0000000..36710fb --- /dev/null +++ b/src/components/funnel/templates/InfoTemplate/index.ts @@ -0,0 +1 @@ +export { InfoTemplate } from "./InfoTemplate"; diff --git a/src/components/funnel/templates/ListTemplate/ListTemplate.stories.tsx b/src/components/funnel/templates/ListTemplate/ListTemplate.stories.tsx new file mode 100644 index 0000000..fc4b716 --- /dev/null +++ b/src/components/funnel/templates/ListTemplate/ListTemplate.stories.tsx @@ -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 = { + 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; + +/** Дефолтный 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, // Используем базовые дефолты без эмодзи + }, + }, +}; diff --git a/src/components/funnel/templates/ListTemplate/ListTemplate.tsx b/src/components/funnel/templates/ListTemplate/ListTemplate.tsx new file mode 100644 index 0000000..d4a1d63 --- /dev/null +++ b/src/components/funnel/templates/ListTemplate/ListTemplate.tsx @@ -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); + } + }, + } : undefined; + + return ( + +
+ {contentType === "radio-answers-list" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/funnel/templates/ListTemplate/index.ts b/src/components/funnel/templates/ListTemplate/index.ts new file mode 100644 index 0000000..dfb163d --- /dev/null +++ b/src/components/funnel/templates/ListTemplate/index.ts @@ -0,0 +1 @@ +export { ListTemplate } from "./ListTemplate"; diff --git a/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.stories.tsx b/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.stories.tsx new file mode 100644 index 0000000..850ab71 --- /dev/null +++ b/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.stories.tsx @@ -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 = { + 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; + +/** Дефолтный 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 }, + }, +}; diff --git a/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.tsx b/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.tsx new file mode 100644 index 0000000..7e0a02b --- /dev/null +++ b/src/components/funnel/templates/LoadersTemplate/LoadersTemplate.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { TemplateLayout } from "../layouts/TemplateLayout"; +import CircularProgressbarsList from "@/components/widgets/CircularProgressbarsList/CircularProgressbarsList"; +import type { LoadersScreenDefinition } from "@/lib/funnel/types"; + +interface LoadersTemplateProps { + screen: LoadersScreenDefinition; + onContinue: () => void; + canGoBack: boolean; + onBack: () => void; + screenProgress?: { current: number; total: number }; + defaultTexts?: { nextButton?: string; continueButton?: string }; +} + +export function LoadersTemplate({ + screen, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, +}: LoadersTemplateProps) { + const [isVisibleButton, setIsVisibleButton] = useState(false); + + const onAnimationEnd = () => { + setIsVisibleButton(true); + }; + + const progressbarsListProps = { + progressbarItems: screen.progressbars?.items?.map((item: unknown, index: number) => { + const typedItem = item as { + title?: string; + processingTitle?: string; + processingSubtitle?: string; + completedTitle?: string; + completedSubtitle?: string; + }; + + return { + circularProgressbarProps: { + text: { children: typedItem.title || `Step ${index + 1}` }, + }, + processing: typedItem.processingTitle ? { + title: { children: typedItem.processingTitle }, + text: typedItem.processingSubtitle ? { children: typedItem.processingSubtitle } : undefined, + } : undefined, + completed: typedItem.completedTitle ? { + title: { children: typedItem.completedTitle }, + text: typedItem.completedSubtitle ? { children: typedItem.completedSubtitle } : undefined, + } : undefined, + }; + }) || [], + transitionDurationItem: screen.progressbars?.transitionDuration || 3000, // Как в оригинале + onAnimationEnd, + }; + + return ( + +
+ +
+
+ ); +} diff --git a/src/components/funnel/templates/LoadersTemplate/index.ts b/src/components/funnel/templates/LoadersTemplate/index.ts new file mode 100644 index 0000000..7c8a764 --- /dev/null +++ b/src/components/funnel/templates/LoadersTemplate/index.ts @@ -0,0 +1 @@ +export { LoadersTemplate } from "./LoadersTemplate"; diff --git a/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.stories.tsx b/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.stories.tsx new file mode 100644 index 0000000..afabd51 --- /dev/null +++ b/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.stories.tsx @@ -0,0 +1,114 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; +import { fn } from "storybook/test"; +import { buildSoulmateDefaults } from "@/lib/admin/builder/state/defaults/soulmate"; +import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types"; + +// Получаем дефолтные значения из builder +const defaultScreen = buildSoulmateDefaults("soulmate-screen-story") as SoulmatePortraitScreenDefinition; + +/** SoulmatePortraitTemplate - результирующие экраны с портретом партнера */ +const meta: Meta = { + title: "Funnel Templates/SoulmatePortraitTemplate", + component: SoulmatePortraitTemplate, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + screen: defaultScreen, + onContinue: fn(), + canGoBack: true, + onBack: fn(), + screenProgress: { current: 10, 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; + +/** Дефолтный soulmate portrait экран */ +export const Default: Story = {}; + +/** Экран без описания */ +export const WithoutDescription: Story = { + args: { + screen: { + ...defaultScreen, + description: undefined, + }, + }, +}; + +/** Экран с кастомным описанием */ +export const CustomDescription: Story = { + args: { + screen: { + ...defaultScreen, + description: { + text: "На основе ваших ответов мы создали уникальный **портрет вашей второй половинки**. Этот анализ поможет вам лучше понять, кто может стать идеальным партнером.", + font: "inter", + weight: "regular", + align: "center", + size: "md", + color: "default", + }, + }, + }, +}; + +/** Экран без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Экран без subtitle */ +export const WithoutSubtitle: Story = { + args: { + screen: { + ...defaultScreen, + subtitle: { + ...defaultScreen.subtitle, + show: false, + text: defaultScreen.subtitle?.text || "", + }, + }, + }, +}; + +/** Финальный экран (без прогресса) */ +export const FinalScreen: Story = { + args: { + screen: { + ...defaultScreen, + header: { + show: true, + showBackButton: false, // На финальном экране обычно нет кнопки назад + showProgress: false, // И нет прогресса + }, + }, + screenProgress: undefined, + canGoBack: false, + }, +}; diff --git a/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.tsx b/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.tsx new file mode 100644 index 0000000..aea5490 --- /dev/null +++ b/src/components/funnel/templates/SoulmatePortraitTemplate/SoulmatePortraitTemplate.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types"; +import { TemplateLayout } from "../layouts/TemplateLayout"; + +interface SoulmatePortraitTemplateProps { + screen: SoulmatePortraitScreenDefinition; + onContinue: () => void; + canGoBack: boolean; + onBack: () => void; + screenProgress?: { current: number; total: number }; + defaultTexts?: { nextButton?: string; continueButton?: string }; +} + +export function SoulmatePortraitTemplate({ + screen, + onContinue, + canGoBack, + onBack, + screenProgress, + defaultTexts, +}: SoulmatePortraitTemplateProps) { + return ( + +
+
+
+ ); +} diff --git a/src/components/funnel/templates/SoulmatePortraitTemplate/index.ts b/src/components/funnel/templates/SoulmatePortraitTemplate/index.ts new file mode 100644 index 0000000..3d17617 --- /dev/null +++ b/src/components/funnel/templates/SoulmatePortraitTemplate/index.ts @@ -0,0 +1 @@ +export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; diff --git a/src/components/funnel/templates/index.ts b/src/components/funnel/templates/index.ts new file mode 100644 index 0000000..cf85da7 --- /dev/null +++ b/src/components/funnel/templates/index.ts @@ -0,0 +1,12 @@ +// Funnel Templates - каждый в своей папке с stories +export { InfoTemplate } from "./InfoTemplate"; +export { ListTemplate } from "./ListTemplate"; +export { DateTemplate } from "./DateTemplate"; +export { FormTemplate } from "./FormTemplate"; +export { EmailTemplate } from "./EmailTemplate"; +export { CouponTemplate } from "./CouponTemplate"; +export { LoadersTemplate } from "./LoadersTemplate"; +export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; + +// Layout Templates +export { TemplateLayout } from "./layouts/TemplateLayout"; diff --git a/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx b/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx new file mode 100644 index 0000000..746d04b --- /dev/null +++ b/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx @@ -0,0 +1,241 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { TemplateLayout } from "./TemplateLayout"; +import { fn } from "storybook/test"; +import type { InfoScreenDefinition } from "@/lib/funnel/types"; + +// Создаем mock экран для демонстрации TemplateLayout +const mockScreen: InfoScreenDefinition = { + id: "template-layout-demo", + template: "info", + header: { + show: true, + showBackButton: true, + showProgress: true, + }, + title: { + text: "TemplateLayout Demo", + font: "manrope", + weight: "bold", + align: "center", + size: "2xl", + color: "default", + }, + description: { + text: "Это демонстрация **TemplateLayout** - централизованного layout wrapper для всех funnel templates. Он управляет header, progress bar, кнопкой назад и нижней кнопкой.", + font: "inter", + weight: "regular", + align: "center", + size: "md", + color: "default", + }, + bottomActionButton: { + show: true, + text: "Continue", + showGradientBlur: true, + }, + navigation: { + defaultNextScreenId: undefined, + rules: [], + }, +}; + +/** TemplateLayout - централизованный wrapper для всех funnel templates */ +const meta: Meta = { + title: "Funnel Templates/TemplateLayout", + component: TemplateLayout, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: ` +TemplateLayout - это централизованный layout wrapper, который используется всеми funnel templates. + +**Основные возможности:** +- Управление header (показать/скрыть, progress bar, кнопка назад) +- Bottom action button с gradient blur +- Privacy terms consent +- Динамическая высота для фиксированного нижнего контента +- Единообразные настройки типографики + +**Архитектурные принципы:** +- Устраняет дублирование кода между templates +- Централизованная логика UI элементов +- Использует только существующие UI компоненты (LayoutQuestion, BottomActionButton) + ` + } + } + }, + args: { + screen: mockScreen, + canGoBack: true, + onBack: fn(), + screenProgress: { current: 5, total: 10 }, + titleDefaults: { font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }, + subtitleDefaults: { font: "inter", weight: "medium", color: "default", align: "center", size: "lg" }, + actionButtonOptions: { + defaultText: "Continue", + disabled: false, + onClick: fn(), + }, + children: ( +
+
+

+ Это контент, который передается в TemplateLayout через children prop. +

+
+
+ Header: управляется TemplateLayout +
+
+ Content: передается через children +
+
+ Button: управляется TemplateLayout +
+
+ Blur: управляется TemplateLayout +
+
+
+
+ ), + }, + argTypes: { + screen: { + control: { type: "object" }, + description: "Screen definition с настройками header и bottomActionButton" + }, + screenProgress: { + control: { type: "object" }, + description: "Прогресс для отображения в header" + }, + actionButtonOptions: { + control: { type: "object" }, + description: "Настройки нижней кнопки (если передается, кнопка показывается)" + }, + children: { + control: false, + description: "Контент template, который рендерится внутри layout" + }, + onBack: { action: "back" }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Дефолтный TemplateLayout со всеми элементами */ +export const Default: Story = {}; + +/** Layout без header */ +export const WithoutHeader: Story = { + args: { + screen: { + ...mockScreen, + header: { + show: false, + }, + }, + }, +}; + +/** Layout без progress bar */ +export const WithoutProgressBar: Story = { + args: { + screen: { + ...mockScreen, + header: { + show: true, + showBackButton: true, + showProgress: false, + }, + }, + }, +}; + +/** Layout без кнопки назад */ +export const WithoutBackButton: Story = { + args: { + screen: { + ...mockScreen, + header: { + show: true, + showBackButton: false, + showProgress: true, + }, + }, + canGoBack: false, + }, +}; + +/** Layout без нижней кнопки */ +export const WithoutActionButton: Story = { + args: { + actionButtonOptions: undefined, + }, +}; + +/** Layout только с blur gradient (без кнопки) */ +export const OnlyBlurGradient: Story = { + args: { + screen: { + ...mockScreen, + bottomActionButton: { + show: false, + showGradientBlur: true, + }, + }, + actionButtonOptions: undefined, + }, +}; + +/** Layout с Privacy Terms Consent */ +export const WithPrivacyConsent: Story = { + args: { + screen: { + ...mockScreen, + bottomActionButton: { + ...mockScreen.bottomActionButton, + showPrivacyTermsConsent: true, + }, + }, + }, +}; + +/** Layout с disabled кнопкой */ +export const WithDisabledButton: Story = { + args: { + actionButtonOptions: { + defaultText: "Continue", + disabled: true, + onClick: fn(), + }, + }, +}; + +/** Минимальный layout (только контент) */ +export const Minimal: Story = { + args: { + screen: { + ...mockScreen, + header: { + show: false, + }, + bottomActionButton: { + show: false, + showGradientBlur: false, + }, + }, + actionButtonOptions: undefined, + children: ( +
+

Минимальный Layout

+

+ Только контент, без header и нижней кнопки +

+
+ ), + }, +}; diff --git a/src/components/funnel/templates/layouts/TemplateLayout.tsx b/src/components/funnel/templates/layouts/TemplateLayout.tsx new file mode 100644 index 0000000..7b7d29f --- /dev/null +++ b/src/components/funnel/templates/layouts/TemplateLayout.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React from "react"; +import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion"; +import { BottomActionButton } from "@/components/widgets/BottomActionButton/BottomActionButton"; +import PrivacyTermsConsent from "@/components/widgets/PrivacyTermsConsent/PrivacyTermsConsent"; +import { useDynamicSize } from "@/hooks/DOM/useDynamicSize"; +import { + buildLayoutQuestionProps, + buildTemplateBottomActionButtonProps, +} from "@/lib/funnel/mappers"; +import type { ScreenDefinition } from "@/lib/funnel/types"; + +interface TemplateLayoutProps { + screen: ScreenDefinition; + canGoBack: boolean; + onBack: () => void; + screenProgress?: { current: number; total: number }; + + // Настройки template + titleDefaults?: { + font?: "manrope" | "inter" | "geistSans" | "geistMono"; + weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black"; + align?: "left" | "center" | "right"; + size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; + color?: "default" | "primary" | "secondary" | "destructive" | "success" | "card" | "accent" | "muted"; + }; + subtitleDefaults?: { + font?: "manrope" | "inter" | "geistSans" | "geistMono"; + weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black"; + color?: "default" | "primary" | "secondary" | "destructive" | "success" | "card" | "accent" | "muted"; + align?: "left" | "center" | "right"; + size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; + }; + actionButtonOptions?: { + defaultText: string; + disabled: boolean; + onClick: () => void; + }; + + // Дополнительные props для BottomActionButton + childrenUnderButton?: React.ReactNode; + + // Контент template + children: React.ReactNode; +} + +/** + * Централизованный layout wrapper для всех templates + * Устраняет дублирование логики Header и BottomActionButton + */ +export function TemplateLayout({ + screen, + canGoBack, + onBack, + screenProgress, + titleDefaults = { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }, + subtitleDefaults = { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }, + actionButtonOptions, + childrenUnderButton, + children, +}: TemplateLayoutProps) { + // 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON + const { + elementRef: bottomActionButtonRef, + } = useDynamicSize({ + defaultHeight: 132, + }); + + // 🎯 ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА HEADER + const layoutQuestionProps = buildLayoutQuestionProps({ + screen, + titleDefaults, + subtitleDefaults, + canGoBack, + onBack, + screenProgress, + }); + + // 🎯 ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON PROPS + const bottomActionButtonProps = actionButtonOptions + ? buildTemplateBottomActionButtonProps({ + screen, + actionButtonOptions, + }) + : undefined; + + // 🎯 Автоматически создаем PrivacyTermsConsent с фиксированными настройками + const shouldShowPrivacyTermsConsent = + 'bottomActionButton' in screen && + screen.bottomActionButton?.showPrivacyTermsConsent === true; + + const autoPrivacyTermsConsent = shouldShowPrivacyTermsConsent ? ( + + ) : null; + + // Комбинируем переданный childrenUnderButton с автоматическим PrivacyTermsConsent + const finalChildrenUnderButton = ( + <> + {childrenUnderButton} + {autoPrivacyTermsConsent} + + ); + + // 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ + return ( +
+ + {children} + + + {bottomActionButtonProps && ( + + )} +
+ ); +} diff --git a/src/components/funnel/templates/layouts/index.ts b/src/components/funnel/templates/layouts/index.ts new file mode 100644 index 0000000..d2f39e6 --- /dev/null +++ b/src/components/funnel/templates/layouts/index.ts @@ -0,0 +1,2 @@ +// Layout components - base layouts and structural components +export { TemplateLayout } from "./TemplateLayout"; diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx index 0a1525c..2a73149 100644 --- a/src/components/layout/Header/Header.tsx +++ b/src/components/layout/Header/Header.tsx @@ -8,25 +8,41 @@ import { Button } from "@/components/ui/button"; interface HeaderProps extends React.ComponentProps<"header"> { progressProps?: React.ComponentProps; onBack?: () => void; + showBackButton?: boolean; } -function Header({ className, progressProps, onBack, ...props }: HeaderProps) { +function Header({ + className, + progressProps, + onBack, + showBackButton = true, + ...props +}: HeaderProps) { + const shouldRenderBackButton = showBackButton && typeof onBack === "function"; + return ( -
-
- -
-
- +
+
+ {shouldRenderBackButton && ( + + )}
+ + {progressProps && ( +
+ +
+ )}
); } -export { Header }; +export { Header }; \ No newline at end of file diff --git a/src/components/layout/LayoutQuestion/LayoutQuestion.stories.tsx b/src/components/layout/LayoutQuestion/LayoutQuestion.stories.tsx index 95c3ad2..775b42e 100644 --- a/src/components/layout/LayoutQuestion/LayoutQuestion.stories.tsx +++ b/src/components/layout/LayoutQuestion/LayoutQuestion.stories.tsx @@ -26,15 +26,10 @@ const meta: Meta = { children: "Let's personalize your hair care journey", }, children: ( -
+
Children
), - bottomActionButtonProps: { - actionButtonProps: { - children: "Continue", - }, - }, }, argTypes: {}, }; diff --git a/src/components/layout/LayoutQuestion/LayoutQuestion.tsx b/src/components/layout/LayoutQuestion/LayoutQuestion.tsx index f7aac4d..e64deb4 100644 --- a/src/components/layout/LayoutQuestion/LayoutQuestion.tsx +++ b/src/components/layout/LayoutQuestion/LayoutQuestion.tsx @@ -2,22 +2,16 @@ import { cn } from "@/lib/utils"; import { Header } from "@/components/layout/Header/Header"; -import Typography, { - TypographyProps, -} from "@/components/ui/Typography/Typography"; -import { - BottomActionButton, - BottomActionButtonProps, -} from "@/components/widgets/BottomActionButton/BottomActionButton"; -import { useEffect, useRef, useState } from "react"; +import Typography, { TypographyProps } from "@/components/ui/Typography/Typography"; export interface LayoutQuestionProps extends Omit, "title" | "content"> { headerProps?: React.ComponentProps; - title: TypographyProps<"h2">; - subtitle: TypographyProps<"p">; + title?: TypographyProps<"h2">; + subtitle?: TypographyProps<"p">; children: React.ReactNode; - bottomActionButtonProps?: BottomActionButtonProps; + contentProps?: React.ComponentProps<"div">; + childrenWrapperProps?: React.ComponentProps<"div">; } function LayoutQuestion({ @@ -26,65 +20,62 @@ function LayoutQuestion({ title, subtitle, children, - bottomActionButtonProps, + contentProps, + childrenWrapperProps, ...props }: LayoutQuestionProps) { - const bottomActionButtonRef = useRef(null); - const [bottomActionButtonHeight, setBottomActionButtonHeight] = - useState(132); - - useEffect(() => { - if (bottomActionButtonRef.current) { - console.log(bottomActionButtonRef.current.clientHeight); - - setBottomActionButtonHeight(bottomActionButtonRef.current.clientHeight); - } - }, [bottomActionButtonProps]); - return (
-
-
+ {headerProps &&
} + +
{title && ( )} + {subtitle && ( )} - {children} - {bottomActionButtonProps && ( - - )} + +
+ {children} +
); } -export { LayoutQuestion }; +export { LayoutQuestion }; \ No newline at end of file diff --git a/src/components/providers/AppProviders.tsx b/src/components/providers/AppProviders.tsx new file mode 100644 index 0000000..c78c50d --- /dev/null +++ b/src/components/providers/AppProviders.tsx @@ -0,0 +1,13 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { FunnelProvider } from "@/lib/funnel/FunnelProvider"; + +interface AppProvidersProps { + children: ReactNode; +} + +export function AppProviders({ children }: AppProvidersProps) { + return {children}; +} diff --git a/src/components/templates/Coupon/Coupon.stories.tsx b/src/components/templates/Coupon/Coupon.stories.tsx new file mode 100644 index 0000000..9700e3a --- /dev/null +++ b/src/components/templates/Coupon/Coupon.stories.tsx @@ -0,0 +1,80 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { Coupon } from "./Coupon"; +import { fn } from "storybook/test"; +import { + LayoutQuestion, + LayoutQuestionProps, +} from "@/components/layout/LayoutQuestion/LayoutQuestion"; + +const layoutQuestionProps: Omit = { + headerProps: { + onBack: fn(), + }, + title: { + children: "Тебе повезло!", + align: "center", + }, + subtitle: { + children: "Ты получил специальную эксклюзивную скидку на 94%", + align: "center", + }, +}; + +/** Reusable Coupon page Component */ +const meta: Meta = { + title: "Templates/Coupon", + component: Coupon, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + couponProps: { + title: { + children: "Special Offer", + }, + offer: { + title: { + children: "94% OFF", + }, + description: { + children: "Одноразовая эксклюзивная скидка", + }, + }, + promoCode: { + children: "HAIR50", + }, + footer: { + children: ( + <> + Скопируйте или нажмите Continue + + ), + }, + onCopyPromoCode: fn(), + }, + bottomActionButtonProps: { + actionButtonProps: { + children: "Continue", + onClick: fn(), + }, + }, + }, + argTypes: { + bottomActionButtonProps: { + control: { type: "object" }, + }, + }, + render: (args) => { + return ( + + + + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default = {} satisfies Story; diff --git a/src/components/templates/Coupon/Coupon.tsx b/src/components/templates/Coupon/Coupon.tsx new file mode 100644 index 0000000..3b3b7d0 --- /dev/null +++ b/src/components/templates/Coupon/Coupon.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { + BottomActionButton, + BottomActionButtonProps, +} from "@/components/widgets/BottomActionButton/BottomActionButton"; +import { Coupon as CouponWidget } from "@/components/widgets/Coupon/Coupon"; +import { useDynamicSize } from "@/hooks/DOM/useDynamicSize"; +import { cn } from "@/lib/utils"; + +interface CouponProps extends Omit, "title"> { + couponProps: React.ComponentProps; + bottomActionButtonProps?: BottomActionButtonProps; +} + +function Coupon({ + couponProps, + bottomActionButtonProps, + ...props +}: CouponProps) { + const { + height: bottomActionButtonHeight, + elementRef: bottomActionButtonRef, + } = useDynamicSize({ + defaultHeight: 132, + }); + + return ( +
+ {/* {title && ( + + )} + {subtitle && ( + + )} */} + + {bottomActionButtonProps && ( + + )} +
+ ); +} + +export { Coupon }; diff --git a/src/components/templates/Email/Email.stories.tsx b/src/components/templates/Email/Email.stories.tsx new file mode 100644 index 0000000..5e3b30d --- /dev/null +++ b/src/components/templates/Email/Email.stories.tsx @@ -0,0 +1,104 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { Email } from "./Email"; +import { fn } from "storybook/test"; +import { + LayoutQuestion, + LayoutQuestionProps, +} from "@/components/layout/LayoutQuestion/LayoutQuestion"; +import Image from "next/image"; + +const layoutQuestionProps: Omit = { + headerProps: { + onBack: fn(), + }, + title: { + children: "Портрет твоей второй половинки готов! Куда нам его отправить?", + align: "center", + }, + contentProps: { + className: "pt-0!", + }, +}; + +/** Reusable Email page Component */ +const meta: Meta = { + title: "Templates/Email", + component: Email, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + textInputProps: { + label: "Email", + placeholder: "Enter your Email", + type: "email", + onChange: fn(), + }, + bottomActionButtonProps: { + actionButtonProps: { + children: "Continue", + onClick: fn(), + }, + }, + image: ( + male portrait + ), + privacyTermsConsentProps: { + privacyPolicy: { + children: "Privacy Policy", + href: "#privacy-policy", + }, + termsOfUse: { + children: "Terms of use", + href: "#terms-of-use", + }, + }, + privacySecurityBannerProps: { + text: { + children: + "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.", + }, + }, + }, + argTypes: { + textInputProps: { + control: { type: "object" }, + }, + bottomActionButtonProps: { + control: { type: "object" }, + }, + }, + render: (args) => { + return ( + + + + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default = {} satisfies Story; + +export const FemalePortrait = { + args: { + image: ( + female portrait + ), + }, +} satisfies Story; diff --git a/src/components/templates/Email/Email.tsx b/src/components/templates/Email/Email.tsx new file mode 100644 index 0000000..b2f44cf --- /dev/null +++ b/src/components/templates/Email/Email.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { + BottomActionButton, + BottomActionButtonProps, +} from "@/components/widgets/BottomActionButton/BottomActionButton"; +import { useDynamicSize } from "@/hooks/DOM/useDynamicSize"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import PrivacyTermsConsent from "@/components/widgets/PrivacyTermsConsent/PrivacyTermsConsent"; +import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner"; + +const formSchema = z.object({ + email: z.email({ + message: "Please enter a valid email address", + }), +}); + +interface EmailProps extends Omit, "title"> { + textInputProps: React.ComponentProps; + bottomActionButtonProps?: BottomActionButtonProps; + image?: React.ReactNode; + privacyTermsConsentProps?: React.ComponentProps; + privacySecurityBannerProps?: React.ComponentProps< + typeof PrivacySecurityBanner + >; +} + +function Email({ + textInputProps, + bottomActionButtonProps, + image, + privacyTermsConsentProps, + privacySecurityBannerProps, + ...props +}: EmailProps) { + const { + height: bottomActionButtonHeight, + elementRef: bottomActionButtonRef, + } = useDynamicSize({ + defaultHeight: 132, + }); + const [isTouched, setIsTouched] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: String(textInputProps.value || ""), + }, + }); + + useEffect(() => { + form.setValue("email", String(textInputProps.value || "")); + }, [textInputProps.value, form]); + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + form.setValue("email", value); + form.trigger("email"); + textInputProps.onChange?.(e); + }; + + const isFormValid = form.formState.isValid && form.getValues("email"); + + return ( +
+ { + setIsTouched(true); + form.trigger("email"); + }} + aria-invalid={isTouched && !!form.formState.errors.email} + aria-errormessage={ + isTouched ? form.formState.errors.email?.message : undefined + } + /> + {image} + {privacySecurityBannerProps && ( + + )} + {bottomActionButtonProps && ( + + ) + } + /> + )} +
+ ); +} + +export { Email }; diff --git a/src/components/templates/Loaders/Loaders.stories.tsx b/src/components/templates/Loaders/Loaders.stories.tsx new file mode 100644 index 0000000..214ad2c --- /dev/null +++ b/src/components/templates/Loaders/Loaders.stories.tsx @@ -0,0 +1,108 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { Loaders } from "./Loaders"; +import { fn } from "storybook/test"; +import { + LayoutQuestion, + LayoutQuestionProps, +} from "@/components/layout/LayoutQuestion/LayoutQuestion"; + +const layoutQuestionProps: Omit = { + headerProps: { + onBack: fn(), + }, + title: { + children: "Создаем портрет твоей второй половинки.", + align: "center", + }, + contentProps: { + className: "pt-5", + }, + childrenWrapperProps: { + className: "mt-[57px]", + }, +}; + +/** Reusable Loaders page Component */ +const meta: Meta = { + title: "Templates/Loaders", + component: Loaders, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + circularProgressbarsListProps: { + progressbarItems: [ + { + processing: { + title: { children: "Анализ твоих ответов" }, + text: { + children: "Processing...", + }, + }, + completed: { + title: { children: "Анализ твоих ответов" }, + text: { + children: "Complete", + }, + }, + }, + { + processing: { + title: { children: "Portrait of the Soulmate" }, + text: { + children: "Processing...", + }, + }, + completed: { + title: { children: "Portrait of the Soulmate" }, + text: { + children: "Complete", + }, + }, + }, + { + processing: { + title: { children: "Portrait of the Soulmate" }, + text: { + children: "Processing...", + }, + }, + completed: { + title: { children: "Connection Insights" }, + text: { + children: "Complete", + }, + }, + }, + ], + onAnimationEnd: fn(), + }, + bottomActionButtonProps: { + actionButtonProps: { + children: "Continue", + onClick: fn(), + }, + }, + }, + argTypes: { + circularProgressbarsListProps: { + control: { type: "object" }, + }, + bottomActionButtonProps: { + control: { type: "object" }, + }, + }, + render: (args) => { + return ( + + + + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default = {} satisfies Story; diff --git a/src/components/templates/Loaders/Loaders.tsx b/src/components/templates/Loaders/Loaders.tsx new file mode 100644 index 0000000..d19890a --- /dev/null +++ b/src/components/templates/Loaders/Loaders.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { + BottomActionButton, + BottomActionButtonProps, +} from "@/components/widgets/BottomActionButton/BottomActionButton"; +import CircularProgressbarsList from "@/components/widgets/CircularProgressbarsList/CircularProgressbarsList"; +import { useDynamicSize } from "@/hooks/DOM/useDynamicSize"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; + +interface LoadersProps extends Omit, "title"> { + circularProgressbarsListProps: React.ComponentProps< + typeof CircularProgressbarsList + >; + bottomActionButtonProps?: BottomActionButtonProps; +} + +function Loaders({ + circularProgressbarsListProps, + bottomActionButtonProps, + ...props +}: LoadersProps) { + const { + height: bottomActionButtonHeight, + elementRef: bottomActionButtonRef, + } = useDynamicSize({ + defaultHeight: 132, + }); + const [isVisibleButton, setIsVisibleButton] = useState(false); + + const onAnimationEnd = () => { + setIsVisibleButton(true); + circularProgressbarsListProps.onAnimationEnd?.(); + }; + + return ( +
+ + {bottomActionButtonProps && ( + + )} +
+ ); +} + +export { Loaders }; diff --git a/src/components/templates/Question/Question.stories.tsx b/src/components/templates/Question/Question.stories.tsx deleted file mode 100644 index 28ff678..0000000 --- a/src/components/templates/Question/Question.stories.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { Meta, StoryObj } from "@storybook/nextjs-vite"; -import { Question } from "./Question"; -import { fn } from "storybook/test"; -import { useState } from "react"; -import { MainButtonProps } from "@/components/ui/MainButton/MainButton"; -import { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList"; - -/** Reusable Question page Component */ -const meta: Meta = { - title: "Templates/Question", - component: Question, - tags: ["autodocs"], - parameters: { - layout: "fullscreen", - }, - args: { - layoutQuestionProps: { - headerProps: { - progressProps: { - value: (5 / 15) * 100, - label: "5 of 15", - className: "max-w-[198px]", - }, - onBack: fn(), - }, - title: { - children: "Which best represents your hair loss and goals?", - }, - subtitle: { - children: "Let's personalize your hair care journey", - }, - }, - contentType: "radio-answers-list", - }, - argTypes: { - contentType: { - control: { type: "select" }, - options: ["radio-answers-list", "select-answers-list"], - }, - content: { - control: { type: "object" }, - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default = {} satisfies Story; - -export const RadioAnswers = { - args: { - contentType: "radio-answers-list", - content: { - answers: [ - { - children: "FEMALE", - emoji: "👩", - id: "female", - }, - { - children: "MALE", - emoji: "👨", - isCheckbox: true, - id: "male", - }, - { - children: "Receding hairline, want to slow its progress", - id: "without-emoji", - }, - ], - activeAnswer: { - children: "MALE", - emoji: "👨", - isCheckbox: true, - id: "male", - }, - onAnswerClick: fn(), - onChangeSelectedAnswer: fn(), - }, - }, -} satisfies Story; - -export const SelectAnswers = { - args: { - contentType: "select-answers-list", - content: { - answers: [ - { - children: "Receding hairline, want to slow its progress", - isCheckbox: true, - id: "hairline", - }, - { - children: "Experiencing hair loss, exploring", - isCheckbox: true, - id: "exploring", - }, - { - children: "Experiencing hair loss, ready to start", - isCheckbox: true, - id: "ready-to-start", - }, - { - children: "Experiencing hair loss, ready to start", - id: "ready-to-start-text", - }, - { - children: "Experiencing hair loss, ready to start", - emoji: "👩🏼", - id: "ready-to-start-emoji", - }, - { - children: "Experiencing hair loss, ready to start", - emoji: "👩🏼", - isCheckbox: true, - id: "ready-to-start-emoji-checkbox", - }, - ], - activeAnswers: [ - { - children: "Experiencing hair loss, ready to start", - isCheckbox: true, - id: "ready-to-start", - }, - { - children: "Experiencing hair loss, ready to start", - emoji: "👩🏼", - id: "ready-to-start-emoji", - }, - ], - onChangeSelectedAnswers: fn(), - onAnswerClick: fn(), - }, - }, - render: (args) => { - const { layoutQuestionProps, content, ...rest } = args; - const [selectedAnswers, setSelectedAnswers] = useState< - MainButtonProps[] | null - >((content as SelectAnswersListProps).activeAnswers); - - const onActionButtonClick = () => { - fn()(selectedAnswers); - }; - - const layoutQuestionArgs = { - ...layoutQuestionProps, - bottomActionButtonProps: { - actionButtonProps: { - children: "Continue", - onClick: onActionButtonClick, - }, - }, - }; - - const onChangeSelectedAnswers = (answers: MainButtonProps[] | null) => { - setSelectedAnswers(answers); - fn()(answers); - }; - - const contentArgs = { - ...content, - onChangeSelectedAnswers, - }; - - return ( - - ); - }, -} satisfies Story; diff --git a/src/components/templates/Question/Question.tsx b/src/components/templates/Question/Question.tsx deleted file mode 100644 index b500478..0000000 --- a/src/components/templates/Question/Question.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { - RadioAnswersList, - RadioAnswersListProps, -} from "@/components/widgets/RadioAnswersList/RadioAnswersList"; -import { - LayoutQuestion, - LayoutQuestionProps, -} from "@/components/layout/LayoutQuestion/LayoutQuestion"; -import { - SelectAnswersList, - SelectAnswersListProps, -} from "@/components/widgets/SelectAnswersList/SelectAnswersList"; - -interface QuestionProps - extends Omit, "title" | "content"> { - layoutQuestionProps: Omit; - content: RadioAnswersListProps | SelectAnswersListProps; - contentType: "radio-answers-list" | "select-answers-list"; -} - -function Question({ - layoutQuestionProps, - content, - contentType, - ...props -}: QuestionProps) { - return ( - - {content && ( -
- {contentType === "radio-answers-list" && ( - - )} - {contentType === "select-answers-list" && ( - - )} -
- )} -
- ); -} - -export { Question }; diff --git a/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.stories.tsx b/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.stories.tsx new file mode 100644 index 0000000..ae2ac58 --- /dev/null +++ b/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.stories.tsx @@ -0,0 +1,127 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QuestionDateAnswers } from "./QuestionDateAnswers"; +import { fn } from "storybook/test"; +import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion"; + +const layoutQuestionProps = { + headerProps: { + progressProps: { + value: (5 / 15) * 100, + label: "5 of 15", + className: "max-w-[198px]", + }, + onBack: fn(), + }, + title: { + children: "When is your birthday?", + }, + subtitle: { + children: "We need this information to personalize your experience", + }, +}; + +/** Reusable QuestionDateAnswers page Component */ +const meta: Meta = { + title: "Templates/QuestionDateAnswers", + component: QuestionDateAnswers, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + content: { + value: null, + onChange: fn(), + maxYear: new Date().getFullYear() - 11, + yearsRange: 100, + locale: "en", + }, + bottomActionButtonProps: { + actionButtonProps: { + children: "Continue", + onClick: fn(), + }, + }, + }, + argTypes: { + content: { + control: { type: "object" }, + }, + bottomActionButtonProps: { + control: { type: "object" }, + }, + }, + render: (args) => { + return ( + + + + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default = {} satisfies Story; + +export const WithInitialValue = { + args: { + content: { + value: "1990-05-15", + onChange: fn(), + maxYear: new Date().getFullYear() - 11, + yearsRange: 100, + locale: "en", + }, + }, +} satisfies Story; + +export const WithError = { + args: { + content: { + value: "", + onChange: fn(), + maxYear: new Date().getFullYear() - 11, + yearsRange: 100, + locale: "en", + }, + }, +} satisfies Story; + +export const WithCustomLocale = { + args: { + content: { + value: null, + onChange: fn(), + maxYear: new Date().getFullYear() - 11, + yearsRange: 100, + locale: "ru", + }, + }, +} satisfies Story; + +export const WithCustomYearRange = { + args: { + content: { + value: null, + onChange: fn(), + maxYear: 2000, + yearsRange: 50, + locale: "en", + }, + }, +} satisfies Story; + +export const WithoutBottomButton = { + args: { + content: { + value: null, + onChange: fn(), + maxYear: new Date().getFullYear() - 11, + yearsRange: 100, + locale: "en", + }, + bottomActionButtonProps: undefined, + }, +} satisfies Story; diff --git a/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.tsx b/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.tsx new file mode 100644 index 0000000..bf7c754 --- /dev/null +++ b/src/components/templates/QuestionDateAnswers/QuestionDateAnswers.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + BottomActionButton, + BottomActionButtonProps, +} from "@/components/widgets/BottomActionButton/BottomActionButton"; +import DateInput, { + DateInputProps, +} from "@/components/widgets/DateInput/DateInput"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useEffect } from "react"; + +const formSchema = z.object({ + date: z.string().min(1, { + message: "Please select a date", + }), +}); + +interface QuestionDateAnswersProps + extends Omit, "content"> { + content: DateInputProps; + bottomActionButtonProps?: BottomActionButtonProps; +} + +function QuestionDateAnswers({ + content, + bottomActionButtonProps, + ...props +}: QuestionDateAnswersProps) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + date: content.value || "", + }, + }); + + useEffect(() => { + form.setValue("date", content.value || ""); + }, [content.value, form]); + + const handleChange = (value: string | null) => { + form.setValue("date", value || ""); + form.trigger("date"); + content.onChange?.(value); + }; + + const isFormValid = form.formState.isValid && form.getValues("date"); + + return ( +
+ + {bottomActionButtonProps && ( + + )} +
+ ); +} + +export { QuestionDateAnswers }; diff --git a/src/components/templates/QuestionInformation/QuestionInformation.stories.tsx b/src/components/templates/QuestionInformation/QuestionInformation.stories.tsx new file mode 100644 index 0000000..764c958 --- /dev/null +++ b/src/components/templates/QuestionInformation/QuestionInformation.stories.tsx @@ -0,0 +1,92 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QuestionInformation } from "./QuestionInformation"; +import { fn } from "storybook/test"; +import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion"; +import Typography from "@/components/ui/Typography/Typography"; +import Image from "next/image"; + +const layoutQuestionProps = { + headerProps: { + progressProps: { + value: (3 / 15) * 100, + label: "3 of 15", + className: "max-w-[198px]", + }, + onBack: fn(), + }, +}; + +/** Reusable QuestionInformation page Component */ +const meta: Meta = { + title: "Templates/QuestionInformation", + component: QuestionInformation, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + image: ( + Information + ), + text: ( + + По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но + одной чувствительности мало. Мы покажем, какие качества второй половинки + дадут тепло и уверенность, и изобразим её портрет. + + ), + bottomActionButtonProps: { + actionButtonProps: { + children: "Continue", + onClick: fn(), + }, + }, + }, + argTypes: { + image: { + control: { type: "object" }, + }, + text: { + control: { type: "object" }, + }, + bottomActionButtonProps: { + control: { type: "object" }, + }, + }, + render: (args) => { + return ( + + + + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default = {} satisfies Story; + +export const WithoutImage = { + args: { + image: undefined, + }, +} satisfies Story; + +export const WithoutText = { + args: { + text: undefined, + }, +} satisfies Story; + +export const WithoutBottomButton = { + args: { + bottomActionButtonProps: undefined, + }, +} satisfies Story; diff --git a/src/components/templates/QuestionInformation/QuestionInformation.tsx b/src/components/templates/QuestionInformation/QuestionInformation.tsx new file mode 100644 index 0000000..b352f2c --- /dev/null +++ b/src/components/templates/QuestionInformation/QuestionInformation.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { + BottomActionButton, + BottomActionButtonProps, +} from "@/components/widgets/BottomActionButton/BottomActionButton"; +import { useDynamicSize } from "@/hooks/DOM/useDynamicSize"; +import { cn } from "@/lib/utils"; + +interface QuestionInformationProps extends React.ComponentProps<"div"> { + image?: React.ReactNode; + text?: React.ReactNode; + bottomActionButtonProps?: BottomActionButtonProps; +} + +function QuestionInformation({ + image, + text, + bottomActionButtonProps, + ...props +}: QuestionInformationProps) { + const { + height: bottomActionButtonHeight, + elementRef: bottomActionButtonRef, + } = useDynamicSize({ + defaultHeight: 132, + }); + + return ( +
+ {image} + {text} + {bottomActionButtonProps && ( + + )} +
+ ); +} + +export { QuestionInformation }; diff --git a/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.stories.tsx b/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.stories.tsx new file mode 100644 index 0000000..8f28ef4 --- /dev/null +++ b/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.stories.tsx @@ -0,0 +1,75 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QuestionRadioAnswers } from "./QuestionRadioAnswers"; +import { fn } from "storybook/test"; +import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion"; + +const layoutQuestionProps = { + headerProps: { + progressProps: { + value: (5 / 15) * 100, + label: "5 of 15", + className: "max-w-[198px]", + }, + onBack: fn(), + }, + title: { + children: "Which best represents your hair loss and goals?", + }, + subtitle: { + children: "Let's personalize your hair care journey", + }, +}; + +/** Reusable QuestionRadioAnswers page Component */ +const meta: Meta = { + title: "Templates/QuestionRadioAnswers", + component: QuestionRadioAnswers, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + content: { + answers: [ + { + children: "FEMALE", + emoji: "👩", + id: "female", + }, + { + children: "MALE", + emoji: "👨", + isCheckbox: true, + id: "male", + }, + { + children: "Receding hairline, want to slow its progress", + id: "without-emoji", + }, + ], + activeAnswer: { + children: "MALE", + emoji: "👨", + isCheckbox: true, + id: "male", + }, + onAnswerClick: fn(), + onChangeSelectedAnswer: fn(), + }, + }, + argTypes: { + content: { + control: { type: "object" }, + }, + }, + render: (args) => ( + + + + ), +}; + +export default meta; +type Story = StoryObj; + +export const Default = {} satisfies Story; diff --git a/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.tsx b/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.tsx new file mode 100644 index 0000000..a57eb12 --- /dev/null +++ b/src/components/templates/QuestionRadioAnswers/QuestionRadioAnswers.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { + RadioAnswersList, + RadioAnswersListProps, +} from "@/components/widgets/RadioAnswersList/RadioAnswersList"; + +interface QuestionRadioAnswersProps + extends Omit, "content"> { + content: RadioAnswersListProps; +} + +function QuestionRadioAnswers({ + content, + ...props +}: QuestionRadioAnswersProps) { + return ( +
+ +
+ ); +} + +export { QuestionRadioAnswers }; diff --git a/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.stories.tsx b/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.stories.tsx new file mode 100644 index 0000000..9625bf8 --- /dev/null +++ b/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.stories.tsx @@ -0,0 +1,104 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { QuestionSelectAnswers } from "./QuestionSelectAnswers"; +import { fn } from "storybook/test"; +import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion"; + +const layoutQuestionProps = { + headerProps: { + progressProps: { + value: (5 / 15) * 100, + label: "5 of 15", + className: "max-w-[198px]", + }, + onBack: fn(), + }, + title: { + children: "Which best represents your hair loss and goals?", + }, + subtitle: { + children: "Let's personalize your hair care journey", + }, +}; + +/** Reusable QuestionSelectAnswers page Component */ +const meta: Meta = { + title: "Templates/QuestionSelectAnswers", + component: QuestionSelectAnswers, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + content: { + answers: [ + { + children: "Receding hairline, want to slow its progress", + isCheckbox: true, + id: "hairline", + }, + { + children: "Experiencing hair loss, exploring", + isCheckbox: true, + id: "exploring", + }, + { + children: "Experiencing hair loss, ready to start", + isCheckbox: true, + id: "ready-to-start", + }, + { + children: "Experiencing hair loss, ready to start", + id: "ready-to-start-text", + }, + { + children: "Experiencing hair loss, ready to start", + emoji: "👩🏼", + id: "ready-to-start-emoji", + }, + { + children: "Experiencing hair loss, ready to start", + emoji: "👩🏼", + isCheckbox: true, + id: "ready-to-start-emoji-checkbox", + }, + ], + activeAnswers: [ + { + children: "Experiencing hair loss, ready to start", + isCheckbox: true, + id: "ready-to-start", + }, + { + children: "Experiencing hair loss, ready to start", + emoji: "👩🏼", + id: "ready-to-start-emoji", + }, + ], + onChangeSelectedAnswers: fn(), + onAnswerClick: fn(), + }, + bottomActionButtonProps: { + actionButtonProps: { + children: "Continue", + onClick: fn(), + }, + }, + }, + argTypes: { + content: { + control: { type: "object" }, + }, + }, + render: (args) => { + return ( + + + + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default = {} satisfies Story; diff --git a/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.tsx b/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.tsx new file mode 100644 index 0000000..fae7fed --- /dev/null +++ b/src/components/templates/QuestionSelectAnswers/QuestionSelectAnswers.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { MainButtonProps } from "@/components/ui/MainButton/MainButton"; +import { + BottomActionButton, + BottomActionButtonProps, +} from "@/components/widgets/BottomActionButton/BottomActionButton"; +import { + SelectAnswersList, + SelectAnswersListProps, +} from "@/components/widgets/SelectAnswersList/SelectAnswersList"; +import { useDynamicSize } from "@/hooks/DOM/useDynamicSize"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; + +interface QuestionSelectAnswersProps + extends Omit, "content"> { + content: SelectAnswersListProps; + bottomActionButtonProps?: BottomActionButtonProps; +} + +function QuestionSelectAnswers({ + content, + bottomActionButtonProps, + ...props +}: QuestionSelectAnswersProps) { + const { + height: bottomActionButtonHeight, + elementRef: bottomActionButtonRef, + } = useDynamicSize({ + defaultHeight: 132, + }); + const [selectedAnswers, setSelectedAnswers] = useState< + MainButtonProps[] | null + >(content.activeAnswers); + + const handleChangeSelectedAnswers = (answers: MainButtonProps[] | null) => { + setSelectedAnswers(answers); + content.onChangeSelectedAnswers?.(answers); + }; + + return ( +
+ + {bottomActionButtonProps && ( + + )} +
+ ); +} + +export { QuestionSelectAnswers }; diff --git a/src/components/templates/SoulmatePortrait/SoulmatePortrait.stories.tsx b/src/components/templates/SoulmatePortrait/SoulmatePortrait.stories.tsx new file mode 100644 index 0000000..eeff31e --- /dev/null +++ b/src/components/templates/SoulmatePortrait/SoulmatePortrait.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from "@storybook/nextjs-vite"; +import SoulmatePortrait from "./SoulmatePortrait"; +import { fn } from "storybook/test"; + +/** Reusable SoulmatePortrait page Component */ +const meta: Meta = { + title: "Templates/SoulmatePortrait", + component: SoulmatePortrait, + tags: ["autodocs"], + parameters: { + layout: "fullscreen", + }, + args: { + bottomActionButtonProps: { + actionButtonProps: { + children: "Continue", + onClick: fn(), + }, + }, + privacyTermsConsentProps: { + privacyPolicy: { + children: "Privacy Policy", + href: "#privacy-policy", + }, + termsOfUse: { + children: "Terms of use", + href: "#terms-of-use", + }, + }, + title: { + children: "Soulmate Portrait", + }, + }, + argTypes: {}, +}; + +export default meta; +type Story = StoryObj; + +export const Default = {} satisfies Story; diff --git a/src/components/templates/SoulmatePortrait/SoulmatePortrait.tsx b/src/components/templates/SoulmatePortrait/SoulmatePortrait.tsx new file mode 100644 index 0000000..f16902e --- /dev/null +++ b/src/components/templates/SoulmatePortrait/SoulmatePortrait.tsx @@ -0,0 +1,69 @@ +import Typography, { + TypographyProps, +} from "@/components/ui/Typography/Typography"; +import { BottomActionButton } from "@/components/widgets/BottomActionButton/BottomActionButton"; +import PrivacyTermsConsent from "@/components/widgets/PrivacyTermsConsent/PrivacyTermsConsent"; +import { useDynamicSize } from "@/hooks/DOM/useDynamicSize"; +import { cn } from "@/lib/utils"; + +export interface SoulmatePortraitProps + extends Omit, "title"> { + bottomActionButtonProps?: React.ComponentProps; + privacyTermsConsentProps?: React.ComponentProps; + title?: TypographyProps<"h2">; +} + +export default function SoulmatePortrait({ + bottomActionButtonProps, + privacyTermsConsentProps, + title, + ...props +}: SoulmatePortraitProps) { + const { + height: bottomActionButtonHeight, + elementRef: bottomActionButtonRef, + } = useDynamicSize({ + defaultHeight: 132, + }); + + return ( +
+
+ {title && ( + + )} +
+ {bottomActionButtonProps && ( + + ) + } + /> + )} +
+ ); +} diff --git a/src/components/ui/ActionButton/ActionButton.tsx b/src/components/ui/ActionButton/ActionButton.tsx index 27af75a..2a111bc 100644 --- a/src/components/ui/ActionButton/ActionButton.tsx +++ b/src/components/ui/ActionButton/ActionButton.tsx @@ -28,12 +28,14 @@ const buttonVariants = cva( } ); +export type ActionButtonProps = React.ComponentProps & + VariantProps; + function ActionButton({ className, cornerRadius, ...props -}: React.ComponentProps & - VariantProps & {}) { +}: ActionButtonProps) { return (