добавил воронку
This commit is contained in:
parent
40e1d6ca21
commit
84fb57ab60
4
public/GuardIcon.svg
Normal file
4
public/GuardIcon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.5 0C9.6725 0 9.845 0.0373134 10.0025 0.108209L17.0637 3.08955C17.8887 3.43657 18.5037 4.24627 18.5 5.22388C18.4812 8.92537 16.9512 15.6978 10.49 18.7761C9.86375 19.0746 9.13625 19.0746 8.51 18.7761C2.04876 15.6978 0.518767 8.92537 0.500017 5.22388C0.496267 4.24627 1.11127 3.43657 1.93626 3.08955L9.00125 0.108209C9.155 0.0373134 9.3275 0 9.5 0Z" fill="#3F83F8"/>
|
||||
<path d="M8.87116 12.38C8.86942 12.38 8.86767 12.38 8.86614 12.38C8.72515 12.3785 8.59338 12.3106 8.51 12.1972L6.58711 9.58194C6.44046 9.38253 6.48336 9.10188 6.68278 8.95523C6.88219 8.80792 7.16306 8.85167 7.30948 9.05091L8.87968 11.1866L11.973 7.17469C12.1241 6.97855 12.4058 6.94201 12.602 7.09345C12.7979 7.24471 12.8345 7.52621 12.6832 7.72235L9.22623 12.2056C9.14128 12.3156 9.01014 12.38 8.87116 12.38Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 905 B |
879
public/funnels/funnel-test.json
Normal file
879
public/funnels/funnel-test.json
Normal file
@ -0,0 +1,879 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"colorPalette": {
|
||||
"text": {
|
||||
"primary": "#1E293B",
|
||||
"secondary": "#475569",
|
||||
"muted": "#64748B",
|
||||
"accent": "#3B82F6",
|
||||
"success": "#10B981",
|
||||
"error": "#EF4444",
|
||||
"warning": "#F59E0B"
|
||||
},
|
||||
"background": {
|
||||
"primary": "#FFFFFF",
|
||||
"secondary": "#F8FAFC",
|
||||
"accent": "#EFF6FF",
|
||||
"success": "#ECFDF5",
|
||||
"error": "#FEF2F2",
|
||||
"warning": "#FFFBEB"
|
||||
},
|
||||
"button": {
|
||||
"primary": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
"primaryText": "#FFFFFF",
|
||||
"secondary": "#F1F5F9",
|
||||
"secondaryText": "#334155",
|
||||
"disabled": "#E2E8F0",
|
||||
"disabledText": "#94A3B8"
|
||||
},
|
||||
"border": {
|
||||
"primary": "#E2E8F0",
|
||||
"accent": "#3B82F6",
|
||||
"success": "#10B981",
|
||||
"error": "#EF4444"
|
||||
},
|
||||
"shadow": {
|
||||
"light": "rgba(0, 0, 0, 0.05)",
|
||||
"medium": "rgba(0, 0, 0, 0.1)",
|
||||
"heavy": "rgba(0, 0, 0, 0.15)",
|
||||
"colored": "rgba(59, 130, 246, 0.3)"
|
||||
}
|
||||
},
|
||||
"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": "Неверный формат"
|
||||
},
|
||||
"bottomActionButton": {
|
||||
"text": "Continue"
|
||||
},
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "statistics-text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "statistics-text",
|
||||
"template": "text",
|
||||
"title": {
|
||||
"text": "Which best represents your hair loss and goals?",
|
||||
"font": "manrope",
|
||||
"weight": "bold",
|
||||
"align": "center"
|
||||
},
|
||||
"content": {
|
||||
"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",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 6,
|
||||
"total": 15,
|
||||
"label": "6 of 15"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Кого анализируем?",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "current-partner",
|
||||
"label": "Текущего партнера"
|
||||
},
|
||||
{
|
||||
"id": "crush",
|
||||
"label": "Человека, который нравится"
|
||||
},
|
||||
{
|
||||
"id": "ex-partner",
|
||||
"label": "Бывшего"
|
||||
},
|
||||
{
|
||||
"id": "future-partner",
|
||||
"label": "Будущую встречу"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["current-partner"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "current-partner-age"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["crush"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "crush-age"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["ex-partner"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "ex-partner-age"
|
||||
},
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "analysis-target",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["future-partner"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "future-partner-age"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "current-partner-age"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "current-partner-age",
|
||||
"template": "list",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 4,
|
||||
"total": 9,
|
||||
"label": "4 of 9"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Возраст текущего партнера",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "current-partner-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "crush-age",
|
||||
"template": "list",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 4,
|
||||
"total": 9,
|
||||
"label": "4 of 9"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Возраст человека, который нравится",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "crush-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ex-partner-age",
|
||||
"template": "list",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 4,
|
||||
"total": 9,
|
||||
"label": "4 of 9"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Возраст бывшего",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "ex-partner-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "future-partner-age",
|
||||
"template": "list",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 4,
|
||||
"total": 9,
|
||||
"label": "4 of 9"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Возраст будущего партнера",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "under-29",
|
||||
"label": "До 29"
|
||||
},
|
||||
{
|
||||
"id": "30-39",
|
||||
"label": "30-39"
|
||||
},
|
||||
{
|
||||
"id": "40-49",
|
||||
"label": "40-49"
|
||||
},
|
||||
{
|
||||
"id": "50-59",
|
||||
"label": "50-59"
|
||||
},
|
||||
{
|
||||
"id": "60-plus",
|
||||
"label": "60+"
|
||||
}
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"rules": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"screenId": "future-partner-age",
|
||||
"operator": "includesAny",
|
||||
"optionIds": ["under-29"]
|
||||
}
|
||||
],
|
||||
"nextScreenId": "age-refine"
|
||||
}
|
||||
],
|
||||
"defaultNextScreenId": "partner-ethnicity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "age-refine",
|
||||
"template": "list",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 5,
|
||||
"total": 9,
|
||||
"label": "5 of 9"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 6,
|
||||
"total": 9,
|
||||
"label": "6 of 9"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 7,
|
||||
"total": 9,
|
||||
"label": "7 of 9"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 8,
|
||||
"total": 9,
|
||||
"label": "8 of 9"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"header": {
|
||||
"progress": {
|
||||
"current": 9,
|
||||
"total": 9,
|
||||
"label": "9 of 9"
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"text": "Когда ты выгораешь, тебе нужно чтобы партнёр...",
|
||||
"font": "manrope",
|
||||
"weight": "bold"
|
||||
},
|
||||
"list": {
|
||||
"selectionType": "single",
|
||||
"options": [
|
||||
{
|
||||
"id": "reassure",
|
||||
"label": "Признал ваше разочарование и успокоил"
|
||||
},
|
||||
{
|
||||
"id": "emotional-support",
|
||||
"label": "Дал эмоциональную опору и безопасное пространство"
|
||||
},
|
||||
{
|
||||
"id": "take-over",
|
||||
"label": "Перехватил быт/дела, чтобы вы восстановились"
|
||||
},
|
||||
{
|
||||
"id": "energize",
|
||||
"label": "Вдохнул энергию через цель и короткий план действий"
|
||||
},
|
||||
{
|
||||
"id": "switch-positive",
|
||||
"label": "Переключил на позитив: прогулка, кино, смешные истории"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
src/app/[funnelId]/[screenId]/page.tsx
Normal file
38
src/app/[funnelId]/[screenId]/page.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition";
|
||||
import { FunnelRuntime } from "@/components/funnel/FunnelRuntime";
|
||||
|
||||
interface FunnelScreenPageProps {
|
||||
params: Promise<{
|
||||
funnelId: string;
|
||||
screenId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: FunnelScreenPageProps): Promise<Metadata> {
|
||||
const { funnelId } = await params;
|
||||
const funnel = await loadFunnelDefinition(funnelId);
|
||||
|
||||
return {
|
||||
title: funnel.meta.title ?? "Funnel",
|
||||
description: funnel.meta.description ?? undefined,
|
||||
} satisfies Metadata;
|
||||
}
|
||||
|
||||
export default async function FunnelScreenPage({
|
||||
params,
|
||||
}: FunnelScreenPageProps) {
|
||||
const { funnelId, screenId } = await params;
|
||||
const funnel = await loadFunnelDefinition(funnelId);
|
||||
|
||||
const screen = funnel.screens.find((item) => item.id === screenId);
|
||||
if (!screen) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <FunnelRuntime funnel={funnel} initialScreenId={screenId} />;
|
||||
}
|
||||
30
src/app/[funnelId]/page.tsx
Normal file
30
src/app/[funnelId]/page.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition";
|
||||
|
||||
interface FunnelRootPageProps {
|
||||
params: {
|
||||
funnelId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FunnelRootPage({ params }: FunnelRootPageProps) {
|
||||
const { funnelId } = params;
|
||||
|
||||
let funnel;
|
||||
try {
|
||||
funnel = await loadFunnelDefinition(funnelId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load funnel '${funnelId}':`, error);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const firstScreenId =
|
||||
funnel.meta.firstScreenId ?? funnel.screens.at(0)?.id ?? "";
|
||||
|
||||
if (!firstScreenId) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
redirect(`/${funnel.meta.id}/${firstScreenId}`);
|
||||
}
|
||||
132
src/app/admin/funnels/builder/page.tsx
Normal file
132
src/app/admin/funnels/builder/page.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { BuilderLayout } from "@/components/admin/builder/BuilderLayout";
|
||||
import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar";
|
||||
import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas";
|
||||
import { BuilderPreview } from "@/components/admin/builder/BuilderPreview";
|
||||
import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar";
|
||||
import {
|
||||
BuilderProvider,
|
||||
useBuilderDispatch,
|
||||
useBuilderState,
|
||||
} from "@/lib/admin/builder/context";
|
||||
import {
|
||||
serializeBuilderState,
|
||||
deserializeFunnelDefinition,
|
||||
} from "@/lib/admin/builder/utils";
|
||||
|
||||
function ExportModal({ json, onClose }: { json: string; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-background p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Экспорт JSON</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-muted-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Скопируйте JSON и используйте в `public/funnels/*.json`.
|
||||
</p>
|
||||
<textarea
|
||||
className="mt-4 h-72 w-full resize-none rounded-xl border border-border bg-muted/30 p-4 font-mono text-xs"
|
||||
readOnly
|
||||
value={json}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderView() {
|
||||
const dispatch = useBuilderDispatch();
|
||||
const state = useBuilderState();
|
||||
const [exportJson, setExportJson] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPreview, setShowPreview] = useState<boolean>(true);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
dispatch({ type: "reset" });
|
||||
}, [dispatch]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const json = JSON.stringify(serializeBuilderState(state), null, 2);
|
||||
setExportJson(json);
|
||||
}, [state]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
(json: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
const builderState = deserializeFunnelDefinition(parsed);
|
||||
dispatch({ type: "reset", payload: builderState });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Некорректный JSON файл");
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleLoadError = useCallback((message: string) => {
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
const handleTogglePreview = useCallback(() => {
|
||||
setShowPreview(prev => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BuilderLayout
|
||||
topBar={
|
||||
<BuilderTopBar
|
||||
onNew={handleNew}
|
||||
onExport={setExportJson}
|
||||
onLoadError={handleLoadError}
|
||||
/>
|
||||
}
|
||||
sidebar={<BuilderSidebar />}
|
||||
canvas={<BuilderCanvas />}
|
||||
preview={<BuilderPreview />}
|
||||
showPreview={showPreview}
|
||||
onTogglePreview={handleTogglePreview}
|
||||
/>
|
||||
|
||||
{exportJson && (
|
||||
<ExportModal
|
||||
json={exportJson}
|
||||
onClose={() => setExportJson(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="fixed bottom-6 right-6 z-50 max-w-sm rounded-xl border border-destructive bg-destructive/10 p-4 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-destructive underline"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuilderPage() {
|
||||
return (
|
||||
<BuilderProvider>
|
||||
<BuilderView />
|
||||
</BuilderProvider>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Inter, Manrope } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AppProviders } from "@/components/providers/AppProviders";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -39,7 +40,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${manrope.variable} ${inter.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
215
src/components/admin/builder/BuilderCanvas.tsx
Normal file
215
src/components/admin/builder/BuilderCanvas.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CARD_WIDTH = 280;
|
||||
const CARD_HEIGHT = 200;
|
||||
const CARD_GAP = 24;
|
||||
|
||||
export function BuilderCanvas() {
|
||||
const { screens, selectedScreenId } = useBuilderState();
|
||||
const dispatch = useBuilderDispatch();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number; currentIndex: number } | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((screenId: string, index: number) => {
|
||||
dragStateRef.current = {
|
||||
screenId,
|
||||
dragStartIndex: index,
|
||||
currentIndex: index,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
if (!dragStateRef.current) return;
|
||||
|
||||
dragStateRef.current.currentIndex = targetIndex;
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!dragStateRef.current) return;
|
||||
|
||||
const { screenId, dragStartIndex, currentIndex } = dragStateRef.current;
|
||||
|
||||
if (dragStartIndex !== currentIndex) {
|
||||
dispatch({
|
||||
type: "reorder-screens",
|
||||
payload: {
|
||||
fromIndex: dragStartIndex,
|
||||
toIndex: currentIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSelectScreen = useCallback(
|
||||
(screenId: string) => {
|
||||
dispatch({ type: "set-selected-screen", payload: { screenId } });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleAddScreen = useCallback(() => {
|
||||
dispatch({ type: "add-screen" });
|
||||
}, [dispatch]);
|
||||
|
||||
const renderArrows = () => {
|
||||
const arrows: JSX.Element[] = [];
|
||||
|
||||
screens.forEach((screen, index) => {
|
||||
const nextIndex = index + 1;
|
||||
if (nextIndex < screens.length) {
|
||||
const startX = (index + 1) * (CARD_WIDTH + CARD_GAP) - CARD_GAP / 2;
|
||||
const endX = startX + CARD_GAP;
|
||||
const y = CARD_HEIGHT / 2;
|
||||
|
||||
arrows.push(
|
||||
<div
|
||||
key={`arrow-${index}`}
|
||||
className="absolute flex items-center justify-center z-10"
|
||||
style={{
|
||||
left: startX,
|
||||
top: y - 12,
|
||||
width: CARD_GAP,
|
||||
height: 24,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex-1 border-t-2 border-primary/60 border-dashed"></div>
|
||||
<div className="w-0 h-0 border-l-[6px] border-l-primary border-t-[4px] border-t-transparent border-b-[4px] border-b-transparent ml-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return arrows;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full w-full overflow-auto bg-slate-50 dark:bg-slate-900">
|
||||
{/* Header with Add Button */}
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-border/60 bg-background/95 px-6 py-4 backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold">Экраны воронки</h2>
|
||||
<Button variant="outline" onClick={handleAddScreen}>
|
||||
<span className="mr-2">+</span>
|
||||
Добавить экран
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Linear Screen Layout */}
|
||||
<div className="relative p-6">
|
||||
<div
|
||||
className="flex items-center gap-6"
|
||||
style={{ minWidth: screens.length * (CARD_WIDTH + CARD_GAP) }}
|
||||
>
|
||||
{screens.map((screen, index) => {
|
||||
const isSelected = screen.id === selectedScreenId;
|
||||
return (
|
||||
<div
|
||||
key={screen.id}
|
||||
className="relative flex-shrink-0"
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(screen.id, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer rounded-2xl border border-border/70 bg-background p-4 shadow-sm transition-all hover:shadow-md",
|
||||
isSelected
|
||||
? "ring-2 ring-primary border-primary/50"
|
||||
: "hover:border-primary/40",
|
||||
"w-[280px] h-[200px] flex flex-col"
|
||||
)}
|
||||
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
|
||||
onClick={() => handleSelectScreen(screen.id)}
|
||||
>
|
||||
{/* Screen Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-xs font-medium uppercase text-muted-foreground">#{screen.id}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1 rounded bg-muted/50">
|
||||
{screen.template}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screen Content */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold leading-5 text-foreground mb-2">
|
||||
{screen.title.text || "Без названия"}
|
||||
</h3>
|
||||
{(screen as any).subtitle?.text && (
|
||||
<p className="text-xs text-muted-foreground mb-3">{(screen as any).subtitle.text}</p>
|
||||
)}
|
||||
|
||||
{/* List Screen Details */}
|
||||
{(screen as any).list && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Тип выбора:</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{(screen as any).list.selectionType === "single" ? "Single" : "Multi"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Опции: {(screen as any).list.options.length}</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{(screen as any).list.options.slice(0, 2).map((option: any) => (
|
||||
<span key={option.id} className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[10px]">
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
{(screen as any).list.options.length > 2 && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
+{(screen as any).list.options.length - 2} ещё
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Info */}
|
||||
<div className="pt-2 border-t border-border/40">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>Следующий: </span>
|
||||
<span className="font-medium text-foreground">
|
||||
{screen.navigation?.defaultNextScreenId ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow to next screen */}
|
||||
{index < screens.length - 1 && (
|
||||
<div className="absolute -right-3 top-1/2 transform -translate-y-1/2 z-10">
|
||||
<div className="flex items-center">
|
||||
<div className="w-6 border-t-2 border-primary/60 border-dashed"></div>
|
||||
<div className="w-0 h-0 border-l-[6px] border-l-primary border-t-[4px] border-t-transparent border-b-[4px] border-b-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/admin/builder/BuilderLayout.tsx
Normal file
85
src/components/admin/builder/BuilderLayout.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BuilderLayoutProps {
|
||||
className?: string;
|
||||
topBar?: ReactNode;
|
||||
sidebar?: ReactNode;
|
||||
canvas?: ReactNode;
|
||||
preview?: ReactNode;
|
||||
showPreview?: boolean;
|
||||
onTogglePreview?: () => void;
|
||||
}
|
||||
|
||||
export function BuilderLayout({
|
||||
className,
|
||||
topBar,
|
||||
sidebar,
|
||||
canvas,
|
||||
preview,
|
||||
showPreview = true,
|
||||
onTogglePreview
|
||||
}: BuilderLayoutProps) {
|
||||
return (
|
||||
<div className={cn("flex h-screen flex-col bg-muted/20", className)}>
|
||||
{topBar && <header className="border-b border-border/60 bg-background/80 backdrop-blur-sm">{topBar}</header>}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebar && (
|
||||
<aside className="w-[340px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95">
|
||||
{sidebar}
|
||||
</aside>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="relative h-full w-full overflow-auto bg-slate-100 dark:bg-slate-900">
|
||||
{canvas}
|
||||
</div>
|
||||
</div>
|
||||
{showPreview && preview && (
|
||||
<div className="w-96 shrink-0 border-l border-border/60 bg-background/95">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Предпросмотр</h3>
|
||||
{onTogglePreview && (
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Скрыть
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">{preview}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showPreview && onTogglePreview && (
|
||||
<div className="flex w-12 shrink-0 items-center justify-center border-l border-border/60 bg-background/95">
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="Показать предпросмотр"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/components/admin/builder/BuilderPreview.tsx
Normal file
164
src/components/admin/builder/BuilderPreview.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ListTemplate } from "@/components/funnel/templates/ListTemplate";
|
||||
import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate";
|
||||
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
|
||||
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
|
||||
import { TextTemplate } from "@/components/funnel/templates/TextTemplate";
|
||||
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
||||
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context";
|
||||
|
||||
export function BuilderPreview() {
|
||||
const selectedScreen = useBuilderSelectedScreen();
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
const [dateData, setDateData] = useState<[number, number, number]>([0, 0, 0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedScreen) {
|
||||
setSelectedIds([]);
|
||||
setFormData({});
|
||||
setDateData([0, 0, 0]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, [selectedScreen]);
|
||||
|
||||
const handleSelectionChange = useCallback((ids: string[]) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.length === ids.length && prev.every((value, index) => value === ids[index])) {
|
||||
return prev;
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFormChange = useCallback((data: Record<string, string>) => {
|
||||
setFormData(data);
|
||||
}, []);
|
||||
|
||||
const handleDateChange = useCallback((data: [number, number, number]) => {
|
||||
setDateData(data);
|
||||
}, []);
|
||||
|
||||
const renderScreenPreview = useCallback(() => {
|
||||
if (!selectedScreen) return null;
|
||||
|
||||
const commonProps = {
|
||||
showGradient: false,
|
||||
canGoBack: false,
|
||||
onBack: () => {},
|
||||
onContinue: () => {}, // Mock continue handler for preview
|
||||
};
|
||||
|
||||
switch (selectedScreen.template) {
|
||||
case "list":
|
||||
return (
|
||||
<ListTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as any}
|
||||
selectedOptionIds={selectedIds}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case "info":
|
||||
return (
|
||||
<InfoTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as any}
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<DateTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as any}
|
||||
selectedDate={{ month: "", day: "", year: "" }}
|
||||
onDateChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "form":
|
||||
return (
|
||||
<FormTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as any}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case "text":
|
||||
return (
|
||||
<TextTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as any}
|
||||
/>
|
||||
);
|
||||
|
||||
case "coupon":
|
||||
return (
|
||||
<CouponTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as any}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border text-sm text-muted-foreground">
|
||||
Предпросмотр для типа “{(selectedScreen as any).template}” не поддерживается.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [selectedScreen, selectedIds, formData, handleSelectionChange, handleFormChange]);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (!selectedScreen) {
|
||||
return (
|
||||
<div className="flex items-center justify-center mx-auto" style={{ height: '600px', width: '320px' }}>
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground w-full h-full">
|
||||
Выберите экран для предпросмотра
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Используем пропорции современных iPhone (19.5:9 = ~2.17:1)
|
||||
const PREVIEW_WIDTH = 320;
|
||||
const PREVIEW_HEIGHT = Math.round(PREVIEW_WIDTH * 2.17); // ~694px
|
||||
|
||||
return (
|
||||
<div className="mx-auto" style={{ width: PREVIEW_WIDTH }}>
|
||||
{/* Mobile Frame - Simple Border */}
|
||||
<div
|
||||
className="relative bg-white rounded-2xl border-4 border-gray-300 shadow-lg overflow-hidden"
|
||||
style={{
|
||||
height: PREVIEW_HEIGHT,
|
||||
width: PREVIEW_WIDTH
|
||||
}}
|
||||
>
|
||||
{/* Screen Content - Scrollable */}
|
||||
<div
|
||||
className="w-full h-full overflow-y-auto overflow-x-hidden bg-white [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||
style={{ height: PREVIEW_HEIGHT }}
|
||||
>
|
||||
{renderScreenPreview()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [renderScreenPreview, selectedScreen]);
|
||||
|
||||
return preview;
|
||||
}
|
||||
658
src/components/admin/builder/BuilderSidebar.tsx
Normal file
658
src/components/admin/builder/BuilderSidebar.tsx
Normal file
@ -0,0 +1,658 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { validateBuilderState } from "@/lib/admin/builder/validation";
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h2>
|
||||
{description && <p className="text-xs text-muted-foreground/80">{description}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="h-px w-full bg-border/80" />;
|
||||
}
|
||||
|
||||
function ValidationSummary() {
|
||||
const state = useBuilderState();
|
||||
const validation = useMemo(() => validateBuilderState(state), [state]);
|
||||
|
||||
if (validation.issues.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 bg-background/60 p-3 text-xs text-muted-foreground">
|
||||
Всё хорошо — воронка валидна.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{validation.issues.map((issue, index) => (
|
||||
<div
|
||||
key={`${issue.severity}-${issue.screenId ?? "root"}-${issue.optionId ?? "all"}-${index}`}
|
||||
className={cn(
|
||||
"rounded-xl border p-3 text-xs",
|
||||
issue.severity === "error"
|
||||
? "border-destructive/60 bg-destructive/10 text-destructive"
|
||||
: "border-amber-400/60 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
)}
|
||||
>
|
||||
<div className="font-semibold uppercase tracking-wide">
|
||||
{issue.severity === "error" ? "Ошибка" : "Предупреждение"}
|
||||
{issue.screenId ? ` · ${issue.screenId}` : ""}
|
||||
{issue.optionId ? ` · ${issue.optionId}` : ""}
|
||||
</div>
|
||||
<p className="mt-1 leading-relaxed">{issue.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuilderSidebar() {
|
||||
const state = useBuilderState();
|
||||
const dispatch = useBuilderDispatch();
|
||||
const selectedScreen = useBuilderSelectedScreen();
|
||||
|
||||
const screenOptions = useMemo(() => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), [
|
||||
state.screens,
|
||||
]);
|
||||
|
||||
const handleMetaChange = (field: keyof typeof state.meta, value: string) => {
|
||||
dispatch({ type: "set-meta", payload: { [field]: value } });
|
||||
};
|
||||
|
||||
const handleFirstScreenChange = (value: string) => {
|
||||
dispatch({ type: "set-meta", payload: { firstScreenId: value } });
|
||||
};
|
||||
|
||||
const getScreenById = (screenId: string): BuilderScreen | undefined =>
|
||||
state.screens.find((item) => item.id === screenId);
|
||||
|
||||
const updateList = (
|
||||
screen: BuilderScreen,
|
||||
listUpdates: Partial<BuilderScreen["list"]>
|
||||
) => {
|
||||
const nextList: BuilderScreen["list"] = {
|
||||
...screen.list,
|
||||
...listUpdates,
|
||||
selectionType: listUpdates.selectionType ?? screen.list.selectionType,
|
||||
options: listUpdates.options ?? screen.list.options,
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: "update-screen",
|
||||
payload: {
|
||||
screenId: screen.id,
|
||||
screen: {
|
||||
list: nextList,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateNavigation = (
|
||||
screen: BuilderScreen,
|
||||
navigationUpdates: Partial<BuilderScreen["navigation"]> = {}
|
||||
) => {
|
||||
dispatch({
|
||||
type: "update-navigation",
|
||||
payload: {
|
||||
screenId: screen.id,
|
||||
navigation: {
|
||||
defaultNextScreenId:
|
||||
navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId,
|
||||
rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectionTypeChange = (
|
||||
screenId: string,
|
||||
selectionType: BuilderScreen["list"]["selectionType"]
|
||||
) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateList(screen, { selectionType });
|
||||
};
|
||||
|
||||
const handleTitleChange = (screenId: string, value: string) => {
|
||||
dispatch({
|
||||
type: "update-screen",
|
||||
payload: {
|
||||
screenId,
|
||||
screen: {
|
||||
title: {
|
||||
text: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubtitleChange = (screenId: string, value: string) => {
|
||||
dispatch({
|
||||
type: "update-screen",
|
||||
payload: {
|
||||
screenId,
|
||||
screen: {
|
||||
subtitle: value
|
||||
? { text: value, color: "muted", font: "inter" }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOptionChange = (
|
||||
screenId: string,
|
||||
index: number,
|
||||
field: "label" | "id" | "emoji" | "description",
|
||||
value: string
|
||||
) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = screen.list.options.map((option, optionIndex) =>
|
||||
optionIndex === index ? { ...option, [field]: value } : option
|
||||
);
|
||||
|
||||
updateList(screen, { options });
|
||||
};
|
||||
|
||||
const handleAddOption = (screen: BuilderScreen) => {
|
||||
const nextIndex = screen.list.options.length + 1;
|
||||
const options = [
|
||||
...screen.list.options,
|
||||
{
|
||||
id: `option-${nextIndex}`,
|
||||
label: `Вариант ${nextIndex}`,
|
||||
},
|
||||
];
|
||||
|
||||
updateList(screen, { options });
|
||||
};
|
||||
|
||||
const handleRemoveOption = (screen: BuilderScreen, index: number) => {
|
||||
const options = screen.list.options.filter((_, optionIndex) => optionIndex !== index);
|
||||
updateList(screen, { options });
|
||||
};
|
||||
|
||||
const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateNavigation(screen, {
|
||||
defaultNextScreenId: nextScreenId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const updateRules = (screenId: string, rules: NavigationRuleDefinition[]) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateNavigation(screen, { rules });
|
||||
};
|
||||
|
||||
const handleRuleOperatorChange = (
|
||||
screenId: string,
|
||||
index: number,
|
||||
operator: NavigationRuleDefinition["conditions"][0]["operator"]
|
||||
) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, ruleIndex) =>
|
||||
ruleIndex === index
|
||||
? {
|
||||
...rule,
|
||||
conditions: rule.conditions.map((condition, conditionIndex) =>
|
||||
conditionIndex === 0
|
||||
? {
|
||||
...condition,
|
||||
operator,
|
||||
}
|
||||
: condition
|
||||
),
|
||||
}
|
||||
: rule
|
||||
);
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleRuleOptionToggle = (screenId: string, ruleIndex: number, optionId: string) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, currentIndex) => {
|
||||
if (currentIndex !== ruleIndex) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
const [condition] = rule.conditions;
|
||||
const optionIds = new Set(condition.optionIds ?? []);
|
||||
if (optionIds.has(optionId)) {
|
||||
optionIds.delete(optionId);
|
||||
} else {
|
||||
optionIds.add(optionId);
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
conditions: [
|
||||
{
|
||||
...condition,
|
||||
optionIds: Array.from(optionIds),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleRuleNextScreenChange = (screenId: string, ruleIndex: number, nextScreenId: string) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.map((rule, currentIndex) =>
|
||||
currentIndex === ruleIndex ? { ...rule, nextScreenId } : rule
|
||||
);
|
||||
|
||||
updateRules(screenId, nextRules);
|
||||
};
|
||||
|
||||
const handleAddRule = (screen: BuilderScreen) => {
|
||||
const defaultCondition: NavigationRuleDefinition["conditions"][number] = {
|
||||
screenId: screen.id,
|
||||
operator: "includesAny",
|
||||
optionIds: screen.list.options.slice(0, 1).map((option) => option.id),
|
||||
};
|
||||
|
||||
const nextRules = [...(screen.navigation?.rules ?? []), { nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] }];
|
||||
updateNavigation(screen, { rules: nextRules });
|
||||
};
|
||||
|
||||
const handleRemoveRule = (screenId: string, ruleIndex: number) => {
|
||||
const screen = getScreenById(screenId);
|
||||
if (!screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = screen.navigation?.rules ?? [];
|
||||
const nextRules = rules.filter((_, index) => index !== ruleIndex);
|
||||
updateNavigation(screen, { rules: nextRules });
|
||||
};
|
||||
|
||||
const handleDeleteScreen = (screenId: string) => {
|
||||
if (state.screens.length <= 1) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "remove-screen", payload: { screenId } });
|
||||
};
|
||||
|
||||
// Показываем настройки воронки, если экран не выбран
|
||||
if (!selectedScreen) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Section title="Валидация">
|
||||
<ValidationSummary />
|
||||
</Section>
|
||||
|
||||
<Section title="Настройки воронки" description="Общие параметры">
|
||||
<TextInput
|
||||
label="ID воронки"
|
||||
value={state.meta.id}
|
||||
onChange={(event) => handleMetaChange("id", event.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Название"
|
||||
value={state.meta.title ?? ""}
|
||||
onChange={(event) => handleMetaChange("title", event.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Описание"
|
||||
value={state.meta.description ?? ""}
|
||||
onChange={(event) => handleMetaChange("description", event.target.value)}
|
||||
/>
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
|
||||
onChange={(event) => handleFirstScreenChange(event.target.value)}
|
||||
>
|
||||
{screenOptions.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</Section>
|
||||
|
||||
<Section title="Экраны" description="Управление экранами">
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Выберите экран на канвасе для редактирования его настроек.
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Всего экранов: <span className="font-medium">{state.screens.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Показываем настройки выбранного экрана
|
||||
const isListScreen = selectedScreen.template === "list" && "list" in selectedScreen;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Информация о выбранном экране */}
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-primary"></div>
|
||||
<span className="text-sm font-semibold text-primary">Редактируем экран</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={() => dispatch({ type: "set-selected-screen", payload: { screenId: null } })}
|
||||
>
|
||||
← К настройкам воронки
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">ID:</span> {selectedScreen.id} •
|
||||
<span className="font-medium">Тип:</span> {selectedScreen.template}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Section title="Основные настройки" description="Заголовок и тип экрана">
|
||||
<div className="flex flex-col gap-3">
|
||||
<TextInput label="ID экрана" value={selectedScreen.id} disabled />
|
||||
<TextInput
|
||||
label="Заголовок"
|
||||
value={selectedScreen.title.text}
|
||||
onChange={(event) => handleTitleChange(selectedScreen.id, event.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Подзаголовок"
|
||||
value={selectedScreen.subtitle?.text ?? ""}
|
||||
onChange={(event) => handleSubtitleChange(selectedScreen.id, event.target.value)}
|
||||
/>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Тип экрана:</span> {selectedScreen.template}
|
||||
<div className="mt-1">
|
||||
<span className="font-medium">Позиция в воронке:</span> экран {state.screens.findIndex(s => s.id === selectedScreen.id) + 1} из {state.screens.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{isListScreen && (
|
||||
<Section title="Варианты ответа" description="Настройки опций">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Тип выбора</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={(selectedScreen as any).list.selectionType}
|
||||
onChange={(event) =>
|
||||
handleSelectionTypeChange(
|
||||
selectedScreen.id,
|
||||
event.target.value as any
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="single">Один ответ</option>
|
||||
<option value="multi">Несколько ответов</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => handleAddOption(selectedScreen as any)}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{(selectedScreen as any).list.options.map((option: any, index: number) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"rounded-xl border border-border/80 bg-background/70 p-3",
|
||||
"flex flex-col gap-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Опция {index + 1}</span>
|
||||
{(selectedScreen as any).list.options.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => handleRemoveOption(selectedScreen as any, index)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TextInput
|
||||
label="ID"
|
||||
value={option.id}
|
||||
onChange={(event) => handleOptionChange(selectedScreen.id, index, "id", event.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Текст"
|
||||
value={option.label}
|
||||
onChange={(event) => handleOptionChange(selectedScreen.id, index, "label", event.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Описание"
|
||||
value={option.description ?? ""}
|
||||
onChange={(event) => handleOptionChange(selectedScreen.id, index, "description", event.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Emoji"
|
||||
value={option.emoji ?? ""}
|
||||
onChange={(event) => handleOptionChange(selectedScreen.id, index, "emoji", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Навигация" description="Переходы между экранами">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
|
||||
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{screenOptions
|
||||
.filter((screen) => screen.id !== selectedScreen.id)
|
||||
.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{isListScreen && (
|
||||
<Section title="Правила переходов" description="Условная навигация">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Направляйте пользователей на разные экраны в зависимости от выбора.
|
||||
</p>
|
||||
<Button className="h-8 px-3 text-xs" onClick={() => handleAddRule(selectedScreen as any)}>
|
||||
Добавить правило
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
|
||||
Правил пока нет
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
|
||||
<div
|
||||
key={ruleIndex}
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
|
||||
>
|
||||
<span className="text-xs">Удалить</span>
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={rule.conditions[0]?.operator ?? "includesAny"}
|
||||
onChange={(event) =>
|
||||
handleRuleOperatorChange(
|
||||
selectedScreen.id,
|
||||
ruleIndex,
|
||||
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="includesAny">contains any</option>
|
||||
<option value="includesAll">contains all</option>
|
||||
<option value="includesExactly">exact match</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
|
||||
{(selectedScreen as any).list?.options?.map((option: any) => {
|
||||
const condition = rule.conditions[0];
|
||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||
return (
|
||||
<label key={option.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
|
||||
/>
|
||||
<span>
|
||||
{option.label}
|
||||
<span className="text-muted-foreground"> ({option.id})</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
|
||||
<select
|
||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||
value={rule.nextScreenId}
|
||||
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
|
||||
>
|
||||
{screenOptions
|
||||
.filter((screen) => screen.id !== selectedScreen.id)
|
||||
.map((screen) => (
|
||||
<option key={screen.id} value={screen.id}>
|
||||
{screen.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Валидация экрана" description="Проверка корректности настроек">
|
||||
<ValidationSummary />
|
||||
</Section>
|
||||
|
||||
<Section title="Управление экраном" description="Опасные действия">
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-9 text-sm"
|
||||
disabled={state.screens.length <= 1}
|
||||
onClick={() => handleDeleteScreen(selectedScreen.id)}
|
||||
>
|
||||
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/admin/builder/BuilderTopBar.tsx
Normal file
77
src/components/admin/builder/BuilderTopBar.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { serializeBuilderState, deserializeFunnelDefinition } from "@/lib/admin/builder/utils";
|
||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderState } from "@/lib/admin/builder/context";
|
||||
|
||||
interface BuilderTopBarProps {
|
||||
onNew: () => void;
|
||||
onExport: (json: string) => void;
|
||||
onLoadError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function BuilderTopBar({ onNew, onExport, onLoadError }: BuilderTopBarProps) {
|
||||
const dispatch = useBuilderDispatch();
|
||||
const state = useBuilderState();
|
||||
const fileInputId = useId();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleExport = () => {
|
||||
const json = JSON.stringify(serializeBuilderState(state), null, 2);
|
||||
onExport(json);
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
const builderState = deserializeFunnelDefinition(parsed);
|
||||
dispatch({ type: "reset", payload: builderState as BuilderState });
|
||||
} catch (error) {
|
||||
onLoadError?.(error instanceof Error ? error.message : "Не удалось загрузить JSON");
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-semibold">Funnel Builder</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Соберите воронку, редактируйте экраны и экспортируйте JSON для рантайма.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" onClick={onNew}>
|
||||
Создать заново
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Загрузить JSON
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button onClick={handleExport}>Export JSON</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/components/admin/builder/templates/CouponScreenConfig.tsx
Normal file
164
src/components/admin/builder/templates/CouponScreenConfig.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { CouponScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface CouponScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "coupon" };
|
||||
onUpdate: (updates: Partial<CouponScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) {
|
||||
const couponScreen = screen as CouponScreenDefinition & { position: any };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<TextInput
|
||||
placeholder="You're Lucky!"
|
||||
value={couponScreen.title?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
title: {
|
||||
...couponScreen.title,
|
||||
text: value,
|
||||
font: couponScreen.title?.font || "manrope",
|
||||
weight: couponScreen.title?.weight || "bold",
|
||||
align: couponScreen.title?.align || "center",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtitle Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Subtitle</label>
|
||||
<TextInput
|
||||
placeholder="You got an exclusive 94% discount"
|
||||
value={couponScreen.subtitle?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
subtitle: {
|
||||
...couponScreen.subtitle,
|
||||
text: value,
|
||||
font: couponScreen.subtitle?.font || "inter",
|
||||
weight: couponScreen.subtitle?.weight || "medium",
|
||||
align: couponScreen.subtitle?.align || "center",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Coupon Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Coupon Details</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Discount Title</label>
|
||||
<TextInput
|
||||
placeholder="94% OFF"
|
||||
value={couponScreen.coupon?.discountTitle || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
coupon: {
|
||||
...couponScreen.coupon,
|
||||
discountTitle: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Discount Description</label>
|
||||
<TextInput
|
||||
placeholder="HAIR LOSS SPECIALIST"
|
||||
value={couponScreen.coupon?.discountDescription || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
coupon: {
|
||||
...couponScreen.coupon,
|
||||
discountDescription: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Promo Code</label>
|
||||
<TextInput
|
||||
placeholder="HAIR50"
|
||||
value={couponScreen.coupon?.promoCode || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
coupon: {
|
||||
...couponScreen.coupon,
|
||||
promoCode: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Footer Text</label>
|
||||
<TextInput
|
||||
placeholder="Click to copy promocode"
|
||||
value={couponScreen.coupon?.footerText || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
coupon: {
|
||||
...couponScreen.coupon,
|
||||
footerText: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Button */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Button Text</label>
|
||||
<TextInput
|
||||
placeholder="Continue"
|
||||
value={couponScreen.bottomActionButton?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
bottomActionButton: {
|
||||
text: value || "Continue",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header Configuration */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Header Settings</h3>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={couponScreen.header?.show !== false}
|
||||
onChange={(e) => onUpdate({
|
||||
header: {
|
||||
...couponScreen.header,
|
||||
show: e.target.checked,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
Show navigation bar
|
||||
</label>
|
||||
|
||||
{couponScreen.header?.show !== false && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={couponScreen.header?.showBackButton !== false}
|
||||
onChange={(e) => onUpdate({
|
||||
header: {
|
||||
...couponScreen.header,
|
||||
showBackButton: e.target.checked,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
Show back button
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
src/components/admin/builder/templates/DateScreenConfig.tsx
Normal file
187
src/components/admin/builder/templates/DateScreenConfig.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { DateScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface DateScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "date" };
|
||||
onUpdate: (updates: Partial<DateScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
|
||||
const dateScreen = screen as DateScreenDefinition & { position: any };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<TextInput
|
||||
placeholder="When were you born?"
|
||||
value={dateScreen.title?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
title: {
|
||||
...dateScreen.title,
|
||||
text: value,
|
||||
font: dateScreen.title?.font || "manrope",
|
||||
weight: dateScreen.title?.weight || "bold",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtitle Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Subtitle (Optional)</label>
|
||||
<TextInput
|
||||
placeholder="Enter subtitle"
|
||||
value={dateScreen.subtitle?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
subtitle: value ? {
|
||||
text: value,
|
||||
font: dateScreen.subtitle?.font || "inter",
|
||||
weight: dateScreen.subtitle?.weight || "medium",
|
||||
color: dateScreen.subtitle?.color || "muted",
|
||||
} : undefined
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date Input Labels */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Date Input Labels</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Month Label</label>
|
||||
<TextInput
|
||||
placeholder="Month"
|
||||
value={dateScreen.dateInput?.monthLabel || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
dateInput: {
|
||||
...dateScreen.dateInput,
|
||||
monthLabel: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Day Label</label>
|
||||
<TextInput
|
||||
placeholder="Day"
|
||||
value={dateScreen.dateInput?.dayLabel || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
dateInput: {
|
||||
...dateScreen.dateInput,
|
||||
dayLabel: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Year Label</label>
|
||||
<TextInput
|
||||
placeholder="Year"
|
||||
value={dateScreen.dateInput?.yearLabel || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
dateInput: {
|
||||
...dateScreen.dateInput,
|
||||
yearLabel: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Month Placeholder</label>
|
||||
<TextInput
|
||||
placeholder="MM"
|
||||
value={dateScreen.dateInput?.monthPlaceholder || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
dateInput: {
|
||||
...dateScreen.dateInput,
|
||||
monthPlaceholder: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Day Placeholder</label>
|
||||
<TextInput
|
||||
placeholder="DD"
|
||||
value={dateScreen.dateInput?.dayPlaceholder || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
dateInput: {
|
||||
...dateScreen.dateInput,
|
||||
dayPlaceholder: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Year Placeholder</label>
|
||||
<TextInput
|
||||
placeholder="YYYY"
|
||||
value={dateScreen.dateInput?.yearPlaceholder || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
dateInput: {
|
||||
...dateScreen.dateInput,
|
||||
yearPlaceholder: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Message */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Info Message (Optional)</label>
|
||||
<TextInput
|
||||
placeholder="We protect your personal data"
|
||||
value={dateScreen.infoMessage?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
infoMessage: value ? {
|
||||
text: value,
|
||||
icon: dateScreen.infoMessage?.icon || "🔒",
|
||||
} : undefined
|
||||
})}
|
||||
/>
|
||||
|
||||
{dateScreen.infoMessage && (
|
||||
<TextInput
|
||||
placeholder="🔒"
|
||||
value={dateScreen.infoMessage.icon}
|
||||
onChange={(value) => onUpdate({
|
||||
infoMessage: {
|
||||
text: dateScreen.infoMessage?.text || "",
|
||||
icon: value,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Button */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Button Text (Optional)</label>
|
||||
<TextInput
|
||||
placeholder="Next"
|
||||
value={dateScreen.bottomActionButton?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
bottomActionButton: value ? {
|
||||
text: value,
|
||||
} : undefined
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
src/components/admin/builder/templates/FormScreenConfig.tsx
Normal file
194
src/components/admin/builder/templates/FormScreenConfig.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { FormScreenDefinition, FormFieldDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface FormScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "form" };
|
||||
onUpdate: (updates: Partial<FormScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
|
||||
const formScreen = screen as FormScreenDefinition & { position: any };
|
||||
|
||||
const updateField = (index: number, updates: Partial<FormFieldDefinition>) => {
|
||||
const newFields = [...(formScreen.fields || [])];
|
||||
newFields[index] = { ...newFields[index], ...updates };
|
||||
onUpdate({ fields: newFields });
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
const newField: FormFieldDefinition = {
|
||||
id: `field_${Date.now()}`,
|
||||
label: "New Field",
|
||||
placeholder: "Enter value",
|
||||
type: "text",
|
||||
required: true,
|
||||
};
|
||||
|
||||
onUpdate({
|
||||
fields: [...(formScreen.fields || []), newField]
|
||||
});
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
const newFields = formScreen.fields?.filter((_, i) => i !== index) || [];
|
||||
onUpdate({ fields: newFields });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<TextInput
|
||||
placeholder="Enter your details"
|
||||
value={formScreen.title?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
title: {
|
||||
...formScreen.title,
|
||||
text: value,
|
||||
font: formScreen.title?.font || "manrope",
|
||||
weight: formScreen.title?.weight || "bold",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtitle Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Subtitle (Optional)</label>
|
||||
<TextInput
|
||||
placeholder="Please fill in all fields"
|
||||
value={formScreen.subtitle?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
subtitle: value ? {
|
||||
text: value,
|
||||
font: formScreen.subtitle?.font || "inter",
|
||||
weight: formScreen.subtitle?.weight || "medium",
|
||||
color: formScreen.subtitle?.color || "muted",
|
||||
} : undefined
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form Fields Configuration */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Form Fields</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addField}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
Add Field
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{formScreen.fields?.map((field, index) => (
|
||||
<div key={index} className="rounded border border-border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Field {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeField(index)}
|
||||
className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Field ID</label>
|
||||
<TextInput
|
||||
placeholder="field_id"
|
||||
value={field.id}
|
||||
onChange={(value) => updateField(index, { id: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Type</label>
|
||||
<select
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={field.type}
|
||||
onChange={(e) => updateField(index, { type: e.target.value as any })}
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="tel">Phone</option>
|
||||
<option value="url">URL</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Label</label>
|
||||
<TextInput
|
||||
placeholder="Field Label"
|
||||
value={field.label}
|
||||
onChange={(value) => updateField(index, { label: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Placeholder</label>
|
||||
<TextInput
|
||||
placeholder="Enter placeholder"
|
||||
value={field.placeholder || ""}
|
||||
onChange={(value) => updateField(index, { placeholder: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required || false}
|
||||
onChange={(e) => updateField(index, { required: e.target.checked })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
|
||||
{field.maxLength && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Max Length:</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-16 rounded border border-border bg-background px-2 py-1 text-xs"
|
||||
value={field.maxLength}
|
||||
onChange={(e) => updateField(index, { maxLength: parseInt(e.target.value) || undefined })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!formScreen.fields || formScreen.fields.length === 0) && (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
No fields added yet. Click "Add Field" to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Button */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Button Text</label>
|
||||
<TextInput
|
||||
placeholder="Continue"
|
||||
value={formScreen.bottomActionButton?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
bottomActionButton: {
|
||||
text: value || "Continue",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/components/admin/builder/templates/InfoScreenConfig.tsx
Normal file
158
src/components/admin/builder/templates/InfoScreenConfig.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface InfoScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "info" };
|
||||
onUpdate: (updates: Partial<InfoScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
|
||||
const infoScreen = screen as InfoScreenDefinition & { position: any };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<TextInput
|
||||
placeholder="Enter screen title"
|
||||
value={infoScreen.title?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
title: {
|
||||
...infoScreen.title,
|
||||
text: value,
|
||||
font: infoScreen.title?.font || "manrope",
|
||||
weight: infoScreen.title?.weight || "bold",
|
||||
align: infoScreen.title?.align || "center",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={infoScreen.title?.font || "manrope"}
|
||||
onChange={(e) => onUpdate({
|
||||
title: {
|
||||
...infoScreen.title,
|
||||
text: infoScreen.title?.text || "",
|
||||
font: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="manrope">Manrope</option>
|
||||
<option value="inter">Inter</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={infoScreen.title?.weight || "bold"}
|
||||
onChange={(e) => onUpdate({
|
||||
title: {
|
||||
...infoScreen.title,
|
||||
text: infoScreen.title?.text || "",
|
||||
weight: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="bold">Bold</option>
|
||||
<option value="semibold">Semibold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description (Optional)</label>
|
||||
<TextInput
|
||||
placeholder="Enter screen description"
|
||||
value={infoScreen.description?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
description: value ? {
|
||||
text: value,
|
||||
font: infoScreen.description?.font || "inter",
|
||||
weight: infoScreen.description?.weight || "medium",
|
||||
align: infoScreen.description?.align || "center",
|
||||
} : undefined
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Icon (Optional)</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={infoScreen.icon?.type || "emoji"}
|
||||
onChange={(e) => onUpdate({
|
||||
icon: infoScreen.icon ? {
|
||||
...infoScreen.icon,
|
||||
type: e.target.value as "emoji" | "image",
|
||||
} : {
|
||||
type: e.target.value as "emoji" | "image",
|
||||
value: "❤️",
|
||||
size: "lg",
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="emoji">Emoji</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={infoScreen.icon?.size || "lg"}
|
||||
onChange={(e) => onUpdate({
|
||||
icon: infoScreen.icon ? {
|
||||
...infoScreen.icon,
|
||||
size: e.target.value as any,
|
||||
} : {
|
||||
type: "emoji",
|
||||
value: "❤️",
|
||||
size: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="sm">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
<option value="xl">Extra Large</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
placeholder={infoScreen.icon?.type === "image" ? "Image URL" : "Emoji (e.g., ❤️)"}
|
||||
value={infoScreen.icon?.value || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
icon: value ? {
|
||||
type: infoScreen.icon?.type || "emoji",
|
||||
value,
|
||||
size: infoScreen.icon?.size || "lg",
|
||||
} : undefined
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Button */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Button Text (Optional)</label>
|
||||
<TextInput
|
||||
placeholder="Next"
|
||||
value={infoScreen.bottomActionButton?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
bottomActionButton: value ? {
|
||||
text: value,
|
||||
} : undefined
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/components/admin/builder/templates/TemplateConfig.tsx
Normal file
80
src/components/admin/builder/templates/TemplateConfig.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { InfoScreenConfig } from "./InfoScreenConfig";
|
||||
import { DateScreenConfig } from "./DateScreenConfig";
|
||||
import { CouponScreenConfig } from "./CouponScreenConfig";
|
||||
import { FormScreenConfig } from "./FormScreenConfig";
|
||||
import { TextScreenConfig } from "./TextScreenConfig";
|
||||
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { ScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface TemplateConfigProps {
|
||||
screen: BuilderScreen;
|
||||
onUpdate: (updates: Partial<ScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
|
||||
const { template } = screen;
|
||||
|
||||
switch (template) {
|
||||
case "info":
|
||||
return (
|
||||
<InfoScreenConfig
|
||||
screen={screen as any}
|
||||
onUpdate={onUpdate as any}
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<DateScreenConfig
|
||||
screen={screen as any}
|
||||
onUpdate={onUpdate as any}
|
||||
/>
|
||||
);
|
||||
|
||||
case "coupon":
|
||||
return (
|
||||
<CouponScreenConfig
|
||||
screen={screen as any}
|
||||
onUpdate={onUpdate as any}
|
||||
/>
|
||||
);
|
||||
|
||||
case "form":
|
||||
return (
|
||||
<FormScreenConfig
|
||||
screen={screen as any}
|
||||
onUpdate={onUpdate as any}
|
||||
/>
|
||||
);
|
||||
|
||||
case "text":
|
||||
return (
|
||||
<TextScreenConfig
|
||||
screen={screen as any}
|
||||
onUpdate={onUpdate as any}
|
||||
/>
|
||||
);
|
||||
|
||||
case "list":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
List template configuration is available in the existing sidebar.
|
||||
This is a legacy template that will be updated soon.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-red-600">
|
||||
Unknown template type: {template}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
198
src/components/admin/builder/templates/TextScreenConfig.tsx
Normal file
198
src/components/admin/builder/templates/TextScreenConfig.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
import type { TextScreenDefinition } from "@/lib/funnel/types";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
interface TextScreenConfigProps {
|
||||
screen: BuilderScreen & { template: "text" };
|
||||
onUpdate: (updates: Partial<TextScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
|
||||
const textScreen = screen as TextScreenDefinition & { position: any };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Title Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<TextInput
|
||||
placeholder="Enter screen title"
|
||||
value={textScreen.title?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
title: {
|
||||
...textScreen.title,
|
||||
text: value,
|
||||
font: textScreen.title?.font || "manrope",
|
||||
weight: textScreen.title?.weight || "bold",
|
||||
align: textScreen.title?.align || "center",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={textScreen.title?.font || "manrope"}
|
||||
onChange={(e) => onUpdate({
|
||||
title: {
|
||||
...textScreen.title,
|
||||
text: textScreen.title?.text || "",
|
||||
font: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="manrope">Manrope</option>
|
||||
<option value="inter">Inter</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={textScreen.title?.weight || "bold"}
|
||||
onChange={(e) => onUpdate({
|
||||
title: {
|
||||
...textScreen.title,
|
||||
text: textScreen.title?.text || "",
|
||||
weight: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="bold">Bold</option>
|
||||
<option value="semibold">Semibold</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={textScreen.title?.align || "center"}
|
||||
onChange={(e) => onUpdate({
|
||||
title: {
|
||||
...textScreen.title,
|
||||
text: textScreen.title?.text || "",
|
||||
align: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Configuration */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Content</label>
|
||||
<textarea
|
||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm min-h-[100px] resize-y"
|
||||
placeholder="Enter the main content text. This can be multiple paragraphs, statistics, or any text content you want to display."
|
||||
value={textScreen.content?.text || ""}
|
||||
onChange={(e) => onUpdate({
|
||||
content: {
|
||||
...textScreen.content,
|
||||
text: e.target.value,
|
||||
font: textScreen.content?.font || "inter",
|
||||
weight: textScreen.content?.weight || "medium",
|
||||
color: textScreen.content?.color || "default",
|
||||
align: textScreen.content?.align || "center",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Font</label>
|
||||
<select
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={textScreen.content?.font || "inter"}
|
||||
onChange={(e) => onUpdate({
|
||||
content: {
|
||||
...textScreen.content,
|
||||
text: textScreen.content?.text || "",
|
||||
font: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="manrope">Manrope</option>
|
||||
<option value="inter">Inter</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Weight</label>
|
||||
<select
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={textScreen.content?.weight || "medium"}
|
||||
onChange={(e) => onUpdate({
|
||||
content: {
|
||||
...textScreen.content,
|
||||
text: textScreen.content?.text || "",
|
||||
weight: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="bold">Bold</option>
|
||||
<option value="semibold">Semibold</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Color</label>
|
||||
<select
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={textScreen.content?.color || "default"}
|
||||
onChange={(e) => onUpdate({
|
||||
content: {
|
||||
...textScreen.content,
|
||||
text: textScreen.content?.text || "",
|
||||
color: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="muted">Muted</option>
|
||||
<option value="accent">Accent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Align</label>
|
||||
<select
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
|
||||
value={textScreen.content?.align || "center"}
|
||||
onChange={(e) => onUpdate({
|
||||
content: {
|
||||
...textScreen.content,
|
||||
text: textScreen.content?.text || "",
|
||||
align: e.target.value as any,
|
||||
}
|
||||
})}
|
||||
>
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Button */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Button Text (Optional)</label>
|
||||
<TextInput
|
||||
placeholder="Next"
|
||||
value={textScreen.bottomActionButton?.text || ""}
|
||||
onChange={(value) => onUpdate({
|
||||
bottomActionButton: value ? {
|
||||
text: value,
|
||||
} : undefined
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/components/admin/builder/templates/index.ts
Normal file
6
src/components/admin/builder/templates/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { InfoScreenConfig } from "./InfoScreenConfig";
|
||||
export { DateScreenConfig } from "./DateScreenConfig";
|
||||
export { CouponScreenConfig } from "./CouponScreenConfig";
|
||||
export { FormScreenConfig } from "./FormScreenConfig";
|
||||
export { TextScreenConfig } from "./TextScreenConfig";
|
||||
export { TemplateConfig } from "./TemplateConfig";
|
||||
328
src/components/funnel/FunnelRuntime.tsx
Normal file
328
src/components/funnel/FunnelRuntime.tsx
Normal file
@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ListTemplate } from "@/components/funnel/templates/ListTemplate";
|
||||
import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate";
|
||||
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
|
||||
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
||||
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
|
||||
import { TextTemplate } from "@/components/funnel/templates/TextTemplate";
|
||||
import { resolveNextScreenId } from "@/lib/funnel/navigation";
|
||||
import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider";
|
||||
import type {
|
||||
FunnelDefinition,
|
||||
InfoScreenDefinition,
|
||||
DateScreenDefinition,
|
||||
CouponScreenDefinition,
|
||||
FormScreenDefinition,
|
||||
TextScreenDefinition,
|
||||
ListScreenDefinition,
|
||||
ScreenDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
|
||||
interface FunnelRuntimeProps {
|
||||
funnel: FunnelDefinition;
|
||||
initialScreenId: string;
|
||||
}
|
||||
|
||||
type TemplateComponentProps = {
|
||||
screen: ScreenDefinition;
|
||||
selectedOptionIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress: { current: number; total: number };
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
};
|
||||
|
||||
type TemplateRenderer = (props: TemplateComponentProps) => JSX.Element;
|
||||
|
||||
const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer> = {
|
||||
info: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
|
||||
const infoScreen = screen as InfoScreenDefinition;
|
||||
|
||||
return (
|
||||
<InfoTemplate
|
||||
screen={infoScreen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
date: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
|
||||
const dateScreen = screen as DateScreenDefinition;
|
||||
|
||||
// For date screens, we store date components as array: [month, day, year]
|
||||
const currentDateArray = selectedOptionIds;
|
||||
const selectedDate = {
|
||||
month: currentDateArray[0] || "",
|
||||
day: currentDateArray[1] || "",
|
||||
year: currentDateArray[2] || "",
|
||||
};
|
||||
|
||||
const handleDateChange = (date: { month?: string; day?: string; year?: string }) => {
|
||||
const dateArray = [date.month || "", date.day || "", date.year || ""];
|
||||
onSelectionChange(dateArray);
|
||||
};
|
||||
|
||||
return (
|
||||
<DateTemplate
|
||||
screen={dateScreen}
|
||||
selectedDate={selectedDate}
|
||||
onDateChange={handleDateChange}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
screenProgress={screenProgress}
|
||||
defaultTexts={defaultTexts}
|
||||
/>
|
||||
);
|
||||
},
|
||||
coupon: ({ screen, onContinue, canGoBack, onBack }) => {
|
||||
const couponScreen = screen as CouponScreenDefinition;
|
||||
|
||||
return (
|
||||
<CouponTemplate
|
||||
screen={couponScreen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
},
|
||||
form: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack }) => {
|
||||
const formScreen = screen as FormScreenDefinition;
|
||||
|
||||
// For form screens, we store form data as JSON string in the first element
|
||||
const formDataJson = selectedOptionIds[0] || "{}";
|
||||
let formData: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
formData = JSON.parse(formDataJson);
|
||||
} catch {
|
||||
formData = {};
|
||||
}
|
||||
|
||||
const handleFormDataChange = (data: Record<string, string>) => {
|
||||
const dataJson = JSON.stringify(data);
|
||||
onSelectionChange([dataJson]);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormTemplate
|
||||
screen={formScreen}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormDataChange}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
},
|
||||
text: ({ screen, onContinue, canGoBack, onBack }) => {
|
||||
const textScreen = screen as TextScreenDefinition;
|
||||
|
||||
return (
|
||||
<TextTemplate
|
||||
screen={textScreen}
|
||||
onContinue={onContinue}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
},
|
||||
list: ({
|
||||
screen,
|
||||
selectedOptionIds,
|
||||
onSelectionChange,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
}) => {
|
||||
const listScreen = screen as ListScreenDefinition;
|
||||
const selectionType = listScreen.list.selectionType;
|
||||
const actionConfig =
|
||||
listScreen.list.bottomActionButton ??
|
||||
(selectionType === "multi" ? { text: "Next" } : undefined);
|
||||
const hasActionButton = Boolean(actionConfig);
|
||||
const isSelectionEmpty = selectedOptionIds.length === 0;
|
||||
|
||||
const showGradient = true;
|
||||
const actionDisabled = hasActionButton && isSelectionEmpty;
|
||||
|
||||
return (
|
||||
<ListTemplate
|
||||
screen={listScreen}
|
||||
selectedOptionIds={selectedOptionIds}
|
||||
onSelectionChange={onSelectionChange}
|
||||
actionButtonProps={hasActionButton
|
||||
? {
|
||||
children: actionConfig?.text ?? "Next",
|
||||
disabled: actionDisabled,
|
||||
onClick: actionDisabled ? undefined : onContinue,
|
||||
}
|
||||
: undefined}
|
||||
showGradient={showGradient}
|
||||
canGoBack={canGoBack}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function getScreenById(funnel: FunnelDefinition, screenId: string) {
|
||||
return funnel.screens.find((screen) => screen.id === screenId);
|
||||
}
|
||||
|
||||
function calculateScreenProgress(
|
||||
currentScreenId: string,
|
||||
funnel: FunnelDefinition,
|
||||
answers: Record<string, string[]>
|
||||
): { current: number; total: number } {
|
||||
// Total is always the same - total number of screens in funnel
|
||||
const total = funnel.screens.length;
|
||||
|
||||
// Find current screen index in the screens array
|
||||
const currentIndex = funnel.screens.findIndex(screen => screen.id === currentScreenId);
|
||||
const current = currentIndex >= 0 ? currentIndex + 1 : 1;
|
||||
|
||||
return {
|
||||
current,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentTemplateRenderer(screen: ScreenDefinition): TemplateRenderer {
|
||||
const renderer = TEMPLATE_REGISTRY[screen.template];
|
||||
if (!renderer) {
|
||||
throw new Error(`Unsupported template: ${screen.template}`);
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
||||
const router = useRouter();
|
||||
const { answers, registerScreen, setAnswers, history } = useFunnelRuntime(
|
||||
funnel.meta.id
|
||||
);
|
||||
|
||||
const currentScreen = useMemo(() => {
|
||||
return getScreenById(funnel, initialScreenId) ?? funnel.screens[0];
|
||||
}, [funnel, initialScreenId]);
|
||||
|
||||
const selectedOptionIds = answers[currentScreen.id] ?? [];
|
||||
|
||||
// Calculate automatic progress
|
||||
const screenProgress = useMemo(() => {
|
||||
return calculateScreenProgress(currentScreen.id, funnel, answers);
|
||||
}, [currentScreen.id, funnel, answers]);
|
||||
|
||||
useEffect(() => {
|
||||
registerScreen(currentScreen.id);
|
||||
}, [currentScreen.id, registerScreen]);
|
||||
|
||||
const historyWithCurrent = useMemo(() => {
|
||||
if (history.length === 0) {
|
||||
return [currentScreen.id];
|
||||
}
|
||||
|
||||
const last = history[history.length - 1];
|
||||
if (last === currentScreen.id) {
|
||||
return history;
|
||||
}
|
||||
|
||||
const existingIndex = history.lastIndexOf(currentScreen.id);
|
||||
if (existingIndex >= 0) {
|
||||
return history.slice(0, existingIndex + 1);
|
||||
}
|
||||
|
||||
return [...history, currentScreen.id];
|
||||
}, [history, currentScreen.id]);
|
||||
|
||||
const goToScreen = (screenId: string | undefined) => {
|
||||
if (!screenId) {
|
||||
return;
|
||||
}
|
||||
router.push(`/${funnel.meta.id}/${screenId}`);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
const nextScreenId = resolveNextScreenId(currentScreen, answers, funnel.screens);
|
||||
goToScreen(nextScreenId);
|
||||
};
|
||||
|
||||
const handleSelectionChange = (ids: string[]) => {
|
||||
const prevSelectedIds = selectedOptionIds;
|
||||
const hasChanged =
|
||||
prevSelectedIds.length !== ids.length ||
|
||||
prevSelectedIds.some((value, index) => value !== ids[index]);
|
||||
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAnswers = {
|
||||
...answers,
|
||||
[currentScreen.id]: ids,
|
||||
} as typeof answers;
|
||||
|
||||
if (ids.length === 0) {
|
||||
delete nextAnswers[currentScreen.id];
|
||||
}
|
||||
|
||||
setAnswers(currentScreen.id, ids);
|
||||
|
||||
// Auto-advance only applies to list screens with single selection
|
||||
if (currentScreen.template === "list") {
|
||||
const listScreen = currentScreen as ListScreenDefinition;
|
||||
const selectionType = listScreen.list.selectionType;
|
||||
const hasActionButton = Boolean(
|
||||
listScreen.list.bottomActionButton ??
|
||||
(selectionType === "multi" ? { text: "Next" } : undefined)
|
||||
);
|
||||
|
||||
if (selectionType === "single" && !hasActionButton && ids.length > 0) {
|
||||
const nextScreenId = resolveNextScreenId(currentScreen, nextAnswers, funnel.screens);
|
||||
goToScreen(nextScreenId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onBack = () => {
|
||||
const currentIndex = historyWithCurrent.lastIndexOf(currentScreen.id);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
goToScreen(historyWithCurrent[currentIndex - 1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (historyWithCurrent.length > 1) {
|
||||
goToScreen(historyWithCurrent[historyWithCurrent.length - 2]);
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
};
|
||||
|
||||
const TemplateComponent = getCurrentTemplateRenderer(currentScreen);
|
||||
|
||||
const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0;
|
||||
|
||||
return TemplateComponent({
|
||||
screen: currentScreen,
|
||||
selectedOptionIds,
|
||||
onSelectionChange: handleSelectionChange,
|
||||
onContinue: handleContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts: funnel.defaultTexts,
|
||||
});
|
||||
}
|
||||
187
src/components/funnel/templates/CouponTemplate.tsx
Normal file
187
src/components/funnel/templates/CouponTemplate.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
||||
import { Coupon } from "@/components/widgets/Coupon/Coupon";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import {
|
||||
buildHeaderProgress,
|
||||
buildTypographyProps,
|
||||
shouldShowBackButton,
|
||||
shouldShowHeader,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { CouponScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface CouponTemplateProps {
|
||||
screen: CouponScreenDefinition;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
export function CouponTemplate({
|
||||
screen,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
defaultTexts,
|
||||
}: CouponTemplateProps) {
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
|
||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||
const showHeader = shouldShowHeader(screen.header);
|
||||
|
||||
const handleCopyPromoCode = (code: string) => {
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedCode(code);
|
||||
|
||||
// Reset copied state after 2 seconds
|
||||
setTimeout(() => {
|
||||
setCopiedCode(null);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
|
||||
? {
|
||||
children: screen.bottomActionButton.text,
|
||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
||||
onClick: onContinue,
|
||||
}
|
||||
: {
|
||||
children: defaultTexts?.continueButton || "Continue",
|
||||
onClick: onContinue,
|
||||
};
|
||||
|
||||
const bottomActionButtonProps: BottomActionButtonProps = {
|
||||
actionButtonProps,
|
||||
};
|
||||
|
||||
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
|
||||
headerProps: showHeader ? {
|
||||
progressProps: buildHeaderProgress(screen.header?.progress),
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
} : undefined,
|
||||
title:
|
||||
buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
align: "center",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
},
|
||||
subtitle: screen.subtitle ? buildTypographyProps(screen.subtitle, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "muted",
|
||||
align: "center",
|
||||
},
|
||||
}) : undefined,
|
||||
bottomActionButtonProps,
|
||||
};
|
||||
|
||||
// Build coupon props from screen definition
|
||||
const couponProps = {
|
||||
title: buildTypographyProps(screen.coupon.title, {
|
||||
as: "h3" as const,
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
color: "primary",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h3" as const,
|
||||
children: screen.coupon.title.text,
|
||||
},
|
||||
offer: {
|
||||
title: buildTypographyProps(screen.coupon.offer.title, {
|
||||
as: "h3" as const,
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "black",
|
||||
color: "card",
|
||||
size: "4xl",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h3" as const,
|
||||
children: screen.coupon.offer.title.text,
|
||||
},
|
||||
description: buildTypographyProps(screen.coupon.offer.description, {
|
||||
as: "p" as const,
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "semiBold",
|
||||
color: "card",
|
||||
},
|
||||
}) ?? {
|
||||
as: "p" as const,
|
||||
children: screen.coupon.offer.description.text,
|
||||
},
|
||||
},
|
||||
promoCode: buildTypographyProps(screen.coupon.promoCode, {
|
||||
as: "span" as const,
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "semiBold",
|
||||
},
|
||||
}) ?? {
|
||||
as: "span" as const,
|
||||
children: screen.coupon.promoCode.text,
|
||||
},
|
||||
footer: buildTypographyProps(screen.coupon.footer, {
|
||||
as: "p" as const,
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "muted",
|
||||
size: "sm",
|
||||
},
|
||||
}) ?? {
|
||||
as: "p" as const,
|
||||
children: screen.coupon.footer.text,
|
||||
},
|
||||
onCopyPromoCode: handleCopyPromoCode,
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full flex flex-col items-center justify-center mt-[30px]">
|
||||
{/* Coupon Widget */}
|
||||
<div className="mb-8">
|
||||
<Coupon {...couponProps} />
|
||||
</div>
|
||||
|
||||
{/* Copy Success Message */}
|
||||
{copiedCode && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<Typography
|
||||
as="p"
|
||||
size="sm"
|
||||
color="success"
|
||||
weight="medium"
|
||||
align="center"
|
||||
>
|
||||
{screen.copiedMessage
|
||||
? screen.copiedMessage.replace("{code}", copiedCode || "")
|
||||
: `Промокод "${copiedCode}" скопирован!`
|
||||
}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
315
src/components/funnel/templates/DateTemplate.tsx
Normal file
315
src/components/funnel/templates/DateTemplate.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import NextImage from "next/image";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import {
|
||||
buildHeaderProgress,
|
||||
buildTypographyProps,
|
||||
shouldShowBackButton,
|
||||
shouldShowHeader,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { DateScreenDefinition } from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateTemplateProps {
|
||||
screen: DateScreenDefinition;
|
||||
selectedDate: { month?: string; day?: string; year?: string };
|
||||
onDateChange: (date: { month?: string; day?: string; year?: string }) => void;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress?: { current: number; total: number };
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
];
|
||||
|
||||
// Generate options for selects
|
||||
const generateMonthOptions = () => {
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const value = (i + 1).toString();
|
||||
return { value, label: value.padStart(2, '0') };
|
||||
});
|
||||
};
|
||||
|
||||
const generateDayOptions = (month: string, year: string) => {
|
||||
const monthNum = parseInt(month) || 1;
|
||||
const yearNum = parseInt(year) || new Date().getFullYear();
|
||||
const daysInMonth = new Date(yearNum, monthNum, 0).getDate();
|
||||
|
||||
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const value = (i + 1).toString();
|
||||
return { value, label: value.padStart(2, '0') };
|
||||
});
|
||||
};
|
||||
|
||||
const generateYearOptions = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startYear = 1920;
|
||||
const endYear = currentYear + 1;
|
||||
|
||||
const years = [];
|
||||
for (let year = endYear; year >= startYear; year--) {
|
||||
years.push({ value: year.toString(), label: year.toString() });
|
||||
}
|
||||
return years;
|
||||
};
|
||||
|
||||
export function DateTemplate({
|
||||
screen,
|
||||
selectedDate,
|
||||
onDateChange,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: DateTemplateProps) {
|
||||
const [month, setMonth] = useState(selectedDate.month || "");
|
||||
const [day, setDay] = useState(selectedDate.day || "");
|
||||
const [year, setYear] = useState(selectedDate.year || "");
|
||||
|
||||
// Generate options with memoization
|
||||
const monthOptions = useMemo(() => generateMonthOptions(), []);
|
||||
const dayOptions = useMemo(() => generateDayOptions(month, year), [month, year]);
|
||||
const yearOptions = useMemo(() => generateYearOptions(), []);
|
||||
|
||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||
const showHeader = shouldShowHeader(screen.header);
|
||||
|
||||
// Custom Select component matching TextInput styling
|
||||
const SelectInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left",
|
||||
"bg-white border border-slate-200 rounded-xl",
|
||||
"text-slate-900 placeholder:text-slate-400",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
||||
"transition-colors duration-200",
|
||||
"appearance-none cursor-pointer",
|
||||
"bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iNiIgdmlld0JveD0iMCAwIDEwIDYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik05IDFMNS4wMDAwNyA1TDEgMSIgc3Ryb2tlPSIjNjQ3NDhCIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=')] bg-no-repeat bg-right-3 bg-center",
|
||||
"pr-10"
|
||||
)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Update parent when local state changes
|
||||
useEffect(() => {
|
||||
onDateChange({ month, day, year });
|
||||
}, [month, day, year, onDateChange]);
|
||||
|
||||
// Reset day if it's invalid for the selected month/year
|
||||
useEffect(() => {
|
||||
if (month && year && day) {
|
||||
const monthNum = parseInt(month);
|
||||
const yearNum = parseInt(year);
|
||||
const dayNum = parseInt(day);
|
||||
const daysInMonth = new Date(yearNum, monthNum, 0).getDate();
|
||||
|
||||
if (dayNum > daysInMonth) {
|
||||
setDay("");
|
||||
}
|
||||
}
|
||||
}, [month, year, day]);
|
||||
|
||||
// Sync with external state
|
||||
useEffect(() => {
|
||||
setMonth(selectedDate.month || "");
|
||||
setDay(selectedDate.day || "");
|
||||
setYear(selectedDate.year || "");
|
||||
}, [selectedDate]);
|
||||
|
||||
const isComplete = month && day && year;
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!month || !day || !year) return null;
|
||||
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
const yearNum = parseInt(year);
|
||||
|
||||
if (monthNum >= 1 && monthNum <= 12 && dayNum >= 1 && dayNum <= 31) {
|
||||
const monthName = MONTH_NAMES[monthNum - 1];
|
||||
return `${monthName} ${dayNum}, ${yearNum}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [month, day, year]);
|
||||
|
||||
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
|
||||
? {
|
||||
children: screen.bottomActionButton.text,
|
||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
||||
onClick: onContinue,
|
||||
disabled: !isComplete,
|
||||
}
|
||||
: {
|
||||
children: defaultTexts?.nextButton || "Next",
|
||||
onClick: onContinue,
|
||||
disabled: !isComplete,
|
||||
};
|
||||
|
||||
const bottomActionButtonProps: BottomActionButtonProps = {
|
||||
actionButtonProps,
|
||||
};
|
||||
|
||||
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
|
||||
headerProps: showHeader ? {
|
||||
progressProps: screenProgress ? buildHeaderProgress({
|
||||
current: screenProgress.current,
|
||||
total: screenProgress.total,
|
||||
label: `${screenProgress.current} of ${screenProgress.total}`
|
||||
}) : buildHeaderProgress(screen.header?.progress),
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
} : undefined,
|
||||
title:
|
||||
buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
align: "left",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
},
|
||||
subtitle: screen.subtitle ? buildTypographyProps(screen.subtitle, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "muted",
|
||||
align: "left",
|
||||
},
|
||||
}) : undefined,
|
||||
bottomActionButtonProps,
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full mt-[30px] space-y-6">
|
||||
{/* Date Input Fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-[1fr_1fr_1.2fr] gap-3">
|
||||
<SelectInput
|
||||
label={screen.dateInput.monthLabel || "Month"}
|
||||
placeholder={screen.dateInput.monthPlaceholder || "MM"}
|
||||
value={month}
|
||||
onChange={setMonth}
|
||||
options={monthOptions}
|
||||
/>
|
||||
<SelectInput
|
||||
label={screen.dateInput.dayLabel || "Day"}
|
||||
placeholder={screen.dateInput.dayPlaceholder || "DD"}
|
||||
value={day}
|
||||
onChange={setDay}
|
||||
options={dayOptions}
|
||||
/>
|
||||
<SelectInput
|
||||
label={screen.dateInput.yearLabel || "Year"}
|
||||
placeholder={screen.dateInput.yearPlaceholder || "YYYY"}
|
||||
value={year}
|
||||
onChange={setYear}
|
||||
options={yearOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Message */}
|
||||
{screen.infoMessage && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<NextImage
|
||||
src="/GuardIcon.svg"
|
||||
alt="Security icon"
|
||||
width={20}
|
||||
height={20}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<Typography
|
||||
as="p"
|
||||
size="sm"
|
||||
color="default"
|
||||
{...buildTypographyProps(screen.infoMessage, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "default",
|
||||
align: "left",
|
||||
},
|
||||
})}
|
||||
className={cn("text-slate-600 leading-relaxed", screen.infoMessage.className)}
|
||||
>
|
||||
{screen.infoMessage.text}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Selected Date Display - positioned 18px above button with high z-index */}
|
||||
{screen.dateInput.showSelectedDate && formattedDate && (
|
||||
<div className="fixed bottom-[98px] left-0 right-0 text-center z-50">
|
||||
<div className="max-w-[560px] mx-auto px-6">
|
||||
<Typography
|
||||
as="p"
|
||||
className="text-[#64748B] text-[16px] font-normal leading-normal mb-1"
|
||||
>
|
||||
{screen.dateInput.selectedDateLabel || "Selected date:"}
|
||||
</Typography>
|
||||
<Typography
|
||||
as="p"
|
||||
className="text-[#1E293B] text-[18px] font-semibold leading-normal"
|
||||
>
|
||||
{formattedDate}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
194
src/components/funnel/templates/FormTemplate.tsx
Normal file
194
src/components/funnel/templates/FormTemplate.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||
|
||||
import {
|
||||
buildHeaderProgress,
|
||||
buildTypographyProps,
|
||||
shouldShowBackButton,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { FormScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface FormTemplateProps {
|
||||
screen: FormScreenDefinition;
|
||||
formData: Record<string, string>;
|
||||
onFormDataChange: (data: Record<string, string>) => void;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
export function FormTemplate({
|
||||
screen,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
defaultTexts,
|
||||
}: FormTemplateProps) {
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||
|
||||
// Sync with external form data
|
||||
useEffect(() => {
|
||||
setLocalFormData(formData);
|
||||
}, [formData]);
|
||||
|
||||
// Update parent when local data changes
|
||||
useEffect(() => {
|
||||
onFormDataChange(localFormData);
|
||||
}, [localFormData, onFormDataChange]);
|
||||
|
||||
const validateField = (fieldId: string, value: string) => {
|
||||
const field = screen.fields.find(f => f.id === fieldId);
|
||||
if (!field) return "";
|
||||
|
||||
const messages = screen.validationMessages;
|
||||
|
||||
// Check required
|
||||
if (field.required && !value.trim()) {
|
||||
const template = messages?.required || "${field} is required";
|
||||
return template.replace("${field}", field.label || field.id);
|
||||
}
|
||||
|
||||
// Check max length
|
||||
if (field.maxLength && value.length > field.maxLength) {
|
||||
const template = messages?.maxLength || "Maximum ${maxLength} characters allowed";
|
||||
return template.replace("${maxLength}", field.maxLength.toString());
|
||||
}
|
||||
|
||||
// Check validation pattern
|
||||
if (field.validation?.pattern && value.trim()) {
|
||||
const regex = new RegExp(field.validation.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return field.validation.message || messages?.invalidFormat || "Invalid format";
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldId: string, value: string) => {
|
||||
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[fieldId]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldId];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
screen.fields.forEach(field => {
|
||||
const value = localFormData[field.id] || "";
|
||||
const error = validateField(field.id, value);
|
||||
if (error) {
|
||||
newErrors[field.id] = error;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (validateForm()) {
|
||||
onContinue();
|
||||
}
|
||||
};
|
||||
|
||||
// Check if form is complete (all required fields filled)
|
||||
const isFormComplete = screen.fields.every(field => {
|
||||
if (!field.required) return true;
|
||||
const value = localFormData[field.id] || "";
|
||||
return value.trim().length > 0;
|
||||
});
|
||||
|
||||
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
|
||||
? {
|
||||
children: screen.bottomActionButton.text,
|
||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
||||
onClick: handleContinue,
|
||||
disabled: !isFormComplete,
|
||||
}
|
||||
: {
|
||||
children: defaultTexts?.continueButton || "Continue",
|
||||
onClick: handleContinue,
|
||||
disabled: !isFormComplete,
|
||||
};
|
||||
|
||||
const bottomActionButtonProps: BottomActionButtonProps = {
|
||||
actionButtonProps,
|
||||
};
|
||||
|
||||
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
|
||||
headerProps: {
|
||||
progressProps: buildHeaderProgress(screen.header?.progress),
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
},
|
||||
title:
|
||||
buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
align: "left",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
},
|
||||
subtitle: screen.subtitle ? buildTypographyProps(screen.subtitle, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "muted",
|
||||
align: "left",
|
||||
},
|
||||
}) : undefined,
|
||||
bottomActionButtonProps,
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full mt-[30px] space-y-4">
|
||||
{screen.fields.map((field) => (
|
||||
<div key={field.id}>
|
||||
<TextInput
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
type={field.type || "text"}
|
||||
value={localFormData[field.id] || ""}
|
||||
onChange={(e) => handleFieldChange(field.id, e.target.value)}
|
||||
maxLength={field.maxLength}
|
||||
aria-invalid={!!errors[field.id]}
|
||||
aria-errormessage={errors[field.id]}
|
||||
/>
|
||||
{errors[field.id] && (
|
||||
<p className="text-destructive font-inter font-medium text-xs mt-1">
|
||||
{errors[field.id]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
157
src/components/funnel/templates/InfoTemplate.tsx
Normal file
157
src/components/funnel/templates/InfoTemplate.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import {
|
||||
buildLayoutQuestionProps,
|
||||
buildHeaderProgress,
|
||||
buildTypographyProps,
|
||||
shouldShowBackButton,
|
||||
shouldShowHeader,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InfoTemplateProps {
|
||||
screen: InfoScreenDefinition;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
screenProgress?: { current: number; total: number };
|
||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||
}
|
||||
|
||||
export function InfoTemplate({
|
||||
screen,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
screenProgress,
|
||||
defaultTexts,
|
||||
}: InfoTemplateProps) {
|
||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||
const showHeader = shouldShowHeader(screen.header);
|
||||
|
||||
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
|
||||
? {
|
||||
children: screen.bottomActionButton.text,
|
||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
||||
onClick: onContinue,
|
||||
}
|
||||
: {
|
||||
children: defaultTexts?.nextButton || "Next",
|
||||
onClick: onContinue,
|
||||
};
|
||||
|
||||
const bottomActionButtonProps: BottomActionButtonProps = {
|
||||
actionButtonProps,
|
||||
};
|
||||
|
||||
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
|
||||
headerProps: showHeader ? {
|
||||
progressProps: screenProgress ? buildHeaderProgress({
|
||||
current: screenProgress.current,
|
||||
total: screenProgress.total,
|
||||
label: `${screenProgress.current} of ${screenProgress.total}`
|
||||
}) : buildHeaderProgress(screen.header?.progress),
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
} : undefined,
|
||||
title:
|
||||
buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
align: "center",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
},
|
||||
bottomActionButtonProps,
|
||||
};
|
||||
|
||||
const iconSizeClasses = useMemo(() => {
|
||||
const size = screen.icon?.size ?? "xl";
|
||||
switch (size) {
|
||||
case "sm":
|
||||
return "text-4xl"; // 36px
|
||||
case "md":
|
||||
return "text-5xl"; // 48px
|
||||
case "lg":
|
||||
return "text-6xl"; // 60px
|
||||
case "xl":
|
||||
default:
|
||||
return "text-8xl"; // 128px
|
||||
}
|
||||
}, [screen.icon?.size]);
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full flex flex-col items-center justify-center text-center mt-[60px]">
|
||||
{/* Icon */}
|
||||
{screen.icon && (
|
||||
<div className={cn("mb-8", screen.icon.className)}>
|
||||
{screen.icon.type === "emoji" ? (
|
||||
<div className={cn(iconSizeClasses, "leading-none")}>
|
||||
{screen.icon.value}
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
src={screen.icon.value}
|
||||
alt=""
|
||||
width={
|
||||
iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}
|
||||
height={
|
||||
iconSizeClasses.includes("text-8xl") ? 128 :
|
||||
iconSizeClasses.includes("text-6xl") ? 64 :
|
||||
iconSizeClasses.includes("text-5xl") ? 48 : 36
|
||||
}
|
||||
className={cn("object-contain")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title - handled by LayoutQuestion */}
|
||||
|
||||
{/* Description */}
|
||||
{screen.description && (
|
||||
<div className="mt-6 max-w-[280px]">
|
||||
<Typography
|
||||
as="p"
|
||||
font="inter"
|
||||
weight="medium"
|
||||
color="default"
|
||||
size="lg"
|
||||
align="center"
|
||||
{...buildTypographyProps(screen.description, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "default",
|
||||
align: "center",
|
||||
},
|
||||
})}
|
||||
className={cn("leading-[26px]", screen.description.className)}
|
||||
>
|
||||
{screen.description.text}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
149
src/components/funnel/templates/ListTemplate.tsx
Normal file
149
src/components/funnel/templates/ListTemplate.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Question } from "@/components/templates/Question/Question";
|
||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
|
||||
import type { RadioAnswersListProps } from "@/components/widgets/RadioAnswersList/RadioAnswersList";
|
||||
import type { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
|
||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
||||
|
||||
import {
|
||||
buildHeaderProgress,
|
||||
buildTypographyProps,
|
||||
mapListOptionsToButtons,
|
||||
shouldShowBackButton,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { ListScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface ListTemplateProps {
|
||||
screen: ListScreenDefinition;
|
||||
selectedOptionIds: string[];
|
||||
onSelectionChange: (selectedIds: string[]) => void;
|
||||
actionButtonProps?: ActionButtonProps;
|
||||
showGradient: boolean;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function stringId(value: MainButtonProps["id"]): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function ListTemplate({
|
||||
screen,
|
||||
selectedOptionIds,
|
||||
onSelectionChange,
|
||||
actionButtonProps,
|
||||
showGradient,
|
||||
canGoBack,
|
||||
onBack,
|
||||
}: ListTemplateProps) {
|
||||
const buttons = useMemo(
|
||||
() => mapListOptionsToButtons(screen.list.options, screen.list.selectionType),
|
||||
[screen.list.options, screen.list.selectionType]
|
||||
);
|
||||
|
||||
const selectionSet = useMemo(
|
||||
() => new Set(selectedOptionIds.map((id) => String(id))),
|
||||
[selectedOptionIds]
|
||||
);
|
||||
|
||||
const contentType: "radio-answers-list" | "select-answers-list" =
|
||||
screen.list.selectionType === "multi"
|
||||
? "select-answers-list"
|
||||
: "radio-answers-list";
|
||||
|
||||
const activeAnswer: MainButtonProps | null =
|
||||
contentType === "radio-answers-list"
|
||||
? buttons.find((button) => selectionSet.has(String(button.id))) ?? null
|
||||
: null;
|
||||
|
||||
const activeAnswers: MainButtonProps[] | null =
|
||||
contentType === "select-answers-list"
|
||||
? buttons.filter((button) => selectionSet.has(String(button.id)))
|
||||
: null;
|
||||
|
||||
const handleRadioChange: RadioAnswersListProps["onChangeSelectedAnswer"] = (
|
||||
answer
|
||||
) => {
|
||||
const id = stringId(answer?.id);
|
||||
onSelectionChange(id ? [id] : []);
|
||||
};
|
||||
|
||||
const handleSelectChange: SelectAnswersListProps["onChangeSelectedAnswers"] = (
|
||||
answers
|
||||
) => {
|
||||
const ids = answers
|
||||
?.map((answer) => stringId(answer.id))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
onSelectionChange(ids ?? []);
|
||||
};
|
||||
|
||||
const radioContent: RadioAnswersListProps = {
|
||||
answers: buttons,
|
||||
activeAnswer,
|
||||
onChangeSelectedAnswer: handleRadioChange,
|
||||
};
|
||||
|
||||
const selectContent: SelectAnswersListProps = {
|
||||
answers: buttons,
|
||||
activeAnswers,
|
||||
onChangeSelectedAnswers: handleSelectChange,
|
||||
};
|
||||
|
||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||
|
||||
const effectiveBottomActionButtonProps: BottomActionButtonProps | undefined = showGradient
|
||||
? actionButtonProps
|
||||
? { actionButtonProps }
|
||||
: {}
|
||||
: undefined;
|
||||
|
||||
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
|
||||
headerProps: {
|
||||
progressProps: buildHeaderProgress(screen.header?.progress),
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
},
|
||||
title:
|
||||
buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
align: "left",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
},
|
||||
subtitle: buildTypographyProps(screen.subtitle, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "muted",
|
||||
align: "left",
|
||||
},
|
||||
}),
|
||||
bottomActionButtonProps: effectiveBottomActionButtonProps,
|
||||
};
|
||||
|
||||
const contentProps =
|
||||
contentType === "radio-answers-list" ? radioContent : selectContent;
|
||||
|
||||
return (
|
||||
<Question
|
||||
layoutQuestionProps={layoutQuestionProps}
|
||||
contentType={contentType}
|
||||
content={contentProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
97
src/components/funnel/templates/TextTemplate.tsx
Normal file
97
src/components/funnel/templates/TextTemplate.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
||||
import Typography from "@/components/ui/Typography/Typography";
|
||||
|
||||
import {
|
||||
buildHeaderProgress,
|
||||
buildTypographyProps,
|
||||
shouldShowBackButton,
|
||||
} from "@/lib/funnel/mappers";
|
||||
import type { TextScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface TextTemplateProps {
|
||||
screen: TextScreenDefinition;
|
||||
onContinue: () => void;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function TextTemplate({
|
||||
screen,
|
||||
onContinue,
|
||||
canGoBack,
|
||||
onBack,
|
||||
}: TextTemplateProps) {
|
||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||
|
||||
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
|
||||
? {
|
||||
children: screen.bottomActionButton.text,
|
||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
||||
onClick: onContinue,
|
||||
}
|
||||
: {
|
||||
children: "Continue",
|
||||
onClick: onContinue,
|
||||
};
|
||||
|
||||
const bottomActionButtonProps: BottomActionButtonProps = {
|
||||
actionButtonProps,
|
||||
};
|
||||
|
||||
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
|
||||
headerProps: {
|
||||
progressProps: buildHeaderProgress(screen.header?.progress),
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
},
|
||||
title:
|
||||
buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: {
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
align: "center",
|
||||
},
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
},
|
||||
bottomActionButtonProps,
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutQuestion {...layoutQuestionProps}>
|
||||
<div className="w-full flex flex-col items-center justify-center text-center mt-[40px]">
|
||||
{/* Content Text */}
|
||||
<div className="max-w-[320px] mx-auto">
|
||||
<Typography
|
||||
as="p"
|
||||
font="inter"
|
||||
weight="medium"
|
||||
color="default"
|
||||
size="lg"
|
||||
align="center"
|
||||
{...buildTypographyProps(screen.content, {
|
||||
as: "p",
|
||||
defaults: {
|
||||
font: "inter",
|
||||
weight: "medium",
|
||||
color: "default",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
},
|
||||
})}
|
||||
className="leading-[26px] text-slate-700"
|
||||
>
|
||||
{screen.content.text}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutQuestion>
|
||||
);
|
||||
}
|
||||
@ -8,19 +8,30 @@ import { Button } from "@/components/ui/button";
|
||||
interface HeaderProps extends React.ComponentProps<"header"> {
|
||||
progressProps?: React.ComponentProps<typeof Progress>;
|
||||
onBack?: () => void;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
function Header({ className, progressProps, onBack, ...props }: HeaderProps) {
|
||||
function Header({
|
||||
className,
|
||||
progressProps,
|
||||
onBack,
|
||||
showBackButton = true,
|
||||
...props
|
||||
}: HeaderProps) {
|
||||
const shouldRenderBackButton = showBackButton && typeof onBack === "function";
|
||||
|
||||
return (
|
||||
<header className={cn("w-full p-6 pb-3", className)} {...props}>
|
||||
<div className="w-full flex justify-left items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent rounded-full p-0! ml-[-13px] mb-[-9px]"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeft size={36} />
|
||||
</Button>
|
||||
<div className="w-full flex justify-left items-center min-h-9">
|
||||
{shouldRenderBackButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-transparent rounded-full p-0! ml-[-13px] mb-[-9px]"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeft size={36} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Progress {...progressProps} />
|
||||
|
||||
@ -15,7 +15,7 @@ export interface LayoutQuestionProps
|
||||
extends Omit<React.ComponentProps<"section">, "title" | "content"> {
|
||||
headerProps?: React.ComponentProps<typeof Header>;
|
||||
title: TypographyProps<"h2">;
|
||||
subtitle: TypographyProps<"p">;
|
||||
subtitle?: TypographyProps<"p">;
|
||||
children: React.ReactNode;
|
||||
bottomActionButtonProps?: BottomActionButtonProps;
|
||||
}
|
||||
@ -57,17 +57,17 @@ function LayoutQuestion({
|
||||
as="h2"
|
||||
font="manrope"
|
||||
weight="bold"
|
||||
align="left"
|
||||
{...title}
|
||||
className={cn(title.className, "text-[25px] leading-[38px]")}
|
||||
align={title.align ?? "left"}
|
||||
className={cn(title.className, "w-full text-[25px] leading-[38px]")}
|
||||
/>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Typography
|
||||
as="p"
|
||||
weight="medium"
|
||||
align="left"
|
||||
{...subtitle}
|
||||
align={subtitle.align ?? "left"}
|
||||
className={cn(
|
||||
subtitle.className,
|
||||
"w-full mt-2.5 text-[17px] leading-[26px]"
|
||||
|
||||
13
src/components/providers/AppProviders.tsx
Normal file
13
src/components/providers/AppProviders.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { FunnelProvider } from "@/lib/funnel/FunnelProvider";
|
||||
|
||||
interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
return <FunnelProvider>{children}</FunnelProvider>;
|
||||
}
|
||||
@ -28,12 +28,14 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
export type ActionButtonProps = React.ComponentProps<typeof Button> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
function ActionButton({
|
||||
className,
|
||||
cornerRadius,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> &
|
||||
VariantProps<typeof buttonVariants> & {}) {
|
||||
}: ActionButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="action-button"
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { GradientBlur } from "../GradientBlur/GradientBlur";
|
||||
import { ActionButton } from "@/components/ui/ActionButton/ActionButton";
|
||||
|
||||
@ -8,24 +10,26 @@ export interface BottomActionButtonProps extends React.ComponentProps<"div"> {
|
||||
actionButtonProps?: React.ComponentProps<typeof ActionButton>;
|
||||
}
|
||||
|
||||
function BottomActionButton({
|
||||
actionButtonProps,
|
||||
className,
|
||||
...props
|
||||
}: BottomActionButtonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-0 left-[50%] translate-x-[-50%] w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<GradientBlur className="p-6 pt-11">
|
||||
<ActionButton {...actionButtonProps} />
|
||||
</GradientBlur>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
|
||||
function BottomActionButton(
|
||||
{ actionButtonProps, className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-[50%] translate-x-[-50%] w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<GradientBlur className="p-6 pt-11">
|
||||
{actionButtonProps ? <ActionButton {...actionButtonProps} /> : null}
|
||||
</GradientBlur>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { BottomActionButton };
|
||||
|
||||
@ -26,6 +26,10 @@ function RadioAnswersList({
|
||||
activeAnswer
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAnswer(activeAnswer ?? null);
|
||||
}, [activeAnswer]);
|
||||
|
||||
const handleAnswerClick = (answer: MainButtonProps) => {
|
||||
setSelectedAnswer(answer);
|
||||
onAnswerClick?.(answer);
|
||||
|
||||
@ -26,6 +26,10 @@ function SelectAnswersList({
|
||||
MainButtonProps[] | null
|
||||
>(activeAnswers);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAnswers(activeAnswers ?? null);
|
||||
}, [activeAnswers]);
|
||||
|
||||
const handleAnswerClick = (answer: MainButtonProps) => {
|
||||
if (selectedAnswers?.some((a) => a.id === answer.id)) {
|
||||
setSelectedAnswers(
|
||||
|
||||
310
src/lib/admin/builder/context.tsx
Normal file
310
src/lib/admin/builder/context.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useReducer, type ReactNode } from "react";
|
||||
|
||||
import type {
|
||||
BuilderFunnelState,
|
||||
BuilderScreen,
|
||||
} from "@/lib/admin/builder/types";
|
||||
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
|
||||
|
||||
interface BuilderState extends BuilderFunnelState {
|
||||
selectedScreenId: string | null;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_META: BuilderFunnelState["meta"] = {
|
||||
id: "funnel-builder-draft",
|
||||
title: "New Funnel",
|
||||
description: "",
|
||||
firstScreenId: "screen-1",
|
||||
};
|
||||
|
||||
const INITIAL_SCREEN: BuilderScreen = {
|
||||
id: "screen-1",
|
||||
template: "list",
|
||||
header: {
|
||||
progress: {
|
||||
current: 1,
|
||||
total: 1,
|
||||
label: "1 of 1",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: "Новый экран",
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
},
|
||||
subtitle: {
|
||||
text: "Добавьте детали справа",
|
||||
color: "muted",
|
||||
font: "inter",
|
||||
},
|
||||
list: {
|
||||
selectionType: "single",
|
||||
options: [
|
||||
{
|
||||
id: "option-1",
|
||||
label: "Вариант 1",
|
||||
},
|
||||
{
|
||||
id: "option-2",
|
||||
label: "Вариант 2",
|
||||
},
|
||||
],
|
||||
},
|
||||
navigation: {
|
||||
defaultNextScreenId: undefined,
|
||||
rules: [],
|
||||
},
|
||||
position: {
|
||||
x: 80,
|
||||
y: 120,
|
||||
},
|
||||
};
|
||||
|
||||
const INITIAL_STATE: BuilderState = {
|
||||
meta: INITIAL_META,
|
||||
screens: [INITIAL_SCREEN],
|
||||
selectedScreenId: INITIAL_SCREEN.id,
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
type BuilderAction =
|
||||
| { type: "set-meta"; payload: Partial<BuilderFunnelState["meta"]> }
|
||||
| { type: "add-screen"; payload?: Partial<BuilderScreen> }
|
||||
| { type: "remove-screen"; payload: { screenId: string } }
|
||||
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
|
||||
| { type: "reposition-screen"; payload: { screenId: string; position: BuilderScreen["position"] } }
|
||||
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }
|
||||
| { type: "set-selected-screen"; payload: { screenId: string | null } }
|
||||
| { type: "set-screens"; payload: BuilderScreen[] }
|
||||
| {
|
||||
type: "update-navigation";
|
||||
payload: {
|
||||
screenId: string;
|
||||
navigation: {
|
||||
defaultNextScreenId?: string | null;
|
||||
rules?: NavigationRuleDefinition[];
|
||||
};
|
||||
};
|
||||
}
|
||||
| { type: "reset"; payload?: BuilderState };
|
||||
|
||||
function withDirty(state: BuilderState, next: BuilderState): BuilderState {
|
||||
if (next === state) {
|
||||
return state;
|
||||
}
|
||||
return { ...next, isDirty: true };
|
||||
}
|
||||
|
||||
function generateScreenId(existing: string[]): string {
|
||||
let index = existing.length + 1;
|
||||
let attempt = `screen-${index}`;
|
||||
while (existing.includes(attempt)) {
|
||||
index += 1;
|
||||
attempt = `screen-${index}`;
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
function builderReducer(state: BuilderState, action: BuilderAction): BuilderState {
|
||||
switch (action.type) {
|
||||
case "set-meta": {
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
meta: {
|
||||
...state.meta,
|
||||
...action.payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "add-screen": {
|
||||
const nextId = generateScreenId(state.screens.map((s) => s.id));
|
||||
const newScreen: BuilderScreen = {
|
||||
...INITIAL_SCREEN,
|
||||
id: nextId,
|
||||
position: {
|
||||
x: (action.payload?.position?.x ?? 120) + state.screens.length * 40,
|
||||
y: (action.payload?.position?.y ?? 120) + state.screens.length * 20,
|
||||
},
|
||||
...action.payload,
|
||||
list: {
|
||||
...INITIAL_SCREEN.list,
|
||||
...(action.payload?.list ?? {}),
|
||||
options:
|
||||
action.payload?.list?.options && action.payload.list.options.length > 0
|
||||
? action.payload.list.options
|
||||
: INITIAL_SCREEN.list.options.map((option, index) => ({
|
||||
...option,
|
||||
id: `option-${index + 1}`,
|
||||
})),
|
||||
},
|
||||
navigation: {
|
||||
defaultNextScreenId: action.payload?.navigation?.defaultNextScreenId,
|
||||
rules: action.payload?.navigation?.rules ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: [...state.screens, newScreen],
|
||||
selectedScreenId: newScreen.id,
|
||||
meta: {
|
||||
...state.meta,
|
||||
firstScreenId: state.meta.firstScreenId ?? newScreen.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "remove-screen": {
|
||||
const filtered = state.screens.filter((screen) => screen.id !== action.payload.screenId);
|
||||
const selectedScreenId =
|
||||
state.selectedScreenId === action.payload.screenId ? filtered[0]?.id ?? null : state.selectedScreenId;
|
||||
|
||||
const nextMeta = {
|
||||
...state.meta,
|
||||
firstScreenId:
|
||||
state.meta.firstScreenId === action.payload.screenId
|
||||
? filtered[0]?.id ?? null
|
||||
: state.meta.firstScreenId,
|
||||
};
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: filtered,
|
||||
selectedScreenId,
|
||||
meta: nextMeta,
|
||||
});
|
||||
}
|
||||
case "update-screen": {
|
||||
const { screenId, screen } = action.payload;
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: state.screens.map((current) =>
|
||||
current.id === screenId
|
||||
? {
|
||||
...current,
|
||||
...screen,
|
||||
title: screen.title ? { ...current.title, ...screen.title } : current.title,
|
||||
subtitle:
|
||||
screen.subtitle !== undefined
|
||||
? screen.subtitle
|
||||
: current.subtitle,
|
||||
list: screen.list
|
||||
? {
|
||||
...current.list,
|
||||
...screen.list,
|
||||
options: screen.list.options ?? current.list.options,
|
||||
}
|
||||
: current.list,
|
||||
}
|
||||
: current
|
||||
),
|
||||
});
|
||||
}
|
||||
case "reposition-screen": {
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: state.screens.map((screen) =>
|
||||
screen.id === action.payload.screenId
|
||||
? { ...screen, position: action.payload.position }
|
||||
: screen
|
||||
),
|
||||
});
|
||||
}
|
||||
case "reorder-screens": {
|
||||
const { fromIndex, toIndex } = action.payload;
|
||||
const newScreens = [...state.screens];
|
||||
const [removed] = newScreens.splice(fromIndex, 1);
|
||||
newScreens.splice(toIndex, 0, removed);
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: newScreens,
|
||||
});
|
||||
}
|
||||
case "set-selected-screen": {
|
||||
return {
|
||||
...state,
|
||||
selectedScreenId: action.payload.screenId,
|
||||
};
|
||||
}
|
||||
case "set-screens": {
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: action.payload,
|
||||
selectedScreenId: action.payload[0]?.id ?? null,
|
||||
meta: {
|
||||
...state.meta,
|
||||
firstScreenId: state.meta.firstScreenId ?? action.payload[0]?.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "update-navigation": {
|
||||
const { screenId, navigation } = action.payload;
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: state.screens.map((screen) =>
|
||||
screen.id === screenId
|
||||
? {
|
||||
...screen,
|
||||
navigation: {
|
||||
defaultNextScreenId: navigation.defaultNextScreenId ?? undefined,
|
||||
rules: navigation.rules ?? [],
|
||||
},
|
||||
}
|
||||
: screen
|
||||
),
|
||||
});
|
||||
}
|
||||
case "reset": {
|
||||
return action.payload ? { ...action.payload, isDirty: false } : INITIAL_STATE;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
interface BuilderProviderProps {
|
||||
children: ReactNode;
|
||||
initialState?: BuilderState;
|
||||
}
|
||||
|
||||
const BuilderStateContext = createContext<BuilderState | undefined>(undefined);
|
||||
const BuilderDispatchContext = createContext<((action: BuilderAction) => void) | undefined>(undefined);
|
||||
|
||||
export function BuilderProvider({ children, initialState }: BuilderProviderProps) {
|
||||
const [state, dispatch] = useReducer(builderReducer, initialState ?? INITIAL_STATE);
|
||||
|
||||
const memoizedState = useMemo(() => state, [state]);
|
||||
const memoizedDispatch = useMemo(() => dispatch, []);
|
||||
|
||||
return (
|
||||
<BuilderStateContext.Provider value={memoizedState}>
|
||||
<BuilderDispatchContext.Provider value={memoizedDispatch}>{children}</BuilderDispatchContext.Provider>
|
||||
</BuilderStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBuilderState(): BuilderState {
|
||||
const ctx = useContext(BuilderStateContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useBuilderState must be used within BuilderProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useBuilderDispatch(): (action: BuilderAction) => void {
|
||||
const ctx = useContext(BuilderDispatchContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useBuilderDispatch must be used within BuilderProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useBuilderSelectedScreen(): BuilderScreen | undefined {
|
||||
const state = useBuilderState();
|
||||
return state.screens.find((screen) => screen.id === state.selectedScreenId);
|
||||
}
|
||||
|
||||
export type { BuilderState, BuilderAction };
|
||||
106
src/lib/admin/builder/templates.ts
Normal file
106
src/lib/admin/builder/templates.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
import type { ListOptionDefinition } from "@/lib/funnel/types";
|
||||
|
||||
export interface CreateTemplateScreenOptions {
|
||||
templateId?: string;
|
||||
screenId: string;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface BuilderTemplateDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
create: (options: CreateTemplateScreenOptions, overrides?: Partial<BuilderScreen>) => BuilderScreen;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATE_ID = "list";
|
||||
|
||||
function cloneOptions(options: ListOptionDefinition[]): ListOptionDefinition[] {
|
||||
return options.map((option) => ({ ...option }));
|
||||
}
|
||||
|
||||
const LIST_TEMPLATE: BuilderTemplateDefinition = {
|
||||
id: "list",
|
||||
label: "Вопрос с вариантами",
|
||||
create: ({ screenId, position }, overrides) => {
|
||||
const base: BuilderScreen = {
|
||||
id: screenId,
|
||||
template: "list",
|
||||
header: {
|
||||
progress: {
|
||||
current: 1,
|
||||
total: 1,
|
||||
label: "1 of 1",
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: "Новый экран",
|
||||
font: "manrope",
|
||||
weight: "bold",
|
||||
},
|
||||
subtitle: {
|
||||
text: "Опишите вопрос справа",
|
||||
color: "muted",
|
||||
font: "inter",
|
||||
},
|
||||
list: {
|
||||
selectionType: "single",
|
||||
options: cloneOptions([
|
||||
{ id: "option-1", label: "Вариант 1" },
|
||||
{ id: "option-2", label: "Вариант 2" },
|
||||
]),
|
||||
},
|
||||
navigation: {
|
||||
defaultNextScreenId: undefined,
|
||||
rules: [],
|
||||
},
|
||||
position,
|
||||
};
|
||||
|
||||
if (!overrides) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
list: overrides.list
|
||||
? {
|
||||
...base.list,
|
||||
...overrides.list,
|
||||
options: overrides.list.options ?? base.list.options,
|
||||
}
|
||||
: base.list,
|
||||
navigation: overrides.navigation
|
||||
? {
|
||||
defaultNextScreenId:
|
||||
overrides.navigation.defaultNextScreenId ?? base.navigation?.defaultNextScreenId,
|
||||
rules: overrides.navigation.rules ?? base.navigation?.rules ?? [],
|
||||
}
|
||||
: base.navigation,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const BUILDER_TEMPLATES: BuilderTemplateDefinition[] = [LIST_TEMPLATE];
|
||||
|
||||
export function getTemplateDefinition(templateId: string): BuilderTemplateDefinition {
|
||||
return BUILDER_TEMPLATES.find((template) => template.id === templateId) ?? LIST_TEMPLATE;
|
||||
}
|
||||
|
||||
export function createTemplateScreen(
|
||||
options: CreateTemplateScreenOptions,
|
||||
overrides?: Partial<BuilderScreen>
|
||||
): BuilderScreen {
|
||||
const definition = getTemplateDefinition(options.templateId ?? DEFAULT_TEMPLATE_ID);
|
||||
return definition.create(options, overrides);
|
||||
}
|
||||
|
||||
export function getTemplateOptions(): { id: string; label: string; description?: string }[] {
|
||||
return BUILDER_TEMPLATES.map((template) => ({
|
||||
id: template.id,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
}));
|
||||
}
|
||||
15
src/lib/admin/builder/types.ts
Normal file
15
src/lib/admin/builder/types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { FunnelDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
export type BuilderScreenPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type BuilderScreen = ScreenDefinition & {
|
||||
position: BuilderScreenPosition;
|
||||
};
|
||||
|
||||
export interface BuilderFunnelState {
|
||||
meta: FunnelDefinition["meta"];
|
||||
screens: BuilderScreen[];
|
||||
}
|
||||
68
src/lib/admin/builder/utils.ts
Normal file
68
src/lib/admin/builder/utils.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { BuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen, BuilderFunnelState } from "@/lib/admin/builder/types";
|
||||
import type { FunnelDefinition, ListScreenDefinition } from "@/lib/funnel/types";
|
||||
|
||||
function withPositions(screens: ListScreenDefinition[]): BuilderScreen[] {
|
||||
return screens.map((screen, index) => ({
|
||||
...screen,
|
||||
position: {
|
||||
x: 120 + (index % 4) * 240,
|
||||
y: 120 + Math.floor(index / 4) * 200,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderState {
|
||||
const builderScreens = withPositions(funnel.screens);
|
||||
|
||||
return {
|
||||
meta: funnel.meta,
|
||||
screens: builderScreens,
|
||||
selectedScreenId: builderScreens[0]?.id ?? null,
|
||||
isDirty: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeBuilderState(state: BuilderFunnelState): FunnelDefinition {
|
||||
const screens = state.screens.map(({ position, ...rest }) => rest);
|
||||
const meta: FunnelDefinition["meta"] = {
|
||||
...state.meta,
|
||||
firstScreenId: state.meta.firstScreenId ?? state.screens[0]?.id,
|
||||
};
|
||||
|
||||
return {
|
||||
meta,
|
||||
screens,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderScreen>): BuilderScreen {
|
||||
const copy: BuilderScreen = {
|
||||
...screen,
|
||||
position: { ...screen.position },
|
||||
list: {
|
||||
...screen.list,
|
||||
options: screen.list.options.map((option) => ({ ...option })),
|
||||
},
|
||||
navigation: screen.navigation
|
||||
? {
|
||||
defaultNextScreenId: screen.navigation.defaultNextScreenId,
|
||||
rules: screen.navigation.rules?.map((rule) => ({
|
||||
nextScreenId: rule.nextScreenId,
|
||||
conditions: rule.conditions.map((condition) => ({
|
||||
screenId: condition.screenId,
|
||||
operator: condition.operator,
|
||||
optionIds: [...condition.optionIds],
|
||||
})),
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return overrides ? { ...copy, ...overrides } : copy;
|
||||
}
|
||||
|
||||
export function toBuilderFunnelState(state: BuilderState): BuilderFunnelState {
|
||||
const { isDirty, selectedScreenId, ...rest } = state;
|
||||
return rest;
|
||||
}
|
||||
175
src/lib/admin/builder/validation.ts
Normal file
175
src/lib/admin/builder/validation.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import type { BuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||
|
||||
export interface BuilderValidationIssue {
|
||||
severity: "error" | "warning";
|
||||
message: string;
|
||||
screenId?: string;
|
||||
optionId?: string;
|
||||
}
|
||||
|
||||
export interface BuilderValidationResult {
|
||||
issues: BuilderValidationIssue[];
|
||||
errors: BuilderValidationIssue[];
|
||||
warnings: BuilderValidationIssue[];
|
||||
}
|
||||
|
||||
function createIssue(
|
||||
severity: BuilderValidationIssue["severity"],
|
||||
message: string,
|
||||
context: Partial<Pick<BuilderValidationIssue, "screenId" | "optionId">> = {}
|
||||
): BuilderValidationIssue {
|
||||
return { severity, message, ...context };
|
||||
}
|
||||
|
||||
function collectDuplicateIds(values: string[]): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([value]) => value);
|
||||
}
|
||||
|
||||
function validateScreenIds(state: BuilderState, issues: BuilderValidationIssue[]) {
|
||||
const duplicates = collectDuplicateIds(state.screens.map((screen) => screen.id));
|
||||
for (const duplicateId of duplicates) {
|
||||
issues.push(createIssue("error", `Дублирующийся идентификатор экрана \`${duplicateId}\``, { screenId: duplicateId }));
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionIds(screen: BuilderScreen, issues: BuilderValidationIssue[]) {
|
||||
// Проверяем опции только для экранов типа 'list', у которых есть свойство list
|
||||
if (screen.template !== "list" || !("list" in screen)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screenWithList = screen as any;
|
||||
const duplicates = collectDuplicateIds(screenWithList.list.options.map((option: any) => option.id));
|
||||
for (const duplicateId of duplicates) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\`: опция с идентификатором \`${duplicateId}\` повторяется несколько раз`,
|
||||
{ screenId: screen.id, optionId: duplicateId }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNavigation(screen: BuilderScreen, state: BuilderState, issues: BuilderValidationIssue[]) {
|
||||
const screenIds = new Set(state.screens.map((candidate) => candidate.id));
|
||||
|
||||
const navigation = screen.navigation;
|
||||
if (!navigation) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"warning",
|
||||
`Экран \`${screen.id}\` не имеет настроенной навигации (переход по умолчанию или правил)`,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigation.defaultNextScreenId && (!navigation.rules || navigation.rules.length === 0)) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"warning",
|
||||
`Экран \`${screen.id}\` не ведёт на следующий экран. Добавьте переход по умолчанию или правило.`,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (navigation.defaultNextScreenId && !screenIds.has(navigation.defaultNextScreenId)) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\` ссылается на несуществующий default next экран \`${navigation.defaultNextScreenId}\``,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const [ruleIndex, rule] of (navigation.rules ?? []).entries()) {
|
||||
if (!screenIds.has(rule.nextScreenId)) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\`, правило #${ruleIndex + 1}: следующий экран \`${rule.nextScreenId}\` не найден`,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const condition of rule.conditions) {
|
||||
if (!screenIds.has(condition.screenId)) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\`, правило #${ruleIndex + 1}: условие указывает на отсутствующий экран \`${condition.screenId}\``,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceScreen = state.screens.find((candidate) => candidate.id === condition.screenId);
|
||||
if (!referenceScreen) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем опции только для экранов типа 'list'
|
||||
if (referenceScreen.template !== "list" || !("list" in referenceScreen)) {
|
||||
// Если это не list экран, но правило ссылается на опции, это ошибка
|
||||
if (condition.optionIds && condition.optionIds.length > 0) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"error",
|
||||
`Экран \`${screen.id}\`, правило #${ruleIndex + 1}: экран \`${referenceScreen.id}\` типа "${referenceScreen.template}" не имеет опций`,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const referenceScreenWithList = referenceScreen as any;
|
||||
const availableOptionIds = new Set(referenceScreenWithList.list.options.map((option: any) => option.id));
|
||||
const missingOptionIds = (condition.optionIds ?? []).filter((optionId) => !availableOptionIds.has(optionId));
|
||||
if (missingOptionIds.length > 0) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
"warning",
|
||||
`Экран \`${screen.id}\`, правило #${ruleIndex + 1}: опции ${missingOptionIds
|
||||
.map((id) => `\`${id}\``)
|
||||
.join(", ")} не найдены на экране \`${referenceScreen.id}\``,
|
||||
{ screenId: screen.id }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBuilderState(state: BuilderState): BuilderValidationResult {
|
||||
const issues: BuilderValidationIssue[] = [];
|
||||
|
||||
validateScreenIds(state, issues);
|
||||
|
||||
for (const screen of state.screens) {
|
||||
validateOptionIds(screen, issues);
|
||||
validateNavigation(screen, state, issues);
|
||||
}
|
||||
|
||||
const errors = issues.filter((issue) => issue.severity === "error");
|
||||
const warnings = issues.filter((issue) => issue.severity === "warning");
|
||||
|
||||
return {
|
||||
issues,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
211
src/lib/funnel/FunnelProvider.tsx
Normal file
211
src/lib/funnel/FunnelProvider.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { FunnelAnswers } from "./types";
|
||||
|
||||
interface FunnelRuntimeState {
|
||||
answers: FunnelAnswers;
|
||||
history: string[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface FunnelContextValue {
|
||||
state: Record<string, FunnelRuntimeState>;
|
||||
registerScreenVisit: (funnelId: string, screenId: string) => void;
|
||||
updateScreenAnswers: (
|
||||
funnelId: string,
|
||||
screenId: string,
|
||||
answers: string[]
|
||||
) => void;
|
||||
resetFunnel: (funnelId: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_RUNTIME_STATE: FunnelRuntimeState = {
|
||||
answers: {},
|
||||
history: [],
|
||||
version: 0,
|
||||
};
|
||||
|
||||
function createInitialState(): FunnelRuntimeState {
|
||||
return {
|
||||
answers: {},
|
||||
history: [],
|
||||
version: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function arraysEqual(left: string[] | undefined, right: string[]): boolean {
|
||||
if (!left && right.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (!left || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
const FunnelContext = createContext<FunnelContextValue | undefined>(undefined);
|
||||
|
||||
interface FunnelProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FunnelProvider({ children }: FunnelProviderProps) {
|
||||
const [state, setState] = useState<Record<string, FunnelRuntimeState>>({});
|
||||
|
||||
const registerScreenVisit = useCallback((funnelId: string, screenId: string) => {
|
||||
setState((prev) => {
|
||||
const previousState = prev[funnelId] ?? createInitialState();
|
||||
const history = previousState.history ?? [];
|
||||
|
||||
let nextHistory = history;
|
||||
|
||||
if (history.length === 0 || history[history.length - 1] !== screenId) {
|
||||
const existingIndex = history.indexOf(screenId);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
nextHistory = [...history, screenId];
|
||||
} else if (existingIndex !== history.length - 1) {
|
||||
nextHistory = history.slice(0, existingIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextHistory === history) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[funnelId]: {
|
||||
...previousState,
|
||||
history: nextHistory,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateScreenAnswers = useCallback(
|
||||
(funnelId: string, screenId: string, answers: string[]) => {
|
||||
setState((prev) => {
|
||||
const previousState = prev[funnelId] ?? createInitialState();
|
||||
const previousAnswers = previousState.answers ?? {};
|
||||
const existingAnswers = previousAnswers[screenId];
|
||||
|
||||
if (answers.length === 0) {
|
||||
if (!existingAnswers) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const rest = { ...previousAnswers };
|
||||
delete rest[screenId];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[funnelId]: {
|
||||
...previousState,
|
||||
answers: rest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (arraysEqual(existingAnswers, answers)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[funnelId]: {
|
||||
...previousState,
|
||||
answers: {
|
||||
...previousAnswers,
|
||||
[screenId]: answers,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resetFunnel = useCallback((funnelId: string) => {
|
||||
setState((prev) => {
|
||||
const previousState = prev[funnelId];
|
||||
if (!previousState) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (
|
||||
previousState.history.length === 0 &&
|
||||
Object.keys(previousState.answers).length === 0
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[funnelId]: {
|
||||
...createInitialState(),
|
||||
version: (previousState.version ?? 0) + 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<FunnelContextValue>(
|
||||
() => ({ state, registerScreenVisit, updateScreenAnswers, resetFunnel }),
|
||||
[state, registerScreenVisit, updateScreenAnswers, resetFunnel]
|
||||
);
|
||||
|
||||
return <FunnelContext.Provider value={value}>{children}</FunnelContext.Provider>;
|
||||
}
|
||||
|
||||
function useFunnelContext() {
|
||||
const context = useContext(FunnelContext);
|
||||
if (!context) {
|
||||
throw new Error("useFunnelContext must be used within a FunnelProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useFunnelRuntime(funnelId: string) {
|
||||
const { state, registerScreenVisit, updateScreenAnswers, resetFunnel } =
|
||||
useFunnelContext();
|
||||
|
||||
const runtime = state[funnelId] ?? DEFAULT_RUNTIME_STATE;
|
||||
|
||||
const setAnswers = useCallback(
|
||||
(screenId: string, answers: string[]) => {
|
||||
updateScreenAnswers(funnelId, screenId, answers);
|
||||
},
|
||||
[funnelId, updateScreenAnswers]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
(screenId: string) => {
|
||||
registerScreenVisit(funnelId, screenId);
|
||||
},
|
||||
[funnelId, registerScreenVisit]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
resetFunnel(funnelId);
|
||||
}, [funnelId, resetFunnel]);
|
||||
|
||||
return {
|
||||
answers: runtime.answers,
|
||||
history: runtime.history,
|
||||
version: runtime.version,
|
||||
setAnswers,
|
||||
registerScreen: register,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
20
src/lib/funnel/loadFunnelDefinition.ts
Normal file
20
src/lib/funnel/loadFunnelDefinition.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { FunnelDefinition } from "./types";
|
||||
|
||||
export async function loadFunnelDefinition(
|
||||
funnelId: string
|
||||
): Promise<FunnelDefinition> {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"funnels",
|
||||
`${funnelId}.json`
|
||||
);
|
||||
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as FunnelDefinition;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
305
src/lib/funnel/mappers.tsx
Normal file
305
src/lib/funnel/mappers.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import type { TypographyProps } from "@/components/ui/Typography/Typography";
|
||||
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
|
||||
|
||||
import type {
|
||||
HeaderDefinition,
|
||||
HeaderProgressDefinition,
|
||||
ListOptionDefinition,
|
||||
SelectionType,
|
||||
TypographyVariant,
|
||||
BottomActionButtonDefinition,
|
||||
ScreenDefinition,
|
||||
ColorPalette,
|
||||
} from "./types";
|
||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
||||
|
||||
type TypographyAs = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div";
|
||||
|
||||
interface TypographyDefaults {
|
||||
font?: TypographyVariant["font"];
|
||||
weight?: TypographyVariant["weight"];
|
||||
size?: TypographyVariant["size"];
|
||||
align?: TypographyVariant["align"];
|
||||
color?: TypographyVariant["color"];
|
||||
}
|
||||
|
||||
interface BuildTypographyOptions<T extends TypographyAs> {
|
||||
as: T;
|
||||
defaults?: TypographyDefaults;
|
||||
}
|
||||
|
||||
export function buildTypographyProps<T extends TypographyAs>(
|
||||
variant: TypographyVariant | undefined,
|
||||
options: BuildTypographyOptions<T>
|
||||
): TypographyProps<T> | undefined {
|
||||
if (!variant) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { as, defaults } = options;
|
||||
|
||||
return {
|
||||
as,
|
||||
children: variant.text,
|
||||
font: variant.font ?? defaults?.font,
|
||||
weight: variant.weight ?? defaults?.weight,
|
||||
size: variant.size ?? defaults?.size,
|
||||
align: variant.align ?? defaults?.align,
|
||||
color: variant.color ?? defaults?.color,
|
||||
className: variant.className,
|
||||
} as TypographyProps<T>;
|
||||
}
|
||||
|
||||
export function buildHeaderProgress(progress?: HeaderProgressDefinition) {
|
||||
if (!progress) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { current, total, value, label, className } = progress;
|
||||
|
||||
const computedValue =
|
||||
value ?? (current !== undefined && total ? (current / total) * 100 : undefined);
|
||||
|
||||
return {
|
||||
value: computedValue,
|
||||
label,
|
||||
className,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAutoHeaderProgress(
|
||||
currentScreenId: string,
|
||||
totalScreens: number,
|
||||
currentPosition: number,
|
||||
explicitProgress?: HeaderProgressDefinition
|
||||
) {
|
||||
// If explicit progress is provided, use it
|
||||
if (explicitProgress) {
|
||||
return buildHeaderProgress(explicitProgress);
|
||||
}
|
||||
|
||||
// Otherwise, auto-calculate
|
||||
const autoProgress: HeaderProgressDefinition = {
|
||||
current: currentPosition,
|
||||
total: totalScreens,
|
||||
label: `${currentPosition} of ${totalScreens}`,
|
||||
};
|
||||
|
||||
return buildHeaderProgress(autoProgress);
|
||||
}
|
||||
|
||||
export function mapListOptionsToButtons(
|
||||
options: ListOptionDefinition[],
|
||||
selectionType: SelectionType
|
||||
): MainButtonProps[] {
|
||||
return options.map((option) => ({
|
||||
id: option.id,
|
||||
children: option.label,
|
||||
emoji: option.emoji,
|
||||
isCheckbox: selectionType === "multi",
|
||||
disabled: option.disabled,
|
||||
}));
|
||||
}
|
||||
export function shouldShowBackButton(header?: HeaderDefinition, canGoBack?: boolean) {
|
||||
if (header?.showBackButton === false) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(canGoBack);
|
||||
}
|
||||
|
||||
export function shouldShowHeader(header?: HeaderDefinition) {
|
||||
return header?.show !== false;
|
||||
}
|
||||
|
||||
interface BuildActionButtonOptions {
|
||||
defaultText?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function buildActionButtonProps(
|
||||
options: BuildActionButtonOptions,
|
||||
buttonDef?: BottomActionButtonDefinition
|
||||
): ActionButtonProps {
|
||||
const { defaultText = "Continue", disabled = false, onClick } = options;
|
||||
|
||||
return {
|
||||
children: buttonDef?.text ?? defaultText,
|
||||
cornerRadius: buttonDef?.cornerRadius,
|
||||
disabled: buttonDef?.disabled ?? disabled,
|
||||
onClick: (buttonDef?.disabled ?? disabled) ? undefined : onClick,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBottomActionButtonProps(
|
||||
options: BuildActionButtonOptions,
|
||||
buttonDef?: BottomActionButtonDefinition
|
||||
): BottomActionButtonProps | undefined {
|
||||
if (buttonDef?.show === false) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const actionButtonProps = buildActionButtonProps(options, buttonDef);
|
||||
|
||||
return {
|
||||
actionButtonProps,
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildLayoutQuestionOptions {
|
||||
screen: ScreenDefinition;
|
||||
titleDefaults?: TypographyDefaults;
|
||||
subtitleDefaults?: TypographyDefaults;
|
||||
canGoBack: boolean;
|
||||
onBack: () => void;
|
||||
actionButtonOptions: BuildActionButtonOptions;
|
||||
}
|
||||
|
||||
export function buildLayoutQuestionProps(
|
||||
options: BuildLayoutQuestionOptions
|
||||
): Omit<LayoutQuestionProps, "children"> {
|
||||
const {
|
||||
screen,
|
||||
titleDefaults = { font: "manrope", weight: "bold", align: "left" },
|
||||
subtitleDefaults = { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||
canGoBack,
|
||||
onBack,
|
||||
actionButtonOptions
|
||||
} = options;
|
||||
|
||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||
const showHeader = shouldShowHeader(screen.header);
|
||||
|
||||
return {
|
||||
headerProps: showHeader ? {
|
||||
progressProps: buildHeaderProgress(screen.header?.progress),
|
||||
onBack: showBackButton ? onBack : undefined,
|
||||
showBackButton,
|
||||
} : undefined,
|
||||
title: buildTypographyProps(screen.title, {
|
||||
as: "h2",
|
||||
defaults: titleDefaults,
|
||||
}) ?? {
|
||||
as: "h2",
|
||||
children: screen.title.text,
|
||||
},
|
||||
subtitle: 'subtitle' in screen ? buildTypographyProps(screen.subtitle, {
|
||||
as: "p",
|
||||
defaults: subtitleDefaults,
|
||||
}) : undefined,
|
||||
bottomActionButtonProps: buildBottomActionButtonProps(
|
||||
actionButtonOptions,
|
||||
'bottomActionButton' in screen ? screen.bottomActionButton : undefined
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Color system utilities
|
||||
const DEFAULT_COLOR_PALETTE: ColorPalette = {
|
||||
text: {
|
||||
primary: "#1E293B",
|
||||
secondary: "#475569",
|
||||
muted: "#64748B",
|
||||
accent: "#3B82F6",
|
||||
success: "#10B981",
|
||||
error: "#EF4444",
|
||||
warning: "#F59E0B",
|
||||
},
|
||||
background: {
|
||||
primary: "#FFFFFF",
|
||||
secondary: "#F8FAFC",
|
||||
accent: "#EFF6FF",
|
||||
success: "#ECFDF5",
|
||||
error: "#FEF2F2",
|
||||
warning: "#FFFBEB",
|
||||
},
|
||||
button: {
|
||||
primary: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
primaryText: "#FFFFFF",
|
||||
secondary: "#F1F5F9",
|
||||
secondaryText: "#334155",
|
||||
disabled: "#E2E8F0",
|
||||
disabledText: "#94A3B8",
|
||||
},
|
||||
border: {
|
||||
primary: "#E2E8F0",
|
||||
accent: "#3B82F6",
|
||||
success: "#10B981",
|
||||
error: "#EF4444",
|
||||
},
|
||||
shadow: {
|
||||
light: "rgba(0, 0, 0, 0.05)",
|
||||
medium: "rgba(0, 0, 0, 0.1)",
|
||||
heavy: "rgba(0, 0, 0, 0.15)",
|
||||
colored: "rgba(59, 130, 246, 0.3)",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveColorPalette(
|
||||
funnelPalette?: ColorPalette,
|
||||
screenOverrides?: Partial<ColorPalette>
|
||||
): ColorPalette {
|
||||
// Deep merge: Default -> Funnel -> Screen overrides
|
||||
const basePalette = {
|
||||
text: { ...DEFAULT_COLOR_PALETTE.text, ...funnelPalette?.text },
|
||||
background: { ...DEFAULT_COLOR_PALETTE.background, ...funnelPalette?.background },
|
||||
button: { ...DEFAULT_COLOR_PALETTE.button, ...funnelPalette?.button },
|
||||
border: { ...DEFAULT_COLOR_PALETTE.border, ...funnelPalette?.border },
|
||||
shadow: { ...DEFAULT_COLOR_PALETTE.shadow, ...funnelPalette?.shadow },
|
||||
};
|
||||
|
||||
if (!screenOverrides) return basePalette;
|
||||
|
||||
return {
|
||||
text: { ...basePalette.text, ...screenOverrides.text },
|
||||
background: { ...basePalette.background, ...screenOverrides.background },
|
||||
button: { ...basePalette.button, ...screenOverrides.button },
|
||||
border: { ...basePalette.border, ...screenOverrides.border },
|
||||
shadow: { ...basePalette.shadow, ...screenOverrides.shadow },
|
||||
};
|
||||
}
|
||||
|
||||
export function getCSSVariables(palette: ColorPalette): Record<string, string> {
|
||||
const cssVars: Record<string, string> = {};
|
||||
|
||||
// Text colors
|
||||
if (palette.text?.primary) cssVars['--funnel-text-primary'] = palette.text.primary;
|
||||
if (palette.text?.secondary) cssVars['--funnel-text-secondary'] = palette.text.secondary;
|
||||
if (palette.text?.muted) cssVars['--funnel-text-muted'] = palette.text.muted;
|
||||
if (palette.text?.accent) cssVars['--funnel-text-accent'] = palette.text.accent;
|
||||
if (palette.text?.success) cssVars['--funnel-text-success'] = palette.text.success;
|
||||
if (palette.text?.error) cssVars['--funnel-text-error'] = palette.text.error;
|
||||
if (palette.text?.warning) cssVars['--funnel-text-warning'] = palette.text.warning;
|
||||
|
||||
// Background colors
|
||||
if (palette.background?.primary) cssVars['--funnel-bg-primary'] = palette.background.primary;
|
||||
if (palette.background?.secondary) cssVars['--funnel-bg-secondary'] = palette.background.secondary;
|
||||
if (palette.background?.accent) cssVars['--funnel-bg-accent'] = palette.background.accent;
|
||||
if (palette.background?.success) cssVars['--funnel-bg-success'] = palette.background.success;
|
||||
if (palette.background?.error) cssVars['--funnel-bg-error'] = palette.background.error;
|
||||
if (palette.background?.warning) cssVars['--funnel-bg-warning'] = palette.background.warning;
|
||||
|
||||
// Button colors
|
||||
if (palette.button?.primary) cssVars['--funnel-btn-primary'] = palette.button.primary;
|
||||
if (palette.button?.primaryText) cssVars['--funnel-btn-primary-text'] = palette.button.primaryText;
|
||||
if (palette.button?.secondary) cssVars['--funnel-btn-secondary'] = palette.button.secondary;
|
||||
if (palette.button?.secondaryText) cssVars['--funnel-btn-secondary-text'] = palette.button.secondaryText;
|
||||
if (palette.button?.disabled) cssVars['--funnel-btn-disabled'] = palette.button.disabled;
|
||||
if (palette.button?.disabledText) cssVars['--funnel-btn-disabled-text'] = palette.button.disabledText;
|
||||
|
||||
// Border colors
|
||||
if (palette.border?.primary) cssVars['--funnel-border-primary'] = palette.border.primary;
|
||||
if (palette.border?.accent) cssVars['--funnel-border-accent'] = palette.border.accent;
|
||||
if (palette.border?.success) cssVars['--funnel-border-success'] = palette.border.success;
|
||||
if (palette.border?.error) cssVars['--funnel-border-error'] = palette.border.error;
|
||||
|
||||
// Shadow colors
|
||||
if (palette.shadow?.light) cssVars['--funnel-shadow-light'] = palette.shadow.light;
|
||||
if (palette.shadow?.medium) cssVars['--funnel-shadow-medium'] = palette.shadow.medium;
|
||||
if (palette.shadow?.heavy) cssVars['--funnel-shadow-heavy'] = palette.shadow.heavy;
|
||||
if (palette.shadow?.colored) cssVars['--funnel-shadow-colored'] = palette.shadow.colored;
|
||||
|
||||
return cssVars;
|
||||
}
|
||||
78
src/lib/funnel/navigation.ts
Normal file
78
src/lib/funnel/navigation.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { FunnelAnswers, NavigationConditionDefinition, NavigationRuleDefinition, ScreenDefinition } from "./types";
|
||||
|
||||
function getScreenAnswers(answers: FunnelAnswers, screenId: string): string[] {
|
||||
return answers[screenId] ?? [];
|
||||
}
|
||||
|
||||
function satisfiesCondition(
|
||||
condition: NavigationConditionDefinition,
|
||||
answers: FunnelAnswers
|
||||
): boolean {
|
||||
const selected = new Set(getScreenAnswers(answers, condition.screenId));
|
||||
const expected = new Set(condition.optionIds ?? []);
|
||||
const operator = condition.operator ?? "includesAny";
|
||||
|
||||
if (expected.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "includesAny": {
|
||||
return condition.optionIds.some((id) => selected.has(id));
|
||||
}
|
||||
case "includesAll": {
|
||||
return condition.optionIds.every((id) => selected.has(id));
|
||||
}
|
||||
case "includesExactly": {
|
||||
if (selected.size !== expected.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const id of expected) {
|
||||
if (!selected.has(id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function satisfiesRule(rule: NavigationRuleDefinition, answers: FunnelAnswers): boolean {
|
||||
if (!rule.conditions || rule.conditions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return rule.conditions.every((condition) => satisfiesCondition(condition, answers));
|
||||
}
|
||||
|
||||
export function resolveNextScreenId(
|
||||
currentScreen: ScreenDefinition,
|
||||
answers: FunnelAnswers,
|
||||
orderedScreens: ScreenDefinition[]
|
||||
): string | undefined {
|
||||
const navigation = currentScreen.navigation;
|
||||
|
||||
if (navigation?.rules) {
|
||||
for (const rule of navigation.rules) {
|
||||
if (satisfiesRule(rule, answers)) {
|
||||
return rule.nextScreenId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (navigation?.defaultNextScreenId) {
|
||||
return navigation.defaultNextScreenId;
|
||||
}
|
||||
|
||||
const currentIndex = orderedScreens.findIndex((screen) => screen.id === currentScreen.id);
|
||||
if (currentIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nextScreen = orderedScreens[currentIndex + 1];
|
||||
return nextScreen?.id;
|
||||
}
|
||||
281
src/lib/funnel/types.ts
Normal file
281
src/lib/funnel/types.ts
Normal file
@ -0,0 +1,281 @@
|
||||
export type TypographyVariant = {
|
||||
text: string;
|
||||
font?: "manrope" | "inter" | "geistSans" | "geistMono";
|
||||
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
|
||||
align?: "center" | "left" | "right";
|
||||
color?:
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "success"
|
||||
| "card"
|
||||
| "accent"
|
||||
| "muted";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export interface HeaderProgressDefinition {
|
||||
/** When both current and total provided, value is computed automatically (current / total * 100). */
|
||||
current?: number;
|
||||
total?: number;
|
||||
/** Explicit percentage override (0-100). */
|
||||
value?: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface HeaderDefinition {
|
||||
progress?: HeaderProgressDefinition;
|
||||
/** Controls whether back button should be displayed. Defaults to true. */
|
||||
showBackButton?: boolean;
|
||||
/** Controls whether header should be displayed at all. Defaults to true. */
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export type SelectionType = "single" | "multi";
|
||||
|
||||
export interface ListOptionDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
/** Optional machine-readable value; defaults to the option id. */
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface BottomActionButtonDefinition {
|
||||
text: string;
|
||||
cornerRadius?: "3xl" | "full";
|
||||
/** Controls whether button should be displayed. Defaults to true. */
|
||||
show?: boolean;
|
||||
/** Controls whether gradient blur background should be shown. Defaults to true. */
|
||||
showGradientBlur?: boolean;
|
||||
/** Custom disabled state (overrides template logic). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DefaultTexts {
|
||||
nextButton?: string; // "Next"
|
||||
continueButton?: string; // "Continue"
|
||||
}
|
||||
|
||||
// Color system for consistent theming
|
||||
export interface TextColors {
|
||||
primary?: string; // Main text color - #1E293B
|
||||
secondary?: string; // Secondary text - #475569
|
||||
muted?: string; // Muted/disabled text - #64748B
|
||||
accent?: string; // Accent/highlight text - #3B82F6
|
||||
success?: string; // Success messages - #10B981
|
||||
error?: string; // Error messages - #EF4444
|
||||
warning?: string; // Warning messages - #F59E0B
|
||||
}
|
||||
|
||||
export interface BackgroundColors {
|
||||
primary?: string; // Main background - #FFFFFF
|
||||
secondary?: string; // Secondary background - #F8FAFC
|
||||
accent?: string; // Accent background - #EFF6FF
|
||||
success?: string; // Success background - #ECFDF5
|
||||
error?: string; // Error background - #FEF2F2
|
||||
warning?: string; // Warning background - #FFFBEB
|
||||
}
|
||||
|
||||
export interface ButtonColors {
|
||||
primary?: string; // Primary button background - gradient or solid
|
||||
primaryText?: string; // Primary button text - #FFFFFF
|
||||
secondary?: string; // Secondary button background - #F1F5F9
|
||||
secondaryText?: string; // Secondary button text - #334155
|
||||
disabled?: string; // Disabled button background - #E2E8F0
|
||||
disabledText?: string; // Disabled button text - #94A3B8
|
||||
}
|
||||
|
||||
export interface BorderColors {
|
||||
primary?: string; // Main borders - #E2E8F0
|
||||
accent?: string; // Accent borders - #3B82F6
|
||||
success?: string; // Success borders - #10B981
|
||||
error?: string; // Error borders - #EF4444
|
||||
}
|
||||
|
||||
export interface ShadowColors {
|
||||
light?: string; // Light shadow - rgba(0, 0, 0, 0.05)
|
||||
medium?: string; // Medium shadow - rgba(0, 0, 0, 0.1)
|
||||
heavy?: string; // Heavy shadow - rgba(0, 0, 0, 0.15)
|
||||
colored?: string; // Colored shadow (for buttons) - rgba(59, 130, 246, 0.3)
|
||||
}
|
||||
|
||||
export interface ColorPalette {
|
||||
text?: TextColors;
|
||||
background?: BackgroundColors;
|
||||
button?: ButtonColors;
|
||||
border?: BorderColors;
|
||||
shadow?: ShadowColors;
|
||||
}
|
||||
|
||||
export interface NavigationConditionDefinition {
|
||||
screenId: string;
|
||||
/**
|
||||
* - includesAny: at least one option id is selected.
|
||||
* - includesAll: all option ids are selected.
|
||||
* - includesExactly: selection matches the provided set exactly (order-independent).
|
||||
*/
|
||||
operator?: "includesAny" | "includesAll" | "includesExactly";
|
||||
optionIds: string[];
|
||||
}
|
||||
|
||||
export interface NavigationRuleDefinition {
|
||||
conditions: NavigationConditionDefinition[];
|
||||
nextScreenId: string;
|
||||
}
|
||||
|
||||
export interface NavigationDefinition {
|
||||
rules?: NavigationRuleDefinition[];
|
||||
defaultNextScreenId?: string;
|
||||
}
|
||||
|
||||
export interface InfoScreenDefinition {
|
||||
id: string;
|
||||
template: "info";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
description?: TypographyVariant;
|
||||
icon?: {
|
||||
type: "emoji" | "image";
|
||||
value: string; // emoji character or image URL/path
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
className?: string;
|
||||
};
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
colorOverrides?: Partial<ColorPalette>; // Override colors for this screen
|
||||
}
|
||||
|
||||
export interface DateInputDefinition {
|
||||
monthPlaceholder?: string;
|
||||
dayPlaceholder?: string;
|
||||
yearPlaceholder?: string;
|
||||
monthLabel?: string;
|
||||
dayLabel?: string;
|
||||
yearLabel?: string;
|
||||
showSelectedDate?: boolean;
|
||||
selectedDateFormat?: string; // e.g., "MMMM d, yyyy" for "April 8, 1987"
|
||||
validationMessage?: string;
|
||||
selectedDateLabel?: string; // "Выбранная дата:" text
|
||||
}
|
||||
|
||||
export interface DateScreenDefinition {
|
||||
id: string;
|
||||
template: "date";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
dateInput: DateInputDefinition;
|
||||
infoMessage?: TypographyVariant & {
|
||||
icon?: string; // emoji or icon
|
||||
};
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
colorOverrides?: Partial<ColorPalette>;
|
||||
}
|
||||
|
||||
export interface CouponDefinition {
|
||||
title: TypographyVariant;
|
||||
offer: {
|
||||
title: TypographyVariant;
|
||||
description: TypographyVariant;
|
||||
};
|
||||
promoCode: TypographyVariant;
|
||||
footer: TypographyVariant;
|
||||
}
|
||||
|
||||
export interface CouponScreenDefinition {
|
||||
id: string;
|
||||
template: "coupon";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
coupon: CouponDefinition;
|
||||
copiedMessage?: string; // "Промокод скопирован!" text
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
colorOverrides?: Partial<ColorPalette>;
|
||||
}
|
||||
|
||||
export interface FormFieldDefinition {
|
||||
id: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: "text" | "email" | "tel" | "url";
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
validation?: {
|
||||
pattern?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FormValidationMessages {
|
||||
required?: string; // "${field} is required"
|
||||
maxLength?: string; // "Maximum ${maxLength} characters allowed"
|
||||
invalidFormat?: string; // "Invalid format"
|
||||
}
|
||||
|
||||
export interface FormScreenDefinition {
|
||||
id: string;
|
||||
template: "form";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
fields: FormFieldDefinition[];
|
||||
validationMessages?: FormValidationMessages;
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
colorOverrides?: Partial<ColorPalette>;
|
||||
}
|
||||
|
||||
export interface TextScreenDefinition {
|
||||
id: string;
|
||||
template: "text";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
content: TypographyVariant;
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
navigation?: NavigationDefinition;
|
||||
colorOverrides?: Partial<ColorPalette>;
|
||||
}
|
||||
|
||||
export interface ListScreenDefinition {
|
||||
id: string;
|
||||
template: "list";
|
||||
header?: HeaderDefinition;
|
||||
title: TypographyVariant;
|
||||
subtitle?: TypographyVariant;
|
||||
list: {
|
||||
selectionType: SelectionType;
|
||||
autoAdvance?: boolean;
|
||||
options: ListOptionDefinition[];
|
||||
bottomActionButton?: BottomActionButtonDefinition;
|
||||
};
|
||||
navigation?: NavigationDefinition;
|
||||
colorOverrides?: Partial<ColorPalette>;
|
||||
}
|
||||
|
||||
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | TextScreenDefinition | ListScreenDefinition;
|
||||
|
||||
export interface FunnelMetaDefinition {
|
||||
id: string;
|
||||
version?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
firstScreenId?: string;
|
||||
}
|
||||
|
||||
export interface FunnelDefinition {
|
||||
meta: FunnelMetaDefinition;
|
||||
defaultTexts?: DefaultTexts;
|
||||
colorPalette?: ColorPalette;
|
||||
screens: ScreenDefinition[];
|
||||
}
|
||||
|
||||
export type FunnelAnswers = Record<string, string[]>;
|
||||
Loading…
Reference in New Issue
Block a user