добавил воронку

This commit is contained in:
dev.daminik00 2025-09-25 18:04:52 +02:00
parent 40e1d6ca21
commit 84fb57ab60
42 changed files with 6339 additions and 35 deletions

4
public/GuardIcon.svg Normal file
View 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

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

View 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} />;
}

View 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}`);
}

View 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>
);
}

View File

@ -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>
);

View 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>
);
}

View 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>
);
}

View 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">
Предпросмотр для типа &ldquo;{(selectedScreen as any).template}&rdquo; не поддерживается.
</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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}
}

View 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>
);
}

View 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";

View 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,
});
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View File

@ -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} />

View File

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

View 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>;
}

View File

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

View File

@ -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 };

View File

@ -26,6 +26,10 @@ function RadioAnswersList({
activeAnswer
);
useEffect(() => {
setSelectedAnswer(activeAnswer ?? null);
}, [activeAnswer]);
const handleAnswerClick = (answer: MainButtonProps) => {
setSelectedAnswer(answer);
onAnswerClick?.(answer);

View File

@ -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(

View 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 };

View 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,
}));
}

View 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[];
}

View 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;
}

View 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,
};
}

View 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,
};
}

View 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
View 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;
}

View 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
View 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[]>;