This commit is contained in:
dev.daminik00 2025-09-28 06:38:15 +02:00
parent 58f96f652c
commit b3eaa19fcd
31 changed files with 2187 additions and 2115 deletions

View File

@ -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 (
<div
className={cn(
"mx-4 h-9 rounded-xl border-2 border-dashed border-primary/50 bg-primary/10 transition-all",
isActive ? "opacity-100" : "pointer-events-none opacity-0"
)}
/>
);
}
const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
list: "Список",
form: "Форма",
info: "Инфо",
date: "Дата",
coupon: "Купон",
email: "Email",
loaders: "Загрузка",
soulmate: "Портрет партнера",
};
const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, 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 (
<div
className={cn(
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
type === "branch"
? "border-primary/40 bg-primary/5"
: "border-border/60 bg-background/90"
)}
>
<div
className={cn(
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
type === "branch" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
type === "branch" ? "text-primary" : "text-muted-foreground"
)}
>
{label}
</span>
{operator && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{operator}
</span>
)}
</div>
{optionSummaries.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{optionSummaries.map((option) => (
<span
key={option.id}
className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
{option.label}
</span>
))}
</div>
)}
<div className="flex items-center gap-2 text-sm text-foreground">
{type === "end" ? (
<span className="text-muted-foreground">Завершение воронки</span>
) : (
<>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
{typeof targetIndex === "number" && (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
#{targetIndex + 1}
</span>
)}
<span className="font-semibold">
{targetLabel ?? "Не выбрано"}
</span>
</>
)}
</div>
</div>
</div>
);
}
function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
switch (screen.template) {
case "list": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
</span>
</div>
<div>
<p className="font-medium text-foreground">Варианты ({screen.list.options.length})</p>
<div className="mt-2 flex flex-wrap gap-2">
{screen.list.options.map((option) => (
<span
key={option.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/5 px-2 py-1 text-[11px] text-primary"
>
{option.emoji && <span className="text-base leading-none">{option.emoji}</span>}
<span className="font-medium">{option.label}</span>
</span>
))}
</div>
</div>
</div>
);
}
case "form": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Полей: {screen.fields.length}
</span>
{screen.bottomActionButton?.text && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
{screen.bottomActionButton.text}
</span>
)}
</div>
{screen.validationMessages && (
<div className="rounded-lg border border-border/50 bg-background/80 p-2">
<p className="text-[11px] text-muted-foreground">
Настроены пользовательские сообщения валидации
</p>
</div>
)}
</div>
);
}
case "coupon": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p>
<span className="font-medium">Промо:</span> {screen.coupon.promoCode.text}
</p>
<p className="text-muted-foreground/80">{screen.coupon.offer.title.text}</p>
</div>
);
}
case "date": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p className="font-medium">Формат даты:</p>
<div className="flex flex-wrap gap-2">
{screen.dateInput.monthLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.monthLabel}</span>}
{screen.dateInput.dayLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.dayLabel}</span>}
{screen.dateInput.yearLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.yearLabel}</span>}
</div>
{screen.dateInput.validationMessage && (
<p className="text-[11px] text-destructive">{screen.dateInput.validationMessage}</p>
)}
</div>
);
}
case "info": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
{screen.description?.text && <p>{screen.description.text}</p>}
{screen.icon?.value && (
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
<span className="text-base">{screen.icon.value}</span>
<span className="text-[11px] uppercase text-muted-foreground">Иконка</span>
</div>
)}
</div>
);
}
default:
return null;
}
}
function VariantSummary({
screen,
screenTitleMap,
listOptionsMap,
}: {
screen: ScreenDefinition;
screenTitleMap: Record<string, string>;
listOptionsMap: Record<string, ListOptionDefinition[]>;
}) {
const variants = (
screen as ScreenDefinition & {
variants?: ScreenVariantDefinition<ScreenDefinition>[];
}
).variants;
if (!variants || variants.length === 0) {
return null;
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">Варианты</span>
<div className="h-px flex-1 bg-border/60" />
<span className="text-[11px] uppercase text-muted-foreground/70">{variants.length}</span>
</div>
<div className="space-y-3">
{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<NavigationConditionDefinition["operator"], undefined>
| undefined;
const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
return (
<div
key={`${index}-${controllingScreenId ?? "none"}`}
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Вариант {index + 1}</span>
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{operatorLabel}
</span>
</div>
<div className="space-y-1 text-xs text-primary/90">
<div>
<span className="font-semibold">Экран:</span> {controllingScreenTitle}
</div>
{optionSummaries.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{optionSummaries.map((option) => (
<span key={option.id} className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium">
{option.label}
</span>
))}
</div>
) : (
<div className="text-primary/70">Нет выбранных ответов</div>
)}
</div>
<div className="space-y-1 text-xs text-primary/90">
<span className="font-semibold">Изменяет:</span>
<div className="flex flex-wrap gap-1.5">
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
<span
key={highlight}
className="rounded-lg bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
{highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
</span>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
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<number | null>(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<HTMLDivElement>, 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<Record<string, string>>((accumulator, screen) => {
accumulator[screen.id] = screen.title.text || screen.id;
return accumulator;
}, {});
}, [screens]);
const listOptionsMap = useMemo(() => {
return screens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, screen) => {
if (screen.template === "list") {
accumulator[screen.id] = screen.list.options;
}
return accumulator;
}, {});
}, [screens]);
return (
<>
<div className="flex h-full flex-col">
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-border/60 bg-background/95 px-6 py-4 backdrop-blur-sm">
<div>
<h2 className="text-lg font-semibold">Экраны воронки</h2>
</div>
<Button variant="outline" className="w-8 h-8 p-0 flex items-center justify-center" onClick={handleAddScreen}>
<span className="text-lg leading-none">+</span>
</Button>
</div>
<div className="relative flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-900">
<div className="relative mx-auto max-w-4xl">
<div className="absolute left-6 top-0 bottom-0 hidden w-px bg-border md:block" aria-hidden />
<div
className="space-y-6 pl-0 md:pl-12"
onDragOver={handleDragOverList}
onDrop={handleDrop}
>
{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 (
<div key={screen.id} className="relative">
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
<div className="flex items-start gap-4 md:gap-6">
<div className="relative hidden w-8 flex-shrink-0 md:flex md:flex-col md:items-center">
<span className="mt-1 h-3 w-3 rounded-full border-2 border-background bg-primary shadow" />
{!isLast && (
<div className="mt-2 flex h-full flex-col items-center">
<div className="flex-1 w-px bg-gradient-to-b from-primary/40 via-border/40 to-transparent" />
<ArrowDown className="mt-1 h-4 w-4 text-border/70" />
</div>
)}
</div>
<div
className={cn(
"relative flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
isSelected && "border-primary/50 ring-2 ring-primary",
dragStateRef.current?.screenId === screen.id && "cursor-grabbing opacity-90"
)}
draggable
onDragStart={(event) => handleDragStart(event, screen.id, index)}
onDragOver={(event) => handleDragOverCard(event, index)}
onDragEnd={handleDragEnd}
onClick={() => handleSelectScreen(screen.id)}
>
<span className="absolute right-5 top-5 inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{TEMPLATE_TITLES[screen.template] ?? screen.template}
</span>
<div className="pr-28">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{index + 1}
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
#{screen.id}
</span>
<span className="max-h-14 overflow-hidden break-words text-lg font-semibold leading-tight text-foreground">
{screen.title.text || "Без названия"}
</span>
</div>
</div>
</div>
{("subtitle" in screen && screen.subtitle?.text) && (
<p className="mt-3 max-h-12 overflow-hidden text-sm leading-snug text-muted-foreground">
{screen.subtitle.text}
</p>
)}
<div className="mt-4 space-y-5">
<TemplateSummary screen={screen} />
<VariantSummary
screen={screen}
screenTitleMap={screenTitleMap}
listOptionsMap={listOptionsMap}
/>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
<div className="h-px flex-1 bg-border/60" />
</div>
<div className="space-y-3">
<TransitionRow
type={
screen.navigation?.isEndScreen
? "end"
: defaultNext
? "default"
: "end"
}
label={
screen.navigation?.isEndScreen
? "🏁 Финальный экран"
: defaultNext
? "По умолчанию"
: "Завершение"
}
targetLabel={defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : undefined}
targetIndex={defaultNext && defaultTargetIndex !== -1 ? defaultTargetIndex : null}
/>
{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<NavigationConditionDefinition["operator"], undefined>
| 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 (
<TransitionRow
key={`${ruleIndex}-${rule.nextScreenId}`}
type="branch"
label="Вариативность"
targetLabel={ruleTargetLabel}
targetIndex={ruleTargetIndex !== -1 ? ruleTargetIndex : null}
optionSummaries={optionSummaries}
operator={operatorLabel}
/>
);
})}
</div>
</div>
</div>
</div>
</div>
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
</div>
);
})}
{screens.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-background/80 p-8 text-center text-sm text-muted-foreground">
Добавьте первый экран, чтобы начать строить воронку.
</div>
)}
<div className="pt-4">
<Button variant="ghost" onClick={handleAddScreen} className="w-8 h-8 p-0 mx-auto flex items-center justify-center">
+
</Button>
</div>
</div>
</div>
</div>
</div>
<AddScreenDialog
open={addScreenDialogOpen}
onOpenChange={setAddScreenDialogOpen}
onAddScreen={handleAddScreenWithTemplate}
/>
</>
);
}
// Re-export everything from the new modular structure for backward compatibility
export {
BuilderCanvas,
DropIndicator,
TransitionRow,
TemplateSummary,
VariantSummary,
getOptionLabel,
TEMPLATE_TITLES,
OPERATOR_LABELS,
} from "./Canvas";

