w-funnel/src/components/admin/builder/ScreenVariantsConfig.tsx
2025-09-26 21:55:53 +02:00

419 lines
15 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 { 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>
);
}