feat: support screen variants in funnel builder
This commit is contained in:
parent
0b10a09497
commit
e6ba879575
@ -4,6 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
|
|||||||
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
|
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||||
|
import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants";
|
||||||
import type {
|
import type {
|
||||||
ListOptionDefinition,
|
ListOptionDefinition,
|
||||||
NavigationConditionDefinition,
|
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 {
|
function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
|
||||||
const option = options.find((item) => item.id === optionId);
|
const option = options.find((item) => item.id === optionId);
|
||||||
return option ? option.label : optionId;
|
return option ? option.label : optionId;
|
||||||
@ -330,6 +425,15 @@ export function BuilderCanvas() {
|
|||||||
}, {});
|
}, {});
|
||||||
}, [screens]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<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">
|
<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">
|
<div className="mt-4 space-y-5">
|
||||||
<TemplateSummary screen={screen} />
|
<TemplateSummary screen={screen} />
|
||||||
|
|
||||||
|
<VariantSummary
|
||||||
|
screen={screen}
|
||||||
|
screenTitleMap={screenTitleMap}
|
||||||
|
listOptionsMap={listOptionsMap}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
|
<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 { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
||||||
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context";
|
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context";
|
||||||
import type { ListScreenDefinition, InfoScreenDefinition, DateScreenDefinition, FormScreenDefinition, CouponScreenDefinition } from "@/lib/funnel/types";
|
import type { ListScreenDefinition, InfoScreenDefinition, DateScreenDefinition, FormScreenDefinition, CouponScreenDefinition } from "@/lib/funnel/types";
|
||||||
|
import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants";
|
||||||
|
|
||||||
export function BuilderPreview() {
|
export function BuilderPreview() {
|
||||||
const selectedScreen = useBuilderSelectedScreen();
|
const selectedScreen = useBuilderSelectedScreen();
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
|
const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedScreen) {
|
if (!selectedScreen) {
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setFormData({});
|
setFormData({});
|
||||||
|
setPreviewVariantIndex(null);
|
||||||
return;
|
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(() => {
|
const renderScreenPreview = useCallback(() => {
|
||||||
if (!selectedScreen) return null;
|
if (!previewScreen) return null;
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
showGradient: false,
|
showGradient: false,
|
||||||
@ -54,12 +80,12 @@ export function BuilderPreview() {
|
|||||||
onContinue: () => {}, // Mock continue handler for preview
|
onContinue: () => {}, // Mock continue handler for preview
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (selectedScreen.template) {
|
switch (previewScreen.template) {
|
||||||
case "list":
|
case "list":
|
||||||
return (
|
return (
|
||||||
<ListTemplate
|
<ListTemplate
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
screen={selectedScreen as ListScreenDefinition}
|
screen={previewScreen as ListScreenDefinition}
|
||||||
selectedOptionIds={selectedIds}
|
selectedOptionIds={selectedIds}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
/>
|
/>
|
||||||
@ -69,7 +95,7 @@ export function BuilderPreview() {
|
|||||||
return (
|
return (
|
||||||
<InfoTemplate
|
<InfoTemplate
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
screen={selectedScreen as InfoScreenDefinition}
|
screen={previewScreen as InfoScreenDefinition}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -77,7 +103,7 @@ export function BuilderPreview() {
|
|||||||
return (
|
return (
|
||||||
<DateTemplate
|
<DateTemplate
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
screen={selectedScreen as DateScreenDefinition}
|
screen={previewScreen as DateScreenDefinition}
|
||||||
selectedDate={{ month: "", day: "", year: "" }}
|
selectedDate={{ month: "", day: "", year: "" }}
|
||||||
onDateChange={() => {}}
|
onDateChange={() => {}}
|
||||||
/>
|
/>
|
||||||
@ -87,7 +113,7 @@ export function BuilderPreview() {
|
|||||||
return (
|
return (
|
||||||
<FormTemplate
|
<FormTemplate
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
screen={selectedScreen as FormScreenDefinition}
|
screen={previewScreen as FormScreenDefinition}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={handleFormChange}
|
onFormDataChange={handleFormChange}
|
||||||
/>
|
/>
|
||||||
@ -98,7 +124,7 @@ export function BuilderPreview() {
|
|||||||
return (
|
return (
|
||||||
<CouponTemplate
|
<CouponTemplate
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
screen={selectedScreen as CouponScreenDefinition}
|
screen={previewScreen as CouponScreenDefinition}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -109,10 +135,10 @@ export function BuilderPreview() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [selectedScreen, selectedIds, formData, handleSelectionChange, handleFormChange]);
|
}, [previewScreen, selectedIds, formData, handleSelectionChange, handleFormChange]);
|
||||||
|
|
||||||
const preview = useMemo(() => {
|
const preview = useMemo(() => {
|
||||||
if (!selectedScreen) {
|
if (!previewScreen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center mx-auto" style={{ height: '600px', width: '320px' }}>
|
<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">
|
<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
|
const PREVIEW_HEIGHT = Math.round(PREVIEW_WIDTH * 2.17); // ~694px
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Mobile Frame - Simple Border */}
|
||||||
<div
|
<div
|
||||||
className="relative bg-white rounded-2xl border-4 border-gray-300 shadow-lg overflow-hidden"
|
className="relative bg-white rounded-2xl border-4 border-gray-300 shadow-lg overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
height: PREVIEW_HEIGHT,
|
height: PREVIEW_HEIGHT,
|
||||||
width: PREVIEW_WIDTH
|
width: PREVIEW_WIDTH
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -146,7 +196,7 @@ export function BuilderPreview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [renderScreenPreview, selectedScreen]);
|
}, [previewScreen, renderScreenPreview, variants, previewVariantIndex]);
|
||||||
|
|
||||||
return preview;
|
return preview;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,14 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TemplateConfig } from "@/components/admin/builder/templates";
|
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||||||
|
import { ScreenVariantsConfig } from "@/components/admin/builder/ScreenVariantsConfig";
|
||||||
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
|
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
|
||||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
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 { cn } from "@/lib/utils";
|
||||||
import { validateBuilderState } from "@/lib/admin/builder/validation";
|
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;
|
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -406,6 +426,14 @@ export function BuilderSidebar() {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Вариативность" description="Переопределения контента по условиям">
|
||||||
|
<ScreenVariantsConfig
|
||||||
|
screen={selectedScreen}
|
||||||
|
allScreens={state.screens}
|
||||||
|
onChange={(variants) => handleVariantsChange(selectedScreen.id, variants)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="Навигация" description="Переходы между экранами">
|
<Section title="Навигация" description="Переходы между экранами">
|
||||||
<label className="flex flex-col gap-2">
|
<label className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
|
<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,
|
...state,
|
||||||
screens: state.screens.map((current) =>
|
screens: state.screens.map((current) =>
|
||||||
current.id === screenId
|
current.id === screenId
|
||||||
? ({
|
? (() => {
|
||||||
...current,
|
const nextScreen = {
|
||||||
...screen,
|
...current,
|
||||||
title: screen.title ? { ...current.title, ...screen.title } : current.title,
|
...screen,
|
||||||
...(('subtitle' in screen && screen.subtitle !== undefined) ? {
|
title: screen.title ? { ...current.title, ...screen.title } : current.title,
|
||||||
subtitle: screen.subtitle
|
...(("subtitle" in screen && screen.subtitle !== undefined)
|
||||||
} : ('subtitle' in current) ? {
|
? { subtitle: screen.subtitle }
|
||||||
subtitle: current.subtitle
|
: "subtitle" in current
|
||||||
} : {}),
|
? { subtitle: current.subtitle }
|
||||||
...(current.template === "list" && 'list' in screen && screen.list ? {
|
: {}),
|
||||||
list: {
|
...(current.template === "list" && "list" in screen && screen.list
|
||||||
...(current as ListScreenDefinition & { position: BuilderScreenPosition }).list,
|
? {
|
||||||
...screen.list,
|
list: {
|
||||||
options: screen.list.options ?? (current as ListScreenDefinition & { position: BuilderScreenPosition }).list.options,
|
...(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
|
: current
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,27 @@
|
|||||||
import type { BuilderState } from "@/lib/admin/builder/context";
|
import type { BuilderState } from "@/lib/admin/builder/context";
|
||||||
import type { BuilderScreen, BuilderFunnelState, BuilderScreenPosition } from "@/lib/admin/builder/types";
|
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[] {
|
function withPositions(screens: ScreenDefinition[]): BuilderScreen[] {
|
||||||
return screens.map((screen, index) => ({
|
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 })),
|
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
|
navigation: screen.navigation
|
||||||
? {
|
? {
|
||||||
defaultNextScreenId: screen.navigation.defaultNextScreenId,
|
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