View File

@ -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<typeof validateBuilderState>["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 (
<section className="flex flex-col gap-3">
<div
className={cn(
"flex items-center gap-2 cursor-pointer",
!alwaysExpanded && "hover:text-foreground transition-colors"
)}
onClick={handleToggle}
>
{!alwaysExpanded && (
effectiveExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)
)}
<div className="flex flex-col gap-1 flex-1">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h2>
{description && <p className="text-xs text-muted-foreground/80">{description}</p>}
</div>
</div>
{effectiveExpanded && (
<div className="flex flex-col gap-2 ml-2 pl-2 border-l-2 border-border/30">{children}</div>
)}
</section>
);
}
function ValidationSummary({ issues }: { issues: ValidationIssues }) {
if (issues.length === 0) {
return (
<div className="rounded-lg border border-border/30 bg-background/40 p-2 text-xs text-muted-foreground">
Всё хорошо воронка валидна.
</div>
);
}
return (
<div className="space-y-2">
{issues.map((issue, index) => (
<div
key={index}
className="rounded-lg border border-destructive/20 bg-destructive/5 p-2 text-xs text-destructive"
>
<div className="flex items-start gap-2">
<span className="text-destructive/80"></span>
<div>
<p className="font-medium">{issue.message}</p>
{issue.screenId && <p className="mt-1 text-destructive/80">Экран: {issue.screenId}</p>}
</div>
</div>
</div>
))}
</div>
);
}
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<BuilderScreen["navigation"]> = {}
) => {
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<ScreenDefinition>) => {
dispatch({
type: "update-screen",
payload: {
screenId,
screen: updates as Partial<BuilderScreen>,
},
});
};
const handleVariantsChange = (
screenId: string,
variants: ScreenVariantDefinition<ScreenDefinition>[]
) => {
dispatch({
type: "update-screen",
payload: {
screenId,
screen: {
variants: variants.length > 0 ? variants : undefined,
} as Partial<BuilderScreen>,
},
});
};
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
return (
<div className="flex h-full flex-col">
<div className="sticky top-0 z-20 border-b border-border/60 bg-background/95 px-4 py-3">
<div className="flex flex-col gap-1">
<h1 className="text-base font-semibold">Настройки</h1>
</div>
<div className="mt-3 flex rounded-lg bg-muted/40 p-1 text-sm font-medium">
<button
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition",
activeTab === "funnel"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("funnel")}
>
Воронка
</button>
<button
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition",
activeTab === "screen"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
!selectedScreen && "cursor-not-allowed opacity-60"
)}
onClick={() => selectedScreen && setActiveTab("screen")}
>
Экран
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{activeTab === "funnel" ? (
<div className="flex flex-col gap-4">
<Section title="Валидация">
<ValidationSummary issues={validation.issues} />
</Section>
<Section title="Настройки воронки" description="Общие параметры">
<TextInput
label="ID воронки"
value={state.meta.id}
onChange={(event) => handleMetaChange("id", event.target.value)}
/>
<TextInput
label="Название"
value={state.meta.title ?? ""}
onChange={(event) => handleMetaChange("title", event.target.value)}
/>
<TextInput
label="Описание"
value={state.meta.description ?? ""}
onChange={(event) => handleMetaChange("description", event.target.value)}
/>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
onChange={(event) => handleFirstScreenChange(event.target.value)}
>
{screenOptions.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</Section>
<Section title="Экраны">
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Всего экранов</span>
<span className="font-semibold text-foreground">{state.screens.length}</span>
</div>
<div className="flex flex-col gap-1 text-xs">
{state.screens.map((screen, index) => (
<span key={screen.id} className="flex items-center justify-between">
<span className="truncate">{index + 1}. {screen.title.text}</span>
<span className="uppercase text-muted-foreground/80">{screen.template}</span>
</span>
))}
</div>
</div>
</Section>
</div>
) : selectedScreen ? (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">#{selectedScreen.id}</span>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary uppercase">
{selectedScreen.template}
</span>
</div>
<span className="text-xs text-muted-foreground">
{state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length}
</span>
</div>
</div>
<Section title="Общие данные">
<TextInput
label="ID экрана"
value={selectedScreen.id}
onChange={(event) => handleScreenIdChange(selectedScreen.id, event.target.value)}
/>
</Section>
<Section title="Контент и оформление">
<TemplateConfig
screen={selectedScreen}
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
/>
</Section>
<Section title="Вариативность">
<ScreenVariantsConfig
screen={selectedScreen}
allScreens={state.screens}
onChange={(variants) => handleVariantsChange(selectedScreen.id, variants)}
/>
</Section>
<Section title="Навигация">
{/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedScreen.navigation?.isEndScreen ?? false}
onChange={(e) => {
updateNavigation(selectedScreen, { isEndScreen: e.target.checked });
}}
className="rounded border-border"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">Финальный экран</span>
<span className="text-xs text-muted-foreground">
Этот экран завершает воронку (переход не требуется)
</span>
</div>
</label>
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
{!selectedScreen.navigation?.isEndScreen && (
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
>
<option value=""></option>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
)}
</Section>
{selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
<Section title="Правила переходов" description="Условная навигация">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
</p>
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={() => handleAddRule(selectedScreen)}>
<span className="text-lg leading-none">+</span>
</Button>
</div>
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Правил пока нет
</div>
)}
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
<div
key={ruleIndex}
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
<Button
variant="ghost"
className="text-destructive"
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
>
<span className="text-xs">Удалить</span>
</Button>
</div>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.conditions[0]?.operator ?? "includesAny"}
onChange={(event) =>
handleRuleOperatorChange(
selectedScreen.id,
ruleIndex,
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
)
}
>
<option value="includesAny">contains any</option>
<option value="includesAll">contains all</option>
<option value="includesExactly">exact match</option>
</select>
</label>
{selectedScreen.template === "list" ? (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
const isChecked = condition.optionIds?.includes(option.id) ?? false;
return (
<label key={option.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isChecked}
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
/>
<span>
{option.label}
<span className="text-muted-foreground"> ({option.id})</span>
</span>
</label>
);
})}
</div>
</div>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
Навигационные правила с вариантами ответа доступны только для экранов со списком.
</div>
)}
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.nextScreenId}
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</div>
))}
</div>
</Section>
)}
<Section title="Валидация">
<ValidationSummary issues={screenValidationIssues} />
</Section>
<Section title="Управление">
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="mb-3 text-sm text-muted-foreground">
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
</p>
<Button
variant="destructive"
className="h-9 text-sm"
disabled={state.screens.length <= 1}
onClick={() => handleDeleteScreen(selectedScreen.id)}
>
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
</Button>
</div>
</Section>
</div>
) : (
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/20 p-6 text-center text-sm text-muted-foreground">
Выберите экран в списке слева, чтобы настроить его параметры.
</div>
)}
</div>
</div>
);
}
// Re-export types for backward compatibility
export type { ValidationIssues, SectionProps } from "./Sidebar";

