From fcfa97c8f7ced857bbdc4f3c7bad0b7c00e74172 Mon Sep 17 00:00:00 2001 From: pennyteenycat Date: Fri, 26 Sep 2025 12:46:40 +0200 Subject: [PATCH] Enhance funnel builder configuration UX --- .../admin/builder/BuilderCanvas.tsx | 466 +++++++---- .../admin/builder/BuilderSidebar.tsx | 722 ++++++++---------- .../builder/templates/CouponScreenConfig.tsx | 247 ++---- .../builder/templates/DateScreenConfig.tsx | 260 +++---- .../builder/templates/FormScreenConfig.tsx | 272 ++++--- .../builder/templates/InfoScreenConfig.tsx | 220 ++---- .../builder/templates/ListScreenConfig.tsx | 414 ++++++---- .../builder/templates/TemplateConfig.tsx | 464 +++++++++-- src/lib/admin/builder/utils.ts | 2 + 9 files changed, 1743 insertions(+), 1324 deletions(-) diff --git a/src/components/admin/builder/BuilderCanvas.tsx b/src/components/admin/builder/BuilderCanvas.tsx index ef53d43..7ae919d 100644 --- a/src/components/admin/builder/BuilderCanvas.tsx +++ b/src/components/admin/builder/BuilderCanvas.tsx @@ -1,56 +1,212 @@ "use client"; -import React, { useCallback, useRef } from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context"; -import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/types"; +import type { ListOptionDefinition, ScreenDefinition } from "@/lib/funnel/types"; import { cn } from "@/lib/utils"; -const CARD_WIDTH = 280; -const CARD_HEIGHT = 200; -const CARD_GAP = 24; +function DropIndicator({ isActive }: { isActive: boolean }) { + return ( +
+ ); +} + +function TemplateSummary({ screen }: { screen: ScreenDefinition }) { + switch (screen.template) { + case "list": { + return ( +
+
+ + Выбор: {screen.list.selectionType === "single" ? "один" : "несколько"} + + {screen.list.autoAdvance && ( + + авто переход + + )} + {screen.list.bottomActionButton?.text && ( + + {screen.list.bottomActionButton.text} + + )} +
+
+

Варианты ({screen.list.options.length})

+
+ {screen.list.options.map((option) => ( + + {option.emoji && {option.emoji}} + {option.label} + + ))} +
+
+
+ ); + } + case "form": { + return ( +
+
+ + Полей: {screen.fields.length} + + {screen.bottomActionButton?.text && ( + + {screen.bottomActionButton.text} + + )} +
+ {screen.validationMessages && ( +
+

+ Настроены пользовательские сообщения валидации +

+
+ )} +
+ ); + } + case "coupon": { + return ( +
+

+ Промо: {screen.coupon.promoCode.text} +

+

{screen.coupon.offer.title.text}

+
+ ); + } + case "date": { + return ( +
+

Формат даты:

+
+ {screen.dateInput.monthLabel && {screen.dateInput.monthLabel}} + {screen.dateInput.dayLabel && {screen.dateInput.dayLabel}} + {screen.dateInput.yearLabel && {screen.dateInput.yearLabel}} +
+ {screen.dateInput.validationMessage && ( +

{screen.dateInput.validationMessage}

+ )} +
+ ); + } + case "info": { + return ( +
+ {screen.description?.text &&

{screen.description.text}

} + {screen.icon?.value && ( +
+ {screen.icon.value} + Иконка +
+ )} +
+ ); + } + default: + return null; + } +} + +function getOptionLabel(options: ListOptionDefinition[], optionId: string): string { + const option = options.find((item) => item.id === optionId); + return option ? option.label : optionId; +} export function BuilderCanvas() { const { screens, selectedScreenId } = useBuilderState(); const dispatch = useBuilderDispatch(); - const containerRef = useRef(null); - const dragStateRef = useRef<{ screenId: string; dragStartIndex: number; currentIndex: number } | null>(null); + const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null); + const [dropIndex, setDropIndex] = useState(null); - const handleDragStart = useCallback((screenId: string, index: number) => { - dragStateRef.current = { - screenId, - dragStartIndex: index, - currentIndex: index, - }; + const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", screenId); + dragStateRef.current = { screenId, dragStartIndex: index }; + setDropIndex(index); }, []); - const handleDragOver = useCallback((e: React.DragEvent, targetIndex: number) => { - e.preventDefault(); - if (!dragStateRef.current) return; - - dragStateRef.current.currentIndex = targetIndex; - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - if (!dragStateRef.current) return; - - const { dragStartIndex, currentIndex } = dragStateRef.current; - - if (dragStartIndex !== currentIndex) { - dispatch({ - type: "reorder-screens", - payload: { - fromIndex: dragStartIndex, - toIndex: currentIndex, - }, - }); + const handleDragOverCard = useCallback((event: React.DragEvent, index: number) => { + event.preventDefault(); + if (!dragStateRef.current) { + return; } + const rect = event.currentTarget.getBoundingClientRect(); + const offsetY = event.clientY - rect.top; + const nextIndex = offsetY > rect.height / 2 ? index + 1 : index; + setDropIndex(nextIndex); + }, []); + + const handleDragOverList = useCallback( + (event: React.DragEvent) => { + 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) => { + event.preventDefault(); + finalizeDrop(dropIndex); + }, + [dropIndex, finalizeDrop] + ); + + const handleDragEnd = useCallback(() => { dragStateRef.current = null; - }, [dispatch]); + setDropIndex(null); + }, []); const handleSelectScreen = useCallback( (screenId: string) => { @@ -63,128 +219,164 @@ export function BuilderCanvas() { dispatch({ type: "add-screen" }); }, [dispatch]); - // Helper functions for type checking - const hasSubtitle = (screen: ScreenDefinition): screen is ScreenDefinition & { subtitle: { text: string } } => { - return 'subtitle' in screen && screen.subtitle !== undefined; - }; - - const isListScreen = (screen: ScreenDefinition): screen is ListScreenDefinition => { - return screen.template === 'list'; - }; - + const screenTitleMap = useMemo(() => { + return screens.reduce>((accumulator, screen) => { + accumulator[screen.id] = screen.title.text || screen.id; + return accumulator; + }, {}); + }, [screens]); return ( -
- {/* Header with Add Button */} +
-

Экраны воронки

+
+

Экраны воронки

+

Перетаскивайте, чтобы поменять порядок и связь экранов.

+
- {/* Linear Screen Layout */} -
-
- {screens.map((screen, index) => { - const isSelected = screen.id === selectedScreenId; - return ( -
handleDragStart(screen.id, index)} - onDragOver={(e) => handleDragOver(e, index)} - onDrop={handleDrop} - > -
handleSelectScreen(screen.id)} - > - {/* Screen Header */} -
-
-
- {index + 1} -
- #{screen.id} -
-
- {screen.template} -
-
+
+
+
+
+ {screens.map((screen, index) => { + const isSelected = screen.id === selectedScreenId; + const isDropBefore = dropIndex === index; + const isDropAfter = dropIndex === screens.length && index === screens.length - 1; + const rules = screen.navigation?.rules ?? []; + const defaultNext = screen.navigation?.defaultNextScreenId; - {/* Screen Content */} -
-

- {screen.title.text || "Без названия"} -

- {hasSubtitle(screen) && ( -

{screen.subtitle.text}

- )} - - {/* List Screen Details */} - {isListScreen(screen) && ( -
-
- Тип выбора: - - {screen.list.selectionType === "single" ? "Single" : "Multi"} - + return ( +
+
+ {isDropBefore && } +
+
+
handleDragStart(event, screen.id, index)} + onDragOver={(event) => handleDragOverCard(event, index)} + onDragEnd={handleDragEnd} + onClick={() => handleSelectScreen(screen.id)} + > +
+
+
+ {index + 1} +
+
+ #{screen.id} + + {screen.title.text || "Без названия"} + +
-
- Опции: {screen.list.options.length} -
- {screen.list.options.slice(0, 2).map((option) => ( - - {option.label} + + {screen.template} + +
+ + {screen.subtitle?.text && ( +

{screen.subtitle.text}

+ )} + +
+ + +
+
+ Переходы +
+
+ +
+
+ + По умолчанию - ))} - {screen.list.options.length > 2 && ( - - +{screen.list.options.length - 2} ещё + + {defaultNext ? screenTitleMap[defaultNext] ?? defaultNext : "Воронка завершится"} - )} +
+ + {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 ( +
+
+ + Вариативность + + {condition?.operator && ( + + {condition.operator} + + )} +
+ {optionSummaries.length > 0 && ( +
+ {optionSummaries.map((option) => ( + + {option.label} + + ))} +
+ )} +
+ → {screenTitleMap[rule.nextScreenId] ?? rule.nextScreenId} +
+
+ ); + })}
- )} -
- - {/* Navigation Info */} -
-
- Следующий: - - {screen.navigation?.defaultNextScreenId ?? "—"} -
+ {isDropAfter && }
+ ); + })} - {/* Arrow to next screen */} - {index < screens.length - 1 && ( -
-
-
-
-
-
- )} + {screens.length === 0 && ( +
+ Добавьте первый экран, чтобы начать строить воронку.
- ); - })} + )} + +
+ +
+
diff --git a/src/components/admin/builder/BuilderSidebar.tsx b/src/components/admin/builder/BuilderSidebar.tsx index bc2145a..342138d 100644 --- a/src/components/admin/builder/BuilderSidebar.tsx +++ b/src/components/admin/builder/BuilderSidebar.tsx @@ -1,22 +1,27 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { TextInput } from "@/components/ui/TextInput/TextInput"; import { Button } from "@/components/ui/button"; +import { TemplateConfig } from "@/components/admin/builder/templates"; import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context"; import type { BuilderScreen } from "@/lib/admin/builder/types"; -import type { NavigationRuleDefinition } from "@/lib/funnel/types"; +import type { NavigationRuleDefinition, ScreenDefinition } from "@/lib/funnel/types"; import { cn } from "@/lib/utils"; import { validateBuilderState } from "@/lib/admin/builder/validation"; -// Type guards для безопасной работы с разными типами экранов -function isListScreen(screen: BuilderScreen): screen is BuilderScreen & { list: { selectionType: "single" | "multi"; options: Array<{ id: string; label: string; description?: string; emoji?: string; }> } } { - return screen.template === "list" && "list" in screen; -} +type ValidationIssues = ReturnType["issues"]; -function hasSubtitle(screen: BuilderScreen): screen is BuilderScreen & { subtitle?: { text: string; color?: string; font?: string; } } { - return "subtitle" in screen; +function isListScreen( + screen: BuilderScreen +): screen is BuilderScreen & { + list: { + selectionType: "single" | "multi"; + options: Array<{ id: string; label: string; description?: string; emoji?: string }>; + }; +} { + return screen.template === "list" && "list" in screen; } function Section({ @@ -26,7 +31,7 @@ function Section({ }: { title: string; description?: string; - children: React.ReactNode; + children: ReactNode; }) { return (
@@ -39,12 +44,8 @@ function Section({ ); } - -function ValidationSummary() { - const state = useBuilderState(); - const validation = useMemo(() => validateBuilderState(state), [state]); - - if (validation.issues.length === 0) { +function ValidationSummary({ issues }: { issues: ValidationIssues }) { + if (issues.length === 0) { return (
Всё хорошо — воронка валидна. @@ -54,7 +55,7 @@ function ValidationSummary() { return (
- {validation.issues.map((issue, index) => ( + {issues.map((issue, index) => (
state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), [ - state.screens, - ]); + const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel"); + const selectedScreenId = selectedScreen?.id ?? null; + + useEffect(() => { + setActiveTab((previous) => { + if (selectedScreenId) { + return "screen"; + } + return previous === "screen" ? "funnel" : previous; + }); + }, [selectedScreenId]); + + const validation = useMemo(() => validateBuilderState(state), [state]); + const screenValidationIssues = useMemo(() => { + if (!selectedScreenId) { + return [] as ValidationIssues; + } + + return validation.issues.filter((issue) => issue.screenId === selectedScreenId); + }, [selectedScreenId, validation]); + + const screenOptions = useMemo( + () => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })), + [state.screens] + ); const handleMetaChange = (field: keyof typeof state.meta, value: string) => { dispatch({ type: "set-meta", payload: { [field]: value } }); @@ -96,32 +119,6 @@ export function BuilderSidebar() { const getScreenById = (screenId: string): BuilderScreen | undefined => state.screens.find((item) => item.id === screenId); - const updateList = ( - screen: BuilderScreen, - listUpdates: Partial<{ selectionType: "single" | "multi"; options: Array<{ id: string; label: string; description?: string; emoji?: string; }> }> - ) => { - if (!isListScreen(screen)) { - return; - } - - const nextList = { - ...screen.list, - ...listUpdates, - selectionType: listUpdates.selectionType ?? screen.list.selectionType, - options: listUpdates.options ?? screen.list.options, - }; - - dispatch({ - type: "update-screen", - payload: { - screenId: screen.id, - screen: { - list: nextList, - }, - }, - }); - }; - const updateNavigation = ( screen: BuilderScreen, navigationUpdates: Partial = {} @@ -139,90 +136,6 @@ export function BuilderSidebar() { }); }; - const handleSelectionTypeChange = ( - screenId: string, - selectionType: "single" | "multi" - ) => { - const screen = getScreenById(screenId); - if (!screen || !isListScreen(screen)) { - return; - } - - updateList(screen, { selectionType }); - }; - - const handleTitleChange = (screenId: string, value: string) => { - dispatch({ - type: "update-screen", - payload: { - screenId, - screen: { - title: { - text: value, - }, - }, - }, - }); - }; - - const handleSubtitleChange = (screenId: string, value: string) => { - dispatch({ - type: "update-screen", - payload: { - screenId, - screen: { - subtitle: value - ? { text: value, color: "muted", font: "inter" } - : undefined, - }, - }, - }); - }; - - const handleOptionChange = ( - screenId: string, - index: number, - field: "label" | "id" | "emoji" | "description", - value: string - ) => { - const screen = getScreenById(screenId); - if (!screen || !isListScreen(screen)) { - return; - } - - const options = screen.list.options.map((option, optionIndex) => - optionIndex === index ? { ...option, [field]: value } : option - ); - - updateList(screen, { options }); - }; - - const handleAddOption = (screen: BuilderScreen) => { - if (!isListScreen(screen)) { - return; - } - - const nextIndex = screen.list.options.length + 1; - const options = [ - ...screen.list.options, - { - id: `option-${nextIndex}`, - label: `Вариант ${nextIndex}`, - }, - ]; - - updateList(screen, { options }); - }; - - const handleRemoveOption = (screen: BuilderScreen, index: number) => { - if (!isListScreen(screen)) { - return; - } - - const options = screen.list.options.filter((_, optionIndex) => optionIndex !== index); - updateList(screen, { options }); - }; - const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => { const screen = getScreenById(screenId); if (!screen) { @@ -332,7 +245,10 @@ export function BuilderSidebar() { optionIds: screen.list.options.slice(0, 1).map((option) => option.id), }; - const nextRules = [...(screen.navigation?.rules ?? []), { nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] }]; + const nextRules = [ + ...(screen.navigation?.rules ?? []), + { nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] }, + ]; updateNavigation(screen, { rules: nextRules }); }; @@ -354,326 +270,284 @@ export function BuilderSidebar() { dispatch({ type: "remove-screen", payload: { screenId } }); }; - // Показываем настройки воронки, если экран не выбран - if (!selectedScreen) { - return ( -
-
-
- -
+ const handleTemplateUpdate = (screenId: string, updates: Partial) => { + dispatch({ + type: "update-screen", + payload: { + screenId, + screen: updates as Partial, + }, + }); + }; -
- handleMetaChange("id", event.target.value)} - /> - handleMetaChange("title", event.target.value)} - /> - handleMetaChange("description", event.target.value)} - /> - -
- -
-
-

- Выберите экран на канвасе для редактирования его настроек. -

-
- Всего экранов: {state.screens.length} -
-
-
-
-
- ); - } - - // Показываем настройки выбранного экрана - const selectedScreenIsListType = isListScreen(selectedScreen); + const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false; return ( -
-
- {/* Информация о выбранном экране */} -
-
-
-
- Редактируем экран -
- -
-
- ID: {selectedScreen.id} • - Тип: {selectedScreen.template} -
+
+
+
+ + Режим редактирования + +

Настройки

+
+ + +
+
-
-
- - handleTitleChange(selectedScreen.id, event.target.value)} - /> - handleSubtitleChange(selectedScreen.id, event.target.value)} - /> -
-
- Тип экрана: {selectedScreen.template} -
- Позиция в воронке: экран {state.screens.findIndex(s => s.id === selectedScreen.id) + 1} из {state.screens.length} -
-
-
-
-
+
+ {activeTab === "funnel" ? ( +
+
+ +
- {selectedScreenIsListType && ( -
-
-
- -
-
+ + +
- {selectedScreenIsListType && ( -
-
-
-

- Направляйте пользователей на разные экраны в зависимости от выбора. -

- -
- - {(selectedScreen.navigation?.rules ?? []).length === 0 && ( -
- Правил пока нет +
+
+
+ Всего экранов + {state.screens.length}
- )} +
+ {state.screens.map((screen, index) => ( + + {index + 1}. {screen.title.text} + {screen.template} + + ))} +
+
+
+
+ ) : selectedScreen ? ( +
+
+
+ + ID: {selectedScreen.id} + + + Тип: {selectedScreen.template} + + + Позиция: экран {state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1} из {state.screens.length} + +
+
- {(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => ( -
+ +
+ Текущий шаблон: {selectedScreen.template} +
+
+ +
+ handleTemplateUpdate(selectedScreen.id, updates)} + /> +
+ +
+ +
+ + {selectedScreenIsListType && ( +
+
- Правило {ruleIndex + 1} -
- -
- Варианты ответа -
- {selectedScreenIsListType && selectedScreen.list.options.map((option) => { - const condition = rule.conditions[0]; - const isChecked = condition.optionIds?.includes(option.id) ?? false; - return ( - - ); - })} + {(selectedScreen.navigation?.rules ?? []).length === 0 && ( +
+ Правил пока нет
-
+ )} - +
+ Правило {ruleIndex + 1} + +
+ + +
+ Варианты ответа +
+ {selectedScreen.list.options.map((option) => { + const condition = rule.conditions[0]; + const isChecked = condition.optionIds?.includes(option.id) ?? false; + return ( + + ); + })} +
+
+ + +
+ ))}
- ))} -
- - )} + + )} -
- -
+
+ +
-
-
-

- Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны. -

- +
+
+

+ Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны. +

+ +
+
-
+ ) : ( +
+ Выберите экран в списке слева, чтобы настроить его параметры. +
+ )}
); diff --git a/src/components/admin/builder/templates/CouponScreenConfig.tsx b/src/components/admin/builder/templates/CouponScreenConfig.tsx index 73edbd9..773cbac 100644 --- a/src/components/admin/builder/templates/CouponScreenConfig.tsx +++ b/src/components/admin/builder/templates/CouponScreenConfig.tsx @@ -11,179 +11,96 @@ interface CouponScreenConfigProps { export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) { const couponScreen = screen as CouponScreenDefinition & { position: { x: number; y: number } }; - + + const handleCouponUpdate = ( + field: T, + value: CouponScreenDefinition["coupon"][T] + ) => { + onUpdate({ + coupon: { + ...couponScreen.coupon, + [field]: value, + }, + }); + }; + return ( -
- {/* Title Configuration */} -
- - onUpdate({ - title: { - ...couponScreen.title, - text: e.target.value, - font: couponScreen.title?.font || "manrope", - weight: couponScreen.title?.weight || "bold", - align: couponScreen.title?.align || "center", - } - })} - /> -
- - {/* Subtitle Configuration */} -
- - onUpdate({ - subtitle: { - ...couponScreen.subtitle, - text: e.target.value, - font: couponScreen.subtitle?.font || "inter", - weight: couponScreen.subtitle?.weight || "medium", - align: couponScreen.subtitle?.align || "center", - } - })} - /> -
- - {/* Coupon Configuration */} +
-

Coupon Details

- -
- +

+ Настройки оффера +

+
- -
- - 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", - } - } - } - })} - /> -
- -
- - onUpdate({ - coupon: { - ...couponScreen.coupon, - promoCode: { - ...couponScreen.coupon?.promoCode, - text: e.target.value, - font: couponScreen.coupon?.promoCode?.font || "manrope", - weight: couponScreen.coupon?.promoCode?.weight || "bold", - } - } - })} - /> -
- -
- - onUpdate({ - coupon: { - ...couponScreen.coupon, - footer: { - ...couponScreen.coupon?.footer, - text: e.target.value, - font: couponScreen.coupon?.footer?.font || "inter", - weight: couponScreen.coupon?.footer?.weight || "medium", - } - } - })} - /> -
-
- - {/* Bottom Action Button */} -
- - onUpdate({ - bottomActionButton: { - text: e.target.value || "Continue", + placeholder="-50% на первый заказ" + value={couponScreen.coupon?.offer?.title?.text ?? ""} + onChange={(event) => + handleCouponUpdate("offer", { + ...couponScreen.coupon.offer, + title: { + ...(couponScreen.coupon.offer?.title ?? {}), + text: event.target.value, + }, + }) } - })} - /> + /> + +
- {/* Header Configuration */} -
-

