add load json

This commit is contained in:
dev.daminik00 2025-09-28 22:48:50 +02:00
parent 053bb0f106
commit 68e990db12
58 changed files with 840 additions and 10562 deletions

View File

@ -29,3 +29,47 @@ npm run build:full # full system development bundle
``` ```
After building, start the chosen bundle with `npm run start` (frontend-only) or `npm run start:full`. After building, start the chosen bundle with `npm run start` (frontend-only) or `npm run start:full`.
## Funnel Management
### Database Synchronization
To sync published funnels from MongoDB into the codebase:
```bash
# Sync all published funnels from database
npm run sync:funnels
# Preview what would be synced (dry-run mode)
npm run sync:funnels -- --dry-run
# Sync only specific funnels
npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
# Keep JSON files for debugging
npm run sync:funnels -- --keep-files
```
This script:
1. Connects to MongoDB and fetches all latest published funnels
2. Saves them as temporary JSON files in `public/funnels/`
3. Bakes them into TypeScript (`src/lib/funnel/bakedFunnels.ts`)
4. Cleans up temporary JSON files
### Other Funnel Commands
```bash
# Import JSON files from public/funnels/ to MongoDB
npm run import:funnels
# Manually bake JSON files to TypeScript
npm run bake:funnels
```
**Recommended Workflow:**
1. Create/edit funnels in the admin panel
2. Publish them in the admin
3. Run `npm run sync:funnels` to update the codebase
4. Build and deploy with the latest funnels

View File

@ -15,6 +15,7 @@
"lint": "eslint", "lint": "eslint",
"bake:funnels": "node scripts/bake-funnels.mjs", "bake:funnels": "node scripts/bake-funnels.mjs",
"import:funnels": "node scripts/import-funnels-to-db.mjs", "import:funnels": "node scripts/import-funnels-to-db.mjs",
"sync:funnels": "node scripts/sync-funnels-from-db.mjs",
"storybook": "storybook dev -p 6006 --ci", "storybook": "storybook dev -p 6006 --ci",
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },

View File

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

View File

@ -1,662 +0,0 @@
{
"meta": {
"id": "funnel-test-variants",
"title": "Relationship Portrait",
"description": "Demo funnel mirroring design screens with branching by analysis target.",
"firstScreenId": "intro-welcome"
},
"defaultTexts": {
"nextButton": "Next",
"continueButton": "Continue"
},
"screens": [
{
"id": "intro-welcome",
"template": "info",
"title": {
"text": "Вы не одиноки в этом страхе",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Многие боятся повторить прошлый опыт. Мы поможем распознать верные сигналы и выбрать «своего» человека.",
"font": "inter",
"weight": "medium",
"color": "default",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "❤️",
"size": "xl"
},
"bottomActionButton": {
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "intro-statistics"
}
},
{
"id": "intro-statistics",
"template": "info",
"title": {
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "🔥❤️",
"size": "xl"
},
"bottomActionButton": {
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "intro-partner-traits"
}
},
{
"id": "intro-partner-traits",
"template": "info",
"header": {
"showBackButton": false
},
"title": {
"text": "Такой партнёр умеет слышать и поддерживать, а вы — человек с глубокой душой, который ценит искренность и силу настоящих чувств.",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "💖",
"size": "xl"
},
"bottomActionButton": {
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "birth-date"
}
},
{
"id": "birth-date",
"template": "date",
"title": {
"text": "Когда ты родился?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "В момент вашего рождения заложенны глубинные закономерности.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "MM",
"dayPlaceholder": "DD",
"yearPlaceholder": "YYYY",
"monthLabel": "Month",
"dayLabel": "Day",
"yearLabel": "Year",
"showSelectedDate": true,
"selectedDateLabel": "Выбранная дата:"
},
"infoMessage": {
"text": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"bottomActionButton": {
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "address-form"
}
},
{
"id": "address-form",
"template": "form",
"title": {
"text": "Which best represents your hair loss and goals?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Let's personalize your hair care journey",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"fields": [
{
"id": "address",
"label": "Address",
"placeholder": "Enter your full address",
"type": "text",
"required": true,
"maxLength": 200
}
],
"validationMessages": {
"required": "${field} обязательно для заполнения",
"maxLength": "Максимум ${maxLength} символов",
"invalidFormat": "Неверный формат"
},
"navigation": {
"defaultNextScreenId": "statistics-text"
}
},
{
"id": "statistics-text",
"template": "info",
"title": {
"text": "Which best represents your hair loss and goals?",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
"font": "inter",
"weight": "medium",
"color": "default",
"align": "center"
}
},
{
"id": "gender",
"template": "list",
"title": {
"text": "Какого ты пола?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Все начинается с тебя! Выбери свой пол.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "female",
"label": "FEMALE",
"emoji": "💗"
},
{
"id": "male",
"label": "MALE",
"emoji": "💙"
}
]
},
"navigation": {
"defaultNextScreenId": "relationship-status"
}
},
{
"id": "relationship-status",
"template": "list",
"title": {
"text": "Вы сейчас?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Это нужно, чтобы портрет и советы были точнее.",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "in-relationship",
"label": "В отношениях"
},
{
"id": "single",
"label": "Свободны"
},
{
"id": "after-breakup",
"label": "После расставания"
},
{
"id": "complicated",
"label": "Все сложно"
}
]
},
"navigation": {
"defaultNextScreenId": "analysis-target"
}
},
{
"id": "analysis-target",
"template": "list",
"title": {
"text": "Кого анализируем?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "current-partner",
"label": "Текущего партнера"
},
{
"id": "crush",
"label": "Человека, который нравится"
},
{
"id": "ex-partner",
"label": "Бывшего"
},
{
"id": "future-partner",
"label": "Будущую встречу"
}
]
},
"navigation": {
"defaultNextScreenId": "partner-age"
}
},
{
"id": "partner-age",
"template": "list",
"title": {
"text": "Возраст партнера",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Чтобы портрет был максимально точным, уточните возраст.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "under-29",
"label": "До 29"
},
{
"id": "30-39",
"label": "30-39"
},
{
"id": "40-49",
"label": "40-49"
},
{
"id": "50-59",
"label": "50-59"
},
{
"id": "60-plus",
"label": "60+"
}
]
},
"variants": [
{
"conditions": [
{
"screenId": "analysis-target",
"operator": "includesAny",
"optionIds": ["current-partner"]
}
],
"overrides": {
"title": {
"text": "Возраст текущего партнера",
"font": "manrope",
"weight": "bold"
}
}
},
{
"conditions": [
{
"screenId": "analysis-target",
"operator": "includesAny",
"optionIds": ["crush"]
}
],
"overrides": {
"title": {
"text": "Возраст человека, который нравится",
"font": "manrope",
"weight": "bold"
},
"bottomActionButton": {
"show": false
}
}
},
{
"conditions": [
{
"screenId": "analysis-target",
"operator": "includesAny",
"optionIds": ["ex-partner"]
}
],
"overrides": {
"title": {
"text": "Возраст бывшего",
"font": "manrope",
"weight": "bold"
}
}
},
{
"conditions": [
{
"screenId": "analysis-target",
"operator": "includesAny",
"optionIds": ["future-partner"]
}
],
"overrides": {
"title": {
"text": "Возраст будущего партнера",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Чтобы мы не упустили важные нюансы будущей встречи.",
"font": "inter",
"weight": "medium",
"color": "muted"
}
}
}
],
"navigation": {
"rules": [
{
"conditions": [
{
"screenId": "partner-age",
"operator": "includesAny",
"optionIds": ["under-29"]
}
],
"nextScreenId": "age-refine"
}
],
"defaultNextScreenId": "partner-ethnicity"
}
},
{
"id": "age-refine",
"template": "list",
"title": {
"text": "Уточните чуть точнее",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Чтобы портрет был максимально похож.",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "18-21",
"label": "18-21"
},
{
"id": "22-25",
"label": "22-25"
},
{
"id": "26-29",
"label": "26-29"
}
]
},
"navigation": {
"defaultNextScreenId": "partner-ethnicity"
}
},
{
"id": "partner-ethnicity",
"template": "list",
"title": {
"text": "Этническая принадлежность твоей второй половинки?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "white",
"label": "White"
},
{
"id": "hispanic",
"label": "Hispanic / Latino"
},
{
"id": "african",
"label": "African / African-American"
},
{
"id": "asian",
"label": "Asian"
},
{
"id": "south-asian",
"label": "Indian / South Asian"
},
{
"id": "middle-eastern",
"label": "Middle Eastern / Arab"
},
{
"id": "indigenous",
"label": "Native American / Indigenous"
},
{
"id": "no-preference",
"label": "No preference"
}
]
},
"navigation": {
"defaultNextScreenId": "partner-eyes"
}
},
{
"id": "partner-eyes",
"template": "list",
"title": {
"text": "Что из этого «про глаза»?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "warm-glow",
"label": "Тёплые искры на свету"
},
{
"id": "clear-depth",
"label": "Прозрачная глубина"
},
{
"id": "green-sheen",
"label": "Зелёный отлив на границе зрачка"
},
{
"id": "steel-glint",
"label": "Холодный стальной отблеск"
},
{
"id": "deep-shadow",
"label": "Насыщенная темнота"
},
{
"id": "dont-know",
"label": "Не знаю"
}
]
},
"navigation": {
"defaultNextScreenId": "partner-hair-length"
}
},
{
"id": "partner-hair-length",
"template": "list",
"title": {
"text": "Выберите длину волос",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "От неё зависит форма и настроение портрета.",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "short",
"label": "Короткие"
},
{
"id": "medium",
"label": "Средние"
},
{
"id": "long",
"label": "Длинные"
}
]
},
"navigation": {
"defaultNextScreenId": "burnout-support"
}
},
{
"id": "burnout-support",
"template": "list",
"title": {
"text": "Когда ты выгораешь, тебе нужно чтобы партнёр...",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{
"id": "reassure",
"label": "Признал ваше разочарование и успокоил"
},
{
"id": "emotional-support",
"label": "Дал эмоциональную опору и безопасное пространство"
},
{
"id": "take-over",
"label": "Перехватил быт/дела, чтобы вы восстановились"
},
{
"id": "energize",
"label": "Вдохнул энергию через цель и короткий план действий"
},
{
"id": "switch-positive",
"label": "Переключил на позитив: прогулка, кино, смешные истории"
}
]
},
"bottomActionButton": {
"show": false
},
"navigation": {
"defaultNextScreenId": "special-offer"
}
},
{
"id": "special-offer",
"template": "coupon",
"header": {
"show": false
},
"title": {
"text": "Тебе повезло!",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Ты получил специальную эксклюзивную скидку на 94%",
"font": "inter",
"weight": "medium",
"color": "muted",
"align": "center"
},
"copiedMessage": "Промокод \"{code}\" скопирован!",
"coupon": {
"title": {
"text": "Special Offer",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "94% OFF",
"font": "manrope",
"weight": "black",
"color": "card",
"size": "4xl"
},
"description": {
"text": "Одноразовая эксклюзивная скидка",
"font": "inter",
"weight": "semiBold",
"color": "card"
}
},
"promoCode": {
"text": "HAIR50",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Скопируйте или нажмите Continue",
"font": "inter",
"weight": "medium",
"color": "muted",
"size": "sm"
}
},
"bottomActionButton": {
"text": "Continue"
}
}
]
}

View File

