Merge pull request #7 from WIT-LAB-LLC/codex/complete-admin-panel-for-funnels

Refine funnel builder sidebar and template editors
This commit is contained in:
pennyteenycat 2025-09-26 12:46:58 +02:00 committed by GitHub
commit a15dff0b84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1743 additions and 1324 deletions

View File

@ -1,56 +1,212 @@
"use client";
import React, { useCallback, useRef } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/types";
import type { ListOptionDefinition, ScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
const CARD_WIDTH = 280;
const CARD_HEIGHT = 200;
const CARD_GAP = 24;
function DropIndicator({ isActive }: { isActive: boolean }) {
return (
<div
className={cn(
"mx-4 h-9 rounded-xl border-2 border-dashed border-primary/50 bg-primary/10 transition-all",
isActive ? "opacity-100" : "pointer-events-none opacity-0"
)}
/>
);
}
function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
switch (screen.template) {
case "list": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
</span>
{screen.list.autoAdvance && (
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
авто переход
</span>
)}
{screen.list.bottomActionButton?.text && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
{screen.list.bottomActionButton.text}
</span>
)}
</div>
<div>
<p className="font-medium text-foreground">Варианты ({screen.list.options.length})</p>
<div className="mt-2 flex flex-wrap gap-2">
{screen.list.options.map((option) => (
<span
key={option.id}
className="inline-flex items-center gap-1 rounded-lg bg-primary/5 px-2 py-1 text-[11px] text-primary"
>
{option.emoji && <span className="text-base leading-none">{option.emoji}</span>}
<span className="font-medium">{option.label}</span>
</span>
))}
</div>
</div>
</div>
);
}
case "form": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
Полей: {screen.fields.length}
</span>
{screen.bottomActionButton?.text && (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
{screen.bottomActionButton.text}
</span>
)}
</div>
{screen.validationMessages && (
<div className="rounded-lg border border-border/50 bg-background/80 p-2">
<p className="text-[11px] text-muted-foreground">
Настроены пользовательские сообщения валидации
</p>
</div>
)}
</div>
);
}
case "coupon": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p>
<span className="font-medium">Промо:</span> {screen.coupon.promoCode.text}
</p>
<p className="text-muted-foreground/80">{screen.coupon.offer.title.text}</p>
</div>
);
}
case "date": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
<p className="font-medium">Формат даты:</p>
<div className="flex flex-wrap gap-2">
{screen.dateInput.monthLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.monthLabel}</span>}
{screen.dateInput.dayLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.dayLabel}</span>}
{screen.dateInput.yearLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.yearLabel}</span>}
</div>
{screen.dateInput.validationMessage && (
<p className="text-[11px] text-destructive">{screen.dateInput.validationMessage}</p>
)}
</div>
);
}
case "info": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
{screen.description?.text && <p>{screen.description.text}</p>}
{screen.icon?.value && (
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
<span className="text-base">{screen.icon.value}</span>
<span className="text-[11px] uppercase text-muted-foreground">Иконка</span>
</div>
)}
</div>
);
}
default:
return null;
}
}
function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
const option = options.find((item) => item.id === optionId);
return option ? option.label : optionId;
}
export function BuilderCanvas() {
const { screens, selectedScreenId } = useBuilderState();
const dispatch = useBuilderDispatch();
const containerRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number; currentIndex: number } | null>(null);
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const handleDragStart = useCallback((screenId: string, index: number) => {
dragStateRef.current = {
screenId,
dragStartIndex: index,
currentIndex: index,
};
const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", screenId);
dragStateRef.current = { screenId, dragStartIndex: index };
setDropIndex(index);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (!dragStateRef.current) return;
dragStateRef.current.currentIndex = targetIndex;
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!dragStateRef.current) return;
const { dragStartIndex, currentIndex } = dragStateRef.current;
if (dragStartIndex !== currentIndex) {
dispatch({
type: "reorder-screens",
payload: {
fromIndex: dragStartIndex,
toIndex: currentIndex,
},
});
const handleDragOverCard = useCallback((event: React.DragEvent<HTMLDivElement>, index: number) => {
event.preventDefault();
if (!dragStateRef.current) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const offsetY = event.clientY - rect.top;
const nextIndex = offsetY > rect.height / 2 ? index + 1 : index;
setDropIndex(nextIndex);
}, []);
const handleDragOverList = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
if (!dragStateRef.current) {
return;
}
event.preventDefault();
if (event.target === event.currentTarget) {
setDropIndex(screens.length);
}
},
[screens.length]
);
const finalizeDrop = useCallback(
(insertionIndex: number | null) => {
if (!dragStateRef.current) {
return;
}
const { dragStartIndex } = dragStateRef.current;
const boundedIndex = Math.max(0, Math.min(insertionIndex ?? dragStartIndex, screens.length));
let targetIndex = boundedIndex;
if (targetIndex > dragStartIndex) {
targetIndex -= 1;
}
if (dragStartIndex !== targetIndex) {
dispatch({
type: "reorder-screens",
payload: {
fromIndex: dragStartIndex,
toIndex: targetIndex,
},
});
}
dragStateRef.current = null;
setDropIndex(null);
},
[dispatch, screens.length]
);
const handleDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
finalizeDrop(dropIndex);
},
[dropIndex, finalizeDrop]
);
const handleDragEnd = useCallback(() => {
dragStateRef.current = null;
}, [dispatch]);
setDropIndex(null);
}, []);
const handleSelectScreen = useCallback(
(screenId: string) => {
@ -63,128 +219,164 @@ export function BuilderCanvas() {
dispatch({ type: "add-screen" });
}, [dispatch]);
// Helper functions for type checking
const hasSubtitle = (screen: ScreenDefinition): screen is ScreenDefinition & { subtitle: { text: string } } => {
return 'subtitle' in screen && screen.subtitle !== undefined;
};
const isListScreen = (screen: ScreenDefinition): screen is ListScreenDefinition => {
return screen.template === 'list';
};
const screenTitleMap = useMemo(() => {
return screens.reduce<Record<string, string>>((accumulator, screen) => {
accumulator[screen.id] = screen.title.text || screen.id;
return accumulator;
}, {});
}, [screens]);
return (
<div ref={containerRef} className="h-full w-full overflow-auto bg-slate-50 dark:bg-slate-900">
{/* Header with Add Button */}
<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">
<h2 className="text-lg font-semibold">Экраны воронки</h2>
<div>
<h2 className="text-lg font-semibold">Экраны воронки</h2>
<p className="text-sm text-muted-foreground">Перетаскивайте, чтобы поменять порядок и связь экранов.</p>
</div>
<Button variant="outline" onClick={handleAddScreen}>
<span className="mr-2">+</span>
<span className="mr-2 text-lg leading-none">+</span>
Добавить экран
</Button>
</div>
{/* Linear Screen Layout */}
<div className="relative p-6">
<div
className="flex items-center gap-6"
style={{ minWidth: screens.length * (CARD_WIDTH + CARD_GAP) }}
>
{screens.map((screen, index) => {
const isSelected = screen.id === selectedScreenId;
return (
<div
key={screen.id}
className="relative flex-shrink-0"
draggable
onDragStart={() => handleDragStart(screen.id, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
>
<div
className={cn(
"cursor-pointer rounded-2xl border border-border/70 bg-background p-4 shadow-sm transition-all hover:shadow-md",
isSelected
? "ring-2 ring-primary border-primary/50"
: "hover:border-primary/40",
"w-[280px] h-[200px] flex flex-col"
)}
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
onClick={() => handleSelectScreen(screen.id)}
>
{/* Screen Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
{index + 1}
</div>
<span className="text-xs font-medium uppercase text-muted-foreground">#{screen.id}</span>
</div>
<div className="text-xs text-muted-foreground px-2 py-1 rounded bg-muted/50">
{screen.template}
</div>
</div>
<div className="relative flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-900">
<div className="relative mx-auto max-w-4xl">
<div className="absolute left-6 top-0 bottom-0 hidden w-px bg-border md:block" aria-hidden />
<div
className="space-y-6 pl-0 md:pl-12"
onDragOver={handleDragOverList}
onDrop={handleDrop}
>
{screens.map((screen, index) => {
const isSelected = screen.id === selectedScreenId;
const isDropBefore = dropIndex === index;
const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
const rules = screen.navigation?.rules ?? [];
const defaultNext = screen.navigation?.defaultNextScreenId;
{/* Screen Content */}
<div className="flex-1">
<h3 className="text-base font-semibold leading-5 text-foreground mb-2">
{screen.title.text || "Без названия"}
</h3>
{hasSubtitle(screen) && (
<p className="text-xs text-muted-foreground mb-3">{screen.subtitle.text}</p>
)}
{/* List Screen Details */}
{isListScreen(screen) && (
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex items-center justify-between">
<span>Тип выбора:</span>
<span className="font-medium text-foreground">
{screen.list.selectionType === "single" ? "Single" : "Multi"}
</span>
return (
<div key={screen.id} className="relative">
<div className="absolute left-0 top-6 hidden h-[calc(100%-1.5rem)] w-px bg-border md:block" aria-hidden />
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
<div className="flex items-start gap-4 md:gap-6">
<div className="relative mt-1 hidden h-3 w-3 flex-shrink-0 rounded-full border-2 border-background bg-primary shadow md:block" />
<div
className={cn(
"flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
isSelected && "border-primary/50 ring-2 ring-primary",
dragStateRef.current?.screenId === screen.id && "cursor-grabbing opacity-90"
)}
draggable
onDragStart={(event) => handleDragStart(event, screen.id, index)}
onDragOver={(event) => handleDragOverCard(event, index)}
onDragEnd={handleDragEnd}
onClick={() => handleSelectScreen(screen.id)}
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{index + 1}
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase text-muted-foreground">#{screen.id}</span>
<span className="text-lg font-semibold text-foreground">
{screen.title.text || "Без названия"}
</span>
</div>
</div>
<div>
<span className="font-medium text-foreground">Опции: {screen.list.options.length}</span>
<div className="mt-1 flex flex-wrap gap-1">
{screen.list.options.slice(0, 2).map((option) => (
<span key={option.id} className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[10px]">
{option.label}
<span className="inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs font-medium uppercase text-muted-foreground">
{screen.template}
</span>
</div>
{screen.subtitle?.text && (
<p className="mt-3 text-sm text-muted-foreground">{screen.subtitle.text}</p>
)}
<div className="mt-4 space-y-4">
<TemplateSummary screen={screen} />
<div className="space-y-2">
<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" />
</div>
<div className="space-y-2">
<div className="flex flex-col gap-1 rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex w-fit items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
По умолчанию
</span>
))}
{screen.list.options.length > 2 && (
<span className="text-muted-foreground text-[10px]">
+{screen.list.options.length - 2} ещё
<span className="text-sm text-foreground">
{defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : "Воронка завершится"}
</span>
)}
</div>
{rules.map((rule, ruleIndex) => {
const condition = rule.conditions[0];
const optionSummaries =
screen.template === "list" && condition?.optionIds
? condition.optionIds.map((optionId) => ({
id: optionId,
label: getOptionLabel(screen.list.options, optionId),
}))
: [];
return (
<div
key={`${ruleIndex}-${rule.nextScreenId}`}
className="flex flex-col gap-2 rounded-xl border border-primary/30 bg-primary/5 p-3 text-xs text-muted-foreground"
>
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">
Вариативность
</span>
{condition?.operator && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
{condition.operator}
</span>
)}
</div>
{optionSummaries.length > 0 && (
<div className="flex flex-wrap gap-2">
{optionSummaries.map((option) => (
<span
key={option.id}
className="inline-flex items-center rounded-lg bg-background px-2 py-1 text-[11px] text-foreground"
>
{option.label}
</span>
))}
</div>
)}
<div className="text-sm text-foreground">
{screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId}
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
{/* Navigation Info */}
<div className="pt-2 border-t border-border/40">
<div className="text-xs text-muted-foreground">
<span>Следующий: </span>
<span className="font-medium text-foreground">
{screen.navigation?.defaultNextScreenId ?? "—"}
</span>
</div>
</div>
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
</div>
);
})}
{/* Arrow to next screen */}
{index < screens.length - 1 && (
<div className="absolute -right-3 top-1/2 transform -translate-y-1/2 z-10">
<div className="flex items-center">
<div className="w-6 border-t-2 border-primary/60 border-dashed"></div>
<div className="w-0 h-0 border-l-[6px] border-l-primary border-t-[4px] border-t-transparent border-b-[4px] border-b-transparent"></div>
</div>
</div>
)}
{screens.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-background/80 p-8 text-center text-sm text-muted-foreground">
Добавьте первый экран, чтобы начать строить воронку.
</div>
);
})}
)}
<div className="pt-4">
<Button variant="ghost" onClick={handleAddScreen} className="w-full justify-center">
+ Добавить экран
</Button>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,22 +1,27 @@
"use client";
import { useMemo } from "react";
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 { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
import type { NavigationRuleDefinition, ScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
import { validateBuilderState } from "@/lib/admin/builder/validation";
// Type guards для безопасной работы с разными типами экранов
function isListScreen(screen: BuilderScreen): screen is BuilderScreen & { list: { selectionType: "single" | "multi"; options: Array<{ id: string; label: string; description?: string; emoji?: string; }> } } {
return screen.template === "list" && "list" in screen;
}
type ValidationIssues = ReturnType<typeof validateBuilderState>["issues"];
function hasSubtitle(screen: BuilderScreen): screen is BuilderScreen & { subtitle?: { text: string; color?: string; font?: string; } } {
return "subtitle" in screen;
function isListScreen(
screen: BuilderScreen
): screen is BuilderScreen & {
list: {
selectionType: "single" | "multi";
options: Array<{ id: string; label: string; description?: string; emoji?: string }>;
};
} {
return screen.template === "list" && "list" in screen;
}
function Section({
@ -26,7 +31,7 @@ function Section({
}: {
title: string;
description?: string;
children: React.ReactNode;
children: ReactNode;
}) {
return (
<section className="flex flex-col gap-4">
@ -39,12 +44,8 @@ function Section({
);
}
function ValidationSummary() {
const state = useBuilderState();
const validation = useMemo(() => validateBuilderState(state), [state]);
if (validation.issues.length === 0) {
function ValidationSummary({ issues }: { issues: ValidationIssues }) {
if (issues.length === 0) {
return (
<div className="rounded-xl border border-border/50 bg-background/60 p-3 text-xs text-muted-foreground">
Всё хорошо воронка валидна.
@ -54,7 +55,7 @@ function ValidationSummary() {
return (
<div className="flex flex-col gap-3">
{validation.issues.map((issue, index) => (
{issues.map((issue, index) => (
<div
key={`${issue.severity}-${issue.screenId ?? "root"}-${issue.optionId ?? "all"}-${index}`}
className={cn(
@ -81,9 +82,31 @@ export function BuilderSidebar() {
const dispatch = useBuilderDispatch();
const selectedScreen = useBuilderSelectedScreen();
const screenOptions = useMemo(() => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), [
state.screens,
]);
const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel");
const selectedScreenId = selectedScreen?.id ?? null;
useEffect(() => {
setActiveTab((previous) => {
if (selectedScreenId) {
return "screen";
}
return previous === "screen" ? "funnel" : previous;
});
}, [selectedScreenId]);
const validation = useMemo(() => validateBuilderState(state), [state]);
const screenValidationIssues = useMemo(() => {
if (!selectedScreenId) {
return [] as ValidationIssues;
}
return validation.issues.filter((issue) => issue.screenId === selectedScreenId);
}, [selectedScreenId, validation]);
const screenOptions = useMemo(
() => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })),
[state.screens]
);
const handleMetaChange = (field: keyof typeof state.meta, value: string) => {
dispatch({ type: "set-meta", payload: { [field]: value } });
@ -96,32 +119,6 @@ export function BuilderSidebar() {
const getScreenById = (screenId: string): BuilderScreen | undefined =>
state.screens.find((item) => item.id === screenId);
const updateList = (
screen: BuilderScreen,
listUpdates: Partial<{ selectionType: "single" | "multi"; options: Array<{ id: string; label: string; description?: string; emoji?: string; }> }>
) => {
if (!isListScreen(screen)) {
return;
}
const nextList = {
...screen.list,
...listUpdates,
selectionType: listUpdates.selectionType ?? screen.list.selectionType,
options: listUpdates.options ?? screen.list.options,
};
dispatch({
type: "update-screen",
payload: {
screenId: screen.id,
screen: {
list: nextList,
},
},
});
};
const updateNavigation = (
screen: BuilderScreen,
navigationUpdates: Partial<BuilderScreen["navigation"]> = {}
@ -139,90 +136,6 @@ export function BuilderSidebar() {
});
};
const handleSelectionTypeChange = (
screenId: string,
selectionType: "single" | "multi"
) => {
const screen = getScreenById(screenId);
if (!screen || !isListScreen(screen)) {
return;
}
updateList(screen, { selectionType });
};
const handleTitleChange = (screenId: string, value: string) => {
dispatch({
type: "update-screen",
payload: {
screenId,
screen: {
title: {
text: value,
},
},
},
});
};
const handleSubtitleChange = (screenId: string, value: string) => {
dispatch({
type: "update-screen",
payload: {
screenId,
screen: {
subtitle: value
? { text: value, color: "muted", font: "inter" }
: undefined,
},
},
});
};
const handleOptionChange = (
screenId: string,
index: number,
field: "label" | "id" | "emoji" | "description",
value: string
) => {
const screen = getScreenById(screenId);
if (!screen || !isListScreen(screen)) {
return;
}
const options = screen.list.options.map((option, optionIndex) =>
optionIndex === index ? { ...option, [field]: value } : option
);
updateList(screen, { options });
};
const handleAddOption = (screen: BuilderScreen) => {
if (!isListScreen(screen)) {
return;
}
const nextIndex = screen.list.options.length + 1;
const options = [
...screen.list.options,
{
id: `option-${nextIndex}`,
label: `Вариант ${nextIndex}`,
},
];
updateList(screen, { options });
};
const handleRemoveOption = (screen: BuilderScreen, index: number) => {
if (!isListScreen(screen)) {
return;
}
const options = screen.list.options.filter((_, optionIndex) => optionIndex !== index);
updateList(screen, { options });
};
const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => {
const screen = getScreenById(screenId);
if (!screen) {
@ -332,7 +245,10 @@ export function BuilderSidebar() {
optionIds: screen.list.options.slice(0, 1).map((option) => option.id),
};
const nextRules = [...(screen.navigation?.rules ?? []), { nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] }];
const nextRules = [
...(screen.navigation?.rules ?? []),
{ nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] },
];
updateNavigation(screen, { rules: nextRules });
};
@ -354,326 +270,284 @@ export function BuilderSidebar() {
dispatch({ type: "remove-screen", payload: { screenId } });
};
// Показываем настройки воронки, если экран не выбран
if (!selectedScreen) {
return (
<div className="p-6">
<div className="flex flex-col gap-6">
<Section title="Валидация">
<ValidationSummary />
</Section>
const handleTemplateUpdate = (screenId: string, updates: Partial<ScreenDefinition>) => {
dispatch({
type: "update-screen",
payload: {
screenId,
screen: updates as Partial<BuilderScreen>,
},
});
};
<Section title="Настройки воронки" description="Общие параметры">
<TextInput
label="ID воронки"
value={state.meta.id}
onChange={(event) => handleMetaChange("id", event.target.value)}
/>
<TextInput
label="Название"
value={state.meta.title ?? ""}
onChange={(event) => handleMetaChange("title", event.target.value)}
/>
<TextInput
label="Описание"
value={state.meta.description ?? ""}
onChange={(event) => handleMetaChange("description", event.target.value)}
/>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
onChange={(event) => handleFirstScreenChange(event.target.value)}
>
{screenOptions.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</Section>
<Section title="Экраны" description="Управление экранами">
<div className="rounded-lg border border-border/60 p-3">
<p className="text-sm text-muted-foreground mb-3">
Выберите экран на канвасе для редактирования его настроек.
</p>
<div className="text-xs text-muted-foreground">
Всего экранов: <span className="font-medium">{state.screens.length}</span>
</div>
</div>
</Section>
</div>
</div>
);
}
// Показываем настройки выбранного экрана
const selectedScreenIsListType = isListScreen(selectedScreen);
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
return (
<div className="p-6">
<div className="flex flex-col gap-6">
{/* Информация о выбранном экране */}
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-primary"></div>
<span className="text-sm font-semibold text-primary">Редактируем экран</span>
</div>
<Button
variant="ghost"
className="h-6 px-2 text-xs text-muted-foreground hover:text-primary"
onClick={() => dispatch({ type: "set-selected-screen", payload: { screenId: null } })}
>
К настройкам воронки
</Button>
</div>
<div className="text-xs text-muted-foreground">
<span className="font-medium">ID:</span> {selectedScreen.id}
<span className="font-medium">Тип:</span> {selectedScreen.template}
</div>
<div className="flex h-full flex-col">
<div className="sticky top-0 z-20 border-b border-border/60 bg-background/95 px-6 py-4">
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/70">
Режим редактирования
</span>
<h1 className="text-lg font-semibold">Настройки</h1>
</div>
<div className="mt-4 flex rounded-lg bg-muted/40 p-1 text-sm font-medium">
<button
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition",
activeTab === "funnel"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("funnel")}
>
Воронка
</button>
<button
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition",
activeTab === "screen"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
!selectedScreen && "cursor-not-allowed opacity-60"
)}
onClick={() => selectedScreen && setActiveTab("screen")}
>
Экран
</button>
</div>
</div>
<Section title="Основные настройки" description="Заголовок и тип экрана">
<div className="flex flex-col gap-3">
<TextInput label="ID экрана" value={selectedScreen.id} disabled />
<TextInput
label="Заголовок"
value={selectedScreen.title.text}
onChange={(event) => handleTitleChange(selectedScreen.id, event.target.value)}
/>
<TextInput
label="Подзаголовок"
value={hasSubtitle(selectedScreen) ? selectedScreen.subtitle?.text ?? "" : ""}
onChange={(event) => handleSubtitleChange(selectedScreen.id, event.target.value)}
/>
<div className="rounded-lg border border-border/60 bg-muted/30 p-3">
<div className="text-xs text-muted-foreground">
<span className="font-medium">Тип экрана:</span> {selectedScreen.template}
<div className="mt-1">
<span className="font-medium">Позиция в воронке:</span> экран {state.screens.findIndex(s => s.id === selectedScreen.id) + 1} из {state.screens.length}
</div>
</div>
</div>
</div>
</Section>
<div className="flex-1 overflow-y-auto px-6 py-6">
{activeTab === "funnel" ? (
<div className="flex flex-col gap-6">
<Section title="Валидация" description="Проверка общих настроек">
<ValidationSummary issues={validation.issues} />
</Section>
{selectedScreenIsListType && (
<Section title="Варианты ответа" description="Настройки опций">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Тип выбора</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={selectedScreenIsListType ? selectedScreen.list.selectionType : "single"}
onChange={(event) =>
handleSelectionTypeChange(
selectedScreen.id,
event.target.value as "single" | "multi"
)
}
>
<option value="single">Один ответ</option>
<option value="multi">Несколько ответов</option>
</select>
</label>
<Button
className="h-8 px-3 text-xs"
onClick={() => handleAddOption(selectedScreen)}
<Section title="Настройки воронки" description="Общие параметры">
<TextInput
label="ID воронки"
value={state.meta.id}
onChange={(event) => handleMetaChange("id", event.target.value)}
/>
<TextInput
label="Название"
value={state.meta.title ?? ""}
onChange={(event) => handleMetaChange("title", event.target.value)}
/>
<TextInput
label="Описание"
value={state.meta.description ?? ""}
onChange={(event) => handleMetaChange("description", event.target.value)}
/>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
onChange={(event) => handleFirstScreenChange(event.target.value)}
>
Добавить
</Button>
</div>
<div className="flex flex-col gap-4">
{selectedScreenIsListType && selectedScreen.list.options.map((option, index) => (
<div
key={option.id}
className={cn(
"rounded-xl border border-border/80 bg-background/70 p-3",
"flex flex-col gap-2"
)}
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">Опция {index + 1}</span>
{selectedScreenIsListType && selectedScreen.list.options.length > 1 && (
<Button
variant="ghost"
className="text-destructive"
onClick={() => handleRemoveOption(selectedScreen, index)}
>
Удалить
</Button>
)}
</div>
<TextInput
label="ID"
value={option.id}
onChange={(event) => handleOptionChange(selectedScreen.id, index, "id", event.target.value)}
/>
<TextInput
label="Текст"
value={option.label}
onChange={(event) => handleOptionChange(selectedScreen.id, index, "label", event.target.value)}
/>
<TextInput
label="Описание"
value={option.description ?? ""}
onChange={(event) => handleOptionChange(selectedScreen.id, index, "description", event.target.value)}
/>
<TextInput
label="Emoji"
value={option.emoji ?? ""}
onChange={(event) => handleOptionChange(selectedScreen.id, index, "emoji", event.target.value)}
/>
</div>
))}
</div>
</div>
</Section>
)}
<Section title="Навигация" description="Переходы между экранами">
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
>
<option value=""></option>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
{screenOptions.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</div>
</Section>
</select>
</label>
</Section>
{selectedScreenIsListType && (
<Section title="Правила переходов" description="Условная навигация">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
</p>
<Button className="h-8 px-3 text-xs" onClick={() => handleAddRule(selectedScreen)}>
Добавить правило
</Button>
</div>
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Правил пока нет
<Section title="Экраны" description="Управление и статистика">
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Всего экранов</span>
<span className="font-semibold text-foreground">{state.screens.length}</span>
</div>
)}
<div className="flex flex-col gap-1 text-xs">
{state.screens.map((screen, index) => (
<span key={screen.id} className="flex items-center justify-between">
<span className="truncate">{index + 1}. {screen.title.text}</span>
<span className="uppercase text-muted-foreground/80">{screen.template}</span>
</span>
))}
</div>
</div>
</Section>
</div>
) : selectedScreen ? (
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
<span>
<span className="font-semibold">ID:</span> {selectedScreen.id}
</span>
<span>
<span className="font-semibold">Тип:</span> {selectedScreen.template}
</span>
<span>
<span className="font-semibold">Позиция:</span> экран {state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1} из {state.screens.length}
</span>
</div>
</div>
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
<div
key={ruleIndex}
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
<Section title="Общие данные" description="ID и тип текущего экрана">
<TextInput label="ID экрана" value={selectedScreen.id} disabled />
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
Текущий шаблон: <span className="font-semibold text-foreground">{selectedScreen.template}</span>
</div>
</Section>
<Section title="Контент и оформление" description="Все параметры выбранного шаблона">
<TemplateConfig
screen={selectedScreen}
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
/>
</Section>
<Section title="Навигация" description="Переходы между экранами">
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
>
<option value=""></option>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</Section>
{selectedScreenIsListType && (
<Section title="Правила переходов" description="Условная навигация">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
<Button
variant="ghost"
className="text-destructive"
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
>
<span className="text-xs">Удалить</span>
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
</p>
<Button className="h-8 px-3 text-xs" onClick={() => handleAddRule(selectedScreen)}>
Добавить правило
</Button>
</div>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.conditions[0]?.operator ?? "includesAny"}
onChange={(event) =>
handleRuleOperatorChange(
selectedScreen.id,
ruleIndex,
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
)
}
>
<option value="includesAny">contains any</option>
<option value="includesAll">contains all</option>
<option value="includesExactly">exact match</option>
</select>
</label>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{selectedScreenIsListType && selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
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={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
/>
<span>
{option.label}
<span className="text-muted-foreground"> ({option.id})</span>
</span>
</label>
);
})}
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Правил пока нет
</div>
</div>
)}
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.nextScreenId}
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
<div
key={ruleIndex}
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
<Button
variant="ghost"
className="text-destructive"
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
>
<span className="text-xs">Удалить</span>
</Button>
</div>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.conditions[0]?.operator ?? "includesAny"}
onChange={(event) =>
handleRuleOperatorChange(
selectedScreen.id,
ruleIndex,
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
)
}
>
<option value="includesAny">contains any</option>
<option value="includesAll">contains all</option>
<option value="includesExactly">exact match</option>
</select>
</label>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
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={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
/>
<span>
{option.label}
<span className="text-muted-foreground"> ({option.id})</span>
</span>
</label>
);
})}
</div>
</div>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.nextScreenId}
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</div>
))}
</div>
))}
</div>
</Section>
)}
</Section>
)}
<Section title="Валидация экрана" description="Проверка корректности настроек">
<ValidationSummary />
</Section>
<Section title="Валидация экрана" description="Проверка корректности настроек">
<ValidationSummary issues={screenValidationIssues} />
</Section>
<Section title="Управление экраном" description="Опасные действия">
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="text-sm text-muted-foreground mb-3">
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
</p>
<Button
variant="destructive"
className="h-9 text-sm"
disabled={state.screens.length <= 1}
onClick={() => handleDeleteScreen(selectedScreen.id)}
>
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
</Button>
<Section title="Управление экраном" description="Опасные действия">
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="mb-3 text-sm text-muted-foreground">
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
</p>
<Button
variant="destructive"
className="h-9 text-sm"
disabled={state.screens.length <= 1}
onClick={() => handleDeleteScreen(selectedScreen.id)}
>
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
</Button>
</div>
</Section>
</div>
</Section>
) : (
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/20 p-6 text-center text-sm text-muted-foreground">
Выберите экран в списке слева, чтобы настроить его параметры.
</div>
)}
</div>
</div>
);

View File

@ -11,179 +11,96 @@ interface CouponScreenConfigProps {
export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) {
const couponScreen = screen as CouponScreenDefinition & { position: { x: number; y: number } };
const handleCouponUpdate = <T extends keyof CouponScreenDefinition["coupon"]>(
field: T,
value: CouponScreenDefinition["coupon"][T]
) => {
onUpdate({
coupon: {
...couponScreen.coupon,
[field]: value,
},
});
};
return (
<div className="space-y-4">
{/* Title Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<TextInput
placeholder="You're Lucky!"
value={couponScreen.title?.text || ""}
onChange={(e) => onUpdate({
title: {
...couponScreen.title,
text: e.target.value,
font: couponScreen.title?.font || "manrope",
weight: couponScreen.title?.weight || "bold",
align: couponScreen.title?.align || "center",
}
})}
/>
</div>
{/* Subtitle Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Subtitle</label>
<TextInput
placeholder="You got an exclusive 94% discount"
value={couponScreen.subtitle?.text || ""}
onChange={(e) => onUpdate({
subtitle: {
...couponScreen.subtitle,
text: e.target.value,
font: couponScreen.subtitle?.font || "inter",
weight: couponScreen.subtitle?.weight || "medium",
align: couponScreen.subtitle?.align || "center",
}
})}
/>
</div>
{/* Coupon Configuration */}
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="space-y-3">
<h3 className="text-sm font-semibold">Coupon Details</h3>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Offer Title</label>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Настройки оффера
</h3>
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
Заголовок оффера
<TextInput
placeholder="94% OFF"
value={couponScreen.coupon?.offer?.title?.text || ""}
onChange={(e) => onUpdate({
coupon: {
...couponScreen.coupon,
offer: {
...couponScreen.coupon?.offer,
title: {
...couponScreen.coupon?.offer?.title,
text: e.target.value,
font: couponScreen.coupon?.offer?.title?.font || "manrope",
weight: couponScreen.coupon?.offer?.title?.weight || "bold",
}
}
}
})}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Offer Description</label>
<TextInput
placeholder="HAIR LOSS SPECIALIST"
value={couponScreen.coupon?.offer?.description?.text || ""}
onChange={(e) => onUpdate({
coupon: {
...couponScreen.coupon,
offer: {
...couponScreen.coupon?.offer,
description: {
...couponScreen.coupon?.offer?.description,
text: e.target.value,
font: couponScreen.coupon?.offer?.description?.font || "inter",
weight: couponScreen.coupon?.offer?.description?.weight || "medium",
}
}
}
})}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Promo Code</label>
<TextInput
placeholder="HAIR50"
value={couponScreen.coupon?.promoCode?.text || ""}
onChange={(e) => onUpdate({
coupon: {
...couponScreen.coupon,
promoCode: {
...couponScreen.coupon?.promoCode,
text: e.target.value,
font: couponScreen.coupon?.promoCode?.font || "manrope",
weight: couponScreen.coupon?.promoCode?.weight || "bold",
}
}
})}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Footer Text</label>
<TextInput
placeholder="Click to copy promocode"
value={couponScreen.coupon?.footer?.text || ""}
onChange={(e) => onUpdate({
coupon: {
...couponScreen.coupon,
footer: {
...couponScreen.coupon?.footer,
text: e.target.value,
font: couponScreen.coupon?.footer?.font || "inter",
weight: couponScreen.coupon?.footer?.weight || "medium",
}
}
})}
/>
</div>
</div>
{/* Bottom Action Button */}
<div className="space-y-2">
<label className="text-sm font-medium">Button Text</label>
<TextInput
placeholder="Continue"
value={couponScreen.bottomActionButton?.text || ""}
onChange={(e) => onUpdate({
bottomActionButton: {
text: e.target.value || "Continue",
placeholder="-50% на первый заказ"
value={couponScreen.coupon?.offer?.title?.text ?? ""}
onChange={(event) =>
handleCouponUpdate("offer", {
...couponScreen.coupon.offer,
title: {
...(couponScreen.coupon.offer?.title ?? {}),
text: event.target.value,
},
})
}
})}
/>
/>
</label>
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
Подзаголовок/описание
<TextInput
placeholder="Персональная акция только сегодня"
value={couponScreen.coupon?.offer?.description?.text ?? ""}
onChange={(event) =>
handleCouponUpdate("offer", {
...couponScreen.coupon.offer,
description: {
...(couponScreen.coupon.offer?.description ?? {}),
text: event.target.value,
},
})
}
/>
</label>
</div>
{/* Header Configuration */}
<div className="space-y-2">
<h3 className="text-sm font-semibold">Header Settings</h3>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={couponScreen.header?.show !== false}
onChange={(e) => onUpdate({
header: {
...couponScreen.header,
show: e.target.checked,
}
})}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Промокод</h4>
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
Текст промокода
<TextInput
placeholder="SALE50"
value={couponScreen.coupon?.promoCode?.text ?? ""}
onChange={(event) =>
handleCouponUpdate("promoCode", {
...(couponScreen.coupon.promoCode ?? {}),
text: event.target.value,
})
}
/>
Show navigation bar
</label>
{couponScreen.header?.show !== false && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={couponScreen.header?.showBackButton !== false}
onChange={(e) => onUpdate({
header: {
...couponScreen.header,
showBackButton: e.target.checked,
}
})}
/>
Show back button
</label>
)}
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
Подпись под промокодом
<TextInput
placeholder="Нажмите, чтобы скопировать"
value={couponScreen.coupon?.footer?.text ?? ""}
onChange={(event) =>
handleCouponUpdate("footer", {
...(couponScreen.coupon.footer ?? {}),
text: event.target.value,
})
}
/>
</label>
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">Сообщение об успехе</h4>
<TextInput
placeholder="Промокод скопирован!"
value={couponScreen.copiedMessage ?? ""}
onChange={(event) => onUpdate({ copiedMessage: event.target.value || undefined })}
/>
</div>
</div>
);

View File

@ -11,176 +11,140 @@ interface DateScreenConfigProps {
export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
const dateScreen = screen as DateScreenDefinition & { position: { x: number; y: number } };
const handleDateInputChange = <T extends keyof DateScreenDefinition["dateInput"]>(field: T, value: string | boolean) => {
onUpdate({
dateInput: {
...dateScreen.dateInput,
[field]: value,
},
});
};
const handleInfoMessageChange = (field: "text" | "icon", value: string) => {
const baseInfo = dateScreen.infoMessage ?? { text: "", icon: "" };
const nextInfo = { ...baseInfo, [field]: value };
if (!nextInfo.text) {
onUpdate({ infoMessage: undefined });
return;
}
onUpdate({ infoMessage: nextInfo });
};
return (
<div className="space-y-4">
{/* Title Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<TextInput
placeholder="When were you born?"
value={dateScreen.title?.text || ""}
onChange={(e) => onUpdate({
title: {
...dateScreen.title,
text: e.target.value,
font: dateScreen.title?.font || "manrope",
weight: dateScreen.title?.weight || "bold",
}
})}
/>
</div>
{/* Subtitle Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Subtitle (Optional)</label>
<TextInput
placeholder="Enter subtitle"
value={dateScreen.subtitle?.text || ""}
onChange={(e) => onUpdate({
subtitle: e.target.value ? {
text: e.target.value,
font: dateScreen.subtitle?.font || "inter",
weight: dateScreen.subtitle?.weight || "medium",
color: dateScreen.subtitle?.color || "muted",
} : undefined
})}
/>
</div>
{/* Date Input Labels */}
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="space-y-3">
<h3 className="text-sm font-semibold">Date Input Labels</h3>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Month Label</label>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Поля ввода даты
</h3>
<div className="grid grid-cols-3 gap-3 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Подпись месяца
<TextInput
placeholder="Month"
value={dateScreen.dateInput?.monthLabel || ""}
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
monthLabel: e.target.value,
}
})}
value={dateScreen.dateInput?.monthLabel ?? ""}
onChange={(event) => handleDateInputChange("monthLabel", event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Day Label</label>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Подпись дня
<TextInput
placeholder="Day"
value={dateScreen.dateInput?.dayLabel || ""}
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
dayLabel: e.target.value,
}
})}
value={dateScreen.dateInput?.dayLabel ?? ""}
onChange={(event) => handleDateInputChange("dayLabel", event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Year Label</label>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Подпись года
<TextInput
placeholder="Year"
value={dateScreen.dateInput?.yearLabel || ""}
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
yearLabel: e.target.value,
}
})}
value={dateScreen.dateInput?.yearLabel ?? ""}
onChange={(event) => handleDateInputChange("yearLabel", event.target.value)}
/>
</div>
</label>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Month Placeholder</label>
<div className="grid grid-cols-3 gap-3 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Placeholder месяца
<TextInput
placeholder="MM"
value={dateScreen.dateInput?.monthPlaceholder || ""}
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
monthPlaceholder: e.target.value,
}
})}
value={dateScreen.dateInput?.monthPlaceholder ?? ""}
onChange={(event) => handleDateInputChange("monthPlaceholder", event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Day Placeholder</label>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Placeholder дня
<TextInput
placeholder="DD"
value={dateScreen.dateInput?.dayPlaceholder || ""}
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
dayPlaceholder: e.target.value,
}
})}
value={dateScreen.dateInput?.dayPlaceholder ?? ""}
onChange={(event) => handleDateInputChange("dayPlaceholder", event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Year Placeholder</label>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Placeholder года
<TextInput
placeholder="YYYY"
value={dateScreen.dateInput?.yearPlaceholder || ""}
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
yearPlaceholder: e.target.value,
}
})}
value={dateScreen.dateInput?.yearPlaceholder ?? ""}
onChange={(event) => handleDateInputChange("yearPlaceholder", event.target.value)}
/>
</div>
</label>
</div>
</div>
{/* Info Message */}
<div className="space-y-2">
<label className="text-sm font-medium">Info Message (Optional)</label>
<TextInput
placeholder="We protect your personal data"
value={dateScreen.infoMessage?.text || ""}
onChange={(e) => onUpdate({
infoMessage: e.target.value ? {
text: e.target.value,
icon: dateScreen.infoMessage?.icon || "🔒",
} : undefined
})}
/>
{dateScreen.infoMessage && (
<TextInput
placeholder="🔒"
value={dateScreen.infoMessage.icon}
onChange={(e) => onUpdate({
infoMessage: {
text: dateScreen.infoMessage?.text || "",
icon: e.target.value,
}
})}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Поведение поля</h4>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={dateScreen.dateInput?.showSelectedDate === true}
onChange={(event) => handleDateInputChange("showSelectedDate", event.target.checked)}
/>
)}
Показывать выбранную дату под полем
</label>
<div className="grid grid-cols-2 gap-3 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Подпись выбранной даты
<TextInput
value={dateScreen.dateInput?.selectedDateLabel ?? ""}
onChange={(event) => handleDateInputChange("selectedDateLabel", event.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Формат отображения (date-fns)
<TextInput
placeholder="MMMM d, yyyy"
value={dateScreen.dateInput?.selectedDateFormat ?? ""}
onChange={(event) => handleDateInputChange("selectedDateFormat", event.target.value)}
/>
</label>
</div>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Текст ошибки валидации
<TextInput
placeholder="Пожалуйста, укажите корректную дату"
value={dateScreen.dateInput?.validationMessage ?? ""}
onChange={(event) => handleDateInputChange("validationMessage", event.target.value)}
/>
</label>
</div>
{/* Bottom Action Button */}
<div className="space-y-2">
<label className="text-sm font-medium">Button Text (Optional)</label>
<TextInput
placeholder="Next"
value={dateScreen.bottomActionButton?.text || ""}
onChange={(e) => onUpdate({
bottomActionButton: e.target.value ? {
text: e.target.value,
} : undefined
})}
/>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Информационный блок</h4>
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Сообщение (оставьте пустым, чтобы скрыть)</span>
<TextInput
value={dateScreen.infoMessage?.text ?? ""}
onChange={(event) => handleInfoMessageChange("text", event.target.value)}
/>
</label>
{dateScreen.infoMessage && (
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Emoji/иконка для сообщения</span>
<TextInput
value={dateScreen.infoMessage.icon ?? ""}
onChange={(event) => handleInfoMessageChange("icon", event.target.value)}
/>
</label>
)}
</div>
</div>
);

View File

@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { FormScreenDefinition, FormFieldDefinition } from "@/lib/funnel/types";
import type { FormScreenDefinition, FormFieldDefinition, FormValidationMessages } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
interface FormScreenConfigProps {
@ -12,180 +12,202 @@ interface FormScreenConfigProps {
export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
const formScreen = screen as FormScreenDefinition & { position: { x: number; y: number } };
const updateField = (index: number, updates: Partial<FormFieldDefinition>) => {
const newFields = [...(formScreen.fields || [])];
newFields[index] = { ...newFields[index], ...updates };
onUpdate({ fields: newFields });
};
const updateValidationMessages = (updates: Partial<FormValidationMessages>) => {
onUpdate({
validationMessages: {
...(formScreen.validationMessages ?? {}),
...updates,
},
});
};
const addField = () => {
const newField: FormFieldDefinition = {
id: `field_${Date.now()}`,
label: "New Field",
placeholder: "Enter value",
label: "Новое поле",
placeholder: "Введите значение",
type: "text",
required: true,
};
onUpdate({
fields: [...(formScreen.fields || []), newField]
fields: [...(formScreen.fields || []), newField],
});
};
const removeField = (index: number) => {
const newFields = formScreen.fields?.filter((_, i) => i !== index) || [];
onUpdate({ fields: newFields });
};
return (
<div className="space-y-4">
{/* Title Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<TextInput
placeholder="Enter your details"
value={formScreen.title?.text || ""}
onChange={(e) => onUpdate({
title: {
...formScreen.title,
text: e.target.value,
font: formScreen.title?.font || "manrope",
weight: formScreen.title?.weight || "bold",
}
})}
/>
</div>
{/* Subtitle Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Subtitle (Optional)</label>
<TextInput
placeholder="Please fill in all fields"
value={formScreen.subtitle?.text || ""}
onChange={(e) => onUpdate({
subtitle: e.target.value ? {
text: e.target.value,
font: formScreen.subtitle?.font || "inter",
weight: formScreen.subtitle?.weight || "medium",
color: formScreen.subtitle?.color || "muted",
} : undefined
})}
/>
</div>
{/* Form Fields Configuration */}
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Form Fields</h3>
<Button
onClick={addField}
className="h-7 px-3 text-xs"
>
Add Field
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Поля формы</h3>
<Button onClick={addField} variant="outline" className="h-8 px-3 text-xs">
Добавить поле
</Button>
</div>
{formScreen.fields?.map((field, index) => (
<div key={index} className="rounded border border-border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Field {index + 1}</span>
<div key={field.id} className="space-y-3 rounded-xl border border-border/70 bg-muted/10 p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Поле {index + 1}
</span>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => removeField(index)}
className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
>
Remove
Удалить
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Field ID</label>
<TextInput
placeholder="field_id"
value={field.id}
onChange={(e) => updateField(index, { id: e.target.value })}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Type</label>
<div className="grid grid-cols-2 gap-3 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
ID поля
<TextInput value={field.id} onChange={(event) => updateField(index, { id: event.target.value })} />
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Тип
<select
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value as FormFieldDefinition['type'] })}
className="rounded-lg border border-border bg-background px-2 py-1"
value={field.type ?? "text"}
onChange={(event) => updateField(index, { type: event.target.value as FormFieldDefinition["type"] })}
>
<option value="text">Text</option>
<option value="email">Email</option>
<option value="tel">Phone</option>
<option value="url">URL</option>
<option value="text">Текст</option>
<option value="email">E-mail</option>
<option value="tel">Телефон</option>
<option value="url">Ссылка</option>
</select>
</div>
</label>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Label</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Метка поля
<TextInput
placeholder="Field Label"
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
value={field.label ?? ""}
onChange={(event) => updateField(index, { label: event.target.value })}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Placeholder</label>
</label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Placeholder
<TextInput
placeholder="Enter placeholder"
value={field.placeholder || ""}
onChange={(e) => updateField(index, { placeholder: e.target.value })}
value={field.placeholder ?? ""}
onChange={(event) => updateField(index, { placeholder: event.target.value })}
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
</label>
<div className="grid grid-cols-2 gap-3 text-xs">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={field.required || false}
onChange={(e) => updateField(index, { required: e.target.checked })}
checked={field.required ?? false}
onChange={(event) => updateField(index, { required: event.target.checked })}
/>
Обязательно для заполнения
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Максимальная длина
<input
type="number"
min={1}
className="rounded-lg border border-border bg-background px-2 py-1"
value={field.maxLength ?? ""}
onChange={(event) =>
updateField(index, {
maxLength: event.target.value ? Number(event.target.value) : undefined,
})
}
/>
</label>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Регулярное выражение (pattern)
<TextInput
placeholder="Например, ^\\d+$"
value={field.validation?.pattern ?? ""}
onChange={(event) =>
updateField(index, {
validation: {
...(field.validation ?? {}),
pattern: event.target.value || undefined,
message: field.validation?.message,
},
})
}
/>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Текст ошибки для pattern
<TextInput
placeholder="Неверный формат"
value={field.validation?.message ?? ""}
onChange={(event) =>
updateField(index, {
validation:
field.validation || event.target.value
? {
...(field.validation ?? {}),
message: event.target.value || undefined,
}
: undefined,
})
}
/>
Required
</label>
{field.maxLength && (
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Max Length:</label>
<input
type="number"
className="w-16 rounded border border-border bg-background px-2 py-1 text-xs"
value={field.maxLength}
onChange={(e) => updateField(index, { maxLength: parseInt(e.target.value) || undefined })}
/>
</div>
)}
</div>
</div>
))}
{(!formScreen.fields || formScreen.fields.length === 0) && (
<div className="text-center py-4 text-sm text-muted-foreground">
No fields added yet. Click &quot;Add Field&quot; to get started.
<div className="rounded-lg border border-dashed border-border/60 p-4 text-center text-sm text-muted-foreground">
Пока нет полей. Добавьте хотя бы одно, чтобы форма работала.
</div>
)}
</div>
{/* Bottom Action Button */}
<div className="space-y-2">
<label className="text-sm font-medium">Button Text</label>
<TextInput
placeholder="Continue"
value={formScreen.bottomActionButton?.text || ""}
onChange={(e) => onUpdate({
bottomActionButton: {
text: e.target.value || "Continue",
}
})}
/>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Сообщения валидации</h4>
<div className="grid grid-cols-1 gap-3 text-xs md:grid-cols-3">
<label className="flex flex-col gap-1 text-muted-foreground">
Обязательное поле
<TextInput
placeholder="Это поле обязательно"
value={formScreen.validationMessages?.required ?? ""}
onChange={(event) => updateValidationMessages({ required: event.target.value || undefined })}
/>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Превышена длина
<TextInput
placeholder="Превышена допустимая длина"
value={formScreen.validationMessages?.maxLength ?? ""}
onChange={(event) => updateValidationMessages({ maxLength: event.target.value || undefined })}
/>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Неверный формат
<TextInput
placeholder="Введите данные в корректном формате"
value={formScreen.validationMessages?.invalidFormat ?? ""}
onChange={(event) => updateValidationMessages({ invalidFormat: event.target.value || undefined })}
/>
</label>
</div>
</div>
</div>
);