Header Settings

- -
); diff --git a/src/components/admin/builder/templates/DateScreenConfig.tsx b/src/components/admin/builder/templates/DateScreenConfig.tsx index 0e39557..b02a772 100644 --- a/src/components/admin/builder/templates/DateScreenConfig.tsx +++ b/src/components/admin/builder/templates/DateScreenConfig.tsx @@ -11,176 +11,140 @@ interface DateScreenConfigProps { export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) { const dateScreen = screen as DateScreenDefinition & { position: { x: number; y: number } }; - + + const handleDateInputChange = (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 ( -
- {/* Title Configuration */} -
- - onUpdate({ - title: { - ...dateScreen.title, - text: e.target.value, - font: dateScreen.title?.font || "manrope", - weight: dateScreen.title?.weight || "bold", - } - })} - /> -
- - {/* Subtitle Configuration */} -
- - 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 - })} - /> -
- - {/* Date Input Labels */} +
-

Date Input Labels

- -
-
- +

+ Поля ввода даты +

+
+
- -
- + +
- -
- + +
+
-
-
- +
+
- -
- + +
- -
- + +
+
- {/* Info Message */} -
- - onUpdate({ - infoMessage: e.target.value ? { - text: e.target.value, - icon: dateScreen.infoMessage?.icon || "🔒", - } : undefined - })} - /> - - {dateScreen.infoMessage && ( - onUpdate({ - infoMessage: { - text: dateScreen.infoMessage?.text || "", - icon: e.target.value, - } - })} +
+

Поведение поля

+ + +
+ + +
+ +
- {/* Bottom Action Button */} -
- - onUpdate({ - bottomActionButton: e.target.value ? { - text: e.target.value, - } : undefined - })} - /> +
+

