diff --git a/AGE_SYSTEM.md b/AGE_SYSTEM.md new file mode 100644 index 0000000..84fb726 --- /dev/null +++ b/AGE_SYSTEM.md @@ -0,0 +1,292 @@ +# 🎂 Система работы с возрастом WitLab Funnel + +## Описание + +Универсальная система автоматического расчета возраста и определения поколений из даты рождения пользователя. Интегрируется с системой вариативности для создания возрастных условий навигации. + +## Возможности + +### 🎯 **Автоматический расчет из даты:** +- **Точный возраст** (например: 25, 30, 45 лет) +- **Возрастные группы** (18-21, 22-25, 26-30, 31-35, 36-40, 41-45, 46-50, 51-60, 60+) +- **Поколения** (Generation Z, Millennials, Generation X, Boomers, Silent Generation) +- **Комбинации** со знаками зодиака + +### 🎨 **Красивый UI для админки:** +- Селектор возрастных групп с описаниями +- Селектор поколений с иконками +- Возможность добавлять кастомные диапазоны +- Живое превью выбранных условий + +## Технические детали + +### 📊 **Возрастные группы:** +```typescript +export const AGE_GROUPS = [ + { id: "18-21", name: "18-21 год", min: 18, max: 21, description: "Студенческий возраст" }, + { id: "22-25", name: "22-25 лет", min: 22, max: 25, description: "Молодые профессионалы" }, + { id: "26-30", name: "26-30 лет", min: 26, max: 30, description: "Карьерный рост" }, + { id: "31-35", name: "31-35 лет", min: 31, max: 35, description: "Становление личности" }, + { id: "36-40", name: "36-40 лет", min: 36, max: 40, description: "Зрелость и стабильность" }, + { id: "41-45", name: "41-45 лет", min: 41, max: 45, description: "Средний возраст" }, + { id: "46-50", name: "46-50 лет", min: 46, max: 50, description: "Жизненный опыт" }, + { id: "51-60", name: "51-60 лет", min: 51, max: 60, description: "Зрелые отношения" }, + { id: "60+", name: "60+ лет", min: 60, max: 120, description: "Золотой возраст" }, +] as const; +``` + +### 🚀 **Поколения:** +```typescript +export const GENERATION_GROUPS = [ + { id: "gen-z", name: "Поколение Z", minYear: 1997, maxYear: 2012, description: "Цифровые аборигены" }, + { id: "millennials", name: "Миллениалы", minYear: 1981, maxYear: 1996, description: "Поколение интернета" }, + { id: "gen-x", name: "Поколение X", minYear: 1965, maxYear: 1980, description: "Поколение перемен" }, + { id: "boomers", name: "Бумеры", minYear: 1946, maxYear: 1964, description: "Послевоенное поколение" }, + { id: "silent", name: "Молчаливое поколение", minYear: 1928, maxYear: 1945, description: "Довоенное поколение" }, +] as const; +``` + +### ⚡ **Основные функции:** + +#### 1. **Расчет возраста:** +```typescript +// Из даты рождения +calculateAge(new Date(1987, 3, 8)); // → 36 + +// Из массива [month, day, year] +calculateAgeFromArray([4, 8, 1987]); // → 36 +``` + +#### 2. **Определение группы:** +```typescript +getAgeGroup(25); // → { id: "22-25", name: "22-25 лет", ... } +getGeneration(1987); // → { id: "millennials", name: "Миллениалы", ... } +``` + +#### 3. **Создание значений для навигации:** +```typescript +createAgeValue(25); // → "22-25" +createGenerationValue(1987); // → "millennials" +``` + +## Использование в вариативности + +### 📅 **Date экраны автоматически:** +Когда пользователь вводит дату рождения в date экране, система автоматически добавляет к ответам: + +**Пример:** Дата рождения 8 апреля 1987 года `[4, 8, 1987]` + +**Автоматически добавляется в ответы:** +```typescript +[ + "4", "8", "1987", // Исходная дата + "36-40", // Возрастная группа + "age-36", // Точный возраст + "millennials", // Поколение + "aries" // Знак зодиака (из существующей системы) +] +``` + +### 🎯 **Условия навигации:** + +#### **По возрастным группам:** +```json +{ + "conditions": [{ + "screenId": "birth-date", + "conditionType": "values", + "operator": "includesAny", + "values": ["22-25", "26-30"] + }] +} +``` + +#### **По поколениям:** +```json +{ + "conditions": [{ + "screenId": "birth-date", + "conditionType": "values", + "operator": "equals", + "values": ["millennials"] + }] +} +``` + +#### **Комбинированные условия:** +```json +{ + "conditions": [ + { + "screenId": "birth-date", + "conditionType": "values", + "operator": "includesAny", + "values": ["gen-z", "millennials"] + }, + { + "screenId": "birth-date", + "conditionType": "values", + "operator": "includesAny", + "values": ["aries", "leo", "sagittarius"] + } + ] +} +``` + +## UI компоненты + +### 🎨 **AgeSelector для админки:** +```tsx + console.log('Selected:', value)} + onAddCustomValue={(value) => console.log('Added:', value)} +/> +``` + +**Возможности:** +- 🎂 Сетка возрастных групп с описаниями +- 🚀 Список поколений с иконками +- 🎯 Поле для кастомных диапазонов (25-35, 40+) +- 📋 Превью выбранных значений с цветовым кодированием + +### 📊 **Интеграция в ScreenVariantsConfig:** +Для date экранов автоматически показываются: +1. **AgeSelector** - для возрастных условий +2. **ZodiacSelector** - для знаков зодиака +3. **Умная фильтрация** - каждый селектор показывает только свои значения + +## Примеры использования + +### 💖 **Романтические предпочтения по возрасту:** +```json +{ + "id": "young-romance", + "variants": [{ + "conditions": [{ + "screenId": "birth-date", + "conditionType": "values", + "operator": "includesAny", + "values": ["18-21", "22-25"] + }], + "overrides": { + "title": { "text": "Найти **молодую любовь**!" }, + "description": { "text": "Специально для **молодых сердец** - найдем твою половинку среди сверстников!" } + } + }] +} +``` + +### 🚀 **Карьерные амбиции по поколениям:** +```json +{ + "id": "career-focus", + "variants": [{ + "conditions": [{ + "screenId": "birth-date", + "conditionType": "values", + "operator": "equals", + "values": ["millennials"] + }], + "overrides": { + "title": { "text": "Карьера + **отношения**" }, + "description": { "text": "Для **миллениалов** - найдем партнера, который поддержит твои амбиции!" } + } + }] +} +``` + +### 🎯 **Зрелые отношения:** +```json +{ + "id": "mature-love", + "variants": [{ + "conditions": [{ + "screenId": "birth-date", + "conditionType": "values", + "operator": "includesAny", + "values": ["46-50", "51-60", "60+"] + }], + "overrides": { + "title": { "text": "**Зрелая любовь**" }, + "description": { "text": "Для тех, кто знает цену **настоящим чувствам** и **жизненному опыту**" } + } + }] +} +``` + +## Архитектура + +### 📁 **Файловая структура:** +``` +src/lib/age-utils.ts # Основные утилиты возраста +src/components/admin/builder/ + ├── AgeSelector.tsx # UI селектор для админки + ├── AgeDemo.tsx # Демо компонент + └── ScreenVariantsConfig.tsx # Интеграция в вариативность +src/lib/funnel/navigation.ts # Интеграция в навигацию +``` + +### 🔧 **Интеграция в систему:** + +#### **1. Автоматический расчет (navigation.ts):** +```typescript +// При получении ответов из date экрана автоматически добавляем: +const age = calculateAgeFromArray(dateArray); +const ageGroup = createAgeValue(age); +const generation = createGenerationValue(year); +const zodiac = getZodiacSign(month, day); + +enhancedAnswers.push(ageGroup, `age-${age}`, generation, zodiac); +``` + +#### **2. UI селекция (AgeSelector.tsx):** +```typescript +// Красивые карточки для выбора условий +{AGE_GROUPS.map((group) => ( + +))} +``` + +#### **3. Валидация диапазонов:** +```typescript +// Проверка попадания возраста в диапазон +isAgeInRange(25, "22-25"); // → true +isAgeInRange(30, "18-21"); // → false +``` + +## Преимущества + +### ✅ **Автоматизация:** +- Пользователь вводит только дату рождения +- Система автоматически рассчитывает все значения +- Не нужно спрашивать возраст отдельно + +### ✅ **Гибкость:** +- Поддержка любых возрастных диапазонов +- Кастомные значения (25-35, 40+) +- Комбинации с другими условиями + +### ✅ **UX дружелюбность:** +- Понятные названия групп +- Описания для каждой группы +- Иконки для поколений +- Превью выбранных условий + +### ✅ **Маркетинговая сегментация:** +- Готовые возрастные группы для таргетинга +- Поколенческая сегментация +- Психологические портреты по возрасту + +## Совместимость + +- ✅ **React 18+** +- ✅ **TypeScript 5+** +- ✅ **Next.js 14+** +- ✅ **Существующая система зодиака** +- ✅ **Система вариативности** +- ✅ **Обратная совместимость** с существующими воронками + +**💡 Теперь можно создавать условия навигации на основе возраста пользователя, автоматически рассчитанного из даты рождения!** diff --git a/MARKUP.md b/MARKUP.md new file mode 100644 index 0000000..cf01e25 --- /dev/null +++ b/MARKUP.md @@ -0,0 +1,180 @@ +# 🎨 Система разметки текста WitLab Funnel + +## Описание + +Универсальная система разметки позволяет выделять части текста **жирным шрифтом** в любых текстовых полях воронки. Система автоматически обнаруживает разметку и применяет стили. + +## Синтаксис + +### **Жирный текст** +``` +**текст** - выделяет текст жирным шрифтом +``` + +### Примеры использования: + +#### 📝 **В заголовках:** +``` +"Добро пожаловать в **WitLab**!" +``` +Результат: "Добро пожаловать в **WitLab**!" + +#### 💰 **В предложениях скидок:** +``` +"**50%** скидка только сегодня!" +``` +Результат: "**50%** скидка только сегодня!" + +#### 💖 **В результатах анализа:** +``` +"Ваш **идеальный партнер** найден на основе анализа ваших ответов" +``` +Результат: "Ваш **идеальный партнер** найден на основе анализа ваших ответов" + +#### 👤 **С именами пользователей:** +``` +"Поздравляем, **Анна**! Ваш портрет готов." +``` +Результат: "Поздравляем, **Анна**! Ваш портрет готов." + +## Где работает + +### ✅ Автоматически поддерживается: +- **Все текстовые поля** в экранах воронки (title, subtitle, description) +- **Info экраны** - описания +- **Soulmate Portrait** - описания портрета +- **Date экраны** - info сообщения +- **Form экраны** - лейблы и placeholder +- **Coupon экраны** - все текстовые поля купона + +### 🔧 Как это работает: + +#### 1. **Автоматическое обнаружение:** +```typescript +// Система автоматически проверяет каждый текст на наличие разметки +import { hasTextMarkup } from "@/lib/text-markup"; + +if (hasTextMarkup("Ваш **идеальный** партнер")) { + // Включает обработку разметки автоматически +} +``` + +#### 2. **Универсальная обработка:** +```typescript +// Все компоненты Typography автоматически поддерживают разметку + + {text} + +``` + +#### 3. **Превью в админке:** +```typescript +// В админке показывается живое превью разметки + +``` + +## Примеры для разных типов экранов + +### 📋 **Info экраны:** +```json +{ + "template": "info", + "description": { + "text": "Мы проанализировали **12 миллионов** анкет и нашли **идеальные совпадения** для вас!" + } +} +``` + +### 💖 **Soulmate Portrait:** +```json +{ + "template": "soulmate", + "description": { + "text": "Ваш **идеальный партнер** найден на основе **глубокого анализа** ваших ответов" + } +} +``` + +### 📅 **Date экраны:** +```json +{ + "template": "date", + "infoMessage": { + "text": "Мы используем дату рождения для определения **знака зодиака** и **совместимости**" + } +} +``` + +### 🎟️ **Coupon экраны:** +```json +{ + "template": "coupon", + "coupon": { + "title": { "text": "**94% скидка** только сегодня!" }, + "description": { "text": "Используйте промокод **HAIR50** и получите максимальную скидку" } + } +} +``` + +## Технические детали + +### 🔧 **Компоненты системы:** + +#### 1. **Утилиты (`/lib/text-markup.ts`):** +- `parseTextMarkup()` - парсинг разметки в сегменты +- `hasTextMarkup()` - проверка наличия разметки +- `stripTextMarkup()` - удаление разметки + +#### 2. **React компоненты (`/components/ui/MarkupText/`):** +- `` - рендеринг текста с разметкой +- `` - превью в админке +- `useHasMarkup()` - React хук для проверки + +#### 3. **Интеграция (`Typography.tsx`):** +- Автоматическая активация при обнаружении разметки +- Параметр `enableMarkup` для ручного управления +- Совместимость с существующими стилями + +### 📝 **Пример кода:** +```tsx +// Автоматическое использование + + Ваш **идеальный** партнер найден! + + +// Ручное управление + + Обычный текст с **выделением** + + +// Прямое использование MarkupText + + **WitLab** - найди свою любовь! + +``` + +## Лучшие практики + +### ✅ **Хорошо:** +- `"Ваш **идеальный** партнер найден!"` - выделение ключевых слов +- `"**50%** скидка только сегодня"` - выделение цифр и акций +- `"Поздравляем, **Анна**!"` - выделение имен + +### ❌ **Избегайте:** +- `"**Весь текст жирный**"` - потеря контраста +- `"**Сл**ов**ом** **разб**ит**ые**"` - нечитаемость +- `"****"` - пустые выделения + +### 💡 **Советы:** +1. **Выделяйте ключевые слова** - имена, проценты, важные понятия +2. **Соблюдайте баланс** - не более 20% текста должно быть жирным +3. **Тестируйте в превью** - используйте MarkupPreview в админке +4. **Учитывайте контекст** - в заголовках выделение менее заметно + +## Совместимость + +- ✅ **React 18+** +- ✅ **TypeScript 5+** +- ✅ **Next.js 14+** +- ✅ **Tailwind CSS** +- ✅ **Обратная совместимость** - существующий текст работает без изменений diff --git a/package-lock.json b/package-lock.json index aa934d2..6405da2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2124,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", @@ -6048,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", diff --git a/public/funnels/funnel-test.json b/public/funnels/funnel-test.json index c8a9738..09a9f04 100644 --- a/public/funnels/funnel-test.json +++ b/public/funnels/funnel-test.json @@ -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" diff --git a/src/components/admin/builder/AddScreenDialog.tsx b/src/components/admin/builder/AddScreenDialog.tsx index a9359d5..bba41bf 100644 --- a/src/components/admin/builder/AddScreenDialog.tsx +++ b/src/components/admin/builder/AddScreenDialog.tsx @@ -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: "Купон", diff --git a/src/components/admin/builder/AgeDemo.tsx b/src/components/admin/builder/AgeDemo.tsx new file mode 100644 index 0000000..9dc6db1 --- /dev/null +++ b/src/components/admin/builder/AgeDemo.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { AgeSelector } from "./AgeSelector"; +import { AGE_EXAMPLES, calculateAgeFromArray, getAgeGroup, getGenerationFromArray, createAgeValue, createGenerationValue } from "@/lib/age-utils"; +import { useState } from "react"; + +/** + * Демо компонент для показа возможностей системы возраста + */ +export function AgeDemo() { + const [selectedValues, setSelectedValues] = useState([]); + + const toggleValue = (value: string) => { + setSelectedValues(prev => + prev.includes(value) + ? prev.filter(v => v !== value) + : [...prev, value] + ); + }; + + const addCustomValue = (value: string) => { + if (!selectedValues.includes(value)) { + setSelectedValues(prev => [...prev, value]); + } + }; + + return ( +
+
+