@ -1,826 +0,0 @@
{
"meta": {
"id": "funnel-test",
"title": "Relationship Portrait",
"description": "Demo funnel mirroring design screens with branching by analysis target.",
"firstScreenId": "intro-welcome"
},
"defaultTexts": {
"nextButton": "Next",
"continueButton": "Continue",
"privacyBanner": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
},
"screens": [
{
"id": "intro-welcome",
"template": "info",
"title": {
"text": "Вы не одиноки в этом страхе",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Многие боятся повторить прошлый опыт. Мы поможем распознать верные сигналы и выбрать «своего» человека.",
"font": "inter",
"weight": "medium",
"color": "default",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "❤️",
"size": "xl"
},
"bottomActionButton": {
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "intro-statistics"
}
},
{
"id": "intro-statistics",
"template": "info",
"title": {
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "🔥❤️",
"size": "xl"
},
"bottomActionButton": {
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "test-loaders"
}
},
{
"id": "test-loaders",
"template": "loaders",
"title": {
"text": "Анализируем ваши ответы",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Пожалуйста, подождите...",
"font": "inter",
"weight": "medium",
"color": "muted",
"align": "center"
},
"progressbars": {
"transitionDuration": 3000,
"items": [
{
"title": "Анализ ответов",
"processingTitle": "Анализируем ваши ответы...",
"processingSubtitle": "Обрабатываем данные",
"completedTitle": "Анализ завершен",
"completedSubtitle": "Готово!"
},
{
"title": "Поиск совпадений",
"processingTitle": "Ищем идеальные совпадения...",
"processingSubtitle": "Сравниваем профили",
"completedTitle": "Совпадения найдены",
"completedSubtitle": "Отлично!"
},
{
"title": "Создание портрета",
"processingTitle": "Создаем портрет партнера...",
"processingSubtitle": "Финальный штрих",
"completedTitle": "Портрет готов",
"completedSubtitle": "Все готово!"
}
]
},
"bottomActionButton": {
"text": "Продолжить"
},
"navigation": {
"defaultNextScreenId": "intro-statistics"
}
},
{
"id": "intro-statistics",
"template": "info",
"header": {
"show": true,
"showBackButton": false
},
"title": {
"text": "Добро пожаловать в **WitLab**!",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Мы поможем вам найти **идеального партнера** на основе глубокого анализа ваших предпочтений и характера."
},
"icon": {
"type": "emoji",
"value": "❤️",
"size": "xl"
},
"bottomActionButton": {
"text": "Начать"
},
"navigation": {
"defaultNextScreenId": "birth-date"
}
},
{
"id": "birth-date",
"template": "date",
"title": {
"text": "Когда ты родился?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "В момент вашего рождения заложенны глубинные закономерности.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "MM",
"dayPlaceholder": "DD",
"yearPlaceholder": "YYYY",
"monthLabel": "Month",
"dayLabel": "Day",
"yearLabel": "Year",
"showSelectedDate": true,
"selectedDateLabel": "Выбранная дата:"
},
"infoMessage": {
"text": "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"bottomActionButton": {
"text": "Next"
},
"navigation": {
"defaultNextScreenId": "address-form"
}
},
{
"id": "address-form",
"template": "form",
"title": {
"text": "Which best represents your hair loss and goals?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Let's personalize your hair care journey",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"fields": [
{
"id": "address",
"label": "Address",
"placeholder": "Enter your full address",
"type": "text",
"required": true,
"maxLength": 200
}
],
"validationMessages": {
"required": "${field} обязательно для заполнения",
"maxLength": "Максимум ${maxLength} символов",
"invalidFormat": "Неверный формат"
},
"navigation": {
"defaultNextScreenId": "statistics-text"
}
},
{
"id": "statistics-text",
"template": "info",
"title": {
"text": "Which best represents your hair loss and goals?",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
"font": "inter",
"weight": "medium",
"color": "default",
"align": "center"
}
},
{
"id": "gender",
"template": "list",
"title": {
"text": "Какого ты пола?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Все начинается с тебя! Выбери свой пол.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "female",
"label": "FEMALE",
"emoji": "💗"
},
{
"id": "male",
"label": "MALE",
"emoji": "💙"
}
]
},
"navigation": {
"defaultNextScreenId": "relationship-status"
}
},
{
"id": "relationship-status",
"template": "list",
"title": {
"text": "Вы сейчас?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Это нужно, чтобы портрет и советы были точнее.",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "in-relationship",
"label": "В отношениях"
},
{
"id": "single",
"label": "Свободны"
},
{
"id": "after-breakup",
"label": "После расставания"
},
{
"id": "complicated",
"label": "Все сложно"
}
]
},
"navigation": {
"defaultNextScreenId": "analysis-target"
}
},
{
"id": "analysis-target",
"template": "list",
"title": {
"text": "Кого анализируем?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "current-partner",
"label": "Текущего партнера"
},
{
"id": "crush",
"label": "Человека, который нравится"
},
{
"id": "ex-partner",
"label": "Бывшего"
},
{
"id": "future-partner",
"label": "Будущую встречу"
}
]
},
"navigation": {
"rules": [
{
"conditions": [
{
"screenId": "analysis-target",
"operator": "includesAny",
"optionIds": ["current-partner"]
}
],
"nextScreenId": "current-partner-age"
},
{
"conditions": [
{
"screenId": "analysis-target",
"operator": "includesAny",
"optionIds": ["crush"]
}
],
"nextScreenId": "crush-age"
},
{
"conditions": [
{
"screenId": "analysis-target",
"operator": "includesAny",
"optionIds": ["ex-partner"]
}
],
"nextScreenId": "ex-partner-age"
},
{
"conditions": [
{
"screenId": "analysis-target",
"operator": "includesAny",
"optionIds": ["future-partner"]
}
],
"nextScreenId": "future-partner-age"
}
],
"defaultNextScreenId": "current-partner-age"
}
},
{
"id": "current-partner-age",
"template": "list",
"title": {
"text": "Возраст текущего партнера",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "under-29",
"label": "До 29"
},
{
"id": "30-39",
"label": "30-39"
},
{
"id": "40-49",
"label": "40-49"
},
{
"id": "50-59",
"label": "50-59"
},
{
"id": "60-plus",
"label": "60+"
}
]
},
"navigation": {
"rules": [
{
"conditions": [
{
"screenId": "current-partner-age",
"operator": "includesAny",
"optionIds": ["under-29"]
}
],
"nextScreenId": "age-refine"
}
],
"defaultNextScreenId": "partner-ethnicity"
}
},
{
"id": "crush-age",
"template": "list",
"title": {
"text": "Возраст человека, который нравится",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "under-29",
"label": "До 29"
},
{
"id": "30-39",
"label": "30-39"
},
{
"id": "40-49",
"label": "40-49"
},
{
"id": "50-59",
"label": "50-59"
},
{
"id": "60-plus",
"label": "60+"
}
]
},
"bottomActionButton": {
"show": false
},
"navigation": {
"rules": [
{
"conditions": [
{
"screenId": "crush-age",
"operator": "includesAny",
"optionIds": ["under-29"]
}
],
"nextScreenId": "age-refine"
}
],
"defaultNextScreenId": "partner-ethnicity"
}
},
{
"id": "ex-partner-age",
"template": "list",
"title": {
"text": "Возраст бывшего",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "under-29",
"label": "До 29"
},
{
"id": "30-39",
"label": "30-39"
},
{
"id": "40-49",
"label": "40-49"
},
{
"id": "50-59",
"label": "50-59"
},
{
"id": "60-plus",
"label": "60+"
}
]
},
"navigation": {
"rules": [
{
"conditions": [
{
"screenId": "ex-partner-age",
"operator": "includesAny",
"optionIds": ["under-29"]
}
],
"nextScreenId": "age-refine"
}
],
"defaultNextScreenId": "partner-ethnicity"
}
},
{
"id": "future-partner-age",
"template": "list",
"title": {
"text": "Возраст будущего партнера",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "under-29",
"label": "До 29"
},
{
"id": "30-39",
"label": "30-39"
},
{
"id": "40-49",
"label": "40-49"
},
{
"id": "50-59",
"label": "50-59"
},
{
"id": "60-plus",
"label": "60+"
}
]
},
"navigation": {
"rules": [
{
"conditions": [
{
"screenId": "future-partner-age",
"operator": "includesAny",
"optionIds": ["under-29"]
}
],
"nextScreenId": "age-refine"
}
],
"defaultNextScreenId": "partner-ethnicity"
}
},
{
"id": "age-refine",
"template": "list",
"title": {
"text": "Уточните чуть точнее",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Чтобы портрет был максимально похож.",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "18-21",
"label": "18-21"
},
{
"id": "22-25",
"label": "22-25"
},
{
"id": "26-29",
"label": "26-29"
}
]
},
"navigation": {
"defaultNextScreenId": "partner-ethnicity"
}
},
{
"id": "partner-ethnicity",
"template": "list",
"title": {
"text": "Этническая принадлежность твоей второй половинки?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "white",
"label": "White"
},
{
"id": "hispanic",
"label": "Hispanic / Latino"
},
{
"id": "african",
"label": "African / African-American"
},
{
"id": "asian",
"label": "Asian"
},
{
"id": "south-asian",
"label": "Indian / South Asian"
},
{
"id": "middle-eastern",
"label": "Middle Eastern / Arab"
},
{
"id": "indigenous",
"label": "Native American / Indigenous"
},
{
"id": "no-preference",
"label": "No preference"
}
]
},
"navigation": {
"defaultNextScreenId": "partner-eyes"
}
},
{
"id": "partner-eyes",
"template": "list",
"title": {
"text": "Что из этого «про глаза»?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "warm-glow",
"label": "Тёплые искры на свету"
},
{
"id": "clear-depth",
"label": "Прозрачная глубина"
},
{
"id": "green-sheen",
"label": "Зелёный отлив на границе зрачка"
},
{
"id": "steel-glint",
"label": "Холодный стальной отблеск"
},
{
"id": "deep-shadow",
"label": "Насыщенная темнота"
},
{
"id": "dont-know",
"label": "Не знаю"
}
]
},
"navigation": {
"defaultNextScreenId": "partner-hair-length"
}
},
{
"id": "partner-hair-length",
"template": "list",
"title": {
"text": "Выберите длину волос",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "От неё зависит форма и настроение портрета.",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{
"id": "short",
"label": "Короткие"
},
{
"id": "medium",
"label": "Средние"
},
{
"id": "long",
"label": "Длинные"
}
]
},
"navigation": {
"defaultNextScreenId": "burnout-support"
}
},
{
"id": "burnout-support",
"template": "list",
"title": {
"text": "Когда ты выгораешь, тебе нужно чтобы партнёр...",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{
"id": "reassure",
"label": "Признал ваше разочарование и успокоил"
},
{
"id": "emotional-support",
"label": "Дал эмоциональную опору и безопасное пространство"
},
{
"id": "take-over",
"label": "Перехватил быт/дела, чтобы вы восстановились"
},
{
"id": "energize",
"label": "Вдохнул энергию через цель и короткий план действий"
},
{
"id": "switch-positive",
"label": "Переключил на позитив: прогулка, кино, смешные истории"
}
]
},
"bottomActionButton": {
"show": false
},
"navigation": {
"defaultNextScreenId": "special-offer"
}
},
{
"id": "special-offer",
"template": "coupon",
"header": {
"show": false
},
"title": {
"text": "Тебе повезло!",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Ты получил специальную эксклюзивную скидку на 94%",
"font": "inter",
"weight": "medium",
"color": "muted",
"align": "center"
},
"copiedMessage": "Промокод \"{code}\" скопирован!",
"coupon": {
"title": {
"text": "Special Offer",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "94% OFF",
"font": "manrope",
"weight": "black",
"color": "card",
"size": "4xl"
},
"description": {
"text": "Одноразовая эксклюзивная скидка",
"font": "inter",
"weight": "semiBold",
"color": "card"
}
},
"promoCode": {
"text": "HAIR50",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Скопируйте или нажмите Continue",
"font": "inter",
"weight": "medium",
"color": "muted",
"size": "sm"
}
},
"bottomActionButton": {
"text": "Continue"
}
}
]
}

View File

