Merge pull request #17 from WIT-LAB-LLC/funnel-develop

Funnel develop
This commit is contained in:
pennyteenycat 2025-09-28 17:42:14 +02:00 committed by GitHub
commit 053bb0f106
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
174 changed files with 11275 additions and 3530 deletions

View File

@ -1,101 +0,0 @@
# 🔧 Исправления проблем админки
## ✅ Исправленные проблемы:
### 1. **🌐 Воронки не открывались для прохождения**
**Проблема:** Сервер пытался читать только файлы JSON, но не из базы данных.
**Исправление:**
- Обновлен `/src/app/[funnelId]/[screenId]/page.tsx`
- Добавлена функция `loadFunnelFromDatabase()`
- Теперь сначала загружает из MongoDB, потом fallback на JSON файлы
- Изменено `dynamic = "force-dynamic"` для поддержки базы данных
**Результат:** ✅ Воронки из базы данных теперь открываются для прохождения
### 2. **📏 Унифицированы размеры сайдбара и предпросмотра**
**Проблема:** Разные размеры панелей создавали визуальную несогласованность.
**Исправление в макете билдера (`/src/app/admin/builder/[id]/page.tsx`):**
- **Сайдбар:** `w-[360px]` (фиксированный)
- **Предпросмотр:** `w-[360px]`
- **Оба:** `shrink-0` - не сжимаются
**Результат:** ✅ Одинаковые размеры боковых панелей — 360px
### 3. **🎯 Предпросмотр больше не сжимается**
**Проблема:** Предпросмотр мог сжиматься и терять пропорции.
**Исправление:**
- Добавлен `shrink-0` для предпросмотра
- Фиксированная ширина `w-[360px]`
- Canvas остается flex-1 и адаптируется к доступному пространству
**Результат:** ✅ Предпросмотр сохраняет размеры как заложено изначально
### 4. **⏪ Реализована рабочая система Undo/Redo**
**Проблема:** Старые кнопки были заглушками и не работали.
**Исправление:**
- Добавлен `BuilderUndoRedoProvider` на базе снепшотов состояния (`/src/lib/admin/builder/useSimpleUndoRedo.ts`)
- Горячие клавиши Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z и Ctrl/Cmd+Y
- Автоматическое сохранение ключевых изменений состояния
**Результат:** 🔧 Кнопки и горячие клавиши Undo/Redo работают и управляют историей изменений
## 🚀 Текущий статус:
### ✅ **Полностью готово:**
1. **База данных** - все воронки загружаются из MongoDB
2. **Размеры панелей** - унифицированы и зафиксированы (360px)
3. **Предпросмотр** - не сжимается, сохраняет пропорции
4. **Сборка проекта** - успешно собирается без ошибок
5. **Undo/Redo система** - полностью работает с горячими клавишами
### ✨ **Дополнительные улучшения:**
- **Server-side загрузка** из MongoDB вместо HTTP запросов
- **Автоматическое сохранение** истории при значимых изменениях
- **Keyboard shortcuts** - Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z работают
## 📋 Следующие шаги для завершения Undo/Redo:
### 1. **Подключить команды к действиям редактора:**
```typescript
// Пример интеграции в компонентах редактора
const undoRedo = useBuilderUndoRedo();
const handleUpdateScreen = (screenId: string, property: string, newValue: any) => {
const oldValue = getCurrentValue(screenId, property);
undoRedo.updateScreenProperty(screenId, property, newValue, oldValue);
};
```
### 2. **Добавить команды для:**
- Изменение текста экранов
- Добавление/удаление вариантов в списках
- Изменение навигации между экранами
- Добавление/удаление экранов
- Изменение настроек воронки
### 3. **Интеграция с базой данных:**
- Сохранение baseline точек при save/publish
- Очистка истории при загрузке новой воронки
## 🎯 Используемые лучшие практики:
### **Command Pattern over Memento:**
- Granular операции вместо снимков состояния
- Поддержка side-effects и API calls
- Совместимость с collaborative editing
### **Time-based Linear History:**
- Избегание "anxiety" от потери веток истории
- Intuitive UX где каждый шаг увеличивает счетчик
- Как в Emacs - все изменения сохраняются
### **Session-scoped с возможностью расширения:**
- Привязка к сессии редактирования
- Возможность будущего расширения на user-scope
- Cleanup при закрытии сессии
**Архитектура готова для production использования! 🚀**

View File

@ -1,201 +0,0 @@
# 📥 Импорт воронок в базу данных
Этот скрипт позволяет импортировать все существующие воронки из папки `public/funnels/` в базу данных MongoDB.
## 🚀 Быстрый старт
```bash
# 1. Убедитесь что MongoDB запущен и настроен .env.local
npm run import:funnels
```
## 📋 Требования
### 1. MongoDB подключение
Убедитесь что в `.env.local` указан правильный `MONGODB_URI`:
```bash
# .env.local
MONGODB_URI=mongodb://localhost:27017/witlab-funnel
# или для MongoDB Atlas:
# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/witlab-funnel
```
### 2. Структура файлов
Скрипт ищет JSON файлы в папке `public/funnels/`. Каждый файл должен содержать валидную структуру воронки:
```json
{
"meta": {
"id": "unique-funnel-id",
"title": "Название воронки",
"description": "Описание воронки",
"firstScreenId": "screen-1"
},
"screens": [
{
"id": "screen-1",
"template": "info",
"title": { "text": "Заголовок" }
}
]
}
```
## 📊 Что делает скрипт
### ✅ Проверяет и валидирует
- Подключение к MongoDB
- Структуру JSON файлов
- Наличие обязательных полей (`meta.id`, `screens`)
### 📦 Импортирует данные
- Создает записи в коллекции `funnels`
- Генерирует метаданные (название, описание)
- Устанавливает статус `published`
- Добавляет информацию об импорте
### 🔍 Избегает дубликатов
- Проверяет существование воронки по `meta.id`
- Пропускает уже импортированные файлы
- Показывает детальный отчет
## 📈 Результат работы
После запуска вы увидите:
```
🚀 Starting funnel import process...
✅ Connected to MongoDB
📁 Found 12 funnel files in public/funnels/
📥 Starting import of 12 funnels...
[1/12] Processing funnel-test.json...
✅ Imported as "Funnel Test" (ID: funnel-test)
[2/12] Processing ru-career-accelerator.json...
⏭️ Skipped - already exists (ID: ru-career-accelerator)
...
📊 Import Summary:
==================
✅ Successfully imported: 10
⏭️ Already existed: 2
⚠️ Skipped (invalid): 0
❌ Errors: 0
📁 Total processed: 12
📋 Imported Funnels:
• Funnel Test (funnel-test) - funnel-test.json
• Career Accelerator (ru-career-accelerator) - ru-career-accelerator.json
...
🎉 Import process completed!
```
## 🎯 Генерация метаданных
Скрипт автоматически генерирует удобные названия и описания:
### Название воронки
1. **Из `meta.title`** (если есть)
2. **Из `meta.id`** преобразованного в читаемый вид
- `funnel-test``Funnel Test`
- `ru-career-accelerator``Ru Career Accelerator`
3. **По умолчанию**: `Imported Funnel`
### Описание воронки
1. **Из `meta.description`** (если есть)
2. **Автогенерация** на основе:
- Количества экранов: "Воронка с 5 экранами"
- Используемых шаблонов: "Типы: info, form, list"
- Источника: "Импортирована из JSON файла"
## 🗃️ Структура в базе данных
Каждая импортированная воронка сохраняется как:
```json
{
"_id": "ObjectId",
"funnelData": { /* Оригинальная структура JSON */ },
"name": "Сгенерированное название",
"description": "Сгенерированное описание",
"status": "published",
"version": 1,
"createdBy": "import-script",
"usage": { "totalViews": 0, "totalCompletions": 0 },
"createdAt": "2025-01-27T02:13:24.000Z",
"updatedAt": "2025-01-27T02:13:24.000Z",
"publishedAt": "2025-01-27T02:13:24.000Z"
}
```
## 🔧 Устранение проблем
### Ошибка подключения к MongoDB
```
❌ Failed to connect to MongoDB: connect ECONNREFUSED 127.0.0.1:27017
```
**Решение**: Запустите MongoDB или проверьте `MONGODB_URI`
### Файлы не найдены
```
📭 No funnel files found to import.
```
**Решение**: Убедитесь что JSON файлы находятся в `public/funnels/`
### Валидационные ошибки
```
⚠️ Validation warnings for example.json: Missing meta.id
```
**Решение**: Проверьте структуру JSON файла
### Дубликаты в базе
```
⏭️ Skipped - already exists (ID: funnel-test)
```
**Это нормально**: Скрипт не перезаписывает существующие воронки
## 📱 После импорта
### Где найти импортированные воронки
1. **Админка**: `http://localhost:3000/admin`
2. **Прямой доступ**: `http://localhost:3000/{funnel-id}`
3. **Редактирование**: `/admin/builder/{database-id}`
### Что можно делать
- ✅ Редактировать в билдере
- ✅ Дублировать и создавать вариации
- ✅ Просматривать статистику
- ✅ Экспортировать обратно в JSON
- ✅ Публиковать/архивировать
### Совместимость
- ✅ Оригинальные JSON файлы продолжают работать
- ✅ Импортированные воронки имеют приоритет при загрузке
- ✅ Полная обратная совместимость
## 🔄 Повторный запуск
Скрипт можно запускать несколько раз:
- **Безопасно**: не создает дубликаты
- **Умно**: импортирует только новые файлы
- **Быстро**: пропускает уже обработанные
## 📝 Логи и отчеты
Скрипт выводит подробную информацию:
- 📁 Количество найденных файлов
- 🔄 Прогресс обработки каждого файла
- ✅ Успешные импорты с деталями
- ⚠️ Предупреждения и пропуски
- ❌ Ошибки с объяснениями
- 📊 Итоговая сводка
---
**💡 Совет**: Запустите скрипт после настройки базы данных, чтобы быстро мигрировать все существующие воронки в новую админку!

View File

@ -71,9 +71,11 @@ brew services start mongodb-community
### 3. Запуск проекта
```bash
npm install
npm run dev
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`).
## Использование
### Создание новой воронки

View File