Информационный блок

+ + {dateScreen.infoMessage && ( + + )}
); diff --git a/src/components/admin/builder/templates/FormScreenConfig.tsx b/src/components/admin/builder/templates/FormScreenConfig.tsx index b7bafcb..930cdfd 100644 --- a/src/components/admin/builder/templates/FormScreenConfig.tsx +++ b/src/components/admin/builder/templates/FormScreenConfig.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { TextInput } from "@/components/ui/TextInput/TextInput"; -import type { FormScreenDefinition, FormFieldDefinition } from "@/lib/funnel/types"; +import type { FormScreenDefinition, FormFieldDefinition, FormValidationMessages } from "@/lib/funnel/types"; import type { BuilderScreen } from "@/lib/admin/builder/types"; interface FormScreenConfigProps { @@ -12,180 +12,202 @@ interface FormScreenConfigProps { export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) { const formScreen = screen as FormScreenDefinition & { position: { x: number; y: number } }; - + const updateField = (index: number, updates: Partial) => { const newFields = [...(formScreen.fields || [])]; newFields[index] = { ...newFields[index], ...updates }; onUpdate({ fields: newFields }); }; - + + const updateValidationMessages = (updates: Partial) => { + onUpdate({ + validationMessages: { + ...(formScreen.validationMessages ?? {}), + ...updates, + }, + }); + }; + const addField = () => { const newField: FormFieldDefinition = { id: `field_${Date.now()}`, - label: "New Field", - placeholder: "Enter value", + label: "Новое поле", + placeholder: "Введите значение", type: "text", required: true, }; - + onUpdate({ - fields: [...(formScreen.fields || []), newField] + fields: [...(formScreen.fields || []), newField], }); }; - + const removeField = (index: number) => { const newFields = formScreen.fields?.filter((_, i) => i !== index) || []; onUpdate({ fields: newFields }); }; - + return ( -
- {/* Title Configuration */} -
- - onUpdate({ - title: { - ...formScreen.title, - text: e.target.value, - font: formScreen.title?.font || "manrope", - weight: formScreen.title?.weight || "bold", - } - })} - /> -
- - {/* Subtitle Configuration */} -
- - 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 - })} - /> -
- - {/* Form Fields Configuration */} +
-