@ -1,313 +0,0 @@
{
"meta": {
"id": "ru-career-accelerator",
"title": "CareerUp: рывок в карьере",
"description": "Воронка карьерного акселератора для специалистов и руководителей.",
"firstScreenId": "welcome"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "welcome",
"template": "info",
"title": {
"text": "Повысь доход и статус за 12 недель",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Коуч, карьерный стратег и HR-директор ведут тебя к новой должности или росту дохода.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "🚀",
"size": "xl"
},
"bottomActionButton": {
"text": "Пройти диагностику"
},
"navigation": {
"defaultNextScreenId": "pain"
}
},
{
"id": "pain",
"template": "info",
"title": {
"text": "Почему карьера застопорилась?",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Нет стратегии, страх переговоров и слабый личный бренд. Мы закрываем каждый пробел.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "goal-date"
}
},
{
"id": "goal-date",
"template": "date",
"title": {
"text": "Когда хочешь выйти на новую позицию?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Сформируем спринты под конкретный дедлайн.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Цель к:"
},
"navigation": {
"defaultNextScreenId": "current-role"
}
},
{
"id": "current-role",
"template": "list",
"title": {
"text": "Текущая роль",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "specialist", "label": "Специалист" },
{ "id": "lead", "label": "Тимлид" },
{ "id": "manager", "label": "Руководитель отдела" },
{ "id": "c-level", "label": "C-level" }
]
},
"navigation": {
"defaultNextScreenId": "target"
}
},
{
"id": "target",
"template": "list",
"title": {
"text": "Желаемая цель",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "promotion", "label": "Повышение внутри компании" },
{ "id": "newjob", "label": "Переход в топ-компанию" },
{ "id": "salary", "label": "Рост дохода на 50%" },
{ "id": "relocate", "label": "Релокация" }
]
},
"navigation": {
"defaultNextScreenId": "case"
}
},
{
"id": "case",
"template": "info",
"title": {
"text": "История Марии: +85% к доходу",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "За 9 недель она прошла программу, обновила резюме, договорилась о relocation и заняла позицию руководителя продукта.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "bottlenecks"
}
},
{
"id": "bottlenecks",
"template": "list",
"title": {
"text": "Где нужна поддержка?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "resume", "label": "Резюме и LinkedIn" },
{ "id": "network", "label": "Нетворкинг" },
{ "id": "interview", "label": "Интервью" },
{ "id": "negotiation", "label": "Переговоры о зарплате" },
{ "id": "leadership", "label": "Лидерские навыки" }
]
},
"navigation": {
"defaultNextScreenId": "program-format"
}
},
{
"id": "program-format",
"template": "list",
"title": {
"text": "Какой формат подходит?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "group", "label": "Групповой акселератор" },
{ "id": "1on1", "label": "Индивидуальное сопровождение" },
{ "id": "vip", "label": "Executive программа" }
]
},
"navigation": {
"defaultNextScreenId": "form"
}
},
{
"id": "form",
"template": "form",
"title": {
"text": "Получить план роста",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
{ "id": "email", "label": "Email", "placeholder": "Получить карьерный план", "type": "email", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте формат"
},
"navigation": {
"defaultNextScreenId": "mentor"
}
},
{
"id": "mentor",
"template": "info",
"title": {
"text": "Твой наставник",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Ex-HR Director из Microsoft поможет построить стратегию и проведёт ролевые интервью.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "packages"
}
},
{
"id": "packages",
"template": "list",
"title": {
"text": "Выберите пакет",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "start", "label": "Start — 6 недель" },
{ "id": "pro", "label": "Pro — 12 недель" },
{ "id": "elite", "label": "Elite — 16 недель + наставник" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Бонусы при оплате сегодня",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Шаблоны писем рекрутерам, библиотека резюме и доступ к закрытому карьерному клубу.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Зафиксируй скидку и бонусы",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Скидка 20% и два дополнительных карьерных созвона.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "CareerUp",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-20%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Программа + 2 коуч-сессии",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "CAREER20",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы активировать предложение",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,314 +0,0 @@
{
"meta": {
"id": "ru-finance-freedom",
"title": "Capital Sense: финансовая свобода",
"description": "Воронка для консультаций по инвестициям и личному финансовому планированию.",
"firstScreenId": "intro"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "intro",
"template": "info",
"title": {
"text": "Сформируй капитал, который работает за тебя",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Персональный финансовый план, подбор инструментов и сопровождение на каждом шаге.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "💼",
"size": "xl"
},
"bottomActionButton": {
"text": "Начать"
},
"navigation": {
"defaultNextScreenId": "fear"
}
},
{
"id": "fear",
"template": "info",
"title": {
"text": "Почему деньги не приносят свободу?",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Разные цели, хаотичные инвестиции и страх потерять. Мы создаём стратегию с защитой и ростом.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "goal-date"
}
},
{
"id": "goal-date",
"template": "date",
"title": {
"text": "Когда хочешь достичь финансовой цели?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Укажи дату, чтобы рассчитать необходимые шаги.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Цель к дате:"
},
"navigation": {
"defaultNextScreenId": "current-income"
}
},
{
"id": "current-income",
"template": "list",
"title": {
"text": "Какой у тебя ежемесячный доход?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "lt100k", "label": "До 100 000 ₽" },
{ "id": "100-250", "label": "100 000 250 000 ₽" },
{ "id": "250-500", "label": "250 000 500 000 ₽" },
{ "id": "500plus", "label": "Свыше 500 000 ₽" }
]
},
"navigation": {
"defaultNextScreenId": "savings"
}
},
{
"id": "savings",
"template": "list",
"title": {
"text": "Как распределяются накопления?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "deposit", "label": "Банковские вклады" },
{ "id": "stocks", "label": "Акции и фонды" },
{ "id": "realty", "label": "Недвижимость" },
{ "id": "business", "label": "Собственный бизнес" },
{ "id": "cash", "label": "Храню в наличных" }
]
},
"navigation": {
"defaultNextScreenId": "risk"
}
},
{
"id": "risk",
"template": "list",
"title": {
"text": "Готовность к риску",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "conservative", "label": "Консервативная стратегия" },
{ "id": "balanced", "label": "Сбалансированный портфель" },
{ "id": "aggressive", "label": "Готов к высоким рискам ради роста" }
]
},
"navigation": {
"defaultNextScreenId": "case"
}
},
{
"id": "case",
"template": "info",
"title": {
"text": "История Александра: капитал 12 млн за 5 лет",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Использовали облигации, дивидендные акции и страхование. Доходность 18% при низком риске.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "priorities"
}
},
{
"id": "priorities",
"template": "list",
"title": {
"text": "Выбери финансовые приоритеты",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "capital", "label": "Долгосрочный капитал" },
{ "id": "passive", "label": "Пассивный доход" },
{ "id": "education", "label": "Образование детей" },
{ "id": "pension", "label": "Пенсия без тревог" },
{ "id": "protection", "label": "Страхование и защита" }
]
},
"navigation": {
"defaultNextScreenId": "form"
}
},
{
"id": "form",
"template": "form",
"title": {
"text": "Получить расчёт стратегии",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "name", "label": "Имя", "placeholder": "Как вас зовут", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
{ "id": "email", "label": "Email", "placeholder": "Получить PDF-план", "type": "email", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте формат"
},
"navigation": {
"defaultNextScreenId": "advisor"
}
},
{
"id": "advisor",
"template": "info",
"title": {
"text": "Ваш персональный советник",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Сертифицированный финансовый консультант составит портфель и будет сопровождать на ежемесячных созвонах.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "packages"
}
},
{
"id": "packages",
"template": "list",
"title": {
"text": "Выберите пакет сопровождения",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "start", "label": "Start — до 2 млн ₽" },
{ "id": "growth", "label": "Growth — до 10 млн ₽" },
{ "id": "elite", "label": "Elite — от 10 млн ₽ и Family Office" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Бонусы к записи сегодня",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Инвестиционный чек-лист и бесплатный аудит страховок от партнёра.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Забронируйте условия",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Скидка 25% на первый месяц сопровождения и аудит портфеля.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "Capital Sense",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-25%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Первый месяц и аудит портфеля",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "FIN25",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы активировать промокод",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,356 +0,0 @@
{
"meta": {
"id": "ru-fitness-transform",
"title": "Фитнес-вызов: Тело мечты за 12 недель",
"description": "Воронка для продажи онлайн-программы персональных тренировок и питания.",
"firstScreenId": "intro-hero"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "intro-hero",
"template": "info",
"title": {
"text": "Создай тело, которое будет восхищать",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Личный куратор, готовые тренировки и поддержка нутрициолога для стремительного результата.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "💪",
"size": "xl"
},
"bottomActionButton": {
"text": "Начать диагностику"
},
"navigation": {
"defaultNextScreenId": "pain-check"
}
},
{
"id": "pain-check",
"template": "info",
"title": {
"text": "Почему результат не держится?",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "92% наших клиентов приходят после десятков попыток похудеть. Мы устраняем коренные причины: гормональный фон, сон, питание.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "target-date"
}
},
{
"id": "target-date",
"template": "date",
"title": {
"text": "Когда планируешь увидеть первые изменения?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Укажи желаемую дату — мы построим обратный план.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Целевая дата:"
},
"navigation": {
"defaultNextScreenId": "current-state"
}
},
{
"id": "current-state",
"template": "list",
"title": {
"text": "Что больше всего мешает сейчас?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "time", "label": "Нет времени на зал" },
{ "id": "food", "label": "Срывы в питании" },
{ "id": "motivation", "label": "Не хватает мотивации" },
{ "id": "health", "label": "Боли в спине/суставах" },
{ "id": "plateau", "label": "Вес стоит на месте" }
]
},
"navigation": {
"defaultNextScreenId": "goal-selection"
}
},
{
"id": "goal-selection",
"template": "list",
"title": {
"text": "Какая цель приоритетна?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Выбери один вариант — мы адаптируем программу.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "fat-loss", "label": "Снижение веса" },
{ "id": "tone", "label": "Упругость и рельеф" },
{ "id": "health", "label": "Самочувствие и энергия" },
{ "id": "postpartum", "label": "Восстановление после родов" }
]
},
"navigation": {
"defaultNextScreenId": "success-story"
}
},
{
"id": "success-story",
"template": "info",
"title": {
"text": "Света минус 14 кг за 12 недель",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Она работала по 12 часов в офисе. Мы составили план из 30-минутных тренировок и настроили питание без голода. Теперь она ведёт блог и вдохновляет подруг.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "lifestyle"
}
},
{
"id": "lifestyle",
"template": "list",
"title": {
"text": "Сколько времени готов(а) уделять?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "15min", "label": "1520 минут в день" },
{ "id": "30min", "label": "3040 минут" },
{ "id": "60min", "label": "60 минут и более" }
]
},
"navigation": {
"defaultNextScreenId": "nutrition"
}
},
{
"id": "nutrition",
"template": "info",
"title": {
"text": "Питание без жёстких запретов",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Балансируем рацион под твои привычки: любимые блюда остаются, меняются только пропорции.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "support-format"
}
},
{
"id": "support-format",
"template": "list",
"title": {
"text": "Какой формат поддержки комфортен?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "chat", "label": "Чат с куратором ежедневно" },
{ "id": "calls", "label": "Созвоны раз в неделю" },
{ "id": "video", "label": "Видеоразбор техники" },
{ "id": "community", "label": "Группа единомышленников" }
]
},
"navigation": {
"defaultNextScreenId": "contact-form"
}
},
{
"id": "contact-form",
"template": "form",
"title": {
"text": "Почти готово! Оставь контакты для персональной стратегии",
"font": "manrope",
"weight": "bold"
},
"fields": [
{
"id": "name",
"label": "Имя",
"placeholder": "Как к тебе обращаться",
"type": "text",
"required": true,
"maxLength": 60
},
{
"id": "phone",
"label": "Телефон",
"placeholder": "+7 (___) ___-__-__",
"type": "tel",
"required": true
},
{
"id": "email",
"label": "Email",
"placeholder": "Для отправки материалов",
"type": "email",
"required": true
}
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"maxLength": "Максимум ${maxLength} символов",
"invalidFormat": "Проверь формат"
},
"navigation": {
"defaultNextScreenId": "coach-match"
}
},
{
"id": "coach-match",
"template": "info",
"title": {
"text": "Подбираем наставника",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Мы нашли тренера, который специализируется на твоём запросе и будет на связи 24/7.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "bonus-overview"
}
},
{
"id": "bonus-overview",
"template": "info",
"title": {
"text": "Что входит в программу",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Получишь 36 адаптивных тренировок, 3 чек-листа питания, психологическую поддержку и доступ к закрытым эфиром.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "package-choice"
}
},
{
"id": "package-choice",
"template": "list",
"title": {
"text": "Выбери формат участия",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "online", "label": "Онлайн-куратор и видеоуроки" },
{ "id": "vip", "label": "VIP: личные созвоны и чат 24/7" },
{ "id": "studio", "label": "Комбо: онлайн + студийные тренировки" }
]
},
"navigation": {
"defaultNextScreenId": "final-offer"
}
},
{
"id": "final-offer",
"template": "coupon",
"title": {
"text": "Зафиксируй место и подарок",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Скидка действует 24 часа после прохождения диагностики.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "Фитнес-вызов",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-35%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Персональная программа и чат с тренером",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "BODY35",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажми \"Продолжить\" чтобы закрепить скидку",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,330 +0,0 @@
{
"meta": {
"id": "ru-interior-signature",
"title": "Design Bureau: интерьер под ключ",
"description": "Воронка студии дизайна интерьера с авторским сопровождением ремонта.",
"firstScreenId": "intro"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "intro",
"template": "info",
"title": {
"text": "Интерьер, который отражает ваш характер",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Создаём дизайн-проекты премиум-класса с полным контролем ремонта и экономией бюджета до 18%.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "🏡",
"size": "xl"
},
"bottomActionButton": {
"text": "Начать проект"
},
"navigation": {
"defaultNextScreenId": "problem"
}
},
{
"id": "problem",
"template": "info",
"title": {
"text": "Типовая планировка крадёт эмоции",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Мы превращаем квадратные метры в пространство, где хочется жить, а не просто находиться.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "finish-date"
}
},
{
"id": "finish-date",
"template": "date",
"title": {
"text": "Когда планируете переезд?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Укажи сроки, чтобы мы составили реалистичный план работ.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Переезд:"
},
"navigation": {
"defaultNextScreenId": "property-type"
}
},
{
"id": "property-type",
"template": "list",
"title": {
"text": "Какой объект оформляете?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "apartment", "label": "Квартира" },
{ "id": "house", "label": "Дом" },
{ "id": "office", "label": "Коммерческое пространство" }
]
},
"navigation": {
"defaultNextScreenId": "style"
}
},
{
"id": "style",
"template": "list",
"title": {
"text": "Стиль, который вдохновляет",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "minimal", "label": "Минимализм" },
{ "id": "loft", "label": "Лофт" },
{ "id": "classic", "label": "Современная классика" },
{ "id": "eco", "label": "Эко" },
{ "id": "mix", "label": "Эклектика" }
]
},
"navigation": {
"defaultNextScreenId": "pain-points"
}
},
{
"id": "pain-points",
"template": "list",
"title": {
"text": "Что вызывает наибольшие сложности?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "planning", "label": "Планировка" },
{ "id": "contractors", "label": "Поиск подрядчиков" },
{ "id": "budget", "label": "Контроль бюджета" },
{ "id": "decor", "label": "Подбор мебели и декора" }
]
},
"navigation": {
"defaultNextScreenId": "case"
}
},
{
"id": "case",
"template": "info",
"title": {
"text": "Квартира в ЖК CITY PARK",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Мы оптимизировали планировку, сэкономили 2,4 млн ₽ на поставщиках и завершили ремонт на 3 недели раньше срока.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "services"
}
},
{
"id": "services",
"template": "info",
"title": {
"text": "Что входит в нашу работу",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "3D-визуализации, рабочие чертежи, авторский надзор, логистика материалов и финансовый контроль.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "budget"
}
},
{
"id": "budget",
"template": "list",
"title": {
"text": "Планируемый бюджет проекта",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "2m", "label": "до 2 млн ₽" },
{ "id": "5m", "label": "2 5 млн ₽" },
{ "id": "10m", "label": "5 10 млн ₽" },
{ "id": "10mplus", "label": "Более 10 млн ₽" }
]
},
"navigation": {
"defaultNextScreenId": "form"
}
},
{
"id": "form",
"template": "form",
"title": {
"text": "Получите концепцию и смету",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
{ "id": "email", "label": "Email", "placeholder": "Получить презентацию", "type": "email", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте формат"
},
"navigation": {
"defaultNextScreenId": "designer"
}
},
{
"id": "designer",
"template": "info",
"title": {
"text": "Персональный дизайнер",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Автор проектов для бизнес-элиты. Ведёт максимум 5 объектов, чтобы уделять максимум внимания.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "packages"
}
},
{
"id": "packages",
"template": "list",
"title": {
"text": "Выберите формат работы",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "concept", "label": "Concept — планировка и визуализации" },
{ "id": "supervision", "label": "Control — авторский надзор" },
{ "id": "turnkey", "label": "Turnkey — ремонт под ключ" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Бонусы при бронировании сегодня",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Авторский колорит и подбор мебели от итальянских брендов со скидкой до 30%.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Зафиксируйте привилегии",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Скидка 20% на дизайн-проект и доступ к базе подрядчиков.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "Design Bureau",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-20%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Дизайн-проект + база подрядчиков",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "DESIGN20",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы получить предложение",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,311 +0,0 @@
{
"meta": {
"id": "ru-kids-robotics",
"title": "RoboKids: будущее ребёнка",
"description": "Воронка для школы робототехники и программирования для детей 6-14 лет.",
"firstScreenId": "welcome"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "welcome",
"template": "info",
"title": {
"text": "Подарите ребёнку навыки будущего",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Проектные занятия по робототехнике, программированию и soft skills в игровой форме.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "🤖",
"size": "xl"
},
"bottomActionButton": {
"text": "Узнать программу"
},
"navigation": {
"defaultNextScreenId": "pain"
}
},
{
"id": "pain",
"template": "info",
"title": {
"text": "Почему важно развивать навыки сейчас",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "90% современных профессий требуют технического мышления. Мы даём ребёнку уверенность и любовь к обучению.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "birth-date"
}
},
{
"id": "birth-date",
"template": "date",
"title": {
"text": "Когда родился ваш ребёнок?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Возраст помогает подобрать подходящую программу.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Возраст:"
},
"navigation": {
"defaultNextScreenId": "interest"
}
},
{
"id": "interest",
"template": "list",
"title": {
"text": "Что нравится ребёнку?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "lego", "label": "Конструировать" },
{ "id": "games", "label": "Компьютерные игры" },
{ "id": "science", "label": "Экспериментировать" },
{ "id": "art", "label": "Рисовать и создавать истории" }
]
},
"navigation": {
"defaultNextScreenId": "skills"
}
},
{
"id": "skills",
"template": "list",
"title": {
"text": "Какие навыки хотите усилить?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "logic", "label": "Логика и математика" },
{ "id": "team", "label": "Командная работа" },
{ "id": "presentation", "label": "Презентация проектов" },
{ "id": "creativity", "label": "Креативность" }
]
},
"navigation": {
"defaultNextScreenId": "case"
}
},
{
"id": "case",
"template": "info",
"title": {
"text": "Кейс семьи Еремовых",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Сын собрал робота-доставщика и выиграл региональный конкурс. Теперь учится в технопарке.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "format"
}
},
{
"id": "format",
"template": "list",
"title": {
"text": "Какой формат занятий удобен?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "offline", "label": "Очно в технопарке" },
{ "id": "online", "label": "Онлайн-лаборатория" },
{ "id": "hybrid", "label": "Комбо: онлайн + офлайн" }
]
},
"navigation": {
"defaultNextScreenId": "schedule"
}
},
{
"id": "schedule",
"template": "list",
"title": {
"text": "Выберите расписание",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "weekend", "label": "Выходные" },
{ "id": "weekday", "label": "Будни после школы" },
{ "id": "intensive", "label": "Интенсивные каникулы" }
]
},
"navigation": {
"defaultNextScreenId": "form"
}
},
{
"id": "form",
"template": "form",
"title": {
"text": "Получите бесплатный пробный урок",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "parentName", "label": "Имя родителя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
{ "id": "childName", "label": "Имя ребёнка", "placeholder": "Имя ребёнка", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте корректность"
},
"navigation": {
"defaultNextScreenId": "mentor"
}
},
{
"id": "mentor",
"template": "info",
"title": {
"text": "Ваш наставник",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Педагог MIT и финалист World Robot Olympiad проведёт вводную встречу и вовлечёт ребёнка в проект.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "packages"
}
},
{
"id": "packages",
"template": "list",
"title": {
"text": "Выберите программу",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "start", "label": "Start — 2 месяца" },
{ "id": "pro", "label": "Pro — 6 месяцев" },
{ "id": "elite", "label": "Elite — 12 месяцев + наставник" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Бонусы для новых семей",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Сертификат на 3D-печать проекта и доступ к киберспортивной студии.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Забронируйте место",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Скидка 15% и подарок на первый месяц обучения.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "RoboKids",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-15%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Первый месяц + подарок",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "ROBO15",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы активировать скидку",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,330 +0,0 @@
{
"meta": {
"id": "ru-language-immersion",
"title": "LinguaPro: английский за 3 месяца",
"description": "Воронка онлайн-школы английского языка для взрослых.",
"firstScreenId": "start"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "start",
"template": "info",
"title": {
"text": "Говори уверенно через 12 недель",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Живые уроки с преподавателем, ежедневная практика и контроль прогресса.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "🌍",
"size": "xl"
},
"bottomActionButton": {
"text": "Диагностика уровня"
},
"navigation": {
"defaultNextScreenId": "pain"
}
},
{
"id": "pain",
"template": "info",
"title": {
"text": "Почему 4 из 5 студентов не доходят до результата?",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Нерегулярность, отсутствие практики и скучные уроки. Мы исправили каждую точку.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "goal-date"
}
},
{
"id": "goal-date",
"template": "date",
"title": {
"text": "Когда предстоит важное событие на английском?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Сформируем план подготовки под конкретную дату.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Событие:"
},
"navigation": {
"defaultNextScreenId": "current-level"
}
},
{
"id": "current-level",
"template": "list",
"title": {
"text": "Оцени свой текущий уровень",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "starter", "label": "Начинаю с нуля" },
{ "id": "elementary", "label": "Могу поддержать простую беседу" },
{ "id": "intermediate", "label": "Хочу говорить свободно" },
{ "id": "advanced", "label": "Нужен профессиональный английский" }
]
},
"navigation": {
"defaultNextScreenId": "difficulties"
}
},
{
"id": "difficulties",
"template": "list",
"title": {
"text": "Что даётся сложнее всего?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "speaking", "label": "Разговорная речь" },
{ "id": "listening", "label": "Понимание на слух" },
{ "id": "grammar", "label": "Грамматика" },
{ "id": "vocabulary", "label": "Словарный запас" },
{ "id": "confidence", "label": "Стеснение" }
]
},
"navigation": {
"defaultNextScreenId": "success-story"
}
},
{
"id": "success-story",
"template": "info",
"title": {
"text": "Кейс Максима: оффер в международной компании",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "За 10 недель он прокачал разговорный до Upper-Intermediate, прошёл интервью и удвоил доход.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "study-format"
}
},
{
"id": "study-format",
"template": "list",
"title": {
"text": "Как удобнее заниматься?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "individual", "label": "Индивидуально с преподавателем" },
{ "id": "mini-group", "label": "Мини-группа до 4 человек" },
{ "id": "intensive", "label": "Интенсив по выходным" }
]
},
"navigation": {
"defaultNextScreenId": "practice"
}
},
{
"id": "practice",
"template": "info",
"title": {
"text": "Практика каждый день",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Марафоны спикинга, разговорные клубы с носителями и тренажёр произношения в приложении.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "support"
}
},
{
"id": "support",
"template": "list",
"title": {
"text": "Что важно в поддержке?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "mentor", "label": "Личный куратор" },
{ "id": "feedback", "label": "Еженедельный фидбек" },
{ "id": "chat", "label": "Чат 24/7" },
{ "id": "reports", "label": "Отчёт о прогрессе" }
]
},
"navigation": {
"defaultNextScreenId": "contact-form"
}
},
{
"id": "contact-form",
"template": "form",
"title": {
"text": "Получите индивидуальный учебный план",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
{ "id": "email", "label": "Email", "placeholder": "Получите PDF-план", "type": "email", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте ввод"
},
"navigation": {
"defaultNextScreenId": "mentor-match"
}
},
{
"id": "mentor-match",
"template": "info",
"title": {
"text": "Мы подобрали вам преподавателя",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Сертифицированный CELTA преподаватель с опытом подготовки к собеседованиям.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "programs"
}
},
{
"id": "programs",
"template": "list",
"title": {
"text": "Выберите программу",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "starter", "label": "Start Now — 8 недель" },
{ "id": "pro", "label": "Career Boost — 12 недель" },
{ "id": "vip", "label": "Executive — 16 недель + коуч" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Бонусы для тех, кто оплачивает сегодня",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Доступ к библиотеке TED-тренажёров и разговорный клуб в подарок.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Закрепите скидку",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Скидка 30% на первый модуль и бонусный урок с носителем.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "LinguaPro",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-30%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Курс и разговорный клуб",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "LINGUA30",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы использовать промокод",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,313 +0,0 @@
{
"meta": {
"id": "ru-mind-balance",
"title": "MindBalance: психотерапия для результата",
"description": "Воронка сервиса подбора психолога с поддержкой и пакетами сопровождения.",
"firstScreenId": "welcome"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "welcome",
"template": "info",
"title": {
"text": "Верни устойчивость за 8 недель",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Персональный подбор терапевта, структурные сессии и поддержка между встречами.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "🧠",
"size": "xl"
},
"bottomActionButton": {
"text": "Пройти тест"
},
"navigation": {
"defaultNextScreenId": "pain"
}
},
{
"id": "pain",
"template": "info",
"title": {
"text": "Ты не обязан справляться в одиночку",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Выгорание, тревога, сложности в отношениях — наши клиенты чувствовали то же. Сейчас живут без этого тяжёлого груза.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "stress-date"
}
},
{
"id": "stress-date",
"template": "date",
"title": {
"text": "Когда ты последний раз отдыхал(а) без тревог?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Это помогает оценить уровень стресса и подобрать ритм терапии.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Дата отдыха:"
},
"navigation": {
"defaultNextScreenId": "state"
}
},
{
"id": "state",
"template": "list",
"title": {
"text": "Что чувствуешь чаще всего?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "anxiety", "label": "Тревога" },
{ "id": "apathy", "label": "Апатия" },
{ "id": "anger", "label": "Раздражительность" },
{ "id": "insomnia", "label": "Проблемы со сном" },
{ "id": "relationships", "label": "Конфликты" }
]
},
"navigation": {
"defaultNextScreenId": "goals"
}
},
{
"id": "goals",
"template": "list",
"title": {
"text": "К чему хочешь прийти?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "energy", "label": "Больше энергии" },
{ "id": "confidence", "label": "Уверенность в решениях" },
{ "id": "relations", "label": "Гармония в отношениях" },
{ "id": "selfcare", "label": "Ценность себя" },
{ "id": "career", "label": "Сфокусированность в работе" }
]
},
"navigation": {
"defaultNextScreenId": "success"
}
},
{
"id": "success",
"template": "info",
"title": {
"text": "История Ани: спокойствие вместо паники",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Через 7 сессий она перестала просыпаться ночью, получила повышение и наладила отношения с мужем.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "format"
}
},
{
"id": "format",
"template": "list",
"title": {
"text": "Какой формат терапии удобен?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "online", "label": "Онлайн-видеосессии" },
{ "id": "audio", "label": "Аудио и чат-поддержка" },
{ "id": "offline", "label": "Офлайн в кабинете" }
]
},
"navigation": {
"defaultNextScreenId": "frequency"
}
},
{
"id": "frequency",
"template": "list",
"title": {
"text": "С какой частотой готовы встречаться?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "weekly", "label": "Раз в неделю" },
{ "id": "twice", "label": "Дважды в неделю" },
{ "id": "flex", "label": "Гибкий график" }
]
},
"navigation": {
"defaultNextScreenId": "form"
}
},
{
"id": "form",
"template": "form",
"title": {
"text": "Получить подбор психолога",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "name", "label": "Имя", "placeholder": "Ваше имя", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
{ "id": "email", "label": "Email", "placeholder": "Для плана терапии", "type": "email", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте ввод"
},
"navigation": {
"defaultNextScreenId": "therapist"
}
},
{
"id": "therapist",
"template": "info",
"title": {
"text": "Мы нашли специалиста",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Психолог с 9-летним опытом CBT, работает с тревогой и выгоранием. Первичная консультация — завтра.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "packages"
}
},
{
"id": "packages",
"template": "list",
"title": {
"text": "Выберите пакет",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "start", "label": "Start — 4 сессии" },
{ "id": "focus", "label": "Focus — 8 сессий + чат" },
{ "id": "deep", "label": "Deep — 12 сессий + коуч" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Подарок к старту",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Медитации MindBalance и ежедневный трекер настроения бесплатно.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Закрепите скидку",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Скидка 20% на первый пакет и бонусный аудио-курс.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "MindBalance",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-20%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Первый пакет + аудио-курс",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "MIND20",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы применить промокод",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,314 +0,0 @@
{
"meta": {
"id": "ru-skin-renewal",
"title": "Glow Clinic: омоложение без боли",
"description": "Воронка для клиники косметологии с диагностикой кожи и продажей курса процедур.",
"firstScreenId": "welcome"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "welcome",
"template": "info",
"title": {
"text": "Верни коже сияние за 28 дней",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Лицо свежее, овал подтянутый, поры незаметны — результат подтверждён 418 клиентками.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "✨",
"size": "xl"
},
"bottomActionButton": {
"text": "Пройти диагностику"
},
"navigation": {
"defaultNextScreenId": "problem"
}
},
{
"id": "problem",
"template": "info",
"title": {
"text": "85% женщин старят три фактора",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Обезвоженность, пигментация и потеря тонуса. Находим источник и устраняем его комплексно.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "skin-date"
}
},
{
"id": "skin-date",
"template": "date",
"title": {
"text": "Когда была последняя профессиональная чистка?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Дата поможет подобрать интенсивность и глубину процедур.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Последний визит:"
},
"navigation": {
"defaultNextScreenId": "skin-type"
}
},
{
"id": "skin-type",
"template": "list",
"title": {
"text": "Какой у тебя тип кожи?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "dry", "label": "Сухая" },
{ "id": "combination", "label": "Комбинированная" },
{ "id": "oily", "label": "Жирная" },
{ "id": "sensitive", "label": "Чувствительная" }
]
},
"navigation": {
"defaultNextScreenId": "primary-concern"
}
},
{
"id": "primary-concern",
"template": "list",
"title": {
"text": "Что беспокоит больше всего?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "wrinkles", "label": "Морщины" },
{ "id": "pigmentation", "label": "Пигментация" },
{ "id": "pores", "label": "Расширенные поры" },
{ "id": "acne", "label": "Воспаления" },
{ "id": "dryness", "label": "Сухость и шелушение" }
]
},
"navigation": {
"defaultNextScreenId": "success"
}
},
{
"id": "success",
"template": "info",
"title": {
"text": "История Нади: минус 7 лет визуально",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Через 3 сеанса HydraGlow кожа стала плотной, контур подтянулся, ушла желтизна. Её фото попало в наш кейсбук.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "home-care"
}
},
{
"id": "home-care",
"template": "list",
"title": {
"text": "Как ухаживаешь дома?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "basic", "label": "Только базовый уход" },
{ "id": "active", "label": "Активные сыворотки" },
{ "id": "spapro", "label": "Домашние аппараты" },
{ "id": "none", "label": "Практически не ухаживаю" }
]
},
"navigation": {
"defaultNextScreenId": "allergy"
}
},
{
"id": "allergy",
"template": "list",
"title": {
"text": "Есть ли ограничения?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "pregnancy", "label": "Беременность/ГВ" },
{ "id": "allergy", "label": "Аллергия на кислоты" },
{ "id": "derm", "label": "Дерматологические заболевания" },
{ "id": "no", "label": "Нет ограничений" }
]
},
"navigation": {
"defaultNextScreenId": "diagnostic-form"
}
},
{
"id": "diagnostic-form",
"template": "form",
"title": {
"text": "Получить персональный план ухода",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
{ "id": "email", "label": "Email", "placeholder": "Получите чек-лист ухода", "type": "email", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте формат"
},
"navigation": {
"defaultNextScreenId": "expert"
}
},
{
"id": "expert",
"template": "info",
"title": {
"text": "Ваш персональный эксперт",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Врач-косметолог с 12-летним опытом проведёт диагностику, составит план процедур и будет на связи между визитами.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "plan-options"
}
},
{
"id": "plan-options",
"template": "list",
"title": {
"text": "Выберите программу",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "express", "label": "Express Glow — 2 визита" },
{ "id": "course", "label": "Total Lift — 4 визита" },
{ "id": "vip", "label": "VIP Anti-Age — 6 визитов" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Подарок к записи сегодня",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Профессиональная сыворотка Medik8 и массаж шеи в подарок на первом приёме.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Забронируй курс со скидкой",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Только сегодня — до 40% на программу и подарок.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "Glow Clinic",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-40%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Курс омоложения + сыворотка",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "GLOW40",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы закрепить предложение",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,315 +0,0 @@
{
"meta": {
"id": "ru-travel-signature",
"title": "Signature Trips: путешествие мечты",
"description": "Воронка для премиального турагентства по созданию индивидуальных путешествий.",
"firstScreenId": "hero"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "hero",
"template": "info",
"title": {
"text": "Создадим путешествие, о котором будут говорить",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Личный тревел-архитектор, закрытые локации и полный сервис 24/7.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "✈️",
"size": "xl"
},
"bottomActionButton": {
"text": "Начать проект"
},
"navigation": {
"defaultNextScreenId": "inspiration"
}
},
{
"id": "inspiration",
"template": "info",
"title": {
"text": "Премиальный отдых начинается с мечты",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Мы создаём маршруты для Forbes, топ-менеджеров и семей, которые ценят приватность.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "travel-date"
}
},
{
"id": "travel-date",
"template": "date",
"title": {
"text": "Когда планируете отправиться?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Дата позволяет нам зарезервировать лучшие отели и гидов заранее.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Старт путешествия:"
},
"navigation": {
"defaultNextScreenId": "companions"
}
},
{
"id": "companions",
"template": "list",
"title": {
"text": "С кем летите?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "solo", "label": "Соло" },
{ "id": "couple", "label": "Пара" },
{ "id": "family", "label": "Семья" },
{ "id": "friends", "label": "Компания друзей" }
]
},
"navigation": {
"defaultNextScreenId": "style"
}
},
{
"id": "style",
"template": "list",
"title": {
"text": "Какой стиль отдыха хотите?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "beach", "label": "Пляжный релакс" },
{ "id": "city", "label": "Городской lifestyle" },
{ "id": "adventure", "label": "Приключения" },
{ "id": "culture", "label": "Культура и гастрономия" },
{ "id": "wellness", "label": "Wellness & spa" }
]
},
"navigation": {
"defaultNextScreenId": "case"
}
},
{
"id": "case",
"template": "info",
"title": {
"text": "Кейс семьи Морозовых",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "10 дней на Бали: вилла на скале, частный шеф, экскурсии на вертолёте. Экономия времени — 60 часов.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "wishlist"
}
},
{
"id": "wishlist",
"template": "list",
"title": {
"text": "Что должно быть обязательно?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "private", "label": "Приватные перелёты" },
{ "id": "events", "label": "Закрытые мероприятия" },
{ "id": "photographer", "label": "Личный фотограф" },
{ "id": "kids", "label": "Детский клуб" },
{ "id": "chef", "label": "Шеф-повар" }
]
},
"navigation": {
"defaultNextScreenId": "budget"
}
},
{
"id": "budget",
"template": "list",
"title": {
"text": "Какой бюджет готовы инвестировать?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "5k", "label": "до $5 000" },
{ "id": "10k", "label": "$5 000 $10 000" },
{ "id": "20k", "label": "$10 000 $20 000" },
{ "id": "20kplus", "label": "Более $20 000" }
]
},
"navigation": {
"defaultNextScreenId": "form"
}
},
{
"id": "form",
"template": "form",
"title": {
"text": "Получите концепт путешествия",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "name", "label": "Имя", "placeholder": "Как к вам обращаться", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
{ "id": "email", "label": "Email", "placeholder": "Получить концепт", "type": "email", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте формат"
},
"navigation": {
"defaultNextScreenId": "concierge"
}
},
{
"id": "concierge",
"template": "info",
"title": {
"text": "Ваш персональный консьерж",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Будет на связи 24/7, бронирует рестораны, решает любые вопросы во время поездки.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "packages"
}
},
{
"id": "packages",
"template": "list",
"title": {
"text": "Выберите формат сервиса",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "concept", "label": "Concept — разработка маршрута" },
{ "id": "full", "label": "Full Care — сопровождение 24/7" },
{ "id": "ultra", "label": "Ultra Lux — частный самолёт и охрана" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Специальный бонус",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "При бронировании сегодня — апгрейд номера и приватная фотосессия.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Забронируйте бонус",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Подарочный апгрейд и персональный гид входят в промо",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "Signature Trips",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "Premium Bonus",
"font": "manrope",
"weight": "black",
"size": "3xl"
},
"description": {
"text": "Апгрейд номера + личный гид",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "SIGNATURE",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы зафиксировать бонус",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

View File

@ -1,315 +0,0 @@
{
"meta": {
"id": "ru-wedding-dream",
"title": "DreamDay: свадьба без стресса",
"description": "Воронка агентства свадебного продюсирования.",
"firstScreenId": "welcome"
},
"defaultTexts": {
"nextButton": "Далее",
"continueButton": "Продолжить"
},
"screens": [
{
"id": "welcome",
"template": "info",
"title": {
"text": "Создадим свадьбу, о которой мечтаете",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"description": {
"text": "Команда продюсеров возьмёт на себя всё: от концепции до финального танца.",
"font": "inter",
"weight": "medium",
"align": "center"
},
"icon": {
"type": "emoji",
"value": "💍",
"size": "xl"
},
"bottomActionButton": {
"text": "Начать план"
},
"navigation": {
"defaultNextScreenId": "vision"
}
},
{
"id": "vision",
"template": "info",
"title": {
"text": "Каждая история любви уникальна",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Мы создаём сценарии, которые отражают вашу пару, а не Pinterest-копию.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "date"
}
},
{
"id": "date",
"template": "date",
"title": {
"text": "На какую дату планируется свадьба?",
"font": "manrope",
"weight": "bold"
},
"subtitle": {
"text": "Мы проверим занятость площадок и команд.",
"font": "inter",
"weight": "medium",
"color": "muted"
},
"dateInput": {
"monthPlaceholder": "ММ",
"dayPlaceholder": "ДД",
"yearPlaceholder": "ГГГГ",
"monthLabel": "Месяц",
"dayLabel": "День",
"yearLabel": "Год",
"showSelectedDate": true,
"selectedDateLabel": "Дата свадьбы:"
},
"navigation": {
"defaultNextScreenId": "guests"
}
},
{
"id": "guests",
"template": "list",
"title": {
"text": "Сколько гостей ожидаете?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "small", "label": "До 30 гостей" },
{ "id": "medium", "label": "30-70 гостей" },
{ "id": "large", "label": "70-120 гостей" },
{ "id": "xl", "label": "Более 120 гостей" }
]
},
"navigation": {
"defaultNextScreenId": "style"
}
},
{
"id": "style",
"template": "list",
"title": {
"text": "Опишите стиль праздника",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "classic", "label": "Классика" },
{ "id": "modern", "label": "Современный шик" },
{ "id": "boho", "label": "Бохо" },
{ "id": "destination", "label": "Destination wedding" },
{ "id": "party", "label": "Ночной клуб" }
]
},
"navigation": {
"defaultNextScreenId": "case"
}
},
{
"id": "case",
"template": "info",
"title": {
"text": "Свадьба Кати и Максима",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Горная Швейцария, закрытая вилла и живой оркестр. Сэкономили 18 часов подготовки еженедельно.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "priorities"
}
},
{
"id": "priorities",
"template": "list",
"title": {
"text": "Что важнее всего?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "multi",
"options": [
{ "id": "venue", "label": "Локация мечты" },
{ "id": "show", "label": "Вау-программа" },
{ "id": "decor", "label": "Дизайн и флористика" },
{ "id": "photo", "label": "Фото и видео" },
{ "id": "care", "label": "Отсутствие стресса" }
]
},
"navigation": {
"defaultNextScreenId": "budget"
}
},
{
"id": "budget",
"template": "list",
"title": {
"text": "Какой бюджет планируете?",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "3m", "label": "До 3 млн ₽" },
{ "id": "5m", "label": "3-5 млн ₽" },
{ "id": "8m", "label": "5-8 млн ₽" },
{ "id": "8mplus", "label": "Более 8 млн ₽" }
]
},
"navigation": {
"defaultNextScreenId": "form"
}
},
{
"id": "form",
"template": "form",
"title": {
"text": "Получите концепцию свадьбы",
"font": "manrope",
"weight": "bold"
},
"fields": [
{ "id": "names", "label": "Имена пары", "placeholder": "Имена", "type": "text", "required": true },
{ "id": "phone", "label": "Телефон", "placeholder": "+7 (___) ___-__-__", "type": "tel", "required": true },
{ "id": "email", "label": "Email", "placeholder": "Получить презентацию", "type": "email", "required": true }
],
"validationMessages": {
"required": "Поле ${field} обязательно",
"invalidFormat": "Проверьте формат"
},
"navigation": {
"defaultNextScreenId": "team"
}
},
{
"id": "team",
"template": "info",
"title": {
"text": "Команда под вашу историю",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Продюсер, стилист, режиссёр и координатор. Каждую неделю — отчёт и контроль бюджета.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "packages"
}
},
{
"id": "packages",
"template": "list",
"title": {
"text": "Выберите формат сопровождения",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"options": [
{ "id": "concept", "label": "Concept — идея и сценарий" },
{ "id": "production", "label": "Production — организация под ключ" },
{ "id": "lux", "label": "Luxury — destination + премиум команда" }
]
},
"navigation": {
"defaultNextScreenId": "bonus"
}
},
{
"id": "bonus",
"template": "info",
"title": {
"text": "Бонусы при бронировании сегодня",
"font": "manrope",
"weight": "bold"
},
"description": {
"text": "Пробная встреча с ведущим и авторские клятвы, подготовленные нашим спичрайтером.",
"font": "inter",
"weight": "medium"
},
"navigation": {
"defaultNextScreenId": "coupon"
}
},
{
"id": "coupon",
"template": "coupon",
"title": {
"text": "Зафиксируйте дату и бонус",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"subtitle": {
"text": "Скидка 15% на продюсирование и бесплатная love-story съёмка.",
"font": "inter",
"weight": "medium",
"align": "center",
"color": "muted"
},
"coupon": {
"title": {
"text": "DreamDay",
"font": "manrope",
"weight": "bold",
"color": "primary"
},
"offer": {
"title": {
"text": "-15%",
"font": "manrope",
"weight": "black",
"size": "4xl"
},
"description": {
"text": "Продюсирование + love-story",
"font": "inter",
"weight": "medium"
}
},
"promoCode": {
"text": "DREAM15",
"font": "inter",
"weight": "semiBold"
},
"footer": {
"text": "Нажмите, чтобы закрепить предложение",
"font": "inter",
"weight": "medium",
"size": "sm",
"color": "muted"
}
},
"copiedMessage": "Промокод {code} скопирован!"
}
]
}

