From 810bb78847bae353c2e1f73fc7989a5ca5842c65 Mon Sep 17 00:00:00 2001 From: pennyteenycat Date: Fri, 26 Sep 2025 02:44:41 +0200 Subject: [PATCH 1/7] docs: describe funnel templates and builder --- docs/templates-and-builder.md | 232 ++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 docs/templates-and-builder.md diff --git a/docs/templates-and-builder.md b/docs/templates-and-builder.md new file mode 100644 index 0000000..3e1130b --- /dev/null +++ b/docs/templates-and-builder.md @@ -0,0 +1,232 @@ +# Шаблоны экранов и конструктор воронки + +Этот документ описывает, из каких частей состоит JSON-конфигурация воронки, какие шаблоны экранов доступны в рантайме и как с ними работает конструктор (builder). Используйте его как справочник при ручном редактировании JSON или при настройке воронки через интерфейс администратора. + +## Архитектура воронки + +Воронка описывается объектом `FunnelDefinition` и состоит из двух частей: метаданных и списка экранов. Навигация осуществляется по идентификаторам экранов, а состояние (выборы пользователя) хранится отдельно в рантайме. + +```ts +interface FunnelDefinition { + meta: { + id: string; + version?: string; + title?: string; + description?: string; + firstScreenId?: string; // стартовый экран, по умолчанию первый в списке + }; + defaultTexts?: { + nextButton?: string; + continueButton?: string; + }; + screens: ScreenDefinition[]; // набор экранов разных шаблонов +} +``` + +Каждый экран обязан иметь уникальный `id` и поле `template`, которое выбирает шаблон визуализации. Дополнительно поддерживаются: + +- `header` — управляет прогресс-баром, заголовком и кнопкой «Назад». По умолчанию шапка показывается, а прогресс вычисляется автоматически в рантайме. +- `bottomActionButton` — универсальное описание основной кнопки («Продолжить», «Далее» и т. п.). Шаблон может переопределить или скрыть её. +- `navigation` — правила переходов между экранами. + +### Навигация + +Навигация описывается объектом `NavigationDefinition`: + +```ts +interface NavigationDefinition { + defaultNextScreenId?: string; // переход по умолчанию + rules?: Array<{ + nextScreenId: string; // куда перейти, если условие выполнено + conditions: Array<{ + screenId: string; // экран, чьи ответы проверяем + operator?: "includesAny" | "includesAll" | "includesExactly"; + optionIds: string[]; // выбранные опции, которые проверяются + }>; + }>; +} +``` + +Рантайм использует первый сработавший `rule` и только после этого обращается к `defaultNextScreenId`. Для списков с одиночным выбором и скрытой кнопкой переход совершается автоматически при изменении ответа. Для всех прочих шаблонов пользователь должен нажать действие, сконфигурированное для текущего экрана. + +## Шаблоны экранов + +Ниже приведено краткое описание каждого шаблона и JSON-поле, которое его конфигурирует. + +### Информационный экран (`template: "info"`) + +Используется для показа статических сообщений, промо-блоков или инструкций. Обязательные поля — `id`, `template`, `title`. Дополнительно поддерживаются: + +- `description` — расширенный текст под заголовком. +- `icon` — эмодзи или картинка. `type` принимает значения `emoji` или `image`, `value` — символ или URL, `size` — `sm | md | lg | xl`. +- `bottomActionButton` — описание кнопки внизу, если нужно отличное от дефолтного текста. + +```json +{ + "id": "welcome", + "template": "info", + "title": { "text": "Добро пожаловать" }, + "description": { "text": "Заполните короткую анкету, чтобы получить персональное предложение." }, + "icon": { "type": "emoji", "value": "👋", "size": "lg" }, + "navigation": { "defaultNextScreenId": "question-1" } +} +``` + +Рантайм выводит заголовок по центру, кнопку «Next» (или `defaultTexts.nextButton`) и позволяет вернуться назад, если это разрешено в `header`. Логика описана в `InfoTemplate` и `buildLayoutQuestionProps` — дополнительные параметры (`font`, `color`, `align`) влияют на типографику.【F:src/components/funnel/templates/InfoTemplate.tsx†L1-L99】【F:src/lib/funnel/types.ts†L74-L131】 + +### Экран с вопросом и вариантами (`template: "list"`) + +Базовый интерактивный экран. Поле `list` описывает варианты ответов: + +```json +{ + "id": "question-1", + "template": "list", + "title": { "text": "Какой формат подходит?" }, + "subtitle": { "text": "Можно выбрать несколько", "color": "muted" }, + "list": { + "selectionType": "multi", // или "single" + "options": [ + { "id": "opt-online", "label": "Онлайн" }, + { "id": "opt-offline", "label": "Офлайн", "description": "в вашем городе" } + ], + "bottomActionButton": { "text": "Сохранить выбор" } + }, + "bottomActionButton": { "show": false }, + "navigation": { + "defaultNextScreenId": "calendar", + "rules": [ + { + "nextScreenId": "coupon", + "conditions": [{ + "screenId": "question-1", + "operator": "includesAll", + "optionIds": ["opt-online", "opt-offline"] + }] + } + ] + } +} +``` + +Особенности: + +- `selectionType` определяет поведение: `single` строит радиокнопки, `multi` — чекбоксы. Компоненты `RadioAnswersList` и `SelectAnswersList` получают подготовленные данные из `mapListOptionsToButtons`. +- Кнопка действия может описываться либо на уровне `list.bottomActionButton`, либо через общий `bottomActionButton`. В рантайме она скрывается, если `show: false`. Для списков с одиночным выбором и скрытой кнопкой включается автопереход на следующий экран при изменении ответа.【F:src/components/funnel/templates/ListTemplate.tsx†L1-L109】【F:src/components/funnel/FunnelRuntime.tsx†L73-L199】 +- Ответы сохраняются в массиве строк (идентификаторы опций) и используются навигацией и аналитикой. + +### Экран выбора даты (`template: "date"`) + +Предлагает три выпадающих списка (месяц, день, год) и опциональный блок с отформатированной датой. + +```json +{ + "id": "calendar", + "template": "date", + "title": { "text": "Когда планируете начать?" }, + "subtitle": { "text": "Выберите ориентировочную дату", "color": "muted" }, + "dateInput": { + "monthLabel": "Месяц", + "dayLabel": "День", + "yearLabel": "Год", + "showSelectedDate": true, + "selectedDateLabel": "Вы выбрали" + }, + "infoMessage": { "text": "Мы не будем делиться датой с третьими лицами." }, + "navigation": { "defaultNextScreenId": "contact" } +} +``` + +Особенности: + +- Значение сохраняется как массив `[month, day, year]` внутри `answers` рантайма. +- Кнопка «Next» активируется только после заполнения всех полей. Настройка текстов и подсказок — через объект `dateInput` (placeholder, label, формат для превью). +- При `showSelectedDate: true` под кнопкой появляется подтверждающий блок с читабельной датой.【F:src/components/funnel/templates/DateTemplate.tsx†L1-L209】【F:src/lib/funnel/types.ts†L133-L189】 + +### Экран формы (`template: "form"`) + +Подходит для сбора контактных данных. Поле `fields` содержит список текстовых инпутов со своими правилами. + +```json +{ + "id": "contact", + "template": "form", + "title": { "text": "Оставьте контакты" }, + "fields": [ + { "id": "name", "label": "Имя", "required": true, "maxLength": 60 }, + { + "id": "email", + "label": "E-mail", + "type": "email", + "validation": { + "pattern": "^\\S+@\\S+\\.\\S+$", + "message": "Введите корректный e-mail" + } + } + ], + "validationMessages": { + "required": "Поле ${field} обязательно", + "invalidFormat": "Неверный формат" + }, + "navigation": { "defaultNextScreenId": "coupon" } +} +``` + +Особенности рантайма: + +- Локальное состояние синхронизируется с глобальным через `onFormDataChange` — данные сериализуются в JSON-строку и хранятся в массиве ответов (первый элемент).【F:src/components/funnel/FunnelRuntime.tsx†L46-L118】 +- Кнопка продолжения (`defaultTexts.continueButton` или «Continue») активна, если все обязательные поля заполнены. Валидаторы проверяют `required`, `maxLength` и регулярное выражение из `validation.pattern` с кастомными сообщениями.【F:src/components/funnel/templates/FormTemplate.tsx†L1-L119】【F:src/lib/funnel/types.ts†L191-L238】 + +### Экран промокода (`template: "coupon"`) + +Отображает купон с акцией и позволяет скопировать промокод. + +```json +{ + "id": "coupon", + "template": "coupon", + "title": { "text": "Поздравляем!" }, + "subtitle": { "text": "Получите скидку" }, + "coupon": { + "title": { "text": "Скидка 20%" }, + "offer": { + "title": { "text": "-20% на первый заказ" }, + "description": { "text": "Действует до конца месяца" } + }, + "promoCode": { "text": "START20" }, + "footer": { "text": "Скопируйте код и введите при оформлении" } + }, + "copiedMessage": "Код {code} скопирован!", + "navigation": { "defaultNextScreenId": "final-info" } +} +``` + +`CouponTemplate` копирует код в буфер обмена и показывает уведомление `copiedMessage` (строка с подстановкой `{code}`). Кнопка продолжения использует `defaultTexts.continueButton` или значение «Continue».【F:src/components/funnel/templates/CouponTemplate.tsx†L1-L111】【F:src/lib/funnel/types.ts†L191-L230】 + +## Конструктор (Builder) + +Конструктор помогает собирать JSON-конфигурацию и состоит из трёх основных областей: + +1. **Верхняя панель** (`BuilderTopBar`). Позволяет создать пустой проект, загрузить готовый JSON и экспортировать текущую конфигурацию. Импорт использует `deserializeFunnelDefinition`, добавляющий служебные координаты для канваса. Экспорт сериализует состояние обратно в формат `FunnelDefinition` (`serializeBuilderState`).【F:src/components/admin/builder/BuilderTopBar.tsx†L1-L79】【F:src/lib/admin/builder/utils.ts†L1-L58】 +2. **Канвас** (`BuilderCanvas`). Отображает экраны цепочкой, даёт возможность добавлять новые (`add-screen`), менять порядок drag-and-drop (`reorder-screens`) и выбирать экран для редактирования. Каждый экран показывает тип шаблона, количество опций и ссылку на следующий экран по умолчанию.【F:src/components/admin/builder/BuilderCanvas.tsx†L1-L132】 +3. **Боковая панель** (`BuilderSidebar`). Содержит две вкладки состояния: + - Когда экран не выбран, показываются настройки воронки (ID, заголовок, описание, стартовый экран) и сводка валидации (`validateBuilderState`).【F:src/components/admin/builder/BuilderSidebar.tsx†L1-L188】【F:src/lib/admin/builder/validation.ts†L1-L168】 + - Для выбранного экрана доступны поля заголовков, параметры списка (тип выбора, опции), правила навигации, кастомизация кнопок и инструмент удаления. Все изменения отправляются через `update-screen`, `update-navigation` и вспомогательные обработчики, формируя корректный JSON. + +### Предпросмотр + +Компонент `BuilderPreview` визуализирует выбранный экран, используя те же шаблоны, что и боевой рантайм (`ListTemplate`, `InfoTemplate` и др.). Для симуляции действий используются заглушки — выбор опций, заполнение формы и навигация обновляют локальное состояние предпросмотра, но не меняют структуру воронки. При переключении экрана состояние сбрасывается, что позволяет увидеть дефолтное поведение каждого шаблона.【F:src/components/admin/builder/BuilderPreview.tsx†L1-L123】 + +### Валидация и сериализация + +`validateBuilderState` проверяет уникальность идентификаторов экранов и опций, корректность ссылок в навигации и наличие переходов. Ошибки и предупреждения отображаются в боковой панели. При экспорте координаты канваса удаляются, чтобы JSON соответствовал ожиданиям рантайма. Ответы пользователей рантайм хранит в структуре `Record`, где ключ — `id` экрана, а значение — массив выбранных значений (опций, компонентов даты или сериализованные данные формы).【F:src/lib/admin/builder/validation.ts†L1-L168】【F:src/lib/admin/builder/utils.ts†L1-L86】【F:src/components/funnel/FunnelRuntime.tsx†L1-L215】 + +## Рабочий процесс + +1. Создайте экраны через верхнюю панель или кнопку на канвасе. Каждый новый экран получает уникальный ID (`screen-{n}`). +2. Настройте порядок переходов drag-and-drop и установите `firstScreenId`, если стартовать нужно не с первого элемента. +3. Заполните контент для каждого шаблона, настройте условия в `navigation.rules` и убедитесь, что `defaultNextScreenId` указан для веток без правил. +4. Проверьте сводку валидации — при ошибках экспорт JSON будет возможен, но рантайм может не смочь построить маршрут. +5. Экспортируйте JSON и передайте его рантайму (``). + +Такой подход гарантирует, что конструктор и рантайм используют одну и ту же схему данных, а визуальные шаблоны ведут себя предсказуемо при изменении конфигурации. From 084de6c05b62d5739c049c569028284cbbb24ef5 Mon Sep 17 00:00:00 2001 From: pennyteenycat Date: Fri, 26 Sep 2025 02:46:48 +0200 Subject: [PATCH 2/7] Pre-render baked funnel routes --- package.json | 2 + scripts/bake-funnels.mjs | 79 +++ src/app/[funnelId]/[screenId]/page.tsx | 20 +- src/app/[funnelId]/page.tsx | 15 +- src/lib/funnel/bakedFunnels.ts | 799 +++++++++++++++++++++++++ src/lib/funnel/loadFunnelDefinition.ts | 56 +- 6 files changed, 953 insertions(+), 18 deletions(-) create mode 100644 scripts/bake-funnels.mjs create mode 100644 src/lib/funnel/bakedFunnels.ts diff --git a/package.json b/package.json index d0bfb5c..fef34d5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build --turbopack", "start": "next start", "lint": "eslint", + "bake:funnels": "node scripts/bake-funnels.mjs", + "prebuild": "npm run bake:funnels", "storybook": "storybook dev -p 6006 --ci", "build-storybook": "storybook build" }, diff --git a/scripts/bake-funnels.mjs b/scripts/bake-funnels.mjs new file mode 100644 index 0000000..0cdd50d --- /dev/null +++ b/scripts/bake-funnels.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import { promises as fs } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, ".."); +const funnelsDir = path.join(projectRoot, "public", "funnels"); +const outputFile = path.join(projectRoot, "src", "lib", "funnel", "bakedFunnels.ts"); + +function formatFunnelRecord(funnels) { + const entries = Object.entries(funnels) + .map(([funnelId, definition]) => { + const serialized = JSON.stringify(definition, null, 2); + const indented = serialized + .split("\n") + .map((line, index) => (index === 0 ? line : ` ${line}`)) + .join("\n"); + return ` "${funnelId}": ${indented}`; + }) + .join(",\n\n"); + + return `{ +${entries}\n}`; +} + +async function bakeFunnels() { + const dirExists = await fs + .access(funnelsDir) + .then(() => true) + .catch(() => false); + + if (!dirExists) { + throw new Error(`Funnels directory not found: ${funnelsDir}`); + } + + const files = (await fs.readdir(funnelsDir)).sort((a, b) => a.localeCompare(b)); + const funnels = {}; + + for (const file of files) { + if (!file.endsWith(".json")) continue; + + const filePath = path.join(funnelsDir, file); + const raw = await fs.readFile(filePath, "utf8"); + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Failed to parse ${file}: ${error.message}`); + } + + const funnelId = parsed?.meta?.id ?? parsed?.id ?? file.replace(/\.json$/, ""); + + if (!funnelId || typeof funnelId !== "string") { + throw new Error( + `Unable to determine funnel id for '${file}'. Ensure the file contains an 'id' or 'meta.id' field.` + ); + } + + funnels[funnelId] = parsed; + } + + const headerComment = `/**\n * This file is auto-generated by scripts/bake-funnels.mjs.\n * Do not edit this file manually; update the source JSON files instead.\n */`; + + const recordLiteral = formatFunnelRecord(funnels); + const contents = `${headerComment}\n\nimport type { FunnelDefinition } from "./types";\n\nexport const BAKED_FUNNELS: Record = ${recordLiteral};\n`; + + await fs.mkdir(path.dirname(outputFile), { recursive: true }); + await fs.writeFile(outputFile, contents, "utf8"); + + console.log(`Baked ${Object.keys(funnels).length} funnel(s) into ${path.relative(projectRoot, outputFile)}`); +} + +bakeFunnels().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/app/[funnelId]/[screenId]/page.tsx b/src/app/[funnelId]/[screenId]/page.tsx index 8bf55b4..aaf7995 100644 --- a/src/app/[funnelId]/[screenId]/page.tsx +++ b/src/app/[funnelId]/[screenId]/page.tsx @@ -1,7 +1,11 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition"; +import { + listBakedFunnelScreenParams, + peekBakedFunnelDefinition, + loadFunnelDefinition, +} from "@/lib/funnel/loadFunnelDefinition"; import { FunnelRuntime } from "@/components/funnel/FunnelRuntime"; interface FunnelScreenPageProps { @@ -11,11 +15,23 @@ interface FunnelScreenPageProps { }>; } +export const dynamic = "force-static"; + +export function generateStaticParams() { + return listBakedFunnelScreenParams(); +} + export async function generateMetadata({ params, }: FunnelScreenPageProps): Promise { const { funnelId } = await params; - const funnel = await loadFunnelDefinition(funnelId); + let funnel: ReturnType; + try { + funnel = peekBakedFunnelDefinition(funnelId); + } catch (error) { + console.error(`Failed to load funnel '${funnelId}' for metadata:`, error); + notFound(); + } return { title: funnel.meta.title ?? "Funnel", diff --git a/src/app/[funnelId]/page.tsx b/src/app/[funnelId]/page.tsx index f26c135..9a6611f 100644 --- a/src/app/[funnelId]/page.tsx +++ b/src/app/[funnelId]/page.tsx @@ -1,6 +1,15 @@ import { notFound, redirect } from "next/navigation"; -import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition"; +import { + listBakedFunnelIds, + peekBakedFunnelDefinition, +} from "@/lib/funnel/loadFunnelDefinition"; + +export const dynamic = "force-static"; + +export function generateStaticParams() { + return listBakedFunnelIds().map((funnelId) => ({ funnelId })); +} interface FunnelRootPageProps { params: Promise<{ @@ -11,9 +20,9 @@ interface FunnelRootPageProps { export default async function FunnelRootPage({ params }: FunnelRootPageProps) { const { funnelId } = await params; - let funnel; + let funnel: ReturnType; try { - funnel = await loadFunnelDefinition(funnelId); + funnel = peekBakedFunnelDefinition(funnelId); } catch (error) { console.error(`Failed to load funnel '${funnelId}':`, error); notFound(); diff --git a/src/lib/funnel/bakedFunnels.ts b/src/lib/funnel/bakedFunnels.ts new file mode 100644 index 0000000..e5e054b --- /dev/null +++ b/src/lib/funnel/bakedFunnels.ts @@ -0,0 +1,799 @@ +/** + * This file is auto-generated by scripts/bake-funnels.mjs. + * Do not edit this file manually; update the source JSON files instead. + */ + +import type { FunnelDefinition } from "./types"; + +export const BAKED_FUNNELS: Record = { + "funnel-test": { + "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" + }, + "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": { + "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": { + "text": "Continue", + "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" + } + } + ] + } +}; diff --git a/src/lib/funnel/loadFunnelDefinition.ts b/src/lib/funnel/loadFunnelDefinition.ts index 822220d..7abd893 100644 --- a/src/lib/funnel/loadFunnelDefinition.ts +++ b/src/lib/funnel/loadFunnelDefinition.ts @@ -1,20 +1,50 @@ -import { promises as fs } from "fs"; -import path from "path"; +import { BAKED_FUNNELS } from "./bakedFunnels"; +import type { FunnelDefinition } from "./types"; -import { FunnelDefinition } from "./types"; +function resolveBakedFunnel(funnelId: string): FunnelDefinition { + const funnel = BAKED_FUNNELS[funnelId]; + + if (!funnel) { + throw new Error(`Funnel '${funnelId}' is not baked.`); + } + + return funnel; +} + +function cloneFunnel(value: T): T { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + + return JSON.parse(JSON.stringify(value)); +} export async function loadFunnelDefinition( funnelId: string ): Promise { - const filePath = path.join( - process.cwd(), - "public", - "funnels", - `${funnelId}.json` - ); + const funnel = resolveBakedFunnel(funnelId); - const raw = await fs.readFile(filePath, "utf-8"); - const parsed = JSON.parse(raw) as FunnelDefinition; - - return parsed; + return cloneFunnel(funnel); +} + +export function peekBakedFunnelDefinition( + funnelId: string +): FunnelDefinition { + return resolveBakedFunnel(funnelId); +} + +export function listBakedFunnelIds(): string[] { + return Object.keys(BAKED_FUNNELS); +} + +export function listBakedFunnelScreenParams(): Array<{ + funnelId: string; + screenId: string; +}> { + return Object.entries(BAKED_FUNNELS).flatMap(([funnelId, funnel]) => + funnel.screens.map((screen) => ({ + funnelId, + screenId: screen.id, + })) + ); } From 1f1ac7df8531ef3b13a118268c8b52b3ef8f0a69 Mon Sep 17 00:00:00 2001 From: pennyteenycat Date: Fri, 26 Sep 2025 02:47:16 +0200 Subject: [PATCH 3/7] Add variant-enabled funnel example --- public/funnels/funnel-test-variants.json | 663 +++++++++++++++++++++++ src/components/funnel/FunnelRuntime.tsx | 20 +- src/lib/funnel/navigation.ts | 13 +- src/lib/funnel/types.ts | 12 + src/lib/funnel/variants.ts | 78 +++ 5 files changed, 778 insertions(+), 8 deletions(-) create mode 100644 public/funnels/funnel-test-variants.json create mode 100644 src/lib/funnel/variants.ts diff --git a/public/funnels/funnel-test-variants.json b/public/funnels/funnel-test-variants.json new file mode 100644 index 0000000..84595e8 --- /dev/null +++ b/public/funnels/funnel-test-variants.json @@ -0,0 +1,663 @@ +{ + "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" + }, + "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": { + "text": "Continue", + "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" + } + } + ] +} diff --git a/src/components/funnel/FunnelRuntime.tsx b/src/components/funnel/FunnelRuntime.tsx index 0a6752c..4c5c371 100644 --- a/src/components/funnel/FunnelRuntime.tsx +++ b/src/components/funnel/FunnelRuntime.tsx @@ -10,6 +10,7 @@ import { DateTemplate } from "@/components/funnel/templates/DateTemplate"; import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate"; import { FormTemplate } from "@/components/funnel/templates/FormTemplate"; import { resolveNextScreenId } from "@/lib/funnel/navigation"; +import { resolveScreenVariant } from "@/lib/funnel/variants"; import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider"; import type { FunnelDefinition, @@ -31,10 +32,11 @@ function estimatePathLength(funnel: FunnelDefinition, answers: FunnelAnswers): n while (currentScreenId && !visited.has(currentScreenId)) { visited.add(currentScreenId); - const currentScreen = funnel.screens.find(s => s.id === currentScreenId); + const currentScreen = funnel.screens.find((s) => s.id === currentScreenId); if (!currentScreen) break; - - const nextScreenId = resolveNextScreenId(currentScreen, answers, funnel.screens); + + const resolvedScreen = resolveScreenVariant(currentScreen, answers); + const nextScreenId = resolveNextScreenId(resolvedScreen, answers, funnel.screens); // Если достигли конца или зацикливание if (!nextScreenId || visited.has(nextScreenId)) { @@ -229,10 +231,18 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { funnel.meta.id ); - const currentScreen = useMemo(() => { - return getScreenById(funnel, initialScreenId) ?? funnel.screens[0]; + const baseScreen = useMemo(() => { + const screen = getScreenById(funnel, initialScreenId) ?? funnel.screens[0]; + if (!screen) { + throw new Error("Funnel definition does not contain any screens"); + } + return screen; }, [funnel, initialScreenId]); + const currentScreen = useMemo(() => { + return resolveScreenVariant(baseScreen, answers); + }, [baseScreen, answers]); + const selectedOptionIds = answers[currentScreen.id] ?? []; useEffect(() => { diff --git a/src/lib/funnel/navigation.ts b/src/lib/funnel/navigation.ts index a31dbb3..d49b39a 100644 --- a/src/lib/funnel/navigation.ts +++ b/src/lib/funnel/navigation.ts @@ -41,12 +41,19 @@ function satisfiesCondition( } } -function satisfiesRule(rule: NavigationRuleDefinition, answers: FunnelAnswers): boolean { - if (!rule.conditions || rule.conditions.length === 0) { +export function matchesNavigationConditions( + conditions: NavigationConditionDefinition[] | undefined, + answers: FunnelAnswers +): boolean { + if (!conditions || conditions.length === 0) { return false; } - return rule.conditions.every((condition) => satisfiesCondition(condition, answers)); + return conditions.every((condition) => satisfiesCondition(condition, answers)); +} + +function satisfiesRule(rule: NavigationRuleDefinition, answers: FunnelAnswers): boolean { + return matchesNavigationConditions(rule.conditions, answers); } export function resolveNextScreenId( diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index 36f9824..92df89f 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -85,6 +85,13 @@ export interface NavigationDefinition { defaultNextScreenId?: string; } +type ScreenVariantOverrides = Partial>; + +export interface ScreenVariantDefinition { + conditions: NavigationConditionDefinition[]; + overrides: ScreenVariantOverrides; +} + export interface InfoScreenDefinition { id: string; template: "info"; @@ -99,6 +106,7 @@ export interface InfoScreenDefinition { }; bottomActionButton?: BottomActionButtonDefinition; navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; } export interface DateInputDefinition { @@ -126,6 +134,7 @@ export interface DateScreenDefinition { }; bottomActionButton?: BottomActionButtonDefinition; navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; } export interface CouponDefinition { @@ -148,6 +157,7 @@ export interface CouponScreenDefinition { copiedMessage?: string; // "Промокод скопирован!" text bottomActionButton?: BottomActionButtonDefinition; navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; } export interface FormFieldDefinition { @@ -179,6 +189,7 @@ export interface FormScreenDefinition { validationMessages?: FormValidationMessages; bottomActionButton?: BottomActionButtonDefinition; navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; } @@ -196,6 +207,7 @@ export interface ListScreenDefinition { }; bottomActionButton?: BottomActionButtonDefinition; navigation?: NavigationDefinition; + variants?: ScreenVariantDefinition[]; } export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition; diff --git a/src/lib/funnel/variants.ts b/src/lib/funnel/variants.ts new file mode 100644 index 0000000..bc1f019 --- /dev/null +++ b/src/lib/funnel/variants.ts @@ -0,0 +1,78 @@ +import { matchesNavigationConditions } from "./navigation"; +import type { + FunnelAnswers, + ScreenDefinition, + ScreenVariantDefinition, +} from "./types"; + +function cloneScreen(screen: T): T { + if (typeof globalThis.structuredClone === "function") { + return globalThis.structuredClone(screen); + } + + return JSON.parse(JSON.stringify(screen)) as T; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function deepMerge(target: T, source: Partial | undefined): T { + if (!source) { + return target; + } + + const result: T = Array.isArray(target) + ? ([...target] as unknown as T) + : ({ ...(target as Record) } as T); + + for (const key of Object.keys(source) as (keyof T)[]) { + const sourceValue = source[key]; + + if (sourceValue === undefined) { + continue; + } + + const targetValue = (result as Record)[key as unknown as string]; + + if (isPlainObject(sourceValue)) { + const baseValue = isPlainObject(targetValue) ? targetValue : {}; + (result as Record)[key as unknown as string] = deepMerge( + baseValue, + sourceValue as Record + ) as unknown as T[keyof T]; + continue; + } + + (result as Record)[key as unknown as string] = sourceValue as unknown as T[keyof T]; + } + + return result; +} + +function applyScreenOverrides( + screen: T, + overrides: ScreenVariantDefinition["overrides"] +): T { + const cloned = cloneScreen(screen); + return deepMerge(cloned, overrides); +} + +export function resolveScreenVariant( + screen: T, + answers: FunnelAnswers +): T { + const variants = (screen as T & { variants?: ScreenVariantDefinition[] }).variants; + + if (!variants || variants.length === 0) { + return screen; + } + + for (const variant of variants) { + if (matchesNavigationConditions(variant.conditions, answers)) { + return applyScreenOverrides(screen, variant.overrides); + } + } + + return screen; +} From 6606e64987fc63c3affff94b2feb650275f4a207 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Fri, 26 Sep 2025 12:25:56 +0200 Subject: [PATCH 4/7] fix --- src/lib/funnel/types.ts | 2 +- src/lib/funnel/variants.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index 92df89f..6515cda 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -47,7 +47,7 @@ export interface ListOptionDefinition { } export interface BottomActionButtonDefinition { - text: string; + text?: string; cornerRadius?: "3xl" | "full"; /** Controls whether button should be displayed. Defaults to true. */ show?: boolean; diff --git a/src/lib/funnel/variants.ts b/src/lib/funnel/variants.ts index bc1f019..9df515c 100644 --- a/src/lib/funnel/variants.ts +++ b/src/lib/funnel/variants.ts @@ -55,7 +55,7 @@ function applyScreenOverrides( overrides: ScreenVariantDefinition["overrides"] ): T { const cloned = cloneScreen(screen); - return deepMerge(cloned, overrides); + return deepMerge(cloned, overrides as Partial); } export function resolveScreenVariant( From fcfa97c8f7ced857bbdc4f3c7bad0b7c00e74172 Mon Sep 17 00:00:00 2001 From: pennyteenycat Date: Fri, 26 Sep 2025 12:46:40 +0200 Subject: [PATCH 5/7] Enhance funnel builder configuration UX --- .../admin/builder/BuilderCanvas.tsx | 466 +++++++---- .../admin/builder/BuilderSidebar.tsx | 722 ++++++++---------- .../builder/templates/CouponScreenConfig.tsx | 247 ++---- .../builder/templates/DateScreenConfig.tsx | 260 +++---- .../builder/templates/FormScreenConfig.tsx | 272 ++++--- .../builder/templates/InfoScreenConfig.tsx | 220 ++---- .../builder/templates/ListScreenConfig.tsx | 414 ++++++---- .../builder/templates/TemplateConfig.tsx | 464 +++++++++-- src/lib/admin/builder/utils.ts | 2 + 9 files changed, 1743 insertions(+), 1324 deletions(-) diff --git a/src/components/admin/builder/BuilderCanvas.tsx b/src/components/admin/builder/BuilderCanvas.tsx index ef53d43..7ae919d 100644 --- a/src/components/admin/builder/BuilderCanvas.tsx +++ b/src/components/admin/builder/BuilderCanvas.tsx @@ -1,56 +1,212 @@ "use client"; -import React, { useCallback, useRef } from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context"; -import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/types"; +import type { ListOptionDefinition, ScreenDefinition } from "@/lib/funnel/types"; import { cn } from "@/lib/utils"; -const CARD_WIDTH = 280; -const CARD_HEIGHT = 200; -const CARD_GAP = 24; +function DropIndicator({ isActive }: { isActive: boolean }) { + return ( +
+ ); +} + +function TemplateSummary({ screen }: { screen: ScreenDefinition }) { + switch (screen.template) { + case "list": { + return ( +
+
+ + Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"} + + {screen.list.autoAdvance && ( + + авто переход + + )} + {screen.list.bottomActionButton?.text && ( + + {screen.list.bottomActionButton.text} + + )} +
+
+

Варианты ({screen.list.options.length})

+
+ {screen.list.options.map((option) => ( + + {option.emoji && {option.emoji}} + {option.label} + + ))} +
+
+
+ ); + } + case "form": { + return ( +
+
+ + Полей: {screen.fields.length} + + {screen.bottomActionButton?.text && ( + + {screen.bottomActionButton.text} + + )} +
+ {screen.validationMessages && ( +
+

+ Настроены пользовательские сообщения валидации +

+
+ )} +
+ ); + } + case "coupon": { + return ( +
+

+ Промо: {screen.coupon.promoCode.text} +

+

{screen.coupon.offer.title.text}

+
+ ); + } + case "date": { + return ( +
+

Формат даты:

+
+ {screen.dateInput.monthLabel && {screen.dateInput.monthLabel}} + {screen.dateInput.dayLabel && {screen.dateInput.dayLabel}} + {screen.dateInput.yearLabel && {screen.dateInput.yearLabel}} +
+ {screen.dateInput.validationMessage && ( +

{screen.dateInput.validationMessage}

+ )} +
+ ); + } + case "info": { + return ( +
+ {screen.description?.text &&

{screen.description.text}

} + {screen.icon?.value && ( +
+ {screen.icon.value} + Иконка +
+ )} +
+ ); + } + default: + return null; + } +} + +function getOptionLabel(options: ListOptionDefinition[], optionId: string): string { + const option = options.find((item) => item.id === optionId); + return option ? option.label : optionId; +} export function BuilderCanvas() { const { screens, selectedScreenId } = useBuilderState(); const dispatch = useBuilderDispatch(); - const containerRef = useRef(null); - const dragStateRef = useRef<{ screenId: string; dragStartIndex: number; currentIndex: number } | null>(null); + const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null); + const [dropIndex, setDropIndex] = useState(null); - const handleDragStart = useCallback((screenId: string, index: number) => { - dragStateRef.current = { - screenId, - dragStartIndex: index, - currentIndex: index, - }; + const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", screenId); + dragStateRef.current = { screenId, dragStartIndex: index }; + setDropIndex(index); }, []); - const handleDragOver = useCallback((e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - if (!dragStateRef.current) return; - - dragStateRef.current.currentIndex = targetIndex; - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - if (!dragStateRef.current) return; - - const { dragStartIndex, currentIndex } = dragStateRef.current; - - if (dragStartIndex !== currentIndex) { - dispatch({ - type: "reorder-screens", - payload: { - fromIndex: dragStartIndex, - toIndex: currentIndex, - }, - }); + const handleDragOverCard = useCallback((event: React.DragEvent, index: number) => { + event.preventDefault(); + if (!dragStateRef.current) { + return; } + const rect = event.currentTarget.getBoundingClientRect(); + const offsetY = event.clientY - rect.top; + const nextIndex = offsetY > rect.height / 2 ? index + 1 : index; + setDropIndex(nextIndex); + }, []); + + const handleDragOverList = useCallback( + (event: React.DragEvent) => { + if (!dragStateRef.current) { + return; + } + event.preventDefault(); + if (event.target === event.currentTarget) { + setDropIndex(screens.length); + } + }, + [screens.length] + ); + + const finalizeDrop = useCallback( + (insertionIndex: number | null) => { + if (!dragStateRef.current) { + return; + } + + const { dragStartIndex } = dragStateRef.current; + const boundedIndex = Math.max(0, Math.min(insertionIndex ?? dragStartIndex, screens.length)); + let targetIndex = boundedIndex; + + if (targetIndex > dragStartIndex) { + targetIndex -= 1; + } + + if (dragStartIndex !== targetIndex) { + dispatch({ + type: "reorder-screens", + payload: { + fromIndex: dragStartIndex, + toIndex: targetIndex, + }, + }); + } + + dragStateRef.current = null; + setDropIndex(null); + }, + [dispatch, screens.length] + ); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + finalizeDrop(dropIndex); + }, + [dropIndex, finalizeDrop] + ); + + const handleDragEnd = useCallback(() => { dragStateRef.current = null; - }, [dispatch]); + setDropIndex(null); + }, []); const handleSelectScreen = useCallback( (screenId: string) => { @@ -63,128 +219,164 @@ export function BuilderCanvas() { dispatch({ type: "add-screen" }); }, [dispatch]); - // Helper functions for type checking - const hasSubtitle = (screen: ScreenDefinition): screen is ScreenDefinition & { subtitle: { text: string } } => { - return 'subtitle' in screen && screen.subtitle !== undefined; - }; - - const isListScreen = (screen: ScreenDefinition): screen is ListScreenDefinition => { - return screen.template === 'list'; - }; - + const screenTitleMap = useMemo(() => { + return screens.reduce>((accumulator, screen) => { + accumulator[screen.id] = screen.title.text || screen.id; + return accumulator; + }, {}); + }, [screens]); return ( -
- {/* Header with Add Button */} +
-

Экраны воронки

+
+

Экраны воронки

+

Перетаскивайте, чтобы поменять порядок и связь экранов.

+
- {/* Linear Screen Layout */} -
-
- {screens.map((screen, index) => { - const isSelected = screen.id === selectedScreenId; - return ( -
handleDragStart(screen.id, index)} - onDragOver={(e) => handleDragOver(e, index)} - onDrop={handleDrop} - > -
handleSelectScreen(screen.id)} - > - {/* Screen Header */} -
-
-
- {index + 1} -
- #{screen.id} -
-
- {screen.template} -
-
+
+
+
+
+ {screens.map((screen, index) => { + const isSelected = screen.id === selectedScreenId; + const isDropBefore = dropIndex === index; + const isDropAfter = dropIndex === screens.length && index === screens.length - 1; + const rules = screen.navigation?.rules ?? []; + const defaultNext = screen.navigation?.defaultNextScreenId; - {/* Screen Content */} -
-

- {screen.title.text || "Без названия"} -

- {hasSubtitle(screen) && ( -

{screen.subtitle.text}

- )} - - {/* List Screen Details */} - {isListScreen(screen) && ( -
-
- Тип выбора: - - {screen.list.selectionType === "single" ? "Single" : "Multi"} - + return ( +
+
+ {isDropBefore && } +
+
+
handleDragStart(event, screen.id, index)} + onDragOver={(event) => handleDragOverCard(event, index)} + onDragEnd={handleDragEnd} + onClick={() => handleSelectScreen(screen.id)} + > +
+
+
+ {index + 1} +
+
+ #{screen.id} + + {screen.title.text || "Без названия"} + +
-
- Опции: {screen.list.options.length} -
- {screen.list.options.slice(0, 2).map((option) => ( - - {option.label} + + {screen.template} + +
+ + {screen.subtitle?.text && ( +

{screen.subtitle.text}

+ )} + +
+ + +
+
+ Переходы +
+
+ +
+
+ + По умолчанию - ))} - {screen.list.options.length > 2 && ( - - +{screen.list.options.length - 2} ещё + + {defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : "Воронка завершится"} - )} +
+ + {rules.map((rule, ruleIndex) => { + const condition = rule.conditions[0]; + const optionSummaries = + screen.template === "list" && condition?.optionIds + ? condition.optionIds.map((optionId) => ({ + id: optionId, + label: getOptionLabel(screen.list.options, optionId), + })) + : []; + + return ( +
+
+ + Вариативность + + {condition?.operator && ( + + {condition.operator} + + )} +
+ {optionSummaries.length > 0 && ( +
+ {optionSummaries.map((option) => ( + + {option.label} + + ))} +
+ )} +
+ → {screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId} +
+
+ ); + })}
- )} -
- - {/* Navigation Info */} -
-
- Следующий: - - {screen.navigation?.defaultNextScreenId ?? "—"} -
+ {isDropAfter && }
+ ); + })} - {/* Arrow to next screen */} - {index < screens.length - 1 && ( -
-
-
-
-
-
- )} + {screens.length === 0 && ( +
+ Добавьте первый экран, чтобы начать строить воронку.
- ); - })} + )} + +
+ +
+
diff --git a/src/components/admin/builder/BuilderSidebar.tsx b/src/components/admin/builder/BuilderSidebar.tsx index bc2145a..342138d 100644 --- a/src/components/admin/builder/BuilderSidebar.tsx +++ b/src/components/admin/builder/BuilderSidebar.tsx @@ -1,22 +1,27 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { TextInput } from "@/components/ui/TextInput/TextInput"; import { Button } from "@/components/ui/button"; +import { TemplateConfig } from "@/components/admin/builder/templates"; import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context"; import type { BuilderScreen } from "@/lib/admin/builder/types"; -import type { NavigationRuleDefinition } from "@/lib/funnel/types"; +import type { NavigationRuleDefinition, ScreenDefinition } from "@/lib/funnel/types"; import { cn } from "@/lib/utils"; import { validateBuilderState } from "@/lib/admin/builder/validation"; -// Type guards для безопасной работы с разными типами экранов -function isListScreen(screen: BuilderScreen): screen is BuilderScreen & { list: { selectionType: "single" | "multi"; options: Array<{ id: string; label: string; description?: string; emoji?: string; }> } } { - return screen.template === "list" && "list" in screen; -} +type ValidationIssues = ReturnType["issues"]; -function hasSubtitle(screen: BuilderScreen): screen is BuilderScreen & { subtitle?: { text: string; color?: string; font?: string; } } { - return "subtitle" in screen; +function isListScreen( + screen: BuilderScreen +): screen is BuilderScreen & { + list: { + selectionType: "single" | "multi"; + options: Array<{ id: string; label: string; description?: string; emoji?: string }>; + }; +} { + return screen.template === "list" && "list" in screen; } function Section({ @@ -26,7 +31,7 @@ function Section({ }: { title: string; description?: string; - children: React.ReactNode; + children: ReactNode; }) { return (
@@ -39,12 +44,8 @@ function Section({ ); } - -function ValidationSummary() { - const state = useBuilderState(); - const validation = useMemo(() => validateBuilderState(state), [state]); - - if (validation.issues.length === 0) { +function ValidationSummary({ issues }: { issues: ValidationIssues }) { + if (issues.length === 0) { return (
Всё хорошо — воронка валидна. @@ -54,7 +55,7 @@ function ValidationSummary() { return (
- {validation.issues.map((issue, index) => ( + {issues.map((issue, index) => (
state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), [ - state.screens, - ]); + const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel"); + const selectedScreenId = selectedScreen?.id ?? null; + + useEffect(() => { + setActiveTab((previous) => { + if (selectedScreenId) { + return "screen"; + } + return previous === "screen" ? "funnel" : previous; + }); + }, [selectedScreenId]); + + const validation = useMemo(() => validateBuilderState(state), [state]); + const screenValidationIssues = useMemo(() => { + if (!selectedScreenId) { + return [] as ValidationIssues; + } + + return validation.issues.filter((issue) => issue.screenId === selectedScreenId); + }, [selectedScreenId, validation]); + + const screenOptions = useMemo( + () => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), + [state.screens] + ); const handleMetaChange = (field: keyof typeof state.meta, value: string) => { dispatch({ type: "set-meta", payload: { [field]: value } }); @@ -96,32 +119,6 @@ export function BuilderSidebar() { const getScreenById = (screenId: string): BuilderScreen | undefined => state.screens.find((item) => item.id === screenId); - const updateList = ( - screen: BuilderScreen, - listUpdates: Partial<{ selectionType: "single" | "multi"; options: Array<{ id: string; label: string; description?: string; emoji?: string; }> }> - ) => { - if (!isListScreen(screen)) { - return; - } - - const nextList = { - ...screen.list, - ...listUpdates, - selectionType: listUpdates.selectionType ?? screen.list.selectionType, - options: listUpdates.options ?? screen.list.options, - }; - - dispatch({ - type: "update-screen", - payload: { - screenId: screen.id, - screen: { - list: nextList, - }, - }, - }); - }; - const updateNavigation = ( screen: BuilderScreen, navigationUpdates: Partial = {} @@ -139,90 +136,6 @@ export function BuilderSidebar() { }); }; - const handleSelectionTypeChange = ( - screenId: string, - selectionType: "single" | "multi" - ) => { - const screen = getScreenById(screenId); - if (!screen || !isListScreen(screen)) { - return; - } - - updateList(screen, { selectionType }); - }; - - const handleTitleChange = (screenId: string, value: string) => { - dispatch({ - type: "update-screen", - payload: { - screenId, - screen: { - title: { - text: value, - }, - }, - }, - }); - }; - - const handleSubtitleChange = (screenId: string, value: string) => { - dispatch({ - type: "update-screen", - payload: { - screenId, - screen: { - subtitle: value - ? { text: value, color: "muted", font: "inter" } - : undefined, - }, - }, - }); - }; - - const handleOptionChange = ( - screenId: string, - index: number, - field: "label" | "id" | "emoji" | "description", - value: string - ) => { - const screen = getScreenById(screenId); - if (!screen || !isListScreen(screen)) { - return; - } - - const options = screen.list.options.map((option, optionIndex) => - optionIndex === index ? { ...option, [field]: value } : option - ); - - updateList(screen, { options }); - }; - - const handleAddOption = (screen: BuilderScreen) => { - if (!isListScreen(screen)) { - return; - } - - const nextIndex = screen.list.options.length + 1; - const options = [ - ...screen.list.options, - { - id: `option-${nextIndex}`, - label: `Вариант ${nextIndex}`, - }, - ]; - - updateList(screen, { options }); - }; - - const handleRemoveOption = (screen: BuilderScreen, index: number) => { - if (!isListScreen(screen)) { - return; - } - - const options = screen.list.options.filter((_, optionIndex) => optionIndex !== index); - updateList(screen, { options }); - }; - const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => { const screen = getScreenById(screenId); if (!screen) { @@ -332,7 +245,10 @@ export function BuilderSidebar() { optionIds: screen.list.options.slice(0, 1).map((option) => option.id), }; - const nextRules = [...(screen.navigation?.rules ?? []), { nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] }]; + const nextRules = [ + ...(screen.navigation?.rules ?? []), + { nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] }, + ]; updateNavigation(screen, { rules: nextRules }); }; @@ -354,326 +270,284 @@ export function BuilderSidebar() { dispatch({ type: "remove-screen", payload: { screenId } }); }; - // Показываем настройки воронки, если экран не выбран - if (!selectedScreen) { - return ( -
-
-
- -
+ const handleTemplateUpdate = (screenId: string, updates: Partial) => { + dispatch({ + type: "update-screen", + payload: { + screenId, + screen: updates as Partial, + }, + }); + }; -
- handleMetaChange("id", event.target.value)} - /> - handleMetaChange("title", event.target.value)} - /> - handleMetaChange("description", event.target.value)} - /> - -
- -
-
-

- Выберите экран на канвасе для редактирования его настроек. -

-
- Всего экранов: {state.screens.length} -
-
-
-
-
- ); - } - - // Показываем настройки выбранного экрана - const selectedScreenIsListType = isListScreen(selectedScreen); + const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false; return ( -
-
- {/* Информация о выбранном экране */} -
-
-
-
- Редактируем экран -
- -
-
- ID: {selectedScreen.id} • - Тип: {selectedScreen.template} -
+
+
+
+ + Режим редактирования + +

Настройки

+
+ + +
+
-
-
- - handleTitleChange(selectedScreen.id, event.target.value)} - /> - handleSubtitleChange(selectedScreen.id, event.target.value)} - /> -
-
- Тип экрана: {selectedScreen.template} -
- Позиция в воронке: экран {state.screens.findIndex(s => s.id === selectedScreen.id) + 1} из {state.screens.length} -
-
-
-
-
+
+ {activeTab === "funnel" ? ( +
+
+ +
- {selectedScreenIsListType && ( -
-
-
- -
-
+ + +
- {selectedScreenIsListType && ( -
-
-
-

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

- -
- - {(selectedScreen.navigation?.rules ?? []).length === 0 && ( -
- Правил пока нет +
+
+
+ Всего экранов + {state.screens.length}
- )} +
+ {state.screens.map((screen, index) => ( + + {index + 1}. {screen.title.text} + {screen.template} + + ))} +
+
+
+
+ ) : selectedScreen ? ( +
+
+
+ + ID: {selectedScreen.id} + + + Тип: {selectedScreen.template} + + + Позиция: экран {state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1} из {state.screens.length} + +
+
- {(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => ( -
+ +
+ Текущий шаблон: {selectedScreen.template} +
+
+ +
+ handleTemplateUpdate(selectedScreen.id, updates)} + /> +
+ +
+ +
+ + {selectedScreenIsListType && ( +
+
- Правило {ruleIndex + 1} -
- -
- Варианты ответа -
- {selectedScreenIsListType && selectedScreen.list.options.map((option) => { - const condition = rule.conditions[0]; - const isChecked = condition.optionIds?.includes(option.id) ?? false; - return ( - - ); - })} + {(selectedScreen.navigation?.rules ?? []).length === 0 && ( +
+ Правил пока нет
-
+ )} - +
+ Правило {ruleIndex + 1} + +
+ + +
+ Варианты ответа +
+ {selectedScreen.list.options.map((option) => { + const condition = rule.conditions[0]; + const isChecked = condition.optionIds?.includes(option.id) ?? false; + return ( + + ); + })} +
+
+ + +
+ ))}
- ))} -
- - )} + + )} -
- -
+
+ +
-
-
-

- Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны. -

- +
+
+

+ Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны. +

+ +
+
-
+ ) : ( +
+ Выберите экран в списке слева, чтобы настроить его параметры. +
+ )}
); diff --git a/src/components/admin/builder/templates/CouponScreenConfig.tsx b/src/components/admin/builder/templates/CouponScreenConfig.tsx index 73edbd9..773cbac 100644 --- a/src/components/admin/builder/templates/CouponScreenConfig.tsx +++ b/src/components/admin/builder/templates/CouponScreenConfig.tsx @@ -11,179 +11,96 @@ interface CouponScreenConfigProps { export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) { const couponScreen = screen as CouponScreenDefinition & { position: { x: number; y: number } }; - + + const handleCouponUpdate = ( + field: T, + value: CouponScreenDefinition["coupon"][T] + ) => { + onUpdate({ + coupon: { + ...couponScreen.coupon, + [field]: value, + }, + }); + }; + return ( -
- {/* Title Configuration */} -
- - onUpdate({ - title: { - ...couponScreen.title, - text: e.target.value, - font: couponScreen.title?.font || "manrope", - weight: couponScreen.title?.weight || "bold", - align: couponScreen.title?.align || "center", - } - })} - /> -
- - {/* Subtitle Configuration */} -
- - onUpdate({ - subtitle: { - ...couponScreen.subtitle, - text: e.target.value, - font: couponScreen.subtitle?.font || "inter", - weight: couponScreen.subtitle?.weight || "medium", - align: couponScreen.subtitle?.align || "center", - } - })} - /> -
- - {/* Coupon Configuration */} +
-

Coupon Details

- -
- +

+ Настройки оффера +

+
- -
- - onUpdate({ - coupon: { - ...couponScreen.coupon, - offer: { - ...couponScreen.coupon?.offer, - description: { - ...couponScreen.coupon?.offer?.description, - text: e.target.value, - font: couponScreen.coupon?.offer?.description?.font || "inter", - weight: couponScreen.coupon?.offer?.description?.weight || "medium", - } - } - } - })} - /> -
- -
- - onUpdate({ - coupon: { - ...couponScreen.coupon, - promoCode: { - ...couponScreen.coupon?.promoCode, - text: e.target.value, - font: couponScreen.coupon?.promoCode?.font || "manrope", - weight: couponScreen.coupon?.promoCode?.weight || "bold", - } - } - })} - /> -
- -
- - onUpdate({ - coupon: { - ...couponScreen.coupon, - footer: { - ...couponScreen.coupon?.footer, - text: e.target.value, - font: couponScreen.coupon?.footer?.font || "inter", - weight: couponScreen.coupon?.footer?.weight || "medium", - } - } - })} - /> -
-
- - {/* Bottom Action Button */} -
- - onUpdate({ - bottomActionButton: { - text: e.target.value || "Continue", + placeholder="-50% на первый заказ" + value={couponScreen.coupon?.offer?.title?.text ?? ""} + onChange={(event) => + handleCouponUpdate("offer", { + ...couponScreen.coupon.offer, + title: { + ...(couponScreen.coupon.offer?.title ?? {}), + text: event.target.value, + }, + }) } - })} - /> + /> + +
- {/* Header Configuration */} -
-

