diff --git a/src/components/admin/builder/BuilderCanvas.tsx b/src/components/admin/builder/BuilderCanvas.tsx
index e1ec904..5a835e5 100644
--- a/src/components/admin/builder/BuilderCanvas.tsx
+++ b/src/components/admin/builder/BuilderCanvas.tsx
@@ -1,625 +1,20 @@
-"use client";
+/**
+ * @deprecated This file has been refactored into modular structure.
+ * Use imports from "./Canvas" instead:
+ * - BuilderCanvas main component
+ * - DropIndicator, TransitionRow, TemplateSummary, VariantSummary sub-components
+ * - TEMPLATE_TITLES, OPERATOR_LABELS constants
+ * - getOptionLabel utility
+ */
-import React, { useCallback, useMemo, useRef, useState } from "react";
-import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
-import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
-import { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog";
-import type {
- ListOptionDefinition,
- NavigationConditionDefinition,
- ScreenDefinition,
- ScreenVariantDefinition,
-} from "@/lib/funnel/types";
-import { cn } from "@/lib/utils";
-
-function DropIndicator({ isActive }: { isActive: boolean }) {
- return (
-
- );
-}
-
-const TEMPLATE_TITLES: Record = {
- list: "Список",
- form: "Форма",
- info: "Инфо",
- date: "Дата",
- coupon: "Купон",
- email: "Email",
- loaders: "Загрузка",
- soulmate: "Портрет партнера",
-};
-
-const OPERATOR_LABELS: Record, string> = {
- includesAny: "любой из",
- includesAll: "все из",
- includesExactly: "точное совпадение",
- equals: "равно",
-};
-
-interface TransitionRowProps {
- type: "default" | "branch" | "end";
- label: string;
- targetLabel?: string;
- targetIndex?: number | null;
- optionSummaries?: { id: string; label: string }[];
- operator?: string;
-}
-
-function TransitionRow({
- type,
- label,
- targetLabel,
- targetIndex,
- optionSummaries = [],
- operator,
-}: TransitionRowProps) {
- const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
-
- return (
-
-
-
-
-
-
-
- {label}
-
- {operator && (
-
- {operator}
-
- )}
-
- {optionSummaries.length > 0 && (
-
- {optionSummaries.map((option) => (
-
- {option.label}
-
- ))}
-
- )}
-
- {type === "end" ? (
-
Завершение воронки
- ) : (
- <>
-
- {typeof targetIndex === "number" && (
-
- #{targetIndex + 1}
-
- )}
-
- {targetLabel ?? "Не выбрано"}
-
- >
- )}
-
-
-
- );
-}
-
-function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
- switch (screen.template) {
- case "list": {
- return (
-
-
-
- Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
-
-
-
-
Варианты ({screen.list.options.length})
-
- {screen.list.options.map((option) => (
-
- {option.emoji && {option.emoji}}
- {option.label}
-
- ))}
-
-
-
- );
- }
- case "form": {
- return (
-
-
-
- Полей: {screen.fields.length}
-
- {screen.bottomActionButton?.text && (
-
- {screen.bottomActionButton.text}
-
- )}
-
- {screen.validationMessages && (
-
-
- Настроены пользовательские сообщения валидации
-
-
- )}
-
- );
- }
- case "coupon": {
- return (
-
-
- Промо: {screen.coupon.promoCode.text}
-
-
{screen.coupon.offer.title.text}
-
- );
- }
- case "date": {
- return (
-
-
Формат даты:
-
- {screen.dateInput.monthLabel && {screen.dateInput.monthLabel}}
- {screen.dateInput.dayLabel && {screen.dateInput.dayLabel}}
- {screen.dateInput.yearLabel && {screen.dateInput.yearLabel}}
-
- {screen.dateInput.validationMessage && (
-
{screen.dateInput.validationMessage}
- )}
-
- );
- }
- case "info": {
- return (
-
- {screen.description?.text &&
{screen.description.text}
}
- {screen.icon?.value && (
-
- {screen.icon.value}
- Иконка
-
- )}
-
- );
- }
- default:
- return null;
- }
-}
-
-function VariantSummary({
- screen,
- screenTitleMap,
- listOptionsMap,
-}: {
- screen: ScreenDefinition;
- screenTitleMap: Record;
- listOptionsMap: Record;
-}) {
- const variants = (
- screen as ScreenDefinition & {
- variants?: ScreenVariantDefinition[];
- }
- ).variants;
-
- if (!variants || variants.length === 0) {
- return null;
- }
-
- return (
-
-
-
Варианты
-
-
{variants.length}
-
-
-
- {variants.map((variant, index) => {
- const [condition] = variant.conditions ?? [];
- const controllingScreenId = condition?.screenId;
- const controllingScreenTitle = controllingScreenId
- ? screenTitleMap[controllingScreenId] ?? controllingScreenId
- : "Не выбрано";
-
- const options = controllingScreenId ? listOptionsMap[controllingScreenId] ?? [] : [];
- const optionSummaries = (condition?.optionIds ?? []).map((optionId) => ({
- id: optionId,
- label: getOptionLabel(options, optionId),
- }));
-
- const operatorKey = condition?.operator as
- | Exclude
- | undefined;
- const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
-
- const overrideHighlights = listOverridePaths(variant.overrides ?? {});
-
- return (
-
-
- Вариант {index + 1}
-
- {operatorLabel}
-
-
-
-
-
- Экран: {controllingScreenTitle}
-
- {optionSummaries.length > 0 ? (
-
- {optionSummaries.map((option) => (
-
- {option.label}
-
- ))}
-
- ) : (
-
Нет выбранных ответов
- )}
-
-
-
-
Изменяет:
-
- {(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
-
- {highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
-
- ))}
-
-
-
- );
- })}
-
-
- );
-}
-
-function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
- const option = options.find((item) => item.id === optionId);
- return option ? option.label : optionId;
-}
-
-export function BuilderCanvas() {
- const { screens, selectedScreenId } = useBuilderState();
- const dispatch = useBuilderDispatch();
-
- const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
- const [dropIndex, setDropIndex] = useState(null);
- const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false);
-
- const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
- event.dataTransfer.effectAllowed = "move";
- event.dataTransfer.setData("text/plain", screenId);
- dragStateRef.current = { screenId, dragStartIndex: index };
- setDropIndex(index);
- }, []);
-
- const handleDragOverCard = useCallback((event: React.DragEvent, index: number) => {
- event.preventDefault();
- if (!dragStateRef.current) {
- return;
- }
-
- const rect = event.currentTarget.getBoundingClientRect();
- const offsetY = event.clientY - rect.top;
- const nextIndex = offsetY > rect.height / 2 ? index + 1 : index;
- setDropIndex(nextIndex);
- }, []);
-
- const handleDragOverList = useCallback(
- (event: React.DragEvent) => {
- if (!dragStateRef.current) {
- return;
- }
- event.preventDefault();
- if (event.target === event.currentTarget) {
- setDropIndex(screens.length);
- }
- },
- [screens.length]
- );
-
- const finalizeDrop = useCallback(
- (insertionIndex: number | null) => {
- if (!dragStateRef.current) {
- return;
- }
-
- const { dragStartIndex } = dragStateRef.current;
- const boundedIndex = Math.max(0, Math.min(insertionIndex ?? dragStartIndex, screens.length));
- let targetIndex = boundedIndex;
-
- if (targetIndex > dragStartIndex) {
- targetIndex -= 1;
- }
-
- if (dragStartIndex !== targetIndex) {
- dispatch({
- type: "reorder-screens",
- payload: {
- fromIndex: dragStartIndex,
- toIndex: targetIndex,
- },
- });
- }
-
- dragStateRef.current = null;
- setDropIndex(null);
- },
- [dispatch, screens.length]
- );
-
- const handleDrop = useCallback(
- (event: React.DragEvent) => {
- event.preventDefault();
- finalizeDrop(dropIndex);
- },
- [dropIndex, finalizeDrop]
- );
-
- const handleDragEnd = useCallback(() => {
- dragStateRef.current = null;
- setDropIndex(null);
- }, []);
-
- const handleSelectScreen = useCallback(
- (screenId: string) => {
- dispatch({ type: "set-selected-screen", payload: { screenId } });
- },
- [dispatch]
- );
-
- const handleAddScreen = useCallback(() => {
- setAddScreenDialogOpen(true);
- }, []);
-
- const handleAddScreenWithTemplate = useCallback((template: ScreenDefinition["template"]) => {
- dispatch({ type: "add-screen", payload: { template } });
- }, [dispatch]);
-
- const screenTitleMap = useMemo(() => {
- return screens.reduce>((accumulator, screen) => {
- accumulator[screen.id] = screen.title.text || screen.id;
- return accumulator;
- }, {});
- }, [screens]);
-
- const listOptionsMap = useMemo(() => {
- return screens.reduce>((accumulator, screen) => {
- if (screen.template === "list") {
- accumulator[screen.id] = screen.list.options;
- }
- return accumulator;
- }, {});
- }, [screens]);
-
- return (
- <>
-
-
-
-
Экраны воронки
-
-
-
-
-
-
-
-
- {screens.map((screen, index) => {
- const isSelected = screen.id === selectedScreenId;
- const isDropBefore = dropIndex === index;
- const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
- const rules = screen.navigation?.rules ?? [];
- const defaultNext = screen.navigation?.defaultNextScreenId;
- const isLast = index === screens.length - 1;
- const defaultTargetIndex = defaultNext
- ? screens.findIndex((candidate) => candidate.id === defaultNext)
- : null;
-
- return (
-
- {isDropBefore &&
}
-
-
-
- {!isLast && (
-
- )}
-
-
handleDragStart(event, screen.id, index)}
- onDragOver={(event) => handleDragOverCard(event, index)}
- onDragEnd={handleDragEnd}
- onClick={() => handleSelectScreen(screen.id)}
- >
-
- {TEMPLATE_TITLES[screen.template] ?? screen.template}
-
-
-
-
- {index + 1}
-
-
-
- #{screen.id}
-
-
- {screen.title.text || "Без названия"}
-
-
-
-
-
- {("subtitle" in screen && screen.subtitle?.text) && (
-
- {screen.subtitle.text}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- {rules.map((rule, ruleIndex) => {
- const condition = rule.conditions[0];
- const optionSummaries =
- screen.template === "list" && condition?.optionIds
- ? condition.optionIds.map((optionId) => ({
- id: optionId,
- label: getOptionLabel(screen.list.options, optionId),
- }))
- : [];
-
- const operatorKey = condition?.operator as
- | Exclude
- | undefined;
- const operatorLabel = operatorKey
- ? OPERATOR_LABELS[operatorKey] ?? operatorKey
- : undefined;
-
- const ruleTargetIndex = screens.findIndex(
- (candidate) => candidate.id === rule.nextScreenId
- );
- const ruleTargetLabel = screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId;
-
- return (
-
- );
- })}
-
-
-
-
-
- {isDropAfter &&
}
-
- );
- })}
-
- {screens.length === 0 && (
-
- Добавьте первый экран, чтобы начать строить воронку.
-
- )}
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
+// Re-export everything from the new modular structure for backward compatibility
+export {
+ BuilderCanvas,
+ DropIndicator,
+ TransitionRow,
+ TemplateSummary,
+ VariantSummary,
+ getOptionLabel,
+ TEMPLATE_TITLES,
+ OPERATOR_LABELS,
+} from "./Canvas";
diff --git a/src/components/admin/builder/BuilderSidebar.tsx b/src/components/admin/builder/BuilderSidebar.tsx
index 51dddeb..631f931 100644
--- a/src/components/admin/builder/BuilderSidebar.tsx
+++ b/src/components/admin/builder/BuilderSidebar.tsx
@@ -1,678 +1,18 @@
-"use client";
+/**
+ * @deprecated This file has been refactored into modular structure.
+ * Use imports from "./Sidebar" instead:
+ * - BuilderSidebar main component
+ * - Section, ValidationSummary sub-components
+ * - isListScreen utility, ValidationIssues type
+ */
-import { useEffect, useMemo, useState, type ReactNode } from "react";
-import { ChevronDown, ChevronRight } from "lucide-react";
+// Re-export everything from the new modular structure for backward compatibility
+export {
+ BuilderSidebar,
+ Section,
+ ValidationSummary,
+ isListScreen,
+} from "./Sidebar";
-import { TextInput } from "@/components/ui/TextInput/TextInput";
-import { Button } from "@/components/ui/button";
-import { TemplateConfig } from "@/components/admin/builder/templates";
-import { ScreenVariantsConfig } from "@/components/admin/builder/ScreenVariantsConfig";
-import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
-import type { BuilderScreen } from "@/lib/admin/builder/types";
-import type {
- NavigationRuleDefinition,
- ScreenDefinition,
- ScreenVariantDefinition,
-} from "@/lib/funnel/types";
-import { cn } from "@/lib/utils";
-import { validateBuilderState } from "@/lib/admin/builder/validation";
-
-type ValidationIssues = ReturnType["issues"];
-
-function isListScreen(
- screen: BuilderScreen
-): screen is BuilderScreen & {
- list: {
- selectionType: "single" | "multi";
- options: Array<{ id: string; label: string; description?: string; emoji?: string }>;
- };
-} {
- return screen.template === "list" && "list" in screen;
-}
-
-function Section({
- title,
- description,
- children,
- defaultExpanded = false,
- alwaysExpanded = false,
-}: {
- title: string;
- description?: string;
- children: ReactNode;
- defaultExpanded?: boolean;
- alwaysExpanded?: boolean;
-}) {
- const storageKey = `section-${title.toLowerCase().replace(/\s+/g, '-')}`;
-
- const [isExpanded, setIsExpanded] = useState(defaultExpanded);
- const [isHydrated, setIsHydrated] = useState(false);
-
- useEffect(() => {
- if (alwaysExpanded) {
- setIsExpanded(true);
- setIsHydrated(true);
- return;
- }
-
- const stored = sessionStorage.getItem(storageKey);
- if (stored !== null) {
- setIsExpanded(JSON.parse(stored));
- }
- setIsHydrated(true);
- }, [alwaysExpanded, storageKey]);
-
- const handleToggle = () => {
- if (alwaysExpanded) return;
-
- const newExpanded = !isExpanded;
- setIsExpanded(newExpanded);
-
- if (typeof window !== 'undefined') {
- sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
- }
- };
-
- const effectiveExpanded = alwaysExpanded || (isHydrated ? isExpanded : defaultExpanded);
-
- return (
-
-
- {!alwaysExpanded && (
- effectiveExpanded ? (
-
- ) : (
-
- )
- )}
-
-
{title}
- {description &&
{description}
}
-
-
- {effectiveExpanded && (
- {children}
- )}
-
- );
-}
-
-function ValidationSummary({ issues }: { issues: ValidationIssues }) {
- if (issues.length === 0) {
- return (
-
- Всё хорошо — воронка валидна.
-
- );
- }
-
- return (
-
- {issues.map((issue, index) => (
-
-
-
⚠
-
-
{issue.message}
- {issue.screenId &&
Экран: {issue.screenId}
}
-
-
-
- ))}
-
- );
-}
-
-export function BuilderSidebar() {
- const state = useBuilderState();
- const dispatch = useBuilderDispatch();
- const selectedScreen = useBuilderSelectedScreen();
-
- const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel");
- const selectedScreenId = selectedScreen?.id ?? null;
-
- useEffect(() => {
- setActiveTab((previous) => {
- if (selectedScreenId) {
- return "screen";
- }
- return previous === "screen" ? "funnel" : previous;
- });
- }, [selectedScreenId]);
-
- const validation = useMemo(() => validateBuilderState(state), [state]);
- const screenValidationIssues = useMemo(() => {
- if (!selectedScreenId) {
- return [] as ValidationIssues;
- }
-
- return validation.issues.filter((issue) => issue.screenId === selectedScreenId);
- }, [selectedScreenId, validation]);
-
- 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 handleScreenIdChange = (currentId: string, newId: string) => {
- if (newId.trim() === "" || newId === currentId) {
- return;
- }
-
- // Обновляем ID экрана
- dispatch({
- type: "update-screen",
- payload: {
- screenId: currentId,
- screen: { id: newId }
- }
- });
-
- // Если это был первый экран в мета данных, обновляем и там
- if (state.meta.firstScreenId === currentId) {
- dispatch({ type: "set-meta", payload: { firstScreenId: newId } });
- }
- };
-
- const getScreenById = (screenId: string): BuilderScreen | undefined =>
- state.screens.find((item) => item.id === screenId);
-
- const updateNavigation = (
- screen: BuilderScreen,
- navigationUpdates: Partial = {}
- ) => {
- dispatch({
- type: "update-navigation",
- payload: {
- screenId: screen.id,
- navigation: {
- defaultNextScreenId:
- navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId,
- rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
- isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
- },
- },
- });
- };
-
- 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) => {
- if (!isListScreen(screen)) {
- return;
- }
-
- 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 } });
- };
-
- const handleTemplateUpdate = (screenId: string, updates: Partial) => {
- dispatch({
- type: "update-screen",
- payload: {
- screenId,
- screen: updates as Partial,
- },
- });
- };
-
- const handleVariantsChange = (
- screenId: string,
- variants: ScreenVariantDefinition[]
- ) => {
- dispatch({
- type: "update-screen",
- payload: {
- screenId,
- screen: {
- variants: variants.length > 0 ? variants : undefined,
- } as Partial,
- },
- });
- };
-
- const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
-
- return (
-
-
-
-
Настройки
-
-
-
-
-
-
-
-
- {activeTab === "funnel" ? (
-
-
-
-
- handleMetaChange("id", event.target.value)}
- />
- handleMetaChange("title", event.target.value)}
- />
- handleMetaChange("description", event.target.value)}
- />
-
-
-
-
-
-
- Всего экранов
- {state.screens.length}
-
-
- {state.screens.map((screen, index) => (
-
- {index + 1}. {screen.title.text}
- {screen.template}
-
- ))}
-
-
-
-
- ) : selectedScreen ? (
-
-
-
-
- #{selectedScreen.id}
-
- {selectedScreen.template}
-
-
-
- {state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length}
-
-
-
-
-
- handleScreenIdChange(selectedScreen.id, event.target.value)}
- />
-
-
-
- handleTemplateUpdate(selectedScreen.id, updates)}
- />
-
-
-
- handleVariantsChange(selectedScreen.id, variants)}
- />
-
-
-
-
- {selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
-
-
-
-
- Направляйте пользователей на разные экраны в зависимости от выбора.
-
-
-
-
- {(selectedScreen.navigation?.rules ?? []).length === 0 && (
-
- Правил пока нет
-
- )}
-
- {(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
-
-
- Правило {ruleIndex + 1}
-
-
-
-
- {selectedScreen.template === "list" ? (
-
-
Варианты ответа
-
- {selectedScreen.list.options.map((option) => {
- const condition = rule.conditions[0];
- const isChecked = condition.optionIds?.includes(option.id) ?? false;
- return (
-
- );
- })}
-
-
- ) : (
-
- Навигационные правила с вариантами ответа доступны только для экранов со списком.
-
- )}
-
-
-
- ))}
-
-
- )}
-
-
-
-
-
-
- Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
-
-
-
-
-
- ) : (
-
- Выберите экран в списке слева, чтобы настроить его параметры.
-
- )}
-
-
- );
-}
+// Re-export types for backward compatibility
+export type { ValidationIssues, SectionProps } from "./Sidebar";
diff --git a/src/components/admin/builder/Canvas/BuilderCanvas.tsx b/src/components/admin/builder/Canvas/BuilderCanvas.tsx
new file mode 100644
index 0000000..4af268d
--- /dev/null
+++ b/src/components/admin/builder/Canvas/BuilderCanvas.tsx
@@ -0,0 +1,318 @@
+"use client";
+
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { ArrowDown } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
+import { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog";
+import type {
+ ListOptionDefinition,
+ NavigationConditionDefinition,
+ ScreenDefinition,
+} from "@/lib/funnel/types";
+import { cn } from "@/lib/utils";
+import { DropIndicator } from "./DropIndicator";
+import { TransitionRow } from "./TransitionRow";
+import { TemplateSummary } from "./TemplateSummary";
+import { VariantSummary } from "./VariantSummary";
+import { getOptionLabel } from "./utils";
+import { TEMPLATE_TITLES, OPERATOR_LABELS } from "./constants";
+
+export function BuilderCanvas() {
+ const { screens, selectedScreenId } = useBuilderState();
+ const dispatch = useBuilderDispatch();
+
+ const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
+ const [dropIndex, setDropIndex] = useState(null);
+ const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false);
+
+ const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/plain", screenId);
+ dragStateRef.current = { screenId, dragStartIndex: index };
+ setDropIndex(index);
+ }, []);
+
+ const handleDragOverCard = useCallback((event: React.DragEvent, index: number) => {
+ event.preventDefault();
+ if (!dragStateRef.current) {
+ return;
+ }
+
+ const rect = event.currentTarget.getBoundingClientRect();
+ const offsetY = event.clientY - rect.top;
+ const nextIndex = offsetY > rect.height / 2 ? index + 1 : index;
+ setDropIndex(nextIndex);
+ }, []);
+
+ const handleDragOverList = useCallback(
+ (event: React.DragEvent) => {
+ if (!dragStateRef.current) {
+ return;
+ }
+ event.preventDefault();
+ if (event.target === event.currentTarget) {
+ setDropIndex(screens.length);
+ }
+ },
+ [screens.length]
+ );
+
+ const finalizeDrop = useCallback(
+ (insertionIndex: number | null) => {
+ if (!dragStateRef.current) {
+ return;
+ }
+
+ const { dragStartIndex } = dragStateRef.current;
+ const boundedIndex = Math.max(0, Math.min(insertionIndex ?? dragStartIndex, screens.length));
+ let targetIndex = boundedIndex;
+
+ if (targetIndex > dragStartIndex) {
+ targetIndex -= 1;
+ }
+
+ if (dragStartIndex !== targetIndex) {
+ dispatch({
+ type: "reorder-screens",
+ payload: {
+ fromIndex: dragStartIndex,
+ toIndex: targetIndex,
+ },
+ });
+ }
+
+ dragStateRef.current = null;
+ setDropIndex(null);
+ },
+ [dispatch, screens.length]
+ );
+
+ const handleDrop = useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ finalizeDrop(dropIndex);
+ },
+ [dropIndex, finalizeDrop]
+ );
+
+ const handleDragEnd = useCallback(() => {
+ dragStateRef.current = null;
+ setDropIndex(null);
+ }, []);
+
+ const handleSelectScreen = useCallback(
+ (screenId: string) => {
+ dispatch({ type: "set-selected-screen", payload: { screenId } });
+ },
+ [dispatch]
+ );
+
+ const handleAddScreen = useCallback(() => {
+ setAddScreenDialogOpen(true);
+ }, []);
+
+ const handleAddScreenWithTemplate = useCallback((template: ScreenDefinition["template"]) => {
+ dispatch({ type: "add-screen", payload: { template } });
+ }, [dispatch]);
+
+ const screenTitleMap = useMemo(() => {
+ return screens.reduce>((accumulator, screen) => {
+ accumulator[screen.id] = screen.title.text || screen.id;
+ return accumulator;
+ }, {});
+ }, [screens]);
+
+ const listOptionsMap = useMemo(() => {
+ return screens.reduce>((accumulator, screen) => {
+ if (screen.template === "list") {
+ accumulator[screen.id] = screen.list.options;
+ }
+ return accumulator;
+ }, {});
+ }, [screens]);
+
+ return (
+ <>
+
+
+
+
Экраны воронки
+
+
+
+
+
+
+
+
+ {screens.map((screen, index) => {
+ const isSelected = screen.id === selectedScreenId;
+ const isDropBefore = dropIndex === index;
+ const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
+ const rules = screen.navigation?.rules ?? [];
+ const defaultNext = screen.navigation?.defaultNextScreenId;
+ const isLast = index === screens.length - 1;
+ const defaultTargetIndex = defaultNext
+ ? screens.findIndex((candidate) => candidate.id === defaultNext)
+ : null;
+
+ return (
+
+ {isDropBefore &&
}
+
+
+
+ {!isLast && (
+
+ )}
+
+
handleDragStart(event, screen.id, index)}
+ onDragOver={(event) => handleDragOverCard(event, index)}
+ onDragEnd={handleDragEnd}
+ onClick={() => handleSelectScreen(screen.id)}
+ >
+
+ {TEMPLATE_TITLES[screen.template] ?? screen.template}
+
+
+
+
+ {index + 1}
+
+
+
+ #{screen.id}
+
+
+ {screen.title.text || "Без названия"}
+
+
+
+
+
+ {("subtitle" in screen && screen.subtitle?.text) && (
+
+ {screen.subtitle.text}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {rules.map((rule, ruleIndex) => {
+ const condition = rule.conditions[0];
+ const optionSummaries =
+ screen.template === "list" && condition?.optionIds
+ ? condition.optionIds.map((optionId) => ({
+ id: optionId,
+ label: getOptionLabel(screen.list.options, optionId),
+ }))
+ : [];
+
+ const operatorKey = condition?.operator as
+ | Exclude
+ | undefined;
+ const operatorLabel = operatorKey
+ ? OPERATOR_LABELS[operatorKey] ?? operatorKey
+ : undefined;
+
+ const ruleTargetIndex = screens.findIndex(
+ (candidate) => candidate.id === rule.nextScreenId
+ );
+ const ruleTargetLabel = screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId;
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+ {isDropAfter &&
}
+
+ );
+ })}
+
+ {screens.length === 0 && (
+
+ Добавьте первый экран, чтобы начать строить воронку.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/admin/builder/Canvas/DropIndicator.tsx b/src/components/admin/builder/Canvas/DropIndicator.tsx
new file mode 100644
index 0000000..ee0048a
--- /dev/null
+++ b/src/components/admin/builder/Canvas/DropIndicator.tsx
@@ -0,0 +1,16 @@
+import { cn } from "@/lib/utils";
+
+interface DropIndicatorProps {
+ isActive: boolean;
+}
+
+export function DropIndicator({ isActive }: DropIndicatorProps) {
+ return (
+
+ );
+}
diff --git a/src/components/admin/builder/Canvas/TemplateSummary.tsx b/src/components/admin/builder/Canvas/TemplateSummary.tsx
new file mode 100644
index 0000000..c29dd37
--- /dev/null
+++ b/src/components/admin/builder/Canvas/TemplateSummary.tsx
@@ -0,0 +1,98 @@
+import type { ScreenDefinition } from "@/lib/funnel/types";
+
+export interface TemplateSummaryProps {
+ screen: ScreenDefinition;
+}
+
+export function TemplateSummary({ screen }: TemplateSummaryProps) {
+ switch (screen.template) {
+ case "list": {
+ return (
+
+
+
+ Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
+
+
+
+
Варианты ({screen.list.options.length})
+
+ {screen.list.options.map((option) => (
+
+ {option.emoji && {option.emoji}}
+ {option.label}
+
+ ))}
+
+
+
+ );
+ }
+ case "form": {
+ return (
+
+
+
+ Полей: {screen.fields.length}
+
+ {screen.bottomActionButton?.text && (
+
+ {screen.bottomActionButton.text}
+
+ )}
+
+ {screen.validationMessages && (
+
+
+ Настроены пользовательские сообщения валидации
+
+
+ )}
+
+ );
+ }
+ case "coupon": {
+ return (
+
+
+ Промо: {screen.coupon.promoCode.text}
+
+
{screen.coupon.offer.title.text}
+
+ );
+ }
+ case "date": {
+ return (
+
+
Формат даты:
+
+ {screen.dateInput.monthLabel && {screen.dateInput.monthLabel}}
+ {screen.dateInput.dayLabel && {screen.dateInput.dayLabel}}
+ {screen.dateInput.yearLabel && {screen.dateInput.yearLabel}}
+
+ {screen.dateInput.validationMessage && (
+
{screen.dateInput.validationMessage}
+ )}
+
+ );
+ }
+ case "info": {
+ return (
+
+ {screen.description?.text &&
{screen.description.text}
}
+ {screen.icon?.value && (
+
+ {screen.icon.value}
+ Иконка
+
+ )}
+
+ );
+ }
+ default:
+ return null;
+ }
+}
diff --git a/src/components/admin/builder/Canvas/TransitionRow.tsx b/src/components/admin/builder/Canvas/TransitionRow.tsx
new file mode 100644
index 0000000..98c43fb
--- /dev/null
+++ b/src/components/admin/builder/Canvas/TransitionRow.tsx
@@ -0,0 +1,88 @@
+import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface TransitionRowProps {
+ type: "default" | "branch" | "end";
+ label: string;
+ targetLabel?: string;
+ targetIndex?: number | null;
+ optionSummaries?: { id: string; label: string }[];
+ operator?: string;
+}
+
+export function TransitionRow({
+ type,
+ label,
+ targetLabel,
+ targetIndex,
+ optionSummaries = [],
+ operator,
+}: TransitionRowProps) {
+ const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+ {operator && (
+
+ {operator}
+
+ )}
+
+ {optionSummaries.length > 0 && (
+
+ {optionSummaries.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ )}
+
+ {type === "end" ? (
+
Завершение воронки
+ ) : (
+ <>
+
+ {typeof targetIndex === "number" && (
+
+ #{targetIndex + 1}
+
+ )}
+
+ {targetLabel ?? "Не выбрано"}
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/components/admin/builder/Canvas/VariantSummary.tsx b/src/components/admin/builder/Canvas/VariantSummary.tsx
new file mode 100644
index 0000000..4d88f06
--- /dev/null
+++ b/src/components/admin/builder/Canvas/VariantSummary.tsx
@@ -0,0 +1,109 @@
+import type {
+ ScreenDefinition,
+ ScreenVariantDefinition,
+ ListOptionDefinition,
+ NavigationConditionDefinition
+} from "@/lib/funnel/types";
+import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
+import { getOptionLabel } from "./utils";
+import { OPERATOR_LABELS } from "./constants";
+
+export interface VariantSummaryProps {
+ screen: ScreenDefinition;
+ screenTitleMap: Record;
+ listOptionsMap: Record;
+}
+
+export function VariantSummary({
+ screen,
+ screenTitleMap,
+ listOptionsMap,
+}: VariantSummaryProps) {
+ const variants = (
+ screen as ScreenDefinition & {
+ variants?: ScreenVariantDefinition[];
+ }
+ ).variants;
+
+ if (!variants || variants.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
Варианты
+
+
{variants.length}
+
+
+
+ {variants.map((variant, index) => {
+ const [condition] = variant.conditions ?? [];
+ const controllingScreenId = condition?.screenId;
+ const controllingScreenTitle = controllingScreenId
+ ? screenTitleMap[controllingScreenId] ?? controllingScreenId
+ : "Не выбрано";
+
+ const options = controllingScreenId ? listOptionsMap[controllingScreenId] ?? [] : [];
+ const optionSummaries = (condition?.optionIds ?? []).map((optionId) => ({
+ id: optionId,
+ label: getOptionLabel(options, optionId),
+ }));
+
+ const operatorKey = condition?.operator as
+ | Exclude
+ | undefined;
+ const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
+
+ const overrideHighlights = listOverridePaths(variant.overrides ?? {});
+
+ return (
+
+
+ Вариант {index + 1}
+
+ {operatorLabel}
+
+
+
+
+
+ Экран: {controllingScreenTitle}
+
+ {optionSummaries.length > 0 ? (
+
+ {optionSummaries.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ ) : (
+
Нет выбранных ответов
+ )}
+
+
+
+
Изменяет:
+
+ {(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
+
+ {highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
+
+ ))}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/admin/builder/Canvas/constants.ts b/src/components/admin/builder/Canvas/constants.ts
new file mode 100644
index 0000000..c72d4e0
--- /dev/null
+++ b/src/components/admin/builder/Canvas/constants.ts
@@ -0,0 +1,19 @@
+import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types";
+
+export const TEMPLATE_TITLES: Record = {
+ list: "Список",
+ form: "Форма",
+ info: "Инфо",
+ date: "Дата",
+ coupon: "Купон",
+ email: "Email",
+ loaders: "Загрузка",
+ soulmate: "Портрет партнера",
+};
+
+export const OPERATOR_LABELS: Record, string> = {
+ includesAny: "любой из",
+ includesAll: "все из",
+ includesExactly: "точное совпадение",
+ equals: "равно",
+};
diff --git a/src/components/admin/builder/Canvas/index.ts b/src/components/admin/builder/Canvas/index.ts
new file mode 100644
index 0000000..8f409a3
--- /dev/null
+++ b/src/components/admin/builder/Canvas/index.ts
@@ -0,0 +1,17 @@
+// Main component
+export { BuilderCanvas } from "./BuilderCanvas";
+
+// Sub-components
+export { DropIndicator } from "./DropIndicator";
+export { TransitionRow } from "./TransitionRow";
+export { TemplateSummary } from "./TemplateSummary";
+export { VariantSummary } from "./VariantSummary";
+
+// Types
+export type { TransitionRowProps } from "./TransitionRow";
+export type { TemplateSummaryProps } from "./TemplateSummary";
+export type { VariantSummaryProps } from "./VariantSummary";
+
+// Utils and constants
+export { getOptionLabel } from "./utils";
+export { TEMPLATE_TITLES, OPERATOR_LABELS } from "./constants";
diff --git a/src/components/admin/builder/Canvas/utils.ts b/src/components/admin/builder/Canvas/utils.ts
new file mode 100644
index 0000000..c5239e9
--- /dev/null
+++ b/src/components/admin/builder/Canvas/utils.ts
@@ -0,0 +1,9 @@
+import type { ListOptionDefinition } from "@/lib/funnel/types";
+
+/**
+ * Получает лейбл опции по ID
+ */
+export function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
+ const option = options.find((item) => item.id === optionId);
+ return option ? option.label : optionId;
+}
diff --git a/src/components/admin/builder/Sidebar/BuilderSidebar.tsx b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx
new file mode 100644
index 0000000..4216edf
--- /dev/null
+++ b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx
@@ -0,0 +1,564 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { TextInput } from "@/components/ui/TextInput/TextInput";
+import { Button } from "@/components/ui/button";
+import { TemplateConfig } from "@/components/admin/builder/templates";
+import { ScreenVariantsConfig } from "@/components/admin/builder/ScreenVariantsConfig";
+import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
+import type { BuilderScreen } from "@/lib/admin/builder/types";
+import type {
+ NavigationRuleDefinition,
+ ScreenDefinition,
+ ScreenVariantDefinition,
+} from "@/lib/funnel/types";
+import { cn } from "@/lib/utils";
+import { validateBuilderState } from "@/lib/admin/builder/validation";
+import { Section } from "./Section";
+import { ValidationSummary } from "./ValidationSummary";
+import { isListScreen, type ValidationIssues } from "./types";
+
+export function BuilderSidebar() {
+ const state = useBuilderState();
+ const dispatch = useBuilderDispatch();
+ const selectedScreen = useBuilderSelectedScreen();
+
+ const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel");
+ const selectedScreenId = selectedScreen?.id ?? null;
+
+ useEffect(() => {
+ setActiveTab((previous) => {
+ if (selectedScreenId) {
+ return "screen";
+ }
+ return previous === "screen" ? "funnel" : previous;
+ });
+ }, [selectedScreenId]);
+
+ const validation = useMemo(() => validateBuilderState(state), [state]);
+ const screenValidationIssues = useMemo(() => {
+ if (!selectedScreenId) {
+ return [] as ValidationIssues;
+ }
+
+ return validation.issues.filter((issue) => issue.screenId === selectedScreenId);
+ }, [selectedScreenId, validation]);
+
+ 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 handleScreenIdChange = (currentId: string, newId: string) => {
+ if (newId.trim() === "" || newId === currentId) {
+ return;
+ }
+
+ // Обновляем ID экрана
+ dispatch({
+ type: "update-screen",
+ payload: {
+ screenId: currentId,
+ screen: { id: newId }
+ }
+ });
+
+ // Если это был первый экран в мета данных, обновляем и там
+ if (state.meta.firstScreenId === currentId) {
+ dispatch({ type: "set-meta", payload: { firstScreenId: newId } });
+ }
+ };
+
+ const getScreenById = (screenId: string): BuilderScreen | undefined =>
+ state.screens.find((item) => item.id === screenId);
+
+ const updateNavigation = (
+ screen: BuilderScreen,
+ navigationUpdates: Partial = {}
+ ) => {
+ dispatch({
+ type: "update-navigation",
+ payload: {
+ screenId: screen.id,
+ navigation: {
+ defaultNextScreenId:
+ navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId,
+ rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
+ isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
+ },
+ },
+ });
+ };
+
+ 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) => {
+ if (!isListScreen(screen)) {
+ return;
+ }
+
+ 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 } });
+ };
+
+ const handleTemplateUpdate = (screenId: string, updates: Partial) => {
+ dispatch({
+ type: "update-screen",
+ payload: {
+ screenId,
+ screen: updates as Partial,
+ },
+ });
+ };
+
+ const handleVariantsChange = (
+ screenId: string,
+ variants: ScreenVariantDefinition[]
+ ) => {
+ dispatch({
+ type: "update-screen",
+ payload: {
+ screenId,
+ screen: {
+ variants: variants.length > 0 ? variants : undefined,
+ } as Partial,
+ },
+ });
+ };
+
+ const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
+
+ return (
+
+
+
+
Настройки
+
+
+
+
+
+
+
+
+ {activeTab === "funnel" ? (
+
+
+
+
+ handleMetaChange("id", event.target.value)}
+ />
+ handleMetaChange("title", event.target.value)}
+ />
+ handleMetaChange("description", event.target.value)}
+ />
+
+
+
+
+
+
+ Всего экранов
+ {state.screens.length}
+
+
+ {state.screens.map((screen, index) => (
+
+ {index + 1}. {screen.title.text}
+ {screen.template}
+
+ ))}
+
+
+
+
+ ) : selectedScreen ? (
+
+
+
+
+ #{selectedScreen.id}
+
+ {selectedScreen.template}
+
+
+
+ {state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length}
+
+
+
+
+
+ handleScreenIdChange(selectedScreen.id, event.target.value)}
+ />
+
+
+
+ handleTemplateUpdate(selectedScreen.id, updates)}
+ />
+
+
+
+ handleVariantsChange(selectedScreen.id, variants)}
+ />
+
+
+
+
+ {selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
+
+
+
+
+ Направляйте пользователей на разные экраны в зависимости от выбора.
+
+
+
+
+ {(selectedScreen.navigation?.rules ?? []).length === 0 && (
+
+ Правил пока нет
+
+ )}
+
+ {(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
+
+
+ Правило {ruleIndex + 1}
+
+
+
+
+ {selectedScreen.template === "list" ? (
+
+
Варианты ответа
+
+ {selectedScreen.list.options.map((option) => {
+ const condition = rule.conditions[0];
+ const isChecked = condition.optionIds?.includes(option.id) ?? false;
+ return (
+
+ );
+ })}
+
+
+ ) : (
+
+ Навигационные правила с вариантами ответа доступны только для экранов со списком.
+
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
+
+
+
+
+
+ ) : (
+
+ Выберите экран в списке слева, чтобы настроить его параметры.
+
+ )}
+
+
+ );
+}
diff --git a/src/components/admin/builder/Sidebar/Section.tsx b/src/components/admin/builder/Sidebar/Section.tsx
new file mode 100644
index 0000000..d02b731
--- /dev/null
+++ b/src/components/admin/builder/Sidebar/Section.tsx
@@ -0,0 +1,78 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { ChevronDown, ChevronRight } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface SectionProps {
+ title: string;
+ description?: string;
+ children: ReactNode;
+ defaultExpanded?: boolean;
+ alwaysExpanded?: boolean;
+}
+
+export function Section({
+ title,
+ description,
+ children,
+ defaultExpanded = false,
+ alwaysExpanded = false,
+}: SectionProps) {
+ const storageKey = `section-${title.toLowerCase().replace(/\s+/g, '-')}`;
+
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+ const [isHydrated, setIsHydrated] = useState(false);
+
+ useEffect(() => {
+ if (alwaysExpanded) {
+ setIsExpanded(true);
+ setIsHydrated(true);
+ return;
+ }
+
+ const stored = sessionStorage.getItem(storageKey);
+ if (stored !== null) {
+ setIsExpanded(JSON.parse(stored));
+ }
+ setIsHydrated(true);
+ }, [alwaysExpanded, storageKey]);
+
+ const handleToggle = () => {
+ if (alwaysExpanded) return;
+
+ const newExpanded = !isExpanded;
+ setIsExpanded(newExpanded);
+
+ if (typeof window !== 'undefined') {
+ sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
+ }
+ };
+
+ const effectiveExpanded = alwaysExpanded || (isHydrated ? isExpanded : defaultExpanded);
+
+ return (
+
+
+ {!alwaysExpanded && (
+ effectiveExpanded ? (
+
+ ) : (
+
+ )
+ )}
+
+
{title}
+ {description &&
{description}
}
+
+
+ {effectiveExpanded && (
+ {children}
+ )}
+
+ );
+}
diff --git a/src/components/admin/builder/Sidebar/ValidationSummary.tsx b/src/components/admin/builder/Sidebar/ValidationSummary.tsx
new file mode 100644
index 0000000..116dcf8
--- /dev/null
+++ b/src/components/admin/builder/Sidebar/ValidationSummary.tsx
@@ -0,0 +1,34 @@
+import type { ValidationIssues } from "./types";
+
+export interface ValidationSummaryProps {
+ issues: ValidationIssues;
+}
+
+export function ValidationSummary({ issues }: ValidationSummaryProps) {
+ if (issues.length === 0) {
+ return (
+
+ Всё хорошо — воронка валидна.
+
+ );
+ }
+
+ return (
+
+ {issues.map((issue, index) => (
+
+
+
⚠
+
+
{issue.message}
+ {issue.screenId &&
Экран: {issue.screenId}
}
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/admin/builder/Sidebar/index.ts b/src/components/admin/builder/Sidebar/index.ts
new file mode 100644
index 0000000..fda2344
--- /dev/null
+++ b/src/components/admin/builder/Sidebar/index.ts
@@ -0,0 +1,11 @@
+// Main component
+export { BuilderSidebar } from "./BuilderSidebar";
+
+// Sub-components
+export { Section } from "./Section";
+export { ValidationSummary } from "./ValidationSummary";
+
+// Types and utilities
+export { isListScreen } from "./types";
+export type { ValidationIssues, SectionProps } from "./types";
+export type { ValidationSummaryProps } from "./ValidationSummary";
diff --git a/src/components/admin/builder/Sidebar/types.ts b/src/components/admin/builder/Sidebar/types.ts
new file mode 100644
index 0000000..9d7fe27
--- /dev/null
+++ b/src/components/admin/builder/Sidebar/types.ts
@@ -0,0 +1,27 @@
+import type { ReactNode } from "react";
+import type { BuilderScreen } from "@/lib/admin/builder/types";
+import type { validateBuilderState } from "@/lib/admin/builder/validation";
+
+export type ValidationIssues = ReturnType["issues"];
+
+export interface SectionProps {
+ title: string;
+ description?: string;
+ children: ReactNode;
+ defaultExpanded?: boolean;
+ alwaysExpanded?: boolean;
+}
+
+/**
+ * Type guard для проверки что экран является list экраном
+ */
+export function isListScreen(
+ screen: BuilderScreen
+): screen is BuilderScreen & {
+ list: {
+ selectionType: "single" | "multi";
+ options: Array<{ id: string; label: string; description?: string; emoji?: string }>;
+ };
+} {
+ return screen.template === "list" && "list" in screen;
+}
diff --git a/src/components/funnel/templates/CouponTemplate.tsx b/src/components/funnel/templates/CouponTemplate.tsx
index f939bc0..5c5e44a 100644
--- a/src/components/funnel/templates/CouponTemplate.tsx
+++ b/src/components/funnel/templates/CouponTemplate.tsx
@@ -33,11 +33,9 @@ export function CouponTemplate({
const handleCopyPromoCode = (code: string) => {
- // Copy to clipboard
navigator.clipboard.writeText(code);
setCopiedCode(code);
- // Reset copied state after 2 seconds
setTimeout(() => {
setCopiedCode(null);
}, 2000);
@@ -57,7 +55,6 @@ export function CouponTemplate({
screenProgress,
});
- // Build coupon props from screen definition
const couponProps = {
title: buildTypographyProps(screen.coupon.title, {
as: "h3" as const,
@@ -123,12 +120,10 @@ export function CouponTemplate({
return (
- {/* Coupon Widget */}
- {/* Copy Success Message */}
{copiedCode && (
{
const { month, day, year } = selectedDate;
if (!month || !day || !year) return null;
@@ -60,7 +45,6 @@ export function DateTemplate({
return null;
}, [selectedDate]);
- // Обработчик изменения даты - преобразуем ISO обратно в объект
const handleDateChange = (newIsoDate: string | null) => {
if (!newIsoDate) {
onDateChange({ month: "", day: "", year: "" });
@@ -81,7 +65,6 @@ export function DateTemplate({
});
};
- // 🎯 ЛОГИКА ВАЛИДАЦИИ ФОРМЫ ДЛЯ DATE - кнопка disabled пока дата не выбрана
const isFormValid = Boolean(isoDate);
return (
@@ -101,7 +84,6 @@ export function DateTemplate({
}}
>
- {/* Используем DateInput виджет разработчика */}
- {/* Info Message если есть */}
{screen.infoMessage && (
diff --git a/src/components/funnel/templates/EmailTemplate.tsx b/src/components/funnel/templates/EmailTemplate.tsx
index f83a87f..b78bb63 100644
--- a/src/components/funnel/templates/EmailTemplate.tsx
+++ b/src/components/funnel/templates/EmailTemplate.tsx
@@ -10,7 +10,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
-// 🎯 Схема валидации как в оригинале
const formSchema = z.object({
email: z.string().email({
message: "Please enter a valid email address",
@@ -35,10 +34,8 @@ export function EmailTemplate({
onContinue,
canGoBack,
onBack,
- // screenProgress не используется в email template - прогресс отключен
defaultTexts,
}: EmailTemplateProps) {
- // 🎯 Валидация через react-hook-form + zod как в оригинале
const [isTouched, setIsTouched] = useState(false);
const form = useForm
>({
@@ -67,7 +64,7 @@ export function EmailTemplate({
onContinue={onContinue}
canGoBack={canGoBack}
onBack={onBack}
- screenProgress={undefined} // 🚫 Отключаем прогресс бар по умолчанию
+ screenProgress={undefined}
defaultTexts={defaultTexts}
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
@@ -77,9 +74,7 @@ export function EmailTemplate({
onClick: onContinue,
}}
>
- {/* 🎨 Новая структура согласно требованиям */}
- {/* 📧 Email Input - с дефолтными значениями */}
- {/* 🖼️ Image - с зашитыми значениями как в оригинальном Email компоненте */}
{screen.image && (
)}
- {/* 🔒 Privacy Security Banner */}
>(formData);
const [errors, setErrors] = useState>({});
- // Sync with external form data
useEffect(() => {
setLocalFormData(formData);
}, [formData]);
- // Update external form data when local data changes
useEffect(() => {
onFormDataChange(localFormData);
}, [localFormData, onFormDataChange]);
@@ -69,7 +67,6 @@ export function FormTemplate({
const handleFieldChange = (fieldId: string, value: string) => {
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
- // Clear error if field becomes valid
if (errors[fieldId]) {
setErrors(prev => {
const newErrors = { ...prev };
diff --git a/src/components/funnel/templates/InfoTemplate.tsx b/src/components/funnel/templates/InfoTemplate.tsx
index 1b181af..f14d3cf 100644
--- a/src/components/funnel/templates/InfoTemplate.tsx
+++ b/src/components/funnel/templates/InfoTemplate.tsx
@@ -29,14 +29,14 @@ export function InfoTemplate({
const size = screen.icon?.size ?? "xl";
switch (size) {
case "sm":
- return "text-4xl"; // 36px
+ return "text-4xl";
case "md":
- return "text-5xl"; // 48px
+ return "text-5xl";
case "lg":
- return "text-6xl"; // 60px
+ return "text-6xl";
case "xl":
default:
- return "text-8xl"; // 128px
+ return "text-8xl";
}
}, [screen.icon?.size]);
@@ -58,8 +58,7 @@ export function InfoTemplate({
>
{/* Icon */}
{screen.icon && (
@@ -92,7 +91,7 @@ export function InfoTemplate({
{screen.description && (
{}} // Не используется, логика в actionButtonOptions.onClick
+ onContinue={() => {}}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
diff --git a/src/components/funnel/templates/LoadersTemplate.tsx b/src/components/funnel/templates/LoadersTemplate.tsx
index 6a4db08..10baac1 100644
--- a/src/components/funnel/templates/LoadersTemplate.tsx
+++ b/src/components/funnel/templates/LoadersTemplate.tsx
@@ -24,12 +24,10 @@ export function LoadersTemplate({
}: LoadersTemplateProps) {
const [isVisibleButton, setIsVisibleButton] = useState(false);
- // 🎯 Функция завершения анимации - активирует кнопку
const onAnimationEnd = () => {
setIsVisibleButton(true);
};
- // 🎨 Преобразуем данные screen definition в props для CircularProgressbarsList
const progressbarsListProps = {
progressbarItems: screen.progressbars?.items?.map((item: unknown, index: number) => {
const typedItem = item as {
@@ -70,14 +68,14 @@ export function LoadersTemplate({
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.nextButton || "Continue",
- disabled: !isVisibleButton, // 🎯 Кнопка неактивна пока анимация не завершится
+ disabled: !isVisibleButton,
onClick: onContinue,
}}
>
diff --git a/src/components/funnel/templates/SoulmatePortraitTemplate.tsx b/src/components/funnel/templates/SoulmatePortraitTemplate.tsx
index 30ecf36..5b05e19 100644
--- a/src/components/funnel/templates/SoulmatePortraitTemplate.tsx
+++ b/src/components/funnel/templates/SoulmatePortraitTemplate.tsx
@@ -36,9 +36,7 @@ export function SoulmatePortraitTemplate({
onClick: onContinue,
}}
>
- {/* 🎯 Точно как InfoTemplate - пустой контент, без иконки и description */}
- {/* Пустой контент - как InfoTemplate без иконки и без description */}
);
diff --git a/src/lib/admin/builder/context.tsx b/src/lib/admin/builder/context.tsx
index a3dd4da..6e66eb8 100644
--- a/src/lib/admin/builder/context.tsx
+++ b/src/lib/admin/builder/context.tsx
@@ -1,760 +1,25 @@
-"use client";
+/**
+ * @deprecated This file has been refactored into modular structure.
+ * Use imports from "./state" instead:
+ * - BuilderProvider, useBuilderState, useBuilderDispatch, useBuilderSelectedScreen
+ * - BuilderState, BuilderAction types
+ * - INITIAL_STATE, INITIAL_META, INITIAL_SCREEN constants
+ */
-import { createContext, useContext, useMemo, useReducer, type ReactNode } from "react";
-
-import type {
- BuilderFunnelState,
- BuilderScreen,
- BuilderScreenPosition,
-} from "@/lib/admin/builder/types";
-import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/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: {
- show: true,
- showBackButton: true,
- },
- title: {
- text: "Новый экран",
- font: "manrope",
- weight: "bold",
- align: "left",
- size: "2xl",
- color: "default",
- },
- subtitle: {
- text: "Добавьте детали справа",
- font: "manrope",
- weight: "medium",
- color: "default",
- align: "left",
- size: "lg",
- },
- bottomActionButton: {
- text: "Продолжить",
- show: true,
- },
- 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 }
- | { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial }
- | { type: "remove-screen"; payload: { screenId: string } }
- | { type: "update-screen"; payload: { screenId: string; screen: Partial } }
- | { 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[];
- isEndScreen?: boolean;
- };
- };
- }
- | { 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 createScreenByTemplate(template: ScreenDefinition["template"], id: string, position: BuilderScreenPosition): BuilderScreen {
- // ✅ Единые базовые настройки для ВСЕХ типов экранов
- const baseScreen = {
- id,
- position,
- // ✅ Современные настройки header (без устаревшего progress)
- header: {
- show: true,
- showBackButton: true,
- },
- // ✅ Базовые тексты согласно Figma
- title: {
- text: "Новый экран",
- font: "manrope" as const,
- weight: "bold" as const,
- align: "left" as const,
- size: "2xl" as const,
- color: "default" as const,
- },
- subtitle: {
- text: "Добавьте детали справа",
- font: "manrope" as const,
- weight: "medium" as const,
- color: "default" as const,
- align: "left" as const,
- size: "lg" as const,
- },
- // ✅ Единые настройки нижней кнопки
- bottomActionButton: {
- text: "Продолжить",
- show: true,
- },
- // ✅ Навигация
- navigation: {
- defaultNextScreenId: undefined,
- rules: [],
- },
- };
-
- switch (template) {
- case "info":
- // Деструктурируем baseScreen исключая subtitle для InfoScreenDefinition
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { subtitle, ...baseScreenWithoutSubtitle } = baseScreen;
- return {
- ...baseScreenWithoutSubtitle,
- template: "info",
- title: {
- text: "Заголовок информации",
- font: "manrope" as const,
- weight: "bold" as const,
- align: "center" as const, // 🎯 Центрированный заголовок по умолчанию
- size: "2xl" as const,
- color: "default" as const,
- },
- // 🚫 Подзаголовок не включается (InfoScreenDefinition не поддерживает subtitle)
- description: {
- text: "Добавьте описание для информационного экрана. Используйте **жирный текст** для выделения важного.",
- align: "center" as const, // 🎯 Центрированный текст
- },
- // 🚫 Иконка не добавляется по умолчанию - пользователь может добавить при необходимости
- };
-
- case "list":
- return {
- ...baseScreen,
- template: "list",
- list: {
- selectionType: "single" as const,
- options: [
- { id: "option-1", label: "Вариант 1" },
- { id: "option-2", label: "Вариант 2" },
- ],
- },
- };
-
- case "form":
- return {
- ...baseScreen,
- template: "form",
- fields: [
- {
- id: "field-1",
- label: "Имя",
- type: "text" as const,
- required: true
- },
- ],
- validationMessages: {
- required: "Это поле обязательно для заполнения",
- },
- };
-
- case "date":
- return {
- ...baseScreen,
- template: "date",
- dateInput: {
- monthLabel: "Месяц",
- dayLabel: "День",
- yearLabel: "Год",
- monthPlaceholder: "ММ",
- dayPlaceholder: "ДД",
- yearPlaceholder: "ГГГГ",
- showSelectedDate: true,
- selectedDateFormat: "dd MMMM yyyy",
- selectedDateLabel: "Выбранная дата:",
- },
- infoMessage: {
- text: "Мы используем эту информацию только для анализа",
- icon: "🔒",
- },
- };
-
- case "coupon":
- return {
- ...baseScreen,
- template: "coupon",
- header: {
- show: true,
- showBackButton: true,
- // Без прогресс-бара по умолчанию
- },
- title: {
- text: "Ваш промокод",
- font: "manrope" as const,
- weight: "bold" as const,
- align: "center" as const, // 🎯 Центрированный заголовок по умолчанию
- size: "2xl" as const,
- color: "default" as const,
- },
- subtitle: {
- text: "Специальное предложение для вас",
- font: "inter" as const,
- weight: "medium" as const,
- align: "center" as const, // 🎯 Центрированный подзаголовок по умолчанию
- color: "muted" as const,
- },
- coupon: {
- title: {
- text: "Ваш промокод готов!",
- },
- promoCode: {
- text: "PROMO2024",
- },
- offer: {
- title: {
- text: "Специальное предложение!",
- },
- description: {
- text: "Получите скидку с промокодом",
- },
- },
- footer: {
- text: "Промокод активен в течение 24 часов",
- },
- },
- copiedMessage: "Промокод скопирован!",
- bottomActionButton: {
- text: "Продолжить",
- show: true,
- // 🚫 БЕЗ PrivacyTermsConsent по умолчанию для купонов
- },
- };
-
- case "email":
- return {
- ...baseScreen,
- template: "email",
- header: {
- show: true,
- showBackButton: true, // ✅ Только кнопка назад, прогресс отключен
- },
- title: {
- text: "Портрет твоей второй половинки готов! Куда нам его отправить?",
- font: "manrope" as const,
- weight: "bold" as const,
- align: "center" as const,
- size: "2xl" as const,
- color: "default" as const,
- },
- subtitle: undefined, // 🚫 Нет подзаголовка по умолчанию
- emailInput: {
- label: "Email",
- placeholder: "Enter your Email",
- },
- image: {
- src: "/female-portrait.jpg", // 🎯 Дефолтная картинка для женщин
- },
- variants: [
- {
- // 🎯 Вариативность: для мужчин показывать другую картинку
- conditions: [
- {
- screenId: "gender", // Ссылка на экран выбора пола
- conditionType: "values",
- operator: "equals",
- values: ["male"] // Если выбран мужской пол
- }
- ],
- overrides: {
- image: {
- src: "/male-portrait.jpg", // 🎯 Картинка для мужчин
- }
- }
- }
- ],
- bottomActionButton: {
- text: "Получить результат",
- show: true,
- showPrivacyTermsConsent: true, // ✅ По умолчанию включено для email экранов
- },
- };
-
- case "loaders":
- return {
- ...baseScreen,
- template: "loaders",
- title: {
- text: "Создаем ваш персональный отчет",
- font: "manrope" as const,
- weight: "bold" as const,
- align: "center" as const,
- size: "2xl" as const,
- color: "default" as const,
- },
- subtitle: undefined, // 🚫 Убираем подзаголовок по умолчанию
- progressbars: {
- items: [
- {
- title: "Анализ ответов",
- processingTitle: "Анализируем ваши ответы...",
- completedTitle: "Анализ завершен",
- },
- {
- title: "Поиск совпадений",
- processingTitle: "Ищем идеальные совпадения...",
- completedTitle: "Совпадения найдены",
- },
- {
- title: "Создание портрета",
- processingTitle: "Создаем ваш портрет...",
- completedTitle: "Портрет готов",
- },
- ],
- transitionDuration: 5000,
- },
- };
-
- case "soulmate":
- // Деструктурируем baseScreen исключая subtitle для SoulmatePortraitScreenDefinition
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { subtitle: soulmateSubtitle, ...baseSoulmateScreen } = baseScreen;
- return {
- ...baseSoulmateScreen,
- template: "soulmate",
- header: {
- show: false, // ✅ Header показываем для заголовка
- showBackButton: false,
- },
- // 🎯 ТОЛЬКО заголовок по центру как в оригинале SoulmatePortrait
- title: {
- text: "Ваш идеальный партнер",
- font: "manrope" as const,
- weight: "bold" as const,
- size: "xl" as const,
- color: "primary" as const, // 🎯 text-primary как в оригинале
- align: "center" as const, // 🎯 По центру
- className: "leading-[125%]", // 🎯 Как в оригинале
- },
- // 🚫 Никакого description - ТОЛЬКО заголовок и кнопка!
- bottomActionButton: {
- text: "Получить портрет",
- show: true,
- showPrivacyTermsConsent: true, // ✅ По умолчанию включено для soulmate экранов
- },
- };
-
- default:
- // Fallback to info template
- return {
- ...baseScreen,
- template: "info",
- description: {
- text: "Добавьте описание для информационного экрана",
- },
- };
- }
-}
-
-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 template = action.payload?.template || "list";
- const position = {
- x: (action.payload?.position?.x ?? 120) + state.screens.length * 40,
- y: (action.payload?.position?.y ?? 120) + state.screens.length * 20,
- };
-
- const newScreen = createScreenByTemplate(template, nextId, position);
-
- // 🎯 АВТОМАТИЧЕСКОЕ СВЯЗЫВАНИЕ С ПРЕДЫДУЩИМ ЭКРАНОМ
- let updatedScreens = [...state.screens, newScreen];
-
- // Если есть предыдущий экран и у него нет defaultNextScreenId, связываем с новым
- if (state.screens.length > 0) {
- const lastScreen = state.screens[state.screens.length - 1];
- if (!lastScreen.navigation?.defaultNextScreenId) {
- // Обновляем предыдущий экран, чтобы он указывал на новый
- updatedScreens = updatedScreens.map(screen =>
- screen.id === lastScreen.id
- ? {
- ...screen,
- navigation: {
- ...screen.navigation,
- defaultNextScreenId: nextId,
- }
- }
- : screen
- );
- }
- }
-
- return withDirty(state, {
- ...state,
- screens: updatedScreens,
- 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;
- let nextSelectedScreenId = state.selectedScreenId;
-
- const nextScreens = state.screens.map((current) =>
- current.id === screenId
- ? (() => {
- const nextScreen = {
- ...current,
- ...screen,
- title: screen.title ? { ...current.title, ...screen.title } : current.title,
- ...(("subtitle" in screen && screen.subtitle !== undefined)
- ? { subtitle: screen.subtitle }
- : "subtitle" in current
- ? { subtitle: current.subtitle }
- : {}),
- ...(current.template === "list" && "list" in screen && screen.list
- ? {
- list: {
- ...(current as ListScreenDefinition & { position: BuilderScreenPosition }).list,
- ...screen.list,
- options:
- screen.list.options ??
- (current as ListScreenDefinition & { position: BuilderScreenPosition }).list.options,
- },
- }
- : {}),
- } as BuilderScreen;
-
- if ("variants" in screen) {
- if (Array.isArray(screen.variants) && screen.variants.length > 0) {
- nextScreen.variants = screen.variants;
- } else if ("variants" in nextScreen) {
- delete (nextScreen as Partial).variants;
- }
- }
-
- if (state.selectedScreenId === current.id && nextScreen.id !== current.id) {
- nextSelectedScreenId = nextScreen.id;
- }
-
- return nextScreen;
- })()
- : current
- );
-
- return withDirty(state, {
- ...state,
- screens: nextScreens,
- selectedScreenId: nextSelectedScreenId,
- });
- }
- 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 previousScreens = state.screens;
- const newScreens = [...previousScreens];
- const [movedScreen] = newScreens.splice(fromIndex, 1);
- newScreens.splice(toIndex, 0, movedScreen);
-
- const previousSequentialNext = new Map();
- const previousIndexMap = new Map();
- const newSequentialNext = new Map();
-
- previousScreens.forEach((screen, index) => {
- previousSequentialNext.set(screen.id, previousScreens[index + 1]?.id);
- previousIndexMap.set(screen.id, index);
- });
-
- newScreens.forEach((screen, index) => {
- newSequentialNext.set(screen.id, newScreens[index + 1]?.id);
- });
-
- const totalScreens = newScreens.length;
-
- const rewiredScreens = newScreens.map((screen, index) => {
- const prevIndex = previousIndexMap.get(screen.id);
- const prevSequential = previousSequentialNext.get(screen.id);
- const nextSequential = newScreens[index + 1]?.id;
- const navigation = screen.navigation;
- const hasRules = Boolean(navigation?.rules && navigation.rules.length > 0);
-
- let defaultNext = navigation?.defaultNextScreenId;
- if (!hasRules) {
- if (!defaultNext || defaultNext === prevSequential) {
- defaultNext = nextSequential;
- }
- } else if (defaultNext === prevSequential) {
- defaultNext = nextSequential;
- }
-
- const updatedNavigation = (() => {
- if ((navigation?.rules && navigation.rules.length > 0) || defaultNext) {
- // Обновляем nextScreenId в правилах навигации при reorder
- const updatedRules = navigation?.rules?.map(rule => {
- let updatedNextScreenId = rule.nextScreenId;
-
- // Обновляем ссылки если правило указывает на экран, который "следовал" за другим экраном
- // и эта последовательность изменилась
- for (const [screenId, oldNext] of previousSequentialNext.entries()) {
- const newNext = newSequentialNext.get(screenId);
-
- // Если правило указывало на экран, который раньше был "следующим"
- // за каким-то экраном, но теперь следующим стал другой экран
- if (rule.nextScreenId === oldNext && newNext && oldNext !== newNext) {
- updatedNextScreenId = newNext;
- break;
- }
- }
-
- return {
- ...rule,
- nextScreenId: updatedNextScreenId
- };
- });
-
- return {
- ...(updatedRules ? { rules: updatedRules } : {}),
- ...(defaultNext ? { defaultNextScreenId: defaultNext } : {}),
- };
- }
-
- return undefined;
- })();
-
- let updatedHeader = screen.header;
- if (screen.header?.progress) {
- const progress = { ...screen.header.progress };
- const previousProgress = prevIndex !== undefined ? previousScreens[prevIndex]?.header?.progress : undefined;
-
- if (
- typeof progress.current === "number" &&
- prevIndex !== undefined &&
- (progress.current === prevIndex + 1 || previousProgress?.current === prevIndex + 1)
- ) {
- progress.current = index + 1;
- }
-
- if (typeof progress.total === "number") {
- const previousTotal = previousProgress?.total ?? progress.total;
- if (previousTotal === previousScreens.length) {
- progress.total = totalScreens;
- }
- }
-
- updatedHeader = {
- ...screen.header,
- progress,
- };
- }
-
- const nextScreen: BuilderScreen = {
- ...screen,
- ...(updatedHeader ? { header: updatedHeader } : {}),
- };
-
- if (updatedNavigation) {
- nextScreen.navigation = updatedNavigation;
- } else if ("navigation" in nextScreen) {
- delete nextScreen.navigation;
- }
-
- return nextScreen;
- });
-
- const nextMeta = {
- ...state.meta,
- firstScreenId: rewiredScreens[0]?.id,
- };
-
- const nextSelectedScreenId =
- movedScreen && state.selectedScreenId === movedScreen.id
- ? movedScreen.id
- : state.selectedScreenId;
-
- return withDirty(state, {
- ...state,
- screens: rewiredScreens,
- meta: nextMeta,
- selectedScreenId: nextSelectedScreenId,
- });
- }
- 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 ?? [],
- isEndScreen: navigation.isEndScreen,
- },
- }
- : screen
- ),
- });
- }
- case "reset": {
- return action.payload ? { ...action.payload, isDirty: false } : INITIAL_STATE;
- }
- default:
- return state;
- }
-}
-
-interface BuilderProviderProps {
- children: ReactNode;
- initialState?: BuilderState;
-}
-
-const BuilderStateContext = createContext(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 (
-
- {children}
-
- );
-}
-
-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 };
+// Re-export everything from the new modular structure for backward compatibility
+export {
+ type BuilderState,
+ type BuilderAction,
+ type BuilderProviderProps,
+ INITIAL_STATE,
+ INITIAL_META,
+ INITIAL_SCREEN,
+ withDirty,
+ generateScreenId,
+ createScreenByTemplate,
+ builderReducer,
+ BuilderProvider,
+ useBuilderState,
+ useBuilderDispatch,
+ useBuilderSelectedScreen,
+} from "./state";
diff --git a/src/lib/admin/builder/state/constants.ts b/src/lib/admin/builder/state/constants.ts
new file mode 100644
index 0000000..d797ee7
--- /dev/null
+++ b/src/lib/admin/builder/state/constants.ts
@@ -0,0 +1,66 @@
+import type { BuilderFunnelState, BuilderScreen } from "@/lib/admin/builder/types";
+import type { BuilderState } from "./types";
+
+export const INITIAL_META: BuilderFunnelState["meta"] = {
+ id: "funnel-builder-draft",
+ title: "New Funnel",
+ description: "",
+ firstScreenId: "screen-1",
+};
+
+export const INITIAL_SCREEN: BuilderScreen = {
+ id: "screen-1",
+ template: "list",
+ header: {
+ show: true,
+ showBackButton: true,
+ },
+ title: {
+ text: "Новый экран",
+ font: "manrope",
+ weight: "bold",
+ align: "left",
+ size: "2xl",
+ color: "default",
+ },
+ subtitle: {
+ text: "Добавьте детали справа",
+ font: "manrope",
+ weight: "medium",
+ color: "default",
+ align: "left",
+ size: "lg",
+ },
+ bottomActionButton: {
+ text: "Продолжить",
+ show: true,
+ },
+ list: {
+ selectionType: "single",
+ options: [
+ {
+ id: "option-1",
+ label: "Вариант 1",
+ },
+ {
+ id: "option-2",
+ label: "Вариант 2",
+ },
+ ],
+ },
+ navigation: {
+ defaultNextScreenId: undefined,
+ rules: [],
+ },
+ position: {
+ x: 80,
+ y: 120,
+ },
+};
+
+export const INITIAL_STATE: BuilderState = {
+ meta: INITIAL_META,
+ screens: [INITIAL_SCREEN],
+ selectedScreenId: INITIAL_SCREEN.id,
+ isDirty: false,
+};
diff --git a/src/lib/admin/builder/state/context.tsx b/src/lib/admin/builder/state/context.tsx
new file mode 100644
index 0000000..7c56ace
--- /dev/null
+++ b/src/lib/admin/builder/state/context.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { createContext, useContext, useMemo, useReducer } from "react";
+import type { BuilderScreen } from "@/lib/admin/builder/types";
+import type { BuilderState, BuilderAction, BuilderProviderProps } from "./types";
+import { INITIAL_STATE } from "./constants";
+import { builderReducer } from "./reducer";
+
+const BuilderStateContext = createContext(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 (
+
+ {children}
+
+ );
+}
+
+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);
+}
diff --git a/src/lib/admin/builder/state/index.ts b/src/lib/admin/builder/state/index.ts
new file mode 100644
index 0000000..b270048
--- /dev/null
+++ b/src/lib/admin/builder/state/index.ts
@@ -0,0 +1,19 @@
+// Types
+export type { BuilderState, BuilderAction, BuilderProviderProps } from "./types";
+
+// Constants
+export { INITIAL_STATE, INITIAL_META, INITIAL_SCREEN } from "./constants";
+
+// Utils
+export { withDirty, generateScreenId, createScreenByTemplate } from "./utils";
+
+// Reducer
+export { builderReducer } from "./reducer";
+
+// Context and hooks
+export {
+ BuilderProvider,
+ useBuilderState,
+ useBuilderDispatch,
+ useBuilderSelectedScreen
+} from "./context";
diff --git a/src/lib/admin/builder/state/reducer.ts b/src/lib/admin/builder/state/reducer.ts
new file mode 100644
index 0000000..fa34e82
--- /dev/null
+++ b/src/lib/admin/builder/state/reducer.ts
@@ -0,0 +1,312 @@
+import type { ListScreenDefinition } from "@/lib/funnel/types";
+import type { BuilderScreen, BuilderScreenPosition } from "@/lib/admin/builder/types";
+import type { BuilderState, BuilderAction } from "./types";
+import { INITIAL_STATE } from "./constants";
+import { withDirty, generateScreenId, createScreenByTemplate } from "./utils";
+
+export 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 template = action.payload?.template || "list";
+ const position = {
+ x: (action.payload?.position?.x ?? 120) + state.screens.length * 40,
+ y: (action.payload?.position?.y ?? 120) + state.screens.length * 20,
+ };
+
+ const newScreen = createScreenByTemplate(template, nextId, position);
+
+ // 🎯 АВТОМАТИЧЕСКОЕ СВЯЗЫВАНИЕ С ПРЕДЫДУЩИМ ЭКРАНОМ
+ let updatedScreens = [...state.screens, newScreen];
+
+ // Если есть предыдущий экран и у него нет defaultNextScreenId, связываем с новым
+ if (state.screens.length > 0) {
+ const lastScreen = state.screens[state.screens.length - 1];
+ if (!lastScreen.navigation?.defaultNextScreenId) {
+ // Обновляем предыдущий экран, чтобы он указывал на новый
+ updatedScreens = updatedScreens.map(screen =>
+ screen.id === lastScreen.id
+ ? {
+ ...screen,
+ navigation: {
+ ...screen.navigation,
+ defaultNextScreenId: nextId,
+ }
+ }
+ : screen
+ );
+ }
+ }
+
+ return withDirty(state, {
+ ...state,
+ screens: updatedScreens,
+ 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;
+ let nextSelectedScreenId = state.selectedScreenId;
+
+ const nextScreens = state.screens.map((current) =>
+ current.id === screenId
+ ? (() => {
+ const nextScreen = {
+ ...current,
+ ...screen,
+ title: screen.title ? { ...current.title, ...screen.title } : current.title,
+ ...(("subtitle" in screen && screen.subtitle !== undefined)
+ ? { subtitle: screen.subtitle }
+ : "subtitle" in current
+ ? { subtitle: current.subtitle }
+ : {}),
+ ...(current.template === "list" && "list" in screen && screen.list
+ ? {
+ list: {
+ ...(current as ListScreenDefinition & { position: BuilderScreenPosition }).list,
+ ...screen.list,
+ options:
+ screen.list.options ??
+ (current as ListScreenDefinition & { position: BuilderScreenPosition }).list.options,
+ },
+ }
+ : {}),
+ } as BuilderScreen;
+
+ if ("variants" in screen) {
+ if (Array.isArray(screen.variants) && screen.variants.length > 0) {
+ nextScreen.variants = screen.variants;
+ } else if ("variants" in nextScreen) {
+ delete (nextScreen as Partial).variants;
+ }
+ }
+
+ if (state.selectedScreenId === current.id && nextScreen.id !== current.id) {
+ nextSelectedScreenId = nextScreen.id;
+ }
+
+ return nextScreen;
+ })()
+ : current
+ );
+
+ return withDirty(state, {
+ ...state,
+ screens: nextScreens,
+ selectedScreenId: nextSelectedScreenId,
+ });
+ }
+ 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 previousScreens = state.screens;
+ const newScreens = [...previousScreens];
+ const [movedScreen] = newScreens.splice(fromIndex, 1);
+ newScreens.splice(toIndex, 0, movedScreen);
+
+ const previousSequentialNext = new Map();
+ const previousIndexMap = new Map();
+ const newSequentialNext = new Map();
+
+ previousScreens.forEach((screen, index) => {
+ previousSequentialNext.set(screen.id, previousScreens[index + 1]?.id);
+ previousIndexMap.set(screen.id, index);
+ });
+
+ newScreens.forEach((screen, index) => {
+ newSequentialNext.set(screen.id, newScreens[index + 1]?.id);
+ });
+
+ const totalScreens = newScreens.length;
+
+ const rewiredScreens = newScreens.map((screen, index) => {
+ const prevIndex = previousIndexMap.get(screen.id);
+ const prevSequential = previousSequentialNext.get(screen.id);
+ const nextSequential = newScreens[index + 1]?.id;
+ const navigation = screen.navigation;
+ const hasRules = Boolean(navigation?.rules && navigation.rules.length > 0);
+
+ let defaultNext = navigation?.defaultNextScreenId;
+ if (!hasRules) {
+ if (!defaultNext || defaultNext === prevSequential) {
+ defaultNext = nextSequential;
+ }
+ } else if (defaultNext === prevSequential) {
+ defaultNext = nextSequential;
+ }
+
+ const updatedNavigation = (() => {
+ if ((navigation?.rules && navigation.rules.length > 0) || defaultNext) {
+ // Обновляем nextScreenId в правилах навигации при reorder
+ const updatedRules = navigation?.rules?.map(rule => {
+ let updatedNextScreenId = rule.nextScreenId;
+
+ // Обновляем ссылки если правило указывает на экран, который "следовал" за другим экраном
+ // и эта последовательность изменилась
+ for (const [screenId, oldNext] of previousSequentialNext.entries()) {
+ const newNext = newSequentialNext.get(screenId);
+
+ // Если правило указывало на экран, который раньше был "следующим"
+ // за каким-то экраном, но теперь следующим стал другой экран
+ if (rule.nextScreenId === oldNext && newNext && oldNext !== newNext) {
+ updatedNextScreenId = newNext;
+ break;
+ }
+ }
+
+ return {
+ ...rule,
+ nextScreenId: updatedNextScreenId
+ };
+ });
+
+ return {
+ ...(updatedRules ? { rules: updatedRules } : {}),
+ ...(defaultNext ? { defaultNextScreenId: defaultNext } : {}),
+ };
+ }
+
+ return undefined;
+ })();
+
+ let updatedHeader = screen.header;
+ if (screen.header?.progress) {
+ const progress = { ...screen.header.progress };
+ const previousProgress = prevIndex !== undefined ? previousScreens[prevIndex]?.header?.progress : undefined;
+
+ if (
+ typeof progress.current === "number" &&
+ prevIndex !== undefined &&
+ (progress.current === prevIndex + 1 || previousProgress?.current === prevIndex + 1)
+ ) {
+ progress.current = index + 1;
+ }
+
+ if (typeof progress.total === "number") {
+ const previousTotal = previousProgress?.total ?? progress.total;
+ if (previousTotal === previousScreens.length) {
+ progress.total = totalScreens;
+ }
+ }
+
+ updatedHeader = {
+ ...screen.header,
+ progress,
+ };
+ }
+
+ const nextScreen: BuilderScreen = {
+ ...screen,
+ ...(updatedHeader ? { header: updatedHeader } : {}),
+ };
+
+ if (updatedNavigation) {
+ nextScreen.navigation = updatedNavigation;
+ } else if ("navigation" in nextScreen) {
+ delete nextScreen.navigation;
+ }
+
+ return nextScreen;
+ });
+
+ const nextMeta = {
+ ...state.meta,
+ firstScreenId: rewiredScreens[0]?.id,
+ };
+
+ const nextSelectedScreenId =
+ movedScreen && state.selectedScreenId === movedScreen.id
+ ? movedScreen.id
+ : state.selectedScreenId;
+
+ return withDirty(state, {
+ ...state,
+ screens: rewiredScreens,
+ meta: nextMeta,
+ selectedScreenId: nextSelectedScreenId,
+ });
+ }
+ 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 ?? [],
+ isEndScreen: navigation.isEndScreen,
+ },
+ }
+ : screen
+ ),
+ });
+ }
+ case "reset": {
+ return action.payload ? { ...action.payload, isDirty: false } : INITIAL_STATE;
+ }
+ default:
+ return state;
+ }
+}
diff --git a/src/lib/admin/builder/state/types.ts b/src/lib/admin/builder/state/types.ts
new file mode 100644
index 0000000..801f3e0
--- /dev/null
+++ b/src/lib/admin/builder/state/types.ts
@@ -0,0 +1,37 @@
+import type {
+ BuilderFunnelState,
+ BuilderScreen,
+} from "@/lib/admin/builder/types";
+import type { ScreenDefinition, NavigationRuleDefinition } from "@/lib/funnel/types";
+
+export interface BuilderState extends BuilderFunnelState {
+ selectedScreenId: string | null;
+ isDirty: boolean;
+}
+
+export type BuilderAction =
+ | { type: "set-meta"; payload: Partial }
+ | { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial }
+ | { type: "remove-screen"; payload: { screenId: string } }
+ | { type: "update-screen"; payload: { screenId: string; screen: Partial } }
+ | { 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[];
+ isEndScreen?: boolean;
+ };
+ };
+ }
+ | { type: "reset"; payload?: BuilderState };
+
+export interface BuilderProviderProps {
+ children: React.ReactNode;
+ initialState?: BuilderState;
+}
diff --git a/src/lib/admin/builder/state/utils.ts b/src/lib/admin/builder/state/utils.ts
new file mode 100644
index 0000000..a0e8eee
--- /dev/null
+++ b/src/lib/admin/builder/state/utils.ts
@@ -0,0 +1,243 @@
+import type { BuilderScreen, BuilderScreenPosition } from "@/lib/admin/builder/types";
+import type { ScreenDefinition } from "@/lib/funnel/types";
+import type { BuilderState } from "./types";
+
+/**
+ * Marks the state as dirty if it has changed
+ */
+export function withDirty(state: BuilderState, next: BuilderState): BuilderState {
+ if (next === state) {
+ return state;
+ }
+ return { ...next, isDirty: true };
+}
+
+/**
+ * Generates a unique screen ID
+ */
+export 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;
+}
+
+/**
+ * Creates a new screen based on template with sensible defaults
+ */
+export function createScreenByTemplate(
+ template: ScreenDefinition["template"],
+ id: string,
+ position: BuilderScreenPosition
+): BuilderScreen {
+ // ✅ Единые базовые настройки для ВСЕХ типов экранов
+ const baseScreen = {
+ id,
+ position,
+ // ✅ Современные настройки header (без устаревшего progress)
+ header: {
+ show: true,
+ showBackButton: true,
+ },
+ // ✅ Базовые тексты согласно Figma
+ title: {
+ text: "Новый экран",
+ font: "manrope" as const,
+ weight: "bold" as const,
+ align: "left" as const,
+ size: "2xl" as const,
+ color: "default" as const,
+ },
+ subtitle: {
+ text: "Добавьте детали справа",
+ font: "manrope" as const,
+ weight: "medium" as const,
+ color: "default" as const,
+ align: "left" as const,
+ size: "lg" as const,
+ },
+ // ✅ Единые настройки нижней кнопки
+ bottomActionButton: {
+ text: "Продолжить",
+ show: true,
+ },
+ // ✅ Навигация
+ navigation: {
+ defaultNextScreenId: undefined,
+ rules: [],
+ },
+ };
+
+ switch (template) {
+ case "info":
+ // Деструктурируем baseScreen исключая subtitle для InfoScreenDefinition
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { subtitle, ...baseScreenWithoutSubtitle } = baseScreen;
+ return {
+ ...baseScreenWithoutSubtitle,
+ template: "info",
+ title: {
+ text: "Заголовок информации",
+ font: "manrope" as const,
+ weight: "bold" as const,
+ align: "center" as const, // 🎯 Центрированный заголовок по умолчанию
+ size: "2xl" as const,
+ color: "default" as const,
+ },
+ // 🚫 Подзаголовок не включается (InfoScreenDefinition не поддерживает subtitle)
+ description: {
+ text: "Добавьте описание для информационного экрана. Используйте **жирный текст** для выделения важного.",
+ align: "center" as const, // 🎯 Центрированный текст
+ },
+ // 🚫 Иконка не добавляется по умолчанию - пользователь может добавить при необходимости
+ };
+
+ case "list":
+ return {
+ ...baseScreen,
+ template: "list",
+ list: {
+ selectionType: "single" as const,
+ options: [
+ { id: "option-1", label: "Вариант 1" },
+ { id: "option-2", label: "Вариант 2" },
+ ],
+ },
+ };
+
+ case "form":
+ return {
+ ...baseScreen,
+ template: "form",
+ fields: [
+ {
+ id: "field-1",
+ type: "text",
+ label: "Поле 1",
+ placeholder: "Введите значение",
+ required: true,
+ },
+ ],
+ };
+
+ case "date":
+ return {
+ ...baseScreen,
+ template: "date",
+ dateInput: {
+ monthLabel: "Месяц",
+ dayLabel: "День",
+ yearLabel: "Год",
+ monthPlaceholder: "ММ",
+ dayPlaceholder: "ДД",
+ yearPlaceholder: "ГГГГ",
+ showSelectedDate: true,
+ selectedDateFormat: "dd MMMM yyyy",
+ selectedDateLabel: "Выбранная дата:",
+ },
+ infoMessage: {
+ text: "Мы используем эту информацию только для анализа",
+ icon: "🔒",
+ },
+ };
+
+ case "coupon":
+ return {
+ ...baseScreen,
+ template: "coupon",
+ coupon: {
+ title: {
+ text: "Промокод на скидку",
+ font: "manrope" as const,
+ weight: "bold" as const,
+ },
+ offer: {
+ title: {
+ text: "Скидка 20%",
+ font: "manrope" as const,
+ weight: "bold" as const,
+ },
+ description: {
+ text: "На первую покупку",
+ font: "inter" as const,
+ weight: "medium" as const,
+ color: "muted" as const,
+ },
+ },
+ promoCode: {
+ text: "WELCOME20",
+ font: "geistMono" as const,
+ weight: "bold" as const,
+ },
+ footer: {
+ text: "Сохраните код или скопируйте",
+ font: "inter" as const,
+ weight: "medium" as const,
+ color: "muted" as const,
+ },
+ },
+ copiedMessage: "Промокод {code} скопирован!",
+ };
+
+ case "email":
+ return {
+ ...baseScreen,
+ template: "email",
+ emailInput: {
+ label: "Email адрес",
+ placeholder: "example@email.com",
+ },
+ };
+
+ case "loaders":
+ return {
+ ...baseScreen,
+ template: "loaders",
+ header: {
+ show: false,
+ showBackButton: false,
+ },
+ progressbars: {
+ items: [
+ {
+ title: "Анализ ответов",
+ subtitle: "Обработка данных...",
+ processingTitle: "Анализируем ваши ответы...",
+ processingSubtitle: "Это займет несколько секунд",
+ completedTitle: "Готово!",
+ completedSubtitle: "Данные проанализированы",
+ },
+ {
+ title: "Создание портрета",
+ subtitle: "Построение результата...",
+ processingTitle: "Строим персональный портрет...",
+ processingSubtitle: "Почти готово",
+ completedTitle: "Готово!",
+ completedSubtitle: "Портрет создан",
+ },
+ ],
+ transitionDuration: 3000,
+ },
+ };
+
+ case "soulmate":
+ return {
+ ...baseScreen,
+ template: "soulmate",
+ header: {
+ show: false,
+ showBackButton: false,
+ },
+ bottomActionButton: {
+ text: "Получить полный анализ",
+ show: true,
+ },
+ };
+
+ default:
+ throw new Error(`Unknown template: ${template}`);
+ }
+}
diff --git a/src/lib/models/Funnel.ts b/src/lib/models/Funnel.ts
index c8b73d2..b7eb8f3 100644
--- a/src/lib/models/Funnel.ts
+++ b/src/lib/models/Funnel.ts
@@ -119,7 +119,7 @@ const ScreenDefinitionSchema = new Schema({
id: { type: String, required: true },
template: {
type: String,
- enum: ['info', 'date', 'coupon', 'form', 'list'],
+ enum: ['info', 'date', 'coupon', 'form', 'list', 'email', 'loaders', 'soulmate'],
required: true
},
header: HeaderDefinitionSchema,
@@ -129,7 +129,7 @@ const ScreenDefinitionSchema = new Schema({
navigation: NavigationDefinitionSchema,
// Специфичные для template поля (используем Mixed для максимальной гибкости)
- description: TypographyVariantSchema, // info
+ description: TypographyVariantSchema, // info, soulmate
icon: Schema.Types.Mixed, // info
dateInput: Schema.Types.Mixed, // date
infoMessage: Schema.Types.Mixed, // date
@@ -144,6 +144,9 @@ const ScreenDefinitionSchema = new Schema({
},
options: [ListOptionDefinitionSchema]
},
+ emailInput: Schema.Types.Mixed, // email
+ image: Schema.Types.Mixed, // email, soulmate
+ loadersConfig: Schema.Types.Mixed, // loaders
variants: [Schema.Types.Mixed] // variants для всех типов
}, { _id: false });