561 lines
22 KiB
TypeScript
561 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||
|
||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||
import { Button } from "@/components/ui/button";
|
||
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
|
||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||
import type { NavigationRuleDefinition, ScreenDefinition } 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,
|
||
}: {
|
||
title: string;
|
||
description?: string;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<section className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-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 className="flex flex-col gap-4">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ValidationSummary({ issues }: { issues: ValidationIssues }) {
|
||
if (issues.length === 0) {
|
||
return (
|
||
<div className="rounded-xl border border-border/50 bg-background/60 p-3 text-xs text-muted-foreground">
|
||
Всё хорошо — воронка валидна.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
{issues.map((issue, index) => (
|
||
<div
|
||
key={`${issue.severity}-${issue.screenId ?? "root"}-${issue.optionId ?? "all"}-${index}`}
|
||
className={cn(
|
||
"rounded-xl border p-3 text-xs",
|
||
issue.severity === "error"
|
||
? "border-destructive/60 bg-destructive/10 text-destructive"
|
||
: "border-amber-400/60 bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||
)}
|
||
>
|
||
<div className="font-semibold uppercase tracking-wide">
|
||
{issue.severity === "error" ? "Ошибка" : "Предупреждение"}
|
||
{issue.screenId ? ` · ${issue.screenId}` : ""}
|
||
{issue.optionId ? ` · ${issue.optionId}` : ""}
|
||
</div>
|
||
<p className="mt-1 leading-relaxed">{issue.message}</p>
|
||
</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 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 ?? [],
|
||
},
|
||
},
|
||
});
|
||
};
|
||
|
||
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 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-6 py-4">
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||
Режим редактирования
|
||
</span>
|
||
<h1 className="text-lg font-semibold">Настройки</h1>
|
||
</div>
|
||
<div className="mt-4 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-6 py-6">
|
||
{activeTab === "funnel" ? (
|
||
<div className="flex flex-col gap-6">
|
||
<Section title="Валидация" description="Проверка общих настроек">
|
||
<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="Экраны" description="Управление и статистика">
|
||
<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-6">
|
||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||
<span>
|
||
<span className="font-semibold">ID:</span> {selectedScreen.id}
|
||
</span>
|
||
<span>
|
||
<span className="font-semibold">Тип:</span> {selectedScreen.template}
|
||
</span>
|
||
<span>
|
||
<span className="font-semibold">Позиция:</span> экран {state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1} из {state.screens.length}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Section title="Общие данные" description="ID и тип текущего экрана">
|
||
<TextInput label="ID экрана" value={selectedScreen.id} disabled />
|
||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||
Текущий шаблон: <span className="font-semibold text-foreground">{selectedScreen.template}</span>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section title="Контент и оформление" description="Все параметры выбранного шаблона">
|
||
<TemplateConfig
|
||
screen={selectedScreen}
|
||
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
|
||
/>
|
||
</Section>
|
||
|
||
<Section title="Навигация" description="Переходы между экранами">
|
||
<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 && (
|
||
<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 px-3 text-xs" onClick={() => handleAddRule(selectedScreen)}>
|
||
Добавить правило
|
||
</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="Валидация экрана" description="Проверка корректности настроек">
|
||
<ValidationSummary issues={screenValidationIssues} />
|
||
</Section>
|
||
|
||
<Section title="Управление экраном" description="Опасные действия">
|
||
<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>
|
||
);
|
||
}
|