77
scripts/README.md Normal file
View File

@ -0,0 +1,77 @@
# Scripts Documentation
## Funnel Management Scripts
### 📥 `import-funnels-to-db.mjs`
Импортирует воронки из JSON файлов в `public/funnels/` в MongoDB.
```bash
npm run import:funnels
```
### 📤 `sync-funnels-from-db.mjs`
Синхронизирует опубликованные воронки из MongoDB обратно в проект:
1. Извлекает все последние версии опубликованных воронок из БД
2. Сохраняет их во временные JSON файлы в `public/funnels/`
3. Запекает их в TypeScript (`src/lib/funnel/bakedFunnels.ts`)
4. Удаляет временные JSON файлы
#### Основное использование:
```bash
# Синхронизация всех воронок
npm run sync:funnels
# Просмотр справки
npm run sync:funnels -- --help
```
#### Опции:
**`--dry-run`** - Показать что будет синхронизировано без реальных изменений:
```bash
npm run sync:funnels -- --dry-run
```
**`--keep-files`** - Сохранить JSON файлы после запекания (полезно для отладки):
```bash
npm run sync:funnels -- --keep-files
```
**`--funnel-ids <ids>`** - Синхронизировать только определенные воронки:
```bash
npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
```
**Комбинирование опций:**
```bash
npm run sync:funnels -- --dry-run --funnel-ids funnel-test
npm run sync:funnels -- --keep-files --dry-run
```
### 🔥 `bake-funnels.mjs`
Конвертирует JSON файлы воронок в TypeScript константы.
```bash
npm run bake:funnels
```
## Workflow
### Разработка локально:
1. Создать/редактировать воронки в админке
2. Опубликовать их
3. Запустить `npm run sync:funnels` для обновления кода
### Деплой:
1. Запустить `npm run sync:funnels` перед билдом
2. Собрать проект с актуальными воронками
### Отладка:
1. `npm run sync:funnels -- --dry-run` - посмотреть что будет синхронизировано
2. `npm run sync:funnels -- --keep-files` - оставить JSON файлы для проверки
3. `npm run sync:funnels -- --funnel-ids specific-id` - синхронизировать только одну воронку