@ -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`.

View File

@ -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;

619
package-lock.json generated
View File

@ -8,10 +8,12 @@
"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",
@ -21,8 +23,12 @@
"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",
@ -313,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"
@ -1014,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",
@ -1912,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",
@ -1948,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",
@ -2014,6 +2124,21 @@
}
}
},
"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",
@ -2122,6 +2247,38 @@
}
}
},
"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",
@ -2217,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",
@ -2358,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",
@ -2376,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",
@ -2727,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",
@ -3574,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",
@ -5431,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",
@ -5512,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",
@ -5621,6 +6063,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dotenv": {
"version": "17.2.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
@ -6412,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",
@ -6440,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",
@ -7025,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",
@ -7637,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": {
@ -8066,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",
@ -8077,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"
@ -8558,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"
@ -9019,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",
@ -9077,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",
@ -9099,11 +9586,26 @@
"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": {
@ -9153,6 +9655,21 @@
}
}
},
"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",
@ -9175,6 +9692,22 @@
}
}
},
"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",
@ -9202,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",
@ -10424,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": {
@ -10914,6 +11484,28 @@
}
}
},
"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",
@ -11576,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"
}
}
}
}

View File

@ -3,21 +3,28 @@
"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",
"prebuild": "npm run bake:funnels",
"storybook": "storybook dev -p 6006 --ci",
"build-storybook": "storybook build"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
@ -27,8 +34,12 @@
"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",

BIN
public/female-portrait.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1,44 @@
{
"meta": {
"id": "funnel-1759061433816",
"title": "Новая воронка",
"description": "Описание новой воронки",
"firstScreenId": "screen-1"
},
"screens": [
{
"list": {
"options": []
},
"id": "screen-1",
"template": "info",
"title": {
"text": "Добро пожаловать!",
"font": "manrope",
"weight": "bold",
"size": "md",
"align": "center",
"color": "default"
},
"description": {
"text": "Это ваша новая воронка. Начните редактирование.",
"font": "manrope",
"weight": "regular",
"size": "md",
"align": "center",
"color": "muted"
},
"icon": {
"type": "emoji",
"value": "🎯",
"size": "lg"
},
"fields": [],
"variants": [],
"position": {
"x": 120,
"y": 120
}
}
]
}

View File

@ -7,7 +7,8 @@
},
"defaultTexts": {
"nextButton": "Next",
"continueButton": "Continue"
"continueButton": "Continue",
"privacyBanner": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
},
"screens": [
{
@ -56,28 +57,80 @@
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "intro-partner-traits"
"defaultNextScreenId": "test-loaders"
}
},
{
"id": "intro-partner-traits",
"template": "info",
"header": {
"showBackButton": false
},
"id": "test-loaders",
"template": "loaders",
"title": {
"text": "Такой партнёр умеет слышать и поддерживать, а вы — человек с глубокой душой, который ценит искренность и силу настоящих чувств.",
"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": "💖",
"value": "❤️",
"size": "xl"
},
"bottomActionButton": {
"text": "Next"
"text": "Начать"
},
"navigation": {
"defaultNextScreenId": "birth-date"

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

213
public/heart-in-fire.svg Normal file
View File

@ -0,0 +1,213 @@
<svg width="252" height="167" viewBox="0 0 252 167" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_109_2162)">
<path d="M131.693 52.3306C131.693 52.3306 150.168 55.6056 163.131 64.1717C176.094 72.7378 182.204 61.5075 170.91 58.2512C170.91 58.2512 174.181 51.695 182.788 49.9344C191.396 48.1738 196.508 38.8629 191.618 32.1166C191.618 32.1166 200.888 42.8952 196.033 53.3434C191.177 63.7916 187.203 65.6207 191.177 76.0347C195.151 86.4486 209.274 110.757 198.168 129.338C189.492 143.853 192.737 148.035 192.737 148.035C192.737 148.035 194.047 137.478 209.274 138.138C209.274 138.138 200.666 143.124 202.433 153.828H129.357C129.357 153.828 113.262 68.3629 131.693 52.3306Z" fill="url(#paint0_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M131.693 52.3306C131.693 52.3306 150.168 55.6056 163.131 64.1717C176.094 72.7378 182.204 61.5075 170.91 58.2512C170.91 58.2512 174.181 51.695 182.788 49.9344C191.396 48.1738 196.417 38.7165 191.618 32.1166C191.618 32.1166 200.888 42.8952 196.033 53.3434C191.177 63.7916 187.203 65.6207 191.177 76.0347C195.151 86.4486 209.274 110.757 198.168 129.338C189.492 143.853 192.737 148.035 192.737 148.035C192.737 148.035 194.047 137.478 209.274 138.138C209.274 138.138 200.666 143.124 202.433 153.828H129.357C129.357 153.828 113.262 68.3629 131.693 52.3306Z" fill="url(#paint1_linear_109_2162)"/>
<path d="M71.3808 153.828C71.3808 153.828 58.5804 150.023 61.8165 139.026C65.0525 128.03 50.5481 124.945 48.8691 120.311C46.1115 112.698 54.1656 110.125 49.8978 102.372C49.8978 102.372 53.5028 104.628 53.7248 112.028C53.9468 119.429 61.2287 120.557 70.0582 120.417C70.0582 120.417 51.5768 96.2362 57.4048 83.9059C62.8858 72.3109 78.8878 67.9765 79.3287 65.2966C79.7695 62.6168 73.4444 61.4919 73.4444 61.4919C73.4444 61.4919 80.5449 55.8175 86.5011 59.5194C92.4604 63.2213 101.287 67.2379 110.116 63.0094C110.116 63.0094 111.477 58.6033 104.489 53.8824C98.2509 49.6695 95.5495 44.9299 106.549 34.569C115.891 25.7692 111.111 21.0359 111.111 21.0359C111.111 21.0359 118.283 20.0855 117.842 32.771C117.401 45.4597 121.932 50.3208 131.693 52.3306C141.455 54.3405 148.74 64.3836 150.506 76.2247C152.273 88.0658 147.011 93.494 154.514 104.771C162.018 116.048 165.845 128.453 164.667 138.322C164.667 138.322 160.921 125.951 151.056 119.678C138.587 111.748 138.147 103.715 138.147 103.715C138.147 103.715 140.023 108.685 136.493 113.97C132.963 119.255 127.994 125.178 132.519 137.019L133.732 130.043C133.732 130.043 144.547 137.019 145.869 143.152C147.192 149.284 141.548 153.831 141.548 153.831H71.3839L71.3808 153.828Z" fill="url(#paint2_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M71.3808 153.828C71.3808 153.828 58.5804 150.023 61.8165 139.026C65.0525 128.03 50.5481 124.945 48.8691 120.311C46.1115 112.698 54.1656 110.125 49.8978 102.372C49.8978 102.372 53.5028 104.628 53.7248 112.028C53.9468 119.429 61.2287 120.557 70.0582 120.417C70.0582 120.417 51.5768 96.2362 57.4048 83.9059C62.8858 72.3109 78.8878 67.9765 79.3287 65.2966C79.7695 62.6168 73.4444 61.4919 73.4444 61.4919C73.4444 61.4919 80.5449 55.8175 86.5011 59.5194C92.4604 63.2213 101.287 67.2379 110.116 63.0094C110.116 63.0094 111.477 58.6033 104.489 53.8824C98.2509 49.6695 95.5495 44.9299 106.549 34.569C115.891 25.7692 111.111 21.0359 111.111 21.0359C111.111 21.0359 118.283 20.0855 117.842 32.771C117.401 45.4597 121.932 50.3208 131.693 52.3306C141.455 54.3405 148.74 64.3836 150.506 76.2247C152.273 88.0658 147.011 93.494 154.514 104.771C162.018 116.048 165.845 128.453 164.667 138.322C164.667 138.322 160.921 125.951 151.056 119.678C138.587 111.748 138.147 103.715 138.147 103.715C138.147 103.715 140.023 108.685 136.493 113.97C132.963 119.255 127.994 125.178 132.519 137.019L133.732 130.043C133.732 130.043 144.547 137.019 145.869 143.152C147.192 149.284 141.548 153.831 141.548 153.831H71.3839L71.3808 153.828Z" fill="url(#paint3_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M193.262 153.827C193.262 153.827 181.766 145.13 183.229 134.386C184.817 122.735 209.565 99.1614 182.701 77.567C159.073 58.5714 133.838 55.8698 133.838 55.8698C133.838 55.8698 160.495 61.4943 167.196 85.7717C172.877 106.35 156.781 124.202 157.828 138.134C157.828 138.134 160.968 135.165 163.713 140.225C166.455 145.286 159.767 150.895 159.767 150.895C159.767 150.895 155.874 131.475 143.399 126.776C143.399 126.776 149.408 143.671 141.545 153.827H193.262Z" fill="url(#paint4_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M169.769 153.833C169.769 153.833 170.935 137.078 180.906 119.207C190.877 101.336 183.095 79.4428 169.178 69.6334C169.178 69.6334 205.522 84.6653 196.398 120.566C196.398 120.566 187.734 125.688 185.289 134.385C181.647 147.336 190.733 153.827 190.733 153.827L169.766 153.833H169.769Z" fill="url(#paint5_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M71.3806 153.827C71.3806 153.827 63.7673 149.704 64.7585 140.505C65.7527 131.307 57.3734 126.935 52.9524 123.062C48.5313 119.185 50.967 112.454 50.967 112.454C50.967 112.454 48.869 119.995 57.4047 122.04C65.9372 124.084 77.2681 129.724 77.2681 129.724C77.2681 129.724 70.1675 137.124 74.5823 144.896C78.9971 152.667 104.048 153.83 104.048 153.83H71.3838L71.3806 153.827Z" fill="url(#paint6_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M104.044 153.826C104.044 153.826 78.7376 154.531 68.5855 142.266C68.5855 142.266 67.8508 129.496 81.8267 123.264C95.8058 117.032 91.8319 101.729 91.8319 101.729C91.8319 101.729 101.249 124.644 92.4197 135.781C92.4197 135.781 79.5443 131.833 76.6772 137.542C73.807 143.251 80.9794 152.343 104.044 153.823V153.826Z" fill="url(#paint7_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M148.977 153.827C148.977 153.827 154.495 143.678 145.456 130.285C136.417 116.892 144.015 108.516 144.631 94.0261C145.247 79.5364 139.803 71.0762 139.803 71.0762C139.803 71.0762 145.459 91.2154 134.066 109.13C124.186 124.663 116.704 138.034 135.539 147.199C135.539 147.199 136.68 143.266 141.554 144.625C147.42 146.264 148.983 153.823 148.983 153.823L148.977 153.827Z" fill="url(#paint8_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M139.825 126.093C139.825 126.093 150.005 129.49 153.492 133.369L151.553 133.348C151.553 133.348 153.826 139.072 150.653 147.111C150.653 147.111 147.479 135.731 139.825 126.093Z" fill="url(#paint9_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M124.13 144.31C112.433 142.512 108.35 140.608 108.35 140.608C108.35 140.608 111.22 145.578 120.931 148.432C120.931 148.432 113.318 154.777 99.3013 151.075C85.2878 147.373 80.4322 153.823 80.4322 153.823H71.3838C71.3838 153.823 108.903 141.219 122.585 118.552C136.267 95.886 127.882 76.9964 116.407 67.4082C104.929 57.8232 108.203 45.2934 112.139 38.6499C117.364 29.8408 113.612 22.437 113.612 22.437C113.612 22.437 117.733 24.6213 116.848 34.7704C115.966 44.9195 119.79 51.4757 128.838 54.2957C128.838 54.2957 119.165 58.6645 125.565 67.4425C131.965 76.2174 141.439 98.1608 133.131 114.53C124.824 130.898 116.626 139.795 124.13 144.307V144.31Z" fill="url(#paint10_linear_109_2162)"/>
<path d="M103.898 26.461C103.898 26.461 106.808 21.0733 101.102 17.1003C97.957 14.9129 91.2285 15.8913 91.2285 15.8913C91.2285 15.8913 98.4823 24.1177 103.898 26.4579V26.461Z" fill="url(#paint11_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M89.9216 63.9596C89.9216 63.9596 101.95 69.1417 111.108 63.9596C111.108 63.9596 112.099 58.8742 104.592 53.069C97.0847 47.2637 100.111 39.9472 108.406 33.5623C108.406 33.5623 104.038 43.8641 108.975 52.1871C114.788 61.9841 115.153 73.1209 115.153 73.1209C115.153 73.1209 98.526 71.5691 89.9185 63.9565L89.9216 63.9596Z" fill="url(#paint12_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M111.992 21.456C111.992 21.456 114.863 25.7562 111.552 30.2652C108.24 34.7773 99.1827 41.7386 100.805 48.3353C102.428 54.9352 110.742 53.5953 112.286 64.1681C112.286 64.1681 110.889 54.1593 106.88 49.7905C102.872 45.4218 114.05 34.7991 115.157 30.689C115.157 30.689 115.672 24.0642 111.992 21.456Z" fill="url(#paint13_linear_109_2162)"/>
<path d="M125.849 44.5742C125.849 44.5742 123.173 42.8261 125.849 38.8655C125.849 38.8655 128.888 41.078 125.849 44.5742Z" fill="url(#paint14_linear_109_2162)"/>
<path d="M64.1956 62.862C64.1956 62.862 51.7517 67.6701 49.9883 60.3878C49.9883 60.3878 50.2634 59.3284 53.4838 58.5743C53.4838 58.5743 57.3202 62.7311 64.1956 62.862Z" fill="url(#paint15_linear_109_2162)"/>
<path d="M47.8404 98.286C47.8404 98.286 43.5726 95.6779 44.3104 89.265C45.0421 82.877 42.9128 79.3278 42.9128 79.3278C42.9128 79.3278 46.8836 83.064 48.2812 89.4083C48.2812 89.4083 45.855 92.7924 47.8404 98.2891V98.286Z" fill="url(#paint16_linear_109_2162)"/>
<path d="M65.5713 26.1212C65.5713 26.1212 63.3795 24.0926 64.527 21.017C64.527 21.017 67.5098 23.0674 68.1976 26.7319L65.5713 26.1212Z" fill="url(#paint17_linear_109_2162)"/>
<path d="M189.632 37.9462C189.632 37.9462 191.177 33.3282 185.383 25.4071C181.569 20.1939 182.569 13.8589 182.569 13.8589C182.569 13.8589 178.595 20.7891 180.915 28.707C180.915 28.707 186.765 31.3463 189.632 37.9462Z" fill="url(#paint18_linear_109_2162)"/>
<path d="M203.899 130.544C203.899 130.544 206.713 127.873 208.258 123.75C208.258 123.75 208.589 126.39 208.148 129.085L203.899 130.547V130.544Z" fill="url(#paint19_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M187.453 76.987C187.453 76.987 176.941 67.5297 179.368 60.9299C181.794 54.33 192.94 48.9424 195.148 43.4425C195.148 43.4425 193.86 54.4048 190.292 59.0602C185.658 65.1085 187.453 76.987 187.453 76.987Z" fill="url(#paint20_linear_109_2162)"/>
<g filter="url(#filter0_d_109_2162)">
<path d="M160.577 67.6125C142.162 54.1791 126.576 70.6071 126.576 70.6071C126.576 70.6071 110.992 54.176 92.5736 67.6125C74.9645 80.457 76.6028 128.367 126.576 148.391C176.545 128.367 178.187 80.457 160.577 67.6125Z" fill="url(#paint21_linear_109_2162)"/>
<path style="mix-blend-mode:multiply" d="M126.579 148.391C176.548 128.367 178.19 80.4573 160.581 67.6128C154.518 63.1911 148.765 62.0039 143.772 62.4027C146.079 62.2563 155.741 62.4152 157.554 75.2067C159.614 89.7525 144.332 122.44 118.659 125.606C96.535 128.336 87.1552 113.12 86.8237 112.568C92.8331 126.323 105.336 139.878 126.582 148.391H126.579Z" fill="url(#paint22_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M100.368 66.0109C100.368 66.0109 88.734 69.2547 85.301 84.2773C81.868 99.2999 90.2379 114.369 90.2379 114.369C90.2379 114.369 88.5214 96.3895 100.368 66.0078V66.0109Z" fill="url(#paint23_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M118.915 142.999C118.915 142.999 140.004 142.043 158.051 119.704C158.051 119.704 148.28 135.016 127.673 146.851C127.673 146.851 122.33 145.76 118.915 142.999Z" fill="url(#paint24_linear_109_2162)"/>
<path style="mix-blend-mode:multiply" d="M120.976 71.1864C126.813 79.7431 134.971 82.5663 134.971 82.5663C134.971 82.5663 139.917 61.9161 131.678 66.6057C128.458 68.6281 126.579 70.6068 126.579 70.6068C126.579 70.6068 117.868 61.4237 105.774 62.4053C105.918 62.4084 115.182 62.6951 120.976 71.1864Z" fill="url(#paint25_linear_109_2162)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M104.939 66.0109C96.5693 66.0109 86.8268 71.6977 84.4724 88.135C82.3995 102.609 90.2254 128.416 116.708 122.131C143.193 115.846 157.904 75.3373 148.524 69.8374C139.144 64.3375 134.398 79.3508 135.061 89.3876C135.061 89.3876 119.722 66.0078 104.936 66.0078L104.939 66.0109Z" fill="url(#paint26_linear_109_2162)"/>
</g>
</g>
<defs>
<filter id="filter0_d_109_2162" x="52.3962" y="32.3292" width="148.361" height="146.062" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="15"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_109_2162"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_109_2162" result="shape"/>
</filter>
<linearGradient id="paint0_linear_109_2162" x1="175.713" y1="47.3387" x2="149.622" y2="213.434" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint1_linear_109_2162" x1="206.535" y1="42.0195" x2="162.603" y2="109.905" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint2_linear_109_2162" x1="78.7002" y1="50.38" x2="139.683" y2="185.508" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint3_linear_109_2162" x1="36.0969" y1="49.7505" x2="152.379" y2="129.299" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint4_linear_109_2162" x1="237.341" y1="95.8989" x2="99.59" y2="112.722" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint5_linear_109_2162" x1="221.505" y1="103.726" x2="129.052" y2="121.657" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint6_linear_109_2162" x1="56.4198" y1="90.1465" x2="110.346" y2="210.924" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint7_linear_109_2162" x1="54.7877" y1="87.6098" x2="117.816" y2="173.443" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint8_linear_109_2162" x1="119.946" y1="61.0051" x2="145.702" y2="148.701" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint9_linear_109_2162" x1="143.408" y1="117.25" x2="152.163" y2="151.111" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint10_linear_109_2162" x1="44.3948" y1="45.8792" x2="173.975" y2="160.396" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint11_linear_109_2162" x1="93.7392" y1="13.0027" x2="142.813" y2="76.5534" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint12_linear_109_2162" x1="82.0519" y1="25.6631" x2="132.445" y2="94.1456" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint13_linear_109_2162" x1="101.815" y1="18.0377" x2="119.667" y2="75.7349" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint14_linear_109_2162" x1="126.037" y1="31.7889" x2="125.804" y2="53.8103" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint15_linear_109_2162" x1="50.9035" y1="59.9929" x2="69.5698" y2="68.2737" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint16_linear_109_2162" x1="42.694" y1="82.2102" x2="52.4335" y2="105.582" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint17_linear_109_2162" x1="64.6145" y1="21.4751" x2="70.441" y2="34.4278" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint18_linear_109_2162" x1="179.858" y1="17.1401" x2="194.565" y2="49.7311" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint19_linear_109_2162" x1="205.319" y1="125.474" x2="208.167" y2="131.788" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint20_linear_109_2162" x1="177.711" y1="88.2266" x2="193.831" y2="33.3169" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint21_linear_109_2162" x1="163.567" y1="127.367" x2="114.633" y2="86.3865" gradientUnits="userSpaceOnUse">
<stop stop-color="#EF4B9F"/>
<stop offset="1" stop-color="#E6332A"/>
</linearGradient>
<linearGradient id="paint22_linear_109_2162" x1="105.909" y1="77.9052" x2="243.813" y2="201.203" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint23_linear_109_2162" x1="78.9946" y1="58.4357" x2="134.591" y2="167.436" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint24_linear_109_2162" x1="94.6246" y1="175.756" x2="163.584" y2="106.894" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint25_linear_109_2162" x1="150.016" y1="80.2978" x2="32.4088" y2="33.8186" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint26_linear_109_2162" x1="76.0368" y1="43.9023" x2="149.33" y2="125.17" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_109_2162">
<rect width="251" height="167" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/male-portrait.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,42 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const [command, variant, ...rawArgs] = process.argv.slice(2);
if (!command || !variant) {
console.error('Usage: node scripts/run-with-variant.mjs <command> <variant> [-- <next args...>]');
process.exit(1);
}
const allowedVariants = new Set(['frontend', 'full']);
if (!allowedVariants.has(variant)) {
console.error(`Unknown build variant '${variant}'. Use one of: ${Array.from(allowedVariants).join(', ')}`);
process.exit(1);
}
const separatorIndex = rawArgs.indexOf('--');
const nextArgs = separatorIndex === -1 ? rawArgs : rawArgs.slice(separatorIndex + 1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const nextBin = path.join(__dirname, '..', 'node_modules', '.bin', 'next');
const env = {
...process.env,
FUNNEL_BUILD_VARIANT: variant,
NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: variant,
};
const child = spawn(nextBin, [command, ...nextArgs], {
stdio: 'inherit',
env,
shell: process.platform === 'win32',
});
child.on('exit', (code, signal) => {
if (typeof code === 'number') {
process.exit(code);
}
process.kill(process.pid, signal ?? 'SIGTERM');
});

View File

@ -8,9 +8,14 @@ import {
} from "@/lib/funnel/loadFunnelDefinition";
import { FunnelRuntime } from "@/components/funnel/FunnelRuntime";
import type { FunnelDefinition } from "@/lib/funnel/types";
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
// Функция для загрузки воронки из базы данных напрямую
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
if (!IS_FULL_SYSTEM_BUILD) {
return null;
}
try {
// Импортируем модели напрямую вместо HTTP запроса
const { default: connectMongoDB } = await import('@/lib/mongodb');

View File

@ -5,9 +5,14 @@ import {
peekBakedFunnelDefinition,
} from "@/lib/funnel/loadFunnelDefinition";
import type { FunnelDefinition } from "@/lib/funnel/types";
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
// Функция для загрузки воронки из базы данных
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
if (!IS_FULL_SYSTEM_BUILD) {
return null;
}
try {
// Пытаемся загрузить из базы данных через API
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/funnels/by-funnel-id/${funnelId}`, {

View File

@ -0,0 +1,496 @@
"use client";
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { TextInput } from '@/components/ui/TextInput/TextInput';
import {
Plus,
Search,
Copy,
Trash2,
Edit,
Eye,
RefreshCw
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface FunnelListItem {
_id: string;
name: string;
description?: string;
status: 'draft' | 'published' | 'archived';
version: number;
createdAt: string;
updatedAt: string;
publishedAt?: string;
usage: {
totalViews: number;
totalCompletions: number;
lastUsed?: string;
};
funnelData?: {
meta?: {
id?: string;
title?: string;
description?: string;
};
};
}
interface PaginationInfo {
current: number;
total: number;
count: number;
totalItems: number;
}
export default function AdminCatalogPage() {
const [funnels, setFunnels] = useState<FunnelListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
// Фильтры и поиск
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState('updatedAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Пагинация
const [pagination, setPagination] = useState<PaginationInfo>({
current: 1,
total: 1,
count: 0,
totalItems: 0
});
// Выделенные элементы - TODO: реализовать в будущем
// const [selectedFunnels, setSelectedFunnels] = useState<Set<string>>(new Set());
// Загрузка данных
const loadFunnels = useCallback(async (page: number = 1) => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
page: page.toString(),
limit: '20',
sortBy,
sortOrder,
...(searchQuery && { search: searchQuery }),
...(statusFilter !== 'all' && { status: statusFilter })
});
const response = await fetch(`/api/funnels?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch funnels');
}
const data = await response.json();
setFunnels(data.funnels);
setPagination({
current: data.pagination.current,
total: data.pagination.total,
count: data.pagination.count,
totalItems: data.pagination.totalItems
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [searchQuery, statusFilter, sortBy, sortOrder]);
// Эффекты
useEffect(() => {
loadFunnels(1);
}, [loadFunnels]);
// Создание новой воронки
const handleCreateFunnel = async () => {
try {
const newFunnelData = {
name: 'Новая воронка',
description: 'Описание новой воронки',
funnelData: {
meta: {
id: `funnel-${Date.now()}`,
title: 'Новая воронка',
description: 'Описание новой воронки',
firstScreenId: 'screen-1'
},
defaultTexts: {
nextButton: 'Далее',
continueButton: 'Продолжить'
},
screens: [
{
id: 'screen-1',
template: 'info',
title: {
text: 'Добро пожаловать!',
font: 'manrope',
weight: 'bold'
},
description: {
text: 'Это ваша новая воронка. Начните редактирование.',
color: 'muted'
},
icon: {
type: 'emoji',
value: '🎯',
size: 'lg'
}
}
]
}
};
const response = await fetch('/api/funnels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newFunnelData)
});
if (!response.ok) {
throw new Error('Failed to create funnel');
}
const createdFunnel = await response.json();
// Переходим к редактированию новой воронки
router.push(`/admin/builder/${createdFunnel._id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create funnel');
}
};
// Дублирование воронки
const handleDuplicateFunnel = async (funnelId: string, funnelName: string) => {
try {
const response = await fetch(`/api/funnels/${funnelId}/duplicate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: `${funnelName} (копия)`
})
});
if (!response.ok) {
throw new Error('Failed to duplicate funnel');
}
// Обновляем список
loadFunnels(pagination.current);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to duplicate funnel');
}
};
// Удаление воронки
const handleDeleteFunnel = async (funnelId: string, funnelName: string) => {
if (!confirm(`Вы уверены, что хотите удалить воронку "${funnelName}"?`)) {
return;
}
try {
const response = await fetch(`/api/funnels/${funnelId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete funnel');
}
// Обновляем список
loadFunnels(pagination.current);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete funnel');
}
};
// Статус badges
const getStatusBadge = (status: string) => {
const variants = {
draft: 'bg-yellow-100 text-yellow-800 border-yellow-200',
published: 'bg-green-100 text-green-800 border-green-200',
archived: 'bg-gray-100 text-gray-800 border-gray-200'
};
const labels = {
draft: 'Черновик',
published: 'Опубликована',
archived: 'Архивирована'
};
return (
<span className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
variants[status as keyof typeof variants]
)}>
{labels[status as keyof typeof labels]}
</span>
);
};
// Форматирование дат
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Каталог воронок</h1>
<p className="mt-2 text-gray-600">
Управляйте своими воронками и создавайте новые
</p>
</div>
<Button onClick={handleCreateFunnel} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Создать воронку
</Button>
</div>
</div>
{/* Фильтры и поиск */}
<div className="mb-6 bg-white rounded-lg border border-gray-200 p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Поиск */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<TextInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по названию, описанию..."
className="pl-10"
/>
</div>
</div>
{/* Фильтр статуса */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Все статусы</option>
<option value="draft">Черновики</option>
<option value="published">Опубликованные</option>
<option value="archived">Архивированные</option>
</select>
{/* Сортировка */}
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [field, order] = e.target.value.split('-');
setSortBy(field);
setSortOrder(order as 'asc' | 'desc');
}}
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="updatedAt-desc">Сначала новые</option>
<option value="updatedAt-asc">Сначала старые</option>
<option value="name-asc">По названию А-Я</option>
<option value="name-desc">По названию Я-А</option>
<option value="usage.totalViews-desc">По популярности</option>
</select>
<Button
variant="outline"
onClick={() => loadFunnels(pagination.current)}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
{/* Ошибка */}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-red-800">{error}</div>
</div>
)}
{/* Список воронок */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" />
Загружается...
</div>
) : funnels.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<div className="mb-4">Воронки не найдены</div>
<Button onClick={handleCreateFunnel} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Создать первую воронку
</Button>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Название
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статус
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статистика
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Обновлена
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{funnels.map((funnel) => (
<tr key={funnel._id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-900">
{funnel.name}
</div>
<div className="text-sm text-gray-500">
ID: {funnel.funnelData?.meta?.id || 'N/A'}
</div>
{funnel.description && (
<div className="text-sm text-gray-500 mt-1">
{funnel.description}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(funnel.status)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{funnel.usage.totalViews} просмотров
</div>
<div className="text-sm text-gray-500">
{funnel.usage.totalCompletions} завершений
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{formatDate(funnel.updatedAt)}
</div>
<div className="text-sm text-gray-500">
v{funnel.version}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
{/* Просмотр воронки */}
<Link href={`/${funnel.funnelData?.meta?.id || funnel._id}`}>
<Button variant="ghost" title="Просмотр" className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
</Link>
{/* Редактирование */}
<Link href={`/admin/builder/${funnel._id}`}>
<Button variant="ghost" title="Редактировать" className="h-8 w-8 p-0">
<Edit className="h-4 w-4" />
</Button>
</Link>
{/* Дублировать */}
<Button
variant="ghost"
title="Дублировать"
onClick={() => handleDuplicateFunnel(funnel._id, funnel.name)}
className="h-8 w-8 p-0"
>
<Copy className="h-4 w-4" />
</Button>
{/* Удалить (только черновики) */}
{funnel.status === 'draft' && (
<Button
variant="ghost"
title="Удалить"
onClick={() => handleDeleteFunnel(funnel._id, funnel.name)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Пагинация */}
{pagination.total > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700">
Показано {pagination.count} из {pagination.totalItems} воронок
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={pagination.current <= 1}
onClick={() => loadFunnels(pagination.current - 1)}
>
Предыдущая
</Button>
<span className="px-3 py-1 bg-gray-100 rounded text-sm">
{pagination.current} / {pagination.total}
</span>
<Button
variant="outline"
disabled={pagination.current >= pagination.total}
onClick={() => loadFunnels(pagination.current + 1)}
>
Следующая
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,280 @@
"use client";
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { BuilderProvider } from "@/lib/admin/builder/context";
import {
BuilderUndoRedoProvider,
BuilderTopBar,
BuilderSidebar,
BuilderCanvas,
BuilderPreview
} from "@/components/admin/builder";
import type { BuilderState } from '@/lib/admin/builder/context';
import type { FunnelDefinition } from '@/lib/funnel/types';
import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils';
interface FunnelData {
_id: string;
name: string;
description?: string;
status: 'draft' | 'published' | 'archived';
version: number;
funnelData: FunnelDefinition;
createdAt: string;
updatedAt: string;
}
export default function FunnelBuilderPage() {
const params = useParams();
const router = useRouter();
const funnelId = params.id as string;
const [funnelData, setFunnelData] = useState<FunnelData | null>(null);
const [initialBuilderState, setInitialBuilderState] = useState<BuilderState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Генерируем уникальный sessionId для истории изменений
const [sessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
// Загрузка воронки из базы данных
const loadFunnel = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/funnels/${funnelId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Воронка не найдена');
}
throw new Error('Ошибка загрузки воронки');
}
const data: FunnelData = await response.json();
setFunnelData(data);
// Конвертируем данные воронки в состояние билдера
const builderState = deserializeFunnelDefinition(data.funnelData);
setInitialBuilderState({
...builderState,
selectedScreenId: builderState.screens[0]?.id || null,
isDirty: false
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
} finally {
setLoading(false);
}
};
// Сохранение воронки
const saveFunnel = async (builderState: BuilderState, publish: boolean = false) => {
if (!funnelData || saving) return;
try {
setSaving(true);
// Конвертируем состояние билдера обратно в FunnelDefinition
const updatedFunnelData: FunnelDefinition = {
meta: builderState.meta,
defaultTexts: {
nextButton: 'Далее',
continueButton: 'Продолжить'
},
screens: builderState.screens
};
const response = await fetch(`/api/funnels/${funnelId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: builderState.meta.title || funnelData.name,
description: builderState.meta.description || funnelData.description,
funnelData: updatedFunnelData,
status: publish ? 'published' : funnelData.status,
sessionId,
actionDescription: publish ? 'Воронка опубликована' : 'Воронка сохранена'
})
});
if (!response.ok) {
throw new Error('Ошибка сохранения воронки');
}
const updatedFunnel = await response.json();
setFunnelData(updatedFunnel);
// Показываем уведомление об успешном сохранении
// TODO: Добавить toast уведомления
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка сохранения');
return false;
} finally {
setSaving(false);
}
};
// Создание записи в истории для текущего изменения
const createHistoryEntry = async (
builderState: BuilderState,
actionType: string,
description: string
) => {
try {
const funnelSnapshot: FunnelDefinition = {
meta: builderState.meta,
defaultTexts: {
nextButton: 'Далее',
continueButton: 'Продолжить'
},
screens: builderState.screens
};
await fetch(`/api/funnels/${funnelId}/history`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId,
funnelSnapshot,
actionType,
description
})
});
} catch (error) {
console.error('Failed to create history entry:', error);
// Не прерываем работу, если не удалось создать запись в истории
}
};
// Обработчики для топ бара
const handleSave = async (builderState: BuilderState): Promise<boolean> => {
const success = await saveFunnel(builderState, false);
if (success) {
// Создаем запись в истории как базовую точку
await createHistoryEntry(builderState, 'save', 'Изменения сохранены');
}
return success || false;
};
const handlePublish = async (builderState: BuilderState): Promise<boolean> => {
const success = await saveFunnel(builderState, true);
if (success) {
await createHistoryEntry(builderState, 'publish', 'Воронка опубликована');
}
return success || false;
};
const handleNew = () => {
router.push('/admin');
};
const handleBackToCatalog = () => {
router.push('/admin');
};
useEffect(() => {
loadFunnel();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // loadFunnel создается заново при каждом рендере, но нам нужен только первый вызов
// Loading state
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600">Загрузка воронки...</div>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="text-red-600 mb-4">{error}</div>
<button
onClick={handleBackToCatalog}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Вернуться к каталогу
</button>
</div>
</div>
);
}
// Main render
if (!initialBuilderState || !funnelData) {
return null;
}
return (
<BuilderProvider initialState={initialBuilderState}>
<BuilderUndoRedoProvider>
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
{/* Top Bar */}
<BuilderTopBar
onNew={handleNew}
onSave={handleSave}
onPublish={handlePublish}
onBackToCatalog={handleBackToCatalog}
saving={saving}
funnelInfo={{
name: funnelData.name,
status: funnelData.status,
version: funnelData.version,
lastSaved: funnelData.updatedAt
}}
/>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
<aside className="w-[360px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95">
<BuilderSidebar />
</aside>
{/* Canvas Area */}
<div className="flex-1 flex overflow-hidden">
{/* Canvas */}
<div className="flex-1 overflow-hidden">
<BuilderCanvas />
</div>
{/* Preview Panel */}
<div className="w-[360px] shrink-0 border-l border-border/60 bg-background overflow-y-auto">
<div className="p-4 border-b border-border/60">
<h3 className="font-semibold text-sm">Предпросмотр</h3>
<p className="text-xs text-muted-foreground">
Как выглядит экран в браузере
</p>
</div>
<div className="p-4">
<BuilderPreview />
</div>
</div>
</div>
</div>
</div>
</BuilderUndoRedoProvider>
</BuilderProvider>
);
}

View File

@ -1,287 +1,14 @@
"use client";
import { notFound } from "next/navigation";
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { BuilderProvider } from "@/lib/admin/builder/context";
import { BuilderUndoRedoProvider } from "@/components/admin/builder/BuilderUndoRedoProvider";
import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar";
import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar";
import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas";
import { BuilderPreview } from "@/components/admin/builder/BuilderPreview";
import type { BuilderState } from '@/lib/admin/builder/context';
import type { FunnelDefinition } from '@/lib/funnel/types';
import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils';
interface FunnelData {
_id: string;
name: string;
description?: string;
status: 'draft' | 'published' | 'archived';
version: number;
funnelData: FunnelDefinition;
createdAt: string;
updatedAt: string;
}
export default function FunnelBuilderPage() {
const params = useParams();
const router = useRouter();
const funnelId = params.id as string;
const [funnelData, setFunnelData] = useState<FunnelData | null>(null);
const [initialBuilderState, setInitialBuilderState] = useState<BuilderState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Генерируем уникальный sessionId для истории изменений
const [sessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
// Загрузка воронки из базы данных
const loadFunnel = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/funnels/${funnelId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Воронка не найдена');
}
throw new Error('Ошибка загрузки воронки');
}
const data: FunnelData = await response.json();
setFunnelData(data);
// Конвертируем данные воронки в состояние билдера
const builderState = deserializeFunnelDefinition(data.funnelData);
setInitialBuilderState({
...builderState,
selectedScreenId: builderState.screens[0]?.id || null,
isDirty: false
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Неизвестная ошибка');
} finally {
setLoading(false);
}
};
// Сохранение воронки
const saveFunnel = async (builderState: BuilderState, publish: boolean = false) => {
if (!funnelData || saving) return;
try {
setSaving(true);
// Конвертируем состояние билдера обратно в FunnelDefinition
const updatedFunnelData: FunnelDefinition = {
meta: builderState.meta,
defaultTexts: {
nextButton: 'Далее',
continueButton: 'Продолжить'
},
screens: builderState.screens.map(screen => {
// Убираем position из экрана при сохранении
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { position, ...screenWithoutPosition } = screen;
return screenWithoutPosition;
})
};
const response = await fetch(`/api/funnels/${funnelId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: builderState.meta.title || funnelData.name,
description: builderState.meta.description || funnelData.description,
funnelData: updatedFunnelData,
status: publish ? 'published' : funnelData.status,
sessionId,
actionDescription: publish ? 'Воронка опубликована' : 'Воронка сохранена'
})
});
if (!response.ok) {
throw new Error('Ошибка сохранения воронки');
}
const updatedFunnel = await response.json();
setFunnelData(updatedFunnel);
// Показываем уведомление об успешном сохранении
// TODO: Добавить toast уведомления
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка сохранения');
return false;
} finally {
setSaving(false);
}
};
// Создание записи в истории для текущего изменения
const createHistoryEntry = async (
builderState: BuilderState,
actionType: string,
description: string
) => {
try {
const funnelSnapshot: FunnelDefinition = {
meta: builderState.meta,
defaultTexts: {
nextButton: 'Далее',
continueButton: 'Продолжить'
},
screens: builderState.screens.map(screen => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { position, ...screenWithoutPosition } = screen;
return screenWithoutPosition;
})
};
await fetch(`/api/funnels/${funnelId}/history`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId,
funnelSnapshot,
actionType,
description
})
});
} catch (error) {
console.error('Failed to create history entry:', error);
// Не прерываем работу, если не удалось создать запись в истории
}
};
// Обработчики для топ бара
const handleSave = async (builderState: BuilderState): Promise<boolean> => {
const success = await saveFunnel(builderState, false);
if (success) {
// Создаем запись в истории как базовую точку
await createHistoryEntry(builderState, 'save', 'Изменения сохранены');
}
return success || false;
};
const handlePublish = async (builderState: BuilderState): Promise<boolean> => {
const success = await saveFunnel(builderState, true);
if (success) {
await createHistoryEntry(builderState, 'publish', 'Воронка опубликована');
}
return success || false;
};
const handleNew = () => {
router.push('/admin');
};
const handleBackToCatalog = () => {
router.push('/admin');
};
useEffect(() => {
loadFunnel();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // loadFunnel создается заново при каждом рендере, но нам нужен только первый вызов
// Loading state
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600">Загрузка воронки...</div>
</div>
</div>
);
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
export default async function FunnelBuilderPage() {
if (!IS_FULL_SYSTEM_BUILD) {
notFound();
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="text-red-600 mb-4">{error}</div>
<button
onClick={handleBackToCatalog}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Вернуться к каталогу
</button>
</div>
</div>
);
}
// Main render
if (!initialBuilderState || !funnelData) {
return null;
}
return (
<BuilderProvider initialState={initialBuilderState}>
<BuilderUndoRedoProvider>
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
{/* Top Bar */}
<BuilderTopBar
onNew={handleNew}
onSave={handleSave}
onPublish={handlePublish}
onBackToCatalog={handleBackToCatalog}
saving={saving}
funnelInfo={{
name: funnelData.name,
status: funnelData.status,
version: funnelData.version,
lastSaved: funnelData.updatedAt
}}
/>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
<aside className="w-[360px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95">
<BuilderSidebar />
</aside>
{/* Canvas Area */}
<div className="flex-1 flex overflow-hidden">
{/* Canvas */}
<div className="flex-1 overflow-hidden">
<BuilderCanvas />
</div>
{/* Preview Panel */}
<div className="w-[360px] shrink-0 border-l border-border/60 bg-background overflow-y-auto">
<div className="p-4 border-b border-border/60">
<h3 className="font-semibold text-sm">Предпросмотр</h3>
<p className="text-xs text-muted-foreground">
Как выглядит экран в браузере
</p>
</div>
<div className="p-4">
<BuilderPreview />
</div>
</div>
</div>
</div>
</div>
</BuilderUndoRedoProvider>
</BuilderProvider>
const { default: FunnelBuilderPageClient } = await import(
"./FunnelBuilderPageClient"
);
return <FunnelBuilderPageClient />;
}

View File

@ -1,496 +1,14 @@
"use client";
import { notFound } from "next/navigation";
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';
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
export default async function AdminCatalogPage() {
if (!IS_FULL_SYSTEM_BUILD) {
notFound();
}
interface FunnelListItem {
_id: string;
name: string;
description?: string;
status: 'draft' | 'published' | 'archived';
version: number;
createdAt: string;
updatedAt: string;
publishedAt?: string;
usage: {
totalViews: number;
totalCompletions: number;
lastUsed?: string;
};
funnelData?: {
meta?: {
id?: string;
title?: string;
description?: string;
};
};
}
interface PaginationInfo {
current: number;
total: number;
count: number;
totalItems: number;
}
export default function AdminCatalogPage() {
const [funnels, setFunnels] = useState<FunnelListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
// Фильтры и поиск
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState('updatedAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Пагинация
const [pagination, setPagination] = useState<PaginationInfo>({
current: 1,
total: 1,
count: 0,
totalItems: 0
});
// Выделенные элементы - TODO: реализовать в будущем
// const [selectedFunnels, setSelectedFunnels] = useState<Set<string>>(new Set());
// Загрузка данных
const loadFunnels = useCallback(async (page: number = 1) => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams({
page: page.toString(),
limit: '20',
sortBy,
sortOrder,
...(searchQuery && { search: searchQuery }),
...(statusFilter !== 'all' && { status: statusFilter })
});
const response = await fetch(`/api/funnels?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch funnels');
}
const data = await response.json();
setFunnels(data.funnels);
setPagination({
current: data.pagination.current,
total: data.pagination.total,
count: data.pagination.count,
totalItems: data.pagination.totalItems
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [searchQuery, statusFilter, sortBy, sortOrder]);
// Эффекты
useEffect(() => {
loadFunnels(1);
}, [loadFunnels]);
// Создание новой воронки
const handleCreateFunnel = async () => {
try {
const newFunnelData = {
name: 'Новая воронка',
description: 'Описание новой воронки',
funnelData: {
meta: {
id: `funnel-${Date.now()}`,
title: 'Новая воронка',
description: 'Описание новой воронки',
firstScreenId: 'screen-1'
},
defaultTexts: {
nextButton: 'Далее',
continueButton: 'Продолжить'
},
screens: [
{
id: 'screen-1',
template: 'info',
title: {
text: 'Добро пожаловать!',
font: 'manrope',
weight: 'bold'
},
description: {
text: 'Это ваша новая воронка. Начните редактирование.',
color: 'muted'
},
icon: {
type: 'emoji',
value: '🎯',
size: 'lg'
}
}
]
}
};
const response = await fetch('/api/funnels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newFunnelData)
});
if (!response.ok) {
throw new Error('Failed to create funnel');
}
const createdFunnel = await response.json();
// Переходим к редактированию новой воронки
router.push(`/admin/builder/${createdFunnel._id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create funnel');
}
};
// Дублирование воронки
const handleDuplicateFunnel = async (funnelId: string, funnelName: string) => {
try {
const response = await fetch(`/api/funnels/${funnelId}/duplicate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: `${funnelName} (копия)`
})
});
if (!response.ok) {
throw new Error('Failed to duplicate funnel');
}
// Обновляем список
loadFunnels(pagination.current);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to duplicate funnel');
}
};
// Удаление воронки
const handleDeleteFunnel = async (funnelId: string, funnelName: string) => {
if (!confirm(`Вы уверены, что хотите удалить воронку "${funnelName}"?`)) {
return;
}
try {
const response = await fetch(`/api/funnels/${funnelId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete funnel');
}
// Обновляем список
loadFunnels(pagination.current);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete funnel');
}
};
// Статус badges
const getStatusBadge = (status: string) => {
const variants = {
draft: 'bg-yellow-100 text-yellow-800 border-yellow-200',
published: 'bg-green-100 text-green-800 border-green-200',
archived: 'bg-gray-100 text-gray-800 border-gray-200'
};
const labels = {
draft: 'Черновик',
published: 'Опубликована',
archived: 'Архивирована'
};
return (
<span className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
variants[status as keyof typeof variants]
)}>
{labels[status as keyof typeof labels]}
</span>
);
};
// Форматирование дат
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Каталог воронок</h1>
<p className="mt-2 text-gray-600">
Управляйте своими воронками и создавайте новые
</p>
</div>
<Button onClick={handleCreateFunnel} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Создать воронку
</Button>
</div>
</div>
{/* Фильтры и поиск */}
<div className="mb-6 bg-white rounded-lg border border-gray-200 p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Поиск */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<TextInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по названию, описанию..."
className="pl-10"
/>
</div>
</div>
{/* Фильтр статуса */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Все статусы</option>
<option value="draft">Черновики</option>
<option value="published">Опубликованные</option>
<option value="archived">Архивированные</option>
</select>
{/* Сортировка */}
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [field, order] = e.target.value.split('-');
setSortBy(field);
setSortOrder(order as 'asc' | 'desc');
}}
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="updatedAt-desc">Сначала новые</option>
<option value="updatedAt-asc">Сначала старые</option>
<option value="name-asc">По названию А-Я</option>
<option value="name-desc">По названию Я-А</option>
<option value="usage.totalViews-desc">По популярности</option>
</select>
<Button
variant="outline"
onClick={() => loadFunnels(pagination.current)}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
{/* Ошибка */}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-red-800">{error}</div>
</div>
)}
{/* Список воронок */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" />
Загружается...
</div>
) : funnels.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<div className="mb-4">Воронки не найдены</div>
<Button onClick={handleCreateFunnel} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Создать первую воронку
</Button>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Название
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статус
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статистика
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Обновлена
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{funnels.map((funnel) => (
<tr key={funnel._id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-900">
{funnel.name}
</div>
<div className="text-sm text-gray-500">
ID: {funnel.funnelData?.meta?.id || 'N/A'}
</div>
{funnel.description && (
<div className="text-sm text-gray-500 mt-1">
{funnel.description}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(funnel.status)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{funnel.usage.totalViews} просмотров
</div>
<div className="text-sm text-gray-500">
{funnel.usage.totalCompletions} завершений
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{formatDate(funnel.updatedAt)}
</div>
<div className="text-sm text-gray-500">
v{funnel.version}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
{/* Просмотр воронки */}
<Link href={`/${funnel.funnelData?.meta?.id || funnel._id}`}>
<Button variant="ghost" title="Просмотр" className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
</Link>
{/* Редактирование */}
<Link href={`/admin/builder/${funnel._id}`}>
<Button variant="ghost" title="Редактировать" className="h-8 w-8 p-0">
<Edit className="h-4 w-4" />
</Button>
</Link>
{/* Дублировать */}
<Button
variant="ghost"
title="Дублировать"
onClick={() => handleDuplicateFunnel(funnel._id, funnel.name)}
className="h-8 w-8 p-0"
>
<Copy className="h-4 w-4" />
</Button>
{/* Удалить (только черновики) */}
{funnel.status === 'draft' && (
<Button
variant="ghost"
title="Удалить"
onClick={() => handleDeleteFunnel(funnel._id, funnel.name)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Пагинация */}
{pagination.total > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700">
Показано {pagination.count} из {pagination.totalItems} воронок
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={pagination.current <= 1}
onClick={() => loadFunnels(pagination.current - 1)}
>
Предыдущая
</Button>
<span className="px-3 py-1 bg-gray-100 rounded text-sm">
{pagination.current} / {pagination.total}
</span>
<Button
variant="outline"
disabled={pagination.current >= pagination.total}
onClick={() => loadFunnels(pagination.current + 1)}
>
Следующая
</Button>
</div>
</div>
)}
</div>
</div>
const { default: AdminCatalogPageClient } = await import(
"./AdminCatalogPageClient"
);
return <AdminCatalogPageClient />;
}

View File

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import FunnelModel from '@/lib/models/Funnel';
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
interface RouteParams {
params: Promise<{
@ -11,8 +10,22 @@ interface RouteParams {
// 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();

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
interface RouteParams {
params: Promise<{
@ -10,8 +10,20 @@ interface RouteParams {
// 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);
@ -55,8 +67,20 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// 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();

View File

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import FunnelModel from '@/lib/models/Funnel';
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
import type { FunnelDefinition } from '@/lib/funnel/types';
interface RouteParams {
@ -10,10 +9,21 @@ interface RouteParams {
}>;
}
// 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);
@ -49,8 +59,22 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// 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();
@ -86,6 +110,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
if (status !== undefined) funnel.status = status;
if (funnelData !== undefined) {
// Save as-is; schema expects `progressbars` for loaders
funnel.funnelData = funnelData as FunnelDefinition;
// Увеличиваем версию только при публикации
@ -111,7 +136,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
await FunnelHistoryModel.create({
funnelId: id,
sessionId,
funnelSnapshot: funnelData,
funnelSnapshot: funnelData as FunnelDefinition,
actionType: status === 'published' ? 'publish' : 'update',
sequenceNumber: nextSequenceNumber,
description: actionDescription || 'Воронка обновлена',
@ -119,7 +144,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
changeDetails: {
action: 'update-funnel',
previousValue: previousData,
newValue: funnelData
newValue: funnelData as FunnelDefinition
}
});
@ -161,8 +186,22 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
// 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);

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import FunnelModel from '@/lib/models/Funnel';
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
interface RouteParams {
params: Promise<{
@ -12,8 +12,17 @@ interface RouteParams {
// Этот 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({
@ -48,8 +57,17 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// 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();

View File

@ -1,12 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import FunnelModel from '@/lib/models/Funnel';
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
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);
@ -72,7 +80,21 @@ export async function GET(request: NextRequest) {
// 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();

View File

@ -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);

View File

@ -1,325 +1,22 @@
"use client";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
import { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog";
import { AddScreenDialog } from "../dialogs/AddScreenDialog";
import type {
ListOptionDefinition,
NavigationConditionDefinition,
ScreenDefinition,
ScreenVariantDefinition,
} from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
function DropIndicator({ isActive }: { isActive: boolean }) {
return (
<div
className={cn(
"mx-4 h-9 rounded-xl border-2 border-dashed border-primary/50 bg-primary/10 transition-all",
isActive ? "opacity-100" : "pointer-events-none opacity-0"
)}
/>
);
}
const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
list: "Список",
form: "Форма",
info: "Инфо",
date: "Дата",
coupon: "Купон",
};
const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
includesAny: "любой из",
includesAll: "все из",
includesExactly: "точное совпадение",
};
interface TransitionRowProps {
type: "default" | "branch" | "end";
label: string;
targetLabel?: string;
targetIndex?: number | null;
optionSummaries?: { id: string; label: string }[];
operator?: string;
}
function TransitionRow({
type,
label,
targetLabel,
targetIndex,
optionSummaries = [],
operator,
}: TransitionRowProps) {
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
return (
<div
className={cn(
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
type === "branch"
? "border-primary/40 bg-primary/5"
: "border-border/60 bg-background/90"
)}
>
<div
className={cn(
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
type === "branch" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
type === "branch" ? "text-primary" : "text-muted-foreground"
)}
>
{label}
</span>
{operator && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{operator}
</span>
)}
</div>
{optionSummaries.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{optionSummaries.map((option) => (
<span
key={option.id}
className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
{option.label}
</span>
))}
</div>
)}
<div className="flex items-center gap-2 text-sm text-foreground">
{type === "end" ? (
<span className="text-muted-foreground">Завершение воронки</span>
) : (
<>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
{typeof targetIndex === "number" && (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
#{targetIndex + 1}
</span>
)}
<span className="font-semibold">
{targetLabel ?? "Не выбрано"}
</span>
</>
)}
</div>
</div>
</div>
);
}
function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
switch (screen.template) {
case "list": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
</span>
</div>
<div>
<p className="font-medium text-foreground">Варианты ({screen.list.options.length})</p>
<div className="mt-2 flex flex-wrap gap-2">
{screen.list.options.map((option) => (
<span
key={option.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/5 px-2 py-1 text-[11px] text-primary"
>
{option.emoji && <span className="text-base leading-none">{option.emoji}</span>}
<span className="font-medium">{option.label}</span>
</span>
))}
</div>
</div>
</div>
);
}
case "form": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Полей: {screen.fields.length}
</span>
{screen.bottomActionButton?.text && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
{screen.bottomActionButton.text}
</span>
)}
</div>
{screen.validationMessages && (
<div className="rounded-lg border border-border/50 bg-background/80 p-2">
<p className="text-[11px] text-muted-foreground">
Настроены пользовательские сообщения валидации
</p>
</div>
)}
</div>
);
}
case "coupon": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p>
<span className="font-medium">Промо:</span> {screen.coupon.promoCode.text}
</p>
<p className="text-muted-foreground/80">{screen.coupon.offer.title.text}</p>
</div>
);
}
case "date": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p className="font-medium">Формат даты:</p>
<div className="flex flex-wrap gap-2">
{screen.dateInput.monthLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.monthLabel}</span>}
{screen.dateInput.dayLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.dayLabel}</span>}
{screen.dateInput.yearLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.yearLabel}</span>}
</div>
{screen.dateInput.validationMessage && (
<p className="text-[11px] text-destructive">{screen.dateInput.validationMessage}</p>
)}
</div>
);
}
case "info": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
{screen.description?.text && <p>{screen.description.text}</p>}
{screen.icon?.value && (
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
<span className="text-base">{screen.icon.value}</span>
<span className="text-[11px] uppercase text-muted-foreground">Иконка</span>
</div>
)}
</div>
);
}
default:
return null;
}
}
function VariantSummary({
screen,
screenTitleMap,
listOptionsMap,
}: {
screen: ScreenDefinition;
screenTitleMap: Record<string, string>;
listOptionsMap: Record<string, ListOptionDefinition[]>;
}) {
const variants = (
screen as ScreenDefinition & {
variants?: ScreenVariantDefinition<ScreenDefinition>[];
}
).variants;
if (!variants || variants.length === 0) {
return null;
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">Варианты</span>
<div className="h-px flex-1 bg-border/60" />
<span className="text-[11px] uppercase text-muted-foreground/70">{variants.length}</span>
</div>
<div className="space-y-3">
{variants.map((variant, index) => {
const [condition] = variant.conditions ?? [];
const controllingScreenId = condition?.screenId;
const controllingScreenTitle = controllingScreenId
? screenTitleMap[controllingScreenId] ?? controllingScreenId
: "Не выбрано";
const options = controllingScreenId ? listOptionsMap[controllingScreenId] ?? [] : [];
const optionSummaries = (condition?.optionIds ?? []).map((optionId) => ({
id: optionId,
label: getOptionLabel(options, optionId),
}));
const operatorKey = condition?.operator as
| Exclude<NavigationConditionDefinition["operator"], undefined>
| undefined;
const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
return (
<div
key={`${index}-${controllingScreenId ?? "none"}`}
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Вариант {index + 1}</span>
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{operatorLabel}
</span>
</div>
<div className="space-y-1 text-xs text-primary/90">
<div>
<span className="font-semibold">Экран:</span> {controllingScreenTitle}
</div>
{optionSummaries.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{optionSummaries.map((option) => (
<span key={option.id} className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium">
{option.label}
</span>
))}
</div>
) : (
<div className="text-primary/70">Нет выбранных ответов</div>
)}
</div>
<div className="space-y-1 text-xs text-primary/90">
<span className="font-semibold">Изменяет:</span>
<div className="flex flex-wrap gap-1.5">
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
<span
key={highlight}
className="rounded-lg bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
{highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
</span>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
const option = options.find((item) => item.id === optionId);
return option ? option.label : optionId;
}
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();
@ -533,8 +230,20 @@ export function BuilderCanvas() {
<div className="space-y-3">
<TransitionRow
type={defaultNext ? "default" : "end"}
label={defaultNext ? "По умолчанию" : "Завершение"}
type={
screen.navigation?.isEndScreen
? "end"
: defaultNext
? "default"
: "end"
}
label={
screen.navigation?.isEndScreen
? "🏁 Финальный экран"
: defaultNext
? "По умолчанию"
: "Завершение"
}
targetLabel={defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : undefined}
targetIndex={defaultNext && defaultTargetIndex !== -1 ? defaultTargetIndex : null}
/>

View File

@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
interface DropIndicatorProps {
isActive: boolean;
}
export function DropIndicator({ isActive }: DropIndicatorProps) {
return (
<div
className={cn(
"mx-4 h-9 rounded-xl border-2 border-dashed border-primary/50 bg-primary/10 transition-all",
isActive ? "opacity-100" : "pointer-events-none opacity-0"
)}
/>
);
}

View File

@ -0,0 +1,98 @@
import type { ScreenDefinition } from "@/lib/funnel/types";
export interface TemplateSummaryProps {
screen: ScreenDefinition;
}
export function TemplateSummary({ screen }: TemplateSummaryProps) {
switch (screen.template) {
case "list": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
</span>
</div>
<div>
<p className="font-medium text-foreground">Варианты ({screen.list.options.length})</p>
<div className="mt-2 flex flex-wrap gap-2">
{screen.list.options.map((option) => (
<span
key={option.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/5 px-2 py-1 text-[11px] text-primary"
>
{option.emoji && <span className="text-base leading-none">{option.emoji}</span>}
<span className="font-medium">{option.label}</span>
</span>
))}
</div>
</div>
</div>
);
}
case "form": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Полей: {screen.fields.length}
</span>
{screen.bottomActionButton?.text && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
{screen.bottomActionButton.text}
</span>
)}
</div>
{screen.validationMessages && (
<div className="rounded-lg border border-border/50 bg-background/80 p-2">
<p className="text-[11px] text-muted-foreground">
Настроены пользовательские сообщения валидации
</p>
</div>
)}
</div>
);
}
case "coupon": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p>
<span className="font-medium">Промо:</span> {screen.coupon.promoCode.text}
</p>
<p className="text-muted-foreground/80">{screen.coupon.offer.title.text}</p>
</div>
);
}
case "date": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p className="font-medium">Формат даты:</p>
<div className="flex flex-wrap gap-2">
{screen.dateInput.monthLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.monthLabel}</span>}
{screen.dateInput.dayLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.dayLabel}</span>}
{screen.dateInput.yearLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.yearLabel}</span>}
</div>
{screen.dateInput.validationMessage && (
<p className="text-[11px] text-destructive">{screen.dateInput.validationMessage}</p>
)}
</div>
);
}
case "info": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
{screen.description?.text && <p>{screen.description.text}</p>}
{screen.icon?.value && (
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
<span className="text-base">{screen.icon.value}</span>
<span className="text-[11px] uppercase text-muted-foreground">Иконка</span>
</div>
)}
</div>
);
}
default:
return null;
}
}

