dix
This commit is contained in:
parent
58f96f652c
commit
b3eaa19fcd
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
318
src/components/admin/builder/Canvas/BuilderCanvas.tsx
Normal file
318
src/components/admin/builder/Canvas/BuilderCanvas.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/components/admin/builder/Canvas/DropIndicator.tsx
Normal file
16
src/components/admin/builder/Canvas/DropIndicator.tsx
Normal 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"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
98
src/components/admin/builder/Canvas/TemplateSummary.tsx
Normal file
98
src/components/admin/builder/Canvas/TemplateSummary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
88
src/components/admin/builder/Canvas/TransitionRow.tsx
Normal file
88
src/components/admin/builder/Canvas/TransitionRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/admin/builder/Canvas/VariantSummary.tsx
Normal file
109
src/components/admin/builder/Canvas/VariantSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/admin/builder/Canvas/constants.ts
Normal file
19
src/components/admin/builder/Canvas/constants.ts
Normal 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: "равно",
|
||||
};
|
||||
17
src/components/admin/builder/Canvas/index.ts
Normal file
17
src/components/admin/builder/Canvas/index.ts
Normal 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";
|
||||
9
src/components/admin/builder/Canvas/utils.ts
Normal file
9
src/components/admin/builder/Canvas/utils.ts
Normal 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;
|
||||
}
|
||||
564
src/components/admin/builder/Sidebar/BuilderSidebar.tsx
Normal file
564
src/components/admin/builder/Sidebar/BuilderSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/admin/builder/Sidebar/Section.tsx
Normal file
78
src/components/admin/builder/Sidebar/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/admin/builder/Sidebar/ValidationSummary.tsx
Normal file
34
src/components/admin/builder/Sidebar/ValidationSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/components/admin/builder/Sidebar/index.ts
Normal file
11
src/components/admin/builder/Sidebar/index.ts
Normal 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";
|
||||
27
src/components/admin/builder/Sidebar/types.ts
Normal file
27
src/components/admin/builder/Sidebar/types.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -36,9 +36,7 @@ export function SoulmatePortraitTemplate({
|
||||
onClick: onContinue,
|
||||
}}
|
||||
>
|
||||
{/* 🎯 Точно как InfoTemplate - пустой контент, без иконки и description */}
|
||||
<div className="-mt-[20px]">
|
||||
{/* Пустой контент - как InfoTemplate без иконки и без description */}
|
||||
</div>
|
||||
</TemplateLayout>
|
||||
);
|
||||
|
||||
@ -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";
|
||||
|
||||
66
src/lib/admin/builder/state/constants.ts
Normal file
66
src/lib/admin/builder/state/constants.ts
Normal 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,
|
||||
};
|
||||
44
src/lib/admin/builder/state/context.tsx
Normal file
44
src/lib/admin/builder/state/context.tsx
Normal 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);
|
||||
}
|
||||
19
src/lib/admin/builder/state/index.ts
Normal file
19
src/lib/admin/builder/state/index.ts
Normal 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";
|
||||
312
src/lib/admin/builder/state/reducer.ts
Normal file
312
src/lib/admin/builder/state/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
src/lib/admin/builder/state/types.ts
Normal file
37
src/lib/admin/builder/state/types.ts
Normal 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;
|
||||
}
|
||||
243
src/lib/admin/builder/state/utils.ts
Normal file
243
src/lib/admin/builder/state/utils.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user