From aa956adebb2eaf9592dca88c40087fdf2fdbaa87 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Mon, 6 Oct 2025 02:41:09 +0200 Subject: [PATCH] add funnel --- public/funnels/soulmate.json | 581 +++++++++++++++++- .../0fa73e3a-28b2-4033-ab3f-b68a69807b60.svg | 522 ++++++++++++++++ .../7a55e920-1853-4471-816e-2b93113df772.svg | 213 +++++++ .../cb0c01f0-47f2-452e-8355-2310beef03dc.svg | 572 +++++++++++++++++ .../d530695a-6847-4ef7-935b-a3f5b0f59791.svg | 58 ++ scripts/bake-funnels.mjs | 24 +- scripts/sync-funnels-from-db.mjs | 57 ++ src/app/api/funnels/[id]/route.ts | 6 + .../forms/variants/VariantConditionEditor.tsx | 107 +++- .../admin/builder/layout/BuilderPreview.tsx | 1 + .../builder/templates/InfoScreenConfig.tsx | 177 ++++-- .../VariableMappingConditionEditor.tsx | 280 +++++++++ .../builder/templates/VariablesConfig.tsx | 297 +++++++++ src/components/funnel/FunnelRuntime.tsx | 7 +- .../templates/InfoTemplate/InfoTemplate.tsx | 51 +- src/lib/funnel/bakedFunnels.ts | 581 +++++++++++++++++- src/lib/funnel/screenRenderer.tsx | 4 + src/lib/funnel/types.ts | 43 +- src/lib/funnel/variableSubstitution.ts | 147 +++++ src/lib/funnel/variants.ts | 9 +- src/lib/models/Funnel.ts | 1 + 21 files changed, 3635 insertions(+), 103 deletions(-) create mode 100644 public/images/0fa73e3a-28b2-4033-ab3f-b68a69807b60.svg create mode 100644 public/images/7a55e920-1853-4471-816e-2b93113df772.svg create mode 100644 public/images/cb0c01f0-47f2-452e-8355-2310beef03dc.svg create mode 100644 public/images/d530695a-6847-4ef7-935b-a3f5b0f59791.svg create mode 100644 src/components/admin/builder/templates/VariableMappingConditionEditor.tsx create mode 100644 src/components/admin/builder/templates/VariablesConfig.tsx create mode 100644 src/lib/funnel/variableSubstitution.ts diff --git a/public/funnels/soulmate.json b/public/funnels/soulmate.json index 8e628e2..20b2799 100644 --- a/public/funnels/soulmate.json +++ b/public/funnels/soulmate.json @@ -707,6 +707,7 @@ "value": "/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg", "size": "md" }, + "variables": [], "variants": [ { "conditions": [ @@ -918,7 +919,7 @@ }, "navigation": { "rules": [], - "defaultNextScreenId": "core-need", + "defaultNextScreenId": "love-priority-result", "isEndScreen": false }, "list": { @@ -952,6 +953,579 @@ }, "variants": [] }, + { + "id": "love-priority-result", + "template": "info", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Заголовок информации", + "show": false, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "По нашей статистике **51 % {{gender}} {{zodiac}}** доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-block", + "isEndScreen": false + }, + "icon": { + "type": "image", + "value": "/images/7a55e920-1853-4471-816e-2b93113df772.svg", + "size": "xl" + }, + "variables": [ + { + "name": "gender", + "mappings": [ + { + "conditions": [ + { + "screenId": "gender", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "male" + ] + } + ], + "value": "мужчин" + } + ], + "fallback": "женщин" + }, + { + "name": "zodiac", + "mappings": [ + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "aries" + ] + } + ], + "value": "Овнов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "taurus" + ] + } + ], + "value": "Тельцов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "gemini" + ] + } + ], + "value": "Близнецов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "cancer" + ] + } + ], + "value": "Раков" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "leo" + ] + } + ], + "value": "Львов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "virgo" + ] + } + ], + "value": "Дев" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "libra" + ] + } + ], + "value": "Весов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "scorpio" + ] + } + ], + "value": "Скорпионов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "sagittarius" + ] + } + ], + "value": "Стрельцов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "capricorn" + ] + } + ], + "value": "Козерогов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "aquarius" + ] + } + ], + "value": "Водолеев" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "pisces" + ] + } + ], + "value": "Рыб" + } + ], + "fallback": "Овнов" + } + ], + "variants": [ + { + "conditions": [ + { + "screenId": "love-priority", + "operator": "includesAny", + "optionIds": [ + "follow_mind" + ] + } + ], + "overrides": { + "subtitle": { + "text": "По нашей статистике **43 % {{gender}} {{zodiac}}** выбирают разум. Но одних расчётов недостаточно. Мы откроем, какие черты второй половинки принесут доверие, и нарисуем её образ." + }, + "icon": { + "value": "/images/0fa73e3a-28b2-4033-ab3f-b68a69807b60.svg" + } + } + }, + { + "conditions": [ + { + "screenId": "love-priority", + "operator": "includesAny", + "optionIds": [ + "balance_heart_mind" + ] + } + ], + "overrides": { + "subtitle": { + "text": "По нашей статистике **47 % {{gender}} {{zodiac}}** ищут баланс. Но удержать его непросто. Мы покажем, какие качества второй половинки соединят страсть и надёжность, и создадим её портрет." + }, + "icon": { + "value": "/images/cb0c01f0-47f2-452e-8355-2310beef03dc.svg" + } + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "optionIds": [] + } + ], + "overrides": { + "icon": { + "value": "/images/cb0c01f0-47f2-452e-8355-2310beef03dc.svg" + } + } + } + ] + }, + { + "id": "relationship-block", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Что больше всего мешает вам в отношениях?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-block-result", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "fear_of_wrong_choice", + "label": "Страх снова ошибиться в выборе", + "emoji": "💔", + "disabled": false + }, + { + "id": "wasted_years", + "label": "Трата лет на “не того” человека", + "emoji": "🕰️", + "disabled": false + }, + { + "id": "lack_of_depth", + "label": "Есть страсть, но не хватает глубины", + "emoji": "🔥", + "disabled": false + }, + { + "id": "unclear_desires", + "label": "Не понимаю, чего на самом деле хочу", + "emoji": "🗝", + "disabled": false + }, + { + "id": "stuck_in_past", + "label": "Не могу отпустить прошлые отношения", + "emoji": "👻", + "disabled": false + }, + { + "id": "fear_of_loneliness", + "label": "Боюсь остаться в одиночестве", + "emoji": "🕯", + "disabled": false + } + ] + }, + "variants": [ + { + "conditions": [ + { + "screenId": "relationship-status", + "operator": "includesAny", + "optionIds": [ + "single", + "after_breakup" + ] + } + ], + "overrides": { + "list": { + "options": [ + { + "id": "fear_of_wrong_choice", + "label": "Страх снова ошибиться в выборе", + "emoji": "💔" + }, + { + "id": "wasted_years", + "label": "Ощущение, что годы уходят впустую", + "emoji": "🕰️" + }, + { + "id": "wrong_people", + "label": "Встречаю интересных, но не тех самых", + "emoji": "😕" + }, + { + "id": "unclear_needs", + "label": "Не понимаю, кто мне действительно нужен", + "emoji": "🧩" + }, + { + "id": "stuck_in_past", + "label": "Прошлое не даёт двигаться дальше", + "emoji": "👻" + }, + { + "id": "fear_of_loneliness", + "label": "Боюсь остаться в одиночестве", + "emoji": "🕯" + } + ] + }, + "title": { + "text": "Что больше всего мешает вам в поиске любви?" + } + } + } + ] + }, + { + "id": "relationship-block-result", + "template": "info", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Вы не одиноки в этом страхе", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "Многие боятся повторить прошлый опыт. Мы поможем распознать верные сигналы и выбрать «своего» человека.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "core-need", + "isEndScreen": false + }, + "icon": { + "type": "image", + "value": "/images/d530695a-6847-4ef7-935b-a3f5b0f59791.svg", + "size": "xl" + }, + "variables": [], + "variants": [ + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "wasted_years" + ] + } + ], + "overrides": { + "title": { + "text": "Эта боль знакома многим" + }, + "subtitle": { + "text": "Ощущение потраченного времени тяжело. Мы подскажем, как перестать застревать в прошлом и двигаться вперёд." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "lack_of_depth" + ] + } + ], + "overrides": { + "title": { + "text": "Многие сталкиваются с этим" + }, + "subtitle": { + "text": "Яркие эмоции быстро гаснут, если нет основы. Мы поможем превратить связь в настоящую близость." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "unclear_desires" + ] + } + ], + "overrides": { + "title": { + "text": "С этим часто трудно разобраться" + }, + "subtitle": { + "text": "Понять себя — ключ к правильному выбору. Мы поможем прояснить, какие качества действительно важны для вас." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "stuck_in_past" + ] + } + ], + "overrides": { + "title": { + "text": "Вы не единственные, кто застрял в прошлом" + }, + "subtitle": { + "text": "Прошлое может держать слишком крепко. Мы покажем, как освободиться и дать место новой любви." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "fear_of_loneliness" + ] + } + ], + "overrides": { + "title": { + "text": "Этот страх очень знаком многим" + }, + "subtitle": { + "text": "Мысль о будущем в одиночестве пугает. Мы поможем построить путь, где рядом будет близкий человек." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "wrong_people" + ] + } + ], + "overrides": { + "title": { + "text": "Многие через это проходят" + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "unclear_needs" + ] + } + ], + "overrides": { + "title": { + "text": "Это нормально - не знать сразу" + }, + "subtitle": { + "text": "Разобраться в том, какой партнёр нужен именно вам, непросто. Мы поможем увидеть, какие качества действительно важны." + } + } + } + ] + }, { "id": "core-need", "template": "list", @@ -1509,11 +2083,6 @@ "cornerRadius": "3xl", "showPrivacyTermsConsent": true }, - "navigation": { - "rules": [], - "defaultNextScreenId": "screen-25", - "isEndScreen": true - }, "emailInput": { "label": "Email", "placeholder": "example@email.com" diff --git a/public/images/0fa73e3a-28b2-4033-ab3f-b68a69807b60.svg b/public/images/0fa73e3a-28b2-4033-ab3f-b68a69807b60.svg new file mode 100644 index 0000000..5c8a450 --- /dev/null +++ b/public/images/0fa73e3a-28b2-4033-ab3f-b68a69807b60.svg @@ -0,0 +1,522 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/7a55e920-1853-4471-816e-2b93113df772.svg b/public/images/7a55e920-1853-4471-816e-2b93113df772.svg new file mode 100644 index 0000000..2cdabd3 --- /dev/null +++ b/public/images/7a55e920-1853-4471-816e-2b93113df772.svg @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/cb0c01f0-47f2-452e-8355-2310beef03dc.svg b/public/images/cb0c01f0-47f2-452e-8355-2310beef03dc.svg new file mode 100644 index 0000000..14416c1 --- /dev/null +++ b/public/images/cb0c01f0-47f2-452e-8355-2310beef03dc.svg @@ -0,0 +1,572 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/d530695a-6847-4ef7-935b-a3f5b0f59791.svg b/public/images/d530695a-6847-4ef7-935b-a3f5b0f59791.svg new file mode 100644 index 0000000..f129211 --- /dev/null +++ b/public/images/d530695a-6847-4ef7-935b-a3f5b0f59791.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/bake-funnels.mjs b/scripts/bake-funnels.mjs index 0cdd50d..0dd4f74 100644 --- a/scripts/bake-funnels.mjs +++ b/scripts/bake-funnels.mjs @@ -9,6 +9,27 @@ const projectRoot = path.resolve(__dirname, ".."); const funnelsDir = path.join(projectRoot, "public", "funnels"); const outputFile = path.join(projectRoot, "src", "lib", "funnel", "bakedFunnels.ts"); +/** + * Нормализует данные воронки перед запеканием + * Удаляет поля которые не соответствуют типам TypeScript + */ +function normalizeFunnelData(funnelData) { + return { + ...funnelData, + screens: funnelData.screens.map((screen) => { + const normalizedScreen = { ...screen }; + + // Удаляем variables из экранов, которые не поддерживают это поле + // variables поддерживается только в info экранах + if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') { + delete normalizedScreen.variables; + } + + return normalizedScreen; + }), + }; +} + function formatFunnelRecord(funnels) { const entries = Object.entries(funnels) .map(([funnelId, definition]) => { @@ -59,7 +80,8 @@ async function bakeFunnels() { ); } - funnels[funnelId] = parsed; + // Нормализуем данные перед запеканием + funnels[funnelId] = normalizeFunnelData(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 */`; diff --git a/scripts/sync-funnels-from-db.mjs b/scripts/sync-funnels-from-db.mjs index a178d93..dac8564 100755 --- a/scripts/sync-funnels-from-db.mjs +++ b/scripts/sync-funnels-from-db.mjs @@ -135,9 +135,30 @@ async function downloadImagesFromDatabase(funnels) { for (const funnel of funnels) { for (const screen of funnel.funnelData.screens) { + // Проверяем основной icon экрана (info экраны) if (screen.icon?.type === 'image' && screen.icon.value?.startsWith('/api/images/')) { imageUrls.add(screen.icon.value); } + + // Проверяем image экрана (email экраны) + if (screen.image?.src?.startsWith('/api/images/')) { + imageUrls.add(screen.image.src); + } + + // Проверяем icon и image в вариантах экрана + if (screen.variants && Array.isArray(screen.variants)) { + for (const variant of screen.variants) { + // icon в вариантах (info экраны) + // В вариантах может не быть поля type, проверяем только value + if (variant.overrides?.icon?.value?.startsWith('/api/images/')) { + imageUrls.add(variant.overrides.icon.value); + } + // image в вариантах (email экраны) + if (variant.overrides?.image?.src?.startsWith('/api/images/')) { + imageUrls.add(variant.overrides.image.src); + } + } + } } } @@ -197,12 +218,42 @@ async function downloadImagesFromDatabase(funnels) { function updateImageUrlsInFunnels(funnels, imageMapping) { for (const funnel of funnels) { for (const screen of funnel.funnelData.screens) { + // Обновляем основной icon экрана (info экраны) if (screen.icon?.type === 'image' && screen.icon.value && imageMapping[screen.icon.value]) { const oldUrl = screen.icon.value; const newUrl = imageMapping[oldUrl]; screen.icon.value = newUrl; console.log(`🔗 Updated image URL: ${oldUrl} → ${newUrl}`); } + + // Обновляем image экрана (email экраны) + if (screen.image?.src && imageMapping[screen.image.src]) { + const oldUrl = screen.image.src; + const newUrl = imageMapping[oldUrl]; + screen.image.src = newUrl; + console.log(`🔗 Updated image URL: ${oldUrl} → ${newUrl}`); + } + + // Обновляем icon и image в вариантах экрана + if (screen.variants && Array.isArray(screen.variants)) { + for (const variant of screen.variants) { + // icon в вариантах (info экраны) + // В вариантах может не быть поля type, проверяем только value + if (variant.overrides?.icon?.value && imageMapping[variant.overrides.icon.value]) { + const oldUrl = variant.overrides.icon.value; + const newUrl = imageMapping[oldUrl]; + variant.overrides.icon.value = newUrl; + console.log(`🔗 Updated variant image URL: ${oldUrl} → ${newUrl}`); + } + // image в вариантах (email экраны) + if (variant.overrides?.image?.src && imageMapping[variant.overrides.image.src]) { + const oldUrl = variant.overrides.image.src; + const newUrl = imageMapping[oldUrl]; + variant.overrides.image.src = newUrl; + console.log(`🔗 Updated variant image URL: ${oldUrl} → ${newUrl}`); + } + } + } } } } @@ -324,6 +375,12 @@ function normalizeFunnelData(funnelData) { })); } + // Удаляем variables из экранов, которые не поддерживают это поле + // variables поддерживается только в info экранах + if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') { + delete normalizedScreen.variables; + } + return normalizedScreen; }), }; diff --git a/src/app/api/funnels/[id]/route.ts b/src/app/api/funnels/[id]/route.ts index 9810014..ce4ccfa 100644 --- a/src/app/api/funnels/[id]/route.ts +++ b/src/app/api/funnels/[id]/route.ts @@ -89,6 +89,12 @@ function normalizeFunnelData(funnelData: FunnelDefinition): FunnelDefinition { })); } + // Удаляем variables из экранов, которые не поддерживают это поле + // variables поддерживается только в info экранах + if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') { + delete normalizedScreen.variables; + } + return normalizedScreen as ScreenDefinition; }), }; diff --git a/src/components/admin/builder/forms/variants/VariantConditionEditor.tsx b/src/components/admin/builder/forms/variants/VariantConditionEditor.tsx index ec36ab8..29b7c46 100644 --- a/src/components/admin/builder/forms/variants/VariantConditionEditor.tsx +++ b/src/components/admin/builder/forms/variants/VariantConditionEditor.tsx @@ -16,22 +16,84 @@ export function VariantConditionEditor({ allScreens, onChange, }: VariantConditionEditorProps) { - // Находим выбранный экран - const selectedScreen = useMemo( - () => allScreens.find((s) => s.id === condition.screenId), - [allScreens, condition.screenId] - ); + // Находим выбранный экран (может быть ID экрана или storageKey для zodiac) + const selectedScreen = useMemo(() => { + // Сначала ищем по ID экрана + let screen = allScreens.find((s) => s.id === condition.screenId); + if (screen) return screen; + + // Если не нашли, ищем date экран где storageKey === condition.screenId + screen = allScreens.find((s) => { + if (s.template === "date") { + const dateScreen = s as BuilderScreen & { template: "date"; dateInput?: { zodiac?: { enabled?: boolean; storageKey?: string } } }; + const zodiacSettings = dateScreen.dateInput?.zodiac; + return zodiacSettings?.enabled && zodiacSettings.storageKey?.trim() === condition.screenId; + } + return false; + }); + + return screen; + }, [allScreens, condition.screenId]); // Определяем опции для условия (если это list экран) + // Собираем опции из базового экрана + из всех вариантов const conditionOptions = useMemo(() => { if (!selectedScreen || selectedScreen.template !== "list") { return []; } - return (selectedScreen as BuilderScreen & { template: "list"; list: { options: ListOptionDefinition[] } }).list.options; + + const listScreen = selectedScreen as BuilderScreen & { + template: "list"; + list: { options: ListOptionDefinition[] }; + variants?: Array<{ overrides?: { list?: { options?: ListOptionDefinition[] } } }>; + }; + + // Начинаем с базовых опций + const optionsMap = new Map(); + listScreen.list.options.forEach(opt => { + optionsMap.set(opt.id, opt); + }); + + // Добавляем опции из всех вариантов + if (listScreen.variants) { + listScreen.variants.forEach(variant => { + const variantOptions = variant.overrides?.list?.options; + if (variantOptions) { + variantOptions.forEach(opt => { + // Проверяем что опция валидна (имеет id) + if (opt && opt.id) { + // Добавляем или переопределяем опцию + optionsMap.set(opt.id, opt as ListOptionDefinition); + } + }); + } + }); + } + + // Возвращаем все уникальные опции + return Array.from(optionsMap.values()); }, [selectedScreen]); // Определяем, нужен ли специальный селектор - const showZodiacSelector = selectedScreen?.id === "zodiac-sign"; + const showZodiacSelector = useMemo(() => { + if (!selectedScreen) return false; + // Проверяем специальный zodiac экран + if (selectedScreen.id === "zodiac-sign" || selectedScreen.id === "zodiac") { + return true; + } + // Проверяем date экран с zodiac.enabled и storageKey === condition.screenId + if (selectedScreen.template === "date") { + const dateScreen = selectedScreen as BuilderScreen & { template: "date"; dateInput?: { zodiac?: { enabled?: boolean; storageKey?: string } } }; + const zodiacSettings = dateScreen.dateInput?.zodiac; + const storageKey = zodiacSettings?.storageKey?.trim(); + // Показываем zodiac селектор если: + // 1. zodiac включен + // 2. storageKey совпадает с condition.screenId (т.е. пользователь выбрал этот storageKey) + return zodiacSettings?.enabled === true && storageKey === condition.screenId; + } + return false; + }, [selectedScreen, condition.screenId]); + const showEmailSelector = selectedScreen?.id === "email"; const showAgeSelector = selectedScreen?.id === "age" || selectedScreen?.id === "crush-age" || selectedScreen?.id === "current-partner-age"; @@ -64,11 +126,32 @@ export function VariantConditionEditor({ onChange={(e) => onChange({ ...condition, screenId: e.target.value, optionIds: [] })} className="w-full h-9 rounded-md border border-border bg-background px-3 text-sm" > - {allScreens.map((screen) => ( - - ))} + {allScreens.flatMap((screen) => { + const options = []; + + // Для date экранов с zodiac добавляем отдельную опцию для storageKey + if (screen.template === "date") { + const dateScreen = screen as BuilderScreen & { template: "date"; dateInput?: { zodiac?: { enabled?: boolean; storageKey?: string } } }; + const zodiacSettings = dateScreen.dateInput?.zodiac; + const storageKey = zodiacSettings?.storageKey?.trim(); + if (zodiacSettings?.enabled && storageKey) { + options.push( + + ); + } + } + + // Обычный экран (всегда) + options.push( + + ); + + return options; + })} diff --git a/src/components/admin/builder/layout/BuilderPreview.tsx b/src/components/admin/builder/layout/BuilderPreview.tsx index 141b782..13f1210 100644 --- a/src/components/admin/builder/layout/BuilderPreview.tsx +++ b/src/components/admin/builder/layout/BuilderPreview.tsx @@ -91,6 +91,7 @@ export function BuilderPreview() { onBack: MOCK_CALLBACKS.onBack, screenProgress: MOCK_PROGRESS, defaultTexts: builderState.defaultTexts, + answers: {}, // Mock empty answers для превью }); } catch (error) { console.error('Error rendering preview:', error); diff --git a/src/components/admin/builder/templates/InfoScreenConfig.tsx b/src/components/admin/builder/templates/InfoScreenConfig.tsx index 4129ca1..77f9c8f 100644 --- a/src/components/admin/builder/templates/InfoScreenConfig.tsx +++ b/src/components/admin/builder/templates/InfoScreenConfig.tsx @@ -1,7 +1,11 @@ "use client"; +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { TextInput } from "@/components/ui/TextInput/TextInput"; import { ImageUpload } from "@/components/admin/builder/forms/ImageUpload"; +import { VariablesConfig } from "./VariablesConfig"; +import { useBuilderState } from "@/lib/admin/builder/context"; import type { InfoScreenDefinition } from "@/lib/funnel/types"; import type { BuilderScreen } from "@/lib/admin/builder/types"; @@ -10,8 +14,58 @@ interface InfoScreenConfigProps { onUpdate: (updates: Partial) => void; } +function CollapsibleSection({ + title, + children, + defaultExpanded = false, +}: { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; +}) { + const storageKey = `info-section-${title.toLowerCase().replace(/\s+/g, '-')}`; + + const [isExpanded, setIsExpanded] = useState(() => { + if (typeof window === 'undefined') return defaultExpanded; + + const stored = sessionStorage.getItem(storageKey); + return stored !== null ? JSON.parse(stored) : defaultExpanded; + }); + + const handleToggle = () => { + const newExpanded = !isExpanded; + setIsExpanded(newExpanded); + + if (typeof window !== 'undefined') { + sessionStorage.setItem(storageKey, JSON.stringify(newExpanded)); + } + }; + + return ( +
+ + {isExpanded &&
{children}
} +
+ ); +} + export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) { const infoScreen = screen as InfoScreenDefinition; + const state = useBuilderState(); + + // Получаем доступные экраны для настройки условий переменных + const availableScreens = state.screens; const handleIconChange = >( @@ -40,63 +94,74 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) { }; return ( -
-
-

Иконка

-
- - -
- - {infoScreen.icon?.type === "image" ? ( -
- - Изображение иконки - - handleIconChange("value", url)} - onImageRemove={() => handleIconChange("value", undefined)} - funnelId={screen.id} - /> +
+ {/* Иконка */} + +
+
+ +
- ) : ( - - )} -
+ + {infoScreen.icon?.type === "image" ? ( +
+ + Изображение иконки + + handleIconChange("value", url)} + onImageRemove={() => handleIconChange("value", undefined)} + funnelId={screen.id} + /> +
+ ) : ( + + )} +
+ + + {/* Переменные */} + + onUpdate({ variables })} + availableScreens={availableScreens} + /> +
); } diff --git a/src/components/admin/builder/templates/VariableMappingConditionEditor.tsx b/src/components/admin/builder/templates/VariableMappingConditionEditor.tsx new file mode 100644 index 0000000..44d92d3 --- /dev/null +++ b/src/components/admin/builder/templates/VariableMappingConditionEditor.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useMemo } from "react"; +import { AgeSelector } from "../forms/AgeSelector"; +import { ZodiacSelector } from "../forms/ZodiacSelector"; +import { EmailDomainSelector } from "../forms/EmailDomainSelector"; +import type { NavigationConditionDefinition, ListOptionDefinition, DateScreenDefinition } from "@/lib/funnel/types"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; + +interface VariableMappingConditionEditorProps { + condition: NavigationConditionDefinition; + allScreens: BuilderScreen[]; + onChange: (condition: NavigationConditionDefinition) => void; +} + +/** + * Редактор условия для правила подстановки переменной + * Переиспользует ту же логику что и VariantConditionEditor + */ +export function VariableMappingConditionEditor({ + condition, + allScreens, + onChange, +}: VariableMappingConditionEditorProps) { + // Находим выбранный экран (может быть ID экрана или storageKey для zodiac) + const selectedScreen = useMemo(() => { + // Сначала ищем по ID экрана + let screen = allScreens.find((s) => s.id === condition.screenId); + if (screen) return screen; + + // Если не нашли, ищем date экран где storageKey === condition.screenId + screen = allScreens.find((s) => { + if (s.template === "date") { + const dateScreen = s as DateScreenDefinition; + const zodiacSettings = dateScreen.dateInput?.zodiac; + return zodiacSettings?.enabled && zodiacSettings.storageKey?.trim() === condition.screenId; + } + return false; + }); + + return screen; + }, [allScreens, condition.screenId]); + + // Определяем опции для условия (если это list экран) + // Собираем опции из базового экрана + из всех вариантов + const conditionOptions = useMemo(() => { + if (!selectedScreen || selectedScreen.template !== "list") { + return []; + } + + const listScreen = selectedScreen as BuilderScreen & { + template: "list"; + list: { options: ListOptionDefinition[] }; + variants?: Array<{ overrides?: { list?: { options?: ListOptionDefinition[] } } }>; + }; + + // Начинаем с базовых опций + const optionsMap = new Map(); + listScreen.list.options.forEach(opt => { + optionsMap.set(opt.id, opt); + }); + + // Добавляем опции из всех вариантов + if (listScreen.variants) { + listScreen.variants.forEach(variant => { + const variantOptions = variant.overrides?.list?.options; + if (variantOptions) { + variantOptions.forEach(opt => { + // Проверяем что опция валидна (имеет id) + if (opt && opt.id) { + // Добавляем или переопределяем опцию + optionsMap.set(opt.id, opt as ListOptionDefinition); + } + }); + } + }); + } + + // Возвращаем все уникальные опции + return Array.from(optionsMap.values()); + }, [selectedScreen]); + + // Определяем тип селектора на основе экрана + const selectorType = useMemo(() => { + if (!selectedScreen) return null; + + // Специальные селекторы по ID экрана + if (selectedScreen.id === "zodiac-sign" || selectedScreen.id === "zodiac") { + return "zodiac"; + } + if (selectedScreen.id === "email") { + return "email"; + } + if (selectedScreen.id === "age" || selectedScreen.id === "crush-age" || selectedScreen.id === "current-partner-age") { + return "age"; + } + + // Date экран с zodiac - проверяем что condition.screenId === storageKey + if (selectedScreen.template === "date") { + const dateScreen = selectedScreen as DateScreenDefinition; + const zodiacSettings = dateScreen.dateInput?.zodiac; + const storageKey = zodiacSettings?.storageKey?.trim(); + // Показываем zodiac селектор если: + // 1. zodiac включен + // 2. storageKey совпадает с condition.screenId + if (zodiacSettings?.enabled && storageKey === condition.screenId) { + return "zodiac"; + } + if (selectedScreen.id.includes("age") || selectedScreen.id.includes("birth")) { + return "age"; + } + } + + // List экран + if (selectedScreen.template === "list" && conditionOptions.length > 0) { + return "list"; + } + + return null; + }, [selectedScreen, conditionOptions.length, condition.screenId]); + + // Обработчики + const handleToggleValue = (value: string) => { + const currentValues = condition.values || []; + const nextValues = currentValues.includes(value) + ? currentValues.filter((v) => v !== value) + : [...currentValues, value]; + onChange({ ...condition, values: nextValues }); + }; + + const handleAddCustomValue = (value: string) => { + const currentValues = condition.values || []; + if (!currentValues.includes(value)) { + onChange({ ...condition, values: [...currentValues, value] }); + } + }; + + return ( +
+ {/* Выбор экрана */} +
+ + +
+ + {/* Оператор (только для list экранов с несколькими опциями) */} + {selectorType === "list" && conditionOptions.length > 1 && ( +
+ + +
+ )} + + {/* Zodiac Selector */} + {selectorType === "zodiac" && ( +
+ + +
+ )} + + {/* Email Domain Selector */} + {selectorType === "email" && ( +
+ + +
+ )} + + {/* Age Selector */} + {selectorType === "age" && ( +
+ + +
+ )} + + {/* Опции для обычных list экранов */} + {selectorType === "list" && ( +
+ +
+ {conditionOptions.map((opt) => ( + + ))} +
+
+ )} + + {/* Если тип экрана не поддерживается */} + {!selectorType && selectedScreen && ( +
+ ⚠️ Экран {selectedScreen.template} не поддерживает автоматический выбор значений. + Выберите другой экран (list, date с zodiac, age, email). +
+ )} +
+ ); +} diff --git a/src/components/admin/builder/templates/VariablesConfig.tsx b/src/components/admin/builder/templates/VariablesConfig.tsx new file mode 100644 index 0000000..7d1c65b --- /dev/null +++ b/src/components/admin/builder/templates/VariablesConfig.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { VariableMappingConditionEditor } from "./VariableMappingConditionEditor"; +import type { VariableDefinition, VariableMapping, NavigationConditionDefinition } from "@/lib/funnel/types"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; + +interface VariablesConfigProps { + variables: VariableDefinition[] | undefined; + onUpdate: (variables: VariableDefinition[] | undefined) => void; + availableScreens: BuilderScreen[]; +} + +export function VariablesConfig({ variables = [], onUpdate, availableScreens }: VariablesConfigProps) { + const [expandedVariables, setExpandedVariables] = useState>(new Set([0])); + const [expandedMappings, setExpandedMappings] = useState>(new Set()); + + const toggleVariable = (index: number) => { + const newExpanded = new Set(expandedVariables); + if (newExpanded.has(index)) { + newExpanded.delete(index); + } else { + newExpanded.add(index); + } + setExpandedVariables(newExpanded); + }; + + const toggleMapping = (varIndex: number, mapIndex: number) => { + const key = `${varIndex}-${mapIndex}`; + const newExpanded = new Set(expandedMappings); + if (newExpanded.has(key)) { + newExpanded.delete(key); + } else { + newExpanded.add(key); + } + setExpandedMappings(newExpanded); + }; + + const isMappingExpanded = (varIndex: number, mapIndex: number) => { + return expandedMappings.has(`${varIndex}-${mapIndex}`); + }; + + const addVariable = () => { + const newVariable: VariableDefinition = { + name: "", + mappings: [], + fallback: "", + }; + const updatedVariables = [...(variables || []), newVariable]; + onUpdate(updatedVariables); + setExpandedVariables(new Set([...expandedVariables, (variables || []).length])); + }; + + const removeVariable = (index: number) => { + const newVariables = [...(variables || [])]; + newVariables.splice(index, 1); + onUpdate(newVariables.length > 0 ? newVariables : undefined); + }; + + const updateVariable = (index: number, updates: Partial) => { + const newVariables = [...(variables || [])]; + newVariables[index] = { ...newVariables[index], ...updates }; + onUpdate(newVariables); + }; + + const addMapping = (variableIndex: number) => { + const newMapping: VariableMapping = { + conditions: [{ + screenId: availableScreens[0]?.id || "", + conditionType: "values", + operator: "includesAny", + values: [], + }], + value: "", + }; + const variable = variables![variableIndex]; + updateVariable(variableIndex, { + mappings: [...variable.mappings, newMapping], + }); + }; + + const removeMapping = (variableIndex: number, mappingIndex: number) => { + const variable = variables![variableIndex]; + const newMappings = [...variable.mappings]; + newMappings.splice(mappingIndex, 1); + updateVariable(variableIndex, { mappings: newMappings }); + }; + + const updateMapping = ( + variableIndex: number, + mappingIndex: number, + updates: Partial + ) => { + const variable = variables![variableIndex]; + const newMappings = [...variable.mappings]; + newMappings[mappingIndex] = { ...newMappings[mappingIndex], ...updates }; + updateVariable(variableIndex, { mappings: newMappings }); + }; + + const updateCondition = ( + variableIndex: number, + mappingIndex: number, + conditionIndex: number, + updates: Partial + ) => { + const variable = variables![variableIndex]; + const mapping = variable.mappings[mappingIndex]; + const newConditions = [...mapping.conditions]; + newConditions[conditionIndex] = { ...newConditions[conditionIndex], ...updates }; + updateMapping(variableIndex, mappingIndex, { conditions: newConditions }); + }; + + if (!variables || variables.length === 0) { + return ( +
+
+

Переменные

+

+ Используйте переменные для динамической подстановки текста на основе ответов пользователя. +
+ Синтаксис: {"{{variableName}}"} +

+
+ +
+ ); + } + + return ( +
+
+

Переменные

+ +
+ +
+ {variables.map((variable, varIndex) => ( +
+ {/* Variable Header */} +
+ +
+ +
+ + {expandedVariables.has(varIndex) && ( +
+ {/* Variable Name */} + + + {/* Mappings */} +
+
+ Правила подстановки + +
+ + {variable.mappings.map((mapping, mapIndex) => { + const isExpanded = isMappingExpanded(varIndex, mapIndex); + const condition = mapping.conditions[0]; + const conditionPreview = condition ? `${condition.screenId}: ${(condition.values || []).slice(0, 2).join(", ")}${(condition.values || []).length > 2 ? "..." : ""}` : "Нет условия"; + + return ( +
+ {/* Mapping Header - всегда видимый */} +
+ + +
+ + {/* Mapping Content - сворачиваемое */} + {isExpanded && ( +
+ {/* Condition - используем интуитивный селектор */} + {mapping.conditions.map((condition, condIndex) => ( +
+ + updateCondition(varIndex, mapIndex, condIndex, updated) + } + /> +
+ ))} + + {/* Mapping Value */} + +
+ )} +
+ ); + })} +
+ + {/* Fallback */} + +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/funnel/FunnelRuntime.tsx b/src/components/funnel/FunnelRuntime.tsx index 1f10610..ee77706 100644 --- a/src/components/funnel/FunnelRuntime.tsx +++ b/src/components/funnel/FunnelRuntime.tsx @@ -31,7 +31,7 @@ function estimatePathLength( const currentScreen = funnel.screens.find((s) => s.id === currentScreenId); if (!currentScreen) break; - const resolvedScreen = resolveScreenVariant(currentScreen, answers); + const resolvedScreen = resolveScreenVariant(currentScreen, answers, funnel.screens); const nextScreenId = resolveNextScreenId( resolvedScreen, answers, @@ -77,8 +77,8 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { }, [screenMap, initialScreenId, funnel.screens]); const currentScreen = useMemo(() => { - return resolveScreenVariant(baseScreen, answers); - }, [baseScreen, answers]); + return resolveScreenVariant(baseScreen, answers, funnel.screens); + }, [baseScreen, answers, funnel.screens]); const selectedOptionIds = answers[currentScreen.id] ?? []; @@ -268,5 +268,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { onBack, screenProgress, defaultTexts: funnel.defaultTexts, + answers, }); } diff --git a/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx b/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx index 53141d5..d009c3d 100644 --- a/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx +++ b/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx @@ -2,10 +2,11 @@ import { useMemo } from "react"; import Image from "next/image"; -import type { InfoScreenDefinition, DefaultTexts } from "@/lib/funnel/types"; +import type { InfoScreenDefinition, DefaultTexts, FunnelAnswers } from "@/lib/funnel/types"; import { TemplateLayout } from "../layouts/TemplateLayout"; import { cn } from "@/lib/utils"; import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers"; +import { substituteVariables } from "@/lib/funnel/variableSubstitution"; interface InfoTemplateProps { screen: InfoScreenDefinition; @@ -14,6 +15,7 @@ interface InfoTemplateProps { onBack: () => void; screenProgress?: { current: number; total: number }; defaultTexts?: DefaultTexts; + answers: FunnelAnswers; } export function InfoTemplate({ @@ -23,9 +25,29 @@ export function InfoTemplate({ onBack, screenProgress, defaultTexts, + answers, }: InfoTemplateProps) { + // Подставляем переменные в title и subtitle + const processedScreen = useMemo(() => { + if (!screen.variables || screen.variables.length === 0) { + return screen; + } + + return { + ...screen, + title: { + ...screen.title, + text: substituteVariables(screen.title.text, screen.variables, answers), + }, + subtitle: screen.subtitle ? { + ...screen.subtitle, + text: substituteVariables(screen.subtitle.text, screen.variables, answers), + } : screen.subtitle, + }; + }, [screen, answers]); + const iconSizeClasses = useMemo(() => { - const size = screen.icon?.size ?? "xl"; + const size = processedScreen.icon?.size ?? "xl"; switch (size) { case "sm": return "text-4xl"; @@ -37,7 +59,7 @@ export function InfoTemplate({ default: return "text-8xl"; } - }, [screen.icon?.size]); + }, [processedScreen.icon?.size]); // Функция для проверки валидности URL const isValidUrl = (value: string): boolean => { @@ -53,15 +75,16 @@ export function InfoTemplate({ }; // Создаем иконку для передачи в childrenAboveTitle - const iconElement = screen.icon ? ( -
- {screen.icon.type === "emoji" ? ( + const iconElement = processedScreen.icon ? ( +
+ {/* Если type не указан, определяем автоматически: URL = image, иначе emoji */} + {(processedScreen.icon.type === "emoji" || (!processedScreen.icon.type && !isValidUrl(processedScreen.icon.value))) ? (
- {screen.icon.value} + {processedScreen.icon.value}
- ) : (screen.icon.value && isValidUrl(screen.icon.value)) ? ( + ) : (processedScreen.icon.value && isValidUrl(processedScreen.icon.value)) ? ( { - console.error('Preview image load error:', screen.icon?.value, e); + console.error('Preview image load error:', processedScreen.icon?.value, e); }} onLoad={() => { - console.log('Preview image loaded successfully:', screen.icon?.value); + console.log('Preview image loaded successfully:', processedScreen.icon?.value); }} /> ) : ( @@ -91,7 +114,7 @@ export function InfoTemplate({ ) : null; const layoutProps = createTemplateLayoutProps( - screen, + processedScreen, { canGoBack, onBack }, screenProgress, { @@ -111,7 +134,7 @@ export function InfoTemplate({
{/* Дополнительный контент если нужен */}
diff --git a/src/lib/funnel/bakedFunnels.ts b/src/lib/funnel/bakedFunnels.ts index 1f9c0c3..ac01d40 100644 --- a/src/lib/funnel/bakedFunnels.ts +++ b/src/lib/funnel/bakedFunnels.ts @@ -715,6 +715,7 @@ export const BAKED_FUNNELS: Record = { "value": "/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg", "size": "md" }, + "variables": [], "variants": [ { "conditions": [ @@ -926,7 +927,7 @@ export const BAKED_FUNNELS: Record = { }, "navigation": { "rules": [], - "defaultNextScreenId": "core-need", + "defaultNextScreenId": "love-priority-result", "isEndScreen": false }, "list": { @@ -960,6 +961,579 @@ export const BAKED_FUNNELS: Record = { }, "variants": [] }, + { + "id": "love-priority-result", + "template": "info", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Заголовок информации", + "show": false, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "По нашей статистике **51 % {{gender}} {{zodiac}}** доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-block", + "isEndScreen": false + }, + "icon": { + "type": "image", + "value": "/images/7a55e920-1853-4471-816e-2b93113df772.svg", + "size": "xl" + }, + "variables": [ + { + "name": "gender", + "mappings": [ + { + "conditions": [ + { + "screenId": "gender", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "male" + ] + } + ], + "value": "мужчин" + } + ], + "fallback": "женщин" + }, + { + "name": "zodiac", + "mappings": [ + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "aries" + ] + } + ], + "value": "Овнов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "taurus" + ] + } + ], + "value": "Тельцов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "gemini" + ] + } + ], + "value": "Близнецов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "cancer" + ] + } + ], + "value": "Раков" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "leo" + ] + } + ], + "value": "Львов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "virgo" + ] + } + ], + "value": "Дев" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "libra" + ] + } + ], + "value": "Весов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "scorpio" + ] + } + ], + "value": "Скорпионов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "sagittarius" + ] + } + ], + "value": "Стрельцов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "capricorn" + ] + } + ], + "value": "Козерогов" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "aquarius" + ] + } + ], + "value": "Водолеев" + }, + { + "conditions": [ + { + "screenId": "userZodiac", + "conditionType": "values", + "operator": "includesAny", + "values": [ + "pisces" + ] + } + ], + "value": "Рыб" + } + ], + "fallback": "Овнов" + } + ], + "variants": [ + { + "conditions": [ + { + "screenId": "love-priority", + "operator": "includesAny", + "optionIds": [ + "follow_mind" + ] + } + ], + "overrides": { + "subtitle": { + "text": "По нашей статистике **43 % {{gender}} {{zodiac}}** выбирают разум. Но одних расчётов недостаточно. Мы откроем, какие черты второй половинки принесут доверие, и нарисуем её образ." + }, + "icon": { + "value": "/images/0fa73e3a-28b2-4033-ab3f-b68a69807b60.svg" + } + } + }, + { + "conditions": [ + { + "screenId": "love-priority", + "operator": "includesAny", + "optionIds": [ + "balance_heart_mind" + ] + } + ], + "overrides": { + "subtitle": { + "text": "По нашей статистике **47 % {{gender}} {{zodiac}}** ищут баланс. Но удержать его непросто. Мы покажем, какие качества второй половинки соединят страсть и надёжность, и создадим её портрет." + }, + "icon": { + "value": "/images/cb0c01f0-47f2-452e-8355-2310beef03dc.svg" + } + } + }, + { + "conditions": [ + { + "screenId": "onboarding", + "operator": "includesAny", + "optionIds": [] + } + ], + "overrides": { + "icon": { + "value": "/images/cb0c01f0-47f2-452e-8355-2310beef03dc.svg" + } + } + } + ] + }, + { + "id": "relationship-block", + "template": "list", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Что больше всего мешает вам в отношениях?", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "relationship-block-result", + "isEndScreen": false + }, + "list": { + "selectionType": "single", + "options": [ + { + "id": "fear_of_wrong_choice", + "label": "Страх снова ошибиться в выборе", + "emoji": "💔", + "disabled": false + }, + { + "id": "wasted_years", + "label": "Трата лет на “не того” человека", + "emoji": "🕰️", + "disabled": false + }, + { + "id": "lack_of_depth", + "label": "Есть страсть, но не хватает глубины", + "emoji": "🔥", + "disabled": false + }, + { + "id": "unclear_desires", + "label": "Не понимаю, чего на самом деле хочу", + "emoji": "🗝", + "disabled": false + }, + { + "id": "stuck_in_past", + "label": "Не могу отпустить прошлые отношения", + "emoji": "👻", + "disabled": false + }, + { + "id": "fear_of_loneliness", + "label": "Боюсь остаться в одиночестве", + "emoji": "🕯", + "disabled": false + } + ] + }, + "variants": [ + { + "conditions": [ + { + "screenId": "relationship-status", + "operator": "includesAny", + "optionIds": [ + "single", + "after_breakup" + ] + } + ], + "overrides": { + "list": { + "options": [ + { + "id": "fear_of_wrong_choice", + "label": "Страх снова ошибиться в выборе", + "emoji": "💔" + }, + { + "id": "wasted_years", + "label": "Ощущение, что годы уходят впустую", + "emoji": "🕰️" + }, + { + "id": "wrong_people", + "label": "Встречаю интересных, но не тех самых", + "emoji": "😕" + }, + { + "id": "unclear_needs", + "label": "Не понимаю, кто мне действительно нужен", + "emoji": "🧩" + }, + { + "id": "stuck_in_past", + "label": "Прошлое не даёт двигаться дальше", + "emoji": "👻" + }, + { + "id": "fear_of_loneliness", + "label": "Боюсь остаться в одиночестве", + "emoji": "🕯" + } + ] + }, + "title": { + "text": "Что больше всего мешает вам в поиске любви?" + } + } + } + ] + }, + { + "id": "relationship-block-result", + "template": "info", + "header": { + "showBackButton": true, + "show": true + }, + "title": { + "text": "Вы не одиноки в этом страхе", + "show": true, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "center", + "color": "default" + }, + "subtitle": { + "text": "Многие боятся повторить прошлый опыт. Мы поможем распознать верные сигналы и выбрать «своего» человека.", + "show": true, + "font": "manrope", + "weight": "medium", + "size": "lg", + "align": "center", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "defaultNextScreenId": "core-need", + "isEndScreen": false + }, + "icon": { + "type": "image", + "value": "/images/d530695a-6847-4ef7-935b-a3f5b0f59791.svg", + "size": "xl" + }, + "variables": [], + "variants": [ + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "wasted_years" + ] + } + ], + "overrides": { + "title": { + "text": "Эта боль знакома многим" + }, + "subtitle": { + "text": "Ощущение потраченного времени тяжело. Мы подскажем, как перестать застревать в прошлом и двигаться вперёд." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "lack_of_depth" + ] + } + ], + "overrides": { + "title": { + "text": "Многие сталкиваются с этим" + }, + "subtitle": { + "text": "Яркие эмоции быстро гаснут, если нет основы. Мы поможем превратить связь в настоящую близость." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "unclear_desires" + ] + } + ], + "overrides": { + "title": { + "text": "С этим часто трудно разобраться" + }, + "subtitle": { + "text": "Понять себя — ключ к правильному выбору. Мы поможем прояснить, какие качества действительно важны для вас." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "stuck_in_past" + ] + } + ], + "overrides": { + "title": { + "text": "Вы не единственные, кто застрял в прошлом" + }, + "subtitle": { + "text": "Прошлое может держать слишком крепко. Мы покажем, как освободиться и дать место новой любви." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "fear_of_loneliness" + ] + } + ], + "overrides": { + "title": { + "text": "Этот страх очень знаком многим" + }, + "subtitle": { + "text": "Мысль о будущем в одиночестве пугает. Мы поможем построить путь, где рядом будет близкий человек." + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "wrong_people" + ] + } + ], + "overrides": { + "title": { + "text": "Многие через это проходят" + } + } + }, + { + "conditions": [ + { + "screenId": "relationship-block", + "operator": "includesAny", + "optionIds": [ + "unclear_needs" + ] + } + ], + "overrides": { + "title": { + "text": "Это нормально - не знать сразу" + }, + "subtitle": { + "text": "Разобраться в том, какой партнёр нужен именно вам, непросто. Мы поможем увидеть, какие качества действительно важны." + } + } + } + ] + }, { "id": "core-need", "template": "list", @@ -1517,11 +2091,6 @@ export const BAKED_FUNNELS: Record = { "cornerRadius": "3xl", "showPrivacyTermsConsent": true }, - "navigation": { - "rules": [], - "defaultNextScreenId": "screen-25", - "isEndScreen": true - }, "emailInput": { "label": "Email", "placeholder": "example@email.com" diff --git a/src/lib/funnel/screenRenderer.tsx b/src/lib/funnel/screenRenderer.tsx index df3d34f..5d9431f 100644 --- a/src/lib/funnel/screenRenderer.tsx +++ b/src/lib/funnel/screenRenderer.tsx @@ -24,6 +24,7 @@ import type { ScreenDefinition, DefaultTexts, FunnelDefinition, + FunnelAnswers, } from "@/lib/funnel/types"; export interface ScreenRenderProps { @@ -36,6 +37,7 @@ export interface ScreenRenderProps { onBack: () => void; screenProgress: { current: number; total: number }; defaultTexts?: DefaultTexts; + answers: FunnelAnswers; } export type TemplateRenderer = (props: ScreenRenderProps) => JSX.Element; @@ -51,6 +53,7 @@ const TEMPLATE_REGISTRY: Record< onBack, screenProgress, defaultTexts, + answers, }) => { const infoScreen = screen as InfoScreenDefinition; @@ -62,6 +65,7 @@ const TEMPLATE_REGISTRY: Record< onBack={onBack} screenProgress={screenProgress} defaultTexts={defaultTexts} + answers={answers} /> ); }, diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index dcafdc1..7f00112 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -110,7 +110,13 @@ export interface NavigationDefinition { isEndScreen?: boolean; // Указывает что это финальный экран воронки } -type ScreenVariantOverrides = Partial>; +// Рекурсивный Partial для глубоких вложенных объектов +type DeepPartial = T extends object ? { + [P in keyof T]?: DeepPartial; +} : T; + +// Варианты могут переопределять любые поля экрана, включая вложенные объекты +type ScreenVariantOverrides = DeepPartial>; export interface ScreenVariantDefinition { conditions: NavigationConditionDefinition[]; @@ -118,12 +124,43 @@ export interface ScreenVariantDefinition[]; diff --git a/src/lib/funnel/variableSubstitution.ts b/src/lib/funnel/variableSubstitution.ts new file mode 100644 index 0000000..b72e1cd --- /dev/null +++ b/src/lib/funnel/variableSubstitution.ts @@ -0,0 +1,147 @@ +import type { VariableDefinition, FunnelAnswers, NavigationConditionDefinition } from "./types"; + +/** + * Подставляет значения переменных в текст на основе ответов пользователя. + * + * @param text - Текст с переменными в формате {{variableName}} + * @param variables - Определения переменных с правилами подстановки + * @param answers - Текущие ответы пользователя + * @returns Текст с подставленными значениями + * + * @example + * const text = "По нашей статистике 43% {{gender}} {{zodiac}} выбирают разум"; + * const variables = [ + * { + * name: "gender", + * mappings: [ + * { conditions: [{ screenId: "gender", values: ["female"] }], value: "женщин" }, + * { conditions: [{ screenId: "gender", values: ["male"] }], value: "мужчин" } + * ], + * fallback: "людей" + * }, + * { + * name: "zodiac", + * mappings: [ + * { conditions: [{ screenId: "birthdate", conditionType: "values", values: ["aries"] }], value: "Овнов" } + * ], + * fallback: "" + * } + * ]; + * const answers = { gender: ["female"], birthdate: ["aries"] }; + * + * // Result: "По нашей статистике 43% женщин Овнов выбирают разум" + */ +export function substituteVariables( + text: string | undefined, + variables: VariableDefinition[] | undefined, + answers: FunnelAnswers +): string { + if (!text || !variables || variables.length === 0) { + return text || ""; + } + + let result = text; + + // Проходим по всем переменным + for (const variable of variables) { + const placeholder = `{{${variable.name}}}`; + + // Если переменной нет в тексте, пропускаем + if (!result.includes(placeholder)) { + continue; + } + + // Ищем подходящее значение на основе условий + const value = resolveVariableValue(variable, answers); + + // Заменяем все вхождения переменной на значение + result = result.replaceAll(placeholder, value); + } + + return result; +} + +/** + * Находит значение переменной на основе условий и ответов пользователя. + * + * @param variable - Определение переменной + * @param answers - Текущие ответы пользователя + * @returns Значение для подстановки + */ +function resolveVariableValue( + variable: VariableDefinition, + answers: FunnelAnswers +): string { + // Проверяем каждое правило подстановки по порядку + for (const mapping of variable.mappings) { + // Проверяем все условия этого правила + const allConditionsMet = mapping.conditions.every((condition) => + checkSingleCondition(condition, answers) + ); + + // Если все условия выполнены, возвращаем значение + if (allConditionsMet) { + return mapping.value; + } + } + + // Если ни одно условие не сработало, возвращаем fallback или пустую строку + return variable.fallback ?? ""; +} + +/** + * Проверяет одно условие навигации для системы переменных. + * + * @param condition - Условие для проверки + * @param answers - Текущие ответы пользователя + * @returns true если условие выполнено + */ +function checkSingleCondition( + condition: NavigationConditionDefinition, + answers: FunnelAnswers +): boolean { + // Проверяем что answers существует + if (!answers) { + return false; + } + + const screenAnswers = answers[condition.screenId]; + + // Если нет ответов для этого экрана, условие не выполнено + if (!screenAnswers || screenAnswers.length === 0) { + return false; + } + + // Получаем значения для проверки (поддерживаем и values и optionIds для обратной совместимости) + const targetValues = condition.values ?? condition.optionIds ?? []; + + if (targetValues.length === 0) { + return false; + } + + const operator = condition.operator ?? "includesAny"; + + switch (operator) { + case "includesAny": + // Хотя бы одно значение из targetValues есть в ответах + return targetValues.some((value) => screenAnswers.includes(value)); + + case "includesAll": + // Все значения из targetValues есть в ответах + return targetValues.every((value) => screenAnswers.includes(value)); + + case "includesExactly": + // Ответы содержат ровно те значения что в targetValues + return ( + screenAnswers.length === targetValues.length && + targetValues.every((value) => screenAnswers.includes(value)) + ); + + case "equals": + // Точное совпадение одного значения (для single selection) + return screenAnswers.length === 1 && targetValues.includes(screenAnswers[0]); + + default: + return false; + } +} diff --git a/src/lib/funnel/variants.ts b/src/lib/funnel/variants.ts index 9df515c..277e420 100644 --- a/src/lib/funnel/variants.ts +++ b/src/lib/funnel/variants.ts @@ -55,12 +55,14 @@ function applyScreenOverrides( overrides: ScreenVariantDefinition["overrides"] ): T { const cloned = cloneScreen(screen); - return deepMerge(cloned, overrides as Partial); + // DeepPartial совместим с deepMerge, так как deepMerge обрабатывает вложенные объекты + return deepMerge(cloned, overrides as unknown as Partial); } export function resolveScreenVariant( screen: T, - answers: FunnelAnswers + answers: FunnelAnswers, + allScreens?: ScreenDefinition[] ): T { const variants = (screen as T & { variants?: ScreenVariantDefinition[] }).variants; @@ -69,7 +71,8 @@ export function resolveScreenVariant( } for (const variant of variants) { - if (matchesNavigationConditions(variant.conditions, answers)) { + // Передаем allScreens для правильной проверки условий + if (matchesNavigationConditions(variant.conditions, answers, allScreens)) { return applyScreenOverrides(screen, variant.overrides); } } diff --git a/src/lib/models/Funnel.ts b/src/lib/models/Funnel.ts index 5c5dd37..441ba16 100644 --- a/src/lib/models/Funnel.ts +++ b/src/lib/models/Funnel.ts @@ -147,6 +147,7 @@ const ScreenDefinitionSchema = new Schema({ // Специфичные для template поля (используем Mixed для максимальной гибкости) description: TypographyVariantSchema, // info, soulmate icon: Schema.Types.Mixed, // info + variables: [Schema.Types.Mixed], // info - динамические переменные для подстановки в текст dateInput: Schema.Types.Mixed, // date infoMessage: Schema.Types.Mixed, // date coupon: Schema.Types.Mixed, // coupon