View File

@ -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<number | null>(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<HTMLDivElement>, 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<Record<string, string>>((accumulator, screen) => {
accumulator[screen.id] = screen.title.text || screen.id;
return accumulator;
}, {});
}, [screens]);
const listOptionsMap = useMemo(() => {
return screens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, screen) => {
if (screen.template === "list") {
accumulator[screen.id] = screen.list.options;
}
return accumulator;
}, {});
}, [screens]);
return (
<>
<div className="flex h-full flex-col">
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-border/60 bg-background/95 px-6 py-4 backdrop-blur-sm">
<div>
<h2 className="text-lg font-semibold">Экраны воронки</h2>
</div>
<Button variant="outline" className="w-8 h-8 p-0 flex items-center justify-center" onClick={handleAddScreen}>
<span className="text-lg leading-none">+</span>
</Button>
</div>
<div className="relative flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-900">
<div className="relative mx-auto max-w-4xl">
<div className="absolute left-6 top-0 bottom-0 hidden w-px bg-border md:block" aria-hidden />
<div
className="space-y-6 pl-0 md:pl-12"
onDragOver={handleDragOverList}
onDrop={handleDrop}
>
{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 (
<div key={screen.id} className="relative">
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
<div className="flex items-start gap-4 md:gap-6">
<div className="relative hidden w-8 flex-shrink-0 md:flex md:flex-col md:items-center">
<span className="mt-1 h-3 w-3 rounded-full border-2 border-background bg-primary shadow" />
{!isLast && (
<div className="mt-2 flex h-full flex-col items-center">
<div className="flex-1 w-px bg-gradient-to-b from-primary/40 via-border/40 to-transparent" />
<ArrowDown className="mt-1 h-4 w-4 text-border/70" />
</div>
)}
</div>
<div
className={cn(
"relative flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
isSelected && "border-primary/50 ring-2 ring-primary",
dragStateRef.current?.screenId === screen.id && "cursor-grabbing opacity-90"
)}
draggable
onDragStart={(event) => handleDragStart(event, screen.id, index)}
onDragOver={(event) => handleDragOverCard(event, index)}
onDragEnd={handleDragEnd}
onClick={() => handleSelectScreen(screen.id)}
>
<span className="absolute right-5 top-5 inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{TEMPLATE_TITLES[screen.template] ?? screen.template}
</span>
<div className="pr-28">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{index + 1}
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
#{screen.id}
</span>
<span className="max-h-14 overflow-hidden break-words text-lg font-semibold leading-tight text-foreground">
{screen.title.text || "Без названия"}
</span>
</div>
</div>
</div>
{("subtitle" in screen && screen.subtitle?.text) && (
<p className="mt-3 max-h-12 overflow-hidden text-sm leading-snug text-muted-foreground">
{screen.subtitle.text}
</p>
)}
<div className="mt-4 space-y-5">
<TemplateSummary screen={screen} />
<VariantSummary
screen={screen}
screenTitleMap={screenTitleMap}
listOptionsMap={listOptionsMap}
/>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
<div className="h-px flex-1 bg-border/60" />
</div>
<div className="space-y-3">
<TransitionRow
type={
screen.navigation?.isEndScreen
? "end"
: defaultNext
? "default"
: "end"
}
label={
screen.navigation?.isEndScreen
? "🏁 Финальный экран"
: defaultNext
? "По умолчанию"
: "Завершение"
}
targetLabel={defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : undefined}
targetIndex={defaultNext && defaultTargetIndex !== -1 ? defaultTargetIndex : null}
/>
{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<NavigationConditionDefinition["operator"], undefined>
| 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 (
<TransitionRow
key={`${ruleIndex}-${rule.nextScreenId}`}
type="branch"
label="Вариативность"
targetLabel={ruleTargetLabel}
targetIndex={ruleTargetIndex !== -1 ? ruleTargetIndex : null}
optionSummaries={optionSummaries}
operator={operatorLabel}
/>
);
})}
</div>
</div>
</div>
</div>
</div>
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
</div>
);
})}
{screens.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-background/80 p-8 text-center text-sm text-muted-foreground">
Добавьте первый экран, чтобы начать строить воронку.
</div>
)}
<div className="pt-4">
<Button variant="ghost" onClick={handleAddScreen} className="w-8 h-8 p-0 mx-auto flex items-center justify-center">
+
</Button>
</div>
</div>
</div>
</div>
</div>
<AddScreenDialog
open={addScreenDialogOpen}
onOpenChange={setAddScreenDialogOpen}
onAddScreen={handleAddScreenWithTemplate}
/>
</>
);
}

