Add variant-enabled funnel example

This commit is contained in:
pennyteenycat 2025-09-26 02:47:16 +02:00
parent 22c6d513af
commit 1f1ac7df85
5 changed files with 778 additions and 8 deletions

View File

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

View File

@ -10,6 +10,7 @@ import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate"; import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
import { FormTemplate } from "@/components/funnel/templates/FormTemplate"; import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
import { resolveNextScreenId } from "@/lib/funnel/navigation"; import { resolveNextScreenId } from "@/lib/funnel/navigation";
import { resolveScreenVariant } from "@/lib/funnel/variants";
import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider"; import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider";
import type { import type {
FunnelDefinition, FunnelDefinition,
@ -31,10 +32,11 @@ function estimatePathLength(funnel: FunnelDefinition, answers: FunnelAnswers): n
while (currentScreenId && !visited.has(currentScreenId)) { while (currentScreenId && !visited.has(currentScreenId)) {
visited.add(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; 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)) { if (!nextScreenId || visited.has(nextScreenId)) {
@ -229,10 +231,18 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
funnel.meta.id funnel.meta.id
); );
const currentScreen = useMemo(() => { const baseScreen = useMemo(() => {
return getScreenById(funnel, initialScreenId) ?? funnel.screens[0]; const screen = getScreenById(funnel, initialScreenId) ?? funnel.screens[0];
if (!screen) {
throw new Error("Funnel definition does not contain any screens");
}
return screen;
}, [funnel, initialScreenId]); }, [funnel, initialScreenId]);
const currentScreen = useMemo(() => {
return resolveScreenVariant(baseScreen, answers);
}, [baseScreen, answers]);
const selectedOptionIds = answers[currentScreen.id] ?? []; const selectedOptionIds = answers[currentScreen.id] ?? [];
useEffect(() => { useEffect(() => {

View File

@ -41,12 +41,19 @@ function satisfiesCondition(
} }
} }
function satisfiesRule(rule: NavigationRuleDefinition, answers: FunnelAnswers): boolean { export function matchesNavigationConditions(
if (!rule.conditions || rule.conditions.length === 0) { conditions: NavigationConditionDefinition[] | undefined,
answers: FunnelAnswers
): boolean {
if (!conditions || conditions.length === 0) {
return false; 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( export function resolveNextScreenId(

View File

@ -85,6 +85,13 @@ export interface NavigationDefinition {
defaultNextScreenId?: string; defaultNextScreenId?: string;
} }
type ScreenVariantOverrides<T> = Partial<Omit<T, "id" | "template" | "variants">>;
export interface ScreenVariantDefinition<T extends { id: string; template: string }> {
conditions: NavigationConditionDefinition[];
overrides: ScreenVariantOverrides<T>;
}
export interface InfoScreenDefinition { export interface InfoScreenDefinition {
id: string; id: string;
template: "info"; template: "info";
@ -99,6 +106,7 @@ export interface InfoScreenDefinition {
}; };
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<InfoScreenDefinition>[];
} }
export interface DateInputDefinition { export interface DateInputDefinition {
@ -126,6 +134,7 @@ export interface DateScreenDefinition {
}; };
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<DateScreenDefinition>[];
} }
export interface CouponDefinition { export interface CouponDefinition {
@ -148,6 +157,7 @@ export interface CouponScreenDefinition {
copiedMessage?: string; // "Промокод скопирован!" text copiedMessage?: string; // "Промокод скопирован!" text
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<CouponScreenDefinition>[];
} }
export interface FormFieldDefinition { export interface FormFieldDefinition {
@ -179,6 +189,7 @@ export interface FormScreenDefinition {
validationMessages?: FormValidationMessages; validationMessages?: FormValidationMessages;
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<FormScreenDefinition>[];
} }
@ -196,6 +207,7 @@ export interface ListScreenDefinition {
}; };
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<ListScreenDefinition>[];
} }
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition; export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition;

View File

@ -0,0 +1,78 @@
import { matchesNavigationConditions } from "./navigation";
import type {
FunnelAnswers,
ScreenDefinition,
ScreenVariantDefinition,
} from "./types";
function cloneScreen<T>(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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function deepMerge<T>(target: T, source: Partial<T> | undefined): T {
if (!source) {
return target;
}
const result: T = Array.isArray(target)
? ([...target] as unknown as T)
: ({ ...(target as Record<string, unknown>) } 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<string, unknown>)[key as unknown as string];
if (isPlainObject(sourceValue)) {
const baseValue = isPlainObject(targetValue) ? targetValue : {};
(result as Record<string, unknown>)[key as unknown as string] = deepMerge(
baseValue,
sourceValue as Record<string, unknown>
) as unknown as T[keyof T];
continue;
}
(result as Record<string, unknown>)[key as unknown as string] = sourceValue as unknown as T[keyof T];
}
return result;
}
function applyScreenOverrides<T extends ScreenDefinition>(
screen: T,
overrides: ScreenVariantDefinition<T>["overrides"]
): T {
const cloned = cloneScreen(screen);
return deepMerge(cloned, overrides);
}
export function resolveScreenVariant<T extends ScreenDefinition>(
screen: T,
answers: FunnelAnswers
): T {
const variants = (screen as T & { variants?: ScreenVariantDefinition<T>[] }).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;
}