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";
|
// Re-export everything from the new modular structure for backward compatibility
|
||||||
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
|
export {
|
||||||
import { Button } from "@/components/ui/button";
|
BuilderCanvas,
|
||||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
DropIndicator,
|
||||||
import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
|
TransitionRow,
|
||||||
import { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog";
|
TemplateSummary,
|
||||||
import type {
|
VariantSummary,
|
||||||
ListOptionDefinition,
|
getOptionLabel,
|
||||||
NavigationConditionDefinition,
|
TEMPLATE_TITLES,
|
||||||
ScreenDefinition,
|
OPERATOR_LABELS,
|
||||||
ScreenVariantDefinition,
|
} from "./Canvas";
|
||||||
} 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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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";
|
// Re-export everything from the new modular structure for backward compatibility
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
export {
|
||||||
|
BuilderSidebar,
|
||||||
|
Section,
|
||||||
|
ValidationSummary,
|
||||||
|
isListScreen,
|
||||||
|
} from "./Sidebar";
|
||||||
|
|
||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
// Re-export types for backward compatibility
|
||||||
import { Button } from "@/components/ui/button";
|
export type { ValidationIssues, SectionProps } from "./Sidebar";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
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) => {
|
const handleCopyPromoCode = (code: string) => {
|
||||||
// Copy to clipboard
|
|
||||||
navigator.clipboard.writeText(code);
|
navigator.clipboard.writeText(code);
|
||||||
setCopiedCode(code);
|
setCopiedCode(code);
|
||||||
|
|
||||||
// Reset copied state after 2 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopiedCode(null);
|
setCopiedCode(null);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@ -57,7 +55,6 @@ export function CouponTemplate({
|
|||||||
screenProgress,
|
screenProgress,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build coupon props from screen definition
|
|
||||||
const couponProps = {
|
const couponProps = {
|
||||||
title: buildTypographyProps(screen.coupon.title, {
|
title: buildTypographyProps(screen.coupon.title, {
|
||||||
as: "h3" as const,
|
as: "h3" as const,
|
||||||
@ -123,12 +120,10 @@ export function CouponTemplate({
|
|||||||
return (
|
return (
|
||||||
<LayoutQuestion {...layoutQuestionProps}>
|
<LayoutQuestion {...layoutQuestionProps}>
|
||||||
<div className="w-full flex flex-col items-center justify-center mt-[22px]">
|
<div className="w-full flex flex-col items-center justify-center mt-[22px]">
|
||||||
{/* Coupon Widget */}
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<Coupon {...couponProps} />
|
<Coupon {...couponProps} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Copy Success Message */}
|
|
||||||
{copiedCode && (
|
{copiedCode && (
|
||||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@ -20,20 +20,6 @@ interface DateTemplateProps {
|
|||||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
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({
|
export function DateTemplate({
|
||||||
screen,
|
screen,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
@ -44,7 +30,6 @@ export function DateTemplate({
|
|||||||
screenProgress,
|
screenProgress,
|
||||||
defaultTexts,
|
defaultTexts,
|
||||||
}: DateTemplateProps) {
|
}: DateTemplateProps) {
|
||||||
// Преобразуем объект {month, day, year} в ISO строку для DateInput
|
|
||||||
const isoDate = useMemo(() => {
|
const isoDate = useMemo(() => {
|
||||||
const { month, day, year } = selectedDate;
|
const { month, day, year } = selectedDate;
|
||||||
if (!month || !day || !year) return null;
|
if (!month || !day || !year) return null;
|
||||||
@ -60,7 +45,6 @@ export function DateTemplate({
|
|||||||
return null;
|
return null;
|
||||||
}, [selectedDate]);
|
}, [selectedDate]);
|
||||||
|
|
||||||
// Обработчик изменения даты - преобразуем ISO обратно в объект
|
|
||||||
const handleDateChange = (newIsoDate: string | null) => {
|
const handleDateChange = (newIsoDate: string | null) => {
|
||||||
if (!newIsoDate) {
|
if (!newIsoDate) {
|
||||||
onDateChange({ month: "", day: "", year: "" });
|
onDateChange({ month: "", day: "", year: "" });
|
||||||
@ -81,7 +65,6 @@ export function DateTemplate({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🎯 ЛОГИКА ВАЛИДАЦИИ ФОРМЫ ДЛЯ DATE - кнопка disabled пока дата не выбрана
|
|
||||||
const isFormValid = Boolean(isoDate);
|
const isFormValid = Boolean(isoDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -101,7 +84,6 @@ export function DateTemplate({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full mt-[22px] space-y-6">
|
<div className="w-full mt-[22px] space-y-6">
|
||||||
{/* Используем DateInput виджет разработчика */}
|
|
||||||
<DateInput
|
<DateInput
|
||||||
value={isoDate}
|
value={isoDate}
|
||||||
onChange={handleDateChange}
|
onChange={handleDateChange}
|
||||||
@ -110,7 +92,6 @@ export function DateTemplate({
|
|||||||
locale="en"
|
locale="en"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Info Message если есть */}
|
|
||||||
{screen.infoMessage && (
|
{screen.infoMessage && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
// 🎯 Схема валидации как в оригинале
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({
|
email: z.string().email({
|
||||||
message: "Please enter a valid email address",
|
message: "Please enter a valid email address",
|
||||||
@ -35,10 +34,8 @@ export function EmailTemplate({
|
|||||||
onContinue,
|
onContinue,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
onBack,
|
onBack,
|
||||||
// screenProgress не используется в email template - прогресс отключен
|
|
||||||
defaultTexts,
|
defaultTexts,
|
||||||
}: EmailTemplateProps) {
|
}: EmailTemplateProps) {
|
||||||
// 🎯 Валидация через react-hook-form + zod как в оригинале
|
|
||||||
const [isTouched, setIsTouched] = useState(false);
|
const [isTouched, setIsTouched] = useState(false);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
@ -67,7 +64,7 @@ export function EmailTemplate({
|
|||||||
onContinue={onContinue}
|
onContinue={onContinue}
|
||||||
canGoBack={canGoBack}
|
canGoBack={canGoBack}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
screenProgress={undefined} // 🚫 Отключаем прогресс бар по умолчанию
|
screenProgress={undefined}
|
||||||
defaultTexts={defaultTexts}
|
defaultTexts={defaultTexts}
|
||||||
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
|
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
|
||||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
|
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
|
||||||
@ -77,9 +74,7 @@ export function EmailTemplate({
|
|||||||
onClick: onContinue,
|
onClick: onContinue,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 🎨 Новая структура согласно требованиям */}
|
|
||||||
<div className="w-full flex flex-col items-center gap-[26px]">
|
<div className="w-full flex flex-col items-center gap-[26px]">
|
||||||
{/* 📧 Email Input - с дефолтными значениями */}
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label={screen.emailInput?.label || "Email"}
|
label={screen.emailInput?.label || "Email"}
|
||||||
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
|
placeholder={screen.emailInput?.placeholder || "Enter your Email"}
|
||||||
@ -96,18 +91,16 @@ export function EmailTemplate({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 🖼️ Image - с зашитыми значениями как в оригинальном Email компоненте */}
|
|
||||||
{screen.image && (
|
{screen.image && (
|
||||||
<Image
|
<Image
|
||||||
src={screen.image.src}
|
src={screen.image.src}
|
||||||
alt="portrait" // Зашитое значение согласно дизайну
|
alt="portrait"
|
||||||
width={164} // Зашитое значение согласно дизайну
|
width={164}
|
||||||
height={245} // Зашитое значение согласно дизайну
|
height={245}
|
||||||
className="mt-3.5 rounded-[50px] blur-sm" // Зашитые стили согласно дизайну
|
className="mt-3.5 rounded-[50px] blur-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 🔒 Privacy Security Banner */}
|
|
||||||
<PrivacySecurityBanner
|
<PrivacySecurityBanner
|
||||||
className="mt-[26px]"
|
className="mt-[26px]"
|
||||||
text={{
|
text={{
|
||||||
|
|||||||
@ -34,12 +34,10 @@ export function FormTemplate({
|
|||||||
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
|
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Sync with external form data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalFormData(formData);
|
setLocalFormData(formData);
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
// Update external form data when local data changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFormDataChange(localFormData);
|
onFormDataChange(localFormData);
|
||||||
}, [localFormData, onFormDataChange]);
|
}, [localFormData, onFormDataChange]);
|
||||||
@ -69,7 +67,6 @@ export function FormTemplate({
|
|||||||
const handleFieldChange = (fieldId: string, value: string) => {
|
const handleFieldChange = (fieldId: string, value: string) => {
|
||||||
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
|
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
|
||||||
|
|
||||||
// Clear error if field becomes valid
|
|
||||||
if (errors[fieldId]) {
|
if (errors[fieldId]) {
|
||||||
setErrors(prev => {
|
setErrors(prev => {
|
||||||
const newErrors = { ...prev };
|
const newErrors = { ...prev };
|
||||||
|
|||||||
@ -29,14 +29,14 @@ export function InfoTemplate({
|
|||||||
const size = screen.icon?.size ?? "xl";
|
const size = screen.icon?.size ?? "xl";
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case "sm":
|
case "sm":
|
||||||
return "text-4xl"; // 36px
|
return "text-4xl";
|
||||||
case "md":
|
case "md":
|
||||||
return "text-5xl"; // 48px
|
return "text-5xl";
|
||||||
case "lg":
|
case "lg":
|
||||||
return "text-6xl"; // 60px
|
return "text-6xl";
|
||||||
case "xl":
|
case "xl":
|
||||||
default:
|
default:
|
||||||
return "text-8xl"; // 128px
|
return "text-8xl";
|
||||||
}
|
}
|
||||||
}, [screen.icon?.size]);
|
}, [screen.icon?.size]);
|
||||||
|
|
||||||
@ -58,8 +58,7 @@ export function InfoTemplate({
|
|||||||
>
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-full flex flex-col items-center justify-center text-center",
|
"w-full flex flex-col items-center justify-center text-center",
|
||||||
// 🔧 Уменьшили отступ: без иконки убираем лишнее пространство
|
screen.icon ? "mt-[60px]" : "-mt-[20px]"
|
||||||
screen.icon ? "mt-[60px]" : "-mt-[20px]" // Отрицательный margin компенсирует mt-[30px] из LayoutQuestion
|
|
||||||
)}>
|
)}>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
{screen.icon && (
|
{screen.icon && (
|
||||||
@ -92,7 +91,7 @@ export function InfoTemplate({
|
|||||||
{screen.description && (
|
{screen.description && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"max-w-[280px]",
|
"max-w-[280px]",
|
||||||
screen.icon ? "mt-6" : "mt-0" // 🔧 Убираем отступ сверху для текста если нет иконки
|
screen.icon ? "mt-6" : "mt-0"
|
||||||
)}>
|
)}>
|
||||||
<Typography
|
<Typography
|
||||||
as="p"
|
as="p"
|
||||||
|
|||||||
@ -91,7 +91,6 @@ export function ListTemplate({
|
|||||||
onChangeSelectedAnswers: handleSelectChange,
|
onChangeSelectedAnswers: handleSelectChange,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🎯 СЛОЖНАЯ ЛОГИКА КНОПКИ ДЛЯ СПИСКОВ - actionButtonProps приходит из screenRenderer
|
|
||||||
const actionButtonOptions = actionButtonProps ? {
|
const actionButtonOptions = actionButtonProps ? {
|
||||||
defaultText: actionButtonProps.children as string || "Next",
|
defaultText: actionButtonProps.children as string || "Next",
|
||||||
disabled: actionButtonProps.disabled || false,
|
disabled: actionButtonProps.disabled || false,
|
||||||
@ -105,7 +104,7 @@ export function ListTemplate({
|
|||||||
return (
|
return (
|
||||||
<TemplateLayout
|
<TemplateLayout
|
||||||
screen={screen}
|
screen={screen}
|
||||||
onContinue={() => {}} // Не используется, логика в actionButtonOptions.onClick
|
onContinue={() => {}}
|
||||||
canGoBack={canGoBack}
|
canGoBack={canGoBack}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
screenProgress={screenProgress}
|
screenProgress={screenProgress}
|
||||||
|
|||||||
@ -24,12 +24,10 @@ export function LoadersTemplate({
|
|||||||
}: LoadersTemplateProps) {
|
}: LoadersTemplateProps) {
|
||||||
const [isVisibleButton, setIsVisibleButton] = useState(false);
|
const [isVisibleButton, setIsVisibleButton] = useState(false);
|
||||||
|
|
||||||
// 🎯 Функция завершения анимации - активирует кнопку
|
|
||||||
const onAnimationEnd = () => {
|
const onAnimationEnd = () => {
|
||||||
setIsVisibleButton(true);
|
setIsVisibleButton(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🎨 Преобразуем данные screen definition в props для CircularProgressbarsList
|
|
||||||
const progressbarsListProps = {
|
const progressbarsListProps = {
|
||||||
progressbarItems: screen.progressbars?.items?.map((item: unknown, index: number) => {
|
progressbarItems: screen.progressbars?.items?.map((item: unknown, index: number) => {
|
||||||
const typedItem = item as {
|
const typedItem = item as {
|
||||||
@ -70,14 +68,14 @@ export function LoadersTemplate({
|
|||||||
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
|
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
|
||||||
actionButtonOptions={{
|
actionButtonOptions={{
|
||||||
defaultText: defaultTexts?.nextButton || "Continue",
|
defaultText: defaultTexts?.nextButton || "Continue",
|
||||||
disabled: !isVisibleButton, // 🎯 Кнопка неактивна пока анимация не завершится
|
disabled: !isVisibleButton,
|
||||||
onClick: onContinue,
|
onClick: onContinue,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full flex flex-col items-center gap-[22px] mt-[22px]">
|
<div className="w-full flex flex-col items-center gap-[22px] mt-[22px]">
|
||||||
<CircularProgressbarsList
|
<CircularProgressbarsList
|
||||||
{...progressbarsListProps}
|
{...progressbarsListProps}
|
||||||
showDividers={false} // 🚫 Убираем разделительные линии в экранах лоадера
|
showDividers={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TemplateLayout>
|
</TemplateLayout>
|
||||||
|
|||||||
@ -36,9 +36,7 @@ export function SoulmatePortraitTemplate({
|
|||||||
onClick: onContinue,
|
onClick: onContinue,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 🎯 Точно как InfoTemplate - пустой контент, без иконки и description */}
|
|
||||||
<div className="-mt-[20px]">
|
<div className="-mt-[20px]">
|
||||||
{/* Пустой контент - как InfoTemplate без иконки и без description */}
|
|
||||||
</div>
|
</div>
|
||||||
</TemplateLayout>
|
</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";
|
// Re-export everything from the new modular structure for backward compatibility
|
||||||
|
export {
|
||||||
import type {
|
type BuilderState,
|
||||||
BuilderFunnelState,
|
type BuilderAction,
|
||||||
BuilderScreen,
|
type BuilderProviderProps,
|
||||||
BuilderScreenPosition,
|
INITIAL_STATE,
|
||||||
} from "@/lib/admin/builder/types";
|
INITIAL_META,
|
||||||
import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
INITIAL_SCREEN,
|
||||||
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
|
withDirty,
|
||||||
|
generateScreenId,
|
||||||
interface BuilderState extends BuilderFunnelState {
|
createScreenByTemplate,
|
||||||
selectedScreenId: string | null;
|
builderReducer,
|
||||||
isDirty: boolean;
|
BuilderProvider,
|
||||||
}
|
useBuilderState,
|
||||||
|
useBuilderDispatch,
|
||||||
const INITIAL_META: BuilderFunnelState["meta"] = {
|
useBuilderSelectedScreen,
|
||||||
id: "funnel-builder-draft",
|
} from "./state";
|
||||||
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 };
|
|
||||||
|
|||||||
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 },
|
id: { type: String, required: true },
|
||||||
template: {
|
template: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['info', 'date', 'coupon', 'form', 'list'],
|
enum: ['info', 'date', 'coupon', 'form', 'list', 'email', 'loaders', 'soulmate'],
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
header: HeaderDefinitionSchema,
|
header: HeaderDefinitionSchema,
|
||||||
@ -129,7 +129,7 @@ const ScreenDefinitionSchema = new Schema({
|
|||||||
navigation: NavigationDefinitionSchema,
|
navigation: NavigationDefinitionSchema,
|
||||||
|
|
||||||
// Специфичные для template поля (используем Mixed для максимальной гибкости)
|
// Специфичные для template поля (используем Mixed для максимальной гибкости)
|
||||||
description: TypographyVariantSchema, // info
|
description: TypographyVariantSchema, // info, soulmate
|
||||||
icon: Schema.Types.Mixed, // info
|
icon: Schema.Types.Mixed, // info
|
||||||
dateInput: Schema.Types.Mixed, // date
|
dateInput: Schema.Types.Mixed, // date
|
||||||
infoMessage: Schema.Types.Mixed, // date
|
infoMessage: Schema.Types.Mixed, // date
|
||||||
@ -144,6 +144,9 @@ const ScreenDefinitionSchema = new Schema({
|
|||||||
},
|
},
|
||||||
options: [ListOptionDefinitionSchema]
|
options: [ListOptionDefinitionSchema]
|
||||||
},
|
},
|
||||||
|
emailInput: Schema.Types.Mixed, // email
|
||||||
|
image: Schema.Types.Mixed, // email, soulmate
|
||||||
|
loadersConfig: Schema.Types.Mixed, // loaders
|
||||||
variants: [Schema.Types.Mixed] // variants для всех типов
|
variants: [Schema.Types.Mixed] // variants для всех типов
|
||||||
}, { _id: false });
|
}, { _id: false });
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user