"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 && }
{!isLast && (
)}
handleDragStart(event, screen.id, index)} onDragOver={(event) => handleDragOverCard(event, index)} onDragEnd={handleDragEnd} onClick={() => handleSelectScreen(screen.id)} > {TEMPLATE_TITLES[screen.template] ?? screen.template}
{index + 1}
#{screen.id} {screen.title.text || "Без названия"}
{("subtitle" in screen && screen.subtitle?.text) && (

{screen.subtitle.text}

)}
Переходы
{rules.map((rule, ruleIndex) => { const condition = rule.conditions[0]; const optionSummaries = screen.template === "list" && condition?.optionIds ? condition.optionIds.map((optionId) => ({ id: optionId, label: getOptionLabel(screen.list.options, optionId), })) : []; const operatorKey = condition?.operator as | Exclude | undefined; const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : undefined; const ruleTargetIndex = screens.findIndex( (candidate) => candidate.id === rule.nextScreenId ); const ruleTargetLabel = screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId; return ( ); })}
{isDropAfter && }
); })} {screens.length === 0 && (
Добавьте первый экран, чтобы начать строить воронку.
)}
); }