w-funnel/src/components/admin/builder/BuilderCanvas.tsx
dev.daminik00 0fc1dc756e admin
2025-09-27 05:48:42 +02:00

610 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 (
<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: "Купон",
};
const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, 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 (
<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={defaultNext ? "default" : "end"}
label={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}
/>
</>
);
}