This commit is contained in:
dev.daminik00 2025-09-28 04:41:13 +02:00
parent 0de2ab8381
commit b57e99e472
44 changed files with 3647 additions and 478 deletions

292
AGE_SYSTEM.md Normal file
View 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
View 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
View File

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

View File

@ -7,7 +7,8 @@
},
"defaultTexts": {
"nextButton": "Next",
"continueButton": "Continue"
"continueButton": "Continue",
"privacyBanner": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
},
"screens": [
{
@ -56,28 +57,80 @@
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "intro-partner-traits"
"defaultNextScreenId": "test-loaders"
}
},
{
"id": "intro-partner-traits",
"template": "info",
"header": {
"showBackButton": false
},
"id": "test-loaders",
"template": "loaders",
"title": {
"text": "Такой партнёр умеет слышать и поддерживать, а вы — человек с глубокой душой, который ценит искренность и силу настоящих чувств.",
"text": "Анализируем ваши ответы",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Пожалуйста, подождите...",
"font": "inter",
"weight": "medium",
"color": "muted",
"align": "center"
},
"progressbars": {
"transitionDuration": 3000,
"items": [
{
"title": "Анализ ответов",
"processingTitle": "Анализируем ваши ответы...",
"processingSubtitle": "Обрабатываем данные",
"completedTitle": "Анализ завершен",
"completedSubtitle": "Готово!"
},
{
"title": "Поиск совпадений",
"processingTitle": "Ищем идеальные совпадения...",
"processingSubtitle": "Сравниваем профили",
"completedTitle": "Совпадения найдены",
"completedSubtitle": "Отлично!"
},
{
"title": "Создание портрета",
"processingTitle": "Создаем портрет партнера...",
"processingSubtitle": "Финальный штрих",
"completedTitle": "Портрет готов",
"completedSubtitle": "Все готово!"
}
]
},
"bottomActionButton": {
"text": "Продолжить"
},
"navigation": {
"defaultNextScreenId": "intro-statistics"
}
},
{
"id": "intro-statistics",
"template": "info",
"header": {
"show": true,
"showBackButton": false
},
"title": {
"text": "Добро пожаловать в **WitLab**!",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Мы поможем вам найти **идеального партнера** на основе глубокого анализа ваших предпочтений и характера."
},
"icon": {
"type": "emoji",
"value": "💖",
"value": "❤️",
"size": "xl"
},
"bottomActionButton": {
"text": "Next"
"text": "Начать"
},
"navigation": {
"defaultNextScreenId": "birth-date"

View File

@ -6,7 +6,10 @@ import {
FormInput,
Info,
Calendar,
Ticket
Ticket,
Loader,
Heart,
Mail
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -40,6 +43,13 @@ const TEMPLATE_OPTIONS = [
icon: FormInput,
color: "bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400",
},
{
template: "email" as const,
title: "Email",
description: "Ввод и валидация email адреса",
icon: Mail,
color: "bg-teal-50 text-teal-600 dark:bg-teal-900/20 dark:text-teal-400",
},
{
template: "info" as const,
title: "Информация",
@ -49,11 +59,25 @@ const TEMPLATE_OPTIONS = [
},
{
template: "date" as const,
title: "Дата",
description: "Выбор даты (месяц, день, год)",
title: "Дата рождения",
description: "Выбор даты (месяц, день, год) + автоматический расчет возраста",
icon: Calendar,
color: "bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400",
},
{
template: "loaders" as const,
title: "Загрузка",
description: "Анимированные прогресс-бары с этапами обработки",
icon: Loader,
color: "bg-cyan-50 text-cyan-600 dark:bg-cyan-900/20 dark:text-cyan-400",
},
{
template: "soulmate" as const,
title: "Портрет партнера",
description: "Отображение результата анализа и портрета партнера",
icon: Heart,
color: "bg-pink-50 text-pink-600 dark:bg-pink-900/20 dark:text-pink-400",
},
{
template: "coupon" as const,
title: "Купон",

View File

@ -0,0 +1,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>
);
}

View File

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

View File

@ -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}
/>

View File

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

View File

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

View File

@ -0,0 +1,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>
);
}

View File

@ -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> Для экранов типа &ldquo;{targetScreen?.template}&rdquo;
система сравнивает сохраненные ответы пользователя с указанными значениями.
</div>
{/* Показываем выбранные значения */}
{(condition.values ?? []).length > 0 && (
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Выбранные значения:
</label>
<div className="flex flex-wrap gap-1">
{(condition.values ?? []).map((value) => (
<span
key={value}
className="inline-flex items-center gap-1 px-2 py-1 text-xs bg-primary/10 text-primary rounded-full"
>
{value}
<button
onClick={() => toggleValue(index, conditionIndex, value)}
className="ml-1 hover:text-red-500 transition-colors"
title="Удалить"
>
×
</button>
</span>
))}
</div>
</div>
)}
{/* Поле для добавления новых значений */}
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Добавить значение:
</label>
<input
type="text"
placeholder="Введите значение для сравнения..."
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const value = (e.target as HTMLInputElement).value.trim();
if (value) {
addCustomValue(index, conditionIndex, value);
(e.target as HTMLInputElement).value = "";
}
}
}}
/>
</div>
</div>
);
}
})()}
</div>
</div>
))}
</div>
<div className="space-y-3">
<span className="text-xs font-semibold uppercase text-muted-foreground">Настройка контента</span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />

View File

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

View 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>
);
}

View File

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

View File

@ -2,15 +2,10 @@
import { useMemo } from "react";
import Image from "next/image";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import Typography from "@/components/ui/Typography/Typography";
import {
buildLayoutQuestionProps,
buildTypographyProps,
} from "@/lib/funnel/mappers";
import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { InfoScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "./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>
);
}

View File

@ -1,18 +1,15 @@
"use client";
import { useMemo } from "react";
import { Question } from "@/components/templates/Question/Question";
import { RadioAnswersList } from "@/components/widgets/RadioAnswersList/RadioAnswersList";
import { SelectAnswersList } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
import type { RadioAnswersListProps } from "@/components/widgets/RadioAnswersList/RadioAnswersList";
import type { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
import {
buildLayoutQuestionProps,
mapListOptionsToButtons,
} from "@/lib/funnel/mappers";
import { mapListOptionsToButtons } from "@/lib/funnel/mappers";
import type { ListScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "./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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

@ -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: {

View 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>
);
}

View File

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

View File

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

View File

@ -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={{

View File

@ -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{" "}

View File

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

View File

@ -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] } : {}),
})),
})),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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