w-funnel/src/components/admin/builder/Sidebar/BuilderSidebar.tsx
dev.daminik00 b3eaa19fcd dix
2025-09-28 06:38:15 +02:00

565 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 } from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Button } from "@/components/ui/button";
import { TemplateConfig } from "@/components/admin/builder/templates";
import { ScreenVariantsConfig } from "@/components/admin/builder/ScreenVariantsConfig";
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type {
NavigationRuleDefinition,
ScreenDefinition,
ScreenVariantDefinition,
} from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
import { validateBuilderState } from "@/lib/admin/builder/validation";
import { Section } from "./Section";
import { ValidationSummary } from "./ValidationSummary";
import { isListScreen, type ValidationIssues } from "./types";
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 handleScreenIdChange = (currentId: string, newId: string) => {
if (newId.trim() === "" || newId === currentId) {
return;
}
// Обновляем ID экрана
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId }
}
});
// Если это был первый экран в мета данных, обновляем и там
if (state.meta.firstScreenId === currentId) {
dispatch({ type: "set-meta", payload: { firstScreenId: newId } });
}
};
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 ?? [],
isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
},
},
});
};
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 handleVariantsChange = (
screenId: string,
variants: ScreenVariantDefinition<ScreenDefinition>[]
) => {
dispatch({
type: "update-screen",
payload: {
screenId,
screen: {
variants: variants.length > 0 ? variants : undefined,
} 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-4 py-3">
<div className="flex flex-col gap-1">
<h1 className="text-base font-semibold">Настройки</h1>
</div>
<div className="mt-3 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-4 py-4">
{activeTab === "funnel" ? (
<div className="flex flex-col gap-4">
<Section title="Валидация">
<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="Экраны">
<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-4">
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">#{selectedScreen.id}</span>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary uppercase">
{selectedScreen.template}
</span>
</div>
<span className="text-xs text-muted-foreground">
{state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length}
</span>
</div>
</div>
<Section title="Общие данные">
<TextInput
label="ID экрана"
value={selectedScreen.id}
onChange={(event) => handleScreenIdChange(selectedScreen.id, event.target.value)}
/>
</Section>
<Section title="Контент и оформление">
<TemplateConfig
screen={selectedScreen}
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
/>
</Section>
<Section title="Вариативность">
<ScreenVariantsConfig
screen={selectedScreen}
allScreens={state.screens}
onChange={(variants) => handleVariantsChange(selectedScreen.id, variants)}
/>
</Section>
<Section title="Навигация">
{/* 🎯 ЧЕКБОКС ДЛЯ ФИНАЛЬНОГО ЭКРАНА */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedScreen.navigation?.isEndScreen ?? false}
onChange={(e) => {
updateNavigation(selectedScreen, { isEndScreen: e.target.checked });
}}
className="rounded border-border"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">Финальный экран</span>
<span className="text-xs text-muted-foreground">
Этот экран завершает воронку (переход не требуется)
</span>
</div>
</label>
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
{!selectedScreen.navigation?.isEndScreen && (
<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 && !selectedScreen.navigation?.isEndScreen && (
<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 w-8 p-0 flex items-center justify-center" onClick={() => handleAddRule(selectedScreen)}>
<span className="text-lg leading-none">+</span>
</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="Валидация">
<ValidationSummary issues={screenValidationIssues} />
</Section>
<Section title="Управление">
<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>
);
}