419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||
import { Button } from "@/components/ui/button";
|
||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||
import {
|
||
extractVariantOverrides,
|
||
formatOverridePath,
|
||
listOverridePaths,
|
||
mergeScreenWithOverrides,
|
||
} from "@/lib/admin/builder/variants";
|
||
import type {
|
||
ListOptionDefinition,
|
||
NavigationConditionDefinition,
|
||
ScreenDefinition,
|
||
ScreenVariantDefinition,
|
||
} from "@/lib/funnel/types";
|
||
|
||
interface ScreenVariantsConfigProps {
|
||
screen: BuilderScreen;
|
||
allScreens: BuilderScreen[];
|
||
onChange: (variants: ScreenVariantDefinition<ScreenDefinition>[]) => void;
|
||
}
|
||
|
||
type ListBuilderScreen = BuilderScreen & { template: "list" };
|
||
|
||
type VariantDefinition = ScreenVariantDefinition<ScreenDefinition>;
|
||
|
||
type VariantCondition = NavigationConditionDefinition;
|
||
|
||
function ensureCondition(variant: VariantDefinition, fallbackScreenId: string): VariantCondition {
|
||
const [condition] = variant.conditions;
|
||
|
||
if (!condition) {
|
||
return {
|
||
screenId: fallbackScreenId,
|
||
operator: "includesAny",
|
||
optionIds: [],
|
||
};
|
||
}
|
||
|
||
return condition;
|
||
}
|
||
|
||
function VariantOverridesEditor({
|
||
baseScreen,
|
||
overrides,
|
||
onChange,
|
||
}: {
|
||
baseScreen: BuilderScreen;
|
||
overrides: VariantDefinition["overrides"];
|
||
onChange: (overrides: VariantDefinition["overrides"]) => void;
|
||
}) {
|
||
const baseWithoutVariants = useMemo(() => {
|
||
const clone = mergeScreenWithOverrides(baseScreen, {});
|
||
const sanitized = { ...clone } as BuilderScreen;
|
||
if ("variants" in sanitized) {
|
||
delete (sanitized as Partial<BuilderScreen>).variants;
|
||
}
|
||
return sanitized;
|
||
}, [baseScreen]);
|
||
|
||
const mergedScreen = useMemo(
|
||
() => mergeScreenWithOverrides(baseWithoutVariants, overrides),
|
||
[baseWithoutVariants, overrides]
|
||
);
|
||
|
||
const handleUpdate = useCallback(
|
||
(updates: Partial<ScreenDefinition>) => {
|
||
const nextScreen = mergeScreenWithOverrides(mergedScreen, updates as Partial<ScreenDefinition>);
|
||
const nextOverrides = extractVariantOverrides(baseWithoutVariants, nextScreen);
|
||
onChange(nextOverrides);
|
||
},
|
||
[baseWithoutVariants, mergedScreen, onChange]
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<TemplateConfig screen={mergedScreen} onUpdate={handleUpdate} />
|
||
<Button variant="outline" size="sm" onClick={() => onChange({})}>
|
||
Сбросить переопределения
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVariantsConfigProps) {
|
||
const variants = useMemo(
|
||
() => ((screen.variants ?? []) as VariantDefinition[]),
|
||
[screen.variants]
|
||
);
|
||
const [expandedVariant, setExpandedVariant] = useState<number | null>(() => (variants.length > 0 ? 0 : null));
|
||
|
||
useEffect(() => {
|
||
if (variants.length === 0) {
|
||
setExpandedVariant(null);
|
||
return;
|
||
}
|
||
|
||
if (expandedVariant === null) {
|
||
setExpandedVariant(0);
|
||
return;
|
||
}
|
||
|
||
if (expandedVariant >= variants.length) {
|
||
setExpandedVariant(variants.length - 1);
|
||
}
|
||
}, [expandedVariant, variants]);
|
||
|
||
const listScreens = useMemo(
|
||
() => allScreens.filter((candidate): candidate is ListBuilderScreen => candidate.template === "list"),
|
||
[allScreens]
|
||
);
|
||
|
||
const optionMap = useMemo(() => {
|
||
return listScreens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, listScreen) => {
|
||
accumulator[listScreen.id] = listScreen.list.options;
|
||
return accumulator;
|
||
}, {});
|
||
}, [listScreens]);
|
||
|
||
const handleVariantsUpdate = useCallback(
|
||
(nextVariants: VariantDefinition[]) => {
|
||
onChange(nextVariants);
|
||
},
|
||
[onChange]
|
||
);
|
||
|
||
const addVariant = useCallback(() => {
|
||
const fallbackScreen = listScreens[0] ?? (screen.template === "list" ? (screen as ListBuilderScreen) : undefined);
|
||
|
||
if (!fallbackScreen) {
|
||
return;
|
||
}
|
||
|
||
const firstOptionId = fallbackScreen.list.options[0]?.id;
|
||
|
||
const newVariant: VariantDefinition = {
|
||
conditions: [
|
||
{
|
||
screenId: fallbackScreen.id,
|
||
operator: "includesAny",
|
||
optionIds: firstOptionId ? [firstOptionId] : [],
|
||
},
|
||
],
|
||
overrides: {},
|
||
};
|
||
|
||
handleVariantsUpdate([...variants, newVariant]);
|
||
setExpandedVariant(variants.length);
|
||
}, [handleVariantsUpdate, listScreens, screen, variants]);
|
||
|
||
const removeVariant = useCallback(
|
||
(index: number) => {
|
||
handleVariantsUpdate(variants.filter((_, variantIndex) => variantIndex !== index));
|
||
},
|
||
[handleVariantsUpdate, variants]
|
||
);
|
||
|
||
const updateVariant = useCallback(
|
||
(index: number, patch: Partial<VariantDefinition>) => {
|
||
handleVariantsUpdate(
|
||
variants.map((variant, variantIndex) =>
|
||
variantIndex === index
|
||
? {
|
||
...variant,
|
||
...patch,
|
||
conditions: patch.conditions ?? variant.conditions,
|
||
overrides: patch.overrides ?? variant.overrides,
|
||
}
|
||
: variant
|
||
)
|
||
);
|
||
},
|
||
[handleVariantsUpdate, variants]
|
||
);
|
||
|
||
const updateCondition = useCallback(
|
||
(index: number, updates: Partial<VariantCondition>) => {
|
||
updateVariant(index, {
|
||
conditions: [
|
||
{
|
||
...ensureCondition(variants[index], screen.id),
|
||
...updates,
|
||
},
|
||
],
|
||
});
|
||
},
|
||
[screen.id, updateVariant, variants]
|
||
);
|
||
|
||
const toggleOption = useCallback(
|
||
(index: number, optionId: string) => {
|
||
const condition = ensureCondition(variants[index], screen.id);
|
||
const optionIds = new Set(condition.optionIds ?? []);
|
||
if (optionIds.has(optionId)) {
|
||
optionIds.delete(optionId);
|
||
} else {
|
||
optionIds.add(optionId);
|
||
}
|
||
|
||
updateCondition(index, { optionIds: Array.from(optionIds) });
|
||
},
|
||
[screen.id, updateCondition, variants]
|
||
);
|
||
|
||
const handleScreenChange = useCallback(
|
||
(variantIndex: number, screenId: string) => {
|
||
const listScreen = listScreens.find((candidate) => candidate.id === screenId);
|
||
const defaultOption = listScreen?.list.options[0]?.id;
|
||
updateCondition(variantIndex, {
|
||
screenId,
|
||
optionIds: defaultOption ? [defaultOption] : [],
|
||
});
|
||
},
|
||
[listScreens, updateCondition]
|
||
);
|
||
|
||
const handleOperatorChange = useCallback(
|
||
(variantIndex: number, operator: VariantCondition["operator"]) => {
|
||
updateCondition(variantIndex, { operator });
|
||
},
|
||
[updateCondition]
|
||
);
|
||
|
||
const handleOverridesChange = useCallback(
|
||
(index: number, overrides: VariantDefinition["overrides"]) => {
|
||
updateVariant(index, { overrides });
|
||
},
|
||
[updateVariant]
|
||
);
|
||
|
||
const renderVariantSummary = useCallback(
|
||
(variant: VariantDefinition) => {
|
||
const condition = ensureCondition(variant, screen.id);
|
||
const optionSummaries = (condition.optionIds ?? []).map((optionId) => {
|
||
const options = optionMap[condition.screenId] ?? [];
|
||
const option = options.find((item) => item.id === optionId);
|
||
return option?.label ?? optionId;
|
||
});
|
||
|
||
const listScreenTitle = listScreens.find((candidate) => candidate.id === condition.screenId)?.title.text;
|
||
const operatorLabel = (() => {
|
||
switch (condition.operator) {
|
||
case "includesAll":
|
||
return "все из";
|
||
case "includesExactly":
|
||
return "точное совпадение";
|
||
default:
|
||
return "любой из";
|
||
}
|
||
})();
|
||
|
||
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
|
||
|
||
return (
|
||
<div className="space-y-1 text-xs text-muted-foreground">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-semibold text-foreground">Экран условий:</span>
|
||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||
{listScreenTitle ?? condition.screenId}
|
||
</span>
|
||
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground/80">{operatorLabel}</span>
|
||
</div>
|
||
{optionSummaries.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1">
|
||
{optionSummaries.map((label) => (
|
||
<span key={label} className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
|
||
{label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-muted-foreground/80">Пока нет выбранных ответов</div>
|
||
)}
|
||
<div className="flex flex-wrap gap-1">
|
||
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((item) => (
|
||
<span key={item} className="rounded-md bg-muted px-2 py-0.5 text-[11px]">
|
||
{item === "Без изменений" ? item : formatOverridePath(item)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
},
|
||
[listScreens, optionMap, screen.id]
|
||
);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<p className="text-xs text-muted-foreground">
|
||
Настройте альтернативные варианты контента без изменения переходов.
|
||
</p>
|
||
<Button size="sm" onClick={addVariant} disabled={listScreens.length === 0}>
|
||
Добавить вариант
|
||
</Button>
|
||
</div>
|
||
|
||
{listScreens.length === 0 ? (
|
||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||
Добавьте экран со списком, чтобы настроить вариативность.
|
||
</div>
|
||
) : variants.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-4 text-center text-xs text-muted-foreground">
|
||
Пока нет дополнительных вариантов.
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-4">
|
||
{variants.map((variant, index) => {
|
||
const condition = ensureCondition(variant, screen.id);
|
||
const isExpanded = expandedVariant === index;
|
||
const availableOptions = optionMap[condition.screenId] ?? [];
|
||
|
||
return (
|
||
<div key={index} className="space-y-3 rounded-xl border border-border/70 bg-background/80 p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
Вариант {index + 1}
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">{renderVariantSummary(variant)}</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setExpandedVariant(isExpanded ? null : index)}
|
||
>
|
||
{isExpanded ? "Свернуть" : "Редактировать"}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => removeVariant(index)}>
|
||
Удалить
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{isExpanded && (
|
||
<div className="space-y-4 border-t border-border/60 pt-4">
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<label className="flex flex-col gap-1 text-sm">
|
||
<span className="text-xs font-semibold uppercase text-muted-foreground">Экран условий</span>
|
||
<select
|
||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||
value={condition.screenId}
|
||
onChange={(event) => handleScreenChange(index, event.target.value)}
|
||
>
|
||
{listScreens.map((candidate) => (
|
||
<option key={candidate.id} value={candidate.id}>
|
||
{candidate.title.text}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-col gap-1 text-sm">
|
||
<span className="text-xs font-semibold uppercase text-muted-foreground">Оператор</span>
|
||
<select
|
||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||
value={condition.operator ?? "includesAny"}
|
||
onChange={(event) =>
|
||
handleOperatorChange(index, event.target.value as VariantCondition["operator"])
|
||
}
|
||
>
|
||
<option value="includesAny">любой из</option>
|
||
<option value="includesAll">все из</option>
|
||
<option value="includesExactly">точное совпадение</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<span className="text-xs font-semibold uppercase text-muted-foreground">Ответы</span>
|
||
{availableOptions.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||
В выбранном экране пока нет вариантов ответа.
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-2 md:grid-cols-2">
|
||
{availableOptions.map((option) => {
|
||
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={() => toggleOption(index, option.id)}
|
||
/>
|
||
<span>
|
||
{option.label}
|
||
<span className="text-muted-foreground"> ({option.id})</span>
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<span className="text-xs font-semibold uppercase text-muted-foreground">Настройка контента</span>
|
||
<VariantOverridesEditor
|
||
baseScreen={screen}
|
||
overrides={variant.overrides ?? {}}
|
||
onChange={(overrides) => handleOverridesChange(index, overrides)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|