Header Settings

- -
); diff --git a/src/components/admin/builder/templates/DateScreenConfig.tsx b/src/components/admin/builder/templates/DateScreenConfig.tsx index 0e39557..b02a772 100644 --- a/src/components/admin/builder/templates/DateScreenConfig.tsx +++ b/src/components/admin/builder/templates/DateScreenConfig.tsx @@ -11,176 +11,140 @@ interface DateScreenConfigProps { export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) { const dateScreen = screen as DateScreenDefinition & { position: { x: number; y: number } }; - + + const handleDateInputChange = (field: T, value: string | boolean) => { + onUpdate({ + dateInput: { + ...dateScreen.dateInput, + [field]: value, + }, + }); + }; + + 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 ( -
- {/* Title Configuration */} -
- - onUpdate({ - title: { - ...dateScreen.title, - text: e.target.value, - font: dateScreen.title?.font || "manrope", - weight: dateScreen.title?.weight || "bold", - } - })} - /> -
- - {/* Subtitle Configuration */} -
- - onUpdate({ - subtitle: e.target.value ? { - text: e.target.value, - font: dateScreen.subtitle?.font || "inter", - weight: dateScreen.subtitle?.weight || "medium", - color: dateScreen.subtitle?.color || "muted", - } : undefined - })} - /> -
- - {/* Date Input Labels */} +
-

Date Input Labels

- -
-
- +

+ Поля ввода даты +

+
+
- -
- + +
- -
- + +
+
-
-
- +
+
- -
- + +
- -
- + +
+
- {/* Info Message */} -
- - onUpdate({ - infoMessage: e.target.value ? { - text: e.target.value, - icon: dateScreen.infoMessage?.icon || "🔒", - } : undefined - })} - /> - - {dateScreen.infoMessage && ( - onUpdate({ - infoMessage: { - text: dateScreen.infoMessage?.text || "", - icon: e.target.value, - } - })} +
+

Поведение поля

+ + +
+ + +
+ +
- {/* Bottom Action Button */} -
- - onUpdate({ - bottomActionButton: e.target.value ? { - text: e.target.value, - } : undefined - })} - /> +
+

Информационный блок

+ + {dateScreen.infoMessage && ( + + )}
); diff --git a/src/components/admin/builder/templates/FormScreenConfig.tsx b/src/components/admin/builder/templates/FormScreenConfig.tsx index b7bafcb..930cdfd 100644 --- a/src/components/admin/builder/templates/FormScreenConfig.tsx +++ b/src/components/admin/builder/templates/FormScreenConfig.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { TextInput } from "@/components/ui/TextInput/TextInput"; -import type { FormScreenDefinition, FormFieldDefinition } from "@/lib/funnel/types"; +import type { FormScreenDefinition, FormFieldDefinition, FormValidationMessages } from "@/lib/funnel/types"; import type { BuilderScreen } from "@/lib/admin/builder/types"; interface FormScreenConfigProps { @@ -12,180 +12,202 @@ interface FormScreenConfigProps { export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) { const formScreen = screen as FormScreenDefinition & { position: { x: number; y: number } }; - + const updateField = (index: number, updates: Partial) => { const newFields = [...(formScreen.fields || [])]; newFields[index] = { ...newFields[index], ...updates }; onUpdate({ fields: newFields }); }; - + + const updateValidationMessages = (updates: Partial) => { + onUpdate({ + validationMessages: { + ...(formScreen.validationMessages ?? {}), + ...updates, + }, + }); + }; + const addField = () => { const newField: FormFieldDefinition = { id: `field_${Date.now()}`, - label: "New Field", - placeholder: "Enter value", + label: "Новое поле", + placeholder: "Введите значение", type: "text", required: true, }; - + onUpdate({ - fields: [...(formScreen.fields || []), newField] + fields: [...(formScreen.fields || []), newField], }); }; - + const removeField = (index: number) => { const newFields = formScreen.fields?.filter((_, i) => i !== index) || []; onUpdate({ fields: newFields }); }; - + return ( -
- {/* Title Configuration */} -
- - onUpdate({ - title: { - ...formScreen.title, - text: e.target.value, - font: formScreen.title?.font || "manrope", - weight: formScreen.title?.weight || "bold", - } - })} - /> -
- - {/* Subtitle Configuration */} -
- - onUpdate({ - subtitle: e.target.value ? { - text: e.target.value, - font: formScreen.subtitle?.font || "inter", - weight: formScreen.subtitle?.weight || "medium", - color: formScreen.subtitle?.color || "muted", - } : undefined - })} - /> -
- - {/* Form Fields Configuration */} +
-

Form Fields

-
- + {formScreen.fields?.map((field, index) => ( -
-
- Field {index + 1} +
+
+ + Поле {index + 1} +
- -
-
- - updateField(index, { id: e.target.value })} - /> -
- -
- + +
+ +
+
- -
- + +
- -
- + + +
- -
- + +
+ + +
+ +
+ + - - {field.maxLength && ( -
- - updateField(index, { maxLength: parseInt(e.target.value) || undefined })} - /> -
- )}
))} - + {(!formScreen.fields || formScreen.fields.length === 0) && ( -
- No fields added yet. Click "Add Field" to get started. +
+ Пока нет полей. Добавьте хотя бы одно, чтобы форма работала.
)}
- {/* Bottom Action Button */} -
- - onUpdate({ - bottomActionButton: { - text: e.target.value || "Continue", - } - })} - /> +
+

Сообщения валидации

+
+ + + +
); diff --git a/src/components/admin/builder/templates/InfoScreenConfig.tsx b/src/components/admin/builder/templates/InfoScreenConfig.tsx index ebb91ff..f6c55e1 100644 --- a/src/components/admin/builder/templates/InfoScreenConfig.tsx +++ b/src/components/admin/builder/templates/InfoScreenConfig.tsx @@ -1,7 +1,7 @@ "use client"; import { TextInput } from "@/components/ui/TextInput/TextInput"; -import type { InfoScreenDefinition, TypographyVariant } from "@/lib/funnel/types"; +import type { InfoScreenDefinition } from "@/lib/funnel/types"; import type { BuilderScreen } from "@/lib/admin/builder/types"; interface InfoScreenConfigProps { @@ -11,145 +11,91 @@ interface InfoScreenConfigProps { export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) { const infoScreen = screen as InfoScreenDefinition & { position: { x: number; y: number } }; - + + const handleDescriptionChange = (text: string) => { + onUpdate({ + description: text + ? { + ...(infoScreen.description ?? {}), + text, + } + : undefined, + }); + }; + + const handleIconChange = >( + field: T, + value: NonNullable[T] | undefined + ) => { + const baseIcon = infoScreen.icon ?? { type: "emoji", value: "✨", size: "lg" }; + + if (field === "value") { + if (!value) { + onUpdate({ icon: undefined }); + } else { + onUpdate({ icon: { ...baseIcon, value } }); + } + return; + } + + onUpdate({ icon: { ...baseIcon, [field]: value } }); + }; + return ( -
- {/* Title Configuration */} -
- - onUpdate({ - title: { - ...infoScreen.title, - text: e.target.value, - font: infoScreen.title?.font || "manrope", - weight: infoScreen.title?.weight || "bold", - align: infoScreen.title?.align || "center", - } - })} - /> - -
- - - +
+
+

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

+ +
+ +
+

Иконка

+
+ +
-
- {/* Description Configuration */} -
- - onUpdate({ - description: e.target.value ? { - text: e.target.value, - font: infoScreen.description?.font || "inter", - weight: infoScreen.description?.weight || "medium", - align: infoScreen.description?.align || "center", - } : undefined - })} - /> -
- - {/* Icon Configuration */} -
- -
- - - -
- - onUpdate({ - icon: e.target.value ? { - type: infoScreen.icon?.type || "emoji", - value: e.target.value, - size: infoScreen.icon?.size || "lg", - } : undefined - })} - /> -
- - {/* Bottom Action Button */} -
- - onUpdate({ - bottomActionButton: e.target.value ? { - text: e.target.value, - } : undefined - })} - /> +
); diff --git a/src/components/admin/builder/templates/ListScreenConfig.tsx b/src/components/admin/builder/templates/ListScreenConfig.tsx index df33df6..df239d7 100644 --- a/src/components/admin/builder/templates/ListScreenConfig.tsx +++ b/src/components/admin/builder/templates/ListScreenConfig.tsx @@ -2,8 +2,8 @@ import { TextInput } from "@/components/ui/TextInput/TextInput"; import { Button } from "@/components/ui/button"; -import { Trash2, Plus } from "lucide-react"; -import type { ListScreenDefinition, ListOptionDefinition, SelectionType } from "@/lib/funnel/types"; +import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react"; +import type { ListScreenDefinition, ListOptionDefinition, SelectionType, BottomActionButtonDefinition } from "@/lib/funnel/types"; import type { BuilderScreen } from "@/lib/admin/builder/types"; interface ListScreenConfigProps { @@ -11,194 +11,334 @@ interface ListScreenConfigProps { onUpdate: (updates: Partial) => void; } +function mutateOptions( + options: ListOptionDefinition[], + index: number, + mutation: (option: ListOptionDefinition) => ListOptionDefinition +): ListOptionDefinition[] { + return options.map((option, currentIndex) => (currentIndex === index ? mutation(option) : option)); +} + export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) { const listScreen = screen as ListScreenDefinition & { position: { x: number; y: number } }; - - const handleTitleChange = (text: string) => { - onUpdate({ - title: { - ...listScreen.title, - text, - font: listScreen.title?.font || "manrope", - weight: listScreen.title?.weight || "bold", - align: listScreen.title?.align || "left", - } - }); - }; - - const handleSubtitleChange = (text: string) => { - onUpdate({ - subtitle: text ? { - ...listScreen.subtitle, - text, - font: listScreen.subtitle?.font || "inter", - weight: listScreen.subtitle?.weight || "medium", - color: listScreen.subtitle?.color || "muted", - align: listScreen.subtitle?.align || "left", - } : undefined - }); - }; const handleSelectionTypeChange = (selectionType: SelectionType) => { onUpdate({ list: { ...listScreen.list, selectionType, - } + }, }); }; - const handleOptionChange = (index: number, field: keyof ListOptionDefinition, value: string | boolean) => { - const newOptions = [...listScreen.list.options]; - newOptions[index] = { - ...newOptions[index], - [field]: value, - }; - + const handleAutoAdvanceChange = (checked: boolean) => { onUpdate({ list: { ...listScreen.list, - options: newOptions, - } + autoAdvance: checked || undefined, + }, + }); + }; + + const handleOptionChange = ( + index: number, + field: keyof ListOptionDefinition, + value: string | boolean | undefined + ) => { + const nextOptions = mutateOptions(listScreen.list.options, index, (option) => ({ + ...option, + [field]: value, + })); + + onUpdate({ + list: { + ...listScreen.list, + options: nextOptions, + }, + }); + }; + + const handleMoveOption = (index: number, direction: -1 | 1) => { + const nextOptions = [...listScreen.list.options]; + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= nextOptions.length) { + return; + } + const [current] = nextOptions.splice(index, 1); + nextOptions.splice(targetIndex, 0, current); + + onUpdate({ + list: { + ...listScreen.list, + options: nextOptions, + }, }); }; const handleAddOption = () => { - const newOptions = [...listScreen.list.options]; - newOptions.push({ - id: `option-${Date.now()}`, - label: "New Option", - }); - + const nextOptions = [ + ...listScreen.list.options, + { + id: `option-${Date.now()}`, + label: "Новый вариант", + }, + ]; + onUpdate({ list: { ...listScreen.list, - options: newOptions, - } + options: nextOptions, + }, }); }; const handleRemoveOption = (index: number) => { - const newOptions = listScreen.list.options.filter((_, i) => i !== index); - + const nextOptions = listScreen.list.options.filter((_, currentIndex) => currentIndex !== index); + onUpdate({ list: { ...listScreen.list, - options: newOptions, - } + options: nextOptions, + }, }); }; - const handleBottomActionButtonChange = (text: string) => { + const handleListButtonChange = (value: BottomActionButtonDefinition | undefined) => { onUpdate({ list: { ...listScreen.list, - bottomActionButton: text ? { - text, - show: true, - } : undefined, - } + bottomActionButton: value, + }, }); }; return ( -
- {/* Title Configuration */} -
- - handleTitleChange(e.target.value)} - /> -
- - {/* Subtitle Configuration */} -
- - handleSubtitleChange(e.target.value)} - /> -
- - {/* Selection Type */} -
- -
- - -
-
- - {/* Options */} +
- - + +
+
+ + +
+ +
+
+

Настройка вариантов

+
- -
+ +
{listScreen.list.options.map((option, index) => ( -
-
- handleOptionChange(index, "id", e.target.value)} - /> +
+
+ + Вариант {index + 1} + +
+ + + +
-
+ +
+ + +
+ + + +
+ +
- + +
))}
+ + {listScreen.list.options.length === 0 && ( +
+ Добавьте хотя бы один вариант, чтобы экран работал корректно. +
+ )}
- {/* Bottom Action Button */} -
- - handleBottomActionButtonChange(e.target.value)} - /> -
- {listScreen.list.selectionType === "multi" - ? "Multi selection always shows a button" - : "Single selection: empty = auto-advance, filled = manual button"} +
+

Кнопка внутри списка

+
+ + + {listScreen.list.bottomActionButton && ( +
+ +
+ + +
+
+ )} + +

+ Для одиночного выбора пустая кнопка включает авто-переход. Для множественного выбора кнопка отображается всегда. +

diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index 3472c2e..7db1cd3 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -1,70 +1,432 @@ "use client"; +import { useMemo } from "react"; + import { InfoScreenConfig } from "./InfoScreenConfig"; import { DateScreenConfig } from "./DateScreenConfig"; import { CouponScreenConfig } from "./CouponScreenConfig"; import { FormScreenConfig } from "./FormScreenConfig"; import { ListScreenConfig } from "./ListScreenConfig"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; import type { BuilderScreen } from "@/lib/admin/builder/types"; -import type { ScreenDefinition, InfoScreenDefinition, DateScreenDefinition, CouponScreenDefinition, FormScreenDefinition, ListScreenDefinition } from "@/lib/funnel/types"; +import type { + ScreenDefinition, + InfoScreenDefinition, + DateScreenDefinition, + CouponScreenDefinition, + FormScreenDefinition, + ListScreenDefinition, + TypographyVariant, + BottomActionButtonDefinition, + HeaderDefinition, +} from "@/lib/funnel/types"; + +const FONT_OPTIONS: TypographyVariant["font"][] = ["manrope", "inter", "geistSans", "geistMono"]; +const WEIGHT_OPTIONS: TypographyVariant["weight"][] = [ + "regular", + "medium", + "semiBold", + "bold", + "extraBold", + "black", +]; +const SIZE_OPTIONS: TypographyVariant["size"][] = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"]; +const ALIGN_OPTIONS: TypographyVariant["align"][] = ["left", "center", "right"]; +const COLOR_OPTIONS: Exclude[] = [ + "default", + "primary", + "secondary", + "destructive", + "success", + "card", + "accent", + "muted", +]; +const RADIUS_OPTIONS: BottomActionButtonDefinition["cornerRadius"][] = ["3xl", "full"]; interface TemplateConfigProps { screen: BuilderScreen; onUpdate: (updates: Partial) => void; } +interface TypographyControlsProps { + label: string; + value: TypographyVariant | undefined; + onChange: (value: TypographyVariant | undefined) => void; + allowRemove?: boolean; +} + +function TypographyControls({ label, value, onChange, allowRemove = false }: TypographyControlsProps) { + const merge = (patch: Partial) => { + const base: TypographyVariant = { + text: value?.text ?? "", + font: value?.font ?? "manrope", + weight: value?.weight ?? "bold", + size: value?.size ?? "lg", + align: value?.align ?? "left", + color: value?.color ?? "default", + ...value, + }; + onChange({ ...base, ...patch }); + }; + + const handleTextChange = (text: string) => { + if (text.trim() === "" && allowRemove) { + onChange(undefined); + return; + } + + merge({ text }); + }; + + return ( +
+
+ + handleTextChange(event.target.value)} /> +
+ +
+ + + + + + {allowRemove && ( + + )} +
+
+ ); +} + +interface HeaderControlsProps { + header: HeaderDefinition | undefined; + onChange: (value: HeaderDefinition | undefined) => void; +} + +function HeaderControls({ header, onChange }: HeaderControlsProps) { + const activeHeader = header ?? { show: true, showBackButton: true }; + + const handleProgressChange = (field: "current" | "total" | "value" | "label", rawValue: string) => { + const nextProgress = { + ...(activeHeader.progress ?? {}), + [field]: rawValue === "" ? undefined : field === "label" ? rawValue : Number(rawValue), + }; + + const normalizedProgress = Object.values(nextProgress).every((v) => v === undefined) + ? undefined + : nextProgress; + + onChange({ + ...activeHeader, + progress: normalizedProgress, + }); + }; + + const handleToggle = (field: "show" | "showBackButton", checked: boolean) => { + if (field === "show" && !checked) { + onChange({ + ...activeHeader, + show: false, + showBackButton: false, + progress: undefined, + }); + return; + } + + onChange({ + ...activeHeader, + [field]: checked, + }); + }; + + return ( +
+ + + {activeHeader.show !== false && ( +
+ + +
+ + + + +
+
+ )} +
+ ); +} + +interface ActionButtonControlsProps { + label: string; + value: BottomActionButtonDefinition | undefined; + onChange: (value: BottomActionButtonDefinition | undefined) => void; +} + +function ActionButtonControls({ label, value, onChange }: ActionButtonControlsProps) { + const active = useMemo(() => value, [value]); + const isEnabled = Boolean(active); + + return ( +
+ + + {isEnabled && ( +
+ + +
+ + + + +
+
+ )} +
+ ); +} + export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) { const { template } = screen; - switch (template) { - case "info": - return ( - ) => void} - /> - ); - - case "date": - return ( - ) => void} - /> - ); - - case "coupon": - return ( - ) => void} - /> - ); - - case "form": - return ( - ) => void} - /> - ); - - case "list": - return ( - ) => void} - /> - ); - - default: - return ( -
-
- Unknown template type: {template} -
-
- ); - } + const handleTitleChange = (value: TypographyVariant) => { + onUpdate({ title: value }); + }; + + const handleSubtitleChange = (value: TypographyVariant | undefined) => { + onUpdate({ subtitle: value }); + }; + + const handleHeaderChange = (value: HeaderDefinition | undefined) => { + onUpdate({ header: value }); + }; + + const handleButtonChange = (value: BottomActionButtonDefinition | undefined) => { + onUpdate({ bottomActionButton: value }); + }; + + return ( +
+
+ + + + +
+ +
+ {template === "info" && ( + ) => void} + /> + )} + {template === "date" && ( + ) => void} + /> + )} + {template === "coupon" && ( + ) => void} + /> + )} + {template === "form" && ( + ) => void} + /> + )} + {template === "list" && ( + ) => void} + /> + )} +
+
+ ); } diff --git a/src/lib/admin/builder/utils.ts b/src/lib/admin/builder/utils.ts index 0b7625d..e0f6fd4 100644 --- a/src/lib/admin/builder/utils.ts +++ b/src/lib/admin/builder/utils.ts @@ -24,6 +24,7 @@ export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderSt } export function serializeBuilderState(state: BuilderFunnelState): FunnelDefinition { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const screens = state.screens.map(({ position: _position, ...rest }) => rest); const meta: FunnelDefinition["meta"] = { ...state.meta, @@ -65,6 +66,7 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial Date: Fri, 26 Sep 2025 12:54:43 +0200 Subject: [PATCH 6/7] Add Russian sales funnel JSON templates --- public/funnels/ru-career-accelerator.json | 313 +++++++++++++++++++ public/funnels/ru-finance-freedom.json | 314 +++++++++++++++++++ public/funnels/ru-fitness-transform.json | 356 ++++++++++++++++++++++ public/funnels/ru-interior-signature.json | 330 ++++++++++++++++++++ public/funnels/ru-kids-robotics.json | 311 +++++++++++++++++++ public/funnels/ru-language-immersion.json | 330 ++++++++++++++++++++ public/funnels/ru-mind-balance.json | 313 +++++++++++++++++++ public/funnels/ru-skin-renewal.json | 314 +++++++++++++++++++ public/funnels/ru-travel-signature.json | 315 +++++++++++++++++++ public/funnels/ru-wedding-dream.json | 315 +++++++++++++++++++ 10 files changed, 3211 insertions(+) create mode 100644 public/funnels/ru-career-accelerator.json create mode 100644 public/funnels/ru-finance-freedom.json create mode 100644 public/funnels/ru-fitness-transform.json create mode 100644 public/funnels/ru-interior-signature.json create mode 100644 public/funnels/ru-kids-robotics.json create mode 100644 public/funnels/ru-language-immersion.json create mode 100644 public/funnels/ru-mind-balance.json create mode 100644 public/funnels/ru-skin-renewal.json create mode 100644 public/funnels/ru-travel-signature.json create mode 100644 public/funnels/ru-wedding-dream.json diff --git a/public/funnels/ru-career-accelerator.json b/public/funnels/ru-career-accelerator.json new file mode 100644 index 0000000..5c538d0 --- /dev/null +++ b/public/funnels/ru-career-accelerator.json @@ -0,0 +1,313 @@ +{ + "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-finance-freedom.json b/public/funnels/ru-finance-freedom.json new file mode 100644 index 0000000..9dbf993 --- /dev/null +++ b/public/funnels/ru-finance-freedom.json @@ -0,0 +1,314 @@ +{ + "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-fitness-transform.json b/public/funnels/ru-fitness-transform.json new file mode 100644 index 0000000..c992552 --- /dev/null +++ b/public/funnels/ru-fitness-transform.json @@ -0,0 +1,356 @@ +{ + "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": "15–20 минут в день" }, + { "id": "30min", "label": "30–40 минут" }, + { "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-interior-signature.json b/public/funnels/ru-interior-signature.json new file mode 100644 index 0000000..9790026 --- /dev/null +++ b/public/funnels/ru-interior-signature.json @@ -0,0 +1,330 @@ +{ + "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-kids-robotics.json b/public/funnels/ru-kids-robotics.json new file mode 100644 index 0000000..e4508f6 --- /dev/null +++ b/public/funnels/ru-kids-robotics.json @@ -0,0 +1,311 @@ +{ + "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-language-immersion.json b/public/funnels/ru-language-immersion.json new file mode 100644 index 0000000..438c440 --- /dev/null +++ b/public/funnels/ru-language-immersion.json @@ -0,0 +1,330 @@ +{ + "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-mind-balance.json b/public/funnels/ru-mind-balance.json new file mode 100644 index 0000000..af21137 --- /dev/null +++ b/public/funnels/ru-mind-balance.json @@ -0,0 +1,313 @@ +{ + "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-skin-renewal.json b/public/funnels/ru-skin-renewal.json new file mode 100644 index 0000000..f700858 --- /dev/null +++ b/public/funnels/ru-skin-renewal.json @@ -0,0 +1,314 @@ +{ + "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-travel-signature.json b/public/funnels/ru-travel-signature.json new file mode 100644 index 0000000..3c419a6 --- /dev/null +++ b/public/funnels/ru-travel-signature.json @@ -0,0 +1,315 @@ +{ + "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} скопирован!" + } + ] +} diff --git a/public/funnels/ru-wedding-dream.json b/public/funnels/ru-wedding-dream.json new file mode 100644 index 0000000..f3c5bd5 --- /dev/null +++ b/public/funnels/ru-wedding-dream.json @@ -0,0 +1,315 @@ +{ + "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} скопирован!" + } + ] +} From e98d8a3417405795bb2a3be42328922f9acfc5f0 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Fri, 26 Sep 2025 13:00:37 +0200 Subject: [PATCH 7/7] fix build --- .../admin/builder/BuilderCanvas.tsx | 2 +- .../admin/builder/BuilderSidebar.tsx | 44 ++++++++++--------- .../builder/templates/FormScreenConfig.tsx | 3 +- .../builder/templates/InfoScreenConfig.tsx | 4 +- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/components/admin/builder/BuilderCanvas.tsx b/src/components/admin/builder/BuilderCanvas.tsx index 7ae919d..5d39263 100644 --- a/src/components/admin/builder/BuilderCanvas.tsx +++ b/src/components/admin/builder/BuilderCanvas.tsx @@ -289,7 +289,7 @@ export function BuilderCanvas() {
- {screen.subtitle?.text && ( + {"subtitle" in screen && screen.subtitle?.text && (

{screen.subtitle.text}

)} diff --git a/src/components/admin/builder/BuilderSidebar.tsx b/src/components/admin/builder/BuilderSidebar.tsx index 342138d..30a48a8 100644 --- a/src/components/admin/builder/BuilderSidebar.tsx +++ b/src/components/admin/builder/BuilderSidebar.tsx @@ -478,28 +478,30 @@ export function BuilderSidebar() { -
- Варианты ответа -
- {selectedScreen.list.options.map((option) => { - const condition = rule.conditions[0]; - const isChecked = condition.optionIds?.includes(option.id) ?? false; - return ( - - ); - })} + {selectedScreen.template === "list" && ( +
+ Варианты ответа +
+ {selectedScreen.list.options.map((option) => { + const condition = rule.conditions[0]; + const isChecked = condition.optionIds?.includes(option.id) ?? false; + return ( + + ); + })} +
-
+ )}