Merge pull request #4 from WIT-LAB-LLC/codex/add-configurable-screen-variations
Add variant-enabled funnel example
This commit is contained in:
commit
3898d6d4fa
663
public/funnels/funnel-test-variants.json
Normal file
663
public/funnels/funnel-test-variants.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -85,6 +85,13 @@ export interface NavigationDefinition {
|
||||
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 {
|
||||
id: string;
|
||||
template: "info";
|
||||
@ -99,6 +106,7 @@ export interface InfoScreenDefinition {
|
||||
};
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<InfoScreenDefinition>[];
|
||||
}
|
||||
|
||||
export interface DateInputDefinition {
|
||||
@ -126,6 +134,7 @@ export interface DateScreenDefinition {
|
||||
};
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<DateScreenDefinition>[];
|
||||
}
|
||||
|
||||
export interface CouponDefinition {
|
||||
@ -148,6 +157,7 @@ export interface CouponScreenDefinition {
|
||||
copiedMessage?: string; // "Промокод скопирован!" text
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<CouponScreenDefinition>[];
|
||||
}
|
||||
|
||||
export interface FormFieldDefinition {
|
||||
@ -179,6 +189,7 @@ export interface FormScreenDefinition {
|
||||
validationMessages?: FormValidationMessages;
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<FormScreenDefinition>[];
|
||||
}
|
||||
|
||||
|
||||
@ -196,6 +207,7 @@ export interface ListScreenDefinition {
|
||||
};
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
variants?: ScreenVariantDefinition<ListScreenDefinition>[];
|
||||
}
|
||||
|
||||
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition;
|
||||
|
||||
78
src/lib/funnel/variants.ts
Normal file
78
src/lib/funnel/variants.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user