View File

@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
interface DropIndicatorProps {
isActive: boolean;
}
export function DropIndicator({ isActive }: DropIndicatorProps) {
return (
<div
className={cn(
"mx-4 h-9 rounded-xl border-2 border-dashed border-primary/50 bg-primary/10 transition-all",
isActive ? "opacity-100" : "pointer-events-none opacity-0"
)}
/>
);
}

View File

@ -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 (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
</span>
</div>
<div>
<p className="font-medium text-foreground">Варианты ({screen.list.options.length})</p>
<div className="mt-2 flex flex-wrap gap-2">
{screen.list.options.map((option) => (
<span
key={option.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/5 px-2 py-1 text-[11px] text-primary"
>
{option.emoji && <span className="text-base leading-none">{option.emoji}</span>}
<span className="font-medium">{option.label}</span>
</span>
))}
</div>
</div>
</div>
);
}
case "form": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Полей: {screen.fields.length}
</span>
{screen.bottomActionButton?.text && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
{screen.bottomActionButton.text}
</span>
)}
</div>
{screen.validationMessages && (
<div className="rounded-lg border border-border/50 bg-background/80 p-2">
<p className="text-[11px] text-muted-foreground">
Настроены пользовательские сообщения валидации
</p>
</div>
)}
</div>
);
}
case "coupon": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p>
<span className="font-medium">Промо:</span> {screen.coupon.promoCode.text}
</p>
<p className="text-muted-foreground/80">{screen.coupon.offer.title.text}</p>
</div>
);
}
case "date": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p className="font-medium">Формат даты:</p>
<div className="flex flex-wrap gap-2">
{screen.dateInput.monthLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.monthLabel}</span>}
{screen.dateInput.dayLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.dayLabel}</span>}
{screen.dateInput.yearLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.yearLabel}</span>}
</div>
{screen.dateInput.validationMessage && (
<p className="text-[11px] text-destructive">{screen.dateInput.validationMessage}</p>
)}
</div>
);
}
case "info": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
{screen.description?.text && <p>{screen.description.text}</p>}
{screen.icon?.value && (
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
<span className="text-base">{screen.icon.value}</span>
<span className="text-[11px] uppercase text-muted-foreground">Иконка</span>
</div>
)}
</div>
);
}
default:
return null;
}
}