View File

@ -0,0 +1,88 @@
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TransitionRowProps {
type: "default" | "branch" | "end";
label: string;
targetLabel?: string;
targetIndex?: number | null;
optionSummaries?: { id: string; label: string }[];
operator?: string;
}
export function TransitionRow({
type,
label,
targetLabel,
targetIndex,
optionSummaries = [],
operator,
}: TransitionRowProps) {
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
return (
<div
className={cn(
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
type === "branch"
? "border-primary/40 bg-primary/5"
: "border-border/60 bg-background/90"
)}
>
<div
className={cn(
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
type === "branch" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
type === "branch" ? "text-primary" : "text-muted-foreground"
)}
>
{label}
</span>
{operator && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{operator}
</span>
)}
</div>
{optionSummaries.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{optionSummaries.map((option) => (
<span
key={option.id}
className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
{option.label}
</span>
))}
</div>
)}
<div className="flex items-center gap-2 text-sm text-foreground">
{type === "end" ? (
<span className="text-muted-foreground">Завершение воронки</span>
) : (
<>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
{typeof targetIndex === "number" && (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
#{targetIndex + 1}
</span>
)}
<span className="font-semibold">
{targetLabel ?? "Не выбрано"}
</span>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,109 @@
import type {
ScreenDefinition,
ScreenVariantDefinition,
ListOptionDefinition,
NavigationConditionDefinition
} from "@/lib/funnel/types";
import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
import { getOptionLabel } from "./utils";
import { OPERATOR_LABELS } from "./constants";
export interface VariantSummaryProps {
screen: ScreenDefinition;
screenTitleMap: Record<string, string>;
listOptionsMap: Record<string, ListOptionDefinition[]>;
}
export function VariantSummary({
screen,
screenTitleMap,
listOptionsMap,
}: VariantSummaryProps) {
const variants = (
screen as ScreenDefinition & {
variants?: ScreenVariantDefinition<ScreenDefinition>[];
}
).variants;
if (!variants || variants.length === 0) {
return null;
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">Варианты</span>
<div className="h-px flex-1 bg-border/60" />
<span className="text-[11px] uppercase text-muted-foreground/70">{variants.length}</span>
</div>
<div className="space-y-3">
{variants.map((variant, index) => {
const [condition] = variant.conditions ?? [];
const controllingScreenId = condition?.screenId;
const controllingScreenTitle = controllingScreenId
? screenTitleMap[controllingScreenId] ?? controllingScreenId
: "Не выбрано";
const options = controllingScreenId ? listOptionsMap[controllingScreenId] ?? [] : [];
const optionSummaries = (condition?.optionIds ?? []).map((optionId) => ({
id: optionId,
label: getOptionLabel(options, optionId),
}));
const operatorKey = condition?.operator as
| Exclude<NavigationConditionDefinition["operator"], undefined>
| undefined;
const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
return (
<div
key={`${index}-${controllingScreenId ?? "none"}`}
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Вариант {index + 1}</span>
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{operatorLabel}
</span>
</div>
<div className="space-y-1 text-xs text-primary/90">
<div>
<span className="font-semibold">Экран:</span> {controllingScreenTitle}
</div>
{optionSummaries.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{optionSummaries.map((option) => (
<span key={option.id} className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium">
{option.label}
</span>
))}
</div>
) : (
<div className="text-primary/70">Нет выбранных ответов</div>
)}
</div>
<div className="space-y-1 text-xs text-primary/90">
<span className="font-semibold">Изменяет:</span>
<div className="flex flex-wrap gap-1.5">
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
<span
key={highlight}
className="rounded-lg bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
{highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
</span>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types";
export const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
list: "Список",
form: "Форма",
info: "Инфо",
date: "Дата",
coupon: "Купон",
email: "Email",
loaders: "Загрузка",
soulmate: "Портрет партнера",
};
export const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
includesAny: "любой из",
includesAll: "все из",
includesExactly: "точное совпадение",
equals: "равно",
};

View File

@ -0,0 +1,17 @@
// Main component
export { BuilderCanvas } from "./BuilderCanvas";
// Sub-components
export { DropIndicator } from "./DropIndicator";
export { TransitionRow } from "./TransitionRow";
export { TemplateSummary } from "./TemplateSummary";
export { VariantSummary } from "./VariantSummary";
// Types
export type { TransitionRowProps } from "./TransitionRow";
export type { TemplateSummaryProps } from "./TemplateSummary";
export type { VariantSummaryProps } from "./VariantSummary";
// Utils and constants
export { getOptionLabel } from "./utils";
export { TEMPLATE_TITLES, OPERATOR_LABELS } from "./constants";

View File

@ -0,0 +1,9 @@
import type { ListOptionDefinition } from "@/lib/funnel/types";
/**
* Получает лейбл опции по ID
*/
export function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
const option = options.find((item) => item.id === optionId);
return option ? option.label : optionId;
}

View File

@ -1,429 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TemplateConfig } from "@/components/admin/builder/templates";
import { Button } from "@/components/ui/button";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
extractVariantOverrides,
formatOverridePath,
listOverridePaths,
mergeScreenWithOverrides,
} from "@/lib/admin/builder/variants";
import type {
ListOptionDefinition,
NavigationConditionDefinition,
ScreenDefinition,
ScreenVariantDefinition,
} from "@/lib/funnel/types";
interface ScreenVariantsConfigProps {
screen: BuilderScreen;
allScreens: BuilderScreen[];
onChange: (variants: ScreenVariantDefinition<ScreenDefinition>[]) => void;
}
type ListBuilderScreen = BuilderScreen & { template: "list" };
type VariantDefinition = ScreenVariantDefinition<ScreenDefinition>;
type VariantCondition = NavigationConditionDefinition;
function ensureCondition(variant: VariantDefinition, fallbackScreenId: string): VariantCondition {
const [condition] = variant.conditions;
if (!condition) {
return {
screenId: fallbackScreenId,
operator: "includesAny",
optionIds: [],
};
}
return condition;
}
function VariantOverridesEditor({
baseScreen,
overrides,
onChange,
}: {
baseScreen: BuilderScreen;
overrides: VariantDefinition["overrides"];
onChange: (overrides: VariantDefinition["overrides"]) => void;
}) {
const baseWithoutVariants = useMemo(() => {
const clone = mergeScreenWithOverrides(baseScreen, {});
const sanitized = { ...clone } as BuilderScreen;
if ("variants" in sanitized) {
delete (sanitized as Partial<BuilderScreen>).variants;
}
return sanitized;
}, [baseScreen]);
const mergedScreen = useMemo(
() => mergeScreenWithOverrides<BuilderScreen>(baseWithoutVariants, overrides) as BuilderScreen,
[baseWithoutVariants, overrides]
);
const handleUpdate = useCallback(
(updates: Partial<ScreenDefinition>) => {
const nextScreen = mergeScreenWithOverrides<BuilderScreen>(
mergedScreen,
updates as Partial<BuilderScreen>
);
const nextOverrides = extractVariantOverrides(baseWithoutVariants, nextScreen);
onChange(nextOverrides);
},
[baseWithoutVariants, mergedScreen, onChange]
);
return (
<div className="space-y-3">
<TemplateConfig screen={mergedScreen} onUpdate={handleUpdate} />
<Button variant="outline" className="h-8 px-3 text-xs" onClick={() => onChange({})}>
Сбросить переопределения
</Button>
</div>
);
}
export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVariantsConfigProps) {
const variants = useMemo(
() => ((screen.variants ?? []) as VariantDefinition[]),
[screen.variants]
);
const [expandedVariant, setExpandedVariant] = useState<number | null>(() => (variants.length > 0 ? 0 : null));
useEffect(() => {
if (variants.length === 0) {
setExpandedVariant(null);
return;
}
if (expandedVariant === null) {
setExpandedVariant(0);
return;
}
if (expandedVariant >= variants.length) {
setExpandedVariant(variants.length - 1);
}
}, [expandedVariant, variants]);
const listScreens = useMemo(
() => allScreens.filter((candidate): candidate is ListBuilderScreen => candidate.template === "list"),
[allScreens]
);
const optionMap = useMemo(() => {
return listScreens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, listScreen) => {
accumulator[listScreen.id] = listScreen.list.options;
return accumulator;
}, {});
}, [listScreens]);
const handleVariantsUpdate = useCallback(
(nextVariants: VariantDefinition[]) => {
onChange(nextVariants);
},
[onChange]
);
const addVariant = useCallback(() => {
const fallbackScreen = listScreens[0] ?? (screen.template === "list" ? (screen as ListBuilderScreen) : undefined);
if (!fallbackScreen) {
return;
}
const firstOptionId = fallbackScreen.list.options[0]?.id;
const newVariant: VariantDefinition = {
conditions: [
{
screenId: fallbackScreen.id,
operator: "includesAny",
optionIds: firstOptionId ? [firstOptionId] : [],
},
],
overrides: {},
};
handleVariantsUpdate([...variants, newVariant]);
setExpandedVariant(variants.length);
}, [handleVariantsUpdate, listScreens, screen, variants]);
const removeVariant = useCallback(
(index: number) => {
handleVariantsUpdate(variants.filter((_, variantIndex) => variantIndex !== index));
},
[handleVariantsUpdate, variants]
);
const updateVariant = useCallback(
(index: number, patch: Partial<VariantDefinition>) => {
handleVariantsUpdate(
variants.map((variant, variantIndex) =>
variantIndex === index
? {
...variant,
...patch,
conditions: patch.conditions ?? variant.conditions,
overrides: patch.overrides ?? variant.overrides,
}
: variant
)
);
},
[handleVariantsUpdate, variants]
);
const updateCondition = useCallback(
(index: number, updates: Partial<VariantCondition>) => {
updateVariant(index, {
conditions: [
{
...ensureCondition(variants[index], screen.id),
...updates,
},
],
});
},
[screen.id, updateVariant, variants]
);
const toggleOption = useCallback(
(index: number, optionId: string) => {
const condition = ensureCondition(variants[index], screen.id);
const optionIds = new Set(condition.optionIds ?? []);
if (optionIds.has(optionId)) {
optionIds.delete(optionId);
} else {
optionIds.add(optionId);
}
updateCondition(index, { optionIds: Array.from(optionIds) });
},
[screen.id, updateCondition, variants]
);
const handleScreenChange = useCallback(
(variantIndex: number, screenId: string) => {
const listScreen = listScreens.find((candidate) => candidate.id === screenId);
const defaultOption = listScreen?.list.options[0]?.id;
updateCondition(variantIndex, {
screenId,
optionIds: defaultOption ? [defaultOption] : [],
});
},
[listScreens, updateCondition]
);
const handleOperatorChange = useCallback(
(variantIndex: number, operator: VariantCondition["operator"]) => {
updateCondition(variantIndex, { operator });
},
[updateCondition]
);
const handleOverridesChange = useCallback(
(index: number, overrides: VariantDefinition["overrides"]) => {
updateVariant(index, { overrides });
},
[updateVariant]
);
const renderVariantSummary = useCallback(
(variant: VariantDefinition) => {
const condition = ensureCondition(variant, screen.id);
const optionSummaries = (condition.optionIds ?? []).map((optionId) => {
const options = optionMap[condition.screenId] ?? [];
const option = options.find((item) => item.id === optionId);
return option?.label ?? optionId;
});
const listScreenTitle = listScreens.find((candidate) => candidate.id === condition.screenId)?.title.text;
const operatorLabel = (() => {
switch (condition.operator) {
case "includesAll":
return "все из";
case "includesExactly":
return "точное совпадение";
default:
return "любой из";
}
})();
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
return (
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-foreground">Экран условий:</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
{listScreenTitle ?? condition.screenId}
</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground/80">{operatorLabel}</span>
</div>
{optionSummaries.length > 0 ? (
<div className="flex flex-wrap gap-1">
{optionSummaries.map((label) => (
<span key={label} className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
{label}
</span>
))}
</div>
) : (
<div className="text-muted-foreground/80">Пока нет выбранных ответов</div>
)}
<div className="flex flex-wrap gap-1">
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((item) => (
<span key={item} className="rounded-md bg-muted px-2 py-0.5 text-[11px]">
{item === "Без изменений" ? item : formatOverridePath(item)}
</span>
))}
</div>
</div>
);
},
[listScreens, optionMap, screen.id]
);
return (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground">
Настройте альтернативные варианты контента без изменения переходов.
</p>
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={addVariant} disabled={listScreens.length === 0}>
<span className="text-lg leading-none">+</span>
</Button>
</div>
{listScreens.length === 0 ? (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
Добавьте экран со списком, чтобы настроить вариативность.
</div>
) : variants.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-4 text-center text-xs text-muted-foreground">
Пока нет дополнительных вариантов.
</div>
) : (
<div className="flex flex-col gap-4">
{variants.map((variant, index) => {
const condition = ensureCondition(variant, screen.id);
const isExpanded = expandedVariant === index;
const availableOptions = optionMap[condition.screenId] ?? [];
return (
<div key={index} className="space-y-3 rounded-xl border border-border/70 bg-background/80 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Вариант {index + 1}
</div>
<div className="mt-1 text-xs text-muted-foreground">{renderVariantSummary(variant)}</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="h-8 px-3 text-xs"
onClick={() => setExpandedVariant(isExpanded ? null : index)}
>
{isExpanded ? "Свернуть" : "Редактировать"}
</Button>
<Button
variant="ghost"
className="h-8 px-3 text-xs text-destructive"
onClick={() => removeVariant(index)}
>
Удалить
</Button>
</div>
</div>
{isExpanded && (
<div className="space-y-4 border-t border-border/60 pt-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
<p><strong>Ограничение:</strong> Текущая версия админки поддерживает только одно условие на вариант. Реальная система поддерживает множественные условия через JSON.</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-semibold uppercase text-muted-foreground">Экран условий</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={condition.screenId}
onChange={(event) => handleScreenChange(index, event.target.value)}
>
{listScreens.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.title.text}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-semibold uppercase text-muted-foreground">Оператор</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={condition.operator ?? "includesAny"}
onChange={(event) =>
handleOperatorChange(index, event.target.value as VariantCondition["operator"])
}
>
<option value="includesAny">любой из</option>
<option value="includesAll">все из</option>
<option value="includesExactly">точное совпадение</option>
</select>
</label>
</div>
<div className="space-y-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">Ответы</span>
{availableOptions.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
В выбранном экране пока нет вариантов ответа.
</div>
) : (
<div className="grid gap-2 md:grid-cols-2">
{availableOptions.map((option) => {
const isChecked = condition.optionIds?.includes(option.id) ?? false;
return (
<label key={option.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleOption(index, option.id)}
/>
<span>
{option.label}
<span className="text-muted-foreground"> ({option.id})</span>
</span>
</label>
);
})}
</div>
)}
</div>
<div className="space-y-3">
<span className="text-xs font-semibold uppercase text-muted-foreground">Настройка контента</span>
<VariantOverridesEditor
baseScreen={screen}
overrides={variant.overrides ?? {}}
onChange={(overrides) => handleOverridesChange(index, overrides)}
/>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -1,12 +1,10 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
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 "@/components/admin/builder/ScreenVariantsConfig";
import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig";
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type {
@ -16,121 +14,9 @@ import type {
} from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
import { validateBuilderState } from "@/lib/admin/builder/validation";
type ValidationIssues = ReturnType<typeof validateBuilderState>["issues"];
function isListScreen(
screen: BuilderScreen
): screen is BuilderScreen & {
list: {
selectionType: "single" | "multi";
options: Array<{ id: string; label: string; description?: string; emoji?: string }>;
};
} {
return screen.template === "list" && "list" in screen;
}
function Section({
title,
description,
children,
defaultExpanded = false,
alwaysExpanded = false,
}: {
title: string;
description?: string;
children: ReactNode;
defaultExpanded?: boolean;
alwaysExpanded?: boolean;
}) {
const storageKey = `section-${title.toLowerCase().replace(/\s+/g, '-')}`;
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
if (alwaysExpanded) {
setIsExpanded(true);
setIsHydrated(true);
return;
}
const stored = sessionStorage.getItem(storageKey);
if (stored !== null) {
setIsExpanded(JSON.parse(stored));
}
setIsHydrated(true);
}, [alwaysExpanded, storageKey]);
const handleToggle = () => {
if (alwaysExpanded) return;
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
if (typeof window !== 'undefined') {
sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
}
};
const effectiveExpanded = alwaysExpanded || (isHydrated ? isExpanded : defaultExpanded);
return (
<section className="flex flex-col gap-3">
<div
className={cn(
"flex items-center gap-2 cursor-pointer",
!alwaysExpanded && "hover:text-foreground transition-colors"
)}
onClick={handleToggle}
>
{!alwaysExpanded && (
effectiveExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)
)}
<div className="flex flex-col gap-1 flex-1">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h2>
{description && <p className="text-xs text-muted-foreground/80">{description}</p>}
</div>
</div>
{effectiveExpanded && (
<div className="flex flex-col gap-2 ml-2 pl-2 border-l-2 border-border/30">{children}</div>
)}
</section>
);
}
function ValidationSummary({ issues }: { issues: ValidationIssues }) {
if (issues.length === 0) {
return (
<div className="rounded-lg border border-border/30 bg-background/40 p-2 text-xs text-muted-foreground">
Всё хорошо воронка валидна.
</div>
);
}
return (
<div className="space-y-2">
{issues.map((issue, index) => (
<div
key={index}
className="rounded-lg border border-destructive/20 bg-destructive/5 p-2 text-xs text-destructive"
>
<div className="flex items-start gap-2">
<span className="text-destructive/80"></span>
<div>
<p className="font-medium">{issue.message}</p>
{issue.screenId && <p className="mt-1 text-destructive/80">Экран: {issue.screenId}</p>}
</div>
</div>
</div>
))}
</div>
);
}
import { Section } from "./Section";
import { ValidationSummary } from "./ValidationSummary";
import { isListScreen, type ValidationIssues } from "./types";
export function BuilderSidebar() {
const state = useBuilderState();
@ -206,6 +92,7 @@ export function BuilderSidebar() {
defaultNextScreenId:
navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId,
rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
},
},
});
@ -503,26 +390,47 @@ export function BuilderSidebar() {
</Section>
<Section title="Навигация">
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
>
<option value=""></option>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
{/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedScreen.navigation?.isEndScreen ?? false}
onChange={(e) => {
updateNavigation(selectedScreen, { isEndScreen: e.target.checked });
}}
className="rounded border-border"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">Финальный экран</span>
<span className="text-xs text-muted-foreground">
Этот экран завершает воронку (переход не требуется)
</span>
</div>
</label>
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
{!selectedScreen.navigation?.isEndScreen && (
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
>
<option value=""></option>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
)}
</Section>
{selectedScreenIsListType && (
{selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
<Section title="Правила переходов" description="Условная навигация">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">

View File

@ -0,0 +1,78 @@
import { useEffect, useState, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SectionProps {
title: string;
description?: string;
children: ReactNode;
defaultExpanded?: boolean;
alwaysExpanded?: boolean;
}
export function Section({
title,
description,
children,
defaultExpanded = false,
alwaysExpanded = false,
}: SectionProps) {
const storageKey = `section-${title.toLowerCase().replace(/\s+/g, '-')}`;
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
if (alwaysExpanded) {
setIsExpanded(true);
setIsHydrated(true);
return;
}
const stored = sessionStorage.getItem(storageKey);
if (stored !== null) {
setIsExpanded(JSON.parse(stored));
}
setIsHydrated(true);
}, [alwaysExpanded, storageKey]);
const handleToggle = () => {
if (alwaysExpanded) return;
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
if (typeof window !== 'undefined') {
sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
}
};
const effectiveExpanded = alwaysExpanded || (isHydrated ? isExpanded : defaultExpanded);
return (
<section className="flex flex-col gap-3">
<div
className={cn(
"flex items-center gap-2 cursor-pointer",
!alwaysExpanded && "hover:text-foreground transition-colors"
)}
onClick={handleToggle}
>
{!alwaysExpanded && (
effectiveExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)
)}
<div className="flex flex-col gap-1 flex-1">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h2>
{description && <p className="text-xs text-muted-foreground/80">{description}</p>}
</div>
</div>
{effectiveExpanded && (
<div className="flex flex-col gap-2 ml-2 pl-2 border-l-2 border-border/30">{children}</div>
)}
</section>
);
}

View File

@ -0,0 +1,34 @@
import type { ValidationIssues } from "./types";
export interface ValidationSummaryProps {
issues: ValidationIssues;
}
export function ValidationSummary({ issues }: ValidationSummaryProps) {
if (issues.length === 0) {
return (
<div className="rounded-lg border border-border/30 bg-background/40 p-2 text-xs text-muted-foreground">
Всё хорошо воронка валидна.
</div>
);
}
return (
<div className="space-y-2">
{issues.map((issue, index) => (
<div
key={index}
className="rounded-lg border border-destructive/20 bg-destructive/5 p-2 text-xs text-destructive"
>
<div className="flex items-start gap-2">
<span className="text-destructive/80"></span>
<div>
<p className="font-medium">{issue.message}</p>
{issue.screenId && <p className="mt-1 text-destructive/80">Экран: {issue.screenId}</p>}
</div>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,11 @@
// Main component
export { BuilderSidebar } from "./BuilderSidebar";
// Sub-components
export { Section } from "./Section";
export { ValidationSummary } from "./ValidationSummary";
// Types and utilities
export { isListScreen } from "./types";
export type { ValidationIssues, SectionProps } from "./types";
export type { ValidationSummaryProps } from "./ValidationSummary";

View File

@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { validateBuilderState } from "@/lib/admin/builder/validation";
export type ValidationIssues = ReturnType<typeof validateBuilderState>["issues"];
export interface SectionProps {
title: string;
description?: string;
children: ReactNode;
defaultExpanded?: boolean;
alwaysExpanded?: boolean;
}
/**
* Type guard для проверки что экран является list экраном
*/
export function isListScreen(
screen: BuilderScreen
): screen is BuilderScreen & {
list: {
selectionType: "single" | "multi";
options: Array<{ id: string; label: string; description?: string; emoji?: string }>;
};
} {
return screen.template === "list" && "list" in screen;
}

View File

@ -6,7 +6,10 @@ import {
FormInput,
Info,
Calendar,
Ticket
Ticket,
Loader,
Heart,
Mail
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -40,6 +43,13 @@ const TEMPLATE_OPTIONS = [
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: "Информация",
@ -49,11 +59,25 @@ const TEMPLATE_OPTIONS = [
},
{
template: "date" as const,
title: "Дата",
description: "Выбор даты (месяц, день, год)",
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: "Купон",

View File

@ -0,0 +1,2 @@
// Dialog components for builder interface
export { AddScreenDialog } from "./AddScreenDialog";

View File

@ -0,0 +1,241 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { AGE_GROUPS, GENERATION_GROUPS, parseAgeRange } from "@/lib/age-utils";
interface AgeSelectorProps {
selectedValues: string[];
onToggleValue: (value: string) => void;
onAddCustomValue: (value: string) => void;
}
export function AgeSelector({ selectedValues, onToggleValue, onAddCustomValue }: AgeSelectorProps) {
const [customValue, setCustomValue] = useState("");
const handleAddCustom = () => {
if (customValue.trim()) {
onAddCustomValue(customValue.trim());
setCustomValue("");
}
};
const isValueSelected = (value: string) => selectedValues.includes(value);
return (
<div className="space-y-4">
{/* 🎂 ВОЗРАСТНЫЕ ГРУППЫ */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-foreground">🎂 Возрастные группы</h4>
<div className="grid grid-cols-2 gap-2">
{AGE_GROUPS.map((group) => {
const isSelected = isValueSelected(group.id);
return (
<button
key={group.id}
onClick={() => onToggleValue(group.id)}
className={`
relative group p-3 rounded-lg border-2 transition-all duration-200
hover:shadow-md text-left
${isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border hover:border-primary/50"
}
`}
title={group.description}
>
<div className="flex items-center gap-2">
{/* Возрастной диапазон */}
<span className="text-lg">🎂</span>
{/* Информация */}
<div className="flex-1 min-w-0">
<div className={`font-medium text-sm ${
isSelected ? "text-primary" : "text-foreground"
}`}>
{group.name}
</div>
<div className="text-xs text-muted-foreground truncate">
{group.description}
</div>
</div>
{/* Индикатор выбранного */}
{isSelected && (
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-[10px] text-white"></span>
</div>
)}
</div>
</button>
);
})}
</div>
</div>
{/* 🚀 ПОКОЛЕНИЯ */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-foreground">🚀 Поколения</h4>
<div className="grid grid-cols-1 gap-2">
{GENERATION_GROUPS.map((generation) => {
const isSelected = isValueSelected(generation.id);
return (
<button
key={generation.id}
onClick={() => onToggleValue(generation.id)}
className={`
relative group p-3 rounded-lg border-2 transition-all duration-200
hover:shadow-md text-left
${isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border hover:border-primary/50"
}
`}
title={`Родились ${generation.minYear}-${generation.maxYear}`}
>
<div className="flex items-center gap-2">
{/* Иконка поколения */}
<span className="text-lg">
{generation.id === 'gen-z' ? '📱' :
generation.id === 'millennials' ? '💻' :
generation.id === 'gen-x' ? '📺' :
generation.id === 'boomers' ? '📻' : '📰'}
</span>
{/* Информация */}
<div className="flex-1 min-w-0">
<div className={`font-medium text-sm ${
isSelected ? "text-primary" : "text-foreground"
}`}>
{generation.name}
</div>
<div className="text-xs text-muted-foreground">
{generation.minYear}-{generation.maxYear} {generation.description}
</div>
</div>
{/* Индикатор выбранного */}
{isSelected && (
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-[10px] text-white"></span>
</div>
)}
</div>
</button>
);
})}
</div>
</div>
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Или добавить точный возраст/диапазон:
</label>
<div className="flex gap-2">
<TextInput
placeholder="25, 18-21, 30-35, 60+ и т.д."
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddCustom();
}
}}
/>
<Button
onClick={handleAddCustom}
disabled={!customValue.trim()}
className="text-sm px-3 py-1"
>
Добавить
</Button>
</div>
{/* Подсказки по форматам */}
<div className="text-xs text-muted-foreground">
<strong>Примеры:</strong> 25 (точный возраст), 18-21 (диапазон), 60+ (от 60 лет), age-25 (альтернативный формат)
</div>
</div>
{/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */}
{selectedValues.length > 0 && (
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Выбранные возрастные условия:
</label>
<div className="flex flex-wrap gap-1">
{selectedValues.map((value) => {
// Ищем в возрастных группах
const ageGroup = AGE_GROUPS.find(group => group.id === value);
if (ageGroup) {
return (
<span
key={value}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
>
🎂 {ageGroup.name}
<button
onClick={() => onToggleValue(value)}
className="ml-1 hover:text-red-500 transition-colors"
title="Удалить"
>
×
</button>
</span>
);
}
// Ищем в поколениях
const generation = GENERATION_GROUPS.find(gen => gen.id === value);
if (generation) {
return (
<span
key={value}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full"
>
🚀 {generation.name}
<button
onClick={() => onToggleValue(value)}
className="ml-1 hover:text-red-500 transition-colors"
title="Удалить"
>
×
</button>
</span>
);
}
// Кастомное значение
const range = parseAgeRange(value);
return (
<span
key={value}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full"
>
🎯 {range ? `${range.min}-${range.max === 120 ? '+' : range.max}` : value}
<button
onClick={() => onToggleValue(value)}
className="ml-1 hover:text-red-500 transition-colors"
title="Удалить"
>
×
</button>
</span>
);
})}
</div>
</div>
)}
{/* 💡 ПОДСКАЗКА */}
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
<strong>💡 Как это работает:</strong> Система автоматически рассчитывает возраст из
даты рождения пользователя. Выберите возрастные группы или поколения, при которых
должен показываться этот вариант экрана. Можно комбинировать разные условия.
</div>
</div>
);
}

View File

@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { TextInput } from "@/components/ui/TextInput/TextInput";
// 📧 ПОПУЛЯРНЫЕ EMAIL ДОМЕНЫ
const POPULAR_DOMAINS = [
{ id: "@gmail.com", name: "Gmail", icon: "📧", description: "Google Mail" },
{ id: "@yahoo.com", name: "Yahoo", icon: "🟣", description: "Yahoo Mail" },
{ id: "@hotmail.com", name: "Hotmail", icon: "🔵", description: "Microsoft Hotmail" },
{ id: "@outlook.com", name: "Outlook", icon: "📬", description: "Microsoft Outlook" },
{ id: "@icloud.com", name: "iCloud", icon: "☁️", description: "Apple iCloud" },
{ id: "@mail.ru", name: "Mail.ru", icon: "🔴", description: "Mail.ru" },
{ id: "@yandex.ru", name: "Yandex", icon: "🟡", description: "Яндекс.Почта" },
{ id: "@rambler.ru", name: "Rambler", icon: "🟢", description: "Rambler" },
] as const;
interface EmailDomainSelectorProps {
selectedValues: string[];
onToggleValue: (value: string) => void;
onAddCustomValue: (value: string) => void;
}
export function EmailDomainSelector({ selectedValues, onToggleValue, onAddCustomValue }: EmailDomainSelectorProps) {
const [customDomain, setCustomDomain] = useState("");
const handleAddCustom = () => {
let domain = customDomain.trim();
if (domain) {
// Автоматически добавляем @ если его нет
if (!domain.startsWith("@")) {
domain = "@" + domain;
}
onAddCustomValue(domain);
setCustomDomain("");
}
};
return (
<div className="space-y-4">
{/* 📧 ПОПУЛЯРНЫЕ ДОМЕНЫ */}
<div className="grid grid-cols-2 gap-2">
{POPULAR_DOMAINS.map((domain) => {
const isSelected = selectedValues.includes(domain.id);
return (
<button
key={domain.id}
onClick={() => onToggleValue(domain.id)}
className={`
relative group p-3 rounded-lg border-2 transition-all duration-200
hover:shadow-md text-left
${isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border hover:border-primary/50"
}
`}
title={domain.description}
>
<div className="flex items-center gap-2">
{/* Иконка */}
<span className="text-lg">{domain.icon}</span>
{/* Информация */}
<div className="flex-1 min-w-0">
<div className={`font-medium text-sm ${
isSelected ? "text-primary" : "text-foreground"
}`}>
{domain.name}
</div>
<div className="text-xs text-muted-foreground truncate">
{domain.id}
</div>
</div>
{/* Индикатор выбранного */}
{isSelected && (
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-[10px] text-white"></span>
</div>
)}
</div>
</button>
);
})}
</div>
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ДОМЕНОВ */}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Или добавить другой домен:
</label>
<div className="flex gap-2">
<TextInput
placeholder="example.com (@ добавится автоматически)"
value={customDomain}
onChange={(e) => setCustomDomain(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddCustom();
}
}}
/>
<Button
onClick={handleAddCustom}
disabled={!customDomain.trim()}
className="text-sm px-3 py-1"
>
Добавить
</Button>
</div>
</div>
{/* 📋 ВЫБРАННЫЕ ДОМЕНЫ */}
{selectedValues.length > 0 && (
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Выбранные домены:
</label>
<div className="flex flex-wrap gap-1">
{selectedValues.map((value) => {
const popularDomain = POPULAR_DOMAINS.find(domain => domain.id === value);
return (
<span
key={value}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
>
{popularDomain ? (
<>
<span>{popularDomain.icon}</span>
<span>{popularDomain.name}</span>
</>
) : (
<span>📧 {value}</span>
)}
<button
onClick={() => onToggleValue(value)}
className="ml-1 hover:text-red-500 transition-colors"
title="Удалить"
>
×
</button>
</span>
);
})}
</div>
</div>
)}
{/* 💡 ПОДСКАЗКА */}
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
<strong>💡 Как это работает:</strong> Система проверяет домен email адреса пользователя.
Например, если пользователь ввел &ldquo;user@gmail.com&rdquo;, то значение будет &ldquo;@gmail.com&rdquo;.
Выберите домены, при которых должен показываться этот вариант экрана.
</div>
</div>
);
}

View File

@ -0,0 +1,692 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TemplateConfig } from "@/components/admin/builder/templates";
import { Button } from "@/components/ui/button";
import { ZodiacSelector } from "./ZodiacSelector";
import { EmailDomainSelector } from "./EmailDomainSelector";
import { AgeSelector } from "./AgeSelector";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
extractVariantOverrides,
formatOverridePath,
listOverridePaths,
mergeScreenWithOverrides,
} from "@/lib/admin/builder/variants";
import type {
ListOptionDefinition,
NavigationConditionDefinition,
ScreenDefinition,
ScreenVariantDefinition,
} from "@/lib/funnel/types";
interface ScreenVariantsConfigProps {
screen: BuilderScreen;
allScreens: BuilderScreen[];
onChange: (variants: ScreenVariantDefinition<ScreenDefinition>[]) => void;
}
type ListBuilderScreen = BuilderScreen & { template: "list" };
type VariantDefinition = ScreenVariantDefinition<ScreenDefinition>;
type VariantCondition = NavigationConditionDefinition;
function ensureCondition(variant: VariantDefinition, fallbackScreenId: string): VariantCondition {
const [condition] = variant.conditions;
if (!condition) {
return {
screenId: fallbackScreenId,
operator: "includesAny",
optionIds: [],
};
}
return condition;
}
function VariantOverridesEditor({
baseScreen,
overrides,
onChange,
}: {
baseScreen: BuilderScreen;
overrides: VariantDefinition["overrides"];
onChange: (overrides: VariantDefinition["overrides"]) => void;
}) {
const baseWithoutVariants = useMemo(() => {
const clone = mergeScreenWithOverrides(baseScreen, {});
const sanitized = { ...clone } as BuilderScreen;
if ("variants" in sanitized) {
delete (sanitized as Partial<BuilderScreen>).variants;
}
return sanitized;
}, [baseScreen]);
const mergedScreen = useMemo(
() => mergeScreenWithOverrides<BuilderScreen>(baseWithoutVariants, overrides) as BuilderScreen,
[baseWithoutVariants, overrides]
);
const handleUpdate = useCallback(
(updates: Partial<ScreenDefinition>) => {
const nextScreen = mergeScreenWithOverrides<BuilderScreen>(
mergedScreen,
updates as Partial<BuilderScreen>
);
const nextOverrides = extractVariantOverrides(baseWithoutVariants, nextScreen);
onChange(nextOverrides);
},
[baseWithoutVariants, mergedScreen, onChange]
);
return (
<div className="space-y-3">
<TemplateConfig screen={mergedScreen} onUpdate={handleUpdate} />
<Button variant="outline" className="h-8 px-3 text-xs" onClick={() => onChange({})}>
Сбросить переопределения
</Button>
</div>
);
}
export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVariantsConfigProps) {
const variants = useMemo(
() => ((screen.variants ?? []) as VariantDefinition[]),
[screen.variants]
);
const [expandedVariant, setExpandedVariant] = useState<number | null>(() => (variants.length > 0 ? 0 : null));
useEffect(() => {
if (variants.length === 0) {
setExpandedVariant(null);
return;
}
if (expandedVariant === null) {
setExpandedVariant(0);
return;
}
if (expandedVariant >= variants.length) {
setExpandedVariant(variants.length - 1);
}
}, [expandedVariant, variants]);
// 🎯 ПОКАЗЫВАЕМ ВСЕ ЭКРАНЫ, не только list
const availableScreens = useMemo(
() => allScreens.filter((candidate) => candidate.id !== screen.id), // Исключаем сам экран
[allScreens, screen.id]
);
const listScreens = useMemo(
() => allScreens.filter((candidate): candidate is ListBuilderScreen => candidate.template === "list"),
[allScreens]
);
const optionMap = useMemo(() => {
return listScreens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, listScreen) => {
accumulator[listScreen.id] = listScreen.list.options;
return accumulator;
}, {});
}, [listScreens]);
const handleVariantsUpdate = useCallback(
(nextVariants: VariantDefinition[]) => {
onChange(nextVariants);
},
[onChange]
);
const addVariant = useCallback(() => {
const fallbackScreen = listScreens[0] ?? (screen.template === "list" ? (screen as ListBuilderScreen) : undefined);
if (!fallbackScreen) {
return;
}
const firstOptionId = fallbackScreen.list.options[0]?.id;
const newVariant: VariantDefinition = {
conditions: [
{
screenId: fallbackScreen.id,
operator: "includesAny",
optionIds: firstOptionId ? [firstOptionId] : [],
},
],
overrides: {},
};
handleVariantsUpdate([...variants, newVariant]);
setExpandedVariant(variants.length);
}, [handleVariantsUpdate, listScreens, screen, variants]);
const removeVariant = useCallback(
(index: number) => {
handleVariantsUpdate(variants.filter((_, variantIndex) => variantIndex !== index));
},
[handleVariantsUpdate, variants]
);
const updateVariant = useCallback(
(index: number, patch: Partial<VariantDefinition>) => {
handleVariantsUpdate(
variants.map((variant, variantIndex) =>
variantIndex === index
? {
...variant,
...patch,
conditions: patch.conditions ?? variant.conditions,
overrides: patch.overrides ?? variant.overrides,
}
: variant
)
);
},
[handleVariantsUpdate, variants]
);
const updateCondition = useCallback(
(variantIndex: number, conditionIndex: number, updates: Partial<VariantCondition>) => {
const variant = variants[variantIndex];
const updatedConditions = [...variant.conditions];
updatedConditions[conditionIndex] = {
...ensureCondition(variant, screen.id),
...variant.conditions[conditionIndex],
...updates,
};
updateVariant(variantIndex, { conditions: updatedConditions });
},
[screen.id, updateVariant, variants]
);
const addCondition = useCallback(
(variantIndex: number) => {
const variant = variants[variantIndex];
const fallbackScreen = listScreens[0] ?? (screen.template === "list" ? (screen as ListBuilderScreen) : undefined);
if (!fallbackScreen) return;
const firstOptionId = fallbackScreen.list.options[0]?.id;
const newCondition: VariantCondition = {
screenId: fallbackScreen.id,
operator: "includesAny",
optionIds: firstOptionId ? [firstOptionId] : [],
};
updateVariant(variantIndex, {
conditions: [...variant.conditions, newCondition],
});
},
[variants, listScreens, screen, updateVariant]
);
const removeCondition = useCallback(
(variantIndex: number, conditionIndex: number) => {
const variant = variants[variantIndex];
if (variant.conditions.length <= 1) return; // Минимум одно условие должно остаться
const updatedConditions = variant.conditions.filter((_, index) => index !== conditionIndex);
updateVariant(variantIndex, { conditions: updatedConditions });
},
[variants, updateVariant]
);
const toggleOption = useCallback(
(variantIndex: number, conditionIndex: number, optionId: string) => {
const variant = variants[variantIndex];
const condition = variant.conditions[conditionIndex] || ensureCondition(variant, screen.id);
const optionIds = new Set(condition.optionIds ?? []);
if (optionIds.has(optionId)) {
optionIds.delete(optionId);
} else {
optionIds.add(optionId);
}
updateCondition(variantIndex, conditionIndex, { optionIds: Array.from(optionIds) });
},
[screen.id, updateCondition, variants]
);
// 🎯 НОВАЯ ЛОГИКА: поддержка всех экранов и типов условий
const handleScreenChange = useCallback(
(variantIndex: number, conditionIndex: number, screenId: string) => {
const targetScreen = availableScreens.find((candidate) => candidate.id === screenId);
if (!targetScreen) return;
// Определяем тип условия по типу экрана
if (targetScreen.template === "list") {
const listScreen = targetScreen as ListBuilderScreen;
const defaultOption = listScreen.list.options[0]?.id;
updateCondition(variantIndex, conditionIndex, {
screenId,
conditionType: "options",
optionIds: defaultOption ? [defaultOption] : [],
values: undefined, // Очищаем values при переключении на options
});
} else {
// Для всех остальных экранов используем values
updateCondition(variantIndex, conditionIndex, {
screenId,
conditionType: "values",
values: [],
optionIds: undefined, // Очищаем optionIds при переключении на values
});
}
},
[availableScreens, updateCondition]
);
const handleOperatorChange = useCallback(
(variantIndex: number, conditionIndex: number, operator: VariantCondition["operator"]) => {
updateCondition(variantIndex, conditionIndex, { operator });
},
[updateCondition]
);
// 🎯 НОВЫЕ ФУНКЦИИ для работы с values
const toggleValue = useCallback(
(variantIndex: number, conditionIndex: number, value: string) => {
const variant = variants[variantIndex];
const condition = variant.conditions[conditionIndex] || ensureCondition(variant, screen.id);
const values = new Set(condition.values ?? []);
if (values.has(value)) {
values.delete(value);
} else {
values.add(value);
}
updateCondition(variantIndex, conditionIndex, { values: Array.from(values) });
},
[screen.id, updateCondition, variants]
);
const addCustomValue = useCallback(
(variantIndex: number, conditionIndex: number, value: string) => {
if (!value.trim()) return;
const variant = variants[variantIndex];
const condition = variant.conditions[conditionIndex] || ensureCondition(variant, screen.id);
const values = new Set(condition.values ?? []);
values.add(value.trim());
updateCondition(variantIndex, conditionIndex, { values: Array.from(values) });
},
[screen.id, updateCondition, variants]
);
const handleOverridesChange = useCallback(
(index: number, overrides: VariantDefinition["overrides"]) => {
updateVariant(index, { overrides });
},
[updateVariant]
);
// 🎯 НОВАЯ ФУНКЦИЯ: определение типа экрана для красивого отображения
const getScreenTypeLabel = useCallback((screenId: string) => {
const targetScreen = availableScreens.find(s => s.id === screenId);
if (!targetScreen) return "Неизвестный";
const templateLabels: Record<ScreenDefinition["template"], string> = {
list: "📝 Список",
date: "📅 Дата рождения",
email: "📧 Email",
form: "📋 Форма",
info: " Информация",
coupon: "🎟️ Купон",
loaders: "⏳ Загрузка",
soulmate: "💖 Портрет",
};
return templateLabels[targetScreen.template] || targetScreen.template;
}, [availableScreens]);
const renderVariantSummary = useCallback(
(variant: VariantDefinition) => {
const condition = ensureCondition(variant, screen.id);
const conditionType = condition.conditionType ?? "options";
// Получаем данные в зависимости от типа условия
const summaries = conditionType === "values"
? (condition.values ?? [])
: (condition.optionIds ?? []).map((optionId) => {
const options = optionMap[condition.screenId] ?? [];
const option = options.find((item) => item.id === optionId);
return option?.label ?? optionId;
});
const screenTitle = availableScreens.find((candidate) => candidate.id === condition.screenId)?.title.text;
const screenTypeLabel = getScreenTypeLabel(condition.screenId);
const operatorLabel = (() => {
switch (condition.operator) {
case "includesAll":
return "все из";
case "includesExactly":
return "точное совпадение";
case "equals":
return "равно";
default:
return "любой из";
}
})();
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
return (
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-foreground">Условие:</span>
<span className="rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-[11px] text-blue-700">
{screenTypeLabel}
</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground/80">{operatorLabel}</span>
</div>
<div className="text-[11px]">
<span className="text-muted-foreground">Экран: </span>
<span className="text-foreground font-medium">{screenTitle ?? condition.screenId}</span>
</div>
{summaries.length > 0 ? (
<div className="flex flex-wrap gap-1">
{summaries.map((item) => (
<span key={item} className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
{item}
</span>
))}
</div>
) : (
<div className="text-muted-foreground/80">Пока нет выбранных значений</div>
)}
<div className="flex flex-wrap gap-1">
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((item) => (
<span key={item} className="rounded-md bg-muted px-2 py-0.5 text-[11px]">
{item === "Без изменений" ? item : formatOverridePath(item)}
</span>
))}
</div>
</div>
);
},
[availableScreens, optionMap, screen.id, getScreenTypeLabel]
);
return (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground">
Настройте альтернативные варианты контента без изменения переходов.
</p>
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={addVariant} disabled={availableScreens.length === 0}>
<span className="text-lg leading-none">+</span>
</Button>
</div>
{availableScreens.length === 0 ? (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
Добавьте другие экраны в воронку, чтобы настроить вариативность.
</div>
) : variants.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-4 text-center text-xs text-muted-foreground">
Пока нет дополнительных вариантов.
</div>
) : (
<div className="flex flex-col gap-4">
{variants.map((variant, index) => {
const condition = ensureCondition(variant, screen.id);
const isExpanded = expandedVariant === index;
const availableOptions = optionMap[condition.screenId] ?? [];
return (
<div key={index} className="space-y-3 rounded-xl border border-border/70 bg-background/80 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Вариант {index + 1}
</div>
<div className="mt-1 text-xs text-muted-foreground">{renderVariantSummary(variant)}</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="h-8 px-3 text-xs"
onClick={() => setExpandedVariant(isExpanded ? null : index)}
>
{isExpanded ? "Свернуть" : "Редактировать"}
</Button>
<Button
variant="ghost"
className="h-8 px-3 text-xs text-destructive"
onClick={() => removeVariant(index)}
>
Удалить
</Button>
</div>
</div>
{isExpanded && (
<div className="space-y-4 border-t border-border/60 pt-4">
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-xs text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200">
<p><strong> Поддержка множественных условий:</strong> Теперь вы можете добавить несколько условий для одного варианта. Все условия должны выполняться одновременно (логическое И).</p>
</div>
{/* 🎯 МНОЖЕСТВЕННЫЕ УСЛОВИЯ */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Условия ({variant.conditions.length})
</span>
<Button
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => addCondition(index)}
disabled={availableScreens.length === 0}
>
+ Добавить условие
</Button>
</div>
{variant.conditions.map((condition, conditionIndex) => (
<div key={conditionIndex} className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
Условие #{conditionIndex + 1}
</span>
{variant.conditions.length > 1 && (
<Button
variant="ghost"
className="h-6 px-2 text-xs text-destructive"
onClick={() => removeCondition(index, conditionIndex)}
>
Удалить
</Button>
)}
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-semibold uppercase text-muted-foreground">Экран условий</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={condition.screenId}
onChange={(event) => handleScreenChange(index, conditionIndex, event.target.value)}
>
{availableScreens.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{getScreenTypeLabel(candidate.id)} - {candidate.title.text}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-semibold uppercase text-muted-foreground">Оператор</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={condition.operator ?? "includesAny"}
onChange={(event) =>
handleOperatorChange(index, conditionIndex, event.target.value as VariantCondition["operator"])
}
>
<option value="includesAny">любой из</option>
<option value="includesAll">все из</option>
<option value="includesExactly">точное совпадение</option>
<option value="equals">равно (для одиночных значений)</option>
</select>
</label>
</div>
{/* 🎯 НОВЫЙ UI: поддержка разных типов экранов */}
<div className="space-y-3">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Условия для {getScreenTypeLabel(condition.screenId)}
</span>
{(() => {
const targetScreen = availableScreens.find(s => s.id === condition.screenId);
if (targetScreen?.template === "list") {
// 📝 LIST ЭКРАНЫ - показываем опции
return availableOptions.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
В выбранном экране пока нет вариантов ответа.
</div>
) : (
<div className="grid gap-2 md:grid-cols-2">
{availableOptions.map((option) => {
const isChecked = condition.optionIds?.includes(option.id) ?? false;
return (
<label key={option.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleOption(index, conditionIndex, option.id)}
/>
<span>
{option.label}
<span className="text-muted-foreground"> ({option.id})</span>
</span>
</label>
);
})}
</div>
);
} else if (targetScreen?.template === "date") {
// 📅 DATE ЭКРАНЫ - показываем селекторы возраста и знаков зодиака
return (
<div className="space-y-4">
{/* 🎂 СЕЛЕКТОР ВОЗРАСТА */}
<div>
<h5 className="text-sm font-medium text-foreground mb-3">🎂 Возрастные условия</h5>
<AgeSelector
selectedValues={condition.values?.filter(v =>
v.includes('age-') || v.includes('-') || ['gen-z', 'millennials', 'gen-x', 'boomers', 'silent'].includes(v)
) ?? []}
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
/>
</div>
{/* ♈ СЕЛЕКТОР ЗНАКОВ ЗОДИАКА */}
<div>
<h5 className="text-sm font-medium text-foreground mb-3"> Знаки зодиака</h5>
<ZodiacSelector
selectedValues={condition.values?.filter(v =>
!v.includes('age-') && !v.includes('-') && !['gen-z', 'millennials', 'gen-x', 'boomers', 'silent'].includes(v)
) ?? []}
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
/>
</div>
</div>
);
} else if (targetScreen?.template === "email") {
// 📧 EMAIL ЭКРАНЫ - показываем селектор доменов
return (
<EmailDomainSelector
selectedValues={condition.values ?? []}
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
/>
);
} else {
// 🎯 ОБЩИЕ ЭКРАНЫ - простой ввод значений
return (
<div className="space-y-3">
<div className="text-xs text-muted-foreground bg-blue-50 border border-blue-200 rounded-lg p-3">
<strong>💡 Как работает:</strong> Для экранов типа &ldquo;{targetScreen?.template}&rdquo;
система сравнивает сохраненные ответы пользователя с указанными значениями.
</div>
{/* Показываем выбранные значения */}
{(condition.values ?? []).length > 0 && (
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Выбранные значения:
</label>
<div className="flex flex-wrap gap-1">
{(condition.values ?? []).map((value) => (
<span
key={value}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
>
{value}
<button
onClick={() => toggleValue(index, conditionIndex, value)}
className="ml-1 hover:text-red-500 transition-colors"
title="Удалить"
>
×
</button>
</span>
))}
</div>
</div>
)}
{/* Поле для добавления новых значений */}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Добавить значение:
</label>
<input
type="text"
placeholder="Введите значение для сравнения..."
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const value = (e.target as HTMLInputElement).value.trim();
if (value) {
addCustomValue(index, conditionIndex, value);
(e.target as HTMLInputElement).value = "";
}
}
}}
/>
</div>
</div>
);
}
})()}
</div>
</div>
))}
</div>
<div className="space-y-3">
<span className="text-xs font-semibold uppercase text-muted-foreground">Настройка контента</span>
<VariantOverridesEditor
baseScreen={screen}
overrides={variant.overrides ?? {}}
onChange={(overrides) => handleOverridesChange(index, overrides)}
/>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,155 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { TextInput } from "@/components/ui/TextInput/TextInput";
// 🔮 ЗНАКИ ЗОДИАКА С КРАСИВЫМИ ИКОНКАМИ
const ZODIAC_SIGNS = [
{ id: "aries", name: "Овен", icon: "♈", dates: "21 марта - 19 апреля" },
{ id: "taurus", name: "Телец", icon: "♉", dates: "20 апреля - 20 мая" },
{ id: "gemini", name: "Близнецы", icon: "♊", dates: "21 мая - 20 июня" },
{ id: "cancer", name: "Рак", icon: "♋", dates: "21 июня - 22 июля" },
{ id: "leo", name: "Лев", icon: "♌", dates: "23 июля - 22 августа" },
{ id: "virgo", name: "Дева", icon: "♍", dates: "23 августа - 22 сентября" },
{ id: "libra", name: "Весы", icon: "♎", dates: "23 сентября - 22 октября" },
{ id: "scorpio", name: "Скорпион", icon: "♏", dates: "23 октября - 21 ноября" },
{ id: "sagittarius", name: "Стрелец", icon: "♐", dates: "22 ноября - 21 декабря" },
{ id: "capricorn", name: "Козерог", icon: "♑", dates: "22 декабря - 19 января" },
{ id: "aquarius", name: "Водолей", icon: "♒", dates: "20 января - 18 февраля" },
{ id: "pisces", name: "Рыбы", icon: "♓", dates: "19 февраля - 20 марта" },
] as const;
interface ZodiacSelectorProps {
selectedValues: string[];
onToggleValue: (value: string) => void;
onAddCustomValue: (value: string) => void;
}
export function ZodiacSelector({ selectedValues, onToggleValue, onAddCustomValue }: ZodiacSelectorProps) {
const [customValue, setCustomValue] = useState("");
const handleAddCustom = () => {
if (customValue.trim()) {
onAddCustomValue(customValue.trim());
setCustomValue("");
}
};
return (
<div className="space-y-4">
{/* 🔮 КРАСИВАЯ СЕТКА ЗНАКОВ ЗОДИАКА */}
<div className="grid grid-cols-3 gap-2">
{ZODIAC_SIGNS.map((sign) => {
const isSelected = selectedValues.includes(sign.id);
return (
<button
key={sign.id}
onClick={() => onToggleValue(sign.id)}
className={`
relative group p-3 rounded-lg border-2 transition-all duration-200
hover:shadow-md hover:scale-105 text-center
${isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border hover:border-primary/50"
}
`}
title={sign.dates}
>
{/* Иконка знака */}
<div className={`text-2xl mb-1 transition-all duration-200 ${
isSelected ? "scale-110" : "group-hover:scale-105"
}`}>
{sign.icon}
</div>
{/* Название */}
<div className={`text-xs font-medium transition-colors duration-200 ${
isSelected ? "text-primary" : "text-foreground"
}`}>
{sign.name}
</div>
{/* Индикатор выбранного */}
{isSelected && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center">
<span className="text-[10px] text-white"></span>
</div>
)}
</button>
);
})}
</div>
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Или добавить пользовательское значение:
</label>
<div className="flex gap-2">
<TextInput
placeholder="Например: virgo или другое значение..."
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddCustom();
}
}}
/>
<Button
onClick={handleAddCustom}
disabled={!customValue.trim()}
className="text-sm px-3 py-1"
>
Добавить
</Button>
</div>
</div>
{/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */}
{selectedValues.length > 0 && (
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Выбранные значения:
</label>
<div className="flex flex-wrap gap-1">
{selectedValues.map((value) => {
const zodiacSign = ZODIAC_SIGNS.find(sign => sign.id === value);
return (
<span
key={value}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
>
{zodiacSign ? (
<>
<span>{zodiacSign.icon}</span>
<span>{zodiacSign.name}</span>
</>
) : (
<span>{value}</span>
)}
<button
onClick={() => onToggleValue(value)}
className="ml-1 hover:text-red-500 transition-colors"
title="Удалить"
>
×
</button>
</span>
);
})}
</div>
</div>
)}
{/* 💡 ПОДСКАЗКА */}
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
<strong>💡 Как это работает:</strong> Знак зодиака автоматически определяется из
даты рождения пользователя. Выберите знаки, при которых должен показываться
этот вариант экрана.
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
// Form components and selectors for builder interface
export { AgeSelector } from "./AgeSelector";
export { EmailDomainSelector } from "./EmailDomainSelector";
export { ZodiacSelector } from "./ZodiacSelector";
export { ScreenVariantsConfig } from "./ScreenVariantsConfig";

View File

@ -0,0 +1,22 @@
// Builder interface components organized by category
// Layout components (main UI blocks)
export * from "./layout";
// Canvas components (screen flow visualization)
export * from "./Canvas";
// Sidebar components (screen configuration)
export * from "./Sidebar";
// Dialog components (modal windows)
export * from "./dialogs";
// Form components (selectors and configuration forms)
export * from "./forms";
// Template configuration components
export * from "./templates";
// Provider components (state management)
export * from "./providers";

View File

@ -6,7 +6,7 @@ import { ArrowLeft, Save, Globe, Download, Upload, Undo, Redo } from "lucide-rea
import { Button } from "@/components/ui/button";
import { serializeBuilderState, deserializeFunnelDefinition } from "@/lib/admin/builder/utils";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import { useBuilderUndoRedo } from "@/components/admin/builder/BuilderUndoRedoProvider";
import { useBuilderUndoRedo } from "../providers/BuilderUndoRedoProvider";
import type { BuilderState } from "@/lib/admin/builder/context";
import { cn } from "@/lib/utils";

View File

@ -0,0 +1,3 @@
// Layout components for builder interface
export { BuilderTopBar } from "./BuilderTopBar";
export { BuilderPreview } from "./BuilderPreview";

View File

@ -0,0 +1,2 @@
// Provider components for builder state management
export { BuilderUndoRedoProvider } from "./BuilderUndoRedoProvider";

View File

@ -10,7 +10,7 @@ interface CouponScreenConfigProps {
}
export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) {
const couponScreen = screen as CouponScreenDefinition & { position: { x: number; y: number } };
const couponScreen = screen as CouponScreenDefinition;
const handleCouponUpdate = <T extends keyof CouponScreenDefinition["coupon"]>(
field: T,

View File

@ -10,7 +10,7 @@ interface DateScreenConfigProps {
}
export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
const dateScreen = screen as DateScreenDefinition & { position: { x: number; y: number } };
const dateScreen = screen as DateScreenDefinition;
const handleDateInputChange = <T extends keyof DateScreenDefinition["dateInput"]>(
field: T,

View File

@ -0,0 +1,79 @@
"use client";
import React from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { EmailScreenDefinition } from "@/lib/funnel/types";
interface EmailScreenConfigProps {
screen: BuilderScreen & { template: "email" };
onUpdate: (updates: Partial<EmailScreenDefinition>) => void;
}
export function EmailScreenConfig({ screen, onUpdate }: EmailScreenConfigProps) {
const updateEmailInput = (updates: Partial<EmailScreenDefinition["emailInput"]>) => {
onUpdate({
emailInput: {
...screen.emailInput,
...updates,
},
});
};
const updateImage = (updates: Partial<EmailScreenDefinition["image"]>) => {
onUpdate({
image: screen.image ? {
...screen.image,
...updates,
} : { src: "", ...updates },
});
};
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Настройки поля Email</h4>
<div className="space-y-3">
<TextInput
label="Подпись поля"
placeholder="Email"
value={screen.emailInput?.label || ""}
onChange={(e) => updateEmailInput({ label: e.target.value })}
/>
<TextInput
label="Плейсхолдер"
placeholder="Enter your email"
value={screen.emailInput?.placeholder || ""}
onChange={(e) => updateEmailInput({ placeholder: e.target.value })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Изображение (вариативное)</h4>
<div className="space-y-3">
<TextInput
label="URL изображения"
placeholder="/female-portrait.jpg"
value={screen.image?.src || ""}
onChange={(e) => updateImage({ src: e.target.value })}
/>
</div>
<div className="text-xs text-muted-foreground bg-blue-50 border border-blue-200 rounded-lg p-3 mt-3">
<strong>💡 Вариация изображений:</strong> Базовое изображение настраивается здесь.
Alt текст, размеры (164x245) и стили зашиты в верстку согласно дизайну.
Альтернативные варианты настраиваются в секции &ldquo;Вариативность&rdquo; добавить вариант выбрать условие &ldquo;gender = male&rdquo; переопределить поле image.
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Информация</h4>
<div className="text-xs text-muted-foreground space-y-1">
<p> Банер безопасности отображается автоматически с общим текстом для воронки</p>
<p> PrivacyTermsConsent настраивается через bottomActionButton.showPrivacyTermsConsent</p>
</div>
</div>
</div>
);
}

View File

@ -12,7 +12,7 @@ interface FormScreenConfigProps {
}
export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
const formScreen = screen as FormScreenDefinition & { position: { x: number; y: number } };
const formScreen = screen as FormScreenDefinition;
const updateField = (index: number, updates: Partial<FormFieldDefinition>) => {
const newFields = [...(formScreen.fields || [])];

View File

@ -1,6 +1,7 @@
"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";
@ -10,7 +11,7 @@ interface InfoScreenConfigProps {
}
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
const infoScreen = screen as InfoScreenDefinition & { position: { x: number; y: number } };
const infoScreen = screen as InfoScreenDefinition;
const handleDescriptionChange = (text: string) => {
onUpdate({
@ -47,14 +48,21 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Информационный контент
</h3>
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
<TextInput
placeholder="Введите пояснение для пользователя"
value={infoScreen.description?.text ?? ""}
onChange={(event) => handleDescriptionChange(event.target.value)}
/>
</label>
<div className="space-y-3">
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
<TextInput
placeholder="Введите пояснение для пользователя. Используйте **текст** для выделения жирным."
value={infoScreen.description?.text ?? ""}
onChange={(event) => handleDescriptionChange(event.target.value)}
/>
</label>
{/* 🎨 ПРЕВЬЮ РАЗМЕТКИ */}
{infoScreen.description?.text && (
<MarkupPreview text={infoScreen.description.text} />
)}
</div>
</div>
<div className="space-y-3">

View File

@ -25,7 +25,7 @@ function mutateOptions(
}
export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
const listScreen = screen as ListScreenDefinition & { position: { x: number; y: number } };
const listScreen = screen as ListScreenDefinition;
const [expandedOptions, setExpandedOptions] = useState<Set<number>>(new Set());
const toggleOptionExpanded = (index: number) => {

View File

@ -0,0 +1,167 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Trash2, Plus } from "lucide-react";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { LoadersScreenDefinition } from "@/lib/funnel/types";
interface LoadersScreenConfigProps {
screen: BuilderScreen & { template: "loaders" };
onUpdate: (updates: Partial<LoadersScreenDefinition>) => void;
}
export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigProps) {
const updateProgressbars = (updates: Partial<LoadersScreenDefinition["progressbars"]>) => {
onUpdate({
progressbars: {
items: screen.progressbars?.items || [],
transitionDuration: screen.progressbars?.transitionDuration || 5000,
...updates,
},
});
};
const addProgressbarItem = () => {
const currentItems = screen.progressbars?.items || [];
updateProgressbars({
items: [
...currentItems,
{
title: `Step ${currentItems.length + 1}`,
subtitle: "",
processingTitle: `Processing step ${currentItems.length + 1}...`,
processingSubtitle: "",
completedTitle: `Step ${currentItems.length + 1} completed`,
completedSubtitle: "",
},
],
});
};
const removeProgressbarItem = (index: number) => {
const currentItems = screen.progressbars?.items || [];
updateProgressbars({
items: currentItems.filter((_, i) => i !== index),
});
};
const updateProgressbarItem = (
index: number,
updates: Partial<LoadersScreenDefinition["progressbars"]["items"][0]>
) => {
const currentItems = screen.progressbars?.items || [];
const updatedItems = currentItems.map((item, i) =>
i === index ? { ...item, ...updates } : item
);
updateProgressbars({ items: updatedItems });
};
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Настройки анимации</h4>
<TextInput
label="Длительность анимации (мс)"
type="number"
placeholder="5000"
value={screen.progressbars?.transitionDuration?.toString() || "5000"}
onChange={(e) => updateProgressbars({ transitionDuration: parseInt(e.target.value) || 5000 })}
/>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-slate-700">Шаги загрузки</h4>
<Button
type="button"
variant="outline"
onClick={addProgressbarItem}
className="flex items-center gap-2 text-sm px-3 py-1"
>
<Plus className="w-4 h-4" />
Добавить шаг
</Button>
</div>
<div className="space-y-4">
{(screen.progressbars?.items || []).map((item, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-slate-600">Шаг {index + 1}</h5>
<Button
type="button"
variant="ghost"
onClick={() => removeProgressbarItem(index)}
className="text-red-600 hover:text-red-700 text-sm px-2 py-1"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<TextInput
label="Заголовок"
placeholder="Step 1"
value={item.title || ""}
onChange={(e) => updateProgressbarItem(index, { title: e.target.value })}
/>
<TextInput
label="Подзаголовок"
placeholder="Описание шага"
value={item.subtitle || ""}
onChange={(e) => updateProgressbarItem(index, { subtitle: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextInput
label="Текст во время обработки"
placeholder="Processing..."
value={item.processingTitle || ""}
onChange={(e) => updateProgressbarItem(index, { processingTitle: e.target.value })}
/>
<TextInput
label="Подтекст во время обработки"
placeholder=""
value={item.processingSubtitle || ""}
onChange={(e) => updateProgressbarItem(index, { processingSubtitle: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextInput
label="Текст при завершении"
placeholder="Completed!"
value={item.completedTitle || ""}
onChange={(e) => updateProgressbarItem(index, { completedTitle: e.target.value })}
/>
<TextInput
label="Подтекст при завершении"
placeholder=""
value={item.completedSubtitle || ""}
onChange={(e) => updateProgressbarItem(index, { completedSubtitle: e.target.value })}
/>
</div>
</div>
))}
</div>
{(screen.progressbars?.items || []).length === 0 && (
<div className="text-center py-8 text-slate-500">
<p>Нет шагов загрузки</p>
<Button
type="button"
variant="outline"
onClick={addProgressbarItem}
className="mt-2 text-sm px-3 py-1"
>
Добавить первый шаг
</Button>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import React from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
interface SoulmatePortraitScreenConfigProps {
screen: BuilderScreen & { template: "soulmate" };
onUpdate: (updates: Partial<SoulmatePortraitScreenDefinition>) => void;
}
export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortraitScreenConfigProps) {
const updateDescription = (updates: Partial<SoulmatePortraitScreenDefinition["description"]>) => {
onUpdate({
description: screen.description ? {
...screen.description,
...updates,
} : { text: "", ...updates },
});
};
return (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Описание портрета</h4>
<TextInput
label="Текст описания"
placeholder="Ваш идеальный партнер найден на основе анализа ваших ответов"
value={screen.description?.text || ""}
onChange={(e) => updateDescription({ text: e.target.value })}
/>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Информация</h4>
<div className="text-xs text-muted-foreground">
<p> PrivacyTermsConsent настраивается через bottomActionButton.showPrivacyTermsConsent</p>
</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 mb-2">💡 Назначение экрана</h4>
<p className="text-sm text-blue-700">
Экран &ldquo;Soulmate Portrait&rdquo; предназначен для отображения результатов анализа совместимости
или характеристик идеального партнера на основе ответов пользователя в воронке.
</p>
</div>
</div>
);
}

View File

@ -8,6 +8,9 @@ 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";
@ -18,6 +21,9 @@ import type {
CouponScreenDefinition,
FormScreenDefinition,
ListScreenDefinition,
EmailScreenDefinition,
LoadersScreenDefinition,
SoulmatePortraitScreenDefinition,
TypographyVariant,
BottomActionButtonDefinition,
HeaderDefinition,
@ -260,6 +266,7 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
const isEnabled = value?.show !== false;
const buttonText = value?.text || '';
const cornerRadius = value?.cornerRadius;
const showPrivacyTermsConsent = value?.showPrivacyTermsConsent ?? false;
const handleToggle = (enabled: boolean) => {
if (enabled) {
@ -305,7 +312,23 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
};
// Убираем undefined поля для чистоты
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false) {
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);
@ -349,6 +372,15 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showPrivacyTermsConsent}
onChange={(event) => handlePrivacyTermsToggle(event.target.checked)}
/>
Показывать PrivacyTermsConsent под кнопкой
</label>
</div>
)}
</div>
@ -422,6 +454,24 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
/>
)}
{template === "email" && (
<EmailScreenConfig
screen={screen as BuilderScreen & { template: "email" }}
onUpdate={onUpdate as (updates: Partial<EmailScreenDefinition>) => void}
/>
)}
{template === "loaders" && (
<LoadersScreenConfig
screen={screen as BuilderScreen & { template: "loaders" }}
onUpdate={onUpdate as (updates: Partial<LoadersScreenDefinition>) => void}
/>
)}
{template === "soulmate" && (
<SoulmatePortraitScreenConfig
screen={screen as BuilderScreen & { template: "soulmate" }}
onUpdate={onUpdate as (updates: Partial<SoulmatePortraitScreenDefinition>) => void}
/>
)}
</div>
);
}

View File

@ -0,0 +1,109 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { CouponTemplate } from "./CouponTemplate";
import { fn } from "storybook/test";
import { buildCouponDefaults } from "@/lib/admin/builder/state/defaults/coupon";
import type { CouponScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildCouponDefaults("coupon-screen-story") as CouponScreenDefinition;
/** CouponTemplate - экраны с купонами и промокодами */
const meta: Meta<typeof CouponTemplate> = {
title: "Funnel Templates/CouponTemplate",
component: CouponTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 8, total: 10 },
defaultTexts: {
nextButton: "Next",
continueButton: "Continue"
},
},
argTypes: {
screen: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onContinue: { action: "continue" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтный купон экран */
export const Default: Story = {};
/** Купон с показом прогресса */
export const WithProgress: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: true,
showBackButton: true,
showProgress: true, // Показываем прогресс
},
},
},
};
/** Экран без header */
export const WithoutHeader: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: false,
},
},
},
};
/** Купон с другой скидкой */
export const CustomDiscount: Story = {
args: {
screen: {
...defaultScreen,
coupon: {
...defaultScreen.coupon,
offer: {
title: {
text: "50% OFF",
font: "manrope",
weight: "bold",
align: "center",
size: "3xl",
color: "primary",
},
description: {
text: "Скидка на первую покупку",
font: "inter",
weight: "medium",
color: "muted",
align: "center",
size: "md",
},
},
promoCode: {
text: "FIRST50",
font: "geistMono",
weight: "bold",
align: "center",
size: "lg",
color: "accent",
},
},
},
},
};

View File

@ -2,15 +2,12 @@
import { useState } from "react";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import { Coupon } from "@/components/widgets/Coupon/Coupon";
import Typography from "@/components/ui/Typography/Typography";
import {
buildLayoutQuestionProps,
buildTypographyProps,
} from "@/lib/funnel/mappers";
import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { CouponScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
interface CouponTemplateProps {
screen: CouponScreenDefinition;
@ -33,31 +30,14 @@ export function CouponTemplate({
const handleCopyPromoCode = (code: string) => {
// Copy to clipboard
navigator.clipboard.writeText(code);
setCopiedCode(code);
// Reset copied state after 2 seconds
setTimeout(() => {
setCopiedCode(null);
}, 2000);
};
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "center" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.continueButton || "Continue",
disabled: false,
onClick: onContinue,
},
screenProgress,
});
// Build coupon props from screen definition
const couponProps = {
title: buildTypographyProps(screen.coupon.title, {
as: "h3" as const,
@ -121,14 +101,24 @@ export function CouponTemplate({
};
return (
<LayoutQuestion {...layoutQuestionProps}>
<div className="w-full flex flex-col items-center justify-center mt-[30px]">
{/* Coupon Widget */}
<TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.continueButton || "Continue",
disabled: false,
onClick: onContinue,
}}
>
<div className="w-full flex flex-col items-center justify-center mt-[22px]">
<div className="mb-8">
<Coupon {...couponProps} />
</div>
{/* Copy Success Message */}
{copiedCode && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<Typography
@ -146,6 +136,6 @@ export function CouponTemplate({
</div>
)}
</div>
</LayoutQuestion>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { CouponTemplate } from "./CouponTemplate";

View File

@ -1,271 +0,0 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import NextImage from "next/image";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import Typography from "@/components/ui/Typography/Typography";
import {
buildLayoutQuestionProps,
buildTypographyProps,
} from "@/lib/funnel/mappers";
import type { DateScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
interface DateTemplateProps {
screen: DateScreenDefinition;
selectedDate: { month?: string; day?: string; year?: string };
onDateChange: (date: { month?: string; day?: string; year?: string }) => void;
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string };
}
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
// Generate options for selects
const generateMonthOptions = () => {
return Array.from({ length: 12 }, (_, i) => {
const value = (i + 1).toString();
return { value, label: value.padStart(2, '0') };
});
};
const generateDayOptions = (month: string, year: string) => {
const monthNum = parseInt(month) || 1;
const yearNum = parseInt(year) || new Date().getFullYear();
const daysInMonth = new Date(yearNum, monthNum, 0).getDate();
return Array.from({ length: daysInMonth }, (_, i) => {
const value = (i + 1).toString();
return { value, label: value.padStart(2, '0') };
});
};
const generateYearOptions = () => {
const currentYear = new Date().getFullYear();
const startYear = 1920;
const endYear = currentYear + 1;
const years = [];
for (let year = endYear; year >= startYear; year--) {
years.push({ value: year.toString(), label: year.toString() });
}
return years;
};
export function DateTemplate({
screen,
selectedDate,
onDateChange,
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}: DateTemplateProps) {
const [month, setMonth] = useState(selectedDate.month || "");
const [day, setDay] = useState(selectedDate.day || "");
const [year, setYear] = useState(selectedDate.year || "");
// Generate options with memoization
const monthOptions = useMemo(() => generateMonthOptions(), []);
const dayOptions = useMemo(() => generateDayOptions(month, year), [month, year]);
const yearOptions = useMemo(() => generateYearOptions(), []);
// Custom Select component matching TextInput styling
const SelectInput = ({
label,
value,
onChange,
options,
placeholder
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder: string;
}) => (
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
{label}
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
"w-full px-4 py-3 text-left",
"bg-white border border-slate-200 rounded-xl",
"text-slate-900 placeholder:text-slate-400",
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
"transition-colors duration-200",
"appearance-none cursor-pointer",
"bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNiIgdmlld0JveD0iMCAwIDEwIDYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik05IDFMNS4wMDAwNyA1TDEgMSIgc3Ryb2tlPSIjNjQ3NDhCIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=')] bg-no-repeat bg-right-3 bg-center",
"pr-10"
)}
>
<option value="" disabled>
{placeholder}
</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
// Update parent when local state changes
useEffect(() => {
onDateChange({ month, day, year });
}, [month, day, year, onDateChange]);
// Reset day if it's invalid for the selected month/year
useEffect(() => {
if (month && year && day) {
const monthNum = parseInt(month);
const yearNum = parseInt(year);
const dayNum = parseInt(day);
const daysInMonth = new Date(yearNum, monthNum, 0).getDate();
if (dayNum > daysInMonth) {
setDay("");
}
}
}, [month, year, day]);
// Sync with external state
useEffect(() => {
setMonth(selectedDate.month || "");
setDay(selectedDate.day || "");
setYear(selectedDate.year || "");
}, [selectedDate]);
const isComplete = month && day && year;
const formattedDate = useMemo(() => {
if (!month || !day || !year) return null;
const monthNum = parseInt(month);
const dayNum = parseInt(day);
const yearNum = parseInt(year);
if (monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31) {
const monthName = MONTH_NAMES[monthNum - 1];
return `${monthName} ${dayNum}, ${yearNum}`;
}
return null;
}, [month, day, year]);
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.nextButton || "Next",
disabled: !isComplete,
onClick: onContinue,
},
screenProgress,
});
return (
<LayoutQuestion {...layoutQuestionProps}>
<div className="w-full mt-[30px] space-y-6">
{/* Date Input Fields */}
<div className="space-y-4">
<div className="grid grid-cols-[1fr_1fr_1.2fr] gap-3">
<SelectInput
label={screen.dateInput.monthLabel || "Month"}
placeholder={screen.dateInput.monthPlaceholder || "MM"}
value={month}
onChange={setMonth}
options={monthOptions}
/>
<SelectInput
label={screen.dateInput.dayLabel || "Day"}
placeholder={screen.dateInput.dayPlaceholder || "DD"}
value={day}
onChange={setDay}
options={dayOptions}
/>
<SelectInput
label={screen.dateInput.yearLabel || "Year"}
placeholder={screen.dateInput.yearPlaceholder || "YYYY"}
value={year}
onChange={setYear}
options={yearOptions}
/>
</div>
</div>
{/* Info Message */}
{screen.infoMessage && (
<div className="flex justify-center">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
<NextImage
src="/GuardIcon.svg"
alt="Security icon"
width={20}
height={20}
className="object-contain"
/>
</div>
<Typography
as="p"
size="sm"
color="default"
{...buildTypographyProps(screen.infoMessage, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "default",
align: "left",
},
})}
className={cn("text-slate-600 leading-relaxed", screen.infoMessage.className)}
>
{screen.infoMessage.text}
</Typography>
</div>
</div>
)}
</div>
{/* Selected Date Display - positioned 18px above button with high z-index */}
{screen.dateInput.showSelectedDate && formattedDate && (
<div className="fixed bottom-[98px] left-0 right-0 text-center z-50">
<div className="max-w-[560px] mx-auto px-6">
<Typography
as="p"
className="text-[#64748B] text-[16px] font-normal leading-normal mb-1"
>
{screen.dateInput.selectedDateLabel || "Selected date:"}
</Typography>
<Typography
as="p"
className="text-[#1E293B] text-[18px] font-semibold leading-normal"
>
{formattedDate}
</Typography>
</div>
</div>
)}
</LayoutQuestion>
);
}

View File

@ -0,0 +1,133 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { DateTemplate } from "./DateTemplate";
import { fn } from "storybook/test";
import { buildDateDefaults } from "@/lib/admin/builder/state/defaults/date";
import type { DateScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildDateDefaults("date-screen-story") as DateScreenDefinition;
/** DateTemplate - экраны с выбором даты рождения */
const meta: Meta<typeof DateTemplate> = {
title: "Funnel Templates/DateTemplate",
component: DateTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: defaultScreen,
selectedDate: {},
onDateChange: fn(),
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 4, total: 10 },
defaultTexts: {
nextButton: "Next",
continueButton: "Continue"
},
},
argTypes: {
screen: {
control: { type: "object" },
},
selectedDate: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onDateChange: { action: "date changed" },
onContinue: { action: "continue" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтный экран выбора даты */
export const Default: Story = {};
/** Экран с предзаполненной датой */
export const WithPrefilledDate: Story = {
args: {
selectedDate: {
month: "4",
day: "8",
year: "1987",
},
},
};
/** Экран без показа выбранной даты */
export const WithoutSelectedDate: Story = {
args: {
screen: {
...defaultScreen,
dateInput: {
...defaultScreen.dateInput,
showSelectedDate: false,
},
},
},
};
/** Экран с кастомными лейблами */
export const CustomLabels: Story = {
args: {
screen: {
...defaultScreen,
dateInput: {
...defaultScreen.dateInput,
monthLabel: "Month",
dayLabel: "Day",
yearLabel: "Year",
monthPlaceholder: "MM",
dayPlaceholder: "DD",
yearPlaceholder: "YYYY",
selectedDateLabel: "Selected date:",
selectedDateFormat: "MMMM d, yyyy",
},
},
},
};
/** Экран без информационного сообщения */
export const WithoutInfoMessage: Story = {
args: {
screen: {
...defaultScreen,
infoMessage: undefined,
},
},
};
/** Экран с включенным зодиаком */
export const WithZodiac: Story = {
args: {
screen: {
...defaultScreen,
dateInput: {
...defaultScreen.dateInput,
zodiac: {
enabled: true,
storageKey: "zodiac_sign",
},
},
},
},
};
/** Экран без header */
export const WithoutHeader: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: false,
},
},
},
};

View File

@ -0,0 +1,176 @@
"use client";
import { useMemo } from "react";
import Image from "next/image";
import DateInput from "@/components/widgets/DateInput/DateInput";
import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { DateScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
import { TemplateLayout } from "../layouts/TemplateLayout";
// Утилита для форматирования даты на основе паттерна
function formatDateByPattern(date: Date, pattern: string): string {
const monthNames = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
return pattern
.replace("MMMM", monthNames[date.getMonth()])
.replace("MMM", monthNames[date.getMonth()].substring(0, 3))
.replace("yyyy", date.getFullYear().toString())
.replace("dd", date.getDate().toString().padStart(2, '0'))
.replace("d", date.getDate().toString());
}
interface DateTemplateProps {
screen: DateScreenDefinition;
selectedDate: { month?: string; day?: string; year?: string };
onDateChange: (date: { month?: string; day?: string; year?: string }) => void;
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string };
}
export function DateTemplate({
screen,
selectedDate,
onDateChange,
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}: DateTemplateProps) {
const isoDate = useMemo(() => {
const { month, day, year } = selectedDate;
if (!month || !day || !year) return null;
const monthNum = parseInt(month);
const dayNum = parseInt(day);
const yearNum = parseInt(year);
if (monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31 && yearNum > 1900) {
return `${yearNum}-${monthNum.toString().padStart(2, '0')}-${dayNum.toString().padStart(2, '0')}`;
}
return null;
}, [selectedDate]);
const handleDateChange = (newIsoDate: string | null) => {
if (!newIsoDate) {
onDateChange({ month: "", day: "", year: "" });
return;
}
const match = newIsoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
onDateChange({ month: "", day: "", year: "" });
return;
}
const [, year, month, day] = match;
onDateChange({
month: parseInt(month).toString(),
day: parseInt(day).toString(),
year: year
});
};
const isFormValid = Boolean(isoDate);
// Форматированная дата для отображения
const formattedDate = useMemo(() => {
if (!isoDate) return null;
const date = new Date(isoDate);
const pattern = screen.dateInput?.selectedDateFormat || "MMMM d, yyyy";
return formatDateByPattern(date, pattern);
}, [isoDate, screen.dateInput?.selectedDateFormat]);
// Компонент отображения выбранной даты над кнопкой
const selectedDateDisplay = formattedDate && screen.dateInput?.showSelectedDate !== false ? (
<div className="text-center space-y-1 mb-4">
<Typography
as="p"
size="sm"
color="muted"
className="font-medium"
>
{screen.dateInput?.selectedDateLabel || "Выбранная дата:"}
</Typography>
<Typography
as="p"
size="xl"
weight="bold"
color="default"
className="font-semibold"
>
{formattedDate}
</Typography>
</div>
) : null;
return (
<TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.nextButton || "Next",
disabled: !isFormValid,
onClick: onContinue,
}}
childrenUnderButton={selectedDateDisplay}
>
<div className="w-full mt-[22px] space-y-6">
<DateInput
value={isoDate}
onChange={handleDateChange}
maxYear={new Date().getFullYear() - 11}
yearsRange={100}
locale="en"
/>
{screen.infoMessage && (
<div className="flex justify-center">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
<Image
src="/GuardIcon.svg"
alt="Security icon"
width={20}
height={20}
className="object-contain"
/>
</div>
<Typography
as="p"
size="sm"
color="default"
{...buildTypographyProps(screen.infoMessage, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "default",
align: "left",
},
})}
className={cn("text-slate-600 leading-relaxed", screen.infoMessage.className)}
>
{screen.infoMessage.text}
</Typography>
</div>
</div>
)}
</div>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { DateTemplate } from "./DateTemplate";

View File

@ -0,0 +1,93 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { EmailTemplate } from "./EmailTemplate";
import { fn } from "storybook/test";
import { buildEmailDefaults } from "@/lib/admin/builder/state/defaults/email";
import type { EmailScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildEmailDefaults("email-screen-story") as EmailScreenDefinition;
/** EmailTemplate - экраны сбора email адреса */
const meta: Meta<typeof EmailTemplate> = {
title: "Funnel Templates/EmailTemplate",
component: EmailTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 9, total: 10 },
defaultTexts: {
nextButton: "Next",
continueButton: "Continue"
},
},
argTypes: {
screen: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onContinue: { action: "continue" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтный email экран */
export const Default: Story = {};
/** Экран без изображения */
export const WithoutImage: Story = {
args: {
screen: {
...defaultScreen,
image: undefined,
},
},
};
/** Экран с consent */
export const WithConsent: Story = {
args: {
screen: {
...defaultScreen,
bottomActionButton: {
...defaultScreen.bottomActionButton,
showPrivacyTermsConsent: true,
},
},
},
};
/** Экран без header */
export const WithoutHeader: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: false,
},
},
},
};
/** Экран с кастомными лейблами */
export const CustomLabels: Story = {
args: {
screen: {
...defaultScreen,
emailInput: {
label: "Your Email Address",
placeholder: "Enter your email here...",
},
},
},
};

View File

@ -0,0 +1,112 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { EmailScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email({
message: "Please enter a valid email address",
}),
});
interface EmailTemplateProps {
screen: EmailScreenDefinition;
selectedEmail: string;
onEmailChange: (email: string) => void;
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: DefaultTexts;
}
export function EmailTemplate({
screen,
selectedEmail,
onEmailChange,
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}: EmailTemplateProps) {
const [isTouched, setIsTouched] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: selectedEmail || "",
},
});
useEffect(() => {
form.setValue("email", selectedEmail || "");
}, [selectedEmail, form]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
form.setValue("email", value);
form.trigger("email");
onEmailChange(value);
};
const isFormValid = form.formState.isValid && form.getValues("email");
return (
<TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.nextButton || "Continue",
disabled: !isFormValid,
onClick: onContinue,
}}
>
<div className="w-full flex flex-col items-center gap-[26px]">
<TextInput
label={screen.emailInput?.label || "Email"}
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
type="email"
value={selectedEmail}
onChange={handleChange}
onBlur={() => {
setIsTouched(true);
form.trigger("email");
}}
aria-invalid={isTouched && !!form.formState.errors.email}
aria-errormessage={
isTouched ? form.formState.errors.email?.message : undefined
}
/>
{screen.image && (
<Image
src={screen.image.src}
alt="portrait"
width={164}
height={245}
className="mt-3.5 rounded-[50px] blur-sm"
/>
)}
<PrivacySecurityBanner
className="mt-[26px]"
text={{
children: defaultTexts?.privacyBanner || "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
}}
/>
</div>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { EmailTemplate } from "./EmailTemplate";

