Merge pull request #11 from WIT-LAB-LLC/codex/add-configurable-variability-in-admin-panel
Add builder support for screen content variants
This commit is contained in:
commit
d89b5b002c
@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
|
||||
import type {
|
||||
ListOptionDefinition,
|
||||
NavigationConditionDefinition,
|
||||
@ -225,6 +226,100 @@ function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
|
||||
}
|
||||
}
|
||||
|
||||
function VariantSummary({
|
||||
screen,
|
||||
screenTitleMap,
|
||||
listOptionsMap,
|
||||
}: {
|
||||
screen: ScreenDefinition;
|
||||
screenTitleMap: Record<string, string>;
|
||||
listOptionsMap: Record<string, ListOptionDefinition[]>;
|
||||
}) {
|
||||
const variants = (screen as ScreenDefinition & { variants?: ScreenDefinition["variants"] }).variants;
|
||||
|
||||
if (!variants || variants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Варианты</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
<span className="text-[11px] uppercase text-muted-foreground/70">{variants.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{variants.map((variant, index) => {
|
||||
const [condition] = variant.conditions ?? [];
|
||||
const controllingScreenId = condition?.screenId;
|
||||
const controllingScreenTitle = controllingScreenId
|
||||
? screenTitleMap[controllingScreenId] ?? controllingScreenId
|
||||
: "Не выбрано";
|
||||
|
||||
const options = controllingScreenId ? listOptionsMap[controllingScreenId] ?? [] : [];
|
||||
const optionSummaries = (condition?.optionIds ?? []).map((optionId) => ({
|
||||
id: optionId,
|
||||
label: getOptionLabel(options, optionId),
|
||||
}));
|
||||
|
||||
const operatorKey = condition?.operator as
|
||||
| Exclude<NavigationConditionDefinition["operator"], undefined>
|
||||
| undefined;
|
||||
const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny";
|
||||
|
||||
const overrideHighlights = listOverridePaths(variant.overrides ?? {});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${index}-${controllingScreenId ?? "none"}`}
|
||||
className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Вариант {index + 1}</span>
|
||||
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||
{operatorLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-primary/90">
|
||||
<div>
|
||||
<span className="font-semibold">Экран:</span> {controllingScreenTitle}
|
||||
</div>
|
||||
{optionSummaries.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{optionSummaries.map((option) => (
|
||||
<span key={option.id} className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium">
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-primary/70">Нет выбранных ответов</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-primary/90">
|
||||
<span className="font-semibold">Изменяет:</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => (
|
||||
<span
|
||||
key={highlight}
|
||||
className="rounded-lg bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||
>
|
||||
{highlight === "Без изменений" ? highlight : formatOverridePath(highlight)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
|
||||
const option = options.find((item) => item.id === optionId);
|
||||
return option ? option.label : optionId;
|
||||
@ -330,6 +425,15 @@ export function BuilderCanvas() {
|
||||
}, {});
|
||||
}, [screens]);
|
||||
|
||||
const listOptionsMap = useMemo(() => {
|
||||
return screens.reduce<Record<string, ListOptionDefinition[]>>((accumulator, screen) => {
|
||||
if (screen.template === "list") {
|
||||
accumulator[screen.id] = screen.list.options;
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [screens]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-border/60 bg-background/95 px-6 py-4 backdrop-blur-sm">
|
||||
@ -415,6 +519,12 @@ export function BuilderCanvas() {
|
||||
<div className="mt-4 space-y-5">
|
||||
<TemplateSummary screen={screen} />
|
||||
|
||||
<VariantSummary
|
||||
screen={screen}
|
||||
screenTitleMap={screenTitleMap}
|
||||
listOptionsMap={listOptionsMap}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
|
||||
|
||||
@ -9,16 +9,19 @@ import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
|
||||
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
||||
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context";
|
||||
import type { ListScreenDefinition, InfoScreenDefinition, DateScreenDefinition, FormScreenDefinition, CouponScreenDefinition } from "@/lib/funnel/types";
|
||||
import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants";
|
||||
|
||||
export function BuilderPreview() {
|
||||
const selectedScreen = useBuilderSelectedScreen();
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedScreen) {
|
||||
setSelectedIds([]);
|
||||
setFormData({});
|
||||
setPreviewVariantIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -44,8 +47,31 @@ export function BuilderPreview() {
|
||||
}, []);
|
||||
|
||||
|
||||
const variants = useMemo(() => selectedScreen?.variants ?? [], [selectedScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewVariantIndex(null);
|
||||
}, [selectedScreen]);
|
||||
|
||||
const previewScreen = useMemo(() => {
|
||||
if (!selectedScreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (previewVariantIndex === null) {
|
||||
return selectedScreen;
|
||||
}
|
||||
|
||||
const variant = variants[previewVariantIndex];
|
||||
if (!variant) {
|
||||
return selectedScreen;
|
||||
}
|
||||
|
||||
return mergeScreenWithOverrides(selectedScreen, variant.overrides ?? {});
|
||||
}, [previewVariantIndex, selectedScreen, variants]);
|
||||
|
||||
const renderScreenPreview = useCallback(() => {
|
||||
if (!selectedScreen) return null;
|
||||
if (!previewScreen) return null;
|
||||
|
||||
const commonProps = {
|
||||
showGradient: false,
|
||||
@ -54,12 +80,12 @@ export function BuilderPreview() {
|
||||
onContinue: () => {}, // Mock continue handler for preview
|
||||
};
|
||||
|
||||
switch (selectedScreen.template) {
|
||||
switch (previewScreen.template) {
|
||||
case "list":
|
||||
return (
|
||||
<ListTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as ListScreenDefinition}
|
||||
screen={previewScreen as ListScreenDefinition}
|
||||
selectedOptionIds={selectedIds}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
@ -69,7 +95,7 @@ export function BuilderPreview() {
|
||||
return (
|
||||
<InfoTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as InfoScreenDefinition}
|
||||
screen={previewScreen as InfoScreenDefinition}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -77,7 +103,7 @@ export function BuilderPreview() {
|
||||
return (
|
||||
<DateTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as DateScreenDefinition}
|
||||
screen={previewScreen as DateScreenDefinition}
|
||||
selectedDate={{ month: "", day: "", year: "" }}
|
||||
onDateChange={() => {}}
|
||||
/>
|
||||
@ -87,7 +113,7 @@ export function BuilderPreview() {
|
||||
return (
|
||||
<FormTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as FormScreenDefinition}
|
||||
screen={previewScreen as FormScreenDefinition}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFormChange}
|
||||
/>
|
||||
@ -98,7 +124,7 @@ export function BuilderPreview() {
|
||||
return (
|
||||
<CouponTemplate
|
||||
{...commonProps}
|
||||
screen={selectedScreen as CouponScreenDefinition}
|
||||
screen={previewScreen as CouponScreenDefinition}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -109,10 +135,10 @@ export function BuilderPreview() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [selectedScreen, selectedIds, formData, handleSelectionChange, handleFormChange]);
|
||||
}, [previewScreen, selectedIds, formData, handleSelectionChange, handleFormChange]);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (!selectedScreen) {
|
||||
if (!previewScreen) {
|
||||
return (
|
||||
<div className="flex items-center justify-center mx-auto" style={{ height: '600px', width: '320px' }}>
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground w-full h-full">
|
||||
@ -127,12 +153,36 @@ export function BuilderPreview() {
|
||||
const PREVIEW_HEIGHT = Math.round(PREVIEW_WIDTH * 2.17); // ~694px
|
||||
|
||||
return (
|
||||
<div className="mx-auto" style={{ width: PREVIEW_WIDTH }}>
|
||||
<div className="mx-auto space-y-4" style={{ width: PREVIEW_WIDTH }}>
|
||||
{variants.length > 0 && (
|
||||
<div className="rounded-lg border border-border/60 bg-background/90 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold uppercase tracking-wide text-muted-foreground/80">
|
||||
Вариант предпросмотра
|
||||
</span>
|
||||
<select
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-xs"
|
||||
value={previewVariantIndex === null ? "base" : String(previewVariantIndex)}
|
||||
onChange={(event) =>
|
||||
setPreviewVariantIndex(event.target.value === "base" ? null : Number(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="base">Основной экран</option>
|
||||
{variants.map((variant, index) => (
|
||||
<option key={index} value={index}>
|
||||
Вариант {index + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Frame - Simple Border */}
|
||||
<div
|
||||
<div
|
||||
className="relative bg-white rounded-2xl border-4 border-gray-300 shadow-lg overflow-hidden"
|
||||
style={{
|
||||
height: PREVIEW_HEIGHT,
|
||||
style={{
|
||||
height: PREVIEW_HEIGHT,
|
||||
width: PREVIEW_WIDTH
|
||||
}}
|
||||
>
|
||||
@ -146,7 +196,7 @@ export function BuilderPreview() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [renderScreenPreview, selectedScreen]);
|
||||
}, [previewScreen, renderScreenPreview, variants, previewVariantIndex]);
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
@ -5,9 +5,14 @@ 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 { 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 } from "@/lib/funnel/types";
|
||||
import type {
|
||||
NavigationRuleDefinition,
|
||||
ScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { validateBuilderState } from "@/lib/admin/builder/validation";
|
||||
|
||||
@ -280,6 +285,21 @@ export function BuilderSidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
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 (
|
||||
@ -406,6 +426,14 @@ export function BuilderSidebar() {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Вариативность" description="Переопределения контента по условиям">
|
||||
<ScreenVariantsConfig
|
||||
screen={selectedScreen}
|
||||
allScreens={state.screens}
|
||||
onChange={(variants) => handleVariantsChange(selectedScreen.id, variants)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Навигация" description="Переходы между экранами">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
|
||||
|
||||
418
src/components/admin/builder/ScreenVariantsConfig.tsx
Normal file
418
src/components/admin/builder/ScreenVariantsConfig.tsx
Normal file
@ -0,0 +1,418 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -188,23 +188,39 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat
|
||||
...state,
|
||||
screens: state.screens.map((current) =>
|
||||
current.id === screenId
|
||||
? ({
|
||||
...current,
|
||||
...screen,
|
||||
title: screen.title ? { ...current.title, ...screen.title } : current.title,
|
||||
...(('subtitle' in screen && screen.subtitle !== undefined) ? {
|
||||
subtitle: screen.subtitle
|
||||
} : ('subtitle' in current) ? {
|
||||
subtitle: current.subtitle
|
||||
} : {}),
|
||||
...(current.template === "list" && 'list' in screen && screen.list ? {
|
||||
list: {
|
||||
...(current as ListScreenDefinition & { position: BuilderScreenPosition }).list,
|
||||
...screen.list,
|
||||
options: screen.list.options ?? (current as ListScreenDefinition & { position: BuilderScreenPosition }).list.options,
|
||||
? (() => {
|
||||
const nextScreen = {
|
||||
...current,
|
||||
...screen,
|
||||
title: screen.title ? { ...current.title, ...screen.title } : current.title,
|
||||
...(("subtitle" in screen && screen.subtitle !== undefined)
|
||||
? { subtitle: screen.subtitle }
|
||||
: "subtitle" in current
|
||||
? { subtitle: current.subtitle }
|
||||
: {}),
|
||||
...(current.template === "list" && "list" in screen && screen.list
|
||||
? {
|
||||
list: {
|
||||
...(current as ListScreenDefinition & { position: BuilderScreenPosition }).list,
|
||||
...screen.list,
|
||||
options:
|
||||
screen.list.options ??
|
||||
(current as ListScreenDefinition & { position: BuilderScreenPosition }).list.options,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as BuilderScreen;
|
||||
|
||||
if ("variants" in screen) {
|
||||
if (Array.isArray(screen.variants) && screen.variants.length > 0) {
|
||||
nextScreen.variants = screen.variants;
|
||||
} else if ("variants" in nextScreen) {
|
||||
delete (nextScreen as Partial<BuilderScreen>).variants;
|
||||
}
|
||||
} : {}),
|
||||
} as BuilderScreen)
|
||||
}
|
||||
|
||||
return nextScreen;
|
||||
})()
|
||||
: current
|
||||
),
|
||||
});
|
||||
|
||||
@ -1,6 +1,27 @@
|
||||
import type { BuilderState } from "@/lib/admin/builder/context";
|
||||
import type { BuilderScreen, BuilderFunnelState, BuilderScreenPosition } from "@/lib/admin/builder/types";
|
||||
import type { FunnelDefinition, ScreenDefinition, ListScreenDefinition } from "@/lib/funnel/types";
|
||||
import type {
|
||||
FunnelDefinition,
|
||||
ScreenDefinition,
|
||||
ListScreenDefinition,
|
||||
ScreenVariantDefinition,
|
||||
} from "@/lib/funnel/types";
|
||||
|
||||
function deepCloneValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => deepCloneValue(item)) as unknown as T;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
|
||||
key,
|
||||
deepCloneValue(entryValue),
|
||||
]);
|
||||
return Object.fromEntries(entries) as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function withPositions(screens: ScreenDefinition[]): BuilderScreen[] {
|
||||
return screens.map((screen, index) => ({
|
||||
@ -47,6 +68,20 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderSc
|
||||
options: (screen as ListScreenDefinition & { position: BuilderScreenPosition }).list.options.map((option) => ({ ...option })),
|
||||
}
|
||||
} : {}),
|
||||
...(Array.isArray(screen.variants)
|
||||
? {
|
||||
variants: screen.variants.map((variant) => ({
|
||||
conditions: variant.conditions.map((condition) => ({
|
||||
screenId: condition.screenId,
|
||||
operator: condition.operator,
|
||||
optionIds: [...condition.optionIds],
|
||||
})),
|
||||
...(variant.overrides
|
||||
? { overrides: deepCloneValue(variant.overrides) as ScreenVariantDefinition<ScreenDefinition>["overrides"] }
|
||||
: {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
navigation: screen.navigation
|
||||
? {
|
||||
defaultNextScreenId: screen.navigation.defaultNextScreenId,
|
||||
|
||||
191
src/lib/admin/builder/variants.ts
Normal file
191
src/lib/admin/builder/variants.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import type { ScreenDefinition, ScreenVariantDefinition } from "@/lib/funnel/types";
|
||||
|
||||
const EXCLUDED_KEYS = new Set(["id", "template", "variants", "position"]);
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
function isPlainObject(value: unknown): value is AnyRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => deepClone(item)) as unknown as T;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
const clonedEntries = Object.entries(value).map(([key, entry]) => [key, deepClone(entry)]);
|
||||
return Object.fromEntries(clonedEntries) as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function deepMerge<T>(base: T, patch?: Partial<T>): T {
|
||||
const result = deepClone(base);
|
||||
|
||||
if (!patch) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Object.entries(patch as AnyRecord).forEach(([key, patchValue]) => {
|
||||
if (patchValue === undefined) {
|
||||
(result as AnyRecord)[key] = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = (result as AnyRecord)[key];
|
||||
|
||||
if (isPlainObject(currentValue) && isPlainObject(patchValue)) {
|
||||
(result as AnyRecord)[key] = deepMerge(currentValue, patchValue);
|
||||
return;
|
||||
}
|
||||
|
||||
(result as AnyRecord)[key] = deepClone(patchValue);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a.every((item, index) => deepEqual(item, b[index]));
|
||||
}
|
||||
|
||||
if (isPlainObject(a) && isPlainObject(b)) {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
|
||||
for (const key of keys) {
|
||||
if (!deepEqual(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function diff(base: unknown, target: unknown): unknown {
|
||||
if (deepEqual(base, target)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(target) || Array.isArray(base)) {
|
||||
return deepClone(target);
|
||||
}
|
||||
|
||||
if (isPlainObject(target) && isPlainObject(base)) {
|
||||
const entries: [string, unknown][] = [];
|
||||
const keys = new Set([...Object.keys(target), ...Object.keys(base)]);
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (EXCLUDED_KEYS.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseValue = (base as AnyRecord)[key];
|
||||
const targetValue = (target as AnyRecord)[key];
|
||||
|
||||
const nestedDiff = diff(baseValue, targetValue);
|
||||
|
||||
if (nestedDiff !== undefined) {
|
||||
entries.push([key, nestedDiff]);
|
||||
}
|
||||
});
|
||||
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
return deepClone(target);
|
||||
}
|
||||
|
||||
export function mergeScreenWithOverrides<T extends ScreenDefinition>(
|
||||
base: T,
|
||||
overrides?: ScreenVariantDefinition<T>["overrides"]
|
||||
): T {
|
||||
return deepMerge(base, overrides as Partial<T> | undefined);
|
||||
}
|
||||
|
||||
export function applyScreenUpdates<T extends ScreenDefinition>(screen: T, updates: Partial<T>): T {
|
||||
return deepMerge(screen, updates);
|
||||
}
|
||||
|
||||
export function extractVariantOverrides<T extends ScreenDefinition>(
|
||||
base: T,
|
||||
target: T
|
||||
): ScreenVariantDefinition<T>["overrides"] {
|
||||
return diff(base, target) as ScreenVariantDefinition<T>["overrides"];
|
||||
}
|
||||
|
||||
export function listOverridePaths(value: unknown, prefix = ""): string[] {
|
||||
if (!value || typeof value !== "object") {
|
||||
return prefix ? [prefix.slice(0, -1)] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return [prefix ? `${prefix.slice(0, -1)}[]` : "[]"];
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
Object.entries(value).forEach(([key, entry]) => {
|
||||
const nextPrefix = `${prefix}${key}.`;
|
||||
if (!entry || typeof entry !== "object") {
|
||||
result.push(prefix ? `${prefix.slice(0, -1)} · ${key}` : key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(entry)) {
|
||||
result.push(prefix ? `${prefix.slice(0, -1)} · ${key}[]` : `${key}[]`);
|
||||
return;
|
||||
}
|
||||
|
||||
const nested = listOverridePaths(entry, nextPrefix);
|
||||
if (nested.length === 0) {
|
||||
result.push(prefix ? `${prefix.slice(0, -1)} · ${key}` : key);
|
||||
return;
|
||||
}
|
||||
|
||||
result.push(...nested);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatOverridePath(path: string): string {
|
||||
if (!path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const [head, ...tail] = path.split(" · ");
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
title: "Заголовок",
|
||||
subtitle: "Подзаголовок",
|
||||
bottomActionButton: "Нижняя кнопка",
|
||||
list: "Список",
|
||||
header: "Хедер",
|
||||
dateInput: "Поле даты",
|
||||
coupon: "Купон",
|
||||
description: "Описание",
|
||||
infoMessage: "Инфо сообщение",
|
||||
};
|
||||
|
||||
const formattedHead = labelMap[head] ?? head;
|
||||
|
||||
if (tail.length === 0) {
|
||||
return formattedHead;
|
||||
}
|
||||
|
||||
return `${formattedHead} · ${tail.join(" · ")}`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user