View File

@ -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 (
<div
className={cn(
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
type === "branch"
? "border-primary/40 bg-primary/5"
: "border-border/60 bg-background/90"
)}
>
<div
className={cn(
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
type === "branch" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
"text-[11px] font-semibold uppercase tracking-wide",
type === "branch" ? "text-primary" : "text-muted-foreground"
)}
>
{label}
</span>
{operator && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{operator}
</span>
)}
</div>
{optionSummaries.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{optionSummaries.map((option) => (
<span
key={option.id}
className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
{option.label}
</span>
))}
</div>
)}
<div className="flex items-center gap-2 text-sm text-foreground">
{type === "end" ? (
<span className="text-muted-foreground">Завершение воронки</span>
) : (
<>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
{typeof targetIndex === "number" && (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
#{targetIndex + 1}
</span>
)}
<span className="font-semibold">
{targetLabel ?? "Не выбрано"}
</span>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -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<string, string>;
listOptionsMap: Record<string, ListOptionDefinition[]>;
}
export function VariantSummary({
screen,
screenTitleMap,
listOptionsMap,
}: VariantSummaryProps) {
const variants = (
screen as ScreenDefinition & {
variants?: ScreenVariantDefinition<ScreenDefinition>[];
}
).variants;
if (!variants || variants.length === 0) {
return null;
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">Варианты</span>
<div className="h-px flex-1 bg-border/60" />
<span className="text-[11px] uppercase text-muted-foreground/70">{variants.length}</span>
</div>
<div className="space-y-3">
{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<NavigationConditionDefinition["operator"], undefined>
| undefined;
const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
return (
<div
key={`${index}-${controllingScreenId ?? "none"}`}
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Вариант {index + 1}</span>
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{operatorLabel}
</span>
</div>
<div className="space-y-1 text-xs text-primary/90">
<div>
<span className="font-semibold">Экран:</span> {controllingScreenTitle}
</div>
{optionSummaries.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{optionSummaries.map((option) => (
<span key={option.id} className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium">
{option.label}
</span>
))}
</div>
) : (
<div className="text-primary/70">Нет выбранных ответов</div>
)}
</div>
<div className="space-y-1 text-xs text-primary/90">
<span className="font-semibold">Изменяет:</span>
<div className="flex flex-wrap gap-1.5">
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
<span
key={highlight}
className="rounded-lg bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
{highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
</span>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types";
export const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
list: "Список",
form: "Форма",
info: "Инфо",
date: "Дата",
coupon: "Купон",
email: "Email",
loaders: "Загрузка",
soulmate: "Портрет партнера",
};
export const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
includesAny: "любой из",
includesAll: "все из",
includesExactly: "точное совпадение",
equals: "равно",
};

View File

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

View File

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

View File

@ -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<BuilderScreen["navigation"]> = {}
) => {
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<ScreenDefinition>) => {
dispatch({
type: "update-screen",
payload: {
screenId,
screen: updates as Partial<BuilderScreen>,
},
});
};
const handleVariantsChange = (
screenId: string,
variants: ScreenVariantDefinition<ScreenDefinition>[]
) => {
dispatch({
type: "update-screen",
payload: {
screenId,
screen: {
variants: variants.length > 0 ? variants : undefined,
} as Partial<BuilderScreen>,
},
});
};
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
return (
<div className="flex h-full flex-col">
<div className="sticky top-0 z-20 border-b border-border/60 bg-background/95 px-4 py-3">
<div className="flex flex-col gap-1">
<h1 className="text-base font-semibold">Настройки</h1>
</div>
<div className="mt-3 flex rounded-lg bg-muted/40 p-1 text-sm font-medium">
<button
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition",
activeTab === "funnel"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("funnel")}
>
Воронка
</button>
<button
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition",
activeTab === "screen"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
!selectedScreen && "cursor-not-allowed opacity-60"
)}
onClick={() => selectedScreen && setActiveTab("screen")}
>
Экран
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{activeTab === "funnel" ? (
<div className="flex flex-col gap-4">
<Section title="Валидация">
<ValidationSummary issues={validation.issues} />
</Section>
<Section title="Настройки воронки" description="Общие параметры">
<TextInput
label="ID воронки"
value={state.meta.id}
onChange={(event) => handleMetaChange("id", event.target.value)}
/>
<TextInput
label="Название"
value={state.meta.title ?? ""}
onChange={(event) => handleMetaChange("title", event.target.value)}
/>
<TextInput
label="Описание"
value={state.meta.description ?? ""}
onChange={(event) => handleMetaChange("description", event.target.value)}
/>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
onChange={(event) => handleFirstScreenChange(event.target.value)}
>
{screenOptions.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</Section>
<Section title="Экраны">
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Всего экранов</span>
<span className="font-semibold text-foreground">{state.screens.length}</span>
</div>
<div className="flex flex-col gap-1 text-xs">
{state.screens.map((screen, index) => (
<span key={screen.id} className="flex items-center justify-between">
<span className="truncate">{index + 1}. {screen.title.text}</span>
<span className="uppercase text-muted-foreground/80">{screen.template}</span>
</span>
))}
</div>
</div>
</Section>
</div>
) : selectedScreen ? (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">#{selectedScreen.id}</span>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary uppercase">
{selectedScreen.template}
</span>
</div>
<span className="text-xs text-muted-foreground">
{state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length}
</span>
</div>
</div>
<Section title="Общие данные">
<TextInput
label="ID экрана"
value={selectedScreen.id}
onChange={(event) => handleScreenIdChange(selectedScreen.id, event.target.value)}
/>
</Section>
<Section title="Контент и оформление">
<TemplateConfig
screen={selectedScreen}
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
/>
</Section>
<Section title="Вариативность">
<ScreenVariantsConfig
screen={selectedScreen}
allScreens={state.screens}
onChange={(variants) => handleVariantsChange(selectedScreen.id, variants)}
/>
</Section>
<Section title="Навигация">
{/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedScreen.navigation?.isEndScreen ?? false}
onChange={(e) => {
updateNavigation(selectedScreen, { isEndScreen: e.target.checked });
}}
className="rounded border-border"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">Финальный экран</span>
<span className="text-xs text-muted-foreground">
Этот экран завершает воронку (переход не требуется)
</span>
</div>
</label>
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
{!selectedScreen.navigation?.isEndScreen && (
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
>
<option value=""></option>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
)}
</Section>
{selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
<Section title="Правила переходов" description="Условная навигация">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
</p>
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={() => handleAddRule(selectedScreen)}>
<span className="text-lg leading-none">+</span>
</Button>
</div>
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Правил пока нет
</div>
)}
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
<div
key={ruleIndex}
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
<Button
variant="ghost"
className="text-destructive"
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
>
<span className="text-xs">Удалить</span>
</Button>
</div>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.conditions[0]?.operator ?? "includesAny"}
onChange={(event) =>
handleRuleOperatorChange(
selectedScreen.id,
ruleIndex,
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
)
}
>
<option value="includesAny">contains any</option>
<option value="includesAll">contains all</option>
<option value="includesExactly">exact match</option>
</select>
</label>
{selectedScreen.template === "list" ? (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
const isChecked = condition.optionIds?.includes(option.id) ?? false;
return (
<label key={option.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isChecked}
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
/>
<span>
{option.label}
<span className="text-muted-foreground"> ({option.id})</span>
</span>
</label>
);
})}
</div>
</div>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
Навигационные правила с вариантами ответа доступны только для экранов со списком.
</div>
)}
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.nextScreenId}
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</div>
))}
</div>
</Section>
)}
<Section title="Валидация">
<ValidationSummary issues={screenValidationIssues} />
</Section>
<Section title="Управление">
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="mb-3 text-sm text-muted-foreground">
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
</p>
<Button
variant="destructive"
className="h-9 text-sm"
disabled={state.screens.length <= 1}
onClick={() => handleDeleteScreen(selectedScreen.id)}
>
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
</Button>
</div>
</Section>
</div>
) : (
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/20 p-6 text-center text-sm text-muted-foreground">
Выберите экран в списке слева, чтобы настроить его параметры.
</div>
)}
</div>
</div>
);
}

