From b3eaa19fcdbbc61144fdd5c420f7f66a5bb7e961 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Sun, 28 Sep 2025 06:38:15 +0200 Subject: [PATCH] dix --- .../admin/builder/BuilderCanvas.tsx | 643 +------------- .../admin/builder/BuilderSidebar.tsx | 692 +--------------- .../admin/builder/Canvas/BuilderCanvas.tsx | 318 +++++++ .../admin/builder/Canvas/DropIndicator.tsx | 16 + .../admin/builder/Canvas/TemplateSummary.tsx | 98 +++ .../admin/builder/Canvas/TransitionRow.tsx | 88 ++ .../admin/builder/Canvas/VariantSummary.tsx | 109 +++ .../admin/builder/Canvas/constants.ts | 19 + src/components/admin/builder/Canvas/index.ts | 17 + src/components/admin/builder/Canvas/utils.ts | 9 + .../admin/builder/Sidebar/BuilderSidebar.tsx | 564 +++++++++++++ .../admin/builder/Sidebar/Section.tsx | 78 ++ .../builder/Sidebar/ValidationSummary.tsx | 34 + src/components/admin/builder/Sidebar/index.ts | 11 + src/components/admin/builder/Sidebar/types.ts | 27 + .../funnel/templates/CouponTemplate.tsx | 5 - .../funnel/templates/DateTemplate.tsx | 19 - .../funnel/templates/EmailTemplate.tsx | 17 +- .../funnel/templates/FormTemplate.tsx | 3 - .../funnel/templates/InfoTemplate.tsx | 13 +- .../funnel/templates/ListTemplate.tsx | 3 +- .../funnel/templates/LoadersTemplate.tsx | 6 +- .../templates/SoulmatePortraitTemplate.tsx | 2 - src/lib/admin/builder/context.tsx | 783 +----------------- src/lib/admin/builder/state/constants.ts | 66 ++ src/lib/admin/builder/state/context.tsx | 44 + src/lib/admin/builder/state/index.ts | 19 + src/lib/admin/builder/state/reducer.ts | 312 +++++++ src/lib/admin/builder/state/types.ts | 37 + src/lib/admin/builder/state/utils.ts | 243 ++++++ src/lib/models/Funnel.ts | 7 +- 31 files changed, 2187 insertions(+), 2115 deletions(-) create mode 100644 src/components/admin/builder/Canvas/BuilderCanvas.tsx create mode 100644 src/components/admin/builder/Canvas/DropIndicator.tsx create mode 100644 src/components/admin/builder/Canvas/TemplateSummary.tsx create mode 100644 src/components/admin/builder/Canvas/TransitionRow.tsx create mode 100644 src/components/admin/builder/Canvas/VariantSummary.tsx create mode 100644 src/components/admin/builder/Canvas/constants.ts create mode 100644 src/components/admin/builder/Canvas/index.ts create mode 100644 src/components/admin/builder/Canvas/utils.ts create mode 100644 src/components/admin/builder/Sidebar/BuilderSidebar.tsx create mode 100644 src/components/admin/builder/Sidebar/Section.tsx create mode 100644 src/components/admin/builder/Sidebar/ValidationSummary.tsx create mode 100644 src/components/admin/builder/Sidebar/index.ts create mode 100644 src/components/admin/builder/Sidebar/types.ts create mode 100644 src/lib/admin/builder/state/constants.ts create mode 100644 src/lib/admin/builder/state/context.tsx create mode 100644 src/lib/admin/builder/state/index.ts create mode 100644 src/lib/admin/builder/state/reducer.ts create mode 100644 src/lib/admin/builder/state/types.ts create mode 100644 src/lib/admin/builder/state/utils.ts 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)} - /> -
- -
- {/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */} - - - {/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */} - {!selectedScreen.navigation?.isEndScreen && ( - - )} -
- - {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)} + /> +
+ +
+ {/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */} + + + {/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */} + {!selectedScreen.navigation?.isEndScreen && ( + + )} +
+ + {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 && ( portrait )} - {/* 🔒 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 });