View File

@ -1,7 +1,7 @@
"use client";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { InfoScreenDefinition, TypographyVariant } from "@/lib/funnel/types";
import type { InfoScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
interface InfoScreenConfigProps {
@ -11,145 +11,91 @@ interface InfoScreenConfigProps {
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
const infoScreen = screen as InfoScreenDefinition & { position: { x: number; y: number } };
const handleDescriptionChange = (text: string) => {
onUpdate({
description: text
? {
...(infoScreen.description ?? {}),
text,
}
: undefined,
});
};
const handleIconChange = <T extends keyof NonNullable<InfoScreenDefinition["icon"]>>(
field: T,
value: NonNullable<InfoScreenDefinition["icon"]>[T] | undefined
) => {
const baseIcon = infoScreen.icon ?? { type: "emoji", value: "✨", size: "lg" };
if (field === "value") {
if (!value) {
onUpdate({ icon: undefined });
} else {
onUpdate({ icon: { ...baseIcon, value } });
}
return;
}
onUpdate({ icon: { ...baseIcon, [field]: value } });
};
return (
<div className="space-y-4">
{/* Title Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<TextInput
placeholder="Enter screen title"
value={infoScreen.title?.text || ""}
onChange={(e) => onUpdate({
title: {
...infoScreen.title,
text: e.target.value,
font: infoScreen.title?.font || "manrope",
weight: infoScreen.title?.weight || "bold",
align: infoScreen.title?.align || "center",
}
})}
/>
<div className="grid grid-cols-2 gap-2">
<select
className="rounded border border-border bg-background px-2 py-1 text-sm"
value={infoScreen.title?.font || "manrope"}
onChange={(e) => onUpdate({
title: {
...infoScreen.title,
text: infoScreen.title?.text || "",
font: e.target.value as TypographyVariant['font'],
}
})}
>
<option value="manrope">Manrope</option>
<option value="inter">Inter</option>
</select>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm"
value={infoScreen.title?.weight || "bold"}
onChange={(e) => onUpdate({
title: {
...infoScreen.title,
text: infoScreen.title?.text || "",
weight: e.target.value as TypographyVariant['weight'],
}
})}
>
<option value="medium">Medium</option>
<option value="bold">Bold</option>
<option value="semibold">Semibold</option>
</select>
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Информационный контент
</h3>
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
<TextInput
placeholder="Введите пояснение для пользователя"
value={infoScreen.description?.text ?? ""}
onChange={(event) => handleDescriptionChange(event.target.value)}
/>
</label>
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Иконка</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Тип иконки
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={infoScreen.icon?.type ?? "emoji"}
onChange={(event) => handleIconChange("type", event.target.value as "emoji" | "image")}
>
<option value="emoji">Emoji</option>
<option value="image">Изображение</option>
</select>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Размер
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={infoScreen.icon?.size ?? "lg"}
onChange={(event) => handleIconChange("size", event.target.value)}
>
<option value="sm">Маленький</option>
<option value="md">Средний</option>
<option value="lg">Большой</option>
<option value="xl">Огромный</option>
</select>
</label>
</div>
</div>
{/* Description Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Description (Optional)</label>
<TextInput
placeholder="Enter screen description"
value={infoScreen.description?.text || ""}
onChange={(e) => onUpdate({
description: e.target.value ? {
text: e.target.value,
font: infoScreen.description?.font || "inter",
weight: infoScreen.description?.weight || "medium",
align: infoScreen.description?.align || "center",
} : undefined
})}
/>
</div>
{/* Icon Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Icon (Optional)</label>
<div className="grid grid-cols-2 gap-2">
<select
className="rounded border border-border bg-background px-2 py-1 text-sm"
value={infoScreen.icon?.type || "emoji"}
onChange={(e) => onUpdate({
icon: infoScreen.icon ? {
...infoScreen.icon,
type: e.target.value as "emoji" | "image",
} : {
type: e.target.value as "emoji" | "image",
value: "❤️",
size: "lg",
}
})}
>
<option value="emoji">Emoji</option>
<option value="image">Image</option>
</select>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm"
value={infoScreen.icon?.size || "lg"}
onChange={(e) => onUpdate({
icon: infoScreen.icon ? {
...infoScreen.icon,
size: e.target.value as "sm" | "md" | "lg" | "xl",
} : {
type: "emoji",
value: "❤️",
size: e.target.value as "sm" | "md" | "lg" | "xl",
}
})}
>
<option value="sm">Small</option>
<option value="md">Medium</option>
<option value="lg">Large</option>
<option value="xl">Extra Large</option>
</select>
</div>
<TextInput
placeholder={infoScreen.icon?.type === "image" ? "Image URL" : "Emoji (e.g., ❤️)"}
value={infoScreen.icon?.value || ""}
onChange={(e) => onUpdate({
icon: e.target.value ? {
type: infoScreen.icon?.type || "emoji",
value: e.target.value,
size: infoScreen.icon?.size || "lg",
} : undefined
})}
/>
</div>
{/* Bottom Action Button */}
<div className="space-y-2">
<label className="text-sm font-medium">Button Text (Optional)</label>
<TextInput
placeholder="Next"
value={infoScreen.bottomActionButton?.text || ""}
onChange={(e) => onUpdate({
bottomActionButton: e.target.value ? {
text: e.target.value,
} : undefined
})}
/>
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">
{infoScreen.icon?.type === "image" ? "Ссылка на изображение" : "Emoji символ"}
</span>
<TextInput
placeholder={infoScreen.icon?.type === "image" ? "https://..." : "Например, ✨"}
value={infoScreen.icon?.value ?? ""}
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
/>
</label>
</div>
</div>
);

View File

@ -2,8 +2,8 @@
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Button } from "@/components/ui/button";
import { Trash2, Plus } from "lucide-react";
import type { ListScreenDefinition, ListOptionDefinition, SelectionType } from "@/lib/funnel/types";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import type { ListScreenDefinition, ListOptionDefinition, SelectionType, BottomActionButtonDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
interface ListScreenConfigProps {
@ -11,194 +11,334 @@ interface ListScreenConfigProps {
onUpdate: (updates: Partial<ListScreenDefinition>) => void;
}
function mutateOptions(
options: ListOptionDefinition[],
index: number,
mutation: (option: ListOptionDefinition) => ListOptionDefinition
): ListOptionDefinition[] {
return options.map((option, currentIndex) => (currentIndex === index ? mutation(option) : option));
}
export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
const listScreen = screen as ListScreenDefinition & { position: { x: number; y: number } };
const handleTitleChange = (text: string) => {
onUpdate({
title: {
...listScreen.title,
text,
font: listScreen.title?.font || "manrope",
weight: listScreen.title?.weight || "bold",
align: listScreen.title?.align || "left",
}
});
};
const handleSubtitleChange = (text: string) => {
onUpdate({
subtitle: text ? {
...listScreen.subtitle,
text,
font: listScreen.subtitle?.font || "inter",
weight: listScreen.subtitle?.weight || "medium",
color: listScreen.subtitle?.color || "muted",
align: listScreen.subtitle?.align || "left",
} : undefined
});
};
const handleSelectionTypeChange = (selectionType: SelectionType) => {
onUpdate({
list: {
...listScreen.list,
selectionType,
}
},
});
};
const handleOptionChange = (index: number, field: keyof ListOptionDefinition, value: string | boolean) => {
const newOptions = [...listScreen.list.options];
newOptions[index] = {
...newOptions[index],
[field]: value,
};
const handleAutoAdvanceChange = (checked: boolean) => {
onUpdate({
list: {
...listScreen.list,
options: newOptions,
}
autoAdvance: checked || undefined,
},
});
};
const handleOptionChange = (
index: number,
field: keyof ListOptionDefinition,
value: string | boolean | undefined
) => {
const nextOptions = mutateOptions(listScreen.list.options, index, (option) => ({
...option,
[field]: value,
}));
onUpdate({
list: {
...listScreen.list,
options: nextOptions,
},
});
};
const handleMoveOption = (index: number, direction: -1 | 1) => {
const nextOptions = [...listScreen.list.options];
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= nextOptions.length) {
return;
}
const [current] = nextOptions.splice(index, 1);
nextOptions.splice(targetIndex, 0, current);
onUpdate({
list: {
...listScreen.list,
options: nextOptions,
},
});
};
const handleAddOption = () => {
const newOptions = [...listScreen.list.options];
newOptions.push({
id: `option-${Date.now()}`,
label: "New Option",
});
const nextOptions = [
...listScreen.list.options,
{
id: `option-${Date.now()}`,
label: "Новый вариант",
},
];
onUpdate({
list: {
...listScreen.list,
options: newOptions,
}
options: nextOptions,
},
});
};
const handleRemoveOption = (index: number) => {
const newOptions = listScreen.list.options.filter((_, i) => i !== index);
const nextOptions = listScreen.list.options.filter((_, currentIndex) => currentIndex !== index);
onUpdate({
list: {
...listScreen.list,
options: newOptions,
}
options: nextOptions,
},
});
};
const handleBottomActionButtonChange = (text: string) => {
const handleListButtonChange = (value: BottomActionButtonDefinition | undefined) => {
onUpdate({
list: {
...listScreen.list,
bottomActionButton: text ? {
text,
show: true,
} : undefined,
}
bottomActionButton: value,
},
});
};
return (
<div className="space-y-6">
{/* Title Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<TextInput
placeholder="Enter screen title"
value={listScreen.title?.text || ""}
onChange={(e) => handleTitleChange(e.target.value)}
/>
</div>
{/* Subtitle Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Subtitle (Optional)</label>
<TextInput
placeholder="Enter screen subtitle"
value={listScreen.subtitle?.text || ""}
onChange={(e) => handleSubtitleChange(e.target.value)}
/>
</div>
{/* Selection Type */}
<div className="space-y-2">
<label className="text-sm font-medium">Selection Type</label>
<div className="flex gap-2">
<Button
variant={listScreen.list.selectionType === "single" ? "default" : "outline"}
onClick={() => handleSelectionTypeChange("single")}
className="h-8 px-3 text-sm"
>
Single
</Button>
<Button
variant={listScreen.list.selectionType === "multi" ? "default" : "outline"}
onClick={() => handleSelectionTypeChange("multi")}
className="h-8 px-3 text-sm"
>
Multi
</Button>
</div>
</div>
{/* Options */}
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Options</label>
<Button
variant="outline"
onClick={handleAddOption}
className="h-7 px-3 text-xs"
>
<Plus className="w-4 h-4 mr-1" />
Add Option
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Варианты выбора
</h3>
<div className="flex items-center gap-2 text-xs">
<button
type="button"
className={`rounded-md px-3 py-1 transition ${
listScreen.list.selectionType === "single"
? "bg-primary text-primary-foreground shadow"
: "border border-border/60"
}`}
onClick={() => handleSelectionTypeChange("single")}
>
Один ответ
</button>
<button
type="button"
className={`rounded-md px-3 py-1 transition ${
listScreen.list.selectionType === "multi"
? "bg-primary text-primary-foreground shadow"
: "border border-border/60"
}`}
onClick={() => handleSelectionTypeChange("multi")}
>
Несколько ответов
</button>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={listScreen.list.autoAdvance === true}
disabled={listScreen.list.selectionType === "multi"}
onChange={(event) => handleAutoAdvanceChange(event.target.checked)}
/>
Автоматический переход после выбора (доступно только для одиночного выбора)
</label>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground">Настройка вариантов</h4>
<Button variant="outline" size="sm" className="h-8 px-3" onClick={handleAddOption}>
<Plus className="mr-1 h-4 w-4" /> Добавить
</Button>
</div>
<div className="space-y-2">
<div className="space-y-3">
{listScreen.list.options.map((option, index) => (
<div key={option.id} className="flex gap-2 items-center">
<div className="flex-1">
<TextInput
placeholder="Option ID"
value={option.id}
onChange={(e) => handleOptionChange(index, "id", e.target.value)}
/>
<div
key={option.id}
className="space-y-3 rounded-xl border border-border/70 bg-muted/10 p-4"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Вариант {index + 1}
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-md border border-border/70 p-1 text-muted-foreground transition hover:bg-muted"
onClick={() => handleMoveOption(index, -1)}
disabled={index === 0}
title="Переместить выше"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
className="rounded-md border border-border/70 p-1 text-muted-foreground transition hover:bg-muted"
onClick={() => handleMoveOption(index, 1)}
disabled={index === listScreen.list.options.length - 1}
title="Переместить ниже"
>
<ArrowDown className="h-4 w-4" />
</button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => handleRemoveOption(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-[2]">
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
ID варианта
<TextInput
value={option.id}
onChange={(event) => handleOptionChange(index, "id", event.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Машинное значение (необязательно)
<TextInput
value={option.value ?? ""}
onChange={(event) =>
handleOptionChange(index, "value", event.target.value || undefined)
}
/>
</label>
</div>
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Подпись для пользователя
<TextInput
placeholder="Option Label"
value={option.label}
onChange={(e) => handleOptionChange(index, "label", e.target.value)}
onChange={(event) => handleOptionChange(index, "label", event.target.value)}
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Описание (необязательно)
<TextInput
value={option.description ?? ""}
onChange={(event) =>
handleOptionChange(index, "description", event.target.value || undefined)
}
/>
</label>
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Emoji/иконка
<TextInput
value={option.emoji ?? ""}
onChange={(event) =>
handleOptionChange(index, "emoji", event.target.value || undefined)
}
/>
</label>
</div>
<Button
variant="outline"
onClick={() => handleRemoveOption(index)}
className="h-8 px-2"
>
<Trash2 className="w-4 h-4" />
</Button>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={option.disabled === true}
onChange={(event) =>
handleOptionChange(index, "disabled", event.target.checked || undefined)
}
/>
Сделать вариант неактивным
</label>
</div>
))}
</div>
{listScreen.list.options.length === 0 && (
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
Добавьте хотя бы один вариант, чтобы экран работал корректно.
</div>
)}
</div>
{/* Bottom Action Button */}
<div className="space-y-2">
<label className="text-sm font-medium">Bottom Action Button (Optional)</label>
<TextInput
placeholder="Button text (leave empty for auto-behavior)"
value={listScreen.list.bottomActionButton?.text || ""}
onChange={(e) => handleBottomActionButtonChange(e.target.value)}
/>
<div className="text-xs text-muted-foreground">
{listScreen.list.selectionType === "multi"
? "Multi selection always shows a button"
: "Single selection: empty = auto-advance, filled = manual button"}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Кнопка внутри списка</h4>
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-xs">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={Boolean(listScreen.list.bottomActionButton)}
onChange={(event) =>
handleListButtonChange(
event.target.checked
? listScreen.list.bottomActionButton ?? { text: "Продолжить" }
: undefined
)
}
/>
Показать кнопку под списком
</label>
{listScreen.list.bottomActionButton && (
<div className="mt-3 space-y-3">
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Текст кнопки
<TextInput
value={listScreen.list.bottomActionButton.text}
onChange={(event) =>
handleListButtonChange({
...listScreen.list.bottomActionButton!,
text: event.target.value,
})
}
/>
</label>
<div className="grid grid-cols-2 gap-2 text-sm">
<label className="flex items-center gap-2 text-muted-foreground">
<input
type="checkbox"
checked={listScreen.list.bottomActionButton.show !== false}
onChange={(event) =>
handleListButtonChange({
...listScreen.list.bottomActionButton!,
show: event.target.checked,
})
}
/>
Показывать кнопку
</label>
<label className="flex items-center gap-2 text-muted-foreground">
<input
type="checkbox"
checked={listScreen.list.bottomActionButton.disabled === true}
onChange={(event) =>
handleListButtonChange({
...listScreen.list.bottomActionButton!,
disabled: event.target.checked || undefined,
})
}
/>
Выключить по умолчанию
</label>
</div>
</div>
)}
<p className="mt-3 text-xs text-muted-foreground">
Для одиночного выбора пустая кнопка включает авто-переход. Для множественного выбора кнопка отображается всегда.
</p>
</div>
</div>
</div>

View File

@ -1,70 +1,432 @@
"use client";
import { useMemo } from "react";
import { InfoScreenConfig } from "./InfoScreenConfig";
import { DateScreenConfig } from "./DateScreenConfig";
import { CouponScreenConfig } from "./CouponScreenConfig";
import { FormScreenConfig } from "./FormScreenConfig";
import { ListScreenConfig } from "./ListScreenConfig";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { ScreenDefinition, InfoScreenDefinition, DateScreenDefinition, CouponScreenDefinition, FormScreenDefinition, ListScreenDefinition } from "@/lib/funnel/types";
import type {
ScreenDefinition,
InfoScreenDefinition,
DateScreenDefinition,
CouponScreenDefinition,
FormScreenDefinition,
ListScreenDefinition,
TypographyVariant,
BottomActionButtonDefinition,
HeaderDefinition,
} from "@/lib/funnel/types";
const FONT_OPTIONS: TypographyVariant["font"][] = ["manrope", "inter", "geistSans", "geistMono"];
const WEIGHT_OPTIONS: TypographyVariant["weight"][] = [
"regular",
"medium",
"semiBold",
"bold",
"extraBold",
"black",
];
const SIZE_OPTIONS: TypographyVariant["size"][] = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"];
const ALIGN_OPTIONS: TypographyVariant["align"][] = ["left", "center", "right"];
const COLOR_OPTIONS: Exclude<TypographyVariant["color"], undefined>[] = [
"default",
"primary",
"secondary",
"destructive",
"success",
"card",
"accent",
"muted",
];
const RADIUS_OPTIONS: BottomActionButtonDefinition["cornerRadius"][] = ["3xl", "full"];
interface TemplateConfigProps {
screen: BuilderScreen;
onUpdate: (updates: Partial<ScreenDefinition>) => void;
}
interface TypographyControlsProps {
label: string;
value: TypographyVariant | undefined;
onChange: (value: TypographyVariant | undefined) => void;
allowRemove?: boolean;
}
function TypographyControls({ label, value, onChange, allowRemove = false }: TypographyControlsProps) {
const merge = (patch: Partial<TypographyVariant>) => {
const base: TypographyVariant = {
text: value?.text ?? "",
font: value?.font ?? "manrope",
weight: value?.weight ?? "bold",
size: value?.size ?? "lg",
align: value?.align ?? "left",
color: value?.color ?? "default",
...value,
};
onChange({ ...base, ...patch });
};
const handleTextChange = (text: string) => {
if (text.trim() === "" && allowRemove) {
onChange(undefined);
return;
}
merge({ text });
};
return (
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{label}</label>
<TextInput value={value?.text ?? ""} onChange={(event) => handleTextChange(event.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Шрифт</span>
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={value?.font ?? "manrope"}
onChange={(event) => merge({ font: event.target.value as TypographyVariant["font"] })}
>
{FONT_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Насыщенность</span>
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={value?.weight ?? "bold"}
onChange={(event) => merge({ weight: event.target.value as TypographyVariant["weight"] })}
>
{WEIGHT_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Размер</span>
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={value?.size ?? "lg"}
onChange={(event) => merge({ size: event.target.value as TypographyVariant["size"] })}
>
{SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Выравнивание</span>
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={value?.align ?? "left"}
onChange={(event) => merge({ align: event.target.value as TypographyVariant["align"] })}
>
{ALIGN_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Цвет</span>
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={value?.color ?? "default"}
onChange={(event) => merge({ color: event.target.value as TypographyVariant["color"] })}
>
{COLOR_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
{allowRemove && (
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Очистить текст</span>
<button
type="button"
className="rounded-lg border border-border bg-background px-2 py-1 text-left text-xs text-muted-foreground transition hover:border-destructive hover:text-destructive"
onClick={() => onChange(undefined)}
>
Удалить поле
</button>
</label>
)}
</div>
</div>
);
}
interface HeaderControlsProps {
header: HeaderDefinition | undefined;
onChange: (value: HeaderDefinition | undefined) => void;
}
function HeaderControls({ header, onChange }: HeaderControlsProps) {
const activeHeader = header ?? { show: true, showBackButton: true };
const handleProgressChange = (field: "current" | "total" | "value" | "label", rawValue: string) => {
const nextProgress = {
...(activeHeader.progress ?? {}),
[field]: rawValue === "" ? undefined : field === "label" ? rawValue : Number(rawValue),
};
const normalizedProgress = Object.values(nextProgress).every((v) => v === undefined)
? undefined
: nextProgress;
onChange({
...activeHeader,
progress: normalizedProgress,
});
};
const handleToggle = (field: "show" | "showBackButton", checked: boolean) => {
if (field === "show" && !checked) {
onChange({
...activeHeader,
show: false,
showBackButton: false,
progress: undefined,
});
return;
}
onChange({
...activeHeader,
[field]: checked,
});
};
return (
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={activeHeader.show !== false}
onChange={(event) => handleToggle("show", event.target.checked)}
/>
Показывать шапку с прогрессом
</label>
{activeHeader.show !== false && (
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={activeHeader.showBackButton !== false}
onChange={(event) => handleToggle("showBackButton", event.target.checked)}
/>
Показывать кнопку «Назад»
</label>
<div className="grid grid-cols-2 gap-2">
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Текущий шаг</span>
<input
type="number"
min={0}
className="rounded-lg border border-border bg-background px-2 py-1"
value={activeHeader.progress?.current ?? ""}
onChange={(event) => handleProgressChange("current", event.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Всего шагов</span>
<input
type="number"
min={0}
className="rounded-lg border border-border bg-background px-2 py-1"
value={activeHeader.progress?.total ?? ""}
onChange={(event) => handleProgressChange("total", event.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Процент (0-100)</span>
<input
type="number"
min={0}
max={100}
className="rounded-lg border border-border bg-background px-2 py-1"
value={activeHeader.progress?.value ?? ""}
onChange={(event) => handleProgressChange("value", event.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Подпись прогресса</span>
<input
type="text"
className="rounded-lg border border-border bg-background px-2 py-1"
value={activeHeader.progress?.label ?? ""}
onChange={(event) => handleProgressChange("label", event.target.value)}
/>
</label>
</div>
</div>
)}
</div>
);
}
interface ActionButtonControlsProps {
label: string;
value: BottomActionButtonDefinition | undefined;
onChange: (value: BottomActionButtonDefinition | undefined) => void;
}
function ActionButtonControls({ label, value, onChange }: ActionButtonControlsProps) {
const active = useMemo<BottomActionButtonDefinition | undefined>(() => value, [value]);
const isEnabled = Boolean(active);
return (
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isEnabled}
onChange={(event) => {
if (event.target.checked) {
onChange({ text: active?.text || "Продолжить", show: true });
} else {
onChange(undefined);
}
}}
/>
{label}
</label>
{isEnabled && (
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-muted-foreground">Текст кнопки</span>
<TextInput value={active?.text ?? ""} onChange={(event) => onChange({ ...active!, text: event.target.value })} />
</label>
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={active?.show !== false}
onChange={(event) => onChange({ ...active!, show: event.target.checked })}
/>
Показывать кнопку
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={active?.disabled === true}
onChange={(event) => onChange({ ...active!, disabled: event.target.checked || undefined })}
/>
Всегда выключена
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={active?.showGradientBlur !== false}
onChange={(event) => onChange({ ...active!, showGradientBlur: event.target.checked })}
/>
Подсветка фоном
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Скругление</span>
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={active?.cornerRadius ?? ""}
onChange={(event) => onChange({ ...active!, cornerRadius: (event.target.value as BottomActionButtonDefinition["cornerRadius"]) || undefined })}
>
<option value="">Авто</option>
{RADIUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
</div>
)}
</div>
);
}
export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
const { template } = screen;
switch (template) {
case "info":
return (
<InfoScreenConfig
screen={screen as BuilderScreen & { template: "info" }}
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void}
/>
);
case "date":
return (
<DateScreenConfig
screen={screen as BuilderScreen & { template: "date" }}
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
/>
);
case "coupon":
return (
<CouponScreenConfig
screen={screen as BuilderScreen & { template: "coupon" }}
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
/>
);
case "form":
return (
<FormScreenConfig
screen={screen as BuilderScreen & { template: "form" }}
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
/>
);
case "list":
return (
<ListScreenConfig
screen={screen as BuilderScreen & { template: "list" }}
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
/>
);
default:
return (
<div className="space-y-4">
<div className="text-sm text-red-600">
Unknown template type: {template}
</div>
</div>
);
}
const handleTitleChange = (value: TypographyVariant) => {
onUpdate({ title: value });
};
const handleSubtitleChange = (value: TypographyVariant | undefined) => {
onUpdate({ subtitle: value });
};
const handleHeaderChange = (value: HeaderDefinition | undefined) => {
onUpdate({ header: value });
};
const handleButtonChange = (value: BottomActionButtonDefinition | undefined) => {
onUpdate({ bottomActionButton: value });
};
return (
<div className="space-y-8">
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<TypographyControls label="Заголовок" value={screen.title} onChange={handleTitleChange} />
<TypographyControls label="Подзаголовок" value={"subtitle" in screen ? screen.subtitle : undefined} onChange={handleSubtitleChange} allowRemove />
<HeaderControls header={screen.header} onChange={handleHeaderChange} />
<ActionButtonControls label="Показывать основную кнопку" value={screen.bottomActionButton} onChange={handleButtonChange} />
</div>
<div className="space-y-6">
{template === "info" && (
<InfoScreenConfig
screen={screen as BuilderScreen & { template: "info" }}
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void}
/>
)}
{template === "date" && (
<DateScreenConfig
screen={screen as BuilderScreen & { template: "date" }}
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
/>
)}
{template === "coupon" && (
<CouponScreenConfig
screen={screen as BuilderScreen & { template: "coupon" }}
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
/>
)}
{template === "form" && (
<FormScreenConfig
screen={screen as BuilderScreen & { template: "form" }}
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
/>
)}
{template === "list" && (
<ListScreenConfig
screen={screen as BuilderScreen & { template: "list" }}
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
/>
)}
</div>
</div>
);
}

View File

@ -24,6 +24,7 @@ export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderSt
}
export function serializeBuilderState(state: BuilderFunnelState): FunnelDefinition {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const screens = state.screens.map(({ position: _position, ...rest }) => rest);
const meta: FunnelDefinition["meta"] = {
...state.meta,
@ -65,6 +66,7 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderSc
}
export function toBuilderFunnelState(state: BuilderState): BuilderFunnelState {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isDirty: _isDirty, selectedScreenId: _selectedScreenId, ...rest } = state;
return rest;
}