View File

@ -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 (
<section className="flex flex-col gap-3">
<div
className={cn(
"flex items-center gap-2 cursor-pointer",
!alwaysExpanded && "hover:text-foreground transition-colors"
)}
onClick={handleToggle}
>
{!alwaysExpanded && (
effectiveExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)
)}
<div className="flex flex-col gap-1 flex-1">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h2>
{description && <p className="text-xs text-muted-foreground/80">{description}</p>}
</div>
</div>
{effectiveExpanded && (
<div className="flex flex-col gap-2 ml-2 pl-2 border-l-2 border-border/30">{children}</div>
)}
</section>
);
}

View File

@ -0,0 +1,34 @@
import type { ValidationIssues } from "./types";
export interface ValidationSummaryProps {
issues: ValidationIssues;
}
export function ValidationSummary({ issues }: ValidationSummaryProps) {
if (issues.length === 0) {
return (
<div className="rounded-lg border border-border/30 bg-background/40 p-2 text-xs text-muted-foreground">
Всё хорошо воронка валидна.
</div>
);
}
return (
<div className="space-y-2">
{issues.map((issue, index) => (
<div
key={index}
className="rounded-lg border border-destructive/20 bg-destructive/5 p-2 text-xs text-destructive"
>
<div className="flex items-start gap-2">
<span className="text-destructive/80"></span>
<div>
<p className="font-medium">{issue.message}</p>
{issue.screenId && <p className="mt-1 text-destructive/80">Экран: {issue.screenId}</p>}
</div>
</div>
</div>
))}
</div>
);
}

View File

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

View File

@ -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<typeof validateBuilderState>["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;
}

View File

@ -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 (
<LayoutQuestion {...layoutQuestionProps}>
<div className="w-full flex flex-col items-center justify-center mt-[22px]">
{/* Coupon Widget */}
<div className="mb-8">
<Coupon {...couponProps} />
</div>
{/* Copy Success Message */}
{copiedCode && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
<Typography

View File

@ -20,20 +20,6 @@ interface DateTemplateProps {
defaultTexts?: { nextButton?: string; continueButton?: string };
}
// Helper functions for date conversion (kept for potential future use)
// function convertArrayToISOString(dateArray: number[]): string {
// if (dateArray.length !== 3) return "";
// const [month, day, year] = dateArray;
// const date = new Date(year, month - 1, day);
// return date.toISOString().split('T')[0];
// }
// function convertISOStringToArray(isoString: string): number[] {
// if (!isoString) return [];
// const [year, month, day] = isoString.split('-').map(Number);
// return [month, day, year];
// }
export function DateTemplate({
screen,
selectedDate,
@ -44,7 +30,6 @@ export function DateTemplate({
screenProgress,
defaultTexts,
}: DateTemplateProps) {
// Преобразуем объект {month, day, year} в ISO строку для DateInput
const isoDate = useMemo(() => {
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({
}}
>
<div className="w-full mt-[22px] space-y-6">
{/* Используем DateInput виджет разработчика */}
<DateInput
value={isoDate}
onChange={handleDateChange}
@ -110,7 +92,6 @@ export function DateTemplate({
locale="en"
/>
{/* Info Message если есть */}
{screen.infoMessage && (
<div className="flex justify-center">
<div className="flex items-start gap-3">

View File

@ -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<z.infer<typeof formSchema>>({
@ -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,
}}
>
{/* 🎨 Новая структура согласно требованиям */}
<div className="w-full flex flex-col items-center gap-[26px]">
{/* 📧 Email Input - с дефолтными значениями */}
<TextInput
label={screen.emailInput?.label || "Email"}
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
@ -96,18 +91,16 @@ export function EmailTemplate({
}
/>
{/* 🖼️ Image - с зашитыми значениями как в оригинальном Email компоненте */}
{screen.image && (
<Image
src={screen.image.src}
alt="portrait" // Зашитое значение согласно дизайну
width={164} // Зашитое значение согласно дизайну
height={245} // Зашитое значение согласно дизайну
className="mt-3.5 rounded-[50px] blur-sm" // Зашитые стили согласно дизайну
alt="portrait"
width={164}
height={245}
className="mt-3.5 rounded-[50px] blur-sm"
/>
)}
{/* 🔒 Privacy Security Banner */}
<PrivacySecurityBanner
className="mt-[26px]"
text={{

View File

@ -34,12 +34,10 @@ export function FormTemplate({
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
const [errors, setErrors] = useState<Record<string, string>>({});
// 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 };

View File

@ -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({
>
<div className={cn(
"w-full flex flex-col items-center justify-center text-center",
// 🔧 Уменьшили отступ: без иконки убираем лишнее пространство
screen.icon ? "mt-[60px]" : "-mt-[20px]" // Отрицательный margin компенсирует mt-[30px] из LayoutQuestion
screen.icon ? "mt-[60px]" : "-mt-[20px]"
)}>
{/* Icon */}
{screen.icon && (
@ -92,7 +91,7 @@ export function InfoTemplate({
{screen.description && (
<div className={cn(
"max-w-[280px]",
screen.icon ? "mt-6" : "mt-0" // 🔧 Убираем отступ сверху для текста если нет иконки
screen.icon ? "mt-6" : "mt-0"
)}>
<Typography
as="p"

View File

@ -91,7 +91,6 @@ export function ListTemplate({
onChangeSelectedAnswers: handleSelectChange,
};
// 🎯 СЛОЖНАЯ ЛОГИКА КНОПКИ ДЛЯ СПИСКОВ - actionButtonProps приходит из screenRenderer
const actionButtonOptions = actionButtonProps ? {
defaultText: actionButtonProps.children as string || "Next",
disabled: actionButtonProps.disabled || false,
@ -105,7 +104,7 @@ export function ListTemplate({
return (
<TemplateLayout
screen={screen}
onContinue={() => {}} // Не используется, логика в actionButtonOptions.onClick
onContinue={() => {}}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}

View File

@ -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,
}}
>
<div className="w-full flex flex-col items-center gap-[22px] mt-[22px]">
<CircularProgressbarsList
{...progressbarsListProps}
showDividers={false} // 🚫 Убираем разделительные линии в экранах лоадера
showDividers={false}
/>
</div>
</TemplateLayout>

View File

@ -36,9 +36,7 @@ export function SoulmatePortraitTemplate({
onClick: onContinue,
}}
>
{/* 🎯 Точно как InfoTemplate - пустой контент, без иконки и description */}
<div className="-mt-[20px]">
{/* Пустой контент - как InfoTemplate без иконки и без description */}
</div>
</TemplateLayout>
);

View File

@ -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<BuilderFunnelState["meta"]> }
| { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> }
| { type: "remove-screen"; payload: { screenId: string } }
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
| { type: "reposition-screen"; payload: { screenId: string; position: BuilderScreen["position"] } }
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }
| { type: "set-selected-screen"; payload: { screenId: string | null } }
| { type: "set-screens"; payload: BuilderScreen[] }
| {
type: "update-navigation";
payload: {
screenId: string;
navigation: {
defaultNextScreenId?: string | null;
rules?: NavigationRuleDefinition[];
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<BuilderScreen>).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<string, string | undefined>();
const previousIndexMap = new Map<string, number>();
const newSequentialNext = new Map<string, string | undefined>();
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<BuilderState | undefined>(undefined);
const BuilderDispatchContext = createContext<((action: BuilderAction) => void) | undefined>(undefined);
export function BuilderProvider({ children, initialState }: BuilderProviderProps) {
const [state, dispatch] = useReducer(builderReducer, initialState ?? INITIAL_STATE);
const memoizedState = useMemo(() => state, [state]);
const memoizedDispatch = useMemo(() => dispatch, []);
return (
<BuilderStateContext.Provider value={memoizedState}>
<BuilderDispatchContext.Provider value={memoizedDispatch}>{children}</BuilderDispatchContext.Provider>
</BuilderStateContext.Provider>
);
}
export function useBuilderState(): BuilderState {
const ctx = useContext(BuilderStateContext);
if (!ctx) {
throw new Error("useBuilderState must be used within BuilderProvider");
}
return ctx;
}
export function useBuilderDispatch(): (action: BuilderAction) => void {
const ctx = useContext(BuilderDispatchContext);
if (!ctx) {
throw new Error("useBuilderDispatch must be used within BuilderProvider");
}
return ctx;
}
export function useBuilderSelectedScreen(): BuilderScreen | undefined {
const state = useBuilderState();
return state.screens.find((screen) => screen.id === state.selectedScreenId);
}
export type { BuilderState, BuilderAction };
// 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";

View File

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

View File

@ -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<BuilderState | undefined>(undefined);
const BuilderDispatchContext = createContext<((action: BuilderAction) => void) | undefined>(undefined);
export function BuilderProvider({ children, initialState }: BuilderProviderProps) {
const [state, dispatch] = useReducer(builderReducer, initialState ?? INITIAL_STATE);
const memoizedState = useMemo(() => state, [state]);
const memoizedDispatch = useMemo(() => dispatch, []);
return (
<BuilderStateContext.Provider value={memoizedState}>
<BuilderDispatchContext.Provider value={memoizedDispatch}>{children}</BuilderDispatchContext.Provider>
</BuilderStateContext.Provider>
);
}
export function useBuilderState(): BuilderState {
const ctx = useContext(BuilderStateContext);
if (!ctx) {
throw new Error("useBuilderState must be used within BuilderProvider");
}
return ctx;
}
export function useBuilderDispatch(): (action: BuilderAction) => void {
const ctx = useContext(BuilderDispatchContext);
if (!ctx) {
throw new Error("useBuilderDispatch must be used within BuilderProvider");
}
return ctx;
}
export function useBuilderSelectedScreen(): BuilderScreen | undefined {
const state = useBuilderState();
return state.screens.find((screen) => screen.id === state.selectedScreenId);
}

View File

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

View File

@ -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<BuilderScreen>).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<string, string | undefined>();
const previousIndexMap = new Map<string, number>();
const newSequentialNext = new Map<string, string | undefined>();
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;
}
}

View File

@ -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<BuilderFunnelState["meta"]> }
| { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> }
| { type: "remove-screen"; payload: { screenId: string } }
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
| { type: "reposition-screen"; payload: { screenId: string; position: BuilderScreen["position"] } }
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }
| { type: "set-selected-screen"; payload: { screenId: string | null } }
| { type: "set-screens"; payload: BuilderScreen[] }
| {
type: "update-navigation";
payload: {
screenId: string;
navigation: {
defaultNextScreenId?: string | null;
rules?: NavigationRuleDefinition[];
isEndScreen?: boolean;
};
};
}
| { type: "reset"; payload?: BuilderState };
export interface BuilderProviderProps {
children: React.ReactNode;
initialState?: BuilderState;
}

View File

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

View File

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