w-funnel/src/components/admin/builder/Sidebar/BuilderSidebar.tsx
gofnnp fba0acaf0b payment
add payment
2025-10-06 22:51:32 +04:00

746 lines
28 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 { useCallback, useEffect, useMemo, useState } from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { TemplateConfig } from "@/components/admin/builder/templates";
import { ScreenVariantsConfig } from "../forms/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]);
// ✅ Оптимизированная validation - только критичные поля
const screenIds = useMemo(
() => state.screens.map((s) => s.id).join(","),
[state.screens]
);
const validation = useMemo(
() => validateBuilderState(state),
// eslint-disable-next-line react-hooks/exhaustive-deps -- Оптимизация: пересчитываем только при изменении критичных полей
[state.meta.id, state.meta.firstScreenId, screenIds, state.screens.length]
);
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]
);
// ✅ Handlers для text inputs
const handleMetaChange = useCallback(
(field: keyof typeof state.meta, value: string) => {
dispatch({ type: "set-meta", payload: { [field]: value } });
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- dispatch стабилен из context
[dispatch]
);
const handleFirstScreenChange = (value: string) => {
dispatch({ type: "set-meta", payload: { firstScreenId: value } });
};
const handleDefaultTextsChange = useCallback(
(field: keyof NonNullable<typeof state.defaultTexts>, value: string) => {
dispatch({ type: "set-default-texts", payload: { [field]: value } });
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- dispatch стабилен из context
[dispatch]
);
const handleScreenIdChange = (currentId: string, newId: string) => {
if (newId === currentId) {
return;
}
// Разрешаем пустые ID для полного переименования
if (newId.trim() === "") {
// Просто обновляем на пустое значение, пользователь сможет ввести новое
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId },
},
});
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">
{/* Валидация всегда вверху, без заголовка */}
<ValidationSummary issues={validation.issues} />
<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="Текст кнопок и баннеров"
>
<TextInput
label="Текст кнопки Next/Continue"
placeholder="Next"
value={state.defaultTexts?.nextButton ?? ""}
onChange={(event) =>
handleDefaultTextsChange("nextButton", event.target.value)
}
/>
<TextInput
label="Баннер приватности"
placeholder="Мы не передаем личную информацию..."
value={state.defaultTexts?.privacyBanner ?? ""}
onChange={(event) =>
handleDefaultTextsChange("privacyBanner", event.target.value)
}
/>
</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">
{/* Валидация всегда вверху, без заголовка */}
<ValidationSummary issues={screenValidationIssues} />
<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="h-8 px-2 text-destructive hover:bg-destructive/10"
onClick={() =>
handleRemoveRule(selectedScreen.id, ruleIndex)
}
>
<Trash2 className="h-3 w-3 mr-1" />
<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="Управление">
<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)}
>
<Trash2 className="h-4 w-4 mr-2" />
{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>
);
}