290
scripts/sync-funnels-from-db.mjs Executable file
View File

@ -0,0 +1,290 @@
#!/usr/bin/env node
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config({ path: '.env.local' });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.dirname(__dirname);
const funnelsDir = path.join(projectRoot, 'public', 'funnels');
// MongoDB connection URI
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/witlab-funnel';
// Mongoose schemas (same as in import script)
const FunnelDataSchema = new mongoose.Schema({
meta: {
id: { type: String, required: true },
version: String,
title: String,
description: String,
firstScreenId: String
},
defaultTexts: {
nextButton: { type: String, default: 'Next' },
continueButton: { type: String, default: 'Continue' }
},
screens: [mongoose.Schema.Types.Mixed]
}, { _id: false });
const FunnelSchema = new mongoose.Schema({
funnelData: {
type: FunnelDataSchema,
required: true
},
name: {
type: String,
required: true,
trim: true,
maxlength: 200
},
description: {
type: String,
trim: true,
maxlength: 1000
},
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft',
required: true
},
version: {
type: Number,
default: 1,
min: 1
},
createdBy: { type: String, default: 'system' },
lastModifiedBy: { type: String, default: 'system' },
usage: {
totalViews: { type: Number, default: 0, min: 0 },
totalCompletions: { type: Number, default: 0, min: 0 },
lastUsed: Date
},
publishedAt: { type: Date, default: Date.now }
}, {
timestamps: true,
collection: 'funnels'
});
const Funnel = mongoose.model('Funnel', FunnelSchema);
async function connectDB() {
try {
await mongoose.connect(MONGODB_URI);
console.log('✅ Connected to MongoDB');
} catch (error) {
console.error('❌ MongoDB connection failed:', error.message);
process.exit(1);
}
}
async function ensureFunnelsDir() {
try {
await fs.access(funnelsDir);
} catch {
await fs.mkdir(funnelsDir, { recursive: true });
console.log('📁 Created funnels directory');
}
}
async function clearFunnelsDir() {
try {
const files = await fs.readdir(funnelsDir);
for (const file of files) {
if (file.endsWith('.json')) {
await fs.unlink(path.join(funnelsDir, file));
}
}
console.log('🧹 Cleared existing JSON files');
} catch (error) {
console.error('⚠️ Error clearing funnels directory:', error.message);
}
}
async function getLatestPublishedFunnels() {
try {
// Группируем по funnelData.meta.id и берем последнюю версию каждой опубликованной воронки
const latestFunnels = await Funnel.aggregate([
// Фильтруем только опубликованные воронки
{ $match: { status: 'published' } },
// Сортируем по версии в убывающем порядке
{ $sort: { 'funnelData.meta.id': 1, version: -1 } },
// Группируем по ID воронки и берем первый документ (с наибольшей версией)
{
$group: {
_id: '$funnelData.meta.id',
latestFunnel: { $first: '$$ROOT' }
}
},
// Заменяем корневой документ на latestFunnel
{ $replaceRoot: { newRoot: '$latestFunnel' } }
]);
console.log(`📊 Found ${latestFunnels.length} latest published funnels`);
return latestFunnels;
} catch (error) {
console.error('❌ Error fetching funnels:', error.message);
throw error;
}
}
async function saveFunnelToFile(funnel) {
const funnelId = funnel.funnelData.meta.id;
const fileName = `${funnelId}.json`;
const filePath = path.join(funnelsDir, fileName);
try {
// Сохраняем только funnelData (структуру воронки)
const funnelContent = JSON.stringify(funnel.funnelData, null, 2);
await fs.writeFile(filePath, funnelContent, 'utf8');
console.log(`💾 Saved ${fileName} (v${funnel.version})`);
} catch (error) {
console.error(`❌ Error saving ${fileName}:`, error.message);
throw error;
}
}
async function bakeFunnels() {
try {
console.log('🔥 Baking funnels...');
execSync('npm run bake:funnels', {
cwd: projectRoot,
stdio: 'inherit'
});
console.log('✅ Funnels baked successfully');
} catch (error) {
console.error('❌ Error baking funnels:', error.message);
throw error;
}
}
// Парсим аргументы командной строки
const args = process.argv.slice(2);
const options = {
funnelIds: [],
dryRun: false,
keepFiles: false,
};
// Парсим опции
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--dry-run') {
options.dryRun = true;
} else if (arg === '--keep-files') {
options.keepFiles = true;
} else if (arg === '--funnel-ids') {
// Следующий аргумент должен содержать ID воронок через запятую
const idsArg = args[++i];
if (idsArg) {
options.funnelIds = idsArg.split(',').map(id => id.trim());
}
} else if (arg === '--help' || arg === '-h') {
console.log(`
🔄 Sync Funnels from Database
Usage: npm run sync:funnels [options]
Options:
--dry-run Show what would be synced without actually doing it
--keep-files Keep JSON files after baking (useful for debugging)
--funnel-ids <ids> Sync only specific funnel IDs (comma-separated)
--help, -h Show this help message
Examples:
npm run sync:funnels
npm run sync:funnels -- --dry-run
npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
npm run sync:funnels -- --keep-files --dry-run
`);
process.exit(0);
}
}
// Обновляем функцию syncFunnels для поддержки опций
async function syncFunnelsWithOptions() {
if (options.dryRun) {
console.log('🔍 DRY RUN MODE - No actual changes will be made\n');
}
console.log('🚀 Starting funnel sync from database...\n');
try {
// 1. Подключаемся к базе данных
await connectDB();
// 2. Создаем/очищаем папку для воронок (только если не dry-run)
await ensureFunnelsDir();
if (!options.dryRun) {
await clearFunnelsDir();
}
// 3. Получаем последние версии всех опубликованных воронок
const allFunnels = await getLatestPublishedFunnels();
// Фильтруем по указанным ID если они заданы
let funnels = allFunnels;
if (options.funnelIds.length > 0) {
funnels = allFunnels.filter(funnel =>
options.funnelIds.includes(funnel.funnelData.meta.id)
);
console.log(`🎯 Filtering to ${funnels.length} specific funnels: ${options.funnelIds.join(', ')}`);
}
if (funnels.length === 0) {
console.log(' No published funnels found matching criteria');
return;
}
// 4. Сохраняем каждую воронку в JSON файл
for (const funnel of funnels) {
if (options.dryRun) {
console.log(`🔍 Would save ${funnel.funnelData.meta.id}.json (v${funnel.version})`);
} else {
await saveFunnelToFile(funnel);
}
}
// 5. Запекаем воронки в TypeScript
if (!options.dryRun) {
await bakeFunnels();
} else {
console.log('🔍 Would bake funnels to TypeScript');
}
// 6. Удаляем JSON файлы после запекания (если не указано сохранить)
if (!options.dryRun && !options.keepFiles) {
await clearFunnelsDir();
} else if (options.keepFiles) {
console.log('📁 Keeping JSON files as requested');
} else if (options.dryRun) {
console.log('🔍 Would clean up JSON files');
}
console.log('\n🎉 Funnel sync completed successfully!');
console.log(`📈 ${options.dryRun ? 'Would sync' : 'Synced'} ${funnels.length} funnels from database`);
} catch (error) {
console.error('\n💥 Sync failed:', error.message);
process.exit(1);
} finally {
await mongoose.disconnect();
console.log('🔌 Disconnected from MongoDB');
}
}
// Запускаем скрипт с опциями
syncFunnelsWithOptions();

View File