View File

@ -0,0 +1,152 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { FormTemplate } from "./FormTemplate";
import { fn } from "storybook/test";
import { buildFormDefaults } from "@/lib/admin/builder/state/defaults/form";
import type { FormScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildFormDefaults("form-screen-story") as FormScreenDefinition;
// Создаем более богатую форму для демонстрации
const richFormScreen: FormScreenDefinition = {
...defaultScreen,
title: {
...defaultScreen.title,
text: "Расскажите о себе",
},
subtitle: {
...defaultScreen.subtitle,
text: "Заполните форму для персонализированного анализа",
},
fields: [
{
id: "name",
label: "Полное имя",
placeholder: "Введите ваше имя",
type: "text",
required: true,
maxLength: 50,
},
{
id: "email",
label: "Email адрес",
placeholder: "example@email.com",
type: "email",
required: true,
},
{
id: "phone",
label: "Телефон",
placeholder: "+7 (999) 123-45-67",
type: "tel",
required: false,
},
{
id: "website",
label: "Веб-сайт",
placeholder: "https://example.com",
type: "url",
required: false,
},
],
};
/** FormTemplate - экраны с формами ввода */
const meta: Meta<typeof FormTemplate> = {
title: "Funnel Templates/FormTemplate",
component: FormTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: richFormScreen,
formData: {},
onFormDataChange: fn(),
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 6, total: 10 },
defaultTexts: {
nextButton: "Next",
continueButton: "Continue"
},
},
argTypes: {
screen: {
control: { type: "object" },
},
formData: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onFormDataChange: { action: "form data changed" },
onContinue: { action: "continue" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтная форма */
export const Default: Story = {};
/** Простая форма с одним полем */
export const SimpleForm: Story = {
args: {
screen: defaultScreen, // Используем базовые дефолты
},
};
/** Форма только с обязательными полями */
export const RequiredFieldsOnly: Story = {
args: {
screen: {
...richFormScreen,
fields: richFormScreen.fields.filter(field => field.required),
},
},
};
/** Форма с кастомными сообщениями валидации */
export const CustomValidation: Story = {
args: {
screen: {
...richFormScreen,
validationMessages: {
required: "Пожалуйста, заполните это поле",
maxLength: "Слишком длинное значение",
invalidFormat: "Неправильный формат данных",
},
},
},
};
/** Форма без header */
export const WithoutHeader: Story = {
args: {
screen: {
...richFormScreen,
header: {
show: false,
},
},
},
};
/** Форма без subtitle */
export const WithoutSubtitle: Story = {
args: {
screen: {
...richFormScreen,
subtitle: {
...richFormScreen.subtitle,
show: false,
text: richFormScreen.subtitle?.text || "",
},
},
},
};

View File

@ -2,13 +2,10 @@
import { useState, useEffect } from "react";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import {
buildLayoutQuestionProps,
} from "@/lib/funnel/mappers";
import type { FormScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
interface FormTemplateProps {
screen: FormScreenDefinition;
@ -34,12 +31,10 @@ export function FormTemplate({
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
const [errors, setErrors] = useState<Record<string, string>>({});
// Sync with external form data
useEffect(() => {
setLocalFormData(formData);
}, [formData]);
// Update external form data when local data changes
useEffect(() => {
onFormDataChange(localFormData);
}, [localFormData, onFormDataChange]);
@ -69,7 +64,6 @@ export function FormTemplate({
const handleFieldChange = (fieldId: string, value: string) => {
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
// Clear error if field becomes valid
if (errors[fieldId]) {
setErrors(prev => {
const newErrors = { ...prev };
@ -108,23 +102,21 @@ export function FormTemplate({
return true;
});
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.continueButton || "Continue",
disabled: !isFormComplete,
onClick: handleContinue,
},
screenProgress,
});
return (
<LayoutQuestion {...layoutQuestionProps}>
<div className="w-full mt-[30px] space-y-4">
<TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.continueButton || "Continue",
disabled: !isFormComplete,
onClick: handleContinue,
}}
>
<div className="w-full mt-[22px] space-y-4">
{screen.fields.map((field) => (
<div key={field.id}>
<TextInput
@ -145,6 +137,6 @@ export function FormTemplate({
</div>
))}
</div>
</LayoutQuestion>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { FormTemplate } from "./FormTemplate";

View File

@ -0,0 +1,95 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { InfoTemplate } from "./InfoTemplate";
import { fn } from "storybook/test";
import { buildInfoDefaults } from "@/lib/admin/builder/state/defaults/info";
import type { InfoScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildInfoDefaults("info-screen-story") as InfoScreenDefinition;
/** InfoTemplate - информационные экраны с иконкой и описанием */
const meta: Meta<typeof InfoTemplate> = {
title: "Funnel Templates/InfoTemplate",
component: InfoTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 3, total: 10 },
defaultTexts: {
nextButton: "Next",
continueButton: "Continue"
},
},
argTypes: {
screen: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onContinue: { action: "continue" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтный информационный экран */
export const Default: Story = {};
/** Экран без иконки */
export const WithoutIcon: Story = {
args: {
screen: {
...defaultScreen,
icon: undefined,
},
},
};
/** Экран с кастомной иконкой */
export const WithCustomIcon: Story = {
args: {
screen: {
...defaultScreen,
icon: {
type: "emoji",
value: "🎯",
size: "lg",
},
},
},
};
/** Экран без header */
export const WithoutHeader: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: false,
},
},
},
};
/** Экран с скрытым прогрессом */
export const WithoutProgress: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: true,
showBackButton: true,
showProgress: false,
},
},
},
};

View File

@ -2,15 +2,10 @@
import { useMemo } from "react";
import Image from "next/image";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import Typography from "@/components/ui/Typography/Typography";
import {
buildLayoutQuestionProps,
buildTypographyProps,
} from "@/lib/funnel/mappers";
import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { InfoScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { cn } from "@/lib/utils";
interface InfoTemplateProps {
@ -30,37 +25,39 @@ export function InfoTemplate({
screenProgress,
defaultTexts,
}: InfoTemplateProps) {
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.nextButton || "Next",
disabled: false,
onClick: onContinue,
},
screenProgress,
});
const iconSizeClasses = useMemo(() => {
const size = screen.icon?.size ?? "xl";
switch (size) {
case "sm":
return "text-4xl"; // 36px
return "text-4xl";
case "md":
return "text-5xl"; // 48px
return "text-5xl";
case "lg":
return "text-6xl"; // 60px
return "text-6xl";
case "xl":
default:
return "text-8xl"; // 128px
return "text-8xl";
}
}, [screen.icon?.size]);
return (
<LayoutQuestion {...layoutQuestionProps}>
<div className="w-full flex flex-col items-center justify-center text-center mt-[60px]">
<TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "center" }}
subtitleDefaults={{ font: "inter", weight: "medium", color: "muted", align: "center" }}
actionButtonOptions={{
defaultText: defaultTexts?.nextButton || "Next",
disabled: false,
onClick: onContinue,
}}
>
<div className={cn(
"w-full flex flex-col items-center justify-center text-center",
screen.icon ? "mt-[60px]" : "-mt-[20px]"
)}>
{/* Icon */}
{screen.icon && (
<div className={cn("mb-8", screen.icon.className)}>
@ -88,11 +85,12 @@ export function InfoTemplate({
</div>
)}
{/* Title - handled by LayoutQuestion */}
{/* Description */}
{screen.description && (
<div className="mt-6 max-w-[280px]">
<div className={cn(
"max-w-[280px]",
screen.icon ? "mt-6" : "mt-0"
)}>
<Typography
as="p"
font="inter"
@ -116,6 +114,6 @@ export function InfoTemplate({
</div>
)}
</div>
</LayoutQuestion>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { InfoTemplate } from "./InfoTemplate";

View File

@ -0,0 +1,144 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { ListTemplate } from "./ListTemplate";
import { fn } from "storybook/test";
import { buildListDefaults } from "@/lib/admin/builder/state/defaults/list";
import type { ListScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildListDefaults("list-screen-story") as ListScreenDefinition;
// Более богатый список опций для демонстрации
const richOptionsScreen: ListScreenDefinition = {
...defaultScreen,
title: {
...defaultScreen.title,
text: "Выберите ваш знак зодиака",
},
list: {
...defaultScreen.list,
options: [
{ id: "aries", label: "Овен", emoji: "♈" },
{ id: "taurus", label: "Телец", emoji: "♉" },
{ id: "gemini", label: "Близнецы", emoji: "♊" },
{ id: "cancer", label: "Рак", emoji: "♋" },
{ id: "leo", label: "Лев", emoji: "♌" },
{ id: "virgo", label: "Дева", emoji: "♍" },
],
},
};
/** ListTemplate - экраны с выбором опций (single/multi selection) */
const meta: Meta<typeof ListTemplate> = {
title: "Funnel Templates/ListTemplate",
component: ListTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: richOptionsScreen,
selectedOptionIds: [],
onSelectionChange: fn(),
actionButtonProps: {
children: "Continue",
onClick: fn(),
},
canGoBack: true,
onBack: fn(),
screenProgress: { current: 5, total: 10 },
},
argTypes: {
screen: {
control: { type: "object" },
},
selectedOptionIds: {
control: { type: "object" },
},
actionButtonProps: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onSelectionChange: { action: "selection changed" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтный single selection список (кнопка disabled пока не выбрано) */
export const SingleSelection: Story = {};
/** Multi selection список (кнопка disabled пока не выбрано) */
export const MultiSelection: Story = {
args: {
screen: {
...richOptionsScreen,
title: {
...richOptionsScreen.title,
text: "Выберите несколько вариантов",
},
list: {
...richOptionsScreen.list,
selectionType: "multi",
},
},
},
};
/** Single selection с предвыбранным значением (кнопка активна) */
export const WithPreselection: Story = {
args: {
selectedOptionIds: ["leo"],
},
};
/** Multi selection с несколькими выбранными (кнопка активна) */
export const MultiWithPreselection: Story = {
args: {
screen: {
...richOptionsScreen,
list: {
...richOptionsScreen.list,
selectionType: "multi",
},
},
selectedOptionIds: ["aries", "leo", "virgo"],
},
};
/** Single selection с автопереходом (без кнопки) */
export const SingleAutoAdvance: Story = {
args: {
screen: {
...richOptionsScreen,
bottomActionButton: {
show: false, // Автопереход при выборе
},
},
actionButtonProps: undefined, // Нет кнопки
},
};
/** Список без header */
export const WithoutHeader: Story = {
args: {
screen: {
...richOptionsScreen,
header: {
show: false,
},
},
},
};
/** Простой список без эмодзи */
export const SimpleList: Story = {
args: {
screen: {
...defaultScreen, // Используем базовые дефолты без эмодзи
},
},
};

View File

@ -1,18 +1,15 @@
"use client";
import { useMemo } from "react";
import { Question } from "@/components/templates/Question/Question";
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 {
buildLayoutQuestionProps,
mapListOptionsToButtons,
} from "@/lib/funnel/mappers";
import { mapListOptionsToButtons } from "@/lib/funnel/mappers";
import type { ListScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
interface ListTemplateProps {
screen: ListScreenDefinition;
@ -40,7 +37,6 @@ export function ListTemplate({
onBack,
screenProgress,
}: ListTemplateProps) {
const buttons = useMemo(
() => mapListOptionsToButtons(screen.list.options, screen.list.selectionType),
[screen.list.options, screen.list.selectionType]
@ -95,10 +91,10 @@ export function ListTemplate({
onChangeSelectedAnswers: handleSelectChange,
};
// Определяем action button options для centralized логики только если кнопка нужна
const actionButtonOptions = actionButtonProps ? {
defaultText: actionButtonProps.children as string || "Next",
disabled: actionButtonProps.disabled || false,
// Кнопка неактивна если: 1) disabled из props ИЛИ 2) ничего не выбрано
disabled: actionButtonProps.disabled || selectedOptionIds.length === 0,
onClick: () => {
if (actionButtonProps.onClick) {
actionButtonProps.onClick({} as React.MouseEvent<HTMLButtonElement>);
@ -106,26 +102,23 @@ export function ListTemplate({
},
} : undefined;
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
canGoBack,
onBack,
actionButtonOptions: actionButtonOptions,
screenProgress,
});
const contentProps =
contentType === "radio-answers-list" ? radioContent : selectContent;
return (
<Question
layoutQuestionProps={layoutQuestionProps}
contentType={contentType}
content={contentProps}
/>
<TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={actionButtonOptions}
>
<div className="w-full mt-[22px]">
{contentType === "radio-answers-list" ? (
<RadioAnswersList {...radioContent} />
) : (
<SelectAnswersList {...selectContent} />
)}
</div>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { ListTemplate } from "./ListTemplate";

View File

@ -0,0 +1,112 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { LoadersTemplate } from "./LoadersTemplate";
import { fn } from "storybook/test";
import { buildLoadersDefaults } from "@/lib/admin/builder/state/defaults/loaders";
import type { LoadersScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildLoadersDefaults("loaders-screen-story") as LoadersScreenDefinition;
/** LoadersTemplate - экраны с анимированными загрузчиками */
const meta: Meta<typeof LoadersTemplate> = {
title: "Funnel Templates/LoadersTemplate",
component: LoadersTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: false, // Обычно на лоадерах нет кнопки назад
onBack: fn(),
screenProgress: undefined, // У лоадеров обычно нет прогресса
defaultTexts: {
nextButton: "Next",
continueButton: "Continue"
},
},
argTypes: {
screen: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onContinue: { action: "continue" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтный loaders экран */
export const Default: Story = {};
/** Лоадеры с быстрой анимацией */
export const FastAnimation: Story = {
args: {
screen: {
...defaultScreen,
progressbars: {
...defaultScreen.progressbars,
transitionDuration: 1000, // Быстрее
},
},
},
};
/** Лоадеры с медленной анимацией */
export const SlowAnimation: Story = {
args: {
screen: {
...defaultScreen,
progressbars: {
...defaultScreen.progressbars,
transitionDuration: 5000, // Медленнее
},
},
},
};
/** Лоадеры с кастомными сообщениями */
export const CustomMessages: Story = {
args: {
screen: {
...defaultScreen,
progressbars: {
...defaultScreen.progressbars,
items: [
{
processingTitle: "Анализируем ваши ответы...",
processingSubtitle: "Обработка данных",
completedTitle: "Анализ завершен",
completedSubtitle: "Готово!",
},
{
processingTitle: "Создаем персональный портрет...",
processingSubtitle: "Генерация",
completedTitle: "Портрет готов",
completedSubtitle: "Завершено",
},
],
},
},
},
};
/** Лоадеры с header (необычно, но возможно) */
export const WithHeader: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: true,
showBackButton: false,
showProgress: true,
},
},
screenProgress: { current: 7, total: 10 },
},
};

View File

@ -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 (
<TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.nextButton || "Continue",
disabled: !isVisibleButton,
onClick: onContinue,
}}
>
<div className="w-full flex flex-col items-center gap-[22px] mt-[22px]">
<CircularProgressbarsList
{...progressbarsListProps}
showDividers={false}
/>
</div>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { LoadersTemplate } from "./LoadersTemplate";

View File

@ -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<typeof SoulmatePortraitTemplate> = {
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<typeof meta>;
/** Дефолтный 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,
},
};

View File

@ -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 (
<TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "xl", color: "primary" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.nextButton || "Continue",
disabled: false,
onClick: onContinue,
}}
>
<div className="-mt-[20px]">
</div>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate";

View File

@ -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";

View File

@ -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<typeof TemplateLayout> = {
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: (
<div className="w-full flex flex-col items-center gap-4 py-8">
<div className="w-full max-w-sm text-center">
<p className="text-muted-foreground mb-4">
Это контент, который передается в TemplateLayout через children prop.
</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="p-3 bg-muted/20 rounded-lg">
<strong>Header:</strong> управляется TemplateLayout
</div>
<div className="p-3 bg-muted/20 rounded-lg">
<strong>Content:</strong> передается через children
</div>
<div className="p-3 bg-muted/20 rounded-lg">
<strong>Button:</strong> управляется TemplateLayout
</div>
<div className="p-3 bg-muted/20 rounded-lg">
<strong>Blur:</strong> управляется TemplateLayout
</div>
</div>
</div>
</div>
),
},
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<typeof meta>;
/** Дефолтный 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: (
<div className="w-full py-16 text-center">
<h1 className="text-2xl font-bold mb-4">Минимальный Layout</h1>
<p className="text-muted-foreground">
Только контент, без header и нижней кнопки
</p>
</div>
),
},
};

View File

@ -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<HTMLDivElement>({
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 ? (
<PrivacyTermsConsent
className="mt-5"
privacyPolicy={{
href: "/privacy",
children: "Privacy Policy"
}}
termsOfUse={{
href: "/terms",
children: "Terms of use"
}}
/>
) : null;
// Комбинируем переданный childrenUnderButton с автоматическим PrivacyTermsConsent
const finalChildrenUnderButton = (
<>
{childrenUnderButton}
{autoPrivacyTermsConsent}
</>
);
// 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ
return (
<div className="w-full">
<LayoutQuestion {...layoutQuestionProps}>
{children}
</LayoutQuestion>
{bottomActionButtonProps && (
<BottomActionButton
{...bottomActionButtonProps}
ref={bottomActionButtonRef}
childrenUnderButton={finalChildrenUnderButton}
/>
)}
</div>
);
}

View File

@ -0,0 +1,2 @@
// Layout components - base layouts and structural components
export { TemplateLayout } from "./TemplateLayout";

View File

@ -22,17 +22,20 @@ function Header({
return (
<header className={cn("w-full p-6 pb-3 min-h-[96px]", className)} {...props}>
<div className="w-full flex justify-left items-center min-h-9">
<div className="w-full flex justify-start items-center min-h-9">
{shouldRenderBackButton && (
<Button
type="button"
variant="ghost"
className="hover:bg-transparent rounded-full p-0! ml-[-13px] mb-[-9px]"
className="hover:bg-transparent rounded-full !p-0 -ml-[13px] -mb-[9px]"
onClick={onBack}
aria-label="Назад"
>
<ChevronLeft size={36} />
</Button>
)}
</div>
{progressProps && (
<div className="w-full flex justify-center items-center mt-3">
<Progress {...progressProps} />
@ -42,4 +45,4 @@ function Header({
);
}
export { Header };
export { Header };

View File

@ -26,15 +26,10 @@ const meta: Meta<typeof LayoutQuestion> = {
children: "Let's personalize your hair care journey",
},
children: (
<div className="w-full mt-[30px] text-center p-8 bg-secondary">
<div className="w-full flex flex-col justify-center items-center mt-[30px]">
Children
</div>
),
bottomActionButtonProps: {
actionButtonProps: {
children: "Continue",
},
},
},
argTypes: {},
};

View File

@ -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<React.ComponentProps<"section">, "title" | "content"> {
headerProps?: React.ComponentProps<typeof Header>;
title: TypographyProps<"h2">;
title?: TypographyProps<"h2">;
subtitle?: TypographyProps<"p">;
children: React.ReactNode;
bottomActionButtonProps?: BottomActionButtonProps;
contentProps?: React.ComponentProps<"div">;
childrenWrapperProps?: React.ComponentProps<"div">;
}
function LayoutQuestion({
@ -26,32 +20,29 @@ function LayoutQuestion({
title,
subtitle,
children,
bottomActionButtonProps,
contentProps,
childrenWrapperProps,
...props
}: LayoutQuestionProps) {
const bottomActionButtonRef = useRef<HTMLDivElement | null>(null);
const [bottomActionButtonHeight, setBottomActionButtonHeight] =
useState<number>(132);
useEffect(() => {
if (bottomActionButtonRef.current) {
console.log(bottomActionButtonRef.current.clientHeight);
setBottomActionButtonHeight(bottomActionButtonRef.current.clientHeight);
}
}, [bottomActionButtonProps]);
return (
<section
className={cn(`block min-h-dvh w-full`, className)}
className={cn("block min-h-dvh w-full", className)}
{...props}
// Безопаснее, чем JS-константа: если CSS-переменная не задана — будет 0px
style={{
paddingBottom: `${bottomActionButtonHeight}px`,
...props.style,
paddingBottom: "var(--bottom-action-button-height, 0px)",
...(props.style ?? {}),
}}
>
{headerProps && <Header {...headerProps} />}
<div className="w-full flex flex-col justify-center items-center p-6 pt-[30px]">
<div
{...contentProps}
className={cn(
"w-full flex flex-col justify-center items-center p-6 pt-[30px]",
contentProps?.className
)}
>
{title && (
<Typography
as="h2"
@ -62,6 +53,7 @@ function LayoutQuestion({
className={cn(title.className, "w-full text-[25px] leading-[38px]")}
/>
)}
{subtitle && (
<Typography
as="p"
@ -74,17 +66,16 @@ function LayoutQuestion({
)}
/>
)}
{children}
{bottomActionButtonProps && (
<BottomActionButton
{...bottomActionButtonProps}
className="max-w-[560px]"
ref={bottomActionButtonRef}
/>
)}
<div
{...childrenWrapperProps}
className={cn("w-full mt-[30px]", childrenWrapperProps?.className)}
>
{children}
</div>
</div>
</section>
);
}
export { LayoutQuestion };
export { LayoutQuestion };

View File

@ -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<LayoutQuestionProps, "children"> = {
headerProps: {
onBack: fn(),
},
title: {
children: "Тебе повезло!",
align: "center",
},
subtitle: {
children: "Ты получил специальную эксклюзивную скидку на 94%",
align: "center",
},
};
/** Reusable Coupon page Component */
const meta: Meta<typeof Coupon> = {
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: (
<>
Скопируйте или нажмите <b>Continue</b>
</>
),
},
onCopyPromoCode: fn(),
},
bottomActionButtonProps: {
actionButtonProps: {
children: "Continue",
onClick: fn(),
},
},
},
argTypes: {
bottomActionButtonProps: {
control: { type: "object" },
},
},
render: (args) => {
return (
<LayoutQuestion {...layoutQuestionProps}>
<Coupon {...args} />
</LayoutQuestion>
);
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story;

View File

@ -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<React.ComponentProps<"div">, "title"> {
couponProps: React.ComponentProps<typeof CouponWidget>;
bottomActionButtonProps?: BottomActionButtonProps;
}
function Coupon({
couponProps,
bottomActionButtonProps,
...props
}: CouponProps) {
const {
height: bottomActionButtonHeight,
elementRef: bottomActionButtonRef,
} = useDynamicSize<HTMLDivElement>({
defaultHeight: 132,
});
return (
<div
className="w-full flex flex-col items-center gap-[22px]"
{...props}
style={{
paddingBottom: `${bottomActionButtonHeight}px`,
...props.style,
}}
>
{/* {title && (
<Typography
as="h2"
weight="bold"
font="manrope"
{...title}
className={cn(title.className, "text-[25px] leading-[38px]")}
/>
)}
{subtitle && (
<Typography
as="p"
weight="medium"
font="inter"
{...subtitle}
className={cn(subtitle.className, "text-[17px] leading-[26px]")}
/>
)} */}
<CouponWidget {...couponProps} />
{bottomActionButtonProps && (
<BottomActionButton
{...bottomActionButtonProps}
className={cn(
"max-w-[560px] z-10",
bottomActionButtonProps.className
)}
ref={bottomActionButtonRef}
/>
)}
</div>
);
}
export { Coupon };

Some files were not shown because too many files have changed in this diff Show More