Restore preview and enhance screen reordering
This commit is contained in:
parent
22c6d513af
commit
5db844c111
@ -5,6 +5,7 @@ import { useCallback, useState } from "react";
|
||||
import { BuilderLayout } from "@/components/admin/builder/BuilderLayout";
|
||||
import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar";
|
||||
import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas";
|
||||
import { BuilderPreview } from "@/components/admin/builder/BuilderPreview";
|
||||
import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar";
|
||||
import {
|
||||
BuilderProvider,
|
||||
@ -41,6 +42,7 @@ function BuilderView() {
|
||||
}
|
||||
sidebar={<BuilderSidebar />}
|
||||
canvas={<BuilderCanvas />}
|
||||
preview={<BuilderPreview />}
|
||||
showPreview={showPreview}
|
||||
onTogglePreview={handleTogglePreview}
|
||||
/>
|
||||
|
||||
@ -1,56 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||
import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
||||
import type {
|
||||
ListOptionDefinition,
|
||||
NavigationConditionDefinition,
|
||||
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"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
|
||||
list: "Список",
|
||||
form: "Форма",
|
||||
info: "Инфо",
|
||||
date: "Дата",
|
||||
coupon: "Купон",
|
||||
};
|
||||
|
||||
const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
|
||||
includesAny: "любой из",
|
||||
includesAll: "все из",
|
||||
includesExactly: "точное совпадение",
|
||||
};
|
||||
|
||||
interface TransitionRowProps {
|
||||
type: "default" | "branch" | "end";
|
||||
label: string;
|
||||
targetLabel?: string;
|
||||
targetIndex?: number | null;
|
||||
optionSummaries?: { id: string; label: string }[];
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
function TransitionRow({
|
||||
type,
|
||||
label,
|
||||
targetLabel,
|
||||
targetIndex,
|
||||
optionSummaries = [],
|
||||
operator,
|
||||
}: TransitionRowProps) {
|
||||
const Icon = type === "branch" ? GitBranch : type === "end" ? CircleSlash2 : ArrowDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-start gap-3 rounded-xl border p-3 text-xs transition-colors",
|
||||
type === "branch"
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border/60 bg-background/90"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full",
|
||||
type === "branch" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-wide",
|
||||
type === "branch" ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{operator && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||
{operator}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{optionSummaries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{optionSummaries.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className="rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
{type === "end" ? (
|
||||
<span className="text-muted-foreground">Завершение воронки</span>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
{typeof targetIndex === "number" && (
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
#{targetIndex + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{targetLabel ?? "Не выбрано"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
const handleDragOverCard = useCallback((event: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
event.preventDefault();
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragStateRef.current.currentIndex = targetIndex;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const offsetY = event.clientY - rect.top;
|
||||
const nextIndex = offsetY > rect.height / 2 ? index + 1 : index;
|
||||
setDropIndex(nextIndex);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!dragStateRef.current) return;
|
||||
const handleDragOverList = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.target === event.currentTarget) {
|
||||
setDropIndex(screens.length);
|
||||
}
|
||||
},
|
||||
[screens.length]
|
||||
);
|
||||
|
||||
const { dragStartIndex, currentIndex } = dragStateRef.current;
|
||||
const finalizeDrop = useCallback(
|
||||
(insertionIndex: number | null) => {
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragStartIndex !== currentIndex) {
|
||||
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: currentIndex,
|
||||
toIndex: targetIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
}, [dispatch]);
|
||||
setDropIndex(null);
|
||||
},
|
||||
[dispatch, screens.length]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
finalizeDrop(dropIndex);
|
||||
},
|
||||
[dropIndex, finalizeDrop]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
setDropIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectScreen = useCallback(
|
||||
(screenId: string) => {
|
||||
@ -63,130 +325,171 @@ 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">
|
||||
<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="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="flex items-center gap-6"
|
||||
style={{ minWidth: screens.length * (CARD_WIDTH + CARD_GAP) }}
|
||||
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;
|
||||
|
||||
const isLast = index === screens.length - 1;
|
||||
const defaultTargetIndex = defaultNext
|
||||
? screens.findIndex((candidate) => candidate.id === defaultNext)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={screen.id}
|
||||
className="relative flex-shrink-0"
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(screen.id, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div key={screen.id} className="relative">
|
||||
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
|
||||
<div className="flex items-start gap-4 md:gap-6">
|
||||
<div className="relative hidden w-8 flex-shrink-0 md:flex md:flex-col md:items-center">
|
||||
<span className="mt-1 h-3 w-3 rounded-full border-2 border-background bg-primary shadow" />
|
||||
{!isLast && (
|
||||
<div className="mt-2 flex h-full flex-col items-center">
|
||||
<div className="flex-1 w-px bg-gradient-to-b from-primary/40 via-border/40 to-transparent" />
|
||||
<ArrowDown className="mt-1 h-4 w-4 text-border/70" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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"
|
||||
"relative 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"
|
||||
)}
|
||||
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
|
||||
draggable
|
||||
onDragStart={(event) => handleDragStart(event, screen.id, index)}
|
||||
onDragOver={(event) => handleDragOverCard(event, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
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">
|
||||
<span className="absolute right-5 top-5 inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{TEMPLATE_TITLES[screen.template] ?? screen.template}
|
||||
</span>
|
||||
<div className="pr-28">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold 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>
|
||||
|
||||
{/* Screen Content */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold leading-5 text-foreground mb-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
#{screen.id}
|
||||
</span>
|
||||
<span className="max-h-14 overflow-hidden break-words text-lg font-semibold leading-tight text-foreground">
|
||||
{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>
|
||||
</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>
|
||||
))}
|
||||
{screen.list.options.length > 2 && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
+{screen.list.options.length - 2} ещё
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
{screen.subtitle?.text && (
|
||||
<p className="mt-3 max-h-12 overflow-hidden text-sm leading-snug text-muted-foreground">
|
||||
{screen.subtitle.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
<TemplateSummary screen={screen} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
|
||||
<div className="h-px flex-1 bg-border/60" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<TransitionRow
|
||||
type={defaultNext ? "default" : "end"}
|
||||
label={defaultNext ? "По умолчанию" : "Завершение"}
|
||||
targetLabel={defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : undefined}
|
||||
targetIndex={defaultNext && defaultTargetIndex !== -1 ? defaultTargetIndex : null}
|
||||
/>
|
||||
|
||||
{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),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const operatorKey = condition?.operator as
|
||||
| Exclude<NavigationConditionDefinition["operator"], undefined>
|
||||
| undefined;
|
||||
const operatorLabel = operatorKey
|
||||
? OPERATOR_LABELS[operatorKey] ?? operatorKey
|
||||
: undefined;
|
||||
|
||||
const ruleTargetIndex = screens.findIndex(
|
||||
(candidate) => candidate.id === rule.nextScreenId
|
||||
);
|
||||
const ruleTargetLabel = screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId;
|
||||
|
||||
return (
|
||||
<TransitionRow
|
||||
key={`${ruleIndex}-${rule.nextScreenId}`}
|
||||
type="branch"
|
||||
label="Вариативность"
|
||||
targetLabel={ruleTargetLabel}
|
||||
targetIndex={ruleTargetIndex !== -1 ? ruleTargetIndex : null}
|
||||
optionSummaries={optionSummaries}
|
||||
operator={operatorLabel}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,13 +270,61 @@ export function BuilderSidebar() {
|
||||
dispatch({ type: "remove-screen", payload: { screenId } });
|
||||
};
|
||||
|
||||
// Показываем настройки воронки, если экран не выбран
|
||||
if (!selectedScreen) {
|
||||
const handleTemplateUpdate = (screenId: string, updates: Partial<ScreenDefinition>) => {
|
||||
dispatch({
|
||||
type: "update-screen",
|
||||
payload: {
|
||||
screenId,
|
||||
screen: updates as Partial<BuilderScreen>,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<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>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
{activeTab === "funnel" ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Section title="Валидация">
|
||||
<ValidationSummary />
|
||||
<Section title="Валидация" description="Проверка общих настроек">
|
||||
<ValidationSummary issues={validation.issues} />
|
||||
</Section>
|
||||
|
||||
<Section title="Настройки воронки" description="Общие параметры">
|
||||
@ -395,149 +359,54 @@ export function BuilderSidebar() {
|
||||
</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);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{selectedScreenIsListType && (
|
||||
<Section title="Варианты ответа" description="Настройки опций">
|
||||
<div className="flex flex-col gap-3">
|
||||
<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">
|
||||
<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)}
|
||||
>
|
||||
Добавить
|
||||
</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)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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="Переходы между экранами">
|
||||
<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
|
||||
@ -555,7 +424,6 @@ export function BuilderSidebar() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{selectedScreenIsListType && (
|
||||
@ -613,7 +481,7 @@ export function BuilderSidebar() {
|
||||
<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) => {
|
||||
{selectedScreen.list.options.map((option) => {
|
||||
const condition = rule.conditions[0];
|
||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||
return (
|
||||
@ -656,12 +524,12 @@ export function BuilderSidebar() {
|
||||
)}
|
||||
|
||||
<Section title="Валидация экрана" description="Проверка корректности настроек">
|
||||
<ValidationSummary />
|
||||
<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 className="mb-3 text-sm text-muted-foreground">
|
||||
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
|
||||
</p>
|
||||
<Button
|
||||
@ -675,6 +543,12 @@ export function BuilderSidebar() {
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,178 +12,95 @@ 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,
|
||||
placeholder="-50% на первый заказ"
|
||||
value={couponScreen.coupon?.offer?.title?.text ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCouponUpdate("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",
|
||||
...(couponScreen.coupon.offer?.title ?? {}),
|
||||
text: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Offer Description</label>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
|
||||
Подзаголовок/описание
|
||||
<TextInput
|
||||
placeholder="HAIR LOSS SPECIALIST"
|
||||
value={couponScreen.coupon?.offer?.description?.text || ""}
|
||||
onChange={(e) => onUpdate({
|
||||
coupon: {
|
||||
...couponScreen.coupon,
|
||||
offer: {
|
||||
...couponScreen.coupon?.offer,
|
||||
placeholder="Персональная акция только сегодня"
|
||||
value={couponScreen.coupon?.offer?.description?.text ?? ""}
|
||||
onChange={(event) =>
|
||||
handleCouponUpdate("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",
|
||||
...(couponScreen.coupon.offer?.description ?? {}),
|
||||
text: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</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",
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</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,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
Show navigation bar
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{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,
|
||||
<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 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>
|
||||
);
|
||||
|
||||
@ -12,176 +12,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>
|
||||
<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
|
||||
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
|
||||
})}
|
||||
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>
|
||||
|
||||
<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
|
||||
placeholder="🔒"
|
||||
value={dateScreen.infoMessage.icon}
|
||||
onChange={(e) => onUpdate({
|
||||
infoMessage: {
|
||||
text: dateScreen.infoMessage?.text || "",
|
||||
icon: e.target.value,
|
||||
}
|
||||
})}
|
||||
value={dateScreen.infoMessage.icon ?? ""}
|
||||
onChange={(event) => handleInfoMessageChange("icon", 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
@ -19,17 +19,26 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
|
||||
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],
|
||||
});
|
||||
};
|
||||
|
||||
@ -39,153 +48,166 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
|
||||
};
|
||||
|
||||
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>
|
||||
<TextInput
|
||||
placeholder="Enter placeholder"
|
||||
value={field.placeholder || ""}
|
||||
onChange={(e) => updateField(index, { placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required || false}
|
||||
onChange={(e) => updateField(index, { required: e.target.checked })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
|
||||
{field.maxLength && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Max Length:</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
Placeholder
|
||||
<TextInput
|
||||
value={field.placeholder ?? ""}
|
||||
onChange={(event) => updateField(index, { placeholder: event.target.value })}
|
||||
/>
|
||||
</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={(event) => updateField(index, { required: event.target.checked })}
|
||||
/>
|
||||
Обязательно для заполнения
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Максимальная длина
|
||||
<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 })}
|
||||
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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!formScreen.fields || formScreen.fields.length === 0) && (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
No fields added yet. Click "Add Field" 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>
|
||||
<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="Continue"
|
||||
value={formScreen.bottomActionButton?.text || ""}
|
||||
onChange={(e) => onUpdate({
|
||||
bottomActionButton: {
|
||||
text: e.target.value || "Continue",
|
||||
}
|
||||
})}
|
||||
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>
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
@ -12,144 +12,90 @@ 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>
|
||||
<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="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",
|
||||
}
|
||||
})}
|
||||
placeholder="Введите пояснение для пользователя"
|
||||
value={infoScreen.description?.text ?? ""}
|
||||
onChange={(event) => handleDescriptionChange(event.target.value)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</label>
|
||||
</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">
|
||||
<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 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",
|
||||
}
|
||||
})}
|
||||
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">Image</option>
|
||||
<option value="image">Изображение</option>
|
||||
</select>
|
||||
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||
Размер
|
||||
<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",
|
||||
}
|
||||
})}
|
||||
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">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
<option value="xl">Extra Large</option>
|
||||
<option value="sm">Маленький</option>
|
||||
<option value="md">Средний</option>
|
||||
<option value="lg">Большой</option>
|
||||
<option value="xl">Огромный</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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" ? "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
|
||||
})}
|
||||
placeholder={infoScreen.icon?.type === "image" ? "https://..." : "Например, ✨"}
|
||||
value={infoScreen.icon?.value ?? ""}
|
||||
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
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: newOptions,
|
||||
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({
|
||||
const nextOptions = [
|
||||
...listScreen.list.options,
|
||||
{
|
||||
id: `option-${Date.now()}`,
|
||||
label: "New Option",
|
||||
});
|
||||
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"
|
||||
<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")}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add Option
|
||||
Один ответ
|
||||
</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>
|
||||
<div className="flex-[2]">
|
||||
<TextInput
|
||||
placeholder="Option Label"
|
||||
value={option.label}
|
||||
onChange={(e) => handleOptionChange(index, "label", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveOption(index)}
|
||||
className="h-8 px-2"
|
||||
<div
|
||||
key={option.id}
|
||||
className="space-y-3 rounded-xl border border-border/70 bg-muted/10 p-4"
|
||||
>
|
||||
<Trash2 className="w-4 h-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="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
|
||||
value={option.label}
|
||||
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>
|
||||
|
||||
<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="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
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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"}
|
||||
Показать кнопку под списком
|
||||
</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>
|
||||
|
||||
@ -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":
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
)}
|
||||
{template === "date" && (
|
||||
<DateScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "date" }}
|
||||
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
|
||||
/>
|
||||
);
|
||||
|
||||
case "coupon":
|
||||
return (
|
||||
)}
|
||||
{template === "coupon" && (
|
||||
<CouponScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "coupon" }}
|
||||
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
|
||||
/>
|
||||
);
|
||||
|
||||
case "form":
|
||||
return (
|
||||
)}
|
||||
{template === "form" && (
|
||||
<FormScreenConfig
|
||||
screen={screen as BuilderScreen & { template: "form" }}
|
||||
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
|
||||
/>
|
||||
);
|
||||
|
||||
case "list":
|
||||
return (
|
||||
)}
|
||||
{template === "list" && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,13 +221,103 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat
|
||||
}
|
||||
case "reorder-screens": {
|
||||
const { fromIndex, toIndex } = action.payload;
|
||||
const newScreens = [...state.screens];
|
||||
const [removed] = newScreens.splice(fromIndex, 1);
|
||||
newScreens.splice(toIndex, 0, removed);
|
||||
const previousScreens = state.screens;
|
||||
const newScreens = [...previousScreens];
|
||||
const [movedScreen] = newScreens.splice(fromIndex, 1);
|
||||
newScreens.splice(toIndex, 0, movedScreen);
|
||||
|
||||
const previousSequentialNext = new Map<string, string | undefined>();
|
||||
const previousIndexMap = new Map<string, number>();
|
||||
|
||||
previousScreens.forEach((screen, index) => {
|
||||
previousSequentialNext.set(screen.id, previousScreens[index + 1]?.id);
|
||||
previousIndexMap.set(screen.id, index);
|
||||
});
|
||||
|
||||
const totalScreens = newScreens.length;
|
||||
|
||||
const rewiredScreens = newScreens.map((screen, index) => {
|
||||
const prevIndex = previousIndexMap.get(screen.id);
|
||||
const prevSequential = previousSequentialNext.get(screen.id);
|
||||
const nextSequential = newScreens[index + 1]?.id;
|
||||
const navigation = screen.navigation;
|
||||
const hasRules = Boolean(navigation?.rules && navigation.rules.length > 0);
|
||||
|
||||
let defaultNext = navigation?.defaultNextScreenId;
|
||||
if (!hasRules) {
|
||||
if (!defaultNext || defaultNext === prevSequential) {
|
||||
defaultNext = nextSequential;
|
||||
}
|
||||
} else if (defaultNext === prevSequential) {
|
||||
defaultNext = nextSequential;
|
||||
}
|
||||
|
||||
const updatedNavigation = (() => {
|
||||
if ((navigation?.rules && navigation.rules.length > 0) || defaultNext) {
|
||||
return {
|
||||
...(navigation?.rules ? { rules: navigation.rules } : {}),
|
||||
...(defaultNext ? { defaultNextScreenId: defaultNext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
let updatedHeader = screen.header;
|
||||
if (screen.header?.progress) {
|
||||
const progress = { ...screen.header.progress };
|
||||
const previousProgress = prevIndex !== undefined ? previousScreens[prevIndex]?.header?.progress : undefined;
|
||||
|
||||
if (
|
||||
typeof progress.current === "number" &&
|
||||
prevIndex !== undefined &&
|
||||
(progress.current === prevIndex + 1 || previousProgress?.current === prevIndex + 1)
|
||||
) {
|
||||
progress.current = index + 1;
|
||||
}
|
||||
|
||||
if (typeof progress.total === "number") {
|
||||
const previousTotal = previousProgress?.total ?? progress.total;
|
||||
if (previousTotal === previousScreens.length) {
|
||||
progress.total = totalScreens;
|
||||
}
|
||||
}
|
||||
|
||||
updatedHeader = {
|
||||
...screen.header,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
const nextScreen: BuilderScreen = {
|
||||
...screen,
|
||||
...(updatedHeader ? { header: updatedHeader } : {}),
|
||||
};
|
||||
|
||||
if (updatedNavigation) {
|
||||
nextScreen.navigation = updatedNavigation;
|
||||
} else if ("navigation" in nextScreen) {
|
||||
delete nextScreen.navigation;
|
||||
}
|
||||
|
||||
return nextScreen;
|
||||
});
|
||||
|
||||
const nextMeta = {
|
||||
...state.meta,
|
||||
firstScreenId: rewiredScreens[0]?.id,
|
||||
};
|
||||
|
||||
const nextSelectedScreenId =
|
||||
movedScreen && state.selectedScreenId === movedScreen.id
|
||||
? movedScreen.id
|
||||
: state.selectedScreenId;
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: newScreens,
|
||||
screens: rewiredScreens,
|
||||
meta: nextMeta,
|
||||
selectedScreenId: nextSelectedScreenId,
|
||||
});
|
||||
}
|
||||
case "set-selected-screen": {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user