🎂 Система работы с возрастом WitLab

+

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

+
+ + {/* 📖 ПРИМЕРЫ РАСЧЕТОВ */} +
+

📖 Примеры автоматических расчетов:

+ + {AGE_EXAMPLES.map((example, index) => ( +
+
+ {example.description} +
+ + {/* Исходная дата */} +
+ Дата: [{example.input.join(', ')}] ({example.input[1]}.{example.input[0]}.{example.input[2]}) +
+ + {/* Рассчитанные значения */} +
+
+ Возраст: {example.age} лет +
+ Группа: {example.ageGroup || 'Не определена'} +
+
+ Поколение: {example.generation || 'Не определено'} +
+ Значения: {[ + createAgeValue(example.age), + `age-${example.age}`, + createGenerationValue(example.input[2]) + ].join(', ')} +
+
+
+ ))} +
+ + {/* 🎯 ИНТЕРАКТИВНЫЙ СЕЛЕКТОР */} +
+

🎯 Интерактивный селектор возраста:

+ + +
+ + {/* 💡 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ */} +
+

💡 Как использовать в условиях навигации:

+ +
+
+ Пример 1 - Возрастные группы: +
+{`{
+  "conditions": [{
+    "screenId": "birth-date",
+    "conditionType": "values", 
+    "operator": "includesAny",
+    "values": ["22-25", "26-30"] // Молодые профессионалы
+  }]
+}`}
+            
+
+ +
+ Пример 2 - Поколения: +
+{`{
+  "conditions": [{
+    "screenId": "birth-date",
+    "conditionType": "values",
+    "operator": "equals", 
+    "values": ["millennials"] // Только миллениалы
+  }]
+}`}
+            
+
+ +
+ Пример 3 - Комбинированные условия: +
+{`{
+  "conditions": [
+    {
+      "screenId": "birth-date",
+      "conditionType": "values",
+      "operator": "includesAny",
+      "values": ["aries", "leo", "sagittarius"] // Огненные знаки
+    },
+    {
+      "screenId": "birth-date", 
+      "conditionType": "values",
+      "operator": "includesAny",
+      "values": ["22-25", "26-30"] // Молодые взрослые
+    }
+  ]
+}`}
+            
+
+
+
+ + {/* 🚀 ВОЗМОЖНОСТИ СИСТЕМЫ */} +
+

🚀 Автоматические значения из даты рождения:

+
    +
  • Точный возраст: age-25, age-30, age-45
  • +
  • Возрастные группы: 18-21, 22-25, 26-30, 31-35, 36-40, 41-45, 46-50, 51-60, 60+
  • +
  • Поколения: gen-z, millennials, gen-x, boomers, silent
  • +
  • Знаки зодиака: aries, taurus, gemini, cancer, leo, virgo, libra, scorpio, sagittarius, capricorn, aquarius, pisces
  • +
  • Кастомные диапазоны: 25-35, 40+, любые пользовательские значения
  • +
+
+ + {/* 📋 ТЕХНИЧЕСКИЕ ДЕТАЛИ */} +
+

📋 Как это работает технически:

+
    +
  • 1. Пользователь вводит дату: [4, 8, 1987] в date экране
  • +
  • 2. Система рассчитывает: возраст = {calculateAgeFromArray([4, 8, 1987])} лет
  • +
  • 3. Определяет группу: {getAgeGroup(calculateAgeFromArray([4, 8, 1987]))?.name}
  • +
  • 4. Определяет поколение: {getGenerationFromArray([4, 8, 1987])?.name}
  • +
  • 5. Добавляет в ответы: все вычисленные значения автоматически
  • +
  • 6. Система навигации: использует эти значения для условий
  • +
