merge
This commit is contained in:
parent
0de2ab8381
commit
b57e99e472
292
AGE_SYSTEM.md
Normal file
292
AGE_SYSTEM.md
Normal file
@ -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
|
||||
<AgeSelector
|
||||
selectedValues={["22-25", "millennials"]}
|
||||
onToggleValue={(value) => 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) => (
|
||||
<button onClick={() => onToggleValue(group.id)}>
|
||||
🎂 {group.name} - {group.description}
|
||||
</button>
|
||||
))}
|
||||
```
|
||||
|
||||
#### **3. Валидация диапазонов:**
|
||||
```typescript
|
||||
// Проверка попадания возраста в диапазон
|
||||
isAgeInRange(25, "22-25"); // → true
|
||||
isAgeInRange(30, "18-21"); // → false
|
||||
```
|
||||
|
||||
## Преимущества
|
||||
|
||||
### ✅ **Автоматизация:**
|
||||
- Пользователь вводит только дату рождения
|
||||
- Система автоматически рассчитывает все значения
|
||||
- Не нужно спрашивать возраст отдельно
|
||||
|
||||
### ✅ **Гибкость:**
|
||||
- Поддержка любых возрастных диапазонов
|
||||
- Кастомные значения (25-35, 40+)
|
||||
- Комбинации с другими условиями
|
||||
|
||||
### ✅ **UX дружелюбность:**
|
||||
- Понятные названия групп
|
||||
- Описания для каждой группы
|
||||
- Иконки для поколений
|
||||
- Превью выбранных условий
|
||||
|
||||
### ✅ **Маркетинговая сегментация:**
|
||||
- Готовые возрастные группы для таргетинга
|
||||
- Поколенческая сегментация
|
||||
- Психологические портреты по возрасту
|
||||
|
||||
## Совместимость
|
||||
|
||||
- ✅ **React 18+**
|
||||
- ✅ **TypeScript 5+**
|
||||
- ✅ **Next.js 14+**
|
||||
- ✅ **Существующая система зодиака**
|
||||
- ✅ **Система вариативности**
|
||||
- ✅ **Обратная совместимость** с существующими воронками
|
||||
|
||||
**💡 Теперь можно создавать условия навигации на основе возраста пользователя, автоматически рассчитанного из даты рождения!**
|
||||
180
MARKUP.md
Normal file
180
MARKUP.md
Normal file
@ -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 автоматически поддерживают разметку
|
||||
<Typography enableMarkup={hasTextMarkup(text)}>
|
||||
{text}
|
||||
</Typography>
|
||||
```
|
||||
|
||||
#### 3. **Превью в админке:**
|
||||
```typescript
|
||||
// В админке показывается живое превью разметки
|
||||
<MarkupPreview text="Ваш **идеальный** партнер" />
|
||||
```
|
||||
|
||||
## Примеры для разных типов экранов
|
||||
|
||||
### 📋 **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/`):**
|
||||
- `<MarkupText>` - рендеринг текста с разметкой
|
||||
- `<MarkupPreview>` - превью в админке
|
||||
- `useHasMarkup()` - React хук для проверки
|
||||
|
||||
#### 3. **Интеграция (`Typography.tsx`):**
|
||||
- Автоматическая активация при обнаружении разметки
|
||||
- Параметр `enableMarkup` для ручного управления
|
||||
- Совместимость с существующими стилями
|
||||
|
||||
### 📝 **Пример кода:**
|
||||
```tsx
|
||||
// Автоматическое использование
|
||||
<Typography>
|
||||
Ваш **идеальный** партнер найден!
|
||||
</Typography>
|
||||
|
||||
// Ручное управление
|
||||
<Typography enableMarkup={true}>
|
||||
Обычный текст с **выделением**
|
||||
</Typography>
|
||||
|
||||
// Прямое использование MarkupText
|
||||
<MarkupText as="h1" boldClassName="text-primary">
|
||||
**WitLab** - найди свою любовь!
|
||||
</MarkupText>
|
||||
```
|
||||
|
||||
## Лучшие практики
|
||||
|
||||
### ✅ **Хорошо:**
|
||||
- `"Ваш **идеальный** партнер найден!"` - выделение ключевых слов
|
||||
- `"**50%** скидка только сегодня"` - выделение цифр и акций
|
||||
- `"Поздравляем, **Анна**!"` - выделение имен
|
||||
|
||||
### ❌ **Избегайте:**
|
||||
- `"**Весь текст жирный**"` - потеря контраста
|
||||
- `"**Сл**ов**ом** **разб**ит**ые**"` - нечитаемость
|
||||
- `"****"` - пустые выделения
|
||||
|
||||
### 💡 **Советы:**
|
||||
1. **Выделяйте ключевые слова** - имена, проценты, важные понятия
|
||||
2. **Соблюдайте баланс** - не более 20% текста должно быть жирным
|
||||
3. **Тестируйте в превью** - используйте MarkupPreview в админке
|
||||
4. **Учитывайте контекст** - в заголовках выделение менее заметно
|
||||
|
||||
## Совместимость
|
||||
|
||||
- ✅ **React 18+**
|
||||
- ✅ **TypeScript 5+**
|
||||
- ✅ **Next.js 14+**
|
||||
- ✅ **Tailwind CSS**
|
||||
- ✅ **Обратная совместимость** - существующий текст работает без изменений
|
||||
25
package-lock.json
generated
25
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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: "Купон",
|
||||
|
||||
166
src/components/admin/builder/AgeDemo.tsx
Normal file
166
src/components/admin/builder/AgeDemo.tsx
Normal file
@ -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<string[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">🎂 Система работы с возрастом WitLab</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Автоматический расчет возраста и поколений из даты рождения для системы вариативности
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 📖 ПРИМЕРЫ РАСЧЕТОВ */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">📖 Примеры автоматических расчетов:</h2>
|
||||
|
||||
{AGE_EXAMPLES.map((example, index) => (
|
||||
<div key={index} className="space-y-2 p-4 bg-muted/20 rounded-lg border">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{example.description}
|
||||
</div>
|
||||
|
||||
{/* Исходная дата */}
|
||||
<div className="text-xs text-muted-foreground bg-slate-100 p-2 rounded font-mono">
|
||||
Дата: [{example.input.join(', ')}] ({example.input[1]}.{example.input[0]}.{example.input[2]})
|
||||
</div>
|
||||
|
||||
{/* Рассчитанные значения */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong>Возраст:</strong> {example.age} лет
|
||||
<br />
|
||||
<strong>Группа:</strong> {example.ageGroup || 'Не определена'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Поколение:</strong> {example.generation || 'Не определено'}
|
||||
<br />
|
||||
<strong>Значения:</strong> {[
|
||||
createAgeValue(example.age),
|
||||
`age-${example.age}`,
|
||||
createGenerationValue(example.input[2])
|
||||
].join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 🎯 ИНТЕРАКТИВНЫЙ СЕЛЕКТОР */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">🎯 Интерактивный селектор возраста:</h2>
|
||||
|
||||
<AgeSelector
|
||||
selectedValues={selectedValues}
|
||||
onToggleValue={toggleValue}
|
||||
onAddCustomValue={addCustomValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 💡 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-blue-800">💡 Как использовать в условиях навигации:</h3>
|
||||
|
||||
<div className="space-y-2 text-xs text-blue-700">
|
||||
<div className="bg-white p-2 rounded border">
|
||||
<strong>Пример 1 - Возрастные группы:</strong>
|
||||
<pre className="mt-1 text-xs">
|
||||
{`{
|
||||
"conditions": [{
|
||||
"screenId": "birth-date",
|
||||
"conditionType": "values",
|
||||
"operator": "includesAny",
|
||||
"values": ["22-25", "26-30"] // Молодые профессионалы
|
||||
}]
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-2 rounded border">
|
||||
<strong>Пример 2 - Поколения:</strong>
|
||||
<pre className="mt-1 text-xs">
|
||||
{`{
|
||||
"conditions": [{
|
||||
"screenId": "birth-date",
|
||||
"conditionType": "values",
|
||||
"operator": "equals",
|
||||
"values": ["millennials"] // Только миллениалы
|
||||
}]
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-2 rounded border">
|
||||
<strong>Пример 3 - Комбинированные условия:</strong>
|
||||
<pre className="mt-1 text-xs">
|
||||
{`{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "birth-date",
|
||||
"conditionType": "values",
|
||||
"operator": "includesAny",
|
||||
"values": ["aries", "leo", "sagittarius"] // Огненные знаки
|
||||
},
|
||||
{
|
||||
"screenId": "birth-date",
|
||||
"conditionType": "values",
|
||||
"operator": "includesAny",
|
||||
"values": ["22-25", "26-30"] // Молодые взрослые
|
||||
}
|
||||
]
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🚀 ВОЗМОЖНОСТИ СИСТЕМЫ */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-green-800">🚀 Автоматические значения из даты рождения:</h3>
|
||||
<ul className="text-xs text-green-700 space-y-1">
|
||||
<li>✅ <strong>Точный возраст:</strong> age-25, age-30, age-45</li>
|
||||
<li>✅ <strong>Возрастные группы:</strong> 18-21, 22-25, 26-30, 31-35, 36-40, 41-45, 46-50, 51-60, 60+</li>
|
||||
<li>✅ <strong>Поколения:</strong> gen-z, millennials, gen-x, boomers, silent</li>
|
||||
<li>✅ <strong>Знаки зодиака:</strong> aries, taurus, gemini, cancer, leo, virgo, libra, scorpio, sagittarius, capricorn, aquarius, pisces</li>
|
||||
<li>✅ <strong>Кастомные диапазоны:</strong> 25-35, 40+, любые пользовательские значения</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 📋 ТЕХНИЧЕСКИЕ ДЕТАЛИ */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">📋 Как это работает технически:</h3>
|
||||
<ul className="text-xs text-gray-700 space-y-1">
|
||||
<li><strong>1. Пользователь вводит дату:</strong> [4, 8, 1987] в date экране</li>
|
||||
<li><strong>2. Система рассчитывает:</strong> возраст = {calculateAgeFromArray([4, 8, 1987])} лет</li>
|
||||
<li><strong>3. Определяет группу:</strong> {getAgeGroup(calculateAgeFromArray([4, 8, 1987]))?.name}</li>
|
||||
<li><strong>4. Определяет поколение:</strong> {getGenerationFromArray([4, 8, 1987])?.name}</li>
|
||||
<li><strong>5. Добавляет в ответы:</strong> все вычисленные значения автоматически</li>
|
||||
<li><strong>6. Система навигации:</strong> использует эти значения для условий</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
241
src/components/admin/builder/AgeSelector.tsx
Normal file
241
src/components/admin/builder/AgeSelector.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { AGE_GROUPS, GENERATION_GROUPS, parseAgeRange } from "@/lib/age-utils";
|
||||
|
||||
interface AgeSelectorProps {
|
||||
selectedValues: string[];
|
||||
onToggleValue: (value: string) => void;
|
||||
onAddCustomValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export function AgeSelector({ selectedValues, onToggleValue, onAddCustomValue }: AgeSelectorProps) {
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
const handleAddCustom = () => {
|
||||
if (customValue.trim()) {
|
||||
onAddCustomValue(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const isValueSelected = (value: string) => selectedValues.includes(value);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 🎂 ВОЗРАСТНЫЕ ГРУППЫ */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-foreground">🎂 Возрастные группы</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AGE_GROUPS.map((group) => {
|
||||
const isSelected = isValueSelected(group.id);
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => onToggleValue(group.id)}
|
||||
className={`
|
||||
relative group p-3 rounded-lg border-2 transition-all duration-200
|
||||
hover:shadow-md text-left
|
||||
${isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border hover:border-primary/50"
|
||||
}
|
||||
`}
|
||||
title={group.description}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Возрастной диапазон */}
|
||||
<span className="text-lg">🎂</span>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium text-sm ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{group.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор выбранного */}
|
||||
{isSelected && (
|
||||
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🚀 ПОКОЛЕНИЯ */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-foreground">🚀 Поколения</h4>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{GENERATION_GROUPS.map((generation) => {
|
||||
const isSelected = isValueSelected(generation.id);
|
||||
return (
|
||||
<button
|
||||
key={generation.id}
|
||||
onClick={() => onToggleValue(generation.id)}
|
||||
className={`
|
||||
relative group p-3 rounded-lg border-2 transition-all duration-200
|
||||
hover:shadow-md text-left
|
||||
${isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border hover:border-primary/50"
|
||||
}
|
||||
`}
|
||||
title={`Родились ${generation.minYear}-${generation.maxYear}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Иконка поколения */}
|
||||
<span className="text-lg">
|
||||
{generation.id === 'gen-z' ? '📱' :
|
||||
generation.id === 'millennials' ? '💻' :
|
||||
generation.id === 'gen-x' ? '📺' :
|
||||
generation.id === 'boomers' ? '📻' : '📰'}
|
||||
</span>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium text-sm ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}>
|
||||
{generation.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{generation.minYear}-{generation.maxYear} • {generation.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор выбранного */}
|
||||
{isSelected && (
|
||||
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Или добавить точный возраст/диапазон:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
placeholder="25, 18-21, 30-35, 60+ и т.д."
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCustom}
|
||||
disabled={!customValue.trim()}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Подсказки по форматам */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<strong>Примеры:</strong> 25 (точный возраст), 18-21 (диапазон), 60+ (от 60 лет), age-25 (альтернативный формат)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */}
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Выбранные возрастные условия:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedValues.map((value) => {
|
||||
// Ищем в возрастных группах
|
||||
const ageGroup = AGE_GROUPS.find(group => group.id === value);
|
||||
if (ageGroup) {
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
|
||||
>
|
||||
🎂 {ageGroup.name}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Ищем в поколениях
|
||||
const generation = GENERATION_GROUPS.find(gen => gen.id === value);
|
||||
if (generation) {
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full"
|
||||
>
|
||||
🚀 {generation.name}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Кастомное значение
|
||||
const range = parseAgeRange(value);
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full"
|
||||
>
|
||||
🎯 {range ? `${range.min}-${range.max === 120 ? '+' : range.max}` : value}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 💡 ПОДСКАЗКА */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
<strong>💡 Как это работает:</strong> Система автоматически рассчитывает возраст из
|
||||
даты рождения пользователя. Выберите возрастные группы или поколения, при которых
|
||||
должен показываться этот вариант экрана. Можно комбинировать разные условия.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -31,12 +31,16 @@ const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
|
||||
info: "Инфо",
|
||||
date: "Дата",
|
||||
coupon: "Купон",
|
||||
email: "Email",
|
||||
loaders: "Загрузка",
|
||||
soulmate: "Портрет партнера",
|
||||
};
|
||||
|
||||
const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
|
||||
includesAny: "любой из",
|
||||
includesAll: "все из",
|
||||
includesExactly: "точное совпадение",
|
||||
equals: "равно",
|
||||
};
|
||||
|
||||
interface TransitionRowProps {
|
||||
@ -533,8 +537,20 @@ export function BuilderCanvas() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<TransitionRow
|
||||
type={defaultNext ? "default" : "end"}
|
||||
label={defaultNext ? "По умолчанию" : "Завершение"}
|
||||
type={
|
||||
screen.navigation?.isEndScreen
|
||||
? "end"
|
||||
: defaultNext
|
||||
? "default"
|
||||
: "end"
|
||||
}
|
||||
label={
|
||||
screen.navigation?.isEndScreen
|
||||
? "🏁 Финальный экран"
|
||||
: defaultNext
|
||||
? "По умолчанию"
|
||||
: "Завершение"
|
||||
}
|
||||
targetLabel={defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : undefined}
|
||||
targetIndex={defaultNext && defaultTargetIndex !== -1 ? defaultTargetIndex : null}
|
||||
/>
|
||||
|
||||
@ -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() {
|
||||
</Section>
|
||||
|
||||
<Section title="Навигация">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
|
||||
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{screenOptions
|
||||
.filter((screen) => screen.id !== selectedScreen.id)
|
||||
.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScreen.navigation?.isEndScreen ?? false}
|
||||
onChange={(e) => {
|
||||
updateNavigation(selectedScreen, { isEndScreen: e.target.checked });
|
||||
}}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">Финальный экран</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Этот экран завершает воронку (переход не требуется)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
|
||||
{!selectedScreen.navigation?.isEndScreen && (
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
|
||||
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{screenOptions
|
||||
.filter((screen) => screen.id !== selectedScreen.id)
|
||||
.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{selectedScreenIsListType && (
|
||||
{selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
|
||||
<Section title="Правила переходов" description="Условная навигация">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
159
src/components/admin/builder/EmailDomainSelector.tsx
Normal file
159
src/components/admin/builder/EmailDomainSelector.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
|
||||
// 📧 ПОПУЛЯРНЫЕ EMAIL ДОМЕНЫ
|
||||
const POPULAR_DOMAINS = [
|
||||
{ id: "@gmail.com", name: "Gmail", icon: "📧", description: "Google Mail" },
|
||||
{ id: "@yahoo.com", name: "Yahoo", icon: "🟣", description: "Yahoo Mail" },
|
||||
{ id: "@hotmail.com", name: "Hotmail", icon: "🔵", description: "Microsoft Hotmail" },
|
||||
{ id: "@outlook.com", name: "Outlook", icon: "📬", description: "Microsoft Outlook" },
|
||||
{ id: "@icloud.com", name: "iCloud", icon: "☁️", description: "Apple iCloud" },
|
||||
{ id: "@mail.ru", name: "Mail.ru", icon: "🔴", description: "Mail.ru" },
|
||||
{ id: "@yandex.ru", name: "Yandex", icon: "🟡", description: "Яндекс.Почта" },
|
||||
{ id: "@rambler.ru", name: "Rambler", icon: "🟢", description: "Rambler" },
|
||||
] as const;
|
||||
|
||||
interface EmailDomainSelectorProps {
|
||||
selectedValues: string[];
|
||||
onToggleValue: (value: string) => void;
|
||||
onAddCustomValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export function EmailDomainSelector({ selectedValues, onToggleValue, onAddCustomValue }: EmailDomainSelectorProps) {
|
||||
const [customDomain, setCustomDomain] = useState("");
|
||||
|
||||
const handleAddCustom = () => {
|
||||
let domain = customDomain.trim();
|
||||
if (domain) {
|
||||
// Автоматически добавляем @ если его нет
|
||||
if (!domain.startsWith("@")) {
|
||||
domain = "@" + domain;
|
||||
}
|
||||
onAddCustomValue(domain);
|
||||
setCustomDomain("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 📧 ПОПУЛЯРНЫЕ ДОМЕНЫ */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{POPULAR_DOMAINS.map((domain) => {
|
||||
const isSelected = selectedValues.includes(domain.id);
|
||||
return (
|
||||
<button
|
||||
key={domain.id}
|
||||
onClick={() => onToggleValue(domain.id)}
|
||||
className={`
|
||||
relative group p-3 rounded-lg border-2 transition-all duration-200
|
||||
hover:shadow-md text-left
|
||||
${isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border hover:border-primary/50"
|
||||
}
|
||||
`}
|
||||
title={domain.description}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Иконка */}
|
||||
<span className="text-lg">{domain.icon}</span>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium text-sm ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}>
|
||||
{domain.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{domain.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Индикатор выбранного */}
|
||||
{isSelected && (
|
||||
<div className="w-4 h-4 bg-primary rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ДОМЕНОВ */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Или добавить другой домен:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
placeholder="example.com (@ добавится автоматически)"
|
||||
value={customDomain}
|
||||
onChange={(e) => setCustomDomain(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCustom}
|
||||
disabled={!customDomain.trim()}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 📋 ВЫБРАННЫЕ ДОМЕНЫ */}
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Выбранные домены:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedValues.map((value) => {
|
||||
const popularDomain = POPULAR_DOMAINS.find(domain => domain.id === value);
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
|
||||
>
|
||||
{popularDomain ? (
|
||||
<>
|
||||
<span>{popularDomain.icon}</span>
|
||||
<span>{popularDomain.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>📧 {value}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 💡 ПОДСКАЗКА */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
<strong>💡 Как это работает:</strong> Система проверяет домен email адреса пользователя.
|
||||
Например, если пользователь ввел “user@gmail.com”, то значение будет “@gmail.com”.
|
||||
Выберите домены, при которых должен показываться этот вариант экрана.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/admin/builder/MarkupDemo.tsx
Normal file
79
src/components/admin/builder/MarkupDemo.tsx
Normal file
@ -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 (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">🎨 Система разметки WitLab</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Используйте **двойные звездочки** для выделения текста жирным шрифтом
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 📖 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">📖 Примеры использования:</h2>
|
||||
|
||||
{MARKUP_EXAMPLES.map((example, index) => (
|
||||
<div key={index} className="space-y-2 p-4 bg-muted/20 rounded-lg border">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{example.description}
|
||||
</div>
|
||||
|
||||
{/* Исходный код */}
|
||||
<div className="text-xs text-muted-foreground bg-slate-100 p-2 rounded font-mono">
|
||||
{example.input}
|
||||
</div>
|
||||
|
||||
{/* Результат */}
|
||||
<div className="text-sm">
|
||||
<strong>Результат:</strong>{" "}
|
||||
<MarkupText>{example.input}</MarkupText>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 🎯 ИНТЕРАКТИВНОЕ ДЕМО */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">🎯 Интерактивное превью:</h2>
|
||||
|
||||
<MarkupPreview text="Ваш **идеальный партнер** найден! Скидка **50%** только сегодня." />
|
||||
|
||||
<MarkupPreview text="Добро пожаловать в **WitLab**! Система анализа совместимости нового поколения." />
|
||||
|
||||
<MarkupPreview text="**Анализ завершен** - переходим к результатам вашего **портрета партнера**." />
|
||||
</div>
|
||||
|
||||
{/* 💡 ИНСТРУКЦИИ */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-blue-800">💡 Как использовать в админке:</h3>
|
||||
<ul className="text-xs text-blue-700 space-y-1">
|
||||
<li>1. Откройте любой экран в админке</li>
|
||||
<li>2. В текстовых полях используйте **двойные звездочки** для выделения</li>
|
||||
<li>3. Система автоматически покажет превью разметки</li>
|
||||
<li>4. В воронке текст будет отображаться с жирным выделением</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 🚀 ПОДДЕРЖИВАЕМЫЕ ЭЛЕМЕНТЫ */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-green-800">🚀 Где работает разметка:</h3>
|
||||
<ul className="text-xs text-green-700 space-y-1">
|
||||
<li>✅ Заголовки и подзаголовки всех экранов</li>
|
||||
<li>✅ Описания в Info и Soulmate экранах</li>
|
||||
<li>✅ Информационные сообщения в Date экранах</li>
|
||||
<li>✅ Лейблы и placeholder в Form экранах</li>
|
||||
<li>✅ Все текстовые поля Coupon экранов</li>
|
||||
<li>✅ Любой компонент Typography с enableMarkup</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<VariantCondition>) => {
|
||||
updateVariant(index, {
|
||||
conditions: [
|
||||
{
|
||||
...ensureCondition(variants[index], screen.id),
|
||||
...updates,
|
||||
},
|
||||
],
|
||||
});
|
||||
(variantIndex: number, conditionIndex: number, updates: Partial<VariantCondition>) => {
|
||||
const variant = variants[variantIndex];
|
||||
const updatedConditions = [...variant.conditions];
|
||||
updatedConditions[conditionIndex] = {
|
||||
...ensureCondition(variant, screen.id),
|
||||
...variant.conditions[conditionIndex],
|
||||
...updates,
|
||||
};
|
||||
updateVariant(variantIndex, { conditions: updatedConditions });
|
||||
},
|
||||
[screen.id, updateVariant, variants]
|
||||
);
|
||||
|
||||
const addCondition = useCallback(
|
||||
(variantIndex: number) => {
|
||||
const variant = variants[variantIndex];
|
||||
const fallbackScreen = listScreens[0] ?? (screen.template === "list" ? (screen as ListBuilderScreen) : undefined);
|
||||
|
||||
if (!fallbackScreen) return;
|
||||
|
||||
const firstOptionId = fallbackScreen.list.options[0]?.id;
|
||||
const newCondition: VariantCondition = {
|
||||
screenId: fallbackScreen.id,
|
||||
operator: "includesAny",
|
||||
optionIds: firstOptionId ? [firstOptionId] : [],
|
||||
};
|
||||
|
||||
updateVariant(variantIndex, {
|
||||
conditions: [...variant.conditions, newCondition],
|
||||
});
|
||||
},
|
||||
[variants, listScreens, screen, updateVariant]
|
||||
);
|
||||
|
||||
const removeCondition = useCallback(
|
||||
(variantIndex: number, conditionIndex: number) => {
|
||||
const variant = variants[variantIndex];
|
||||
if (variant.conditions.length <= 1) return; // Минимум одно условие должно остаться
|
||||
|
||||
const updatedConditions = variant.conditions.filter((_, index) => index !== conditionIndex);
|
||||
updateVariant(variantIndex, { conditions: updatedConditions });
|
||||
},
|
||||
[variants, updateVariant]
|
||||
);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(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<ScreenDefinition["template"], string> = {
|
||||
list: "📝 Список",
|
||||
date: "📅 Дата рождения",
|
||||
email: "📧 Email",
|
||||
form: "📋 Форма",
|
||||
info: "ℹ️ Информация",
|
||||
coupon: "🎟️ Купон",
|
||||
loaders: "⏳ Загрузка",
|
||||
soulmate: "💖 Портрет",
|
||||
};
|
||||
|
||||
return templateLabels[targetScreen.template] || targetScreen.template;
|
||||
}, [availableScreens]);
|
||||
|
||||
const renderVariantSummary = useCallback(
|
||||
(variant: VariantDefinition) => {
|
||||
const condition = ensureCondition(variant, screen.id);
|
||||
const 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 (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-foreground">Экран условий:</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
{listScreenTitle ?? condition.screenId}
|
||||
<span className="font-semibold text-foreground">Условие:</span>
|
||||
<span className="rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-[11px] text-blue-700">
|
||||
{screenTypeLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground/80">{operatorLabel}</span>
|
||||
</div>
|
||||
{optionSummaries.length > 0 ? (
|
||||
<div className="text-[11px]">
|
||||
<span className="text-muted-foreground">Экран: </span>
|
||||
<span className="text-foreground font-medium">{screenTitle ?? condition.screenId}</span>
|
||||
</div>
|
||||
{summaries.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{optionSummaries.map((label) => (
|
||||
<span key={label} className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
|
||||
{label}
|
||||
{summaries.map((item) => (
|
||||
<span key={item} className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground/80">Пока нет выбранных ответов</div>
|
||||
<div className="text-muted-foreground/80">Пока нет выбранных значений</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((item) => (
|
||||
@ -288,7 +407,7 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[listScreens, optionMap, screen.id]
|
||||
[availableScreens, optionMap, screen.id, getScreenTypeLabel]
|
||||
);
|
||||
|
||||
return (
|
||||
@ -297,14 +416,14 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Настройте альтернативные варианты контента без изменения переходов.
|
||||
</p>
|
||||
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={addVariant} disabled={listScreens.length === 0}>
|
||||
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={addVariant} disabled={availableScreens.length === 0}>
|
||||
<span className="text-lg leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listScreens.length === 0 ? (
|
||||
{availableScreens.length === 0 ? (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Добавьте экран со списком, чтобы настроить вариативность.
|
||||
Добавьте другие экраны в воронку, чтобы настроить вариативность.
|
||||
</div>
|
||||
) : variants.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-4 text-center text-xs text-muted-foreground">
|
||||
@ -346,21 +465,54 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-4 border-t border-border/60 pt-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
<p><strong>Ограничение:</strong> Текущая версия админки поддерживает только одно условие на вариант. Реальная система поддерживает множественные условия через JSON.</p>
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3 text-xs text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-200">
|
||||
<p><strong>✨ Поддержка множественных условий:</strong> Теперь вы можете добавить несколько условий для одного варианта. Все условия должны выполняться одновременно (логическое И).</p>
|
||||
</div>
|
||||
|
||||
{/* 🎯 МНОЖЕСТВЕННЫЕ УСЛОВИЯ */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Условия ({variant.conditions.length})
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => addCondition(index)}
|
||||
disabled={availableScreens.length === 0}
|
||||
>
|
||||
+ Добавить условие
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{variant.conditions.map((condition, conditionIndex) => (
|
||||
<div key={conditionIndex} className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Условие #{conditionIndex + 1}
|
||||
</span>
|
||||
{variant.conditions.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-destructive"
|
||||
onClick={() => removeCondition(index, conditionIndex)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Экран условий</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={condition.screenId}
|
||||
onChange={(event) => handleScreenChange(index, event.target.value)}
|
||||
onChange={(event) => handleScreenChange(index, conditionIndex, event.target.value)}
|
||||
>
|
||||
{listScreens.map((candidate) => (
|
||||
{availableScreens.map((candidate) => (
|
||||
<option key={candidate.id} value={candidate.id}>
|
||||
{candidate.title.text}
|
||||
{getScreenTypeLabel(candidate.id)} - {candidate.title.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -371,43 +523,154 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={condition.operator ?? "includesAny"}
|
||||
onChange={(event) =>
|
||||
handleOperatorChange(index, event.target.value as VariantCondition["operator"])
|
||||
handleOperatorChange(index, conditionIndex, event.target.value as VariantCondition["operator"])
|
||||
}
|
||||
>
|
||||
<option value="includesAny">любой из</option>
|
||||
<option value="includesAll">все из</option>
|
||||
<option value="includesExactly">точное совпадение</option>
|
||||
<option value="equals">равно (для одиночных значений)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Ответы</span>
|
||||
{availableOptions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
В выбранном экране пока нет вариантов ответа.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{availableOptions.map((option) => {
|
||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||
return (
|
||||
<label key={option.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleOption(index, option.id)}
|
||||
{/* 🎯 НОВЫЙ UI: поддержка разных типов экранов */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
Условия для {getScreenTypeLabel(condition.screenId)}
|
||||
</span>
|
||||
|
||||
{(() => {
|
||||
const targetScreen = availableScreens.find(s => s.id === condition.screenId);
|
||||
|
||||
if (targetScreen?.template === "list") {
|
||||
// 📝 LIST ЭКРАНЫ - показываем опции
|
||||
return availableOptions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
В выбранном экране пока нет вариантов ответа.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{availableOptions.map((option) => {
|
||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||
return (
|
||||
<label key={option.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleOption(index, conditionIndex, option.id)}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground"> ({option.id})</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
} else if (targetScreen?.template === "date") {
|
||||
// 📅 DATE ЭКРАНЫ - показываем селекторы возраста и знаков зодиака
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 🎂 СЕЛЕКТОР ВОЗРАСТА */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-foreground mb-3">🎂 Возрастные условия</h5>
|
||||
<AgeSelector
|
||||
selectedValues={condition.values?.filter(v =>
|
||||
v.includes('age-') || v.includes('-') || ['gen-z', 'millennials', 'gen-x', 'boomers', 'silent'].includes(v)
|
||||
) ?? []}
|
||||
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
|
||||
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground"> ({option.id})</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ♈ СЕЛЕКТОР ЗНАКОВ ЗОДИАКА */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-foreground mb-3">♈ Знаки зодиака</h5>
|
||||
<ZodiacSelector
|
||||
selectedValues={condition.values?.filter(v =>
|
||||
!v.includes('age-') && !v.includes('-') && !['gen-z', 'millennials', 'gen-x', 'boomers', 'silent'].includes(v)
|
||||
) ?? []}
|
||||
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
|
||||
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (targetScreen?.template === "email") {
|
||||
// 📧 EMAIL ЭКРАНЫ - показываем селектор доменов
|
||||
return (
|
||||
<EmailDomainSelector
|
||||
selectedValues={condition.values ?? []}
|
||||
onToggleValue={(value) => toggleValue(index, conditionIndex, value)}
|
||||
onAddCustomValue={(value) => addCustomValue(index, conditionIndex, value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// 🎯 ОБЩИЕ ЭКРАНЫ - простой ввод значений
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-muted-foreground bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<strong>💡 Как работает:</strong> Для экранов типа “{targetScreen?.template}”
|
||||
система сравнивает сохраненные ответы пользователя с указанными значениями.
|
||||
</div>
|
||||
|
||||
{/* Показываем выбранные значения */}
|
||||
{(condition.values ?? []).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Выбранные значения:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(condition.values ?? []).map((value) => (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
|
||||
>
|
||||
{value}
|
||||
<button
|
||||
onClick={() => toggleValue(index, conditionIndex, value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Поле для добавления новых значений */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Добавить значение:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Введите значение для сравнения..."
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
if (value) {
|
||||
addCustomValue(index, conditionIndex, value);
|
||||
(e.target as HTMLInputElement).value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Настройка контента</span>
|
||||
|
||||
155
src/components/admin/builder/ZodiacSelector.tsx
Normal file
155
src/components/admin/builder/ZodiacSelector.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
|
||||
// 🔮 ЗНАКИ ЗОДИАКА С КРАСИВЫМИ ИКОНКАМИ
|
||||
const ZODIAC_SIGNS = [
|
||||
{ id: "aries", name: "Овен", icon: "♈", dates: "21 марта - 19 апреля" },
|
||||
{ id: "taurus", name: "Телец", icon: "♉", dates: "20 апреля - 20 мая" },
|
||||
{ id: "gemini", name: "Близнецы", icon: "♊", dates: "21 мая - 20 июня" },
|
||||
{ id: "cancer", name: "Рак", icon: "♋", dates: "21 июня - 22 июля" },
|
||||
{ id: "leo", name: "Лев", icon: "♌", dates: "23 июля - 22 августа" },
|
||||
{ id: "virgo", name: "Дева", icon: "♍", dates: "23 августа - 22 сентября" },
|
||||
{ id: "libra", name: "Весы", icon: "♎", dates: "23 сентября - 22 октября" },
|
||||
{ id: "scorpio", name: "Скорпион", icon: "♏", dates: "23 октября - 21 ноября" },
|
||||
{ id: "sagittarius", name: "Стрелец", icon: "♐", dates: "22 ноября - 21 декабря" },
|
||||
{ id: "capricorn", name: "Козерог", icon: "♑", dates: "22 декабря - 19 января" },
|
||||
{ id: "aquarius", name: "Водолей", icon: "♒", dates: "20 января - 18 февраля" },
|
||||
{ id: "pisces", name: "Рыбы", icon: "♓", dates: "19 февраля - 20 марта" },
|
||||
] as const;
|
||||
|
||||
interface ZodiacSelectorProps {
|
||||
selectedValues: string[];
|
||||
onToggleValue: (value: string) => void;
|
||||
onAddCustomValue: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ZodiacSelector({ selectedValues, onToggleValue, onAddCustomValue }: ZodiacSelectorProps) {
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
const handleAddCustom = () => {
|
||||
if (customValue.trim()) {
|
||||
onAddCustomValue(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 🔮 КРАСИВАЯ СЕТКА ЗНАКОВ ЗОДИАКА */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ZODIAC_SIGNS.map((sign) => {
|
||||
const isSelected = selectedValues.includes(sign.id);
|
||||
return (
|
||||
<button
|
||||
key={sign.id}
|
||||
onClick={() => onToggleValue(sign.id)}
|
||||
className={`
|
||||
relative group p-3 rounded-lg border-2 transition-all duration-200
|
||||
hover:shadow-md hover:scale-105 text-center
|
||||
${isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border hover:border-primary/50"
|
||||
}
|
||||
`}
|
||||
title={sign.dates}
|
||||
>
|
||||
{/* Иконка знака */}
|
||||
<div className={`text-2xl mb-1 transition-all duration-200 ${
|
||||
isSelected ? "scale-110" : "group-hover:scale-105"
|
||||
}`}>
|
||||
{sign.icon}
|
||||
</div>
|
||||
|
||||
{/* Название */}
|
||||
<div className={`text-xs font-medium transition-colors duration-200 ${
|
||||
isSelected ? "text-primary" : "text-foreground"
|
||||
}`}>
|
||||
{sign.name}
|
||||
</div>
|
||||
|
||||
{/* Индикатор выбранного */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-primary rounded-full flex items-center justify-center">
|
||||
<span className="text-[10px] text-white">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 🎯 ДОБАВЛЕНИЕ КАСТОМНЫХ ЗНАЧЕНИЙ */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Или добавить пользовательское значение:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
placeholder="Например: virgo или другое значение..."
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddCustom}
|
||||
disabled={!customValue.trim()}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 📋 ВЫБРАННЫЕ ЗНАЧЕНИЯ */}
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Выбранные значения:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedValues.map((value) => {
|
||||
const zodiacSign = ZODIAC_SIGNS.find(sign => sign.id === value);
|
||||
return (
|
||||
<span
|
||||
key={value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
|
||||
>
|
||||
{zodiacSign ? (
|
||||
<>
|
||||
<span>{zodiacSign.icon}</span>
|
||||
<span>{zodiacSign.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onToggleValue(value)}
|
||||
className="ml-1 hover:text-red-500 transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 💡 ПОДСКАЗКА */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
<strong>💡 Как это работает:</strong> Знак зодиака автоматически определяется из
|
||||
даты рождения пользователя. Выберите знаки, при которых должен показываться
|
||||
этот вариант экрана.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/admin/builder/templates/EmailScreenConfig.tsx
Normal file
79
src/components/admin/builder/templates/EmailScreenConfig.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { EmailScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface EmailScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "email" };
|
||||
onUpdate: (updates: Partial<EmailScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function EmailScreenConfig({ screen, onUpdate }: EmailScreenConfigProps) {
|
||||
const updateEmailInput = (updates: Partial<EmailScreenDefinition["emailInput"]>) => {
|
||||
onUpdate({
|
||||
emailInput: {
|
||||
...screen.emailInput,
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateImage = (updates: Partial<EmailScreenDefinition["image"]>) => {
|
||||
onUpdate({
|
||||
image: screen.image ? {
|
||||
...screen.image,
|
||||
...updates,
|
||||
} : { src: "", ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Настройки поля Email</h4>
|
||||
<div className="space-y-3">
|
||||
<TextInput
|
||||
label="Подпись поля"
|
||||
placeholder="Email"
|
||||
value={screen.emailInput?.label || ""}
|
||||
onChange={(e) => updateEmailInput({ label: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Плейсхолдер"
|
||||
placeholder="Enter your email"
|
||||
value={screen.emailInput?.placeholder || ""}
|
||||
onChange={(e) => updateEmailInput({ placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Изображение (вариативное)</h4>
|
||||
<div className="space-y-3">
|
||||
<TextInput
|
||||
label="URL изображения"
|
||||
placeholder="/female-portrait.jpg"
|
||||
value={screen.image?.src || ""}
|
||||
onChange={(e) => updateImage({ src: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground bg-blue-50 border border-blue-200 rounded-lg p-3 mt-3">
|
||||
<strong>💡 Вариация изображений:</strong> Базовое изображение настраивается здесь.
|
||||
Alt текст, размеры (164x245) и стили зашиты в верстку согласно дизайну.
|
||||
Альтернативные варианты настраиваются в секции “Вариативность” → добавить вариант → выбрать условие “gender = male” → переопределить поле image.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Информация</h4>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>• Банер безопасности отображается автоматически с общим текстом для воронки</p>
|
||||
<p>• PrivacyTermsConsent настраивается через bottomActionButton.showPrivacyTermsConsent</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Информационный контент
|
||||
</h3>
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
|
||||
<TextInput
|
||||
placeholder="Введите пояснение для пользователя"
|
||||
value={infoScreen.description?.text ?? ""}
|
||||
onChange={(event) => handleDescriptionChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
|
||||
<TextInput
|
||||
placeholder="Введите пояснение для пользователя. Используйте **текст** для выделения жирным."
|
||||
value={infoScreen.description?.text ?? ""}
|
||||
onChange={(event) => handleDescriptionChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 🎨 ПРЕВЬЮ РАЗМЕТКИ */}
|
||||
{infoScreen.description?.text && (
|
||||
<MarkupPreview text={infoScreen.description.text} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
167
src/components/admin/builder/templates/LoadersScreenConfig.tsx
Normal file
167
src/components/admin/builder/templates/LoadersScreenConfig.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { LoadersScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface LoadersScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "loaders" };
|
||||
onUpdate: (updates: Partial<LoadersScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigProps) {
|
||||
const updateProgressbars = (updates: Partial<LoadersScreenDefinition["progressbars"]>) => {
|
||||
onUpdate({
|
||||
progressbars: {
|
||||
items: screen.progressbars?.items || [],
|
||||
transitionDuration: screen.progressbars?.transitionDuration || 5000,
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addProgressbarItem = () => {
|
||||
const currentItems = screen.progressbars?.items || [];
|
||||
updateProgressbars({
|
||||
items: [
|
||||
...currentItems,
|
||||
{
|
||||
title: `Step ${currentItems.length + 1}`,
|
||||
subtitle: "",
|
||||
processingTitle: `Processing step ${currentItems.length + 1}...`,
|
||||
processingSubtitle: "",
|
||||
completedTitle: `Step ${currentItems.length + 1} completed`,
|
||||
completedSubtitle: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeProgressbarItem = (index: number) => {
|
||||
const currentItems = screen.progressbars?.items || [];
|
||||
updateProgressbars({
|
||||
items: currentItems.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
const updateProgressbarItem = (
|
||||
index: number,
|
||||
updates: Partial<LoadersScreenDefinition["progressbars"]["items"][0]>
|
||||
) => {
|
||||
const currentItems = screen.progressbars?.items || [];
|
||||
const updatedItems = currentItems.map((item, i) =>
|
||||
i === index ? { ...item, ...updates } : item
|
||||
);
|
||||
updateProgressbars({ items: updatedItems });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Настройки анимации</h4>
|
||||
<TextInput
|
||||
label="Длительность анимации (мс)"
|
||||
type="number"
|
||||
placeholder="5000"
|
||||
value={screen.progressbars?.transitionDuration?.toString() || "5000"}
|
||||
onChange={(e) => updateProgressbars({ transitionDuration: parseInt(e.target.value) || 5000 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">Шаги загрузки</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addProgressbarItem}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить шаг
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(screen.progressbars?.items || []).map((item, index) => (
|
||||
<div key={index} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-slate-600">Шаг {index + 1}</h5>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => removeProgressbarItem(index)}
|
||||
className="text-red-600 hover:text-red-700 text-sm px-2 py-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput
|
||||
label="Заголовок"
|
||||
placeholder="Step 1"
|
||||
value={item.title || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { title: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Подзаголовок"
|
||||
placeholder="Описание шага"
|
||||
value={item.subtitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { subtitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput
|
||||
label="Текст во время обработки"
|
||||
placeholder="Processing..."
|
||||
value={item.processingTitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { processingTitle: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Подтекст во время обработки"
|
||||
placeholder=""
|
||||
value={item.processingSubtitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { processingSubtitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextInput
|
||||
label="Текст при завершении"
|
||||
placeholder="Completed!"
|
||||
value={item.completedTitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { completedTitle: e.target.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Подтекст при завершении"
|
||||
placeholder=""
|
||||
value={item.completedSubtitle || ""}
|
||||
onChange={(e) => updateProgressbarItem(index, { completedSubtitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(screen.progressbars?.items || []).length === 0 && (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<p>Нет шагов загрузки</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addProgressbarItem}
|
||||
className="mt-2 text-sm px-3 py-1"
|
||||
>
|
||||
Добавить первый шаг
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface SoulmatePortraitScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "soulmate" };
|
||||
onUpdate: (updates: Partial<SoulmatePortraitScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortraitScreenConfigProps) {
|
||||
const updateDescription = (updates: Partial<SoulmatePortraitScreenDefinition["description"]>) => {
|
||||
onUpdate({
|
||||
description: screen.description ? {
|
||||
...screen.description,
|
||||
...updates,
|
||||
} : { text: "", ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Описание портрета</h4>
|
||||
<TextInput
|
||||
label="Текст описания"
|
||||
placeholder="Ваш идеальный партнер найден на основе анализа ваших ответов"
|
||||
value={screen.description?.text || ""}
|
||||
onChange={(e) => updateDescription({ text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Информация</h4>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>• PrivacyTermsConsent настраивается через bottomActionButton.showPrivacyTermsConsent</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-2">💡 Назначение экрана</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
Экран “Soulmate Portrait” предназначен для отображения результатов анализа совместимости
|
||||
или характеристик идеального партнера на основе ответов пользователя в воронке.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,9 @@ import { DateScreenConfig } from "./DateScreenConfig";
|
||||
import { CouponScreenConfig } from "./CouponScreenConfig";
|
||||
import { FormScreenConfig } from "./FormScreenConfig";
|
||||
import { ListScreenConfig } from "./ListScreenConfig";
|
||||
import { EmailScreenConfig } from "./EmailScreenConfig";
|
||||
import { LoadersScreenConfig } from "./LoadersScreenConfig";
|
||||
import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
@ -18,6 +21,9 @@ import type {
|
||||
CouponScreenDefinition,
|
||||
FormScreenDefinition,
|
||||
ListScreenDefinition,
|
||||
EmailScreenDefinition,
|
||||
LoadersScreenDefinition,
|
||||
SoulmatePortraitScreenDefinition,
|
||||
TypographyVariant,
|
||||
BottomActionButtonDefinition,
|
||||
HeaderDefinition,
|
||||
@ -260,6 +266,7 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
|
||||
const isEnabled = value?.show !== false;
|
||||
const buttonText = value?.text || '';
|
||||
const cornerRadius = value?.cornerRadius;
|
||||
const showPrivacyTermsConsent = value?.showPrivacyTermsConsent ?? false;
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
@ -305,7 +312,23 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
|
||||
};
|
||||
|
||||
// Убираем undefined поля для чистоты
|
||||
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false) {
|
||||
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivacyTermsToggle = (checked: boolean) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
const newValue = {
|
||||
...value,
|
||||
showPrivacyTermsConsent: checked || undefined,
|
||||
};
|
||||
|
||||
// Убираем undefined поля для чистоты
|
||||
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange(newValue);
|
||||
@ -349,6 +372,15 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPrivacyTermsConsent}
|
||||
onChange={(event) => handlePrivacyTermsToggle(event.target.checked)}
|
||||
/>
|
||||
Показывать PrivacyTermsConsent под кнопкой
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -422,6 +454,24 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
|
||||
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "email" && (
|
||||
<EmailScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "email" }}
|
||||
onUpdate={onUpdate as (updates: Partial<EmailScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "loaders" && (
|
||||
<LoadersScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "loaders" }}
|
||||
onUpdate={onUpdate as (updates: Partial<LoadersScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
{template === "soulmate" && (
|
||||
<SoulmatePortraitScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "soulmate" }}
|
||||
onUpdate={onUpdate as (updates: Partial<SoulmatePortraitScreenDefinition>) => void}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full flex flex-col items-center justify-center mt-[30px]">
|
||||
<div className="w-full flex flex-col items-center justify-center mt-[22px]">
|
||||
{/* Coupon Widget */}
|
||||
<div className="mb-8">
|
||||
<Coupon {...couponProps} />
|
||||
|
||||
@ -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;
|
||||
}) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left",
|
||||
"bg-white border border-slate-200 rounded-xl",
|
||||
"text-slate-900 placeholder:text-slate-400",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
||||
"transition-colors duration-200",
|
||||
"appearance-none cursor-pointer",
|
||||
"bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNiIgdmlld0JveD0iMCAwIDEwIDYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik05IDFMNS4wMDAwNyA1TDEgMSIgc3Ryb2tlPSIjNjQ3NDhCIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=')] bg-no-repeat bg-right-3 bg-center",
|
||||
"pr-10"
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Update parent when local state changes
|
||||
useEffect(() => {
|
||||
onDateChange({ month, day, year });
|
||||
}, [month, day, year, onDateChange]);
|
||||
|
||||
// Reset day if it's invalid for the selected month/year
|
||||
useEffect(() => {
|
||||
if (month && year && day) {
|
||||
const monthNum = parseInt(month);
|
||||
const yearNum = parseInt(year);
|
||||
const dayNum = parseInt(day);
|
||||
const daysInMonth = new Date(yearNum, monthNum, 0).getDate();
|
||||
|
||||
if (dayNum > daysInMonth) {
|
||||
setDay("");
|
||||
}
|
||||
}
|
||||
}, [month, year, day]);
|
||||
|
||||
// Sync with external state
|
||||
useEffect(() => {
|
||||
setMonth(selectedDate.month || "");
|
||||
setDay(selectedDate.day || "");
|
||||
setYear(selectedDate.year || "");
|
||||
}, [selectedDate]);
|
||||
|
||||
const isComplete = month && day && year;
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
// Преобразуем объект {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 (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full mt-[30px] space-y-6">
|
||||
{/* Date Input Fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-[1fr_1fr_1.2fr] gap-3">
|
||||
<SelectInput
|
||||
label={screen.dateInput.monthLabel || "Month"}
|
||||
placeholder={screen.dateInput.monthPlaceholder || "MM"}
|
||||
value={month}
|
||||
onChange={setMonth}
|
||||
options={monthOptions}
|
||||
/>
|
||||
<SelectInput
|
||||
label={screen.dateInput.dayLabel || "Day"}
|
||||
placeholder={screen.dateInput.dayPlaceholder || "DD"}
|
||||
value={day}
|
||||
onChange={setDay}
|
||||
options={dayOptions}
|
||||
/>
|
||||
<SelectInput
|
||||
label={screen.dateInput.yearLabel || "Year"}
|
||||
placeholder={screen.dateInput.yearPlaceholder || "YYYY"}
|
||||
value={year}
|
||||
onChange={setYear}
|
||||
options={yearOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.nextButton || "Next",
|
||||
disabled: !isFormValid,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
<div className="w-full mt-[22px] space-y-6">
|
||||
{/* Используем DateInput виджет разработчика */}
|
||||
<DateInput
|
||||
value={isoDate}
|
||||
onChange={handleDateChange}
|
||||
maxYear={new Date().getFullYear() - 11}
|
||||
yearsRange={100}
|
||||
locale="en"
|
||||
/>
|
||||
|
||||
{/* Info Message */}
|
||||
{/* Info Message если есть */}
|
||||
{screen.infoMessage && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<NextImage
|
||||
<Image
|
||||
src="/GuardIcon.svg"
|
||||
alt="Security icon"
|
||||
width={20}
|
||||
@ -244,28 +143,7 @@ export function DateTemplate({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Selected Date Display - positioned 18px above button with high z-index */}
|
||||
{screen.dateInput.showSelectedDate && formattedDate && (
|
||||
<div className="fixed bottom-[98px] left-0 right-0 text-center z-50">
|
||||
<div className="max-w-[560px] mx-auto px-6">
|
||||
<Typography
|
||||
as="p"
|
||||
className="text-[#64748B] text-[16px] font-normal leading-normal mb-1"
|
||||
>
|
||||
{screen.dateInput.selectedDateLabel || "Selected date:"}
|
||||
</Typography>
|
||||
<Typography
|
||||
as="p"
|
||||
className="text-[#1E293B] text-[18px] font-semibold leading-normal"
|
||||
>
|
||||
{formattedDate}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LayoutQuestion>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
|
||||
120
src/components/funnel/templates/EmailTemplate.tsx
Normal file
120
src/components/funnel/templates/EmailTemplate.tsx
Normal file
@ -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<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: selectedEmail || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("email", selectedEmail || "");
|
||||
}, [selectedEmail, form]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
form.setValue("email", value);
|
||||
form.trigger("email");
|
||||
onEmailChange(value);
|
||||
};
|
||||
|
||||
const isFormValid = form.formState.isValid && form.getValues("email");
|
||||
|
||||
return (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={undefined} // 🚫 Отключаем прогресс бар по умолчанию
|
||||
defaultTexts={defaultTexts}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.nextButton || "Continue",
|
||||
disabled: !isFormValid,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
{/* 🎨 Новая структура согласно требованиям */}
|
||||
<div className="w-full flex flex-col items-center gap-[26px]">
|
||||
{/* 📧 Email Input - с дефолтными значениями */}
|
||||
<TextInput
|
||||
label={screen.emailInput?.label || "Email"}
|
||||
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
|
||||
type="email"
|
||||
value={selectedEmail}
|
||||
onChange={handleChange}
|
||||
onBlur={() => {
|
||||
setIsTouched(true);
|
||||
form.trigger("email");
|
||||
}}
|
||||
aria-invalid={isTouched && !!form.formState.errors.email}
|
||||
aria-errormessage={
|
||||
isTouched ? form.formState.errors.email?.message : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 🖼️ Image - с зашитыми значениями как в оригинальном Email компоненте */}
|
||||
{screen.image && (
|
||||
<Image
|
||||
src={screen.image.src}
|
||||
alt="portrait" // Зашитое значение согласно дизайну
|
||||
width={164} // Зашитое значение согласно дизайну
|
||||
height={245} // Зашитое значение согласно дизайну
|
||||
className="mt-3.5 rounded-[50px] blur-sm" // Зашитые стили согласно дизайну
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 🔒 Privacy Security Banner */}
|
||||
<PrivacySecurityBanner
|
||||
className="mt-[26px]"
|
||||
text={{
|
||||
children: defaultTexts?.privacyBanner || "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full mt-[30px] space-y-4">
|
||||
<div className="w-full mt-[22px] space-y-4">
|
||||
{screen.fields.map((field) => (
|
||||
<div key={field.id}>
|
||||
<TextInput
|
||||
|
||||
@ -2,15 +2,10 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import {
|
||||
buildLayoutQuestionProps,
|
||||
buildTypographyProps,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import { buildTypographyProps } from "@/lib/funnel/mappers";
|
||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||
import { TemplateLayout } from "./TemplateLayout";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InfoTemplateProps {
|
||||
@ -30,19 +25,6 @@ export function InfoTemplate({
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: InfoTemplateProps) {
|
||||
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||
screen,
|
||||
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
|
||||
canGoBack,
|
||||
onBack,
|
||||
actionButtonOptions: {
|
||||
defaultText: defaultTexts?.nextButton || "Next",
|
||||
disabled: false,
|
||||
onClick: onContinue,
|
||||
},
|
||||
screenProgress,
|
||||
});
|
||||
|
||||
const iconSizeClasses = useMemo(() => {
|
||||
const size = screen.icon?.size ?? "xl";
|
||||
switch (size) {
|
||||
@ -59,8 +41,26 @@ export function InfoTemplate({
|
||||
}, [screen.icon?.size]);
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full flex flex-col items-center justify-center text-center mt-[60px]">
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "center" }}
|
||||
subtitleDefaults={{ font: "inter", weight: "medium", color: "muted", align: "center" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.nextButton || "Next",
|
||||
disabled: false,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-full flex flex-col items-center justify-center text-center",
|
||||
// 🔧 Уменьшили отступ: без иконки убираем лишнее пространство
|
||||
screen.icon ? "mt-[60px]" : "-mt-[20px]" // Отрицательный margin компенсирует mt-[30px] из LayoutQuestion
|
||||
)}>
|
||||
{/* Icon */}
|
||||
{screen.icon && (
|
||||
<div className={cn("mb-8", screen.icon.className)}>
|
||||
@ -88,11 +88,12 @@ export function InfoTemplate({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title - handled by LayoutQuestion */}
|
||||
|
||||
{/* Description */}
|
||||
{screen.description && (
|
||||
<div className="mt-6 max-w-[280px]">
|
||||
<div className={cn(
|
||||
"max-w-[280px]",
|
||||
screen.icon ? "mt-6" : "mt-0" // 🔧 Убираем отступ сверху для текста если нет иконки
|
||||
)}>
|
||||
<Typography
|
||||
as="p"
|
||||
font="inter"
|
||||
@ -116,6 +117,6 @@ export function InfoTemplate({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutQuestion>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<Question
|
||||
layoutQuestionProps={layoutQuestionProps}
|
||||
contentType={contentType}
|
||||
content={contentProps}
|
||||
/>
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
onContinue={() => {}} // Не используется, логика в 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}
|
||||
>
|
||||
<div className="w-full mt-[22px]">
|
||||
{contentType === "radio-answers-list" ? (
|
||||
<RadioAnswersList {...radioContent} />
|
||||
) : (
|
||||
<SelectAnswersList {...selectContent} />
|
||||
)}
|
||||
</div>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/components/funnel/templates/LoadersTemplate.tsx
Normal file
85
src/components/funnel/templates/LoadersTemplate.tsx
Normal file
@ -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 (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.nextButton || "Continue",
|
||||
disabled: !isVisibleButton, // 🎯 Кнопка неактивна пока анимация не завершится
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex flex-col items-center gap-[22px] mt-[22px]">
|
||||
<CircularProgressbarsList
|
||||
{...progressbarsListProps}
|
||||
showDividers={false} // 🚫 Убираем разделительные линии в экранах лоадера
|
||||
/>
|
||||
</div>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
45
src/components/funnel/templates/SoulmatePortraitTemplate.tsx
Normal file
45
src/components/funnel/templates/SoulmatePortraitTemplate.tsx
Normal file
@ -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 (
|
||||
<TemplateLayout
|
||||
screen={screen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "xl", color: "primary" }}
|
||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
|
||||
actionButtonOptions={{
|
||||
defaultText: defaultTexts?.nextButton || "Continue",
|
||||
disabled: false,
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
{/* 🎯 Точно как InfoTemplate - пустой контент, без иконки и description */}
|
||||
<div className="-mt-[20px]">
|
||||
{/* Пустой контент - как InfoTemplate без иконки и без description */}
|
||||
</div>
|
||||
</TemplateLayout>
|
||||
);
|
||||
}
|
||||
144
src/components/funnel/templates/TemplateLayout.tsx
Normal file
144
src/components/funnel/templates/TemplateLayout.tsx
Normal file
@ -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<HTMLDivElement>({
|
||||
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 ? (
|
||||
<PrivacyTermsConsent
|
||||
className="mt-5"
|
||||
privacyPolicy={{
|
||||
href: "/privacy",
|
||||
children: "Privacy Policy"
|
||||
}}
|
||||
termsOfUse={{
|
||||
href: "/terms",
|
||||
children: "Terms of use"
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Комбинируем переданный childrenUnderButton с автоматическим PrivacyTermsConsent
|
||||
const finalChildrenUnderButton = (
|
||||
<>
|
||||
{childrenUnderButton}
|
||||
{autoPrivacyTermsConsent}
|
||||
</>
|
||||
);
|
||||
|
||||
// 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
style={{ paddingBottom: `${bottomActionButtonHeight}px` }}
|
||||
>
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
{children}
|
||||
</LayoutQuestion>
|
||||
|
||||
{bottomActionButtonProps && (
|
||||
<BottomActionButton
|
||||
{...bottomActionButtonProps}
|
||||
ref={bottomActionButtonRef}
|
||||
childrenUnderButton={finalChildrenUnderButton}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -26,15 +26,10 @@ const meta: Meta<typeof LayoutQuestion> = {
|
||||
children: "Let's personalize your hair care journey",
|
||||
},
|
||||
children: (
|
||||
<div className="w-full mt-[30px] text-center p-8 bg-secondary">
|
||||
<div className="w-full flex flex-col justify-center items-center mt-[30px]">
|
||||
Children
|
||||
</div>
|
||||
),
|
||||
bottomActionButtonProps: {
|
||||
actionButtonProps: {
|
||||
children: "Continue",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
91
src/components/ui/MarkupText/MarkupText.tsx
Normal file
91
src/components/ui/MarkupText/MarkupText.tsx
Normal file
@ -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**
|
||||
*
|
||||
* Примеры использования:
|
||||
* <MarkupText>Добро пожаловать в **WitLab**!</MarkupText>
|
||||
* <MarkupText as="h1">**50%** скидка только сегодня</MarkupText>
|
||||
* <MarkupText boldClassName="text-primary">Ваш **идеальный партнер** найден!</MarkupText>
|
||||
*/
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Превью:
|
||||
</div>
|
||||
<div className="p-3 bg-muted/30 rounded-lg border">
|
||||
<MarkupText className="text-sm">
|
||||
{text}
|
||||
</MarkupText>
|
||||
</div>
|
||||
{hasTextMarkup(text) && (
|
||||
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded p-2">
|
||||
💡 <strong>Разметка обнаружена:</strong> Текст в **двойных звездочках** будет выделен жирным шрифтом.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<T extends keyof TypographyElements = "span"> =
|
||||
Omit<TypographyElements[T], "color"> & {
|
||||
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<T>) {
|
||||
const classes = cn(
|
||||
typographyVariants({ size, weight, color, align, font }),
|
||||
className
|
||||
);
|
||||
|
||||
// 🎨 Если включена разметка и это строка, используем MarkupText
|
||||
if (enableMarkup && typeof children === 'string') {
|
||||
return (
|
||||
<MarkupText
|
||||
as={Component}
|
||||
className={classes}
|
||||
boldClassName={weight === 'bold' ? undefined : 'font-bold'} // Наследуем стиль жирного текста
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MarkupText>
|
||||
);
|
||||
}
|
||||
|
||||
// 🎨 Обычный рендеринг без разметки
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
className: cn(
|
||||
typographyVariants({ size, weight, color, align, font }),
|
||||
className
|
||||
),
|
||||
className: classes,
|
||||
...props,
|
||||
},
|
||||
children
|
||||
|
||||
@ -39,11 +39,8 @@ const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
|
||||
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<HTMLDivElement, BottomActionButtonProps>(
|
||||
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 (
|
||||
<div
|
||||
|
||||
@ -41,6 +41,7 @@ export interface CircularProgressbarsListProps
|
||||
transitionDurationItem?: number; // in milliseconds
|
||||
animationDurationDivider?: number; // in milliseconds
|
||||
onAnimationEnd?: () => 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({
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full grid gap-1.5 items-start justify-items-center",
|
||||
"w-full flex items-center justify-center",
|
||||
shouldUseVerticalLayout
|
||||
? "flex-col gap-4"
|
||||
: "flex-row gap-2 flex-wrap justify-center",
|
||||
props.className
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${progressbarItems.length * 2 - 1}, 1fr)`,
|
||||
maxWidth: `${progressbarItems.length * 120}px`,
|
||||
style={shouldUseVerticalLayout ? {} : {
|
||||
maxWidth: `${Math.min(itemCount * (progressbarSize + 40), 320)}px`, // Адаптивная максимальная ширина
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: progressbarItems.length * 2 - 1 }).map(
|
||||
@ -93,37 +112,51 @@ export default function CircularProgressbarsList({
|
||||
return (
|
||||
<div
|
||||
key={`${progressbarItemId}-${index}`}
|
||||
className="flex flex-col items-center"
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
shouldUseVerticalLayout
|
||||
? "flex-row gap-4 w-full max-w-[280px]" // Горизонтальный в вертикальном режиме
|
||||
: "flex-col gap-2" // Вертикальный в горизонтальном режиме
|
||||
)}
|
||||
>
|
||||
<CircularProgressbar
|
||||
{...progressbarItem.circularProgressbarProps}
|
||||
transitionDuration={`${transitionDurationItem / 100}ms`}
|
||||
// transitionDelay={`${
|
||||
// ((transitionDurationItem + animationDurationDivider) *
|
||||
// index) /
|
||||
// 2
|
||||
// }ms`}
|
||||
value={itemProgress}
|
||||
size={60}
|
||||
// text={{
|
||||
// children: `${itemProgress}%`,
|
||||
// }}
|
||||
size={progressbarSize}
|
||||
// ✅ ПОКАЗЫВАЕМ ПРОЦЕНТЫ (раскомментировано)
|
||||
text={{
|
||||
children: `${itemProgress}%`,
|
||||
}}
|
||||
>
|
||||
{isItemCompleted && (
|
||||
<CheckIcon className="size-4" color="var(--primary)" />
|
||||
<CheckIcon
|
||||
className="size-4"
|
||||
color="var(--primary)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CircularProgressbar>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 w-[110px] mt-2",
|
||||
"max-[415px]:w-[80px]"
|
||||
"flex flex-col items-center gap-1",
|
||||
shouldUseVerticalLayout
|
||||
? "flex-1 items-start" // В вертикальном режиме текст слева
|
||||
: `w-[${Math.max(progressbarSize + 10, 80)}px] mt-2` // В горизонтальном - под кругом
|
||||
)}
|
||||
>
|
||||
{progressbarItem[itemState]?.title && (
|
||||
<Typography
|
||||
as="p"
|
||||
weight="semiBold"
|
||||
size="sm"
|
||||
size={shouldUseVerticalLayout ? "md" : "sm"}
|
||||
align={shouldUseVerticalLayout ? "left" : "center"}
|
||||
{...progressbarItem[itemState]?.title}
|
||||
/>
|
||||
)}
|
||||
@ -131,9 +164,10 @@ export default function CircularProgressbarsList({
|
||||
<Typography
|
||||
size="xs"
|
||||
color="muted"
|
||||
align={shouldUseVerticalLayout ? "left" : "center"}
|
||||
{...progressbarItem[itemState]?.text}
|
||||
className={cn(
|
||||
"mt-[-4px]",
|
||||
shouldUseVerticalLayout ? "mt-1" : "mt-[-4px]",
|
||||
progressbarItem[itemState]?.text.className
|
||||
)}
|
||||
/>
|
||||
@ -143,12 +177,18 @@ export default function CircularProgressbarsList({
|
||||
);
|
||||
}
|
||||
|
||||
// 🎯 В вертикальном режиме или если showDividers = false, пропускаем разделители
|
||||
if (shouldUseVerticalLayout || !showDividers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${progressbarItemId}-divider-${index}`}
|
||||
className={cn(
|
||||
"w-full h-[2px] mt-[31px] mx-[-25px]",
|
||||
"max-[415px]:mx-[-35px] max-w-[40px]",
|
||||
"h-[2px] flex-shrink-0",
|
||||
`w-[${Math.min(40, progressbarSize)}px]`,
|
||||
`mt-[${progressbarSize / 2 + 1}px]`,
|
||||
styles.divider
|
||||
)}
|
||||
style={{
|
||||
|
||||
@ -21,6 +21,7 @@ export default function PrivacyTermsConsent({
|
||||
font="inter"
|
||||
color="muted"
|
||||
{...props}
|
||||
align="center" // Принудительно центрируем - после props чтобы переопределить
|
||||
className={cn("[&_a]:font-medium", props.className)}
|
||||
>
|
||||
I agree to the{" "}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -74,7 +74,9 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderSc
|
||||
conditions: variant.conditions.map((condition) => ({
|
||||
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<ScreenDefinition>["overrides"] }
|
||||
@ -90,7 +92,9 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderSc
|
||||
conditions: rule.conditions.map((condition) => ({
|
||||
screenId: condition.screenId,
|
||||
operator: condition.operator,
|
||||
optionIds: [...condition.optionIds],
|
||||
conditionType: condition.conditionType,
|
||||
...(condition.optionIds ? { optionIds: [...condition.optionIds] } : {}),
|
||||
...(condition.values ? { values: [...condition.values] } : {}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
);
|
||||
|
||||
175
src/lib/age-utils.ts
Normal file
175
src/lib/age-utils.ts
Normal file
@ -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;
|
||||
@ -688,7 +688,8 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
||||
},
|
||||
"defaultTexts": {
|
||||
"nextButton": "Next",
|
||||
"continueButton": "Continue"
|
||||
"continueButton": "Continue",
|
||||
"privacyBanner": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
|
||||
},
|
||||
"screens": [
|
||||
{
|
||||
@ -737,28 +738,80 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
||||
"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"
|
||||
|
||||
@ -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<T extends TypographyAs>(
|
||||
align: variant.align ?? defaults?.align,
|
||||
color: variant.color ?? defaults?.color,
|
||||
className: variant.className,
|
||||
enableMarkup: hasTextMarkup(variant.text || ''), // 🎨 АВТОМАТИЧЕСКИ включаем разметку если обнаружена
|
||||
} as TypographyProps<T>;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ScreenDefinition["template"], TemplateRenderer>
|
||||
/>
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<EmailTemplate
|
||||
screen={emailScreen}
|
||||
selectedEmail={selectedEmail}
|
||||
onEmailChange={handleEmailChange}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
loaders: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
|
||||
const loadersScreen = screen as LoadersScreenDefinition;
|
||||
|
||||
return (
|
||||
<LoadersTemplate
|
||||
screen={loadersScreen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
soulmate: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
|
||||
const soulmateScreen = screen as SoulmatePortraitScreenDefinition;
|
||||
|
||||
return (
|
||||
<SoulmatePortraitTemplate
|
||||
screen={soulmateScreen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export function renderScreen(props: ScreenRenderProps): JSX.Element {
|
||||
|
||||
@ -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<T> = Partial<Omit<T, "id" | "template" | "variants">>;
|
||||
@ -208,7 +223,62 @@ export interface ListScreenDefinition {
|
||||
variants?: ScreenVariantDefinition<ListScreenDefinition>[];
|
||||
}
|
||||
|
||||
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<EmailScreenDefinition>[];
|
||||
}
|
||||
|
||||
// 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<LoadersScreenDefinition>[];
|
||||
}
|
||||
|
||||
// SoulmatePortrait Screen Definition
|
||||
export interface SoulmatePortraitScreenDefinition {
|
||||
id: string;
|
||||
template: "soulmate";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
description?: TypographyVariant; // 🎯 Настраиваемый текст описания
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<SoulmatePortraitScreenDefinition>[];
|
||||
}
|
||||
|
||||
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition | EmailScreenDefinition | LoadersScreenDefinition | SoulmatePortraitScreenDefinition;
|
||||
|
||||
export interface FunnelMetaDefinition {
|
||||
id: string;
|
||||
|
||||
109
src/lib/text-markup.ts
Normal file
109
src/lib/text-markup.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Универсальная система разметки текста для WitLab Funnel
|
||||
*
|
||||
* Поддерживаемые теги:
|
||||
* **текст** - жирный текст
|
||||
*
|
||||
* Примеры использования:
|
||||
* "Добро пожаловать в **WitLab**!" → "Добро пожаловать в <strong>WitLab</strong>!"
|
||||
* "**50%** скидка только сегодня" → "<strong>50%</strong> скидка только сегодня"
|
||||
*/
|
||||
|
||||
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;
|
||||
Loading…
Reference in New Issue
Block a user