From e6ba8795754902eeb124bb87b84cd9d1732fb07e Mon Sep 17 00:00:00 2001 From: pennyteenycat Date: Fri, 26 Sep 2025 21:55:53 +0200 Subject: [PATCH] feat: support screen variants in funnel builder --- .../admin/builder/BuilderCanvas.tsx | 110 +++++ .../admin/builder/BuilderPreview.tsx | 78 +++- .../admin/builder/BuilderSidebar.tsx | 30 +- .../admin/builder/ScreenVariantsConfig.tsx | 418 ++++++++++++++++++ src/lib/admin/builder/context.tsx | 48 +- src/lib/admin/builder/utils.ts | 37 +- src/lib/admin/builder/variants.ts | 191 ++++++++ 7 files changed, 880 insertions(+), 32 deletions(-) create mode 100644 src/components/admin/builder/ScreenVariantsConfig.tsx create mode 100644 src/lib/admin/builder/variants.ts diff --git a/src/components/admin/builder/BuilderCanvas.tsx b/src/components/admin/builder/BuilderCanvas.tsx index 97b2ea3..8b7eb33 100644 --- a/src/components/admin/builder/BuilderCanvas.tsx +++ b/src/components/admin/builder/BuilderCanvas.tsx @@ -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; + listOptionsMap: Record; +}) { + const variants = (screen as ScreenDefinition & { variants?: ScreenDefinition["variants"] }).variants; + + if (!variants || variants.length === 0) { + return null; + } + + return ( +
+
+ Варианты +
+ {variants.length} +
+ +
+ {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 + | undefined; + const operatorLabel = operatorKey ? OPERATOR_LABELS[operatorKey] ?? operatorKey : "includesAny"; + + const overrideHighlights = listOverridePaths(variant.overrides ?? {}); + + return ( +
+
+ Вариант {index + 1} + + {operatorLabel} + +
+ +
+
+ Экран: {controllingScreenTitle} +
+ {optionSummaries.length > 0 ? ( +
+ {optionSummaries.map((option) => ( + + {option.label} + + ))} +
+ ) : ( +
Нет выбранных ответов
+ )} +
+ +
+ Изменяет: +
+ {(overrideHighlights.length > 0 ? overrideHighlights : ["Без изменений"]).map((highlight) => ( + + {highlight === "Без изменений" ? highlight : formatOverridePath(highlight)} + + ))} +
+
+
+ ); + })} +
+
+ ); +} + 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>((accumulator, screen) => { + if (screen.template === "list") { + accumulator[screen.id] = screen.list.options; + } + return accumulator; + }, {}); + }, [screens]); + return (
@@ -415,6 +519,12 @@ export function BuilderCanvas() {
+ +
Переходы diff --git a/src/components/admin/builder/BuilderPreview.tsx b/src/components/admin/builder/BuilderPreview.tsx index bb720f2..7eb7101 100644 --- a/src/components/admin/builder/BuilderPreview.tsx +++ b/src/components/admin/builder/BuilderPreview.tsx @@ -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([]); const [formData, setFormData] = useState>({}); + const [previewVariantIndex, setPreviewVariantIndex] = useState(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 ( @@ -69,7 +95,7 @@ export function BuilderPreview() { return ( ); @@ -77,7 +103,7 @@ export function BuilderPreview() { return ( {}} /> @@ -87,7 +113,7 @@ export function BuilderPreview() { return ( @@ -98,7 +124,7 @@ export function BuilderPreview() { return ( ); @@ -109,10 +135,10 @@ export function BuilderPreview() {
); } - }, [selectedScreen, selectedIds, formData, handleSelectionChange, handleFormChange]); + }, [previewScreen, selectedIds, formData, handleSelectionChange, handleFormChange]); const preview = useMemo(() => { - if (!selectedScreen) { + if (!previewScreen) { return (
@@ -127,12 +153,36 @@ export function BuilderPreview() { const PREVIEW_HEIGHT = Math.round(PREVIEW_WIDTH * 2.17); // ~694px return ( -
+
+ {variants.length > 0 && ( +
+
+ + Вариант предпросмотра + + +
+
+ )} + {/* Mobile Frame - Simple Border */} -
@@ -146,7 +196,7 @@ export function BuilderPreview() {
); - }, [renderScreenPreview, selectedScreen]); + }, [previewScreen, renderScreenPreview, variants, previewVariantIndex]); return preview; } diff --git a/src/components/admin/builder/BuilderSidebar.tsx b/src/components/admin/builder/BuilderSidebar.tsx index aac80e6..4d14765 100644 --- a/src/components/admin/builder/BuilderSidebar.tsx +++ b/src/components/admin/builder/BuilderSidebar.tsx @@ -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[] + ) => { + dispatch({ + type: "update-screen", + payload: { + screenId, + screen: { + variants: variants.length > 0 ? variants : undefined, + } as Partial, + }, + }); + }; + const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false; return ( @@ -406,6 +426,14 @@ export function BuilderSidebar() { /> +
+ handleVariantsChange(selectedScreen.id, variants)} + /> +
+