Form Fields

-
- + {formScreen.fields?.map((field, index) => ( -
-
- Field {index + 1} +
+
+ + Поле {index + 1} +
- -
-
- - updateField(index, { id: e.target.value })} - /> -
- -
- + +
+ +
+
- -
- + +
- -
- + + +
- -
- + +
+ + +
+ +
+ + - - {field.maxLength && ( -
- - updateField(index, { maxLength: parseInt(e.target.value) || undefined })} - /> -
- )}
))} - + {(!formScreen.fields || formScreen.fields.length === 0) && ( -
- No fields added yet. Click "Add Field" to get started. +
+ Пока нет полей. Добавьте хотя бы одно, чтобы форма работала.
)}
- {/* Bottom Action Button */} -
- - onUpdate({ - bottomActionButton: { - text: e.target.value || "Continue", - } - })} - /> +
+

Сообщения валидации

+
+ + + +
); diff --git a/src/components/admin/builder/templates/InfoScreenConfig.tsx b/src/components/admin/builder/templates/InfoScreenConfig.tsx index ebb91ff..f6c55e1 100644 --- a/src/components/admin/builder/templates/InfoScreenConfig.tsx +++ b/src/components/admin/builder/templates/InfoScreenConfig.tsx @@ -1,7 +1,7 @@ "use client"; import { TextInput } from "@/components/ui/TextInput/TextInput"; -import type { InfoScreenDefinition, TypographyVariant } from "@/lib/funnel/types"; +import type { InfoScreenDefinition } from "@/lib/funnel/types"; import type { BuilderScreen } from "@/lib/admin/builder/types"; interface InfoScreenConfigProps { @@ -11,145 +11,91 @@ interface InfoScreenConfigProps { export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) { const infoScreen = screen as InfoScreenDefinition & { position: { x: number; y: number } }; - + + const handleDescriptionChange = (text: string) => { + onUpdate({ + description: text + ? { + ...(infoScreen.description ?? {}), + text, + } + : undefined, + }); + }; + + const handleIconChange = >( + field: T, + value: NonNullable[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 ( -
- {/* Title Configuration */} -
- - onUpdate({ - title: { - ...infoScreen.title, - text: e.target.value, - font: infoScreen.title?.font || "manrope", - weight: infoScreen.title?.weight || "bold", - align: infoScreen.title?.align || "center", - } - })} - /> - -
- - - +
+
+

+ Информационный контент +

+ +
+ +
+

Иконка

+
+ +
-
- {/* Description Configuration */} -
- - onUpdate({ - description: e.target.value ? { - text: e.target.value, - font: infoScreen.description?.font || "inter", - weight: infoScreen.description?.weight || "medium", - align: infoScreen.description?.align || "center", - } : undefined - })} - /> -
- - {/* Icon Configuration */} -
- -
- - - -
- - onUpdate({ - icon: e.target.value ? { - type: infoScreen.icon?.type || "emoji", - value: e.target.value, - size: infoScreen.icon?.size || "lg", - } : undefined - })} - /> -
- - {/* Bottom Action Button */} -
- - onUpdate({ - bottomActionButton: e.target.value ? { - text: e.target.value, - } : undefined - })} - /> +
); diff --git a/src/components/admin/builder/templates/ListScreenConfig.tsx b/src/components/admin/builder/templates/ListScreenConfig.tsx index df33df6..df239d7 100644 --- a/src/components/admin/builder/templates/ListScreenConfig.tsx +++ b/src/components/admin/builder/templates/ListScreenConfig.tsx @@ -2,8 +2,8 @@ import { TextInput } from "@/components/ui/TextInput/TextInput"; import { Button } from "@/components/ui/button"; -import { Trash2, Plus } from "lucide-react"; -import type { ListScreenDefinition, ListOptionDefinition, SelectionType } from "@/lib/funnel/types"; +import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react"; +import type { ListScreenDefinition, ListOptionDefinition, SelectionType, BottomActionButtonDefinition } from "@/lib/funnel/types"; import type { BuilderScreen } from "@/lib/admin/builder/types"; interface ListScreenConfigProps { @@ -11,194 +11,334 @@ interface ListScreenConfigProps { onUpdate: (updates: Partial) => void; } +function mutateOptions( + options: ListOptionDefinition[], + index: number, + mutation: (option: ListOptionDefinition) => ListOptionDefinition +): ListOptionDefinition[] { + return options.map((option, currentIndex) => (currentIndex === index ? mutation(option) : option)); +} + export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) { const listScreen = screen as ListScreenDefinition & { position: { x: number; y: number } }; - - const handleTitleChange = (text: string) => { - onUpdate({ - title: { - ...listScreen.title, - text, - font: listScreen.title?.font || "manrope", - weight: listScreen.title?.weight || "bold", - align: listScreen.title?.align || "left", - } - }); - }; - - const handleSubtitleChange = (text: string) => { - onUpdate({ - subtitle: text ? { - ...listScreen.subtitle, - text, - font: listScreen.subtitle?.font || "inter", - weight: listScreen.subtitle?.weight || "medium", - color: listScreen.subtitle?.color || "muted", - align: listScreen.subtitle?.align || "left", - } : undefined - }); - }; const handleSelectionTypeChange = (selectionType: SelectionType) => { onUpdate({ list: { ...listScreen.list, selectionType, - } + }, }); }; - const handleOptionChange = (index: number, field: keyof ListOptionDefinition, value: string | boolean) => { - const newOptions = [...listScreen.list.options]; - newOptions[index] = { - ...newOptions[index], - [field]: value, - }; - + const handleAutoAdvanceChange = (checked: boolean) => { onUpdate({ list: { ...listScreen.list, - options: newOptions, - } + autoAdvance: checked || undefined, + }, + }); + }; + + const handleOptionChange = ( + index: number, + field: keyof ListOptionDefinition, + value: string | boolean | undefined + ) => { + const nextOptions = mutateOptions(listScreen.list.options, index, (option) => ({ + ...option, + [field]: value, + })); + + onUpdate({ + list: { + ...listScreen.list, + options: nextOptions, + }, + }); + }; + + const handleMoveOption = (index: number, direction: -1 | 1) => { + const nextOptions = [...listScreen.list.options]; + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= nextOptions.length) { + return; + } + const [current] = nextOptions.splice(index, 1); + nextOptions.splice(targetIndex, 0, current); + + onUpdate({ + list: { + ...listScreen.list, + options: nextOptions, + }, }); }; const handleAddOption = () => { - const newOptions = [...listScreen.list.options]; - newOptions.push({ - id: `option-${Date.now()}`, - label: "New Option", - }); - + const nextOptions = [ + ...listScreen.list.options, + { + id: `option-${Date.now()}`, + label: "Новый вариант", + }, + ]; + onUpdate({ list: { ...listScreen.list, - options: newOptions, - } + options: nextOptions, + }, }); }; const handleRemoveOption = (index: number) => { - const newOptions = listScreen.list.options.filter((_, i) => i !== index); - + const nextOptions = listScreen.list.options.filter((_, currentIndex) => currentIndex !== index); + onUpdate({ list: { ...listScreen.list, - options: newOptions, - } + options: nextOptions, + }, }); }; - const handleBottomActionButtonChange = (text: string) => { + const handleListButtonChange = (value: BottomActionButtonDefinition | undefined) => { onUpdate({ list: { ...listScreen.list, - bottomActionButton: text ? { - text, - show: true, - } : undefined, - } + bottomActionButton: value, + }, }); }; return ( -
- {/* Title Configuration */} -
- - handleTitleChange(e.target.value)} - /> -
- - {/* Subtitle Configuration */} -
- - handleSubtitleChange(e.target.value)} - /> -
- - {/* Selection Type */} -
- -
- - -
-
- - {/* Options */} +
- - + +
+
+ + +
+ +
+
+