@ -124,8 +124,7 @@ export default function AdminCatalogPage() {
firstScreenId: 'screen-1' firstScreenId: 'screen-1'
}, },
defaultTexts: { defaultTexts: {
nextButton: 'Далее', nextButton: 'Continue'
continueButton: 'Продолжить'
}, },
screens: [ screens: [
{ {

View File

@ -11,7 +11,8 @@ import {
BuilderPreview BuilderPreview
} from "@/components/admin/builder"; } from "@/components/admin/builder";
import type { BuilderState } from '@/lib/admin/builder/context'; import type { BuilderState } from '@/lib/admin/builder/context';
import type { FunnelDefinition } from '@/lib/funnel/types'; import type { FunnelDefinition, ScreenDefinition } from '@/lib/funnel/types';
import type { BuilderScreen } from '@/lib/admin/builder/types';
import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils'; import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils';
interface FunnelData { interface FunnelData {
@ -71,6 +72,29 @@ export default function FunnelBuilderPage() {
} }
}; };
// Функция очистки экрана от служебных полей админки
const cleanScreen = (screen: BuilderScreen): ScreenDefinition => {
// Создаем копию экрана без служебного поля position
const result = { ...screen } as BuilderScreen & Record<string, unknown>;
// Убираем служебное поле position (используется только в canvas админки)
delete result.position;
// Для НЕ-list экранов убираем поле list если оно есть
if (result.template !== "list" && 'list' in result) {
delete result.list;
}
// Для НЕ-form экранов убираем поле fields если оно есть
if (result.template !== "form" && 'fields' in result) {
delete result.fields;
}
// variants оставляем для всех экранов - это валидное поле
return result as ScreenDefinition;
};
// Сохранение воронки // Сохранение воронки
const saveFunnel = async (builderState: BuilderState, publish: boolean = false) => { const saveFunnel = async (builderState: BuilderState, publish: boolean = false) => {
if (!funnelData || saving) return; if (!funnelData || saving) return;
@ -82,10 +106,9 @@ export default function FunnelBuilderPage() {
const updatedFunnelData: FunnelDefinition = { const updatedFunnelData: FunnelDefinition = {
meta: builderState.meta, meta: builderState.meta,
defaultTexts: { defaultTexts: {
nextButton: 'Далее', nextButton: 'Counitue'
continueButton: 'Продолжить'
}, },
screens: builderState.screens screens: builderState.screens.map(cleanScreen)
}; };
const response = await fetch(`/api/funnels/${funnelId}`, { const response = await fetch(`/api/funnels/${funnelId}`, {
@ -133,8 +156,7 @@ export default function FunnelBuilderPage() {
const funnelSnapshot: FunnelDefinition = { const funnelSnapshot: FunnelDefinition = {
meta: builderState.meta, meta: builderState.meta,
defaultTexts: { defaultTexts: {
nextButton: 'Далее', nextButton: 'Continue'
continueButton: 'Продолжить'
}, },
screens: builderState.screens screens: builderState.screens
}; };
@ -246,7 +268,7 @@ export default function FunnelBuilderPage() {
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-[360px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95"> <aside className="w-[440px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95">
<BuilderSidebar /> <BuilderSidebar />
</aside> </aside>

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context"; import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import { AddScreenDialog } from "../dialogs/AddScreenDialog"; import { AddScreenDialog } from "../dialogs/AddScreenDialog";
@ -145,10 +144,9 @@ export function BuilderCanvas() {
</div> </div>
<div className="relative flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-900"> <div className="relative flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-900">
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto w-full max-w-none">
<div className="absolute left-6 top-0 bottom-0 hidden w-px bg-border md:block" aria-hidden />
<div <div
className="space-y-6 pl-0 md:pl-12" className="space-y-6"
onDragOver={handleDragOverList} onDragOver={handleDragOverList}
onDrop={handleDrop} onDrop={handleDrop}
> >
@ -158,7 +156,6 @@ export function BuilderCanvas() {
const isDropAfter = dropIndex === screens.length && index === screens.length - 1; const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
const rules = screen.navigation?.rules ?? []; const rules = screen.navigation?.rules ?? [];
const defaultNext = screen.navigation?.defaultNextScreenId; const defaultNext = screen.navigation?.defaultNextScreenId;
const isLast = index === screens.length - 1;
const defaultTargetIndex = defaultNext const defaultTargetIndex = defaultNext
? screens.findIndex((candidate) => candidate.id === defaultNext) ? screens.findIndex((candidate) => candidate.id === defaultNext)
: null; : null;
@ -166,16 +163,7 @@ export function BuilderCanvas() {
return ( return (
<div key={screen.id} className="relative"> <div key={screen.id} className="relative">
{isDropBefore && <DropIndicator isActive={isDropBefore} />} {isDropBefore && <DropIndicator isActive={isDropBefore} />}
<div className="flex items-start gap-4 md:gap-6"> <div>
<div className="relative hidden w-8 flex-shrink-0 md:flex md:flex-col md:items-center">
<span className="mt-1 h-3 w-3 rounded-full border-2 border-background bg-primary shadow" />
{!isLast && (
<div className="mt-2 flex h-full flex-col items-center">
<div className="flex-1 w-px bg-gradient-to-b from-primary/40 via-border/40 to-transparent" />
<ArrowDown className="mt-1 h-4 w-4 text-border/70" />
</div>
)}
</div>
<div <div
className={cn( className={cn(
"relative flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md", "relative flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",

View File

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { TemplateConfig } from "@/components/admin/builder/templates"; import { TemplateConfig } from "@/components/admin/builder/templates";
import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig"; import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig";
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context"; import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
@ -57,6 +58,10 @@ export function BuilderSidebar() {
dispatch({ type: "set-meta", payload: { firstScreenId: value } }); dispatch({ type: "set-meta", payload: { firstScreenId: value } });
}; };
const handleDefaultTextsChange = (field: keyof NonNullable<typeof state.defaultTexts>, value: string) => {
dispatch({ type: "set-default-texts", payload: { [field]: value } });
};
const handleScreenIdChange = (currentId: string, newId: string) => { const handleScreenIdChange = (currentId: string, newId: string) => {
if (newId.trim() === "" || newId === currentId) { if (newId.trim() === "" || newId === currentId) {
return; return;
@ -297,9 +302,8 @@ export function BuilderSidebar() {
<div className="flex-1 overflow-y-auto px-4 py-4"> <div className="flex-1 overflow-y-auto px-4 py-4">
{activeTab === "funnel" ? ( {activeTab === "funnel" ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Section title="Валидация"> {/* Валидация всегда вверху, без заголовка */}
<ValidationSummary issues={validation.issues} /> <ValidationSummary issues={validation.issues} />
</Section>
<Section title="Настройки воронки" description="Общие параметры"> <Section title="Настройки воронки" description="Общие параметры">
<TextInput <TextInput
@ -333,6 +337,21 @@ export function BuilderSidebar() {
</label> </label>
</Section> </Section>
<Section title="Дефолтные тексты" description="Текст кнопок и баннеров">
<TextInput
label="Текст кнопки Next/Continue"
placeholder="Next"
value={state.defaultTexts?.nextButton ?? ""}
onChange={(event) => handleDefaultTextsChange("nextButton", event.target.value)}
/>
<TextInput
label="Баннер приватности"
placeholder="Мы не передаем личную информацию..."
value={state.defaultTexts?.privacyBanner ?? ""}
onChange={(event) => handleDefaultTextsChange("privacyBanner", event.target.value)}
/>
</Section>
<Section title="Экраны"> <Section title="Экраны">
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-sm text-muted-foreground"> <div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -352,6 +371,9 @@ export function BuilderSidebar() {
</div> </div>
) : selectedScreen ? ( ) : selectedScreen ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Валидация всегда вверху, без заголовка */}
<ValidationSummary issues={screenValidationIssues} />
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3"> <div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -457,9 +479,10 @@ export function BuilderSidebar() {
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span> <span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
<Button <Button
variant="ghost" variant="ghost"
className="text-destructive" className="h-8 px-2 text-destructive hover:bg-destructive/10"
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)} onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
> >
<Trash2 className="h-3 w-3 mr-1" />
<span className="text-xs">Удалить</span> <span className="text-xs">Удалить</span>
</Button> </Button>
</div> </div>
@ -533,10 +556,6 @@ export function BuilderSidebar() {
</Section> </Section>
)} )}
<Section title="Валидация">
<ValidationSummary issues={screenValidationIssues} />
</Section>
<Section title="Управление"> <Section title="Управление">
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4"> <div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="mb-3 text-sm text-muted-foreground"> <p className="mb-3 text-sm text-muted-foreground">
@ -548,6 +567,7 @@ export function BuilderSidebar() {
disabled={state.screens.length <= 1} disabled={state.screens.length <= 1}
onClick={() => handleDeleteScreen(selectedScreen.id)} onClick={() => handleDeleteScreen(selectedScreen.id)}
> >
<Trash2 className="h-4 w-4 mr-2" />
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"} {state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
</Button> </Button>
</div> </div>

View File

@ -2,12 +2,13 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context"; import { useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
import { renderScreen } from "@/lib/funnel/screenRenderer"; import { renderScreen } from "@/lib/funnel/screenRenderer";
import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants"; import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants";
export function BuilderPreview() { export function BuilderPreview() {
const selectedScreen = useBuilderSelectedScreen(); const selectedScreen = useBuilderSelectedScreen();
const builderState = useBuilderState();
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(null); const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(null);
@ -72,7 +73,7 @@ export function BuilderPreview() {
canGoBack: true, // Show back button in preview canGoBack: true, // Show back button in preview
onBack: () => {}, // Mock back handler for preview onBack: () => {}, // Mock back handler for preview
screenProgress: { current: 1, total: 10 }, // Mock progress for preview screenProgress: { current: 1, total: 10 }, // Mock progress for preview
defaultTexts: { nextButton: "Next", continueButton: "Continue" }, // Mock texts defaultTexts: builderState.defaultTexts, // Use real defaultTexts from builder
}); });
} catch (error) { } catch (error) {
console.error('Error rendering preview:', error); console.error('Error rendering preview:', error);
@ -82,7 +83,7 @@ export function BuilderPreview() {
</div> </div>
); );
} }
}, [previewScreen, selectedIds, handleSelectionChange]); }, [previewScreen, selectedIds, handleSelectionChange, builderState.defaultTexts]);
const preview = useMemo(() => { const preview = useMemo(() => {
if (!previewScreen) { if (!previewScreen) {

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import type { CouponScreenDefinition } from "@/lib/funnel/types"; import type { CouponScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types"; import type { BuilderScreen } from "@/lib/admin/builder/types";
@ -32,7 +33,7 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
</h3> </h3>
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground"> <label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
Заголовок оффера Заголовок оффера
<TextInput <TextAreaInput
placeholder="-50% на первый заказ" placeholder="-50% на первый заказ"
value={couponScreen.coupon?.offer?.title?.text ?? ""} value={couponScreen.coupon?.offer?.title?.text ?? ""}
onChange={(event) => onChange={(event) =>
@ -44,11 +45,13 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
}, },
}) })
} }
rows={2}
className="resize-y"
/> />
</label> </label>
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground"> <label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
Подзаголовок/описание Подзаголовок/описание
<TextInput <TextAreaInput
placeholder="Персональная акция только сегодня" placeholder="Персональная акция только сегодня"
value={couponScreen.coupon?.offer?.description?.text ?? ""} value={couponScreen.coupon?.offer?.description?.text ?? ""}
onChange={(event) => onChange={(event) =>
@ -60,6 +63,8 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
}, },
}) })
} }
rows={2}
className="resize-y"
/> />
</label> </label>
</div> </div>

View File

@ -48,17 +48,6 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
}); });
}; };
const handleInfoMessageChange = (field: "text" | "icon", value: string) => {
const baseInfo = dateScreen.infoMessage ?? { text: "", icon: "" };
const nextInfo = { ...baseInfo, [field]: value };
if (!nextInfo.text) {
onUpdate({ infoMessage: undefined });
return;
}
onUpdate({ infoMessage: nextInfo });
};
return ( return (
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm"> <div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
@ -185,25 +174,6 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
</label> </label>
</div> </div>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Информационный блок</h4>
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Сообщение (оставьте пустым, чтобы скрыть)</span>
<TextInput
value={dateScreen.infoMessage?.text ?? ""}
onChange={(event) => handleInfoMessageChange("text", event.target.value)}
/>
</label>
{dateScreen.infoMessage && (
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Emoji/иконка для сообщения</span>
<TextInput
value={dateScreen.infoMessage.icon ?? ""}
onChange={(event) => handleInfoMessageChange("icon", event.target.value)}
/>
</label>
)}
</div>
</div> </div>
); );
} }

View File

@ -2,7 +2,8 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Plus } from "lucide-react"; import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import { Plus, Trash2 } from "lucide-react";
import type { FormScreenDefinition, FormFieldDefinition, FormValidationMessages } from "@/lib/funnel/types"; import type { FormScreenDefinition, FormFieldDefinition, FormValidationMessages } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types"; import type { BuilderScreen } from "@/lib/admin/builder/types";
@ -59,57 +60,61 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
</div> </div>
{formScreen.fields?.map((field, index) => ( {formScreen.fields?.map((field, index) => (
<div key={field.id} className="space-y-2 rounded-lg border border-border/50 bg-muted/5 p-3"> <div key={index} className="space-y-2 rounded-lg border border-border/50 bg-muted/5 p-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground"> <span className="text-xs font-semibold uppercase text-muted-foreground">
Поле {index + 1} Поле {index + 1}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
className="h-8 px-3 text-xs text-destructive" className="h-8 px-2 text-destructive hover:bg-destructive/10"
onClick={() => removeField(index)} onClick={() => removeField(index)}
> >
<Trash2 className="h-3 w-3 mr-1" />
Удалить Удалить
</Button> </Button>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 text-muted-foreground"> <label className="flex flex-col gap-1">
ID поля <span className="text-sm font-medium text-muted-foreground">ID поля</span>
<TextInput value={field.id} onChange={(event) => updateField(index, { id: event.target.value })} /> <TextInput value={field.id} onChange={(event) => updateField(index, { id: event.target.value })} />
</label> </label>
<label className="flex flex-col gap-1 text-muted-foreground"> <label className="flex flex-col gap-1">
Тип <span className="text-sm font-medium text-muted-foreground">Тип</span>
<select <select
className="rounded-lg border border-border bg-background px-2 py-1" className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={field.type ?? "text"} value={field.type ?? "text"}
onChange={(event) => updateField(index, { type: event.target.value as FormFieldDefinition["type"] })} onChange={(event) => updateField(index, { type: event.target.value as FormFieldDefinition["type"] })}
> >
<option value="text">Текст</option> <option value="text">Текст</option>
<option value="email">E-mail</option> <option value="email">E-mail</option>
<option value="tel">Телефон</option> <option value="tel">Телефон</option>
<option value="url">Ссылка</option>
</select> </select>
</label> </label>
</div> </div>
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> <label className="flex flex-col gap-1">
Метка поля <span className="text-sm font-medium text-muted-foreground">Метка поля</span>
<TextInput <TextAreaInput
value={field.label ?? ""} value={field.label ?? ""}
onChange={(event) => updateField(index, { label: event.target.value })} onChange={(event) => updateField(index, { label: event.target.value })}
rows={2}
className="resize-y"
/> />
</label> </label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> <label className="flex flex-col gap-1">
Placeholder <span className="text-sm font-medium text-muted-foreground">Placeholder</span>
<TextInput <TextAreaInput
value={field.placeholder ?? ""} value={field.placeholder ?? ""}
onChange={(event) => updateField(index, { placeholder: event.target.value })} onChange={(event) => updateField(index, { placeholder: event.target.value })}
rows={2}
className="resize-y"
/> />
</label> </label>
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="pt-2">
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <label className="flex items-center gap-2 text-sm text-muted-foreground">
<input <input
type="checkbox" type="checkbox"
@ -118,58 +123,8 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
/> />
Обязательно для заполнения Обязательно для заполнения
</label> </label>
<label className="flex flex-col gap-1 text-muted-foreground">
Максимальная длина
<input
type="number"
min={1}
className="rounded-lg border border-border bg-background px-2 py-1"
value={field.maxLength ?? ""}
onChange={(event) =>
updateField(index, {
maxLength: event.target.value ? Number(event.target.value) : undefined,
})
}
/>
</label>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Регулярное выражение (pattern)
<TextInput
placeholder="Например, ^\\d+$"
value={field.validation?.pattern ?? ""}
onChange={(event) =>
updateField(index, {
validation: {
...(field.validation ?? {}),
pattern: event.target.value || undefined,
message: field.validation?.message,
},
})
}
/>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Текст ошибки для pattern
<TextInput
placeholder="Неверный формат"
value={field.validation?.message ?? ""}
onChange={(event) =>
updateField(index, {
validation:
field.validation || event.target.value
? {
...(field.validation ?? {}),
message: event.target.value || undefined,
}
: undefined,
})
}
/>
</label>
</div>
</div> </div>
))} ))}
@ -191,45 +146,16 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
Доступна переменная: <code className="bg-muted px-1 rounded">{`{field}`}</code> - название поля Доступна переменная: <code className="bg-muted px-1 rounded">{`{field}`}</code> - название поля
</p> </p>
</div> </div>
<TextInput <TextAreaInput
placeholder="Пример: {field} обязательно для заполнения" placeholder="Пример: Поле {field} обязательно"
value={formScreen.validationMessages?.required ?? ""} value={formScreen.validationMessages?.required ?? ""}
onChange={(event) => updateValidationMessages({ required: event.target.value || undefined })} onChange={(event) => updateValidationMessages({ required: event.target.value || undefined })}
rows={2}
className="resize-y"
/> />
</label> </label>
</div> </div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex flex-col gap-2 text-muted-foreground">
<div>
<span className="font-medium">Превышена длина</span>
<p className="text-xs mt-1 text-muted-foreground/80">
Доступны переменные: <code className="bg-muted px-1 rounded">{`{field}`}</code>, <code className="bg-muted px-1 rounded">{`{maxLength}`}</code>
</p>
</div>
<TextInput
placeholder="Пример: {field} не может быть длиннее {maxLength} символов"
value={formScreen.validationMessages?.maxLength ?? ""}
onChange={(event) => updateValidationMessages({ maxLength: event.target.value || undefined })}
/>
</label>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex flex-col gap-2 text-muted-foreground">
<div>
<span className="font-medium">Неверный формат</span>
<p className="text-xs mt-1 text-muted-foreground/80">
Доступна переменная: <code className="bg-muted px-1 rounded">{`{field}`}</code> - название поля
</p>
</div>
<TextInput
placeholder="Пример: Проверьте формат {field}"
value={formScreen.validationMessages?.invalidFormat ?? ""}
onChange={(event) => updateValidationMessages({ invalidFormat: event.target.value || undefined })}
/>
</label>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import { MarkupPreview } from "@/components/ui/MarkupText/MarkupText"; import { MarkupPreview } from "@/components/ui/MarkupText/MarkupText";
import type { InfoScreenDefinition } from "@/lib/funnel/types"; import type { InfoScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types"; import type { BuilderScreen } from "@/lib/admin/builder/types";
@ -51,10 +52,12 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
<div className="space-y-3"> <div className="space-y-3">
<label className="flex flex-col gap-2 text-sm"> <label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span> <span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
<TextInput <TextAreaInput
placeholder="Введите пояснение для пользователя. Используйте **текст** для выделения жирным." placeholder="Введите пояснение для пользователя. Используйте **текст** для выделения жирным."
value={infoScreen.description?.text ?? ""} value={infoScreen.description?.text ?? ""}
onChange={(event) => handleDescriptionChange(event.target.value)} onChange={(event) => handleDescriptionChange(event.target.value)}
rows={3}
className="resize-y"
/> />
</label> </label>

View File

@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowDown, ArrowUp, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react"; import { ArrowDown, ArrowUp, Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import type { import type {
@ -161,7 +162,7 @@ export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
<div className="space-y-3"> <div className="space-y-3">
{listScreen.list.options.map((option, index) => ( {listScreen.list.options.map((option, index) => (
<div <div
key={option.id} key={index}
className="space-y-2 rounded-lg border border-border/50 bg-muted/5 p-3" className="space-y-2 rounded-lg border border-border/50 bg-muted/5 p-3"
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -222,9 +223,11 @@ export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground"> <label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Подпись для пользователя Подпись для пользователя
<TextInput <TextAreaInput
value={option.label} value={option.label}
onChange={(event) => handleOptionChange(index, "label", event.target.value)} onChange={(event) => handleOptionChange(index, "label", event.target.value)}
rows={2}
className="resize-y"
/> />
</label> </label>
@ -240,12 +243,14 @@ export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground"> <label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Описание (необязательно) Описание (необязательно)
<TextInput <TextAreaInput
placeholder="Дополнительное описание варианта" placeholder="Дополнительное описание варианта"
value={option.description ?? ""} value={option.description ?? ""}
onChange={(event) => onChange={(event) =>
handleOptionChange(index, "description", event.target.value || undefined) handleOptionChange(index, "description", event.target.value || undefined)
} }
rows={2}
className="resize-y"
/> />
</label> </label>

View File

@ -13,6 +13,7 @@ import { LoadersScreenConfig } from "./LoadersScreenConfig";
import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig"; import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import type { BuilderScreen } from "@/lib/admin/builder/types"; import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { import type {
ScreenDefinition, ScreenDefinition,
@ -84,12 +85,13 @@ function CollapsibleSection({
interface TypographyControlsProps { interface TypographyControlsProps {
label: string; label: string;
value: TypographyVariant | undefined; value: (TypographyVariant & { show?: boolean }) | undefined;
onChange: (value: TypographyVariant | undefined) => void; onChange: (value: (TypographyVariant & { show?: boolean }) | undefined) => void;
allowRemove?: boolean; allowRemove?: boolean;
showToggle?: boolean; // Показывать ли чекбокс "Показывать"
} }
function TypographyControls({ label, value, onChange, allowRemove = false }: TypographyControlsProps) { function TypographyControls({ label, value, onChange, allowRemove = false, showToggle = false }: TypographyControlsProps) {
const storageKey = `typography-advanced-${label.toLowerCase().replace(/\s+/g, '-')}`; const storageKey = `typography-advanced-${label.toLowerCase().replace(/\s+/g, '-')}`;
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
@ -124,11 +126,35 @@ function TypographyControls({ label, value, onChange, allowRemove = false }: Typ
}); });
}; };
const handleShowToggle = (show: boolean) => {
onChange({
...value,
text: value?.text || "",
show,
});
};
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{showToggle && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={value?.show ?? true}
onChange={(event) => handleShowToggle(event.target.checked)}
/>
Показывать {label.toLowerCase()}
</label>
)}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-foreground">{label}</label> <label className="text-sm font-medium text-foreground">{label}</label>
<TextInput value={value?.text ?? ""} onChange={(event) => handleTextChange(event.target.value)} /> <TextAreaInput
value={value?.text ?? ""}
onChange={(event) => handleTextChange(event.target.value)}
rows={2}
className="resize-y"
/>
</div> </div>
{value?.text && ( {value?.text && (
@ -412,8 +438,20 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<CollapsibleSection title="Заголовок и подзаголовок"> <CollapsibleSection title="Заголовок и подзаголовок">
<TypographyControls label="Заголовок" value={screen.title} onChange={handleTitleChange} /> <TypographyControls
<TypographyControls label="Подзаголовок" value={"subtitle" in screen ? screen.subtitle : undefined} onChange={handleSubtitleChange} allowRemove /> label="Заголовок"
value={screen.title}
onChange={handleTitleChange}
allowRemove
showToggle
/>
<TypographyControls
label="Подзаголовок"
value={"subtitle" in screen ? screen.subtitle : undefined}
onChange={handleSubtitleChange}
allowRemove
showToggle
/>
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection title="Шапка экрана"> <CollapsibleSection title="Шапка экрана">

View File

@ -22,8 +22,7 @@ const meta: Meta<typeof CouponTemplate> = {
onBack: fn(), onBack: fn(),
screenProgress: { current: 8, total: 10 }, screenProgress: { current: 8, total: 10 },
defaultTexts: { defaultTexts: {
nextButton: "Next", nextButton: "Next"
continueButton: "Continue"
}, },
}, },
argTypes: { argTypes: {

View File

@ -6,7 +6,7 @@ import { Coupon } from "@/components/widgets/Coupon/Coupon";
import Typography from "@/components/ui/Typography/Typography"; import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers"; import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { CouponScreenDefinition } from "@/lib/funnel/types"; import type { CouponScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
interface CouponTemplateProps { interface CouponTemplateProps {
@ -15,7 +15,7 @@ interface CouponTemplateProps {
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
screenProgress?: { current: number; total: number }; screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string }; defaultTexts?: DefaultTexts;
} }
export function CouponTemplate({ export function CouponTemplate({
@ -109,7 +109,7 @@ export function CouponTemplate({
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }} titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }} subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{ actionButtonOptions={{
defaultText: defaultTexts?.continueButton || "Continue", defaultText: defaultTexts?.nextButton || "Continue",
disabled: false, disabled: false,
onClick: onContinue, onClick: onContinue,
}} }}

View File

@ -24,8 +24,7 @@ const meta: Meta<typeof DateTemplate> = {
onBack: fn(), onBack: fn(),
screenProgress: { current: 4, total: 10 }, screenProgress: { current: 4, total: 10 },
defaultTexts: { defaultTexts: {
nextButton: "Next", nextButton: "Next"
continueButton: "Continue"
}, },
}, },
argTypes: { argTypes: {
@ -99,7 +98,6 @@ export const WithoutInfoMessage: Story = {
args: { args: {
screen: { screen: {
...defaultScreen, ...defaultScreen,
infoMessage: undefined,
}, },
}, },
}; };

View File

@ -1,12 +1,10 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import Image from "next/image";
import DateInput from "@/components/widgets/DateInput/DateInput"; import DateInput from "@/components/widgets/DateInput/DateInput";
import Typography from "@/components/ui/Typography/Typography"; import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers"; import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner";
import type { DateScreenDefinition } from "@/lib/funnel/types"; import type { DateScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
import { TemplateLayout } from "../layouts/TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
// Утилита для форматирования даты на основе паттерна // Утилита для форматирования даты на основе паттерна
@ -32,7 +30,7 @@ interface DateTemplateProps {
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
screenProgress?: { current: number; total: number }; screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string }; defaultTexts?: DefaultTexts;
} }
export function DateTemplate({ export function DateTemplate({
@ -138,36 +136,18 @@ export function DateTemplate({
locale="en" locale="en"
/> />
{screen.infoMessage && ( {defaultTexts?.privacyBanner && (
<div className="flex justify-center"> <div className="flex justify-center">
<div className="flex items-start gap-3"> <PrivacySecurityBanner
<div className="mt-0.5 flex-shrink-0"> className="mt-5"
<Image text={{
src="/GuardIcon.svg" children: defaultTexts.privacyBanner,
alt="Security icon" size: "sm",
width={20} color: "default",
height={20} font: "inter",
className="object-contain" weight: "medium"
/> }}
</div> />
<Typography
as="p"
size="sm"
color="default"
{...buildTypographyProps(screen.infoMessage, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "default",
align: "left",
},
})}
className={cn("text-slate-600 leading-relaxed", screen.infoMessage.className)}
>
{screen.infoMessage.text}
</Typography>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -23,7 +23,7 @@ const meta: Meta<typeof EmailTemplate> = {
screenProgress: { current: 9, total: 10 }, screenProgress: { current: 9, total: 10 },
defaultTexts: { defaultTexts: {
nextButton: "Next", nextButton: "Next",
continueButton: "Continue"
}, },
}, },
argTypes: { argTypes: {

View File

@ -25,7 +25,6 @@ const richFormScreen: FormScreenDefinition = {
placeholder: "Введите ваше имя", placeholder: "Введите ваше имя",
type: "text", type: "text",
required: true, required: true,
maxLength: 50,
}, },
{ {
id: "email", id: "email",
@ -41,13 +40,6 @@ const richFormScreen: FormScreenDefinition = {
type: "tel", type: "tel",
required: false, required: false,
}, },
{
id: "website",
label: "Веб-сайт",
placeholder: "https://example.com",
type: "url",
required: false,
},
], ],
}; };
@ -69,7 +61,7 @@ const meta: Meta<typeof FormTemplate> = {
screenProgress: { current: 6, total: 10 }, screenProgress: { current: 6, total: 10 },
defaultTexts: { defaultTexts: {
nextButton: "Next", nextButton: "Next",
continueButton: "Continue"
}, },
}, },
argTypes: { argTypes: {
@ -117,9 +109,7 @@ export const CustomValidation: Story = {
screen: { screen: {
...richFormScreen, ...richFormScreen,
validationMessages: { validationMessages: {
required: "Пожалуйста, заполните это поле", required: "Пожалуйста, заполните это поле",
maxLength: "Слишком длинное значение",
invalidFormat: "Неправильный формат данных",
}, },
}, },
}, },

View File

@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { FormScreenDefinition } from "@/lib/funnel/types"; import type { FormScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
interface FormTemplateProps { interface FormTemplateProps {
@ -15,7 +15,7 @@ interface FormTemplateProps {
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
screenProgress?: { current: number; total: number }; screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string }; defaultTexts?: DefaultTexts;
} }
export function FormTemplate({ export function FormTemplate({
@ -47,16 +47,7 @@ export function FormTemplate({
return screen.validationMessages?.required?.replace('${field}', field.label || field.id) || `${field.label || field.id} is required`; return screen.validationMessages?.required?.replace('${field}', field.label || field.id) || `${field.label || field.id} is required`;
} }
if (field.maxLength && value.length > field.maxLength) {
return screen.validationMessages?.maxLength?.replace('${maxLength}', String(field.maxLength)) || `Maximum ${field.maxLength} characters allowed`;
}
if (field.validation?.pattern) {
const regex = new RegExp(field.validation.pattern);
if (!regex.test(value)) {
return field.validation.message || screen.validationMessages?.invalidFormat || "Invalid format";
}
}
return null; return null;
}; };
@ -111,7 +102,11 @@ export function FormTemplate({
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }} titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }} subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{ actionButtonOptions={{
defaultText: defaultTexts?.continueButton || "Continue", // Правильная логика приоритетов для текста кнопки:
// 1. screen.bottomActionButton.text (настройка экрана)
// 2. defaultTexts.nextButton (глобальная настройка воронки)
// 3. "Next" (хардкод fallback)
defaultText: screen.bottomActionButton?.text || defaultTexts?.nextButton || "Next",
disabled: !isFormComplete, disabled: !isFormComplete,
onClick: handleContinue, onClick: handleContinue,
}} }}
@ -125,7 +120,6 @@ export function FormTemplate({
type={field.type || "text"} type={field.type || "text"}
value={localFormData[field.id] || ""} value={localFormData[field.id] || ""}
onChange={(e) => handleFieldChange(field.id, e.target.value)} onChange={(e) => handleFieldChange(field.id, e.target.value)}
maxLength={field.maxLength}
aria-invalid={!!errors[field.id]} aria-invalid={!!errors[field.id]}
aria-errormessage={errors[field.id]} aria-errormessage={errors[field.id]}
/> />

View File

@ -22,8 +22,7 @@ const meta: Meta<typeof InfoTemplate> = {
onBack: fn(), onBack: fn(),
screenProgress: { current: 3, total: 10 }, screenProgress: { current: 3, total: 10 },
defaultTexts: { defaultTexts: {
nextButton: "Next", nextButton: "Next"
continueButton: "Continue"
}, },
}, },
argTypes: { argTypes: {

View File

@ -4,7 +4,7 @@ import { useMemo } from "react";
import Image from "next/image"; import Image from "next/image";
import Typography from "@/components/ui/Typography/Typography"; import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers"; import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { InfoScreenDefinition } from "@/lib/funnel/types"; import type { InfoScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -14,7 +14,7 @@ interface InfoTemplateProps {
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
screenProgress?: { current: number; total: number }; screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string }; defaultTexts?: DefaultTexts;
} }
export function InfoTemplate({ export function InfoTemplate({

View File

@ -23,7 +23,7 @@ const meta: Meta<typeof LoadersTemplate> = {
screenProgress: undefined, // У лоадеров обычно нет прогресса screenProgress: undefined, // У лоадеров обычно нет прогресса
defaultTexts: { defaultTexts: {
nextButton: "Next", nextButton: "Next",
continueButton: "Continue"
}, },
}, },
argTypes: { argTypes: {

View File

@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { TemplateLayout } from "../layouts/TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
import CircularProgressbarsList from "@/components/widgets/CircularProgressbarsList/CircularProgressbarsList"; import CircularProgressbarsList from "@/components/widgets/CircularProgressbarsList/CircularProgressbarsList";
import type { LoadersScreenDefinition } from "@/lib/funnel/types"; import type { LoadersScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
interface LoadersTemplateProps { interface LoadersTemplateProps {
screen: LoadersScreenDefinition; screen: LoadersScreenDefinition;
@ -11,7 +11,7 @@ interface LoadersTemplateProps {
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
screenProgress?: { current: number; total: number }; screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string }; defaultTexts?: DefaultTexts;
} }
export function LoadersTemplate({ export function LoadersTemplate({

View File

@ -23,7 +23,7 @@ const meta: Meta<typeof SoulmatePortraitTemplate> = {
screenProgress: { current: 10, total: 10 }, // Обычно финальный экран screenProgress: { current: 10, total: 10 }, // Обычно финальный экран
defaultTexts: { defaultTexts: {
nextButton: "Next", nextButton: "Next",
continueButton: "Continue"
}, },
}, },
argTypes: { argTypes: {

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types"; import type { SoulmatePortraitScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
interface SoulmatePortraitTemplateProps { interface SoulmatePortraitTemplateProps {
@ -9,7 +9,7 @@ interface SoulmatePortraitTemplateProps {
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
screenProgress?: { current: number; total: number }; screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string }; defaultTexts?: DefaultTexts;
} }
export function SoulmatePortraitTemplate({ export function SoulmatePortraitTemplate({

View File

@ -39,6 +39,7 @@ interface TemplateLayoutProps {
}; };
// Дополнительные props для BottomActionButton // Дополнительные props для BottomActionButton
childrenAboveButton?: React.ReactNode;
childrenUnderButton?: React.ReactNode; childrenUnderButton?: React.ReactNode;
// Контент template // Контент template
@ -57,6 +58,7 @@ export function TemplateLayout({
titleDefaults = { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }, titleDefaults = { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" },
subtitleDefaults = { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }, subtitleDefaults = { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
actionButtonOptions, actionButtonOptions,
childrenAboveButton,
childrenUnderButton, childrenUnderButton,
children, children,
}: TemplateLayoutProps) { }: TemplateLayoutProps) {
@ -123,6 +125,7 @@ export function TemplateLayout({
<BottomActionButton <BottomActionButton
{...bottomActionButtonProps} {...bottomActionButtonProps}
ref={bottomActionButtonRef} ref={bottomActionButtonRef}
childrenAboveButton={childrenAboveButton}
childrenUnderButton={finalChildrenUnderButton} childrenUnderButton={finalChildrenUnderButton}
/> />
)} )}

View File

@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
import { Textarea } from "../textarea";
import { Label } from "../label";
import { useId } from "react";
interface TextAreaInputProps extends React.ComponentProps<typeof Textarea> {
label?: string;
containerProps?: React.ComponentProps<"div">;
}
function TextAreaInput({
className,
label,
containerProps,
...props
}: TextAreaInputProps) {
const id = useId();
const textareaId = props.id || id;
return (
<div
{...containerProps}
className={cn("w-full flex flex-col gap-2", containerProps?.className)}
>
{label && (
<Label
htmlFor={textareaId}
className="text-muted-foreground font-inter font-medium text-base"
>
{label}
</Label>
)}
<Textarea
data-slot="textarea-input"
className={cn(
"py-3.5 px-4",
"font-inter text-[18px]/[28px] font-semibold text-foreground",
"placeholder:text-muted-foreground placeholder:text-[18px]/[28px] font-medium",
"border-2 border-primary/30 rounded-2xl",
className
)}
id={textareaId}
{...props}
/>
{props["aria-invalid"] && (
<p className="text-destructive font-inter font-medium text-xs">
{props["aria-errormessage"]}
</p>
)}
</div>
);
}
export { TextAreaInput };

View File

@ -5,10 +5,21 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
// Обеспечиваем корректную работу выделения текста и удаления
autoComplete="off"
spellCheck="false"
style={{
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text'
}}
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
// Дополнительные стили для корректной работы с текстом
"select-text cursor-text",
className className
)} )}
{...props} {...props}

View File

@ -0,0 +1,31 @@
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
// Обеспечиваем корректную работу выделения текста и удаления
autoComplete="off"
spellCheck="false"
style={{
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
msUserSelect: 'text'
}}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
// Дополнительные стили для корректной работы с текстом
"select-text cursor-text",
// Стили для textarea
"resize-y min-h-[80px]",
className
)}
{...props}
/>
);
}
export { Textarea };

View File

@ -51,3 +51,114 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Default = {} satisfies Story; export const Default = {} satisfies Story;
export const WithChildrenAbove: Story = {
args: {
actionButtonProps: {
children: "Continue",
},
childrenAboveButton: (
<div className="w-full text-center space-y-1 mb-4">
<Typography
as="p"
size="sm"
color="muted"
className="font-medium text-center"
>
Выбранная дата:
</Typography>
<Typography
as="p"
size="xl"
weight="bold"
color="default"
className="font-semibold text-center"
>
April 8, 1987
</Typography>
</div>
),
},
};
export const WithChildrenUnder: Story = {
args: {
actionButtonProps: {
children: "Continue",
},
childrenUnderButton: (
<Typography
as="p"
size="sm"
color="muted"
className="text-center mt-3"
>
By continuing, you agree to our Terms of Service
</Typography>
),
},
};
export const WithBothChildren: Story = {
args: {
actionButtonProps: {
children: "Continue",
},
childrenAboveButton: (
<div className="w-full text-center space-y-1 mb-4">
<Typography
as="p"
size="sm"
color="muted"
className="font-medium text-center"
>
Selected Date:
</Typography>
<Typography
as="p"
size="xl"
weight="bold"
color="default"
className="font-semibold text-center"
>
March 15, 1990
</Typography>
</div>
),
childrenUnderButton: (
<Typography
as="p"
size="sm"
color="muted"
className="text-center mt-3"
>
Your information is secure and private
</Typography>
),
},
};
export const OnlyGradientBlur: Story = {
args: {
showGradientBlur: true,
childrenAboveButton: (
<div className="text-center space-y-1">
<Typography
as="p"
size="lg"
color="default"
className="font-semibold"
>
Content above the button area
</Typography>
<Typography
as="p"
size="sm"
color="muted"
>
This example shows content in the blur area without a button
</Typography>
</div>
),
},
};

View File

@ -7,7 +7,6 @@ import type {
TypographyVariant, TypographyVariant,
IconDefinition, IconDefinition,
DateInputDefinition, DateInputDefinition,
InfoMessageDefinition,
CouponDefinition, CouponDefinition,
FormFieldDefinition, FormFieldDefinition,
FormValidationMessages, FormValidationMessages,
@ -67,7 +66,6 @@ export function buildDefaultSubtitle(overrides?: Partial<SubtitleDefinition>): S
export function buildDefaultBottomActionButton(overrides?: Partial<BottomActionButtonDefinition>): BottomActionButtonDefinition { export function buildDefaultBottomActionButton(overrides?: Partial<BottomActionButtonDefinition>): BottomActionButtonDefinition {
return { return {
show: true, show: true,
text: "Продолжить",
showGradientBlur: true, showGradientBlur: true,
...overrides, ...overrides,
}; };
@ -129,21 +127,6 @@ export function buildDefaultDateInput(overrides?: Partial<DateInputDefinition>):
}; };
} }
/**
* Creates default info message configuration
*/
export function buildDefaultInfoMessage(overrides?: Partial<InfoMessageDefinition>): InfoMessageDefinition {
return {
text: "Мы используем эту информацию только для анализа",
icon: "🔒",
font: "manrope",
weight: "regular",
align: "center",
size: "sm",
color: "muted",
...overrides,
};
}
/** /**
* Creates default coupon configuration * Creates default coupon configuration
@ -217,8 +200,6 @@ export function buildDefaultFormFields(): FormFieldDefinition[] {
export function buildDefaultFormValidation(overrides?: Partial<FormValidationMessages>): FormValidationMessages { export function buildDefaultFormValidation(overrides?: Partial<FormValidationMessages>): FormValidationMessages {
return { return {
required: "Это поле обязательно для заполнения", required: "Это поле обязательно для заполнения",
maxLength: "Превышена максимальная длина",
invalidFormat: "Неверный формат",
...overrides, ...overrides,
}; };
} }

View File

@ -5,8 +5,7 @@ import {
buildDefaultSubtitle, buildDefaultSubtitle,
buildDefaultBottomActionButton, buildDefaultBottomActionButton,
buildDefaultNavigation, buildDefaultNavigation,
buildDefaultDateInput, buildDefaultDateInput
buildDefaultInfoMessage
} from "./blocks"; } from "./blocks";
export function buildDateDefaults(id: string): BuilderScreen { export function buildDateDefaults(id: string): BuilderScreen {
@ -17,7 +16,6 @@ export function buildDateDefaults(id: string): BuilderScreen {
title: buildDefaultTitle(), title: buildDefaultTitle(),
subtitle: buildDefaultSubtitle(), subtitle: buildDefaultSubtitle(),
dateInput: buildDefaultDateInput(), dateInput: buildDefaultDateInput(),
infoMessage: buildDefaultInfoMessage(),
bottomActionButton: buildDefaultBottomActionButton(), bottomActionButton: buildDefaultBottomActionButton(),
navigation: buildDefaultNavigation(), navigation: buildDefaultNavigation(),
} as BuilderScreen; } as BuilderScreen;

View File

@ -8,7 +8,6 @@ export {
buildDefaultDescription, buildDefaultDescription,
buildDefaultIcon, buildDefaultIcon,
buildDefaultDateInput, buildDefaultDateInput,
buildDefaultInfoMessage,
buildDefaultCoupon, buildDefaultCoupon,
buildDefaultFormFields, buildDefaultFormFields,
buildDefaultFormValidation, buildDefaultFormValidation,

View File

@ -15,6 +15,15 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
}, },
}); });
} }
case "set-default-texts": {
return withDirty(state, {
...state,
defaultTexts: {
...state.defaultTexts,
...action.payload,
},
});
}
case "add-screen": { case "add-screen": {
const nextId = generateScreenId(state.screens.map((s) => s.id)); const nextId = generateScreenId(state.screens.map((s) => s.id));
const template = action.payload?.template || "list"; const template = action.payload?.template || "list";

View File

@ -11,6 +11,7 @@ export interface BuilderState extends BuilderFunnelState {
export type BuilderAction = export type BuilderAction =
| { type: "set-meta"; payload: Partial<BuilderFunnelState["meta"]> } | { type: "set-meta"; payload: Partial<BuilderFunnelState["meta"]> }
| { type: "set-default-texts"; payload: Partial<BuilderFunnelState["defaultTexts"]> }
| { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> } | { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> }
| { type: "remove-screen"; payload: { screenId: string } } | { type: "remove-screen"; payload: { screenId: string } }
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } } | { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }

View File

@ -4,5 +4,6 @@ export type BuilderScreen = ScreenDefinition;
export interface BuilderFunnelState { export interface BuilderFunnelState {
meta: FunnelDefinition["meta"]; meta: FunnelDefinition["meta"];
defaultTexts?: FunnelDefinition["defaultTexts"];
screens: BuilderScreen[]; screens: BuilderScreen[];
} }

View File

@ -38,6 +38,7 @@ export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderSt
return { return {
meta: funnel.meta, meta: funnel.meta,
defaultTexts: funnel.defaultTexts,
screens: builderScreens, screens: builderScreens,
selectedScreenId: builderScreens[0]?.id ?? null, selectedScreenId: builderScreens[0]?.id ?? null,
isDirty: false, isDirty: false,
@ -53,6 +54,7 @@ export function serializeBuilderState(state: BuilderFunnelState): FunnelDefiniti
return { return {
meta, meta,
defaultTexts: state.defaultTexts,
screens, screens,
}; };
} }

File diff suppressed because it is too large Load Diff

View File

@ -193,13 +193,13 @@ export function buildLayoutQuestionProps(
onBack: showBackButton ? onBack : undefined, onBack: showBackButton ? onBack : undefined,
showBackButton, showBackButton,
} : undefined, } : undefined,
title: buildTypographyProps(screen.title, { title: screen.title ? (buildTypographyProps(screen.title, {
as: "h2", as: "h2",
defaults: titleDefaults, defaults: titleDefaults,
}) ?? { }) ?? {
as: "h2", as: "h2",
children: screen.title.text, children: screen.title.text,
}, }) : undefined,
subtitle: 'subtitle' in screen ? buildTypographyProps(screen.subtitle, { subtitle: 'subtitle' in screen ? buildTypographyProps(screen.subtitle, {
as: "p", as: "p",
defaults: subtitleDefaults, defaults: subtitleDefaults,

View File

@ -22,6 +22,7 @@ import type {
LoadersScreenDefinition, LoadersScreenDefinition,
SoulmatePortraitScreenDefinition, SoulmatePortraitScreenDefinition,
ScreenDefinition, ScreenDefinition,
DefaultTexts,
} from "@/lib/funnel/types"; } from "@/lib/funnel/types";
export interface ScreenRenderProps { export interface ScreenRenderProps {
@ -32,7 +33,7 @@ export interface ScreenRenderProps {
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
screenProgress: { current: number; total: number }; screenProgress: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string }; defaultTexts?: DefaultTexts;
} }
export type TemplateRenderer = (props: ScreenRenderProps) => JSX.Element; export type TemplateRenderer = (props: ScreenRenderProps) => JSX.Element;
@ -146,9 +147,12 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
// Простая логика: кнопка есть если не отключена (show: false) // Простая логика: кнопка есть если не отключена (show: false)
const hasActionButton = !isButtonDisabled; const hasActionButton = !isButtonDisabled;
const actionConfig = hasActionButton // Правильная логика приоритетов для текста кнопки:
? (bottomActionButton ?? { text: defaultTexts?.nextButton || "Next" }) // 1. bottomActionButton.text (настройка экрана)
: undefined; // 2. defaultTexts.nextButton (глобальная настройка воронки)
// 3. "Next" (хардкод fallback)
const buttonText = bottomActionButton?.text || defaultTexts?.nextButton || "Next";
const actionDisabled = hasActionButton && isSelectionEmpty; const actionDisabled = hasActionButton && isSelectionEmpty;
return ( return (
@ -158,7 +162,7 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
onSelectionChange={onSelectionChange} onSelectionChange={onSelectionChange}
actionButtonProps={hasActionButton actionButtonProps={hasActionButton
? { ? {
children: actionConfig?.text ?? "Next", children: buttonText,
disabled: actionDisabled, disabled: actionDisabled,
onClick: actionDisabled ? undefined : onContinue, onClick: actionDisabled ? undefined : onContinue,
} }

View File

@ -153,9 +153,6 @@ export interface DateInputDefinition {
}; };
} }
export interface InfoMessageDefinition extends TypographyVariant {
icon?: string;
}
export interface DateScreenDefinition { export interface DateScreenDefinition {
id: string; id: string;
@ -164,7 +161,6 @@ export interface DateScreenDefinition {
title: TitleDefinition; title: TitleDefinition;
subtitle?: SubtitleDefinition; subtitle?: SubtitleDefinition;
dateInput: DateInputDefinition; dateInput: DateInputDefinition;
infoMessage?: InfoMessageDefinition;
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<DateScreenDefinition>[]; variants?: ScreenVariantDefinition<DateScreenDefinition>[];
@ -197,19 +193,15 @@ export interface FormFieldDefinition {
id: string; id: string;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
type?: "text" | "email" | "tel" | "url"; type?: "text" | "email" | "tel";
required?: boolean; required?: boolean;
maxLength?: number;
validation?: { validation?: {
pattern?: string;
message?: string; message?: string;
}; };
} }
export interface FormValidationMessages { export interface FormValidationMessages {
required?: string; // "${field} is required" required?: string; // "${field} is required"
maxLength?: string; // "Maximum ${maxLength} characters allowed"
invalidFormat?: string; // "Invalid format"
} }
export interface FormScreenDefinition { export interface FormScreenDefinition {

View File

@ -165,7 +165,6 @@ const FunnelMetaSchema = new Schema({
const DefaultTextsSchema = new Schema({ const DefaultTextsSchema = new Schema({
nextButton: { type: String, default: 'Next' }, nextButton: { type: String, default: 'Next' },
continueButton: { type: String, default: 'Continue' },
privacyBanner: { type: String }, privacyBanner: { type: String },
}, { _id: false }); }, { _id: false });