"use client";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
import { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog";
import type {
ListOptionDefinition,
NavigationConditionDefinition,
ScreenDefinition,
ScreenVariantDefinition,
} from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
function DropIndicator({ isActive }: { isActive: boolean }) {
return (
);
}
const TEMPLATE_TITLES: Record = {
list: "Список",
form: "Форма",
info: "Инфо",
date: "Дата",
coupon: "Купон",
};
const OPERATOR_LABELS: Record, string> = {
includesAny: "любой из",
includesAll: "все из",
includesExactly: "точное совпадение",
};
interface TransitionRowProps {
type: "default" | "branch" | "end";
label: string;
targetLabel?: string;
targetIndex?: number | null;
optionSummaries?: { id: string; label: string }[];
operator?: string;
}
function TransitionRow({
type,
label,
targetLabel,
targetIndex,
optionSummaries = [],
operator,
}: TransitionRowProps) {
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
return (
{label}
{operator && (
{operator}
)}
{optionSummaries.length > 0 && (
{optionSummaries.map((option) => (
{option.label}
))}
)}
{type === "end" ? (
Завершение воронки
) : (
<>
{typeof targetIndex === "number" && (
#{targetIndex + 1}
)}
{targetLabel ?? "Не выбрано"}
>
)}
);
}
function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
switch (screen.template) {
case "list": {
return (
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
Варианты ({screen.list.options.length})
{screen.list.options.map((option) => (
{option.emoji && {option.emoji}}
{option.label}
))}
);
}
case "form": {
return (
Полей: {screen.fields.length}
{screen.bottomActionButton?.text && (
{screen.bottomActionButton.text}
)}
{screen.validationMessages && (
Настроены пользовательские сообщения валидации
)}
);
}
case "coupon": {
return (
Промо: {screen.coupon.promoCode.text}
{screen.coupon.offer.title.text}
);
}
case "date": {
return (
Формат даты:
{screen.dateInput.monthLabel && {screen.dateInput.monthLabel}}
{screen.dateInput.dayLabel && {screen.dateInput.dayLabel}}
{screen.dateInput.yearLabel && {screen.dateInput.yearLabel}}
{screen.dateInput.validationMessage && (
{screen.dateInput.validationMessage}
)}
);
}
case "info": {
return (
{screen.description?.text &&
{screen.description.text}
}
{screen.icon?.value && (
{screen.icon.value}
Иконка
)}
);
}
default:
return null;
}
}
function VariantSummary({
screen,
screenTitleMap,
listOptionsMap,
}: {
screen: ScreenDefinition;
screenTitleMap: Record;
listOptionsMap: Record;
}) {
const variants = (
screen as ScreenDefinition & {
variants?: ScreenVariantDefinition[];
}
).variants;
if (!variants || variants.length === 0) {
return null;
}
return (
Варианты
{variants.length}
{variants.map((variant, index) => {
const [condition] = variant.conditions ?? [];
const controllingScreenId = condition?.screenId;
const controllingScreenTitle = controllingScreenId
? screenTitleMap[controllingScreenId] ?? controllingScreenId
: "Не выбрано";
const options = controllingScreenId ? listOptionsMap[controllingScreenId] ?? [] : [];
const optionSummaries = (condition?.optionIds ?? []).map((optionId) => ({
id: optionId,
label: getOptionLabel(options, optionId),
}));
const operatorKey = condition?.operator as
| Exclude
| undefined;
const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
return (
Вариант {index + 1}
{operatorLabel}
Экран: {controllingScreenTitle}
{optionSummaries.length > 0 ? (
{optionSummaries.map((option) => (
{option.label}
))}
) : (
Нет выбранных ответов
)}
Изменяет:
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
{highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
))}
);
})}
);
}
function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
const option = options.find((item) => item.id === optionId);
return option ? option.label : optionId;
}
export function BuilderCanvas() {
const { screens, selectedScreenId } = useBuilderState();
const dispatch = useBuilderDispatch();
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
const [dropIndex, setDropIndex] = useState(null);
const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false);
const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", screenId);
dragStateRef.current = { screenId, dragStartIndex: index };
setDropIndex(index);
}, []);
const handleDragOverCard = useCallback((event: React.DragEvent, index: number) => {
event.preventDefault();
if (!dragStateRef.current) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const offsetY = event.clientY - rect.top;
const nextIndex = offsetY > rect.height / 2 ? index + 1 : index;
setDropIndex(nextIndex);
}, []);
const handleDragOverList = useCallback(
(event: React.DragEvent) => {
if (!dragStateRef.current) {
return;
}
event.preventDefault();
if (event.target === event.currentTarget) {
setDropIndex(screens.length);
}
},
[screens.length]
);
const finalizeDrop = useCallback(
(insertionIndex: number | null) => {
if (!dragStateRef.current) {
return;
}
const { dragStartIndex } = dragStateRef.current;
const boundedIndex = Math.max(0, Math.min(insertionIndex ?? dragStartIndex, screens.length));
let targetIndex = boundedIndex;
if (targetIndex > dragStartIndex) {
targetIndex -= 1;
}
if (dragStartIndex !== targetIndex) {
dispatch({
type: "reorder-screens",
payload: {
fromIndex: dragStartIndex,
toIndex: targetIndex,
},
});
}
dragStateRef.current = null;
setDropIndex(null);
},
[dispatch, screens.length]
);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
finalizeDrop(dropIndex);
},
[dropIndex, finalizeDrop]
);
const handleDragEnd = useCallback(() => {
dragStateRef.current = null;
setDropIndex(null);
}, []);
const handleSelectScreen = useCallback(
(screenId: string) => {
dispatch({ type: "set-selected-screen", payload: { screenId } });
},
[dispatch]
);
const handleAddScreen = useCallback(() => {
setAddScreenDialogOpen(true);
}, []);
const handleAddScreenWithTemplate = useCallback((template: ScreenDefinition["template"]) => {
dispatch({ type: "add-screen", payload: { template } });
}, [dispatch]);
const screenTitleMap = useMemo(() => {
return screens.reduce>((accumulator, screen) => {
accumulator[screen.id] = screen.title.text || screen.id;
return accumulator;
}, {});
}, [screens]);
const listOptionsMap = useMemo(() => {
return screens.reduce>((accumulator, screen) => {
if (screen.template === "list") {
accumulator[screen.id] = screen.list.options;
}
return accumulator;
}, {});
}, [screens]);
return (
<>
{screens.map((screen, index) => {
const isSelected = screen.id === selectedScreenId;
const isDropBefore = dropIndex === index;
const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
const rules = screen.navigation?.rules ?? [];
const defaultNext = screen.navigation?.defaultNextScreenId;
const isLast = index === screens.length - 1;
const defaultTargetIndex = defaultNext
? screens.findIndex((candidate) => candidate.id === defaultNext)
: null;
return (
{isDropBefore &&
}
handleDragStart(event, screen.id, index)}
onDragOver={(event) => handleDragOverCard(event, index)}
onDragEnd={handleDragEnd}
onClick={() => handleSelectScreen(screen.id)}
>
{TEMPLATE_TITLES[screen.template] ?? screen.template}
{index + 1}
#{screen.id}
{screen.title.text || "Без названия"}
{("subtitle" in screen && screen.subtitle?.text) && (
{screen.subtitle.text}
)}
{rules.map((rule, ruleIndex) => {
const condition = rule.conditions[0];
const optionSummaries =
screen.template === "list" && condition?.optionIds
? condition.optionIds.map((optionId) => ({
id: optionId,
label: getOptionLabel(screen.list.options, optionId),
}))
: [];
const operatorKey = condition?.operator as
| Exclude
| undefined;
const operatorLabel = operatorKey
? OPERATOR_LABELS[operatorKey] ?? operatorKey
: undefined;
const ruleTargetIndex = screens.findIndex(
(candidate) => candidate.id === rule.nextScreenId
);
const ruleTargetLabel = screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId;
return (
);
})}
{isDropAfter &&
}
);
})}
{screens.length === 0 && (
Добавьте первый экран, чтобы начать строить воронку.
)}
>
);
}