Настройка вариантов

+
- -
+ +
{listScreen.list.options.map((option, index) => ( -
-
- handleOptionChange(index, "id", e.target.value)} - /> +
+
+ + Вариант {index + 1} + +
+ + + +
-
+ +
+ + +
+ + + +
+ +
- + +
))}
+ + {listScreen.list.options.length === 0 && ( +
+ Добавьте хотя бы один вариант, чтобы экран работал корректно. +
+ )}
- {/* Bottom Action Button */} -
- - handleBottomActionButtonChange(e.target.value)} - /> -
- {listScreen.list.selectionType === "multi" - ? "Multi selection always shows a button" - : "Single selection: empty = auto-advance, filled = manual button"} +
+

Кнопка внутри списка

+
+ + + {listScreen.list.bottomActionButton && ( +
+ +
+ + +
+
+ )} + +

+ Для одиночного выбора пустая кнопка включает авто-переход. Для множественного выбора кнопка отображается всегда. +

diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index 3472c2e..7db1cd3 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -1,70 +1,432 @@ "use client"; +import { useMemo } from "react"; + import { InfoScreenConfig } from "./InfoScreenConfig"; import { DateScreenConfig } from "./DateScreenConfig"; import { CouponScreenConfig } from "./CouponScreenConfig"; import { FormScreenConfig } from "./FormScreenConfig"; import { ListScreenConfig } from "./ListScreenConfig"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; import type { BuilderScreen } from "@/lib/admin/builder/types"; -import type { ScreenDefinition, InfoScreenDefinition, DateScreenDefinition, CouponScreenDefinition, FormScreenDefinition, ListScreenDefinition } from "@/lib/funnel/types"; +import type { + ScreenDefinition, + InfoScreenDefinition, + DateScreenDefinition, + CouponScreenDefinition, + FormScreenDefinition, + ListScreenDefinition, + TypographyVariant, + BottomActionButtonDefinition, + HeaderDefinition, +} from "@/lib/funnel/types"; + +const FONT_OPTIONS: TypographyVariant["font"][] = ["manrope", "inter", "geistSans", "geistMono"]; +const WEIGHT_OPTIONS: TypographyVariant["weight"][] = [ + "regular", + "medium", + "semiBold", + "bold", + "extraBold", + "black", +]; +const SIZE_OPTIONS: TypographyVariant["size"][] = ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"]; +const ALIGN_OPTIONS: TypographyVariant["align"][] = ["left", "center", "right"]; +const COLOR_OPTIONS: Exclude[] = [ + "default", + "primary", + "secondary", + "destructive", + "success", + "card", + "accent", + "muted", +]; +const RADIUS_OPTIONS: BottomActionButtonDefinition["cornerRadius"][] = ["3xl", "full"]; interface TemplateConfigProps { screen: BuilderScreen; onUpdate: (updates: Partial) => 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) => { + 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 ( +
+
+ + handleTextChange(event.target.value)} /> +
+ +
+ + + + + + {allowRemove && ( + + )} +
+
+ ); +} + +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 ( +
+ + + {activeHeader.show !== false && ( +
+ + +
+ + + + +
+
+ )} +
+ ); +} + +interface ActionButtonControlsProps { + label: string; + value: BottomActionButtonDefinition | undefined; + onChange: (value: BottomActionButtonDefinition | undefined) => void; +} + +function ActionButtonControls({ label, value, onChange }: ActionButtonControlsProps) { + const active = useMemo(() => value, [value]); + const isEnabled = Boolean(active); + + return ( +
+ + + {isEnabled && ( +
+ + +
+ + + + +
+
+ )} +
+ ); +} + export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) { const { template } = screen; - switch (template) { - case "info": - return ( - ) => void} - /> - ); - - case "date": - return ( - ) => void} - /> - ); - - case "coupon": - return ( - ) => void} - /> - ); - - case "form": - return ( - ) => void} - /> - ); - - case "list": - return ( - ) => void} - /> - ); - - default: - return ( -
-
- Unknown template type: {template} -
-
- ); - } + const handleTitleChange = (value: TypographyVariant) => { + onUpdate({ title: value }); + }; + + const handleSubtitleChange = (value: TypographyVariant | undefined) => { + onUpdate({ subtitle: value }); + }; + + const handleHeaderChange = (value: HeaderDefinition | undefined) => { + onUpdate({ header: value }); + }; + + const handleButtonChange = (value: BottomActionButtonDefinition | undefined) => { + onUpdate({ bottomActionButton: value }); + }; + + return ( +
+
+ + + + +
+ +
+ {template === "info" && ( + ) => void} + /> + )} + {template === "date" && ( + ) => void} + /> + )} + {template === "coupon" && ( + ) => void} + /> + )} + {template === "form" && ( + ) => void} + /> + )} + {template === "list" && ( + ) => void} + /> + )} +
+
+ ); } diff --git a/src/lib/admin/builder/utils.ts b/src/lib/admin/builder/utils.ts index 0b7625d..e0f6fd4 100644 --- a/src/lib/admin/builder/utils.ts +++ b/src/lib/admin/builder/utils.ts @@ -24,6 +24,7 @@ export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderSt } export function serializeBuilderState(state: BuilderFunnelState): FunnelDefinition { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const screens = state.screens.map(({ position: _position, ...rest }) => rest); const meta: FunnelDefinition["meta"] = { ...state.meta, @@ -65,6 +66,7 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial