From 1f1ac7df8531ef3b13a118268c8b52b3ef8f0a69 Mon Sep 17 00:00:00 2001 From: pennyteenycat Date: Fri, 26 Sep 2025 02:47:16 +0200 Subject: [PATCH] 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; +}