w-funnel/src/components/admin/builder/BuilderSidebar.tsx
dev.daminik00 0b10a09497 fix
2025-09-26 20:51:08 +02:00

561 lines
22 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 { 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>
);
}