+
+
+ ); +} diff --git a/src/components/admin/builder/AgeSelector.tsx b/src/components/admin/builder/AgeSelector.tsx new file mode 100644 index 0000000..09870d2 --- /dev/null +++ b/src/components/admin/builder/AgeSelector.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { AGE_GROUPS, GENERATION_GROUPS, parseAgeRange } from "@/lib/age-utils"; + +interface AgeSelectorProps { + selectedValues: string[]; + onToggleValue: (value: string) => void; + onAddCustomValue: (value: string) => void; +} + +export function AgeSelector({ selectedValues, onToggleValue, onAddCustomValue }: AgeSelectorProps) { + const [customValue, setCustomValue] = useState(""); + + const handleAddCustom = () => { + if (customValue.trim()) { + onAddCustomValue(customValue.trim()); + setCustomValue(""); + } + }; + + const isValueSelected = (value: string) => selectedValues.includes(value); + + return ( +
+ {/* 🎂 ВОЗРАСТНЫЕ ГРУППЫ */} +
+

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

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

🚀 Поколения

+
+ {GENERATION_GROUPS.map((generation) => { + const isSelected = isValueSelected(generation.id); + return ( + + ); + })} +
+
+ + {/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */} +
+ +
+ setCustomValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustom(); + } + }} + /> + +
+ + {/* Подсказки по форматам */} +
+ Примеры: 25 (точный возраст), 18-21 (диапазон), 60+ (от 60 лет), age-25 (альтернативный формат) +
+
+ + {/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */} + {selectedValues.length > 0 && ( +
+ +
+ {selectedValues.map((value) => { + // Ищем в возрастных группах + const ageGroup = AGE_GROUPS.find(group => group.id === value); + if (ageGroup) { + return ( + + 🎂 {ageGroup.name} + + + ); + } + + // Ищем в поколениях + const generation = GENERATION_GROUPS.find(gen => gen.id === value); + if (generation) { + return ( + + 🚀 {generation.name} + + + ); + } + + // Кастомное значение + const range = parseAgeRange(value); + return ( + + 🎯 {range ? `${range.min}-${range.max === 120 ? '+' : range.max}` : value} + + + ); + })} +
+
+ )} + + {/* 💡 ПОДСКАЗКА */} +
+ 💡 Как это работает: Система автоматически рассчитывает возраст из + даты рождения пользователя. Выберите возрастные группы или поколения, при которых + должен показываться этот вариант экрана. Можно комбинировать разные условия. +
+
+ ); +} diff --git a/src/components/admin/builder/BuilderCanvas.tsx b/src/components/admin/builder/BuilderCanvas.tsx index 45c92e5..e1ec904 100644 --- a/src/components/admin/builder/BuilderCanvas.tsx +++ b/src/components/admin/builder/BuilderCanvas.tsx @@ -31,12 +31,16 @@ const TEMPLATE_TITLES: Record = { info: "Инфо", date: "Дата", coupon: "Купон", + email: "Email", + loaders: "Загрузка", + soulmate: "Портрет партнера", }; const OPERATOR_LABELS: Record, string> = { includesAny: "любой из", includesAll: "все из", includesExactly: "точное совпадение", + equals: "равно", }; interface TransitionRowProps { @@ -533,8 +537,20 @@ export function BuilderCanvas() {
diff --git a/src/components/admin/builder/BuilderSidebar.tsx b/src/components/admin/builder/BuilderSidebar.tsx index e2b3730..51dddeb 100644 --- a/src/components/admin/builder/BuilderSidebar.tsx +++ b/src/components/admin/builder/BuilderSidebar.tsx @@ -206,6 +206,7 @@ export function BuilderSidebar() { defaultNextScreenId: navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId, rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [], + isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen, }, }, }); @@ -503,26 +504,47 @@ export function BuilderSidebar() {
-
- {selectedScreenIsListType && ( + {selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
diff --git a/src/components/admin/builder/EmailDomainSelector.tsx b/src/components/admin/builder/EmailDomainSelector.tsx new file mode 100644 index 0000000..b83818a --- /dev/null +++ b/src/components/admin/builder/EmailDomainSelector.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; + +// 📧 ПОПУЛЯРНЫЕ EMAIL ДОМЕНЫ +const POPULAR_DOMAINS = [ + { id: "@gmail.com", name: "Gmail", icon: "📧", description: "Google Mail" }, + { id: "@yahoo.com", name: "Yahoo", icon: "🟣", description: "Yahoo Mail" }, + { id: "@hotmail.com", name: "Hotmail", icon: "🔵", description: "Microsoft Hotmail" }, + { id: "@outlook.com", name: "Outlook", icon: "📬", description: "Microsoft Outlook" }, + { id: "@icloud.com", name: "iCloud", icon: "☁️", description: "Apple iCloud" }, + { id: "@mail.ru", name: "Mail.ru", icon: "🔴", description: "Mail.ru" }, + { id: "@yandex.ru", name: "Yandex", icon: "🟡", description: "Яндекс.Почта" }, + { id: "@rambler.ru", name: "Rambler", icon: "🟢", description: "Rambler" }, +] as const; + +interface EmailDomainSelectorProps { + selectedValues: string[]; + onToggleValue: (value: string) => void; + onAddCustomValue: (value: string) => void; +} + +export function EmailDomainSelector({ selectedValues, onToggleValue, onAddCustomValue }: EmailDomainSelectorProps) { + const [customDomain, setCustomDomain] = useState(""); + + const handleAddCustom = () => { + let domain = customDomain.trim(); + if (domain) { + // Автоматически добавляем @ если его нет + if (!domain.startsWith("@")) { + domain = "@" + domain; + } + onAddCustomValue(domain); + setCustomDomain(""); + } + }; + + return ( +
+ {/* 📧 ПОПУЛЯРНЫЕ ДОМЕНЫ */} +
+ {POPULAR_DOMAINS.map((domain) => { + const isSelected = selectedValues.includes(domain.id); + return ( + + ); + })} +
+ + {/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ДОМЕНОВ */} +
+ +
+ setCustomDomain(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustom(); + } + }} + /> + +
+
+ + {/* 📋 ВЫБРАННЫЕ ДОМЕНЫ */} + {selectedValues.length > 0 && ( +
+ +
+ {selectedValues.map((value) => { + const popularDomain = POPULAR_DOMAINS.find(domain => domain.id === value); + return ( + + {popularDomain ? ( + <> + {popularDomain.icon} + {popularDomain.name} + + ) : ( + 📧 {value} + )} + + + ); + })} +
+
+ )} + + {/* 💡 ПОДСКАЗКА */} +
+ 💡 Как это работает: Система проверяет домен email адреса пользователя. + Например, если пользователь ввел “user@gmail.com”, то значение будет “@gmail.com”. + Выберите домены, при которых должен показываться этот вариант экрана. +
+
+ ); +} diff --git a/src/components/admin/builder/MarkupDemo.tsx b/src/components/admin/builder/MarkupDemo.tsx new file mode 100644 index 0000000..1f6fe5b --- /dev/null +++ b/src/components/admin/builder/MarkupDemo.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { MarkupText, MarkupPreview } from "@/components/ui/MarkupText/MarkupText"; +import { MARKUP_EXAMPLES } from "@/lib/text-markup"; + +/** + * Демо компонент для показа возможностей системы разметки + */ +export function MarkupDemo() { + return ( +
+
+

🎨 Система разметки WitLab

+

+ Используйте **двойные звездочки** для выделения текста жирным шрифтом +

+
+ + {/* 📖 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ */} +
+

📖 Примеры использования:

+ + {MARKUP_EXAMPLES.map((example, index) => ( +
+
+ {example.description} +
+ + {/* Исходный код */} +
+ {example.input} +
+ + {/* Результат */} +
+ Результат:{" "} + {example.input} +
+
+ ))} +
+ + {/* 🎯 ИНТЕРАКТИВНОЕ ДЕМО */} +
+

🎯 Интерактивное превью:

+ + + + + + +
+ + {/* 💡 ИНСТРУКЦИИ */} +
+

💡 Как использовать в админке:

+
    +
  • 1. Откройте любой экран в админке
  • +
  • 2. В текстовых полях используйте **двойные звездочки** для выделения
  • +
  • 3. Система автоматически покажет превью разметки
  • +
  • 4. В воронке текст будет отображаться с жирным выделением
  • +
+
+ + {/* 🚀 ПОДДЕРЖИВАЕМЫЕ ЭЛЕМЕНТЫ */} +
+

🚀 Где работает разметка:

+
    +
  • ✅ Заголовки и подзаголовки всех экранов
  • +
  • ✅ Описания в Info и Soulmate экранах
  • +
  • ✅ Информационные сообщения в Date экранах
  • +
  • ✅ Лейблы и placeholder в Form экранах
  • +
  • ✅ Все текстовые поля Coupon экранов
  • +
  • ✅ Любой компонент Typography с enableMarkup
  • +
+
+
+ ); +} diff --git a/src/components/admin/builder/ScreenVariantsConfig.tsx b/src/components/admin/builder/ScreenVariantsConfig.tsx index f855760..5094bac 100644 --- a/src/components/admin/builder/ScreenVariantsConfig.tsx +++ b/src/components/admin/builder/ScreenVariantsConfig.tsx @@ -4,6 +4,9 @@ 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, @@ -112,6 +115,12 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar } }, [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] @@ -181,22 +190,55 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar ); const updateCondition = useCallback( - (index: number, updates: Partial) => { - updateVariant(index, { - conditions: [ - { - ...ensureCondition(variants[index], screen.id), - ...updates, - }, - ], - }); + (variantIndex: number, conditionIndex: number, updates: Partial) => { + const variant = variants[variantIndex]; + const updatedConditions = [...variant.conditions]; + updatedConditions[conditionIndex] = { + ...ensureCondition(variant, screen.id), + ...variant.conditions[conditionIndex], + ...updates, + }; + updateVariant(variantIndex, { conditions: updatedConditions }); }, [screen.id, updateVariant, variants] ); + const addCondition = useCallback( + (variantIndex: number) => { + const variant = variants[variantIndex]; + const fallbackScreen = listScreens[0] ?? (screen.template === "list" ? (screen as ListBuilderScreen) : undefined); + + if (!fallbackScreen) return; + + const firstOptionId = fallbackScreen.list.options[0]?.id; + const newCondition: VariantCondition = { + screenId: fallbackScreen.id, + operator: "includesAny", + optionIds: firstOptionId ? [firstOptionId] : [], + }; + + updateVariant(variantIndex, { + conditions: [...variant.conditions, newCondition], + }); + }, + [variants, listScreens, screen, updateVariant] + ); + + const removeCondition = useCallback( + (variantIndex: number, conditionIndex: number) => { + const variant = variants[variantIndex]; + if (variant.conditions.length <= 1) return; // Минимум одно условие должно остаться + + const updatedConditions = variant.conditions.filter((_, index) => index !== conditionIndex); + updateVariant(variantIndex, { conditions: updatedConditions }); + }, + [variants, updateVariant] + ); + const toggleOption = useCallback( - (index: number, optionId: string) => { - const condition = ensureCondition(variants[index], screen.id); + (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); @@ -204,30 +246,75 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar optionIds.add(optionId); } - updateCondition(index, { optionIds: Array.from(optionIds) }); + updateCondition(variantIndex, conditionIndex, { 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] : [], - }); + (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 + }); + } }, - [listScreens, updateCondition] + [availableScreens, updateCondition] ); const handleOperatorChange = useCallback( - (variantIndex: number, operator: VariantCondition["operator"]) => { - updateCondition(variantIndex, { operator }); + (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 }); @@ -235,22 +322,50 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar [updateVariant] ); + // 🎯 НОВАЯ ФУНКЦИЯ: определение типа экрана для красивого отображения + const getScreenTypeLabel = useCallback((screenId: string) => { + const targetScreen = availableScreens.find(s => s.id === screenId); + if (!targetScreen) return "Неизвестный"; + + const templateLabels: Record = { + list: "📝 Список", + date: "📅 Дата рождения", + email: "📧 Email", + form: "📋 Форма", + info: "ℹ️ Информация", + coupon: "🎟️ Купон", + loaders: "⏳ Загрузка", + soulmate: "💖 Портрет", + }; + + return templateLabels[targetScreen.template] || targetScreen.template; + }, [availableScreens]); + const renderVariantSummary = useCallback( (variant: VariantDefinition) => { const condition = ensureCondition(variant, screen.id); - const optionSummaries = (condition.optionIds ?? []).map((optionId) => { - const options = optionMap[condition.screenId] ?? []; - const option = options.find((item) => item.id === optionId); - return option?.label ?? optionId; - }); + 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 listScreenTitle = listScreens.find((candidate) => candidate.id === condition.screenId)?.title.text; + 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 "любой из"; } @@ -261,22 +376,26 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar return (
- Экран условий: - - {listScreenTitle ?? condition.screenId} + Условие: + + {screenTypeLabel} {operatorLabel}
- {optionSummaries.length > 0 ? ( +
+ Экран: + {screenTitle ?? condition.screenId} +
+ {summaries.length > 0 ? (
- {optionSummaries.map((label) => ( - - {label} + {summaries.map((item) => ( + + {item} ))}
) : ( -
Пока нет выбранных ответов
+
Пока нет выбранных значений
)}
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((item) => ( @@ -288,7 +407,7 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar
); }, - [listScreens, optionMap, screen.id] + [availableScreens, optionMap, screen.id, getScreenTypeLabel] ); return ( @@ -297,14 +416,14 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar

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

-
- {listScreens.length === 0 ? ( + {availableScreens.length === 0 ? (
- Добавьте экран со списком, чтобы настроить вариативность. + Добавьте другие экраны в воронку, чтобы настроить вариативность.
) : variants.length === 0 ? (
@@ -346,21 +465,54 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar {isExpanded && (
-
-

Ограничение: Текущая версия админки поддерживает только одно условие на вариант. Реальная система поддерживает множественные условия через JSON.

+
+

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

+ {/* 🎯 МНОЖЕСТВЕННЫЕ УСЛОВИЯ */} +
+
+ + Условия ({variant.conditions.length}) + + +
+ + {variant.conditions.map((condition, conditionIndex) => ( +
+
+ + Условие #{conditionIndex + 1} + + {variant.conditions.length > 1 && ( + + )} +
+
-
- Ответы - {availableOptions.length === 0 ? ( -
- В выбранном экране пока нет вариантов ответа. -
- ) : ( -
- {availableOptions.map((option) => { - const isChecked = condition.optionIds?.includes(option.id) ?? false; - return ( -
+
+ ))} +
Настройка контента diff --git a/src/components/admin/builder/ZodiacSelector.tsx b/src/components/admin/builder/ZodiacSelector.tsx new file mode 100644 index 0000000..0d7f259 --- /dev/null +++ b/src/components/admin/builder/ZodiacSelector.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; + +// 🔮 ЗНАКИ ЗОДИАКА С КРАСИВЫМИ ИКОНКАМИ +const ZODIAC_SIGNS = [ + { id: "aries", name: "Овен", icon: "♈", dates: "21 марта - 19 апреля" }, + { id: "taurus", name: "Телец", icon: "♉", dates: "20 апреля - 20 мая" }, + { id: "gemini", name: "Близнецы", icon: "♊", dates: "21 мая - 20 июня" }, + { id: "cancer", name: "Рак", icon: "♋", dates: "21 июня - 22 июля" }, + { id: "leo", name: "Лев", icon: "♌", dates: "23 июля - 22 августа" }, + { id: "virgo", name: "Дева", icon: "♍", dates: "23 августа - 22 сентября" }, + { id: "libra", name: "Весы", icon: "♎", dates: "23 сентября - 22 октября" }, + { id: "scorpio", name: "Скорпион", icon: "♏", dates: "23 октября - 21 ноября" }, + { id: "sagittarius", name: "Стрелец", icon: "♐", dates: "22 ноября - 21 декабря" }, + { id: "capricorn", name: "Козерог", icon: "♑", dates: "22 декабря - 19 января" }, + { id: "aquarius", name: "Водолей", icon: "♒", dates: "20 января - 18 февраля" }, + { id: "pisces", name: "Рыбы", icon: "♓", dates: "19 февраля - 20 марта" }, +] as const; + +interface ZodiacSelectorProps { + selectedValues: string[]; + onToggleValue: (value: string) => void; + onAddCustomValue: (value: string) => void; +} + +export function ZodiacSelector({ selectedValues, onToggleValue, onAddCustomValue }: ZodiacSelectorProps) { + const [customValue, setCustomValue] = useState(""); + + const handleAddCustom = () => { + if (customValue.trim()) { + onAddCustomValue(customValue.trim()); + setCustomValue(""); + } + }; + + return ( +
+ {/* 🔮 КРАСИВАЯ СЕТКА ЗНАКОВ ЗОДИАКА */} +
+ {ZODIAC_SIGNS.map((sign) => { + const isSelected = selectedValues.includes(sign.id); + return ( + + ); + })} +
+ + {/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */} +
+ +
+ setCustomValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustom(); + } + }} + /> + +
+
+ + {/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */} + {selectedValues.length > 0 && ( +
+ +
+ {selectedValues.map((value) => { + const zodiacSign = ZODIAC_SIGNS.find(sign => sign.id === value); + return ( + + {zodiacSign ? ( + <> + {zodiacSign.icon} + {zodiacSign.name} + + ) : ( + {value} + )} + + + ); + })} +
+
+ )} + + {/* 💡 ПОДСКАЗКА */} +
+ 💡 Как это работает: Знак зодиака автоматически определяется из + даты рождения пользователя. Выберите знаки, при которых должен показываться + этот вариант экрана. +
+
+ ); +} diff --git a/src/components/admin/builder/templates/EmailScreenConfig.tsx b/src/components/admin/builder/templates/EmailScreenConfig.tsx new file mode 100644 index 0000000..9ff00c5 --- /dev/null +++ b/src/components/admin/builder/templates/EmailScreenConfig.tsx @@ -0,0 +1,79 @@ +"use client"; + +import React from "react"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; +import type { EmailScreenDefinition } from "@/lib/funnel/types"; + +interface EmailScreenConfigProps { + screen: BuilderScreen & { template: "email" }; + onUpdate: (updates: Partial) => void; +} + +export function EmailScreenConfig({ screen, onUpdate }: EmailScreenConfigProps) { + const updateEmailInput = (updates: Partial) => { + onUpdate({ + emailInput: { + ...screen.emailInput, + ...updates, + }, + }); + }; + + const updateImage = (updates: Partial) => { + onUpdate({ + image: screen.image ? { + ...screen.image, + ...updates, + } : { src: "", ...updates }, + }); + }; + + + return ( +
+
+

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

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

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

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

Информация

+
+

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

+

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

+
+
+
+ ); +} diff --git a/src/components/admin/builder/templates/InfoScreenConfig.tsx b/src/components/admin/builder/templates/InfoScreenConfig.tsx index 077dd34..851d24c 100644 --- a/src/components/admin/builder/templates/InfoScreenConfig.tsx +++ b/src/components/admin/builder/templates/InfoScreenConfig.tsx @@ -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"; @@ -47,14 +48,21 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {

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

- +
+ + + {/* 🎨 ПРЕВЬЮ РАЗМЕТКИ */} + {infoScreen.description?.text && ( + + )} +
diff --git a/src/components/admin/builder/templates/LoadersScreenConfig.tsx b/src/components/admin/builder/templates/LoadersScreenConfig.tsx new file mode 100644 index 0000000..96dd99f --- /dev/null +++ b/src/components/admin/builder/templates/LoadersScreenConfig.tsx @@ -0,0 +1,167 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { Trash2, Plus } from "lucide-react"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; +import type { LoadersScreenDefinition } from "@/lib/funnel/types"; + +interface LoadersScreenConfigProps { + screen: BuilderScreen & { template: "loaders" }; + onUpdate: (updates: Partial) => void; +} + +export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigProps) { + const updateProgressbars = (updates: Partial) => { + onUpdate({ + progressbars: { + items: screen.progressbars?.items || [], + transitionDuration: screen.progressbars?.transitionDuration || 5000, + ...updates, + }, + }); + }; + + const addProgressbarItem = () => { + const currentItems = screen.progressbars?.items || []; + updateProgressbars({ + items: [ + ...currentItems, + { + title: `Step ${currentItems.length + 1}`, + subtitle: "", + processingTitle: `Processing step ${currentItems.length + 1}...`, + processingSubtitle: "", + completedTitle: `Step ${currentItems.length + 1} completed`, + completedSubtitle: "", + }, + ], + }); + }; + + const removeProgressbarItem = (index: number) => { + const currentItems = screen.progressbars?.items || []; + updateProgressbars({ + items: currentItems.filter((_, i) => i !== index), + }); + }; + + const updateProgressbarItem = ( + index: number, + updates: Partial + ) => { + const currentItems = screen.progressbars?.items || []; + const updatedItems = currentItems.map((item, i) => + i === index ? { ...item, ...updates } : item + ); + updateProgressbars({ items: updatedItems }); + }; + + return ( +
+
+

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

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

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

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

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

+ +
+ )} +
+
+ ); +} diff --git a/src/components/admin/builder/templates/SoulmatePortraitScreenConfig.tsx b/src/components/admin/builder/templates/SoulmatePortraitScreenConfig.tsx new file mode 100644 index 0000000..7fbe541 --- /dev/null +++ b/src/components/admin/builder/templates/SoulmatePortraitScreenConfig.tsx @@ -0,0 +1,51 @@ +"use client"; + +import React from "react"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; +import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types"; + +interface SoulmatePortraitScreenConfigProps { + screen: BuilderScreen & { template: "soulmate" }; + onUpdate: (updates: Partial) => void; +} + +export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortraitScreenConfigProps) { + const updateDescription = (updates: Partial) => { + onUpdate({ + description: screen.description ? { + ...screen.description, + ...updates, + } : { text: "", ...updates }, + }); + }; + + return ( +
+
+

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

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

Информация

+
+

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

+
+
+ +
+

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

+

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

+
+
+ ); +} diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index fafe92a..f7aec55 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -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 ))} + +
)}
@@ -422,6 +454,24 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) { onUpdate={onUpdate as (updates: Partial) => void} /> )} + {template === "email" && ( + ) => void} + /> + )} + {template === "loaders" && ( + ) => void} + /> + )} + {template === "soulmate" && ( + ) => void} + /> + )}
); } diff --git a/src/components/funnel/templates/CouponTemplate.tsx b/src/components/funnel/templates/CouponTemplate.tsx index 95afd38..f939bc0 100644 --- a/src/components/funnel/templates/CouponTemplate.tsx +++ b/src/components/funnel/templates/CouponTemplate.tsx @@ -45,8 +45,8 @@ export function CouponTemplate({ const layoutQuestionProps = buildLayoutQuestionProps({ screen, - titleDefaults: { font: "manrope", weight: "bold", align: "center" }, - subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "center" }, + titleDefaults: { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }, + subtitleDefaults: { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }, canGoBack, onBack, actionButtonOptions: { @@ -122,7 +122,7 @@ export function CouponTemplate({ return ( -
+
{/* Coupon Widget */}
diff --git a/src/components/funnel/templates/DateTemplate.tsx b/src/components/funnel/templates/DateTemplate.tsx index ff4a8cc..653116d 100644 --- a/src/components/funnel/templates/DateTemplate.tsx +++ b/src/components/funnel/templates/DateTemplate.tsx @@ -1,17 +1,13 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; -import NextImage from "next/image"; - -import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion"; +import { useMemo } from "react"; +import Image from "next/image"; +import DateInput from "@/components/widgets/DateInput/DateInput"; import Typography from "@/components/ui/Typography/Typography"; - -import { - buildLayoutQuestionProps, - buildTypographyProps, -} from "@/lib/funnel/mappers"; +import { buildTypographyProps } from "@/lib/funnel/mappers"; import type { DateScreenDefinition } from "@/lib/funnel/types"; import { cn } from "@/lib/utils"; +import { TemplateLayout } from "./TemplateLayout"; interface DateTemplateProps { screen: DateScreenDefinition; @@ -24,41 +20,19 @@ interface DateTemplateProps { defaultTexts?: { nextButton?: string; continueButton?: string }; } -const MONTH_NAMES = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December" -]; +// Helper functions for date conversion (kept for potential future use) +// function convertArrayToISOString(dateArray: number[]): string { +// if (dateArray.length !== 3) return ""; +// const [month, day, year] = dateArray; +// const date = new Date(year, month - 1, day); +// return date.toISOString().split('T')[0]; +// } -// 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; -}; +// function convertISOStringToArray(isoString: string): number[] { +// if (!isoString) return []; +// const [year, month, day] = isoString.split('-').map(Number); +// return [month, day, year]; +// } export function DateTemplate({ screen, @@ -70,153 +44,78 @@ export function DateTemplate({ 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; - }) => ( -
- - -
- ); - - // 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(() => { + // Преобразуем объект {month, day, year} в ISO строку для DateInput + 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) { - const monthName = MONTH_NAMES[monthNum - 1]; - return `${monthName} ${dayNum}, ${yearNum}`; + 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; - }, [month, day, year]); + }, [selectedDate]); - 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, - }); + // Обработчик изменения даты - преобразуем ISO обратно в объект + 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 + }); + }; + + // 🎯 ЛОГИКА ВАЛИДАЦИИ ФОРМЫ ДЛЯ DATE - кнопка disabled пока дата не выбрана + const isFormValid = Boolean(isoDate); return ( - -
- {/* Date Input Fields */} -
-
- - - -
-
+ +
+ {/* Используем DateInput виджет разработчика */} + - {/* Info Message */} + {/* Info Message если есть */} {screen.infoMessage && (
-
)} -
- - {/* Selected Date Display - positioned 18px above button with high z-index */} - {screen.dateInput.showSelectedDate && formattedDate && ( -
-
- - {screen.dateInput.selectedDateLabel || "Selected date:"} - - - {formattedDate} - -
-
- )} - + ); } diff --git a/src/components/funnel/templates/EmailTemplate.tsx b/src/components/funnel/templates/EmailTemplate.tsx new file mode 100644 index 0000000..f83a87f --- /dev/null +++ b/src/components/funnel/templates/EmailTemplate.tsx @@ -0,0 +1,120 @@ +"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 "./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 не используется в email template - прогресс отключен + defaultTexts, +}: EmailTemplateProps) { + // 🎯 Валидация через react-hook-form + zod как в оригинале + const [isTouched, setIsTouched] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: selectedEmail || "", + }, + }); + + useEffect(() => { + form.setValue("email", selectedEmail || ""); + }, [selectedEmail, form]); + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + form.setValue("email", value); + form.trigger("email"); + onEmailChange(value); + }; + + const isFormValid = form.formState.isValid && form.getValues("email"); + + return ( + + {/* 🎨 Новая структура согласно требованиям */} +
+ {/* 📧 Email Input - с дефолтными значениями */} + { + setIsTouched(true); + form.trigger("email"); + }} + aria-invalid={isTouched && !!form.formState.errors.email} + aria-errormessage={ + isTouched ? form.formState.errors.email?.message : undefined + } + /> + + {/* 🖼️ Image - с зашитыми значениями как в оригинальном Email компоненте */} + {screen.image && ( + portrait + )} + + {/* 🔒 Privacy Security Banner */} + +
+
+ ); +} diff --git a/src/components/funnel/templates/FormTemplate.tsx b/src/components/funnel/templates/FormTemplate.tsx index 19d859f..d56ae17 100644 --- a/src/components/funnel/templates/FormTemplate.tsx +++ b/src/components/funnel/templates/FormTemplate.tsx @@ -110,8 +110,8 @@ export function FormTemplate({ const layoutQuestionProps = buildLayoutQuestionProps({ screen, - titleDefaults: { font: "manrope", weight: "bold", align: "left" }, - subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" }, + titleDefaults: { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }, + subtitleDefaults: { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }, canGoBack, onBack, actionButtonOptions: { @@ -124,7 +124,7 @@ export function FormTemplate({ return ( -
+
{screen.fields.map((field) => (
{ const size = screen.icon?.size ?? "xl"; switch (size) { @@ -59,8 +41,26 @@ export function InfoTemplate({ }, [screen.icon?.size]); return ( - -
+ +
{/* Icon */} {screen.icon && (
@@ -88,11 +88,12 @@ export function InfoTemplate({
)} - {/* Title - handled by LayoutQuestion */} - {/* Description */} {screen.description && ( -
+
)}
- + ); } diff --git a/src/components/funnel/templates/ListTemplate.tsx b/src/components/funnel/templates/ListTemplate.tsx index 0af6e9e..7f1fc90 100644 --- a/src/components/funnel/templates/ListTemplate.tsx +++ b/src/components/funnel/templates/ListTemplate.tsx @@ -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 "./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,7 +91,7 @@ export function ListTemplate({ onChangeSelectedAnswers: handleSelectChange, }; - // Определяем action button options для centralized логики только если кнопка нужна + // 🎯 СЛОЖНАЯ ЛОГИКА КНОПКИ ДЛЯ СПИСКОВ - actionButtonProps приходит из screenRenderer const actionButtonOptions = actionButtonProps ? { defaultText: actionButtonProps.children as string || "Next", disabled: actionButtonProps.disabled || false, @@ -106,26 +102,24 @@ 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 ( - + {}} // Не используется, логика в actionButtonOptions.onClick + 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} + > +
+ {contentType === "radio-answers-list" ? ( + + ) : ( + + )} +
+
); } diff --git a/src/components/funnel/templates/LoadersTemplate.tsx b/src/components/funnel/templates/LoadersTemplate.tsx new file mode 100644 index 0000000..6a4db08 --- /dev/null +++ b/src/components/funnel/templates/LoadersTemplate.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import { TemplateLayout } from "./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); + }; + + // 🎨 Преобразуем данные screen definition в props для CircularProgressbarsList + 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 }, + subtitle: typedItem.processingSubtitle ? { children: typedItem.processingSubtitle } : undefined, + } : undefined, + completed: typedItem.completedTitle ? { + title: { children: typedItem.completedTitle }, + subtitle: typedItem.completedSubtitle ? { children: typedItem.completedSubtitle } : undefined, + } : undefined, + }; + }) || [], + transitionDurationItem: screen.progressbars?.transitionDuration || 3000, // Как в оригинале + onAnimationEnd, + }; + + return ( + +
+ +
+
+ ); +} diff --git a/src/components/funnel/templates/SoulmatePortraitTemplate.tsx b/src/components/funnel/templates/SoulmatePortraitTemplate.tsx new file mode 100644 index 0000000..30ecf36 --- /dev/null +++ b/src/components/funnel/templates/SoulmatePortraitTemplate.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types"; +import { TemplateLayout } from "./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 ( + + {/* 🎯 Точно как InfoTemplate - пустой контент, без иконки и description */} +
+ {/* Пустой контент - как InfoTemplate без иконки и без description */} +
+
+ ); +} diff --git a/src/components/funnel/templates/TemplateLayout.tsx b/src/components/funnel/templates/TemplateLayout.tsx new file mode 100644 index 0000000..04e2acb --- /dev/null +++ b/src/components/funnel/templates/TemplateLayout.tsx @@ -0,0 +1,144 @@ +"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; + onContinue: () => void; + canGoBack: boolean; + onBack: () => void; + screenProgress?: { current: number; total: number }; + defaultTexts?: { nextButton?: string; continueButton?: string }; + + // Настройки 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, + // onContinue, // Unused in this component + canGoBack, + onBack, + screenProgress, + // defaultTexts, // Unused in this component + 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 { + height: bottomActionButtonHeight, + elementRef: bottomActionButtonRef, + } = useDynamicSize({ + defaultHeight: 132, + }); + + // 🎯 ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА HEADER + const layoutQuestionProps = buildLayoutQuestionProps({ + screen, + titleDefaults, + subtitleDefaults, + canGoBack, + onBack, + screenProgress, + }); + + // 🎯 ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON PROPS + const bottomActionButtonProps = actionButtonOptions + ? buildTemplateBottomActionButtonProps({ + screen, + titleDefaults, + subtitleDefaults, + canGoBack, + onBack, + actionButtonOptions, + screenProgress, + }) + : undefined; + + // 🎯 Автоматически создаем PrivacyTermsConsent с фиксированными настройками + const shouldShowPrivacyTermsConsent = + 'bottomActionButton' in screen && + screen.bottomActionButton?.showPrivacyTermsConsent === true; + + const autoPrivacyTermsConsent = shouldShowPrivacyTermsConsent ? ( + + ) : null; + + // Комбинируем переданный childrenUnderButton с автоматическим PrivacyTermsConsent + const finalChildrenUnderButton = ( + <> + {childrenUnderButton} + {autoPrivacyTermsConsent} + + ); + + // 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ + return ( +
+ + {children} + + + {bottomActionButtonProps && ( + + )} +
+ ); +} diff --git a/src/components/layout/LayoutQuestion/LayoutQuestion.stories.tsx b/src/components/layout/LayoutQuestion/LayoutQuestion.stories.tsx index 95c3ad2..775b42e 100644 --- a/src/components/layout/LayoutQuestion/LayoutQuestion.stories.tsx +++ b/src/components/layout/LayoutQuestion/LayoutQuestion.stories.tsx @@ -26,15 +26,10 @@ const meta: Meta = { children: "Let's personalize your hair care journey", }, children: ( -
+
Children
), - bottomActionButtonProps: { - actionButtonProps: { - children: "Continue", - }, - }, }, argTypes: {}, }; diff --git a/src/components/ui/MainButton/MainButton.tsx b/src/components/ui/MainButton/MainButton.tsx index aa40687..3510e88 100644 --- a/src/components/ui/MainButton/MainButton.tsx +++ b/src/components/ui/MainButton/MainButton.tsx @@ -27,7 +27,7 @@ const buttonVariants = cva( active: { true: "bg-gradient-to-r from-[#EBF5FF] to-[#DBEAFE] border-primary shadow-blue-glow-2 text-primary", false: - "bg-background border-border shadow-black-glow text-secondary-foreground", + "bg-background border-border shadow-black-glow text-black", }, }, defaultVariants: { diff --git a/src/components/ui/MarkupText/MarkupText.tsx b/src/components/ui/MarkupText/MarkupText.tsx new file mode 100644 index 0000000..b4e25b2 --- /dev/null +++ b/src/components/ui/MarkupText/MarkupText.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { parseTextMarkup, hasTextMarkup, type TextMarkupSegment } from "@/lib/text-markup"; +import { cn } from "@/lib/utils"; + +interface MarkupTextProps { + children: string; + className?: string; + as?: keyof React.JSX.IntrinsicElements; + boldClassName?: string; +} + +/** + * Компонент для рендеринга текста с разметкой **bold** + * + * Примеры использования: + * Добро пожаловать в **WitLab**! + * **50%** скидка только сегодня + * Ваш **идеальный партнер** найден! + */ +export function MarkupText({ + children, + className, + as: Component = "span", + boldClassName = "font-bold" +}: MarkupTextProps) { + // Если текста нет, возвращаем пустой элемент + if (!children || typeof children !== 'string') { + return React.createElement(Component as string, { className }, children); + } + + // Если нет разметки, возвращаем обычный текст + if (!hasTextMarkup(children)) { + return React.createElement(Component as string, { className }, children); + } + + // Парсим разметку и рендерим сегменты + const segments = parseTextMarkup(children); + + return React.createElement( + Component as string, + { className }, + segments.map((segment: TextMarkupSegment, index: number) => { + if (segment.type === 'bold') { + return React.createElement( + 'strong', + { + key: index, + className: cn(boldClassName) + }, + segment.content + ); + } + + return React.createElement( + React.Fragment, + { key: index }, + segment.content + ); + }) + ); +} + +/** + * Хук для проверки наличия разметки в тексте + */ +export function useHasMarkup(text: string): boolean { + return React.useMemo(() => hasTextMarkup(text), [text]); +} + +/** + * Компонент для превью разметки в админке + */ +export function MarkupPreview({ text }: { text: string }) { + return ( +
+
+ Превью: +
+
+ + {text} + +
+ {hasTextMarkup(text) && ( +
+ 💡 Разметка обнаружена: Текст в **двойных звездочках** будет выделен жирным шрифтом. +
+ )} +
+ ); +} diff --git a/src/components/ui/Typography/Typography.tsx b/src/components/ui/Typography/Typography.tsx index 6a14b96..9b011d9 100644 --- a/src/components/ui/Typography/Typography.tsx +++ b/src/components/ui/Typography/Typography.tsx @@ -1,6 +1,7 @@ import { createElement, JSX, ReactNode } from "react"; import { cva, VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; +import { MarkupText } from "@/components/ui/MarkupText/MarkupText"; const typographyVariants = cva(cn("text-center text-foreground block"), { variants: { @@ -46,10 +47,10 @@ const typographyVariants = cva(cn("text-center text-foreground block"), { }, defaultVariants: { size: "md", - weight: "regular", + weight: "regular", color: "default", - align: "center", - font: "inter", + align: "left", + font: "manrope", }, }); @@ -63,6 +64,7 @@ export type TypographyProps = Omit & { as?: T; children: ReactNode; + enableMarkup?: boolean; // 🎨 Новый параметр для включения разметки }; export default function Typography< @@ -76,15 +78,33 @@ export default function Typography< color, align, font, + enableMarkup = false, // 🎨 По умолчанию выключена для обратной совместимости ...props }: TypographyProps) { + const classes = cn( + typographyVariants({ size, weight, color, align, font }), + className + ); + + // 🎨 Если включена разметка и это строка, используем MarkupText + if (enableMarkup && typeof children === 'string') { + return ( + + {children} + + ); + } + + // 🎨 Обычный рендеринг без разметки return createElement( Component, { - className: cn( - typographyVariants({ size, weight, color, align, font }), - className - ), + className: classes, ...props, }, children diff --git a/src/components/widgets/BottomActionButton/BottomActionButton.tsx b/src/components/widgets/BottomActionButton/BottomActionButton.tsx index 70d9c35..0096b9f 100644 --- a/src/components/widgets/BottomActionButton/BottomActionButton.tsx +++ b/src/components/widgets/BottomActionButton/BottomActionButton.tsx @@ -39,11 +39,8 @@ const BottomActionButton = forwardRef( Boolean(childrenAboveButton) || Boolean(childrenUnderButton); const hasContent = hasButton || hasExtra; - // Ничего не рендерим, если нет контента - if (!hasContent) return null; - useEffect(() => { - if (!syncCssVar || typeof window === "undefined") return; + if (!syncCssVar || typeof window === "undefined" || !hasContent) return; const el = innerRef.current; if (!el) return; @@ -62,10 +59,13 @@ const BottomActionButton = forwardRef( return () => ro.disconnect(); } else { const onResize = () => setVar(); - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); + globalThis.addEventListener("resize", onResize); + return () => globalThis.removeEventListener("resize", onResize); } - }, [syncCssVar]); + }, [syncCssVar, hasContent]); + + // Ничего не рендерим, если нет контента + if (!hasContent) return null; return (
void; + showDividers?: boolean; // Показывать разделительные линии или нет } export default function CircularProgressbarsList({ @@ -48,12 +49,28 @@ export default function CircularProgressbarsList({ transitionDurationItem = 5_000, animationDurationDivider = 1000, onAnimationEnd, + showDividers = true, // По умолчанию показываем разделители ...props }: CircularProgressbarsListProps) { const id = useId(); const progressbarItemId = `${id}-progressbar-item`; const [progress, setProgress] = useState(0); + // 🎯 Вычисляем адаптивные размеры + const itemCount = progressbarItems.length; + + // Динамический размер прогрессбаров в зависимости от количества + const getProgressbarSize = () => { + if (itemCount <= 2) return 80; // Большие для 1-2 элементов + if (itemCount === 3) return 60; // Средние для 3 элементов + return 50; // Маленькие для 4+ элементов + }; + + // Определяем нужен ли вертикальный layout + const shouldUseVerticalLayout = itemCount > 3; + + const progressbarSize = getProgressbarSize(); + useEffect(() => { let delay = transitionDurationItem / 100; if (progress && progress % 100 === 0) { @@ -74,12 +91,14 @@ export default function CircularProgressbarsList({
{Array.from({ length: progressbarItems.length * 2 - 1 }).map( @@ -93,37 +112,51 @@ export default function CircularProgressbarsList({ return (
{isItemCompleted && ( - + )}
{progressbarItem[itemState]?.title && ( )} @@ -131,9 +164,10 @@ export default function CircularProgressbarsList({ @@ -143,12 +177,18 @@ export default function CircularProgressbarsList({ ); } + // 🎯 В вертикальном режиме или если showDividers = false, пропускаем разделители + if (shouldUseVerticalLayout || !showDividers) { + return null; + } + return (
I agree to the{" "} diff --git a/src/lib/admin/builder/context.tsx b/src/lib/admin/builder/context.tsx index 7b7c330..a3dd4da 100644 --- a/src/lib/admin/builder/context.tsx +++ b/src/lib/admin/builder/context.tsx @@ -26,21 +26,28 @@ const INITIAL_SCREEN: BuilderScreen = { id: "screen-1", template: "list", header: { - progress: { - current: 1, - total: 1, - label: "1 of 1", - }, + show: true, + showBackButton: true, }, title: { text: "Новый экран", font: "manrope", weight: "bold", + align: "left", + size: "2xl", + color: "default", }, subtitle: { text: "Добавьте детали справа", - color: "muted", - font: "inter", + font: "manrope", + weight: "medium", + color: "default", + align: "left", + size: "lg", + }, + bottomActionButton: { + text: "Продолжить", + show: true, }, list: { selectionType: "single", @@ -88,6 +95,7 @@ type BuilderAction = navigation: { defaultNextScreenId?: string | null; rules?: NavigationRuleDefinition[]; + isEndScreen?: boolean; }; }; } @@ -120,22 +128,27 @@ function createScreenByTemplate(template: ScreenDefinition["template"], id: stri show: true, showBackButton: true, }, - // ✅ Базовые тексты + // ✅ Базовые тексты согласно Figma title: { text: "Новый экран", font: "manrope" as const, weight: "bold" as const, + align: "left" as const, + size: "2xl" as const, + color: "default" as const, }, subtitle: { text: "Добавьте детали справа", - color: "muted" as const, - font: "inter" as const, + font: "manrope" as const, + weight: "medium" as const, + color: "default" as const, + align: "left" as const, + size: "lg" as const, }, // ✅ Единые настройки нижней кнопки bottomActionButton: { text: "Продолжить", show: true, - showGradientBlur: true, }, // ✅ Навигация navigation: { @@ -146,17 +159,26 @@ function createScreenByTemplate(template: ScreenDefinition["template"], id: stri switch (template) { case "info": + // Деструктурируем baseScreen исключая subtitle для InfoScreenDefinition + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { subtitle, ...baseScreenWithoutSubtitle } = baseScreen; return { - ...baseScreen, + ...baseScreenWithoutSubtitle, template: "info", + title: { + text: "Заголовок информации", + font: "manrope" as const, + weight: "bold" as const, + align: "center" as const, // 🎯 Центрированный заголовок по умолчанию + size: "2xl" as const, + color: "default" as const, + }, + // 🚫 Подзаголовок не включается (InfoScreenDefinition не поддерживает subtitle) description: { - text: "Добавьте описание для информационного экрана", - }, - icon: { - type: "emoji" as const, - value: "ℹ️", - size: "md" as const, + text: "Добавьте описание для информационного экрана. Используйте **жирный текст** для выделения важного.", + align: "center" as const, // 🎯 Центрированный текст }, + // 🚫 Иконка не добавляется по умолчанию - пользователь может добавить при необходимости }; case "list": @@ -214,6 +236,26 @@ function createScreenByTemplate(template: ScreenDefinition["template"], id: stri return { ...baseScreen, template: "coupon", + header: { + show: true, + showBackButton: true, + // Без прогресс-бара по умолчанию + }, + title: { + text: "Ваш промокод", + font: "manrope" as const, + weight: "bold" as const, + align: "center" as const, // 🎯 Центрированный заголовок по умолчанию + size: "2xl" as const, + color: "default" as const, + }, + subtitle: { + text: "Специальное предложение для вас", + font: "inter" as const, + weight: "medium" as const, + align: "center" as const, // 🎯 Центрированный подзаголовок по умолчанию + color: "muted" as const, + }, coupon: { title: { text: "Ваш промокод готов!", @@ -234,6 +276,124 @@ function createScreenByTemplate(template: ScreenDefinition["template"], id: stri }, }, copiedMessage: "Промокод скопирован!", + bottomActionButton: { + text: "Продолжить", + show: true, + // 🚫 БЕЗ PrivacyTermsConsent по умолчанию для купонов + }, + }; + + case "email": + return { + ...baseScreen, + template: "email", + header: { + show: true, + showBackButton: true, // ✅ Только кнопка назад, прогресс отключен + }, + title: { + text: "Портрет твоей второй половинки готов! Куда нам его отправить?", + font: "manrope" as const, + weight: "bold" as const, + align: "center" as const, + size: "2xl" as const, + color: "default" as const, + }, + subtitle: undefined, // 🚫 Нет подзаголовка по умолчанию + emailInput: { + label: "Email", + placeholder: "Enter your Email", + }, + image: { + src: "/female-portrait.jpg", // 🎯 Дефолтная картинка для женщин + }, + variants: [ + { + // 🎯 Вариативность: для мужчин показывать другую картинку + conditions: [ + { + screenId: "gender", // Ссылка на экран выбора пола + conditionType: "values", + operator: "equals", + values: ["male"] // Если выбран мужской пол + } + ], + overrides: { + image: { + src: "/male-portrait.jpg", // 🎯 Картинка для мужчин + } + } + } + ], + bottomActionButton: { + text: "Получить результат", + show: true, + showPrivacyTermsConsent: true, // ✅ По умолчанию включено для email экранов + }, + }; + + case "loaders": + return { + ...baseScreen, + template: "loaders", + title: { + text: "Создаем ваш персональный отчет", + font: "manrope" as const, + weight: "bold" as const, + align: "center" as const, + size: "2xl" as const, + color: "default" as const, + }, + subtitle: undefined, // 🚫 Убираем подзаголовок по умолчанию + progressbars: { + items: [ + { + title: "Анализ ответов", + processingTitle: "Анализируем ваши ответы...", + completedTitle: "Анализ завершен", + }, + { + title: "Поиск совпадений", + processingTitle: "Ищем идеальные совпадения...", + completedTitle: "Совпадения найдены", + }, + { + title: "Создание портрета", + processingTitle: "Создаем ваш портрет...", + completedTitle: "Портрет готов", + }, + ], + transitionDuration: 5000, + }, + }; + + case "soulmate": + // Деструктурируем baseScreen исключая subtitle для SoulmatePortraitScreenDefinition + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { subtitle: soulmateSubtitle, ...baseSoulmateScreen } = baseScreen; + return { + ...baseSoulmateScreen, + template: "soulmate", + header: { + show: false, // ✅ Header показываем для заголовка + showBackButton: false, + }, + // 🎯 ТОЛЬКО заголовок по центру как в оригинале SoulmatePortrait + title: { + text: "Ваш идеальный партнер", + font: "manrope" as const, + weight: "bold" as const, + size: "xl" as const, + color: "primary" as const, // 🎯 text-primary как в оригинале + align: "center" as const, // 🎯 По центру + className: "leading-[125%]", // 🎯 Как в оригинале + }, + // 🚫 Никакого description - ТОЛЬКО заголовок и кнопка! + bottomActionButton: { + text: "Получить портрет", + show: true, + showPrivacyTermsConsent: true, // ✅ По умолчанию включено для soulmate экранов + }, }; default: @@ -269,9 +429,31 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat const newScreen = createScreenByTemplate(template, nextId, position); + // 🎯 АВТОМАТИЧЕСКОЕ СВЯЗЫВАНИЕ С ПРЕДЫДУЩИМ ЭКРАНОМ + let updatedScreens = [...state.screens, newScreen]; + + // Если есть предыдущий экран и у него нет defaultNextScreenId, связываем с новым + if (state.screens.length > 0) { + const lastScreen = state.screens[state.screens.length - 1]; + if (!lastScreen.navigation?.defaultNextScreenId) { + // Обновляем предыдущий экран, чтобы он указывал на новый + updatedScreens = updatedScreens.map(screen => + screen.id === lastScreen.id + ? { + ...screen, + navigation: { + ...screen.navigation, + defaultNextScreenId: nextId, + } + } + : screen + ); + } + } + return withDirty(state, { ...state, - screens: [...state.screens, newScreen], + screens: updatedScreens, selectedScreenId: newScreen.id, meta: { ...state.meta, @@ -518,6 +700,7 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat navigation: { defaultNextScreenId: navigation.defaultNextScreenId ?? undefined, rules: navigation.rules ?? [], + isEndScreen: navigation.isEndScreen, }, } : screen diff --git a/src/lib/admin/builder/utils.ts b/src/lib/admin/builder/utils.ts index f72d757..4de8b65 100644 --- a/src/lib/admin/builder/utils.ts +++ b/src/lib/admin/builder/utils.ts @@ -74,7 +74,9 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial ({ screenId: condition.screenId, operator: condition.operator, - optionIds: [...condition.optionIds], + conditionType: condition.conditionType, + ...(condition.optionIds ? { optionIds: [...condition.optionIds] } : {}), + ...(condition.values ? { values: [...condition.values] } : {}), })), ...(variant.overrides ? { overrides: deepCloneValue(variant.overrides) as ScreenVariantDefinition["overrides"] } @@ -90,7 +92,9 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial ({ screenId: condition.screenId, operator: condition.operator, - optionIds: [...condition.optionIds], + conditionType: condition.conditionType, + ...(condition.optionIds ? { optionIds: [...condition.optionIds] } : {}), + ...(condition.values ? { values: [...condition.values] } : {}), })), })), } diff --git a/src/lib/admin/builder/validation.ts b/src/lib/admin/builder/validation.ts index b9b73ec..9174d81 100644 --- a/src/lib/admin/builder/validation.ts +++ b/src/lib/admin/builder/validation.ts @@ -73,11 +73,17 @@ function validateNavigation(screen: BuilderScreen, state: BuilderState, issues: return; } + // 🎯 ФИНАЛЬНЫЕ ЭКРАНЫ НЕ ТРЕБУЮТ НАВИГАЦИИ + if (navigation.isEndScreen) { + // Финальный экран - навигация не нужна + return; + } + if (!navigation.defaultNextScreenId && (!navigation.rules || navigation.rules.length === 0)) { issues.push( createIssue( "warning", - `Экран \`${screen.id}\` не ведёт на следующий экран. Добавьте переход по умолчанию или правило.`, + `Экран \`${screen.id}\` не ведёт на следующий экран. Добавьте переход по умолчанию, правило, или отметьте как финальный экран.`, { screenId: screen.id } ) ); diff --git a/src/lib/age-utils.ts b/src/lib/age-utils.ts new file mode 100644 index 0000000..2b2f394 --- /dev/null +++ b/src/lib/age-utils.ts @@ -0,0 +1,175 @@ +/** + * Утилиты для работы с возрастом в WitLab Funnel + * + * Функции для расчета возраста из даты рождения и создания возрастных групп + * для использования в системе вариативности воронки + */ + +// 🎂 ВОЗРАСТНЫЕ ГРУППЫ для маркетинга и персонализации +export const AGE_GROUPS = [ + { id: "18-21", name: "18-21 год", min: 18, max: 21, description: "Студенческий возраст" }, + { id: "22-25", name: "22-25 лет", min: 22, max: 25, description: "Молодые профессионалы" }, + { id: "26-30", name: "26-30 лет", min: 26, max: 30, description: "Карьерный рост" }, + { id: "31-35", name: "31-35 лет", min: 31, max: 35, description: "Становление личности" }, + { id: "36-40", name: "36-40 лет", min: 36, max: 40, description: "Зрелость и стабильность" }, + { id: "41-45", name: "41-45 лет", min: 41, max: 45, description: "Средний возраст" }, + { id: "46-50", name: "46-50 лет", min: 46, max: 50, description: "Жизненный опыт" }, + { id: "51-60", name: "51-60 лет", min: 51, max: 60, description: "Зрелые отношения" }, + { id: "60+", name: "60+ лет", min: 60, max: 120, description: "Золотой возраст" }, +] as const; + +// 🎯 МИЛЛЕНИАЛЫ, ПОКОЛЕНИЯ для маркетинговой сегментации +export const GENERATION_GROUPS = [ + { id: "gen-z", name: "Поколение Z", minYear: 1997, maxYear: 2012, description: "Цифровые аборигены" }, + { id: "millennials", name: "Миллениалы", minYear: 1981, maxYear: 1996, description: "Поколение интернета" }, + { id: "gen-x", name: "Поколение X", minYear: 1965, maxYear: 1980, description: "Поколение перемен" }, + { id: "boomers", name: "Бумеры", minYear: 1946, maxYear: 1964, description: "Послевоенное поколение" }, + { id: "silent", name: "Молчаливое поколение", minYear: 1928, maxYear: 1945, description: "Довоенное поколение" }, +] as const; + +/** + * Рассчитывает возраст из даты рождения + */ +export function calculateAge(birthDate: Date, referenceDate: Date = new Date()): number { + let age = referenceDate.getFullYear() - birthDate.getFullYear(); + const monthDiff = referenceDate.getMonth() - birthDate.getMonth(); + + // Если день рождения еще не наступил в этом году + if (monthDiff < 0 || (monthDiff === 0 && referenceDate.getDate() < birthDate.getDate())) { + age--; + } + + return Math.max(0, age); +} + +/** + * Рассчитывает возраст из массива [month, day, year] + */ +export function calculateAgeFromArray(dateArray: number[], referenceDate: Date = new Date()): number { + if (!Array.isArray(dateArray) || dateArray.length !== 3) { + return 0; + } + + const [month, day, year] = dateArray; + + // Валидация входных данных + if (!month || !day || !year || + month < 1 || month > 12 || + day < 1 || day > 31 || + year < 1900 || year > new Date().getFullYear()) { + return 0; + } + + const birthDate = new Date(year, month - 1, day); // month - 1 для JavaScript Date + return calculateAge(birthDate, referenceDate); +} + +/** + * Определяет возрастную группу по возрасту + */ +export function getAgeGroup(age: number): typeof AGE_GROUPS[number] | null { + return AGE_GROUPS.find(group => age >= group.min && age <= group.max) || null; +} + +/** + * Определяет поколение по году рождения + */ +export function getGeneration(birthYear: number): typeof GENERATION_GROUPS[number] | null { + const currentYear = new Date().getFullYear(); + + // Валидация года + if (birthYear < 1900 || birthYear > currentYear) { + return null; + } + + return GENERATION_GROUPS.find(gen => birthYear >= gen.minYear && birthYear <= gen.maxYear) || null; +} + +/** + * Определяет поколение из массива [month, day, year] + */ +export function getGenerationFromArray(dateArray: number[]): typeof GENERATION_GROUPS[number] | null { + if (!Array.isArray(dateArray) || dateArray.length !== 3) { + return null; + } + + const [, , year] = dateArray; + return getGeneration(year); +} + +/** + * Создает возрастное значение для системы навигации + * Возвращает строку которая будет использоваться в условиях + */ +export function createAgeValue(age: number): string { + const ageGroup = getAgeGroup(age); + return ageGroup ? ageGroup.id : `age-${age}`; +} + +/** + * Создает значение поколения для системы навигации + */ +export function createGenerationValue(birthYear: number): string { + const generation = getGeneration(birthYear); + return generation ? generation.id : `year-${birthYear}`; +} + +/** + * Парсит возрастной диапазон из строки (например "26-30" -> {min: 26, max: 30}) + */ +export function parseAgeRange(ageRangeId: string): { min: number; max: number } | null { + // Для конкретного возраста (например "age-25") + if (ageRangeId.startsWith('age-')) { + const age = parseInt(ageRangeId.replace('age-', '')); + if (isNaN(age)) return null; + return { min: age, max: age }; + } + + // Для диапазона (например "26-30") + const match = ageRangeId.match(/^(\d+)-(\d+|\+)$/); + if (!match) return null; + + const min = parseInt(match[1]); + const max = match[2] === '+' ? 120 : parseInt(match[2]); + + if (isNaN(min) || isNaN(max)) return null; + + return { min, max }; +} + +/** + * Проверяет, попадает ли возраст в указанный диапазон + */ +export function isAgeInRange(age: number, ageRangeId: string): boolean { + const range = parseAgeRange(ageRangeId); + if (!range) return false; + + return age >= range.min && age <= range.max; +} + +/** + * Примеры использования для документации + */ +export const AGE_EXAMPLES = [ + { + input: [4, 8, 1987], // 8 апреля 1987 + age: calculateAgeFromArray([4, 8, 1987]), + ageGroup: getAgeGroup(calculateAgeFromArray([4, 8, 1987]))?.name, + generation: getGenerationFromArray([4, 8, 1987])?.name, + description: "Миллениал среднего возраста" + }, + { + input: [12, 15, 2000], // 15 декабря 2000 + age: calculateAgeFromArray([12, 15, 2000]), + ageGroup: getAgeGroup(calculateAgeFromArray([12, 15, 2000]))?.name, + generation: getGenerationFromArray([12, 15, 2000])?.name, + description: "Поколение Z" + }, + { + input: [7, 22, 1975], // 22 июля 1975 + age: calculateAgeFromArray([7, 22, 1975]), + ageGroup: getAgeGroup(calculateAgeFromArray([7, 22, 1975]))?.name, + generation: getGenerationFromArray([7, 22, 1975])?.name, + description: "Поколение X" + } +] as const; diff --git a/src/lib/funnel/bakedFunnels.ts b/src/lib/funnel/bakedFunnels.ts index beb40c5..62e49a9 100644 --- a/src/lib/funnel/bakedFunnels.ts +++ b/src/lib/funnel/bakedFunnels.ts @@ -688,7 +688,8 @@ export const BAKED_FUNNELS: Record = { }, "defaultTexts": { "nextButton": "Next", - "continueButton": "Continue" + "continueButton": "Continue", + "privacyBanner": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем." }, "screens": [ { @@ -737,28 +738,80 @@ export const BAKED_FUNNELS: Record = { "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" diff --git a/src/lib/funnel/mappers.tsx b/src/lib/funnel/mappers.tsx index ded708d..77ba39c 100644 --- a/src/lib/funnel/mappers.tsx +++ b/src/lib/funnel/mappers.tsx @@ -1,5 +1,6 @@ import type { TypographyProps } from "@/components/ui/Typography/Typography"; import type { MainButtonProps } from "@/components/ui/MainButton/MainButton"; +import { hasTextMarkup } from "@/lib/text-markup"; import type { HeaderDefinition, @@ -48,6 +49,7 @@ export function buildTypographyProps( align: variant.align ?? defaults?.align, color: variant.color ?? defaults?.color, className: variant.className, + enableMarkup: hasTextMarkup(variant.text || ''), // 🎨 АВТОМАТИЧЕСКИ включаем разметку если обнаружена } as TypographyProps; } @@ -169,23 +171,12 @@ export function buildLayoutQuestionProps( subtitleDefaults = { font: "inter", weight: "medium", color: "muted", align: "left" }, canGoBack, onBack, - actionButtonOptions, screenProgress } = options; const showBackButton = shouldShowBackButton(screen.header, canGoBack); const showHeader = shouldShowHeader(screen.header); - const bottomActionButtonProps = actionButtonOptions ? buildBottomActionButtonProps( - actionButtonOptions, - // Если передаются actionButtonOptions, это означает что кнопка должна показываться - // Если кнопка отключена (show: false), принудительно включаем её - 'bottomActionButton' in screen ? - (screen.bottomActionButton?.show === false ? { ...screen.bottomActionButton, show: true } : screen.bottomActionButton) - : undefined - ) : undefined; - - return { headerProps: showHeader ? { progressProps: screenProgress ? buildHeaderProgress({ @@ -207,7 +198,25 @@ export function buildLayoutQuestionProps( as: "p", defaults: subtitleDefaults, }) : undefined, - bottomActionButtonProps, }; } +// Отдельная функция для получения bottomActionButtonProps +export function buildTemplateBottomActionButtonProps( + options: BuildLayoutQuestionOptions +) { + const { + screen, + actionButtonOptions + } = options; + + return actionButtonOptions ? buildBottomActionButtonProps( + actionButtonOptions, + // Если передаются actionButtonOptions, это означает что кнопка должна показываться + // Принудительно включаем её независимо от настроек экрана + 'bottomActionButton' in screen ? + (screen.bottomActionButton?.show === false ? { ...screen.bottomActionButton, show: true } : screen.bottomActionButton) + : undefined + ) : undefined; +} + diff --git a/src/lib/funnel/navigation.ts b/src/lib/funnel/navigation.ts index d49b39a..f7df33b 100644 --- a/src/lib/funnel/navigation.ts +++ b/src/lib/funnel/navigation.ts @@ -1,41 +1,99 @@ import { FunnelAnswers, NavigationConditionDefinition, NavigationRuleDefinition, ScreenDefinition } from "./types"; +import { calculateAgeFromArray, createAgeValue, createGenerationValue } from "@/lib/age-utils"; +import { getZodiacSign } from "@/lib/funnel/zodiac"; -function getScreenAnswers(answers: FunnelAnswers, screenId: string): string[] { - return answers[screenId] ?? []; +/** + * Расширенная функция получения ответов экрана + * Автоматически рассчитывает возраст и знак зодиака для date экранов + */ +function getScreenAnswers(answers: FunnelAnswers, screenId: string, allScreens?: ScreenDefinition[]): string[] { + const rawAnswers = answers[screenId] ?? []; + + // 🎯 ОСОБАЯ ЛОГИКА для date экранов - автоматически добавляем рассчитанные значения + const screen = allScreens?.find(s => s.id === screenId); + if (screen?.template === "date" && rawAnswers.length === 3) { + const [month, day, year] = rawAnswers.map(Number); + + // Валидируем дату + if (month >= 1 && month <= 12 && day >= 1 && day <= 31 && year >= 1900) { + const dateArray = [month, day, year]; + const enhancedAnswers = [...rawAnswers]; + + try { + // 🎂 Добавляем возрастные значения + const age = calculateAgeFromArray(dateArray); + if (age > 0) { + enhancedAnswers.push( + createAgeValue(age), // "26-30" + `age-${age}`, // "age-25" + createGenerationValue(year) // "millennials" + ); + } + + // ♈ Добавляем знак зодиака + const zodiac = getZodiacSign(month, day); + if (zodiac) { + enhancedAnswers.push(zodiac); // "aries" + } + } catch (error) { + // В случае ошибки возвращаем исходные ответы + console.warn('Error calculating age/zodiac from date:', error); + } + + return enhancedAnswers; + } + } + + return rawAnswers; } function satisfiesCondition( condition: NavigationConditionDefinition, - answers: FunnelAnswers + answers: FunnelAnswers, + allScreens?: ScreenDefinition[] ): boolean { - const selected = new Set(getScreenAnswers(answers, condition.screenId)); - const expected = new Set(condition.optionIds ?? []); + const selected = new Set(getScreenAnswers(answers, condition.screenId, allScreens)); const operator = condition.operator ?? "includesAny"; + const conditionType = condition.conditionType ?? "options"; - if (expected.size === 0) { + // 🎯 НОВАЯ ЛОГИКА: поддержка values для любых экранов + const expectedValues = conditionType === "values" + ? new Set(condition.values ?? []) + : new Set(condition.optionIds ?? []); + + if (expectedValues.size === 0) { return false; } switch (operator) { case "includesAny": { - return condition.optionIds.some((id) => selected.has(id)); + return Array.from(expectedValues).some((value) => selected.has(value)); } case "includesAll": { - return condition.optionIds.every((id) => selected.has(id)); + return Array.from(expectedValues).every((value) => selected.has(value)); } case "includesExactly": { - if (selected.size !== expected.size) { + if (selected.size !== expectedValues.size) { return false; } - for (const id of expected) { - if (!selected.has(id)) { + for (const value of expectedValues) { + if (!selected.has(value)) { return false; } } return true; } + case "equals": { + // 🎯 НОВЫЙ ОПЕРАТОР: точное совпадение для одиночных значений + const selectedArray = Array.from(selected); + const expectedArray = Array.from(expectedValues); + + return selectedArray.length === 1 && + expectedArray.length === 1 && + selectedArray[0] === expectedArray[0]; + } default: return false; } @@ -43,17 +101,18 @@ function satisfiesCondition( export function matchesNavigationConditions( conditions: NavigationConditionDefinition[] | undefined, - answers: FunnelAnswers + answers: FunnelAnswers, + allScreens?: ScreenDefinition[] ): boolean { if (!conditions || conditions.length === 0) { return false; } - return conditions.every((condition) => satisfiesCondition(condition, answers)); + return conditions.every((condition) => satisfiesCondition(condition, answers, allScreens)); } -function satisfiesRule(rule: NavigationRuleDefinition, answers: FunnelAnswers): boolean { - return matchesNavigationConditions(rule.conditions, answers); +function satisfiesRule(rule: NavigationRuleDefinition, answers: FunnelAnswers, allScreens?: ScreenDefinition[]): boolean { + return matchesNavigationConditions(rule.conditions, answers, allScreens); } export function resolveNextScreenId( @@ -65,7 +124,7 @@ export function resolveNextScreenId( if (navigation?.rules) { for (const rule of navigation.rules) { - if (satisfiesRule(rule, answers)) { + if (satisfiesRule(rule, answers, orderedScreens)) { return rule.nextScreenId; } } diff --git a/src/lib/funnel/screenRenderer.tsx b/src/lib/funnel/screenRenderer.tsx index ecc0758..818eba2 100644 --- a/src/lib/funnel/screenRenderer.tsx +++ b/src/lib/funnel/screenRenderer.tsx @@ -7,12 +7,18 @@ import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate"; import { DateTemplate } from "@/components/funnel/templates/DateTemplate"; import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate"; import { FormTemplate } from "@/components/funnel/templates/FormTemplate"; +import { EmailTemplate } from "@/components/funnel/templates/EmailTemplate"; +import { LoadersTemplate } from "@/components/funnel/templates/LoadersTemplate"; +import { SoulmatePortraitTemplate } from "@/components/funnel/templates/SoulmatePortraitTemplate"; import type { ListScreenDefinition, DateScreenDefinition, FormScreenDefinition, CouponScreenDefinition, InfoScreenDefinition, + EmailScreenDefinition, + LoadersScreenDefinition, + SoulmatePortraitScreenDefinition, ScreenDefinition, } from "@/lib/funnel/types"; @@ -161,6 +167,57 @@ const TEMPLATE_REGISTRY: Record /> ); }, + email: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + const emailScreen = screen as EmailScreenDefinition; + + // For email screens, we store email as single string in first element + const selectedEmail = selectedOptionIds[0] || ""; + + const handleEmailChange = (email: string) => { + onSelectionChange([email]); + }; + + return ( + + ); + }, + loaders: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + const loadersScreen = screen as LoadersScreenDefinition; + + return ( + + ); + }, + soulmate: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => { + const soulmateScreen = screen as SoulmatePortraitScreenDefinition; + + return ( + + ); + }, }; export function renderScreen(props: ScreenRenderProps): JSX.Element { diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index 58c2106..b125a7b 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -51,24 +51,38 @@ export interface BottomActionButtonDefinition { show?: boolean; text?: string; cornerRadius?: "3xl" | "full"; + /** Controls whether PrivacyTermsConsent should be shown under the button. Defaults to false. */ + showPrivacyTermsConsent?: boolean; } export interface DefaultTexts { nextButton?: string; // "Next" continueButton?: string; // "Continue" + privacyBanner?: string; // "Мы не передаем личную информацию..." } - export interface NavigationConditionDefinition { screenId: string; /** - * - includesAny: at least one option id is selected. - * - includesAll: all option ids are selected. - * - includesExactly: selection matches the provided set exactly (order-independent). + * Тип условия: + * - options: проверка выбранных опций в списках + * - values: проверка конкретных значений (зодиак, email, дата, etc.) */ - operator?: "includesAny" | "includesAll" | "includesExactly"; - optionIds: string[]; + conditionType?: "options" | "values"; + /** + * - includesAny: at least one option/value is present. + * - includesAll: all of the options/values are present. + * - includesExactly: only the specified options/values are present. + * - equals: точное совпадение значения (для одиночных значений) + */ + operator?: "includesAny" | "includesAll" | "includesExactly" | "equals"; + + // Для list экранов (legacy, но поддерживается) + optionIds?: string[]; + + // Для любых экранов - универсальные значения + values?: string[]; } export interface NavigationRuleDefinition { @@ -77,8 +91,9 @@ export interface NavigationRuleDefinition { } export interface NavigationDefinition { - rules?: NavigationRuleDefinition[]; defaultNextScreenId?: string; + rules?: NavigationRuleDefinition[]; + isEndScreen?: boolean; // Указывает что это финальный экран воронки } type ScreenVariantOverrides = Partial>; @@ -208,7 +223,62 @@ export interface ListScreenDefinition { variants?: ScreenVariantDefinition[]; } -export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition; +// Email Screen Definition +export interface EmailScreenDefinition { + id: string; + template: "email"; + header?: HeaderDefinition; + title: TypographyVariant; + subtitle?: TypographyVariant; + emailInput: { + placeholder?: string; + label?: string; + }; + image?: { + src: string; // Единственное настраиваемое поле - остальное зашито в коде + }; + bottomActionButton?: BottomActionButtonDefinition; + navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; +} + +// Loaders Screen Definition +export interface LoadersScreenDefinition { + id: string; + template: "loaders"; + header?: HeaderDefinition; + title: TypographyVariant; + subtitle?: TypographyVariant; + progressbars: { + items: Array<{ + title?: string; + subtitle?: string; + processingTitle?: string; + processingSubtitle?: string; + completedTitle?: string; + completedSubtitle?: string; + }>; + transitionDuration?: number; // в миллисекундах, по умолчанию 5000 + }; + bottomActionButton?: BottomActionButtonDefinition; + navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; +} + +// SoulmatePortrait Screen Definition +export interface SoulmatePortraitScreenDefinition { + id: string; + template: "soulmate"; + header?: HeaderDefinition; + title: TypographyVariant; + subtitle?: TypographyVariant; + description?: TypographyVariant; // 🎯 Настраиваемый текст описания + bottomActionButton?: BottomActionButtonDefinition; + navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; +} + +export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition | EmailScreenDefinition | LoadersScreenDefinition | SoulmatePortraitScreenDefinition; export interface FunnelMetaDefinition { id: string; diff --git a/src/lib/text-markup.ts b/src/lib/text-markup.ts new file mode 100644 index 0000000..2213b80 --- /dev/null +++ b/src/lib/text-markup.ts @@ -0,0 +1,109 @@ +/** + * Универсальная система разметки текста для WitLab Funnel + * + * Поддерживаемые теги: + * **текст** - жирный текст + * + * Примеры использования: + * "Добро пожаловать в **WitLab**!" → "Добро пожаловать в WitLab!" + * "**50%** скидка только сегодня" → "50% скидка только сегодня" + */ + +export interface TextMarkupSegment { + type: 'text' | 'bold'; + content: string; +} + +/** + * Парсит текст с разметкой **bold** и возвращает массив сегментов + */ +export function parseTextMarkup(text: string): TextMarkupSegment[] { + if (!text || typeof text !== 'string') { + return [{ type: 'text', content: text || '' }]; + } + + const segments: TextMarkupSegment[] = []; + const boldRegex = /\*\*(.*?)\*\*/g; + let lastIndex = 0; + let match; + + while ((match = boldRegex.exec(text)) !== null) { + const matchStart = match.index; + const matchEnd = boldRegex.lastIndex; + const boldContent = match[1]; + + // Добавляем обычный текст перед жирным (если есть) + if (matchStart > lastIndex) { + const normalText = text.slice(lastIndex, matchStart); + if (normalText) { + segments.push({ type: 'text', content: normalText }); + } + } + + // Добавляем жирный текст + if (boldContent) { + segments.push({ type: 'bold', content: boldContent }); + } + + lastIndex = matchEnd; + } + + // Добавляем оставшийся обычный текст + if (lastIndex < text.length) { + const remainingText = text.slice(lastIndex); + if (remainingText) { + segments.push({ type: 'text', content: remainingText }); + } + } + + // Если нет жирного текста, возвращаем весь текст как обычный + if (segments.length === 0) { + segments.push({ type: 'text', content: text }); + } + + return segments; +} + +/** + * Проверяет, содержит ли текст разметку + */ +export function hasTextMarkup(text: string): boolean { + return /\*\*(.*?)\*\*/g.test(text || ''); +} + +/** + * Убирает разметку из текста, оставляя только чистый текст + */ +export function stripTextMarkup(text: string): string { + if (!text || typeof text !== 'string') { + return text || ''; + } + + return text.replace(/\*\*(.*?)\*\*/g, '$1'); +} + +/** + * Примеры для тестирования + */ +export const MARKUP_EXAMPLES = [ + { + input: "Добро пожаловать в **WitLab**!", + description: "Выделение названия продукта" + }, + { + input: "**50%** скидка только сегодня", + description: "Выделение процента скидки" + }, + { + input: "Ваш **идеальный партнер** найден!", + description: "Выделение ключевой фразы" + }, + { + input: "**Анализ завершен** - переходим к результатам", + description: "Выделение статуса" + }, + { + input: "Поздравляем, **Анна**! Ваш портрет готов.", + description: "Выделение имени пользователя" + } +] as const;