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:
pennyteenycat 2025-09-26 21:57:10 +02:00 committed by GitHub
commit d89b5b002c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 880 additions and 32 deletions

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View 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>
);
}

View File

@ -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
),
});

View File

@ -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,

View 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(" · ")}`;
}