Enhance funnel builder configuration UX
This commit is contained in:
parent
22c6d513af
commit
fcfa97c8f7
@ -1,56 +1,212 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useRef } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
|
||||||
import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
import type { ListOptionDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const CARD_WIDTH = 280;
|
function DropIndicator({ isActive }: { isActive: boolean }) {
|
||||||
const CARD_HEIGHT = 200;
|
return (
|
||||||
const CARD_GAP = 24;
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-4 h-9 rounded-xl border-2 border-dashed border-primary/50 bg-primary/10 transition-all",
|
||||||
|
isActive ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateSummary({ screen }: { screen: ScreenDefinition }) {
|
||||||
|
switch (screen.template) {
|
||||||
|
case "list": {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-xs text-muted-foreground">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
|
||||||
|
Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"}
|
||||||
|
</span>
|
||||||
|
{screen.list.autoAdvance && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||||
|
авто переход
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{screen.list.bottomActionButton?.text && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
|
||||||
|
{screen.list.bottomActionButton.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Варианты ({screen.list.options.length})</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{screen.list.options.map((option) => (
|
||||||
|
<span
|
||||||
|
key={option.id}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-primary/5 px-2 py-1 text-[11px] text-primary"
|
||||||
|
>
|
||||||
|
{option.emoji && <span className="text-base leading-none">{option.emoji}</span>}
|
||||||
|
<span className="font-medium">{option.label}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "form": {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-xs text-muted-foreground">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-primary">
|
||||||
|
Полей: {screen.fields.length}
|
||||||
|
</span>
|
||||||
|
{screen.bottomActionButton?.text && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
|
||||||
|
{screen.bottomActionButton.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{screen.validationMessages && (
|
||||||
|
<div className="rounded-lg border border-border/50 bg-background/80 p-2">
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Настроены пользовательские сообщения валидации
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "coupon": {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-xs text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Промо:</span> {screen.coupon.promoCode.text}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground/80">{screen.coupon.offer.title.text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "date": {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium">Формат даты:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{screen.dateInput.monthLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.monthLabel}</span>}
|
||||||
|
{screen.dateInput.dayLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.dayLabel}</span>}
|
||||||
|
{screen.dateInput.yearLabel && <span className="rounded bg-muted px-2 py-0.5">{screen.dateInput.yearLabel}</span>}
|
||||||
|
</div>
|
||||||
|
{screen.dateInput.validationMessage && (
|
||||||
|
<p className="text-[11px] text-destructive">{screen.dateInput.validationMessage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "info": {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-xs text-muted-foreground">
|
||||||
|
{screen.description?.text && <p>{screen.description.text}</p>}
|
||||||
|
{screen.icon?.value && (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
|
||||||
|
<span className="text-base">{screen.icon.value}</span>
|
||||||
|
<span className="text-[11px] uppercase text-muted-foreground">Иконка</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionLabel(options: ListOptionDefinition[], optionId: string): string {
|
||||||
|
const option = options.find((item) => item.id === optionId);
|
||||||
|
return option ? option.label : optionId;
|
||||||
|
}
|
||||||
|
|
||||||
export function BuilderCanvas() {
|
export function BuilderCanvas() {
|
||||||
const { screens, selectedScreenId } = useBuilderState();
|
const { screens, selectedScreenId } = useBuilderState();
|
||||||
const dispatch = useBuilderDispatch();
|
const dispatch = useBuilderDispatch();
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null);
|
||||||
const dragStateRef = useRef<{ screenId: string; dragStartIndex: number; currentIndex: number } | null>(null);
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const handleDragStart = useCallback((screenId: string, index: number) => {
|
const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => {
|
||||||
dragStateRef.current = {
|
event.dataTransfer.effectAllowed = "move";
|
||||||
screenId,
|
event.dataTransfer.setData("text/plain", screenId);
|
||||||
dragStartIndex: index,
|
dragStateRef.current = { screenId, dragStartIndex: index };
|
||||||
currentIndex: index,
|
setDropIndex(index);
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent, targetIndex: number) => {
|
const handleDragOverCard = useCallback((event: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
if (!dragStateRef.current) return;
|
if (!dragStateRef.current) {
|
||||||
|
return;
|
||||||
dragStateRef.current.currentIndex = targetIndex;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!dragStateRef.current) return;
|
|
||||||
|
|
||||||
const { dragStartIndex, currentIndex } = dragStateRef.current;
|
|
||||||
|
|
||||||
if (dragStartIndex !== currentIndex) {
|
|
||||||
dispatch({
|
|
||||||
type: "reorder-screens",
|
|
||||||
payload: {
|
|
||||||
fromIndex: dragStartIndex,
|
|
||||||
toIndex: currentIndex,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const offsetY = event.clientY - rect.top;
|
||||||
|
const nextIndex = offsetY > rect.height / 2 ? index + 1 : index;
|
||||||
|
setDropIndex(nextIndex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOverList = useCallback(
|
||||||
|
(event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragStateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
setDropIndex(screens.length);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[screens.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalizeDrop = useCallback(
|
||||||
|
(insertionIndex: number | null) => {
|
||||||
|
if (!dragStateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dragStartIndex } = dragStateRef.current;
|
||||||
|
const boundedIndex = Math.max(0, Math.min(insertionIndex ?? dragStartIndex, screens.length));
|
||||||
|
let targetIndex = boundedIndex;
|
||||||
|
|
||||||
|
if (targetIndex > dragStartIndex) {
|
||||||
|
targetIndex -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragStartIndex !== targetIndex) {
|
||||||
|
dispatch({
|
||||||
|
type: "reorder-screens",
|
||||||
|
payload: {
|
||||||
|
fromIndex: dragStartIndex,
|
||||||
|
toIndex: targetIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dragStateRef.current = null;
|
||||||
|
setDropIndex(null);
|
||||||
|
},
|
||||||
|
[dispatch, screens.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
finalizeDrop(dropIndex);
|
||||||
|
},
|
||||||
|
[dropIndex, finalizeDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
}, [dispatch]);
|
setDropIndex(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSelectScreen = useCallback(
|
const handleSelectScreen = useCallback(
|
||||||
(screenId: string) => {
|
(screenId: string) => {
|
||||||
@ -63,128 +219,164 @@ export function BuilderCanvas() {
|
|||||||
dispatch({ type: "add-screen" });
|
dispatch({ type: "add-screen" });
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
// Helper functions for type checking
|
const screenTitleMap = useMemo(() => {
|
||||||
const hasSubtitle = (screen: ScreenDefinition): screen is ScreenDefinition & { subtitle: { text: string } } => {
|
return screens.reduce<Record<string, string>>((accumulator, screen) => {
|
||||||
return 'subtitle' in screen && screen.subtitle !== undefined;
|
accumulator[screen.id] = screen.title.text || screen.id;
|
||||||
};
|
return accumulator;
|
||||||
|
}, {});
|
||||||
const isListScreen = (screen: ScreenDefinition): screen is ListScreenDefinition => {
|
}, [screens]);
|
||||||
return screen.template === 'list';
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="h-full w-full overflow-auto bg-slate-50 dark:bg-slate-900">
|
<div className="flex h-full flex-col">
|
||||||
{/* Header with Add Button */}
|
|
||||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-border/60 bg-background/95 px-6 py-4 backdrop-blur-sm">
|
<div className="sticky top-0 z-30 flex items-center justify-between border-b border-border/60 bg-background/95 px-6 py-4 backdrop-blur-sm">
|
||||||
<h2 className="text-lg font-semibold">Экраны воронки</h2>
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Экраны воронки</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Перетаскивайте, чтобы поменять порядок и связь экранов.</p>
|
||||||
|
</div>
|
||||||
<Button variant="outline" onClick={handleAddScreen}>
|
<Button variant="outline" onClick={handleAddScreen}>
|
||||||
<span className="mr-2">+</span>
|
<span className="mr-2 text-lg leading-none">+</span>
|
||||||
Добавить экран
|
Добавить экран
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Linear Screen Layout */}
|
<div className="relative flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-900">
|
||||||
<div className="relative p-6">
|
<div className="relative mx-auto max-w-4xl">
|
||||||
<div
|
<div className="absolute left-6 top-0 bottom-0 hidden w-px bg-border md:block" aria-hidden />
|
||||||
className="flex items-center gap-6"
|
<div
|
||||||
style={{ minWidth: screens.length * (CARD_WIDTH + CARD_GAP) }}
|
className="space-y-6 pl-0 md:pl-12"
|
||||||
>
|
onDragOver={handleDragOverList}
|
||||||
{screens.map((screen, index) => {
|
onDrop={handleDrop}
|
||||||
const isSelected = screen.id === selectedScreenId;
|
>
|
||||||
return (
|
{screens.map((screen, index) => {
|
||||||
<div
|
const isSelected = screen.id === selectedScreenId;
|
||||||
key={screen.id}
|
const isDropBefore = dropIndex === index;
|
||||||
className="relative flex-shrink-0"
|
const isDropAfter = dropIndex === screens.length && index === screens.length - 1;
|
||||||
draggable
|
const rules = screen.navigation?.rules ?? [];
|
||||||
onDragStart={() => handleDragStart(screen.id, index)}
|
const defaultNext = screen.navigation?.defaultNextScreenId;
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer rounded-2xl border border-border/70 bg-background p-4 shadow-sm transition-all hover:shadow-md",
|
|
||||||
isSelected
|
|
||||||
? "ring-2 ring-primary border-primary/50"
|
|
||||||
: "hover:border-primary/40",
|
|
||||||
"w-[280px] h-[200px] flex flex-col"
|
|
||||||
)}
|
|
||||||
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
|
|
||||||
onClick={() => handleSelectScreen(screen.id)}
|
|
||||||
>
|
|
||||||
{/* Screen Header */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium uppercase text-muted-foreground">#{screen.id}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground px-2 py-1 rounded bg-muted/50">
|
|
||||||
{screen.template}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Screen Content */}
|
return (
|
||||||
<div className="flex-1">
|
<div key={screen.id} className="relative">
|
||||||
<h3 className="text-base font-semibold leading-5 text-foreground mb-2">
|
<div className="absolute left-0 top-6 hidden h-[calc(100%-1.5rem)] w-px bg-border md:block" aria-hidden />
|
||||||
{screen.title.text || "Без названия"}
|
{isDropBefore && <DropIndicator isActive={isDropBefore} />}
|
||||||
</h3>
|
<div className="flex items-start gap-4 md:gap-6">
|
||||||
{hasSubtitle(screen) && (
|
<div className="relative mt-1 hidden h-3 w-3 flex-shrink-0 rounded-full border-2 border-background bg-primary shadow md:block" />
|
||||||
<p className="text-xs text-muted-foreground mb-3">{screen.subtitle.text}</p>
|
<div
|
||||||
)}
|
className={cn(
|
||||||
|
"flex-1 cursor-grab rounded-2xl border border-border/70 bg-background/95 p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md",
|
||||||
{/* List Screen Details */}
|
isSelected && "border-primary/50 ring-2 ring-primary",
|
||||||
{isListScreen(screen) && (
|
dragStateRef.current?.screenId === screen.id && "cursor-grabbing opacity-90"
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
)}
|
||||||
<div className="flex items-center justify-between">
|
draggable
|
||||||
<span>Тип выбора:</span>
|
onDragStart={(event) => handleDragStart(event, screen.id, index)}
|
||||||
<span className="font-medium text-foreground">
|
onDragOver={(event) => handleDragOverCard(event, index)}
|
||||||
{screen.list.selectionType === "single" ? "Single" : "Multi"}
|
onDragEnd={handleDragEnd}
|
||||||
</span>
|
onClick={() => handleSelectScreen(screen.id)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase text-muted-foreground">#{screen.id}</span>
|
||||||
|
<span className="text-lg font-semibold text-foreground">
|
||||||
|
{screen.title.text || "Без названия"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<span className="inline-flex items-center rounded-full bg-muted px-3 py-1 text-xs font-medium uppercase text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">Опции: {screen.list.options.length}</span>
|
{screen.template}
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
</span>
|
||||||
{screen.list.options.slice(0, 2).map((option) => (
|
</div>
|
||||||
<span key={option.id} className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[10px]">
|
|
||||||
{option.label}
|
{screen.subtitle?.text && (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">{screen.subtitle.text}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<TemplateSummary screen={screen} />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase text-muted-foreground">Переходы</span>
|
||||||
|
<div className="h-px flex-1 bg-border/60" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-col gap-1 rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex w-fit items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||||
|
По умолчанию
|
||||||
</span>
|
</span>
|
||||||
))}
|
<span className="text-sm text-foreground">
|
||||||
{screen.list.options.length > 2 && (
|
{defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : "Воронка завершится"}
|
||||||
<span className="text-muted-foreground text-[10px]">
|
|
||||||
+{screen.list.options.length - 2} ещё
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{rules.map((rule, ruleIndex) => {
|
||||||
|
const condition = rule.conditions[0];
|
||||||
|
const optionSummaries =
|
||||||
|
screen.template === "list" && condition?.optionIds
|
||||||
|
? condition.optionIds.map((optionId) => ({
|
||||||
|
id: optionId,
|
||||||
|
label: getOptionLabel(screen.list.options, optionId),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${ruleIndex}-${rule.nextScreenId}`}
|
||||||
|
className="flex flex-col gap-2 rounded-xl border border-primary/30 bg-primary/5 p-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">
|
||||||
|
Вариативность
|
||||||
|
</span>
|
||||||
|
{condition?.operator && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary">
|
||||||
|
{condition.operator}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{optionSummaries.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{optionSummaries.map((option) => (
|
||||||
|
<span
|
||||||
|
key={option.id}
|
||||||
|
className="inline-flex items-center rounded-lg bg-background px-2 py-1 text-[11px] text-foreground"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-sm text-foreground">
|
||||||
|
→ {screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
{isDropAfter && <DropIndicator isActive={isDropAfter} />}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Arrow to next screen */}
|
{screens.length === 0 && (
|
||||||
{index < screens.length - 1 && (
|
<div className="rounded-2xl border border-dashed border-border/60 bg-background/80 p-8 text-center text-sm text-muted-foreground">
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button variant="ghost" onClick={handleAddScreen} className="w-full justify-center">
|
||||||
|
+ Добавить экран
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,22 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { TemplateConfig } from "@/components/admin/builder/templates";
|
||||||
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
|
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
|
||||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||||
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
|
import type { NavigationRuleDefinition, ScreenDefinition } from "@/lib/funnel/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { validateBuilderState } from "@/lib/admin/builder/validation";
|
import { validateBuilderState } from "@/lib/admin/builder/validation";
|
||||||
|
|
||||||
// Type guards для безопасной работы с разными типами экранов
|
type ValidationIssues = ReturnType<typeof validateBuilderState>["issues"];
|
||||||
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 hasSubtitle(screen: BuilderScreen): screen is BuilderScreen & { subtitle?: { text: string; color?: string; font?: string; } } {
|
function isListScreen(
|
||||||
return "subtitle" in screen;
|
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({
|
function Section({
|
||||||
@ -26,7 +31,7 @@ function Section({
|
|||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
@ -39,12 +44,8 @@ function Section({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ValidationSummary({ issues }: { issues: ValidationIssues }) {
|
||||||
function ValidationSummary() {
|
if (issues.length === 0) {
|
||||||
const state = useBuilderState();
|
|
||||||
const validation = useMemo(() => validateBuilderState(state), [state]);
|
|
||||||
|
|
||||||
if (validation.issues.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border/50 bg-background/60 p-3 text-xs text-muted-foreground">
|
<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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{validation.issues.map((issue, index) => (
|
{issues.map((issue, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${issue.severity}-${issue.screenId ?? "root"}-${issue.optionId ?? "all"}-${index}`}
|
key={`${issue.severity}-${issue.screenId ?? "root"}-${issue.optionId ?? "all"}-${index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -81,9 +82,31 @@ export function BuilderSidebar() {
|
|||||||
const dispatch = useBuilderDispatch();
|
const dispatch = useBuilderDispatch();
|
||||||
const selectedScreen = useBuilderSelectedScreen();
|
const selectedScreen = useBuilderSelectedScreen();
|
||||||
|
|
||||||
const screenOptions = useMemo(() => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), [
|
const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel");
|
||||||
state.screens,
|
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) => {
|
const handleMetaChange = (field: keyof typeof state.meta, value: string) => {
|
||||||
dispatch({ type: "set-meta", payload: { [field]: value } });
|
dispatch({ type: "set-meta", payload: { [field]: value } });
|
||||||
@ -96,32 +119,6 @@ export function BuilderSidebar() {
|
|||||||
const getScreenById = (screenId: string): BuilderScreen | undefined =>
|
const getScreenById = (screenId: string): BuilderScreen | undefined =>
|
||||||
state.screens.find((item) => item.id === screenId);
|
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 = (
|
const updateNavigation = (
|
||||||
screen: BuilderScreen,
|
screen: BuilderScreen,
|
||||||
navigationUpdates: Partial<BuilderScreen["navigation"]> = {}
|
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 handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => {
|
||||||
const screen = getScreenById(screenId);
|
const screen = getScreenById(screenId);
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
@ -332,7 +245,10 @@ export function BuilderSidebar() {
|
|||||||
optionIds: screen.list.options.slice(0, 1).map((option) => option.id),
|
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 });
|
updateNavigation(screen, { rules: nextRules });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -354,326 +270,284 @@ export function BuilderSidebar() {
|
|||||||
dispatch({ type: "remove-screen", payload: { screenId } });
|
dispatch({ type: "remove-screen", payload: { screenId } });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Показываем настройки воронки, если экран не выбран
|
const handleTemplateUpdate = (screenId: string, updates: Partial<ScreenDefinition>) => {
|
||||||
if (!selectedScreen) {
|
dispatch({
|
||||||
return (
|
type: "update-screen",
|
||||||
<div className="p-6">
|
payload: {
|
||||||
<div className="flex flex-col gap-6">
|
screenId,
|
||||||
<Section title="Валидация">
|
screen: updates as Partial<BuilderScreen>,
|
||||||
<ValidationSummary />
|
},
|
||||||
</Section>
|
});
|
||||||
|
};
|
||||||
|
|
||||||
<Section title="Настройки воронки" description="Общие параметры">
|
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
|
||||||
<TextInput
|
|
||||||
label="ID воронки"
|
|
||||||
value={state.meta.id}
|
|
||||||
onChange={(event) => handleMetaChange("id", event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Название"
|
|
||||||
value={state.meta.title ?? ""}
|
|
||||||
onChange={(event) => handleMetaChange("title", event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Описание"
|
|
||||||
value={state.meta.description ?? ""}
|
|
||||||
onChange={(event) => handleMetaChange("description", event.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="flex flex-col gap-2">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
|
|
||||||
<select
|
|
||||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
|
||||||
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
|
|
||||||
onChange={(event) => handleFirstScreenChange(event.target.value)}
|
|
||||||
>
|
|
||||||
{screenOptions.map((screen) => (
|
|
||||||
<option key={screen.id} value={screen.id}>
|
|
||||||
{screen.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Экраны" description="Управление экранами">
|
|
||||||
<div className="rounded-lg border border-border/60 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
Выберите экран на канвасе для редактирования его настроек.
|
|
||||||
</p>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Всего экранов: <span className="font-medium">{state.screens.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показываем настройки выбранного экрана
|
|
||||||
const selectedScreenIsListType = isListScreen(selectedScreen);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex flex-col gap-6">
|
<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">
|
||||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||||||
<div className="flex items-center justify-between mb-2">
|
Режим редактирования
|
||||||
<div className="flex items-center gap-3">
|
</span>
|
||||||
<div className="w-2 h-2 rounded-full bg-primary"></div>
|
<h1 className="text-lg font-semibold">Настройки</h1>
|
||||||
<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>
|
</div>
|
||||||
|
<div className="mt-4 flex rounded-lg bg-muted/40 p-1 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-md px-3 py-1.5 transition",
|
||||||
|
activeTab === "funnel"
|
||||||
|
? "bg-background text-foreground shadow"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveTab("funnel")}
|
||||||
|
>
|
||||||
|
Воронка
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-md px-3 py-1.5 transition",
|
||||||
|
activeTab === "screen"
|
||||||
|
? "bg-background text-foreground shadow"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
!selectedScreen && "cursor-not-allowed opacity-60"
|
||||||
|
)}
|
||||||
|
onClick={() => selectedScreen && setActiveTab("screen")}
|
||||||
|
>
|
||||||
|
Экран
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Section title="Основные настройки" description="Заголовок и тип экрана">
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
<div className="flex flex-col gap-3">
|
{activeTab === "funnel" ? (
|
||||||
<TextInput label="ID экрана" value={selectedScreen.id} disabled />
|
<div className="flex flex-col gap-6">
|
||||||
<TextInput
|
<Section title="Валидация" description="Проверка общих настроек">
|
||||||
label="Заголовок"
|
<ValidationSummary issues={validation.issues} />
|
||||||
value={selectedScreen.title.text}
|
</Section>
|
||||||
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="Общие параметры">
|
||||||
<Section title="Варианты ответа" description="Настройки опций">
|
<TextInput
|
||||||
<div className="flex flex-col gap-3">
|
label="ID воронки"
|
||||||
<div className="flex items-center justify-between">
|
value={state.meta.id}
|
||||||
<label className="flex flex-col gap-2">
|
onChange={(event) => handleMetaChange("id", event.target.value)}
|
||||||
<span className="text-sm font-medium text-muted-foreground">Тип выбора</span>
|
/>
|
||||||
<select
|
<TextInput
|
||||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
label="Название"
|
||||||
value={selectedScreenIsListType ? selectedScreen.list.selectionType : "single"}
|
value={state.meta.title ?? ""}
|
||||||
onChange={(event) =>
|
onChange={(event) => handleMetaChange("title", event.target.value)}
|
||||||
handleSelectionTypeChange(
|
/>
|
||||||
selectedScreen.id,
|
<TextInput
|
||||||
event.target.value as "single" | "multi"
|
label="Описание"
|
||||||
)
|
value={state.meta.description ?? ""}
|
||||||
}
|
onChange={(event) => handleMetaChange("description", event.target.value)}
|
||||||
>
|
/>
|
||||||
<option value="single">Один ответ</option>
|
<label className="flex flex-col gap-2">
|
||||||
<option value="multi">Несколько ответов</option>
|
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
|
||||||
</select>
|
<select
|
||||||
</label>
|
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
<Button
|
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
|
||||||
className="h-8 px-3 text-xs"
|
onChange={(event) => handleFirstScreenChange(event.target.value)}
|
||||||
onClick={() => handleAddOption(selectedScreen)}
|
|
||||||
>
|
>
|
||||||
Добавить
|
{screenOptions.map((screen) => (
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{selectedScreenIsListType && selectedScreen.list.options.map((option, index) => (
|
|
||||||
<div
|
|
||||||
key={option.id}
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl border border-border/80 bg-background/70 p-3",
|
|
||||||
"flex flex-col gap-2"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Опция {index + 1}</span>
|
|
||||||
{selectedScreenIsListType && selectedScreen.list.options.length > 1 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={() => handleRemoveOption(selectedScreen, index)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<TextInput
|
|
||||||
label="ID"
|
|
||||||
value={option.id}
|
|
||||||
onChange={(event) => handleOptionChange(selectedScreen.id, index, "id", event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Текст"
|
|
||||||
value={option.label}
|
|
||||||
onChange={(event) => handleOptionChange(selectedScreen.id, index, "label", event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Описание"
|
|
||||||
value={option.description ?? ""}
|
|
||||||
onChange={(event) => handleOptionChange(selectedScreen.id, index, "description", event.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Emoji"
|
|
||||||
value={option.emoji ?? ""}
|
|
||||||
onChange={(event) => handleOptionChange(selectedScreen.id, index, "emoji", event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section title="Навигация" description="Переходы между экранами">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<label className="flex flex-col gap-2">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
|
|
||||||
<select
|
|
||||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
|
||||||
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
|
|
||||||
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
{screenOptions
|
|
||||||
.filter((screen) => screen.id !== selectedScreen.id)
|
|
||||||
.map((screen) => (
|
|
||||||
<option key={screen.id} value={screen.id}>
|
<option key={screen.id} value={screen.id}>
|
||||||
{screen.title}
|
{screen.title}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</Section>
|
||||||
</Section>
|
|
||||||
|
|
||||||
{selectedScreenIsListType && (
|
<Section title="Экраны" description="Управление и статистика">
|
||||||
<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 flex-col gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<span>Всего экранов</span>
|
||||||
<p className="text-xs text-muted-foreground">
|
<span className="font-semibold text-foreground">{state.screens.length}</span>
|
||||||
Направляйте пользователей на разные экраны в зависимости от выбора.
|
|
||||||
</p>
|
|
||||||
<Button className="h-8 px-3 text-xs" onClick={() => handleAddRule(selectedScreen)}>
|
|
||||||
Добавить правило
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
|
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
|
|
||||||
Правил пока нет
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
|
{state.screens.map((screen, index) => (
|
||||||
|
<span key={screen.id} className="flex items-center justify-between">
|
||||||
|
<span className="truncate">{index + 1}. {screen.title.text}</span>
|
||||||
|
<span className="uppercase text-muted-foreground/80">{screen.template}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
) : selectedScreen ? (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||||
|
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">ID:</span> {selectedScreen.id}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">Тип:</span> {selectedScreen.template}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">Позиция:</span> экран {state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1} из {state.screens.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
|
<Section title="Общие данные" description="ID и тип текущего экрана">
|
||||||
<div
|
<TextInput label="ID экрана" value={selectedScreen.id} disabled />
|
||||||
key={ruleIndex}
|
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
|
Текущий шаблон: <span className="font-semibold text-foreground">{selectedScreen.template}</span>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Контент и оформление" description="Все параметры выбранного шаблона">
|
||||||
|
<TemplateConfig
|
||||||
|
screen={selectedScreen}
|
||||||
|
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Навигация" description="Переходы между экранами">
|
||||||
|
<label className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
|
||||||
|
<select
|
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
|
||||||
|
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
|
||||||
>
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{screenOptions
|
||||||
|
.filter((screen) => screen.id !== selectedScreen.id)
|
||||||
|
.map((screen) => (
|
||||||
|
<option key={screen.id} value={screen.id}>
|
||||||
|
{screen.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{selectedScreenIsListType && (
|
||||||
|
<Section title="Правила переходов" description="Условная навигация">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
|
<p className="text-xs text-muted-foreground">
|
||||||
<Button
|
Направляйте пользователей на разные экраны в зависимости от выбора.
|
||||||
variant="ghost"
|
</p>
|
||||||
className="text-destructive"
|
<Button className="h-8 px-3 text-xs" onClick={() => handleAddRule(selectedScreen)}>
|
||||||
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
|
Добавить правило
|
||||||
>
|
|
||||||
<span className="text-xs">Удалить</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex flex-col gap-2">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
|
|
||||||
<select
|
|
||||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
|
||||||
value={rule.conditions[0]?.operator ?? "includesAny"}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleRuleOperatorChange(
|
|
||||||
selectedScreen.id,
|
|
||||||
ruleIndex,
|
|
||||||
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="includesAny">contains any</option>
|
|
||||||
<option value="includesAll">contains all</option>
|
|
||||||
<option value="includesExactly">exact match</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
|
||||||
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
|
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
|
Правил пока нет
|
||||||
{selectedScreenIsListType && selectedScreen.list.options.map((option) => {
|
|
||||||
const condition = rule.conditions[0];
|
|
||||||
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
|
||||||
return (
|
|
||||||
<label key={option.id} className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isChecked}
|
|
||||||
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{option.label}
|
|
||||||
<span className="text-muted-foreground"> ({option.id})</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<label className="flex flex-col gap-2">
|
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
|
||||||
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
|
<div
|
||||||
<select
|
key={ruleIndex}
|
||||||
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
|
||||||
value={rule.nextScreenId}
|
|
||||||
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
|
|
||||||
>
|
>
|
||||||
{screenOptions
|
<div className="flex items-center justify-between">
|
||||||
.filter((screen) => screen.id !== selectedScreen.id)
|
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
|
||||||
.map((screen) => (
|
<Button
|
||||||
<option key={screen.id} value={screen.id}>
|
variant="ghost"
|
||||||
{screen.title}
|
className="text-destructive"
|
||||||
</option>
|
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
|
||||||
))}
|
>
|
||||||
</select>
|
<span className="text-xs">Удалить</span>
|
||||||
</label>
|
</Button>
|
||||||
|
</div>
|
||||||
|
<label className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
|
||||||
|
<select
|
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
value={rule.conditions[0]?.operator ?? "includesAny"}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleRuleOperatorChange(
|
||||||
|
selectedScreen.id,
|
||||||
|
ruleIndex,
|
||||||
|
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="includesAny">contains any</option>
|
||||||
|
<option value="includesAll">contains all</option>
|
||||||
|
<option value="includesExactly">exact match</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
|
||||||
|
{selectedScreen.list.options.map((option) => {
|
||||||
|
const condition = rule.conditions[0];
|
||||||
|
const isChecked = condition.optionIds?.includes(option.id) ?? false;
|
||||||
|
return (
|
||||||
|
<label key={option.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{option.label}
|
||||||
|
<span className="text-muted-foreground"> ({option.id})</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
|
||||||
|
<select
|
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
value={rule.nextScreenId}
|
||||||
|
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
|
||||||
|
>
|
||||||
|
{screenOptions
|
||||||
|
.filter((screen) => screen.id !== selectedScreen.id)
|
||||||
|
.map((screen) => (
|
||||||
|
<option key={screen.id} value={screen.id}>
|
||||||
|
{screen.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</Section>
|
||||||
</div>
|
)}
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section title="Валидация экрана" description="Проверка корректности настроек">
|
<Section title="Валидация экрана" description="Проверка корректности настроек">
|
||||||
<ValidationSummary />
|
<ValidationSummary issues={screenValidationIssues} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Управление экраном" description="Опасные действия">
|
<Section title="Управление экраном" description="Опасные действия">
|
||||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
<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>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
disabled={state.screens.length <= 1}
|
disabled={state.screens.length <= 1}
|
||||||
onClick={() => handleDeleteScreen(selectedScreen.id)}
|
onClick={() => handleDeleteScreen(selectedScreen.id)}
|
||||||
>
|
>
|
||||||
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
|
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/20 p-6 text-center text-sm text-muted-foreground">
|
||||||
|
Выберите экран в списке слева, чтобы настроить его параметры.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,178 +12,95 @@ interface CouponScreenConfigProps {
|
|||||||
export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) {
|
export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) {
|
||||||
const couponScreen = screen as CouponScreenDefinition & { position: { x: number; y: number } };
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||||
{/* 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-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Coupon Details</h3>
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Настройки оффера
|
||||||
<div className="space-y-2">
|
</h3>
|
||||||
<label className="text-xs font-medium text-muted-foreground">Offer Title</label>
|
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Заголовок оффера
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="94% OFF"
|
placeholder="-50% на первый заказ"
|
||||||
value={couponScreen.coupon?.offer?.title?.text || ""}
|
value={couponScreen.coupon?.offer?.title?.text ?? ""}
|
||||||
onChange={(e) => onUpdate({
|
onChange={(event) =>
|
||||||
coupon: {
|
handleCouponUpdate("offer", {
|
||||||
...couponScreen.coupon,
|
...couponScreen.coupon.offer,
|
||||||
offer: {
|
title: {
|
||||||
...couponScreen.coupon?.offer,
|
...(couponScreen.coupon.offer?.title ?? {}),
|
||||||
title: {
|
text: event.target.value,
|
||||||
...couponScreen.coupon?.offer?.title,
|
},
|
||||||
text: e.target.value,
|
})
|
||||||
font: couponScreen.coupon?.offer?.title?.font || "manrope",
|
|
||||||
weight: couponScreen.coupon?.offer?.title?.weight || "bold",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground">Offer Description</label>
|
|
||||||
<TextInput
|
|
||||||
placeholder="HAIR LOSS SPECIALIST"
|
|
||||||
value={couponScreen.coupon?.offer?.description?.text || ""}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
coupon: {
|
|
||||||
...couponScreen.coupon,
|
|
||||||
offer: {
|
|
||||||
...couponScreen.coupon?.offer,
|
|
||||||
description: {
|
|
||||||
...couponScreen.coupon?.offer?.description,
|
|
||||||
text: e.target.value,
|
|
||||||
font: couponScreen.coupon?.offer?.description?.font || "inter",
|
|
||||||
weight: couponScreen.coupon?.offer?.description?.weight || "medium",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground">Promo Code</label>
|
|
||||||
<TextInput
|
|
||||||
placeholder="HAIR50"
|
|
||||||
value={couponScreen.coupon?.promoCode?.text || ""}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
coupon: {
|
|
||||||
...couponScreen.coupon,
|
|
||||||
promoCode: {
|
|
||||||
...couponScreen.coupon?.promoCode,
|
|
||||||
text: e.target.value,
|
|
||||||
font: couponScreen.coupon?.promoCode?.font || "manrope",
|
|
||||||
weight: couponScreen.coupon?.promoCode?.weight || "bold",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground">Footer Text</label>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Click to copy promocode"
|
|
||||||
value={couponScreen.coupon?.footer?.text || ""}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
coupon: {
|
|
||||||
...couponScreen.coupon,
|
|
||||||
footer: {
|
|
||||||
...couponScreen.coupon?.footer,
|
|
||||||
text: e.target.value,
|
|
||||||
font: couponScreen.coupon?.footer?.font || "inter",
|
|
||||||
weight: couponScreen.coupon?.footer?.weight || "medium",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Action Button */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Button Text</label>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Continue"
|
|
||||||
value={couponScreen.bottomActionButton?.text || ""}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
bottomActionButton: {
|
|
||||||
text: e.target.value || "Continue",
|
|
||||||
}
|
}
|
||||||
})}
|
/>
|
||||||
/>
|
</label>
|
||||||
|
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Подзаголовок/описание
|
||||||
|
<TextInput
|
||||||
|
placeholder="Персональная акция только сегодня"
|
||||||
|
value={couponScreen.coupon?.offer?.description?.text ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleCouponUpdate("offer", {
|
||||||
|
...couponScreen.coupon.offer,
|
||||||
|
description: {
|
||||||
|
...(couponScreen.coupon.offer?.description ?? {}),
|
||||||
|
text: event.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header Configuration */}
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<h4 className="text-sm font-semibold text-foreground">Промокод</h4>
|
||||||
<h3 className="text-sm font-semibold">Header Settings</h3>
|
<label className="flex flex-col gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
Текст промокода
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<TextInput
|
||||||
<input
|
placeholder="SALE50"
|
||||||
type="checkbox"
|
value={couponScreen.coupon?.promoCode?.text ?? ""}
|
||||||
checked={couponScreen.header?.show !== false}
|
onChange={(event) =>
|
||||||
onChange={(e) => onUpdate({
|
handleCouponUpdate("promoCode", {
|
||||||
header: {
|
...(couponScreen.coupon.promoCode ?? {}),
|
||||||
...couponScreen.header,
|
text: event.target.value,
|
||||||
show: e.target.checked,
|
})
|
||||||
}
|
}
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
Show navigation bar
|
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
{couponScreen.header?.show !== false && (
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<h4 className="text-sm font-semibold text-foreground">Сообщение об успехе</h4>
|
||||||
<input
|
<TextInput
|
||||||
type="checkbox"
|
placeholder="Промокод скопирован!"
|
||||||
checked={couponScreen.header?.showBackButton !== false}
|
value={couponScreen.copiedMessage ?? ""}
|
||||||
onChange={(e) => onUpdate({
|
onChange={(event) => onUpdate({ copiedMessage: event.target.value || undefined })}
|
||||||
header: {
|
/>
|
||||||
...couponScreen.header,
|
|
||||||
showBackButton: e.target.checked,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
Show back button
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,175 +12,139 @@ interface DateScreenConfigProps {
|
|||||||
export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
|
export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
|
||||||
const dateScreen = screen as DateScreenDefinition & { position: { x: number; y: number } };
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||||
{/* 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-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Date Input Labels</h3>
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Поля ввода даты
|
||||||
<div className="grid grid-cols-3 gap-2">
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Month Label</label>
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
|
Подпись месяца
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Month"
|
value={dateScreen.dateInput?.monthLabel ?? ""}
|
||||||
value={dateScreen.dateInput?.monthLabel || ""}
|
onChange={(event) => handleDateInputChange("monthLabel", event.target.value)}
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
dateInput: {
|
|
||||||
...dateScreen.dateInput,
|
|
||||||
monthLabel: e.target.value,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<div className="space-y-1">
|
Подпись дня
|
||||||
<label className="text-xs font-medium text-muted-foreground">Day Label</label>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Day"
|
value={dateScreen.dateInput?.dayLabel ?? ""}
|
||||||
value={dateScreen.dateInput?.dayLabel || ""}
|
onChange={(event) => handleDateInputChange("dayLabel", event.target.value)}
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
dateInput: {
|
|
||||||
...dateScreen.dateInput,
|
|
||||||
dayLabel: e.target.value,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<div className="space-y-1">
|
Подпись года
|
||||||
<label className="text-xs font-medium text-muted-foreground">Year Label</label>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Year"
|
value={dateScreen.dateInput?.yearLabel ?? ""}
|
||||||
value={dateScreen.dateInput?.yearLabel || ""}
|
onChange={(event) => handleDateInputChange("yearLabel", event.target.value)}
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
dateInput: {
|
|
||||||
...dateScreen.dateInput,
|
|
||||||
yearLabel: e.target.value,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||||
<div className="space-y-1">
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Month Placeholder</label>
|
Placeholder месяца
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="MM"
|
value={dateScreen.dateInput?.monthPlaceholder ?? ""}
|
||||||
value={dateScreen.dateInput?.monthPlaceholder || ""}
|
onChange={(event) => handleDateInputChange("monthPlaceholder", event.target.value)}
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
dateInput: {
|
|
||||||
...dateScreen.dateInput,
|
|
||||||
monthPlaceholder: e.target.value,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<div className="space-y-1">
|
Placeholder дня
|
||||||
<label className="text-xs font-medium text-muted-foreground">Day Placeholder</label>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="DD"
|
value={dateScreen.dateInput?.dayPlaceholder ?? ""}
|
||||||
value={dateScreen.dateInput?.dayPlaceholder || ""}
|
onChange={(event) => handleDateInputChange("dayPlaceholder", event.target.value)}
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
dateInput: {
|
|
||||||
...dateScreen.dateInput,
|
|
||||||
dayPlaceholder: e.target.value,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<div className="space-y-1">
|
Placeholder года
|
||||||
<label className="text-xs font-medium text-muted-foreground">Year Placeholder</label>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="YYYY"
|
value={dateScreen.dateInput?.yearPlaceholder ?? ""}
|
||||||
value={dateScreen.dateInput?.yearPlaceholder || ""}
|
onChange={(event) => handleDateInputChange("yearPlaceholder", event.target.value)}
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
dateInput: {
|
|
||||||
...dateScreen.dateInput,
|
|
||||||
yearPlaceholder: e.target.value,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Message */}
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<h4 className="text-sm font-semibold text-foreground">Поведение поля</h4>
|
||||||
<label className="text-sm font-medium">Info Message (Optional)</label>
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<TextInput
|
<input
|
||||||
placeholder="We protect your personal data"
|
type="checkbox"
|
||||||
value={dateScreen.infoMessage?.text || ""}
|
checked={dateScreen.dateInput?.showSelectedDate === true}
|
||||||
onChange={(e) => onUpdate({
|
onChange={(event) => handleDateInputChange("showSelectedDate", event.target.checked)}
|
||||||
infoMessage: e.target.value ? {
|
|
||||||
text: e.target.value,
|
|
||||||
icon: dateScreen.infoMessage?.icon || "🔒",
|
|
||||||
} : undefined
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{dateScreen.infoMessage && (
|
|
||||||
<TextInput
|
|
||||||
placeholder="🔒"
|
|
||||||
value={dateScreen.infoMessage.icon}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
infoMessage: {
|
|
||||||
text: dateScreen.infoMessage?.text || "",
|
|
||||||
icon: e.target.value,
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
)}
|
Показывать выбранную дату под полем
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
|
Подпись выбранной даты
|
||||||
|
<TextInput
|
||||||
|
value={dateScreen.dateInput?.selectedDateLabel ?? ""}
|
||||||
|
onChange={(event) => handleDateInputChange("selectedDateLabel", event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
|
Формат отображения (date-fns)
|
||||||
|
<TextInput
|
||||||
|
placeholder="MMMM d, yyyy"
|
||||||
|
value={dateScreen.dateInput?.selectedDateFormat ?? ""}
|
||||||
|
onChange={(event) => handleDateInputChange("selectedDateFormat", event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
Текст ошибки валидации
|
||||||
|
<TextInput
|
||||||
|
placeholder="Пожалуйста, укажите корректную дату"
|
||||||
|
value={dateScreen.dateInput?.validationMessage ?? ""}
|
||||||
|
onChange={(event) => handleDateInputChange("validationMessage", event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Action Button */}
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<h4 className="text-sm font-semibold text-foreground">Информационный блок</h4>
|
||||||
<label className="text-sm font-medium">Button Text (Optional)</label>
|
<label className="flex flex-col gap-2 text-sm">
|
||||||
<TextInput
|
<span className="text-xs font-medium text-muted-foreground">Сообщение (оставьте пустым, чтобы скрыть)</span>
|
||||||
placeholder="Next"
|
<TextInput
|
||||||
value={dateScreen.bottomActionButton?.text || ""}
|
value={dateScreen.infoMessage?.text ?? ""}
|
||||||
onChange={(e) => onUpdate({
|
onChange={(event) => handleInfoMessageChange("text", event.target.value)}
|
||||||
bottomActionButton: e.target.value ? {
|
/>
|
||||||
text: e.target.value,
|
</label>
|
||||||
} : undefined
|
{dateScreen.infoMessage && (
|
||||||
})}
|
<label className="flex flex-col gap-2 text-sm">
|
||||||
/>
|
<span className="text-xs font-medium text-muted-foreground">Emoji/иконка для сообщения</span>
|
||||||
|
<TextInput
|
||||||
|
value={dateScreen.infoMessage.icon ?? ""}
|
||||||
|
onChange={(event) => handleInfoMessageChange("icon", event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
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";
|
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||||
|
|
||||||
interface FormScreenConfigProps {
|
interface FormScreenConfigProps {
|
||||||
@ -19,17 +19,26 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
|
|||||||
onUpdate({ fields: newFields });
|
onUpdate({ fields: newFields });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateValidationMessages = (updates: Partial<FormValidationMessages>) => {
|
||||||
|
onUpdate({
|
||||||
|
validationMessages: {
|
||||||
|
...(formScreen.validationMessages ?? {}),
|
||||||
|
...updates,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const addField = () => {
|
const addField = () => {
|
||||||
const newField: FormFieldDefinition = {
|
const newField: FormFieldDefinition = {
|
||||||
id: `field_${Date.now()}`,
|
id: `field_${Date.now()}`,
|
||||||
label: "New Field",
|
label: "Новое поле",
|
||||||
placeholder: "Enter value",
|
placeholder: "Введите значение",
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
onUpdate({
|
onUpdate({
|
||||||
fields: [...(formScreen.fields || []), newField]
|
fields: [...(formScreen.fields || []), newField],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -39,153 +48,166 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||||
{/* 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-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">Form Fields</h3>
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Поля формы</h3>
|
||||||
<Button
|
<Button onClick={addField} variant="outline" className="h-8 px-3 text-xs">
|
||||||
onClick={addField}
|
Добавить поле
|
||||||
className="h-7 px-3 text-xs"
|
|
||||||
>
|
|
||||||
Add Field
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formScreen.fields?.map((field, index) => (
|
{formScreen.fields?.map((field, index) => (
|
||||||
<div key={index} className="rounded border border-border p-3 space-y-2">
|
<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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-medium">Field {index + 1}</span>
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||||
|
Поле {index + 1}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
onClick={() => removeField(index)}
|
onClick={() => removeField(index)}
|
||||||
className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
|
|
||||||
>
|
>
|
||||||
Remove
|
Удалить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
<div className="space-y-1">
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Field ID</label>
|
ID поля
|
||||||
<TextInput
|
<TextInput value={field.id} onChange={(event) => updateField(index, { id: event.target.value })} />
|
||||||
placeholder="field_id"
|
</label>
|
||||||
value={field.id}
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
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>
|
|
||||||
<select
|
<select
|
||||||
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
|
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||||
value={field.type}
|
value={field.type ?? "text"}
|
||||||
onChange={(e) => updateField(index, { type: e.target.value as FormFieldDefinition['type'] })}
|
onChange={(event) => updateField(index, { type: event.target.value as FormFieldDefinition["type"] })}
|
||||||
>
|
>
|
||||||
<option value="text">Text</option>
|
<option value="text">Текст</option>
|
||||||
<option value="email">Email</option>
|
<option value="email">E-mail</option>
|
||||||
<option value="tel">Phone</option>
|
<option value="tel">Телефон</option>
|
||||||
<option value="url">URL</option>
|
<option value="url">Ссылка</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Label</label>
|
Метка поля
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Field Label"
|
value={field.label ?? ""}
|
||||||
value={field.label}
|
onChange={(event) => updateField(index, { label: event.target.value })}
|
||||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Placeholder</label>
|
Placeholder
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Enter placeholder"
|
value={field.placeholder ?? ""}
|
||||||
value={field.placeholder || ""}
|
onChange={(event) => updateField(index, { placeholder: event.target.value })}
|
||||||
onChange={(e) => updateField(index, { placeholder: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={field.required || false}
|
checked={field.required ?? false}
|
||||||
onChange={(e) => updateField(index, { required: e.target.checked })}
|
onChange={(event) => updateField(index, { required: event.target.checked })}
|
||||||
/>
|
/>
|
||||||
Required
|
Обязательно для заполнения
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
|
Максимальная длина
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||||
|
value={field.maxLength ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateField(index, {
|
||||||
|
maxLength: event.target.value ? Number(event.target.value) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{field.maxLength && (
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
<label className="text-xs text-muted-foreground">Max Length:</label>
|
Регулярное выражение (pattern)
|
||||||
<input
|
<TextInput
|
||||||
type="number"
|
placeholder="Например, ^\\d+$"
|
||||||
className="w-16 rounded border border-border bg-background px-2 py-1 text-xs"
|
value={field.validation?.pattern ?? ""}
|
||||||
value={field.maxLength}
|
onChange={(event) =>
|
||||||
onChange={(e) => updateField(index, { maxLength: parseInt(e.target.value) || undefined })}
|
updateField(index, {
|
||||||
/>
|
validation: {
|
||||||
</div>
|
...(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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(!formScreen.fields || formScreen.fields.length === 0) && (
|
{(!formScreen.fields || formScreen.fields.length === 0) && (
|
||||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
<div className="rounded-lg border border-dashed border-border/60 p-4 text-center text-sm text-muted-foreground">
|
||||||
No fields added yet. Click "Add Field" to get started.
|
Пока нет полей. Добавьте хотя бы одно, чтобы форма работала.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Action Button */}
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<h4 className="text-sm font-semibold text-foreground">Сообщения валидации</h4>
|
||||||
<label className="text-sm font-medium">Button Text</label>
|
<div className="grid grid-cols-1 gap-3 text-xs md:grid-cols-3">
|
||||||
<TextInput
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
placeholder="Continue"
|
Обязательное поле
|
||||||
value={formScreen.bottomActionButton?.text || ""}
|
<TextInput
|
||||||
onChange={(e) => onUpdate({
|
placeholder="Это поле обязательно"
|
||||||
bottomActionButton: {
|
value={formScreen.validationMessages?.required ?? ""}
|
||||||
text: e.target.value || "Continue",
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
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";
|
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||||
|
|
||||||
interface InfoScreenConfigProps {
|
interface InfoScreenConfigProps {
|
||||||
@ -12,144 +12,90 @@ interface InfoScreenConfigProps {
|
|||||||
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
|
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
|
||||||
const infoScreen = screen as InfoScreenDefinition & { position: { x: number; y: number } };
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||||
{/* Title Configuration */}
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<label className="text-sm font-medium">Title</label>
|
Информационный контент
|
||||||
<TextInput
|
</h3>
|
||||||
placeholder="Enter screen title"
|
<label className="flex flex-col gap-2 text-sm">
|
||||||
value={infoScreen.title?.text || ""}
|
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
|
||||||
onChange={(e) => onUpdate({
|
<TextInput
|
||||||
title: {
|
placeholder="Введите пояснение для пользователя"
|
||||||
...infoScreen.title,
|
value={infoScreen.description?.text ?? ""}
|
||||||
text: e.target.value,
|
onChange={(event) => handleDescriptionChange(event.target.value)}
|
||||||
font: infoScreen.title?.font || "manrope",
|
/>
|
||||||
weight: infoScreen.title?.weight || "bold",
|
</label>
|
||||||
align: infoScreen.title?.align || "center",
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<select
|
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
|
||||||
value={infoScreen.title?.font || "manrope"}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
title: {
|
|
||||||
...infoScreen.title,
|
|
||||||
text: infoScreen.title?.text || "",
|
|
||||||
font: e.target.value as TypographyVariant['font'],
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<option value="manrope">Manrope</option>
|
|
||||||
<option value="inter">Inter</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
|
||||||
value={infoScreen.title?.weight || "bold"}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
title: {
|
|
||||||
...infoScreen.title,
|
|
||||||
text: infoScreen.title?.text || "",
|
|
||||||
weight: e.target.value as TypographyVariant['weight'],
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="bold">Bold</option>
|
|
||||||
<option value="semibold">Semibold</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description Configuration */}
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<h4 className="text-sm font-semibold text-foreground">Иконка</h4>
|
||||||
<label className="text-sm font-medium">Description (Optional)</label>
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<TextInput
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
placeholder="Enter screen description"
|
Тип иконки
|
||||||
value={infoScreen.description?.text || ""}
|
<select
|
||||||
onChange={(e) => onUpdate({
|
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||||
description: e.target.value ? {
|
value={infoScreen.icon?.type ?? "emoji"}
|
||||||
text: e.target.value,
|
onChange={(event) => handleIconChange("type", event.target.value as "emoji" | "image")}
|
||||||
font: infoScreen.description?.font || "inter",
|
>
|
||||||
weight: infoScreen.description?.weight || "medium",
|
<option value="emoji">Emoji</option>
|
||||||
align: infoScreen.description?.align || "center",
|
<option value="image">Изображение</option>
|
||||||
} : undefined
|
</select>
|
||||||
})}
|
</label>
|
||||||
/>
|
<label className="flex flex-col gap-1 text-muted-foreground">
|
||||||
</div>
|
Размер
|
||||||
|
<select
|
||||||
{/* Icon Configuration */}
|
className="rounded-lg border border-border bg-background px-2 py-1"
|
||||||
<div className="space-y-2">
|
value={infoScreen.icon?.size ?? "lg"}
|
||||||
<label className="text-sm font-medium">Icon (Optional)</label>
|
onChange={(event) => handleIconChange("size", event.target.value)}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
>
|
||||||
<select
|
<option value="sm">Маленький</option>
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
<option value="md">Средний</option>
|
||||||
value={infoScreen.icon?.type || "emoji"}
|
<option value="lg">Большой</option>
|
||||||
onChange={(e) => onUpdate({
|
<option value="xl">Огромный</option>
|
||||||
icon: infoScreen.icon ? {
|
</select>
|
||||||
...infoScreen.icon,
|
</label>
|
||||||
type: e.target.value as "emoji" | "image",
|
|
||||||
} : {
|
|
||||||
type: e.target.value as "emoji" | "image",
|
|
||||||
value: "❤️",
|
|
||||||
size: "lg",
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<option value="emoji">Emoji</option>
|
|
||||||
<option value="image">Image</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-sm"
|
|
||||||
value={infoScreen.icon?.size || "lg"}
|
|
||||||
onChange={(e) => onUpdate({
|
|
||||||
icon: infoScreen.icon ? {
|
|
||||||
...infoScreen.icon,
|
|
||||||
size: e.target.value as "sm" | "md" | "lg" | "xl",
|
|
||||||
} : {
|
|
||||||
type: "emoji",
|
|
||||||
value: "❤️",
|
|
||||||
size: e.target.value as "sm" | "md" | "lg" | "xl",
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<option value="sm">Small</option>
|
|
||||||
<option value="md">Medium</option>
|
|
||||||
<option value="lg">Large</option>
|
|
||||||
<option value="xl">Extra Large</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextInput
|
<label className="flex flex-col gap-2 text-sm">
|
||||||
placeholder={infoScreen.icon?.type === "image" ? "Image URL" : "Emoji (e.g., ❤️)"}
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
value={infoScreen.icon?.value || ""}
|
{infoScreen.icon?.type === "image" ? "Ссылка на изображение" : "Emoji символ"}
|
||||||
onChange={(e) => onUpdate({
|
</span>
|
||||||
icon: e.target.value ? {
|
<TextInput
|
||||||
type: infoScreen.icon?.type || "emoji",
|
placeholder={infoScreen.icon?.type === "image" ? "https://..." : "Например, ✨"}
|
||||||
value: e.target.value,
|
value={infoScreen.icon?.value ?? ""}
|
||||||
size: infoScreen.icon?.size || "lg",
|
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
|
||||||
} : undefined
|
/>
|
||||||
})}
|
</label>
|
||||||
/>
|
|
||||||
</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
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Trash2, Plus } from "lucide-react";
|
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||||
import type { ListScreenDefinition, ListOptionDefinition, SelectionType } from "@/lib/funnel/types";
|
import type { ListScreenDefinition, ListOptionDefinition, SelectionType, BottomActionButtonDefinition } from "@/lib/funnel/types";
|
||||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||||
|
|
||||||
interface ListScreenConfigProps {
|
interface ListScreenConfigProps {
|
||||||
@ -11,194 +11,334 @@ interface ListScreenConfigProps {
|
|||||||
onUpdate: (updates: Partial<ListScreenDefinition>) => void;
|
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) {
|
export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
|
||||||
const listScreen = screen as ListScreenDefinition & { position: { x: number; y: number } };
|
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) => {
|
const handleSelectionTypeChange = (selectionType: SelectionType) => {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
list: {
|
list: {
|
||||||
...listScreen.list,
|
...listScreen.list,
|
||||||
selectionType,
|
selectionType,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOptionChange = (index: number, field: keyof ListOptionDefinition, value: string | boolean) => {
|
const handleAutoAdvanceChange = (checked: boolean) => {
|
||||||
const newOptions = [...listScreen.list.options];
|
onUpdate({
|
||||||
newOptions[index] = {
|
list: {
|
||||||
...newOptions[index],
|
...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,
|
[field]: value,
|
||||||
};
|
}));
|
||||||
|
|
||||||
onUpdate({
|
onUpdate({
|
||||||
list: {
|
list: {
|
||||||
...listScreen.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 handleAddOption = () => {
|
||||||
const newOptions = [...listScreen.list.options];
|
const nextOptions = [
|
||||||
newOptions.push({
|
...listScreen.list.options,
|
||||||
id: `option-${Date.now()}`,
|
{
|
||||||
label: "New Option",
|
id: `option-${Date.now()}`,
|
||||||
});
|
label: "Новый вариант",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
onUpdate({
|
onUpdate({
|
||||||
list: {
|
list: {
|
||||||
...listScreen.list,
|
...listScreen.list,
|
||||||
options: newOptions,
|
options: nextOptions,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveOption = (index: number) => {
|
const handleRemoveOption = (index: number) => {
|
||||||
const newOptions = listScreen.list.options.filter((_, i) => i !== index);
|
const nextOptions = listScreen.list.options.filter((_, currentIndex) => currentIndex !== index);
|
||||||
|
|
||||||
onUpdate({
|
onUpdate({
|
||||||
list: {
|
list: {
|
||||||
...listScreen.list,
|
...listScreen.list,
|
||||||
options: newOptions,
|
options: nextOptions,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBottomActionButtonChange = (text: string) => {
|
const handleListButtonChange = (value: BottomActionButtonDefinition | undefined) => {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
list: {
|
list: {
|
||||||
...listScreen.list,
|
...listScreen.list,
|
||||||
bottomActionButton: text ? {
|
bottomActionButton: value,
|
||||||
text,
|
},
|
||||||
show: true,
|
|
||||||
} : undefined,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||||
{/* 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-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium">Options</label>
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<Button
|
Варианты выбора
|
||||||
variant="outline"
|
</h3>
|
||||||
onClick={handleAddOption}
|
<div className="flex items-center gap-2 text-xs">
|
||||||
className="h-7 px-3 text-xs"
|
<button
|
||||||
>
|
type="button"
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
className={`rounded-md px-3 py-1 transition ${
|
||||||
Add Option
|
listScreen.list.selectionType === "single"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "border border-border/60"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectionTypeChange("single")}
|
||||||
|
>
|
||||||
|
Один ответ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md px-3 py-1 transition ${
|
||||||
|
listScreen.list.selectionType === "multi"
|
||||||
|
? "bg-primary text-primary-foreground shadow"
|
||||||
|
: "border border-border/60"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectionTypeChange("multi")}
|
||||||
|
>
|
||||||
|
Несколько ответов
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={listScreen.list.autoAdvance === true}
|
||||||
|
disabled={listScreen.list.selectionType === "multi"}
|
||||||
|
onChange={(event) => handleAutoAdvanceChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Автоматический переход после выбора (доступно только для одиночного выбора)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">Настройка вариантов</h4>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 px-3" onClick={handleAddOption}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" /> Добавить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{listScreen.list.options.map((option, index) => (
|
{listScreen.list.options.map((option, index) => (
|
||||||
<div key={option.id} className="flex gap-2 items-center">
|
<div
|
||||||
<div className="flex-1">
|
key={option.id}
|
||||||
<TextInput
|
className="space-y-3 rounded-xl border border-border/70 bg-muted/10 p-4"
|
||||||
placeholder="Option ID"
|
>
|
||||||
value={option.id}
|
<div className="flex items-center justify-between gap-2">
|
||||||
onChange={(e) => handleOptionChange(index, "id", e.target.value)}
|
<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>
|
||||||
<div className="flex-[2]">
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||||
|
ID варианта
|
||||||
|
<TextInput
|
||||||
|
value={option.id}
|
||||||
|
onChange={(event) => handleOptionChange(index, "id", event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Машинное значение (необязательно)
|
||||||
|
<TextInput
|
||||||
|
value={option.value ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleOptionChange(index, "value", event.target.value || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Подпись для пользователя
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Option Label"
|
|
||||||
value={option.label}
|
value={option.label}
|
||||||
onChange={(e) => handleOptionChange(index, "label", e.target.value)}
|
onChange={(event) => handleOptionChange(index, "label", event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Описание (необязательно)
|
||||||
|
<TextInput
|
||||||
|
value={option.description ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleOptionChange(index, "description", event.target.value || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Emoji/иконка
|
||||||
|
<TextInput
|
||||||
|
value={option.emoji ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleOptionChange(index, "emoji", event.target.value || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
onClick={() => handleRemoveOption(index)}
|
<input
|
||||||
className="h-8 px-2"
|
type="checkbox"
|
||||||
>
|
checked={option.disabled === true}
|
||||||
<Trash2 className="w-4 h-4" />
|
onChange={(event) =>
|
||||||
</Button>
|
handleOptionChange(index, "disabled", event.target.checked || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Сделать вариант неактивным
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Action Button */}
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<h4 className="text-sm font-semibold text-foreground">Кнопка внутри списка</h4>
|
||||||
<label className="text-sm font-medium">Bottom Action Button (Optional)</label>
|
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-xs">
|
||||||
<TextInput
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
placeholder="Button text (leave empty for auto-behavior)"
|
<input
|
||||||
value={listScreen.list.bottomActionButton?.text || ""}
|
type="checkbox"
|
||||||
onChange={(e) => handleBottomActionButtonChange(e.target.value)}
|
checked={Boolean(listScreen.list.bottomActionButton)}
|
||||||
/>
|
onChange={(event) =>
|
||||||
<div className="text-xs text-muted-foreground">
|
handleListButtonChange(
|
||||||
{listScreen.list.selectionType === "multi"
|
event.target.checked
|
||||||
? "Multi selection always shows a button"
|
? listScreen.list.bottomActionButton ?? { text: "Продолжить" }
|
||||||
: "Single selection: empty = auto-advance, filled = manual button"}
|
: undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Показать кнопку под списком
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{listScreen.list.bottomActionButton && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
|
||||||
|
Текст кнопки
|
||||||
|
<TextInput
|
||||||
|
value={listScreen.list.bottomActionButton.text}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleListButtonChange({
|
||||||
|
...listScreen.list.bottomActionButton!,
|
||||||
|
text: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<label className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={listScreen.list.bottomActionButton.show !== false}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleListButtonChange({
|
||||||
|
...listScreen.list.bottomActionButton!,
|
||||||
|
show: event.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Показывать кнопку
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={listScreen.list.bottomActionButton.disabled === true}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleListButtonChange({
|
||||||
|
...listScreen.list.bottomActionButton!,
|
||||||
|
disabled: event.target.checked || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Выключить по умолчанию
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
|
Для одиночного выбора пустая кнопка включает авто-переход. Для множественного выбора кнопка отображается всегда.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,70 +1,432 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { InfoScreenConfig } from "./InfoScreenConfig";
|
import { InfoScreenConfig } from "./InfoScreenConfig";
|
||||||
import { DateScreenConfig } from "./DateScreenConfig";
|
import { DateScreenConfig } from "./DateScreenConfig";
|
||||||
import { CouponScreenConfig } from "./CouponScreenConfig";
|
import { CouponScreenConfig } from "./CouponScreenConfig";
|
||||||
import { FormScreenConfig } from "./FormScreenConfig";
|
import { FormScreenConfig } from "./FormScreenConfig";
|
||||||
import { ListScreenConfig } from "./ListScreenConfig";
|
import { ListScreenConfig } from "./ListScreenConfig";
|
||||||
|
|
||||||
|
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
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 {
|
interface TemplateConfigProps {
|
||||||
screen: BuilderScreen;
|
screen: BuilderScreen;
|
||||||
onUpdate: (updates: Partial<ScreenDefinition>) => void;
|
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) {
|
export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
|
||||||
const { template } = screen;
|
const { template } = screen;
|
||||||
|
|
||||||
switch (template) {
|
const handleTitleChange = (value: TypographyVariant) => {
|
||||||
case "info":
|
onUpdate({ title: value });
|
||||||
return (
|
};
|
||||||
<InfoScreenConfig
|
|
||||||
screen={screen as BuilderScreen & { template: "info" }}
|
|
||||||
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "date":
|
const handleSubtitleChange = (value: TypographyVariant | undefined) => {
|
||||||
return (
|
onUpdate({ subtitle: value });
|
||||||
<DateScreenConfig
|
};
|
||||||
screen={screen as BuilderScreen & { template: "date" }}
|
|
||||||
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "coupon":
|
const handleHeaderChange = (value: HeaderDefinition | undefined) => {
|
||||||
return (
|
onUpdate({ header: value });
|
||||||
<CouponScreenConfig
|
};
|
||||||
screen={screen as BuilderScreen & { template: "coupon" }}
|
|
||||||
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "form":
|
const handleButtonChange = (value: BottomActionButtonDefinition | undefined) => {
|
||||||
return (
|
onUpdate({ bottomActionButton: value });
|
||||||
<FormScreenConfig
|
};
|
||||||
screen={screen as BuilderScreen & { template: "form" }}
|
|
||||||
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "list":
|
return (
|
||||||
return (
|
<div className="space-y-8">
|
||||||
<ListScreenConfig
|
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
|
||||||
screen={screen as BuilderScreen & { template: "list" }}
|
<TypographyControls label="Заголовок" value={screen.title} onChange={handleTitleChange} />
|
||||||
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
|
<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>
|
||||||
|
|
||||||
default:
|
<div className="space-y-6">
|
||||||
return (
|
{template === "info" && (
|
||||||
<div className="space-y-4">
|
<InfoScreenConfig
|
||||||
<div className="text-sm text-red-600">
|
screen={screen as BuilderScreen & { template: "info" }}
|
||||||
Unknown template type: {template}
|
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void}
|
||||||
</div>
|
/>
|
||||||
</div>
|
)}
|
||||||
);
|
{template === "date" && (
|
||||||
}
|
<DateScreenConfig
|
||||||
|
screen={screen as BuilderScreen & { template: "date" }}
|
||||||
|
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template === "coupon" && (
|
||||||
|
<CouponScreenConfig
|
||||||
|
screen={screen as BuilderScreen & { template: "coupon" }}
|
||||||
|
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template === "form" && (
|
||||||
|
<FormScreenConfig
|
||||||
|
screen={screen as BuilderScreen & { template: "form" }}
|
||||||
|
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template === "list" && (
|
||||||
|
<ListScreenConfig
|
||||||
|
screen={screen as BuilderScreen & { template: "list" }}
|
||||||
|
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function serializeBuilderState(state: BuilderFunnelState): FunnelDefinition {
|
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 screens = state.screens.map(({ position: _position, ...rest }) => rest);
|
||||||
const meta: FunnelDefinition["meta"] = {
|
const meta: FunnelDefinition["meta"] = {
|
||||||
...state.meta,
|
...state.meta,
|
||||||
@ -65,6 +66,7 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toBuilderFunnelState(state: BuilderState): BuilderFunnelState {
|
export function toBuilderFunnelState(state: BuilderState): BuilderFunnelState {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { isDirty: _isDirty, selectedScreenId: _selectedScreenId, ...rest } = state;
|
const { isDirty: _isDirty, selectedScreenId: _selectedScreenId, ...rest } = state;
|
||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user