This commit is contained in:
dev.daminik00 2025-09-28 15:59:45 +02:00
parent b3eaa19fcd
commit e98b1bfc05
63 changed files with 861 additions and 829 deletions

View File

@ -3,11 +3,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { BuilderProvider } from "@/lib/admin/builder/context"; import { BuilderProvider } from "@/lib/admin/builder/context";
import { BuilderUndoRedoProvider } from "@/components/admin/builder/BuilderUndoRedoProvider"; import {
import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar"; BuilderUndoRedoProvider,
import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar"; BuilderTopBar,
import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas"; BuilderSidebar,
import { BuilderPreview } from "@/components/admin/builder/BuilderPreview"; BuilderCanvas,
BuilderPreview
} from "@/components/admin/builder";
import type { BuilderState } from '@/lib/admin/builder/context'; import type { BuilderState } from '@/lib/admin/builder/context';
import type { FunnelDefinition } from '@/lib/funnel/types'; import type { FunnelDefinition } from '@/lib/funnel/types';
import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils'; import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils';
@ -83,12 +85,7 @@ export default function FunnelBuilderPage() {
nextButton: 'Далее', nextButton: 'Далее',
continueButton: 'Продолжить' continueButton: 'Продолжить'
}, },
screens: builderState.screens.map(screen => { screens: builderState.screens
// Убираем position из экрана при сохранении
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { position, ...screenWithoutPosition } = screen;
return screenWithoutPosition;
})
}; };
const response = await fetch(`/api/funnels/${funnelId}`, { const response = await fetch(`/api/funnels/${funnelId}`, {
@ -139,11 +136,7 @@ export default function FunnelBuilderPage() {
nextButton: 'Далее', nextButton: 'Далее',
continueButton: 'Продолжить' continueButton: 'Продолжить'
}, },
screens: builderState.screens.map(screen => { screens: builderState.screens
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { position, ...screenWithoutPosition } = screen;
return screenWithoutPosition;
})
}; };
await fetch(`/api/funnels/${funnelId}/history`, { await fetch(`/api/funnels/${funnelId}/history`, {

View File

@ -10,6 +10,8 @@ interface RouteParams {
}>; }>;
} }
// No normalization needed: we require `progressbars` for loaders
// GET /api/funnels/[id] - получить конкретную воронку // GET /api/funnels/[id] - получить конкретную воронку
export async function GET(request: NextRequest, { params }: RouteParams) { export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
@ -86,6 +88,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
if (status !== undefined) funnel.status = status; if (status !== undefined) funnel.status = status;
if (funnelData !== undefined) { if (funnelData !== undefined) {
// Save as-is; schema expects `progressbars` for loaders
funnel.funnelData = funnelData as FunnelDefinition; funnel.funnelData = funnelData as FunnelDefinition;
// Увеличиваем версию только при публикации // Увеличиваем версию только при публикации
@ -111,7 +114,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
await FunnelHistoryModel.create({ await FunnelHistoryModel.create({
funnelId: id, funnelId: id,
sessionId, sessionId,
funnelSnapshot: funnelData, funnelSnapshot: funnelData as FunnelDefinition,
actionType: status === 'published' ? 'publish' : 'update', actionType: status === 'published' ? 'publish' : 'update',
sequenceNumber: nextSequenceNumber, sequenceNumber: nextSequenceNumber,
description: actionDescription || 'Воронка обновлена', description: actionDescription || 'Воронка обновлена',
@ -119,7 +122,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
changeDetails: { changeDetails: {
action: 'update-funnel', action: 'update-funnel',
previousValue: previousData, previousValue: previousData,
newValue: funnelData newValue: funnelData as FunnelDefinition
} }
}); });

View File

@ -1,166 +0,0 @@
"use client";
import { AgeSelector } from "./AgeSelector";
import { AGE_EXAMPLES, calculateAgeFromArray, getAgeGroup, getGenerationFromArray, createAgeValue, createGenerationValue } from "@/lib/age-utils";
import { useState } from "react";
/**
* Демо компонент для показа возможностей системы возраста
*/
export function AgeDemo() {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const toggleValue = (value: string) => {
setSelectedValues(prev =>
prev.includes(value)
? prev.filter(v => v !== value)
: [...prev, value]
);
};
const addCustomValue = (value: string) => {
if (!selectedValues.includes(value)) {
setSelectedValues(prev => [...prev, value]);
}
};
return (
<div className="space-y-6 p-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold">🎂 Система работы с возрастом WitLab</h1>
<p className="text-muted-foreground">
Автоматический расчет возраста и поколений из даты рождения для системы вариативности
</p>
</div>
{/* 📖 ПРИМЕРЫ РАСЧЕТОВ */}
<div className="space-y-4">
<h2 className="text-lg font-semibold">📖 Примеры автоматических расчетов:</h2>
{AGE_EXAMPLES.map((example, index) => (
<div key={index} className="space-y-2 p-4 bg-muted/20 rounded-lg border">
<div className="text-sm font-medium text-foreground">
{example.description}
</div>
{/* Исходная дата */}
<div className="text-xs text-muted-foreground bg-slate-100 p-2 rounded font-mono">
Дата: [{example.input.join(', ')}] ({example.input[1]}.{example.input[0]}.{example.input[2]})
</div>
{/* Рассчитанные значения */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<strong>Возраст:</strong> {example.age} лет
<br />
<strong>Группа:</strong> {example.ageGroup || 'Не определена'}
</div>
<div>
<strong>Поколение:</strong> {example.generation || 'Не определено'}
<br />
<strong>Значения:</strong> {[
createAgeValue(example.age),
`age-${example.age}`,
createGenerationValue(example.input[2])
].join(', ')}
</div>
</div>
</div>
))}
</div>
{/* 🎯 ИНТЕРАКТИВНЫЙ СЕЛЕКТОР */}
<div className="space-y-4">
<h2 className="text-lg font-semibold">🎯 Интерактивный селектор возраста:</h2>
<AgeSelector
selectedValues={selectedValues}
onToggleValue={toggleValue}
onAddCustomValue={addCustomValue}
/>
</div>
{/* 💡 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
<h3 className="text-sm font-semibold text-blue-800">💡 Как использовать в условиях навигации:</h3>
<div className="space-y-2 text-xs text-blue-700">
<div className="bg-white p-2 rounded border">
<strong>Пример 1 - Возрастные группы:</strong>
<pre className="mt-1 text-xs">
{`{
"conditions": [{
"screenId": "birth-date",
"conditionType": "values",
"operator": "includesAny",
"values": ["22-25", "26-30"] // Молодые профессионалы
}]
}`}
</pre>
</div>
<div className="bg-white p-2 rounded border">
<strong>Пример 2 - Поколения:</strong>
<pre className="mt-1 text-xs">
{`{
"conditions": [{
"screenId": "birth-date",
"conditionType": "values",
"operator": "equals",
"values": ["millennials"] // Только миллениалы
}]
}`}
</pre>
</div>
<div className="bg-white p-2 rounded border">
<strong>Пример 3 - Комбинированные условия:</strong>
<pre className="mt-1 text-xs">
{`{
"conditions": [
{
"screenId": "birth-date",
"conditionType": "values",
"operator": "includesAny",
"values": ["aries", "leo", "sagittarius"] // Огненные знаки
},
{
"screenId": "birth-date",
"conditionType": "values",
"operator": "includesAny",
"values": ["22-25", "26-30"] // Молодые взрослые
}
]
}`}
</pre>
</div>
</div>
</div>
{/* 🚀 ВОЗМОЖНОСТИ СИСТЕМЫ */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-2">
<h3 className="text-sm font-semibold text-green-800">🚀 Автоматические значения из даты рождения:</h3>
<ul className="text-xs text-green-700 space-y-1">
<li> <strong>Точный возраст:</strong> age-25, age-30, age-45</li>
<li> <strong>Возрастные группы:</strong> 18-21, 22-25, 26-30, 31-35, 36-40, 41-45, 46-50, 51-60, 60+</li>
<li> <strong>Поколения:</strong> gen-z, millennials, gen-x, boomers, silent</li>
<li> <strong>Знаки зодиака:</strong> aries, taurus, gemini, cancer, leo, virgo, libra, scorpio, sagittarius, capricorn, aquarius, pisces</li>
<li> <strong>Кастомные диапазоны:</strong> 25-35, 40+, любые пользовательские значения</li>
</ul>
</div>
{/* 📋 ТЕХНИЧЕСКИЕ ДЕТАЛИ */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-2">
<h3 className="text-sm font-semibold text-gray-800">📋 Как это работает технически:</h3>
<ul className="text-xs text-gray-700 space-y-1">
<li><strong>1. Пользователь вводит дату:</strong> [4, 8, 1987] в date экране</li>
<li><strong>2. Система рассчитывает:</strong> возраст = {calculateAgeFromArray([4, 8, 1987])} лет</li>
<li><strong>3. Определяет группу:</strong> {getAgeGroup(calculateAgeFromArray([4, 8, 1987]))?.name}</li>
<li><strong>4. Определяет поколение:</strong> {getGenerationFromArray([4, 8, 1987])?.name}</li>
<li><strong>5. Добавляет в ответы:</strong> все вычисленные значения автоматически</li>
<li><strong>6. Система навигации:</strong> использует эти значения для условий</li>
</ul>
</div>
</div>
);
}

View File

@ -1,20 +0,0 @@
/**
* @deprecated This file has been refactored into modular structure.
* Use imports from "./Canvas" instead:
* - BuilderCanvas main component
* - DropIndicator, TransitionRow, TemplateSummary, VariantSummary sub-components
* - TEMPLATE_TITLES, OPERATOR_LABELS constants
* - getOptionLabel utility
*/
// Re-export everything from the new modular structure for backward compatibility
export {
BuilderCanvas,
DropIndicator,
TransitionRow,
TemplateSummary,
VariantSummary,
getOptionLabel,
TEMPLATE_TITLES,
OPERATOR_LABELS,
} from "./Canvas";

View File

@ -1,18 +0,0 @@
/**
* @deprecated This file has been refactored into modular structure.
* Use imports from "./Sidebar" instead:
* - BuilderSidebar main component
* - Section, ValidationSummary sub-components
* - isListScreen utility, ValidationIssues type
*/
// Re-export everything from the new modular structure for backward compatibility
export {
BuilderSidebar,
Section,
ValidationSummary,
isListScreen,
} from "./Sidebar";
// Re-export types for backward compatibility
export type { ValidationIssues, SectionProps } from "./Sidebar";

View File

@ -4,7 +4,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
import { ArrowDown } from "lucide-react"; import { ArrowDown } from "lucide-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 { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog"; import { AddScreenDialog } from "../dialogs/AddScreenDialog";
import type { import type {
ListOptionDefinition, ListOptionDefinition,
NavigationConditionDefinition, NavigationConditionDefinition,

View File

@ -1,79 +0,0 @@
"use client";
import { MarkupText, MarkupPreview } from "@/components/ui/MarkupText/MarkupText";
import { MARKUP_EXAMPLES } from "@/lib/text-markup";
/**
* Демо компонент для показа возможностей системы разметки
*/
export function MarkupDemo() {
return (
<div className="space-y-6 p-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold">🎨 Система разметки WitLab</h1>
<p className="text-muted-foreground">
Используйте **двойные звездочки** для выделения текста жирным шрифтом
</p>
</div>
{/* 📖 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ */}
<div className="space-y-4">
<h2 className="text-lg font-semibold">📖 Примеры использования:</h2>
{MARKUP_EXAMPLES.map((example, index) => (
<div key={index} className="space-y-2 p-4 bg-muted/20 rounded-lg border">
<div className="text-sm font-medium text-foreground">
{example.description}
</div>
{/* Исходный код */}
<div className="text-xs text-muted-foreground bg-slate-100 p-2 rounded font-mono">
{example.input}
</div>
{/* Результат */}
<div className="text-sm">
<strong>Результат:</strong>{" "}
<MarkupText>{example.input}</MarkupText>
</div>
</div>
))}
</div>
{/* 🎯 ИНТЕРАКТИВНОЕ ДЕМО */}
<div className="space-y-4">
<h2 className="text-lg font-semibold">🎯 Интерактивное превью:</h2>
<MarkupPreview text="Ваш **идеальный партнер** найден! Скидка **50%** только сегодня." />
<MarkupPreview text="Добро пожаловать в **WitLab**! Система анализа совместимости нового поколения." />
<MarkupPreview text="**Анализ завершен** - переходим к результатам вашего **портрета партнера**." />
</div>
{/* 💡 ИНСТРУКЦИИ */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2">
<h3 className="text-sm font-semibold text-blue-800">💡 Как использовать в админке:</h3>
<ul className="text-xs text-blue-700 space-y-1">
<li>1. Откройте любой экран в админке</li>
<li>2. В текстовых полях используйте **двойные звездочки** для выделения</li>
<li>3. Система автоматически покажет превью разметки</li>
<li>4. В воронке текст будет отображаться с жирным выделением</li>
</ul>
</div>
{/* 🚀 ПОДДЕРЖИВАЕМЫЕ ЭЛЕМЕНТЫ */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-2">
<h3 className="text-sm font-semibold text-green-800">🚀 Где работает разметка:</h3>
<ul className="text-xs text-green-700 space-y-1">
<li> Заголовки и подзаголовки всех экранов</li>
<li> Описания в Info и Soulmate экранах</li>
<li> Информационные сообщения в Date экранах</li>
<li> Лейблы и placeholder в Form экранах</li>
<li> Все текстовые поля Coupon экранов</li>
<li> Любой компонент Typography с enableMarkup</li>
</ul>
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } 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 { TemplateConfig } from "@/components/admin/builder/templates";
import { ScreenVariantsConfig } from "@/components/admin/builder/ScreenVariantsConfig"; import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig";
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 { import type {

View File

@ -0,0 +1,2 @@
// Dialog components for builder interface
export { AddScreenDialog } from "./AddScreenDialog";

View File

@ -0,0 +1,5 @@
// Form components and selectors for builder interface
export { AgeSelector } from "./AgeSelector";
export { EmailDomainSelector } from "./EmailDomainSelector";
export { ZodiacSelector } from "./ZodiacSelector";
export { ScreenVariantsConfig } from "./ScreenVariantsConfig";

View File

@ -0,0 +1,22 @@
// Builder interface components organized by category
// Layout components (main UI blocks)
export * from "./layout";
// Canvas components (screen flow visualization)
export * from "./Canvas";
// Sidebar components (screen configuration)
export * from "./Sidebar";
// Dialog components (modal windows)
export * from "./dialogs";
// Form components (selectors and configuration forms)
export * from "./forms";
// Template configuration components
export * from "./templates";
// Provider components (state management)
export * from "./providers";

View File

@ -6,7 +6,7 @@ import { ArrowLeft, Save, Globe, Download, Upload, Undo, Redo } from "lucide-rea
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { serializeBuilderState, deserializeFunnelDefinition } from "@/lib/admin/builder/utils"; import { serializeBuilderState, deserializeFunnelDefinition } from "@/lib/admin/builder/utils";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context"; import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import { useBuilderUndoRedo } from "@/components/admin/builder/BuilderUndoRedoProvider"; import { useBuilderUndoRedo } from "../providers/BuilderUndoRedoProvider";
import type { BuilderState } from "@/lib/admin/builder/context"; import type { BuilderState } from "@/lib/admin/builder/context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@ -0,0 +1,3 @@
// Layout components for builder interface
export { BuilderTopBar } from "./BuilderTopBar";
export { BuilderPreview } from "./BuilderPreview";

View File

@ -0,0 +1,2 @@
// Provider components for builder state management
export { BuilderUndoRedoProvider } from "./BuilderUndoRedoProvider";

View File

@ -10,7 +10,7 @@ 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;
const handleCouponUpdate = <T extends keyof CouponScreenDefinition["coupon"]>( const handleCouponUpdate = <T extends keyof CouponScreenDefinition["coupon"]>(
field: T, field: T,

View File

@ -10,7 +10,7 @@ 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;
const handleDateInputChange = <T extends keyof DateScreenDefinition["dateInput"]>( const handleDateInputChange = <T extends keyof DateScreenDefinition["dateInput"]>(
field: T, field: T,

View File

@ -12,7 +12,7 @@ interface FormScreenConfigProps {
} }
export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) { export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
const formScreen = screen as FormScreenDefinition & { position: { x: number; y: number } }; const formScreen = screen as FormScreenDefinition;
const updateField = (index: number, updates: Partial<FormFieldDefinition>) => { const updateField = (index: number, updates: Partial<FormFieldDefinition>) => {
const newFields = [...(formScreen.fields || [])]; const newFields = [...(formScreen.fields || [])];

View File

@ -11,7 +11,7 @@ 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;
const handleDescriptionChange = (text: string) => { const handleDescriptionChange = (text: string) => {
onUpdate({ onUpdate({

View File

@ -25,7 +25,7 @@ function mutateOptions(
} }
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;
const [expandedOptions, setExpandedOptions] = useState<Set<number>>(new Set()); const [expandedOptions, setExpandedOptions] = useState<Set<number>>(new Set());
const toggleOptionExpanded = (index: number) => { const toggleOptionExpanded = (index: number) => {

View File

@ -5,7 +5,7 @@ import Image from "next/image";
import Typography from "@/components/ui/Typography/Typography"; import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers"; import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { InfoScreenDefinition } from "@/lib/funnel/types"; import type { InfoScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "./TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface InfoTemplateProps { interface InfoTemplateProps {
@ -43,11 +43,9 @@ export function InfoTemplate({
return ( return (
<TemplateLayout <TemplateLayout
screen={screen} screen={screen}
onContinue={onContinue}
canGoBack={canGoBack} canGoBack={canGoBack}
onBack={onBack} onBack={onBack}
screenProgress={screenProgress} screenProgress={screenProgress}
defaultTexts={defaultTexts}
titleDefaults={{ font: "manrope", weight: "bold", align: "center" }} titleDefaults={{ font: "manrope", weight: "bold", align: "center" }}
subtitleDefaults={{ font: "inter", weight: "medium", color: "muted", align: "center" }} subtitleDefaults={{ font: "inter", weight: "medium", color: "muted", align: "center" }}
actionButtonOptions={{ actionButtonOptions={{

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { TemplateLayout } from "./TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
import CircularProgressbarsList from "@/components/widgets/CircularProgressbarsList/CircularProgressbarsList"; import CircularProgressbarsList from "@/components/widgets/CircularProgressbarsList/CircularProgressbarsList";
import type { LoadersScreenDefinition } from "@/lib/funnel/types"; import type { LoadersScreenDefinition } from "@/lib/funnel/types";
@ -44,11 +44,11 @@ export function LoadersTemplate({
}, },
processing: typedItem.processingTitle ? { processing: typedItem.processingTitle ? {
title: { children: typedItem.processingTitle }, title: { children: typedItem.processingTitle },
subtitle: typedItem.processingSubtitle ? { children: typedItem.processingSubtitle } : undefined, text: typedItem.processingSubtitle ? { children: typedItem.processingSubtitle } : undefined,
} : undefined, } : undefined,
completed: typedItem.completedTitle ? { completed: typedItem.completedTitle ? {
title: { children: typedItem.completedTitle }, title: { children: typedItem.completedTitle },
subtitle: typedItem.completedSubtitle ? { children: typedItem.completedSubtitle } : undefined, text: typedItem.completedSubtitle ? { children: typedItem.completedSubtitle } : undefined,
} : undefined, } : undefined,
}; };
}) || [], }) || [],
@ -59,11 +59,9 @@ export function LoadersTemplate({
return ( return (
<TemplateLayout <TemplateLayout
screen={screen} screen={screen}
onContinue={onContinue}
canGoBack={canGoBack} canGoBack={canGoBack}
onBack={onBack} onBack={onBack}
screenProgress={screenProgress} screenProgress={screenProgress}
defaultTexts={defaultTexts}
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }} titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }} subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
actionButtonOptions={{ actionButtonOptions={{

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types"; import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "./TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
interface SoulmatePortraitTemplateProps { interface SoulmatePortraitTemplateProps {
screen: SoulmatePortraitScreenDefinition; screen: SoulmatePortraitScreenDefinition;
@ -23,11 +23,9 @@ export function SoulmatePortraitTemplate({
return ( return (
<TemplateLayout <TemplateLayout
screen={screen} screen={screen}
onContinue={onContinue}
canGoBack={canGoBack} canGoBack={canGoBack}
onBack={onBack} onBack={onBack}
screenProgress={screenProgress} screenProgress={screenProgress}
defaultTexts={defaultTexts}
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "xl", color: "primary" }} titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "xl", color: "primary" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }} subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{ actionButtonOptions={{

View File

@ -0,0 +1,4 @@
// Content templates - informational and display screens
export { InfoTemplate } from "./InfoTemplate";
export { LoadersTemplate } from "./LoadersTemplate";
export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate";

View File

@ -7,7 +7,7 @@ import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers"; import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { DateScreenDefinition } from "@/lib/funnel/types"; import type { DateScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TemplateLayout } from "./TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
interface DateTemplateProps { interface DateTemplateProps {
screen: DateScreenDefinition; screen: DateScreenDefinition;
@ -70,11 +70,9 @@ export function DateTemplate({
return ( return (
<TemplateLayout <TemplateLayout
screen={screen} screen={screen}
onContinue={onContinue}
canGoBack={canGoBack} canGoBack={canGoBack}
onBack={onBack} onBack={onBack}
screenProgress={screenProgress} screenProgress={screenProgress}
defaultTexts={defaultTexts}
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }} titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }} subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{ actionButtonOptions={{

View File

@ -5,7 +5,7 @@ import Image from "next/image";
import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner"; import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { EmailScreenDefinition, DefaultTexts } from "@/lib/funnel/types"; import type { EmailScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import { TemplateLayout } from "./TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@ -34,6 +34,7 @@ export function EmailTemplate({
onContinue, onContinue,
canGoBack, canGoBack,
onBack, onBack,
screenProgress,
defaultTexts, defaultTexts,
}: EmailTemplateProps) { }: EmailTemplateProps) {
const [isTouched, setIsTouched] = useState(false); const [isTouched, setIsTouched] = useState(false);
@ -61,11 +62,9 @@ export function EmailTemplate({
return ( return (
<TemplateLayout <TemplateLayout
screen={screen} screen={screen}
onContinue={onContinue}
canGoBack={canGoBack} canGoBack={canGoBack}
onBack={onBack} onBack={onBack}
screenProgress={undefined} screenProgress={screenProgress}
defaultTexts={defaultTexts}
titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }} titleDefaults={{ font: "manrope", weight: "bold", align: "center", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }} subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "center", size: "lg" }}
actionButtonOptions={{ actionButtonOptions={{

View File

@ -2,13 +2,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import { TextInput } from "@/components/ui/TextInput/TextInput"; import { TextInput } from "@/components/ui/TextInput/TextInput";
import {
buildLayoutQuestionProps,
} from "@/lib/funnel/mappers";
import type { FormScreenDefinition } from "@/lib/funnel/types"; import type { FormScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
interface FormTemplateProps { interface FormTemplateProps {
screen: FormScreenDefinition; screen: FormScreenDefinition;
@ -105,22 +102,20 @@ export function FormTemplate({
return true; return true;
}); });
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" },
subtitleDefaults: { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.continueButton || "Continue",
disabled: !isFormComplete,
onClick: handleContinue,
},
screenProgress,
});
return ( return (
<LayoutQuestion {...layoutQuestionProps}> <TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.continueButton || "Continue",
disabled: !isFormComplete,
onClick: handleContinue,
}}
>
<div className="w-full mt-[22px] space-y-4"> <div className="w-full mt-[22px] space-y-4">
{screen.fields.map((field) => ( {screen.fields.map((field) => (
<div key={field.id}> <div key={field.id}>
@ -142,6 +137,6 @@ export function FormTemplate({
</div> </div>
))} ))}
</div> </div>
</LayoutQuestion> </TemplateLayout>
); );
} }

View File

@ -0,0 +1,4 @@
// Form templates - input and data collection screens
export { FormTemplate } from "./FormTemplate";
export { DateTemplate } from "./DateTemplate";
export { EmailTemplate } from "./EmailTemplate";

View File

@ -0,0 +1,13 @@
// Funnel templates organized by category
// Content templates (informational and display)
export * from "./content";
// Form templates (input and data collection)
export * from "./forms";
// Interactive templates (user choice and engagement)
export * from "./interactive";
// Layout components (base layouts and structural)
export * from "./layouts";

View File

@ -2,15 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import { Coupon } from "@/components/widgets/Coupon/Coupon"; import { Coupon } from "@/components/widgets/Coupon/Coupon";
import Typography from "@/components/ui/Typography/Typography"; import Typography from "@/components/ui/Typography/Typography";
import { import { buildTypographyProps } from "@/lib/funnel/mappers";
buildLayoutQuestionProps,
buildTypographyProps,
} from "@/lib/funnel/mappers";
import type { CouponScreenDefinition } from "@/lib/funnel/types"; import type { CouponScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
interface CouponTemplateProps { interface CouponTemplateProps {
screen: CouponScreenDefinition; screen: CouponScreenDefinition;
@ -41,20 +38,6 @@ export function CouponTemplate({
}, 2000); }, 2000);
}; };
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" },
subtitleDefaults: { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.continueButton || "Continue",
disabled: false,
onClick: onContinue,
},
screenProgress,
});
const couponProps = { const couponProps = {
title: buildTypographyProps(screen.coupon.title, { title: buildTypographyProps(screen.coupon.title, {
as: "h3" as const, as: "h3" as const,
@ -118,7 +101,19 @@ export function CouponTemplate({
}; };
return ( return (
<LayoutQuestion {...layoutQuestionProps}> <TemplateLayout
screen={screen}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
titleDefaults={{ font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }}
subtitleDefaults={{ font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }}
actionButtonOptions={{
defaultText: defaultTexts?.continueButton || "Continue",
disabled: false,
onClick: onContinue,
}}
>
<div className="w-full flex flex-col items-center justify-center mt-[22px]"> <div className="w-full flex flex-col items-center justify-center mt-[22px]">
<div className="mb-8"> <div className="mb-8">
<Coupon {...couponProps} /> <Coupon {...couponProps} />
@ -141,6 +136,6 @@ export function CouponTemplate({
</div> </div>
)} )}
</div> </div>
</LayoutQuestion> </TemplateLayout>
); );
} }

View File

@ -9,7 +9,7 @@ import type { RadioAnswersListProps } from "@/components/widgets/RadioAnswersLis
import type { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList"; import type { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
import { mapListOptionsToButtons } from "@/lib/funnel/mappers"; import { mapListOptionsToButtons } from "@/lib/funnel/mappers";
import type { ListScreenDefinition } from "@/lib/funnel/types"; import type { ListScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "./TemplateLayout"; import { TemplateLayout } from "../layouts/TemplateLayout";
interface ListTemplateProps { interface ListTemplateProps {
screen: ListScreenDefinition; screen: ListScreenDefinition;
@ -104,7 +104,6 @@ export function ListTemplate({
return ( return (
<TemplateLayout <TemplateLayout
screen={screen} screen={screen}
onContinue={() => {}}
canGoBack={canGoBack} canGoBack={canGoBack}
onBack={onBack} onBack={onBack}
screenProgress={screenProgress} screenProgress={screenProgress}

View File

@ -0,0 +1,3 @@
// Interactive templates - user choice and engagement screens
export { ListTemplate } from "./ListTemplate";
export { CouponTemplate } from "./CouponTemplate";

View File

@ -13,11 +13,9 @@ import type { ScreenDefinition } from "@/lib/funnel/types";
interface TemplateLayoutProps { interface TemplateLayoutProps {
screen: ScreenDefinition; screen: ScreenDefinition;
onContinue: () => void;
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
screenProgress?: { current: number; total: number }; screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string };
// Настройки template // Настройки template
titleDefaults?: { titleDefaults?: {
@ -53,11 +51,9 @@ interface TemplateLayoutProps {
*/ */
export function TemplateLayout({ export function TemplateLayout({
screen, screen,
// onContinue, // Unused in this component
canGoBack, canGoBack,
onBack, onBack,
screenProgress, screenProgress,
// defaultTexts, // Unused in this component
titleDefaults = { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" }, titleDefaults = { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" },
subtitleDefaults = { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" }, subtitleDefaults = { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
actionButtonOptions, actionButtonOptions,
@ -86,12 +82,7 @@ export function TemplateLayout({
const bottomActionButtonProps = actionButtonOptions const bottomActionButtonProps = actionButtonOptions
? buildTemplateBottomActionButtonProps({ ? buildTemplateBottomActionButtonProps({
screen, screen,
titleDefaults,
subtitleDefaults,
canGoBack,
onBack,
actionButtonOptions, actionButtonOptions,
screenProgress,
}) })
: undefined; : undefined;

View File

@ -0,0 +1,2 @@
// Layout components - base layouts and structural components
export { TemplateLayout } from "./TemplateLayout";

View File

@ -60,9 +60,8 @@ function Coupon({
as="h3" as="h3"
size="xl" size="xl"
weight="bold" weight="bold"
color="primary"
{...title} {...title}
className={cn(title.className, "leading-[140%]")} className={cn(title.className, "leading-[140%] text-white")}
/> />
)} )}
{offer && ( {offer && (
@ -79,18 +78,17 @@ function Coupon({
as="h3" as="h3"
size="4xl" size="4xl"
weight="black" weight="black"
color="card"
{...offer.title} {...offer.title}
className={cn(offer.title?.className, "text-[#1F2937]")}
/> />
)} )}
{offer.description && ( {offer.description && (
<Typography <Typography
as="p" as="p"
weight="semiBold" weight="semiBold"
color="card"
{...offer.description} {...offer.description}
className={cn( className={cn(
"text-[17px] leading-[100%]", "text-[17px] leading-[100%] text-[#1F2937]",
"mt-2", "mt-2",
offer.description.className offer.description.className
)} )}

View File

@ -1,5 +1,6 @@
import type { BuilderFunnelState, BuilderScreen } from "@/lib/admin/builder/types"; import type { BuilderFunnelState, BuilderScreen } from "@/lib/admin/builder/types";
import type { BuilderState } from "./types"; import type { BuilderState } from "./types";
import { buildListDefaults } from "./defaults/list";
export const INITIAL_META: BuilderFunnelState["meta"] = { export const INITIAL_META: BuilderFunnelState["meta"] = {
id: "funnel-builder-draft", id: "funnel-builder-draft",
@ -9,37 +10,13 @@ export const INITIAL_META: BuilderFunnelState["meta"] = {
}; };
export const INITIAL_SCREEN: BuilderScreen = { export const INITIAL_SCREEN: BuilderScreen = {
id: "screen-1", ...buildListDefaults("screen-1"),
template: "list", // Переопределяем опции для начального экрана
header: {
show: true,
showBackButton: true,
},
title: {
text: "Новый экран",
font: "manrope",
weight: "bold",
align: "left",
size: "2xl",
color: "default",
},
subtitle: {
text: "Добавьте детали справа",
font: "manrope",
weight: "medium",
color: "default",
align: "left",
size: "lg",
},
bottomActionButton: {
text: "Продолжить",
show: true,
},
list: { list: {
selectionType: "single", selectionType: "single",
options: [ options: [
{ {
id: "option-1", id: "option-1",
label: "Вариант 1", label: "Вариант 1",
}, },
{ {
@ -48,15 +25,7 @@ export const INITIAL_SCREEN: BuilderScreen = {
}, },
], ],
}, },
navigation: { } as BuilderScreen;
defaultNextScreenId: undefined,
rules: [],
},
position: {
x: 80,
y: 120,
},
};
export const INITIAL_STATE: BuilderState = { export const INITIAL_STATE: BuilderState = {
meta: INITIAL_META, meta: INITIAL_META,

View File

@ -0,0 +1,4 @@
// Base screen interface for common properties (used internally by builders)
export interface BaseScreenCommon {
id: string;
}

View File

@ -0,0 +1,281 @@
import type {
HeaderDefinition,
TitleDefinition,
SubtitleDefinition,
BottomActionButtonDefinition,
NavigationDefinition,
TypographyVariant,
IconDefinition,
DateInputDefinition,
InfoMessageDefinition,
CouponDefinition,
FormFieldDefinition,
FormValidationMessages,
ProgressbarDefinition,
} from "@/lib/funnel/types";
// ===== BUILDING BLOCK FUNCTIONS (КИРПИЧИКИ) =====
// These functions return default values for different screen components
/**
* Creates default header configuration
*/
export function buildDefaultHeader(overrides?: Partial<HeaderDefinition>): HeaderDefinition {
return {
show: true,
showBackButton: true,
...overrides,
};
}
/**
* Creates default title configuration
*/
export function buildDefaultTitle(overrides?: Partial<TitleDefinition>): TitleDefinition {
return {
show: true,
text: "Новый экран",
font: "manrope",
weight: "bold",
align: "left",
size: "2xl",
color: "default",
...overrides,
};
}
/**
* Creates default subtitle configuration
*/
export function buildDefaultSubtitle(overrides?: Partial<SubtitleDefinition>): SubtitleDefinition {
return {
show: true,
text: "Добавьте детали справа",
font: "manrope",
weight: "medium",
color: "default",
align: "left",
size: "lg",
...overrides,
};
}
/**
* Creates default bottom action button configuration
*/
export function buildDefaultBottomActionButton(overrides?: Partial<BottomActionButtonDefinition>): BottomActionButtonDefinition {
return {
show: true,
text: "Продолжить",
showGradientBlur: true,
...overrides,
};
}
/**
* Creates default navigation configuration
*/
export function buildDefaultNavigation(overrides?: Partial<NavigationDefinition>): NavigationDefinition {
return {
defaultNextScreenId: undefined,
rules: [],
...overrides,
};
}
/**
* Creates default description configuration
*/
export function buildDefaultDescription(overrides?: Partial<TypographyVariant>): TypographyVariant {
return {
text: "Добавьте описание для экрана",
font: "manrope",
weight: "regular",
align: "center",
size: "md",
color: "default",
...overrides,
};
}
/**
* Creates default icon configuration
*/
export function buildDefaultIcon(overrides?: Partial<IconDefinition>): IconDefinition {
return {
type: "emoji",
value: "",
size: "md",
...overrides,
};
}
/**
* Creates default date input configuration
*/
export function buildDefaultDateInput(overrides?: Partial<DateInputDefinition>): DateInputDefinition {
return {
monthLabel: "Месяц",
dayLabel: "День",
yearLabel: "Год",
monthPlaceholder: "ММ",
dayPlaceholder: "ДД",
yearPlaceholder: "ГГГГ",
showSelectedDate: true,
selectedDateFormat: "dd MMMM yyyy",
selectedDateLabel: "Выбранная дата:",
...overrides,
};
}
/**
* Creates default info message configuration
*/
export function buildDefaultInfoMessage(overrides?: Partial<InfoMessageDefinition>): InfoMessageDefinition {
return {
text: "Мы используем эту информацию только для анализа",
icon: "🔒",
font: "manrope",
weight: "regular",
align: "center",
size: "sm",
color: "muted",
...overrides,
};
}
/**
* Creates default coupon configuration
*/
export function buildDefaultCoupon(overrides?: Partial<CouponDefinition>): CouponDefinition {
return {
title: {
text: "Специальное предложение",
font: "manrope",
weight: "bold",
align: "center",
size: "lg",
color: "default",
},
offer: {
title: {
text: "94% OFF",
font: "manrope",
weight: "extraBold",
align: "center",
size: "3xl",
color: "primary",
},
description: {
text: "Полный анализ личности",
font: "manrope",
weight: "medium",
align: "center",
size: "md",
color: "default",
},
},
promoCode: {
text: "PROMO50",
font: "geistMono",
weight: "bold",
align: "center",
size: "lg",
color: "accent",
},
footer: {
text: "Нажмите чтобы скопировать промокод",
font: "manrope",
weight: "regular",
align: "center",
size: "sm",
color: "muted",
},
...overrides,
};
}
/**
* Creates default form fields configuration
*/
export function buildDefaultFormFields(): FormFieldDefinition[] {
return [
{
id: "field1",
label: "Имя",
placeholder: "Введите ваше имя",
type: "text",
required: true,
},
];
}
/**
* Creates default form validation messages
*/
export function buildDefaultFormValidation(overrides?: Partial<FormValidationMessages>): FormValidationMessages {
return {
required: "Это поле обязательно для заполнения",
maxLength: "Превышена максимальная длина",
invalidFormat: "Неверный формат",
...overrides,
};
}
/**
* Creates default progressbars configuration with sample data from Loaders.stories.tsx
*/
export function buildDefaultProgressbars(overrides?: Partial<ProgressbarDefinition>): ProgressbarDefinition {
return {
items: [
{
processingTitle: "Анализ твоих ответов",
processingSubtitle: "Processing...",
completedTitle: "Анализ твоих ответов",
completedSubtitle: "Complete",
},
{
processingTitle: "Portrait of the Soulmate",
processingSubtitle: "Processing...",
completedTitle: "Portrait of the Soulmate",
completedSubtitle: "Complete",
},
{
processingTitle: "Portrait of the Soulmate",
processingSubtitle: "Processing...",
completedTitle: "Connection Insights",
completedSubtitle: "Complete",
},
],
transitionDuration: 3000,
...overrides,
};
}
/**
* Creates default coupon copied message
*/
export function buildDefaultCopiedMessage(): string {
return "Промокод скопирован!";
}
/**
* Creates default image configuration
*/
export function buildDefaultImage(overrides?: { src?: string }): { src: string } {
return {
src: "/female-portrait.jpg",
...overrides,
};
}
/**
* Creates default email input configuration
*/
export function buildDefaultEmailInput(overrides?: { label?: string; placeholder?: string }): { label: string; placeholder: string } {
return {
label: "Email адрес",
placeholder: "example@email.com",
...overrides,
};
}

View File

@ -0,0 +1,73 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultCoupon,
buildDefaultCopiedMessage
} from "./blocks";
export function buildCouponDefaults(id: string): BuilderScreen {
return {
id,
template: "coupon",
header: buildDefaultHeader(),
title: buildDefaultTitle({
text: "Тебе повезло!",
align: "center",
}),
subtitle: buildDefaultSubtitle({
text: "Ты получил специальную эксклюзивную скидку на 94%",
align: "center",
}),
coupon: buildDefaultCoupon({
title: {
text: "Special Offer",
font: "manrope",
weight: "bold",
align: "center",
size: "lg",
color: "default",
},
offer: {
title: {
text: "94% OFF",
font: "manrope",
weight: "bold",
align: "center",
size: "3xl",
color: "primary",
},
description: {
text: "Одноразовая эксклюзивная скидка",
font: "inter",
weight: "medium",
color: "muted",
align: "center",
size: "md",
},
},
promoCode: {
text: "HAIR50",
font: "geistMono",
weight: "bold",
align: "center",
size: "lg",
color: "accent",
},
footer: {
text: "Скопируйте или нажмите **Continue**",
font: "inter",
weight: "medium",
color: "muted",
align: "center",
size: "sm",
},
}),
copiedMessage: buildDefaultCopiedMessage(),
bottomActionButton: buildDefaultBottomActionButton(),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -0,0 +1,24 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultDateInput,
buildDefaultInfoMessage
} from "./blocks";
export function buildDateDefaults(id: string): BuilderScreen {
return {
id,
template: "date",
header: buildDefaultHeader(),
title: buildDefaultTitle(),
subtitle: buildDefaultSubtitle(),
dateInput: buildDefaultDateInput(),
infoMessage: buildDefaultInfoMessage(),
bottomActionButton: buildDefaultBottomActionButton(),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -0,0 +1,40 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultImage,
buildDefaultEmailInput
} from "./blocks";
export function buildEmailDefaults(id: string): BuilderScreen {
return {
id,
template: "email",
header: buildDefaultHeader(),
title: buildDefaultTitle({
text: "Портрет твоей второй половинки готов! Куда нам его отправить?",
align: "center",
}),
subtitle: buildDefaultSubtitle({
show: false,
text: undefined,
}),
image: buildDefaultImage(),
emailInput: buildDefaultEmailInput(),
bottomActionButton: buildDefaultBottomActionButton({
showPrivacyTermsConsent: true
}),
navigation: buildDefaultNavigation(),
variants: [
{
conditions: [],
overrides: {
image: buildDefaultImage({ src: "/male-portrait.jpg" }),
},
},
],
} as BuilderScreen;
}

View File

@ -0,0 +1,24 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultFormFields,
buildDefaultFormValidation
} from "./blocks";
export function buildFormDefaults(id: string): BuilderScreen {
return {
id,
template: "form",
header: buildDefaultHeader(),
title: buildDefaultTitle(),
subtitle: buildDefaultSubtitle(),
fields: buildDefaultFormFields(),
validationMessages: buildDefaultFormValidation(),
bottomActionButton: buildDefaultBottomActionButton(),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -0,0 +1,32 @@
// Export all building blocks functions for easy import
export {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultDescription,
buildDefaultIcon,
buildDefaultDateInput,
buildDefaultInfoMessage,
buildDefaultCoupon,
buildDefaultFormFields,
buildDefaultFormValidation,
buildDefaultProgressbars,
buildDefaultCopiedMessage,
buildDefaultImage,
buildDefaultEmailInput,
} from "./blocks";
// Export base screen interface
export { type BaseScreenCommon } from "./baseScreen";
// Export specific screen builders
export { buildInfoDefaults } from "./info";
export { buildDateDefaults } from "./date";
export { buildFormDefaults } from "./form";
export { buildListDefaults } from "./list";
export { buildCouponDefaults } from "./coupon";
export { buildEmailDefaults } from "./email";
export { buildLoadersDefaults } from "./loaders";
export { buildSoulmateDefaults } from "./soulmate";

View File

@ -0,0 +1,31 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultDescription,
buildDefaultSubtitle
} from "./blocks";
export function buildInfoDefaults(id: string): BuilderScreen {
return {
id,
template: "info",
header: buildDefaultHeader(),
title: buildDefaultTitle({
text: "Заголовок информации",
align: "center",
}),
subtitle: buildDefaultSubtitle({
show: false,
text: undefined,
}),
description: buildDefaultDescription({
text: "Добавьте описание для информационного экрана. Используйте **жирный текст** для выделения важного.",
align: "center",
}),
bottomActionButton: buildDefaultBottomActionButton(),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -0,0 +1,33 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
} from "./blocks";
export function buildListDefaults(id: string): BuilderScreen {
return {
id,
template: "list",
header: buildDefaultHeader(),
title: buildDefaultTitle(),
subtitle: buildDefaultSubtitle(),
list: {
selectionType: "single",
options: [
{
id: "option-1",
label: "Вариант 1",
},
{
id: "option-2",
label: "Вариант 2",
},
],
},
bottomActionButton: buildDefaultBottomActionButton(),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -0,0 +1,31 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultProgressbars
} from "./blocks";
export function buildLoadersDefaults(id: string): BuilderScreen {
return {
id,
template: "loaders",
header: buildDefaultHeader({
show: false,
showBackButton: false,
}),
title: buildDefaultTitle({
text: "Создаем портрет твоей второй половинки.",
align: "center",
}),
subtitle: buildDefaultSubtitle({
show: false,
text: undefined,
}),
progressbars: buildDefaultProgressbars(),
bottomActionButton: buildDefaultBottomActionButton(),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -0,0 +1,30 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultDescription
} from "./blocks";
export function buildSoulmateDefaults(id: string): BuilderScreen {
return {
id,
template: "soulmate",
header: buildDefaultHeader({
show: false,
showBackButton: false,
}),
title: buildDefaultTitle(),
subtitle: buildDefaultSubtitle(),
bottomActionButton: buildDefaultBottomActionButton({
text: "Получить полный анализ",
}),
description: buildDefaultDescription({
text: "Ваш персональный портрет почти готов.",
align: "center",
}),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -1,5 +1,5 @@
import type { ListScreenDefinition } from "@/lib/funnel/types"; import type { ListScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen, BuilderScreenPosition } from "@/lib/admin/builder/types"; import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { BuilderState, BuilderAction } from "./types"; import type { BuilderState, BuilderAction } from "./types";
import { INITIAL_STATE } from "./constants"; import { INITIAL_STATE } from "./constants";
import { withDirty, generateScreenId, createScreenByTemplate } from "./utils"; import { withDirty, generateScreenId, createScreenByTemplate } from "./utils";
@ -18,12 +18,8 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
case "add-screen": { case "add-screen": {
const nextId = generateScreenId(state.screens.map((s) => s.id)); const nextId = generateScreenId(state.screens.map((s) => s.id));
const template = action.payload?.template || "list"; const template = action.payload?.template || "list";
const position = {
x: (action.payload?.position?.x ?? 120) + state.screens.length * 40,
y: (action.payload?.position?.y ?? 120) + state.screens.length * 20,
};
const newScreen = createScreenByTemplate(template, nextId, position); const newScreen = createScreenByTemplate(template, nextId);
// 🎯 АВТОМАТИЧЕСКОЕ СВЯЗЫВАНИЕ С ПРЕДЫДУЩИМ ЭКРАНОМ // 🎯 АВТОМАТИЧЕСКОЕ СВЯЗЫВАНИЕ С ПРЕДЫДУЩИМ ЭКРАНОМ
let updatedScreens = [...state.screens, newScreen]; let updatedScreens = [...state.screens, newScreen];
@ -96,11 +92,11 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
...(current.template === "list" && "list" in screen && screen.list ...(current.template === "list" && "list" in screen && screen.list
? { ? {
list: { list: {
...(current as ListScreenDefinition & { position: BuilderScreenPosition }).list, ...(current as ListScreenDefinition).list,
...screen.list, ...screen.list,
options: options:
screen.list.options ?? screen.list.options ??
(current as ListScreenDefinition & { position: BuilderScreenPosition }).list.options, (current as ListScreenDefinition).list.options,
}, },
} }
: {}), : {}),
@ -129,16 +125,6 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
selectedScreenId: nextSelectedScreenId, selectedScreenId: nextSelectedScreenId,
}); });
} }
case "reposition-screen": {
return withDirty(state, {
...state,
screens: state.screens.map((screen) =>
screen.id === action.payload.screenId
? { ...screen, position: action.payload.position }
: screen
),
});
}
case "reorder-screens": { case "reorder-screens": {
const { fromIndex, toIndex } = action.payload; const { fromIndex, toIndex } = action.payload;
const previousScreens = state.screens; const previousScreens = state.screens;

View File

@ -14,7 +14,6 @@ export type BuilderAction =
| { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> } | { type: "add-screen"; payload?: { template?: ScreenDefinition["template"] } & Partial<BuilderScreen> }
| { type: "remove-screen"; payload: { screenId: string } } | { type: "remove-screen"; payload: { screenId: string } }
| { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } } | { type: "update-screen"; payload: { screenId: string; screen: Partial<BuilderScreen> } }
| { type: "reposition-screen"; payload: { screenId: string; position: BuilderScreen["position"] } }
| { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } } | { type: "reorder-screens"; payload: { fromIndex: number; toIndex: number } }
| { type: "set-selected-screen"; payload: { screenId: string | null } } | { type: "set-selected-screen"; payload: { screenId: string | null } }
| { type: "set-screens"; payload: BuilderScreen[] } | { type: "set-screens"; payload: BuilderScreen[] }

View File

@ -1,6 +1,14 @@
import type { BuilderScreen, BuilderScreenPosition } from "@/lib/admin/builder/types"; import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { ScreenDefinition } from "@/lib/funnel/types"; import type { ScreenDefinition } from "@/lib/funnel/types";
import type { BuilderState } from "./types"; import type { BuilderState } from "./types";
import { buildInfoDefaults } from "./defaults/info";
import { buildListDefaults } from "./defaults/list";
import { buildFormDefaults } from "./defaults/form";
import { buildDateDefaults } from "./defaults/date";
import { buildCouponDefaults } from "./defaults/coupon";
import { buildEmailDefaults } from "./defaults/email";
import { buildLoadersDefaults } from "./defaults/loaders";
import { buildSoulmateDefaults } from "./defaults/soulmate";
/** /**
* Marks the state as dirty if it has changed * Marks the state as dirty if it has changed
@ -30,213 +38,25 @@ export function generateScreenId(existing: string[]): string {
*/ */
export function createScreenByTemplate( export function createScreenByTemplate(
template: ScreenDefinition["template"], template: ScreenDefinition["template"],
id: string, id: string
position: BuilderScreenPosition
): BuilderScreen { ): BuilderScreen {
// ✅ Единые базовые настройки для ВСЕХ типов экранов
const baseScreen = {
id,
position,
// ✅ Современные настройки header (без устаревшего progress)
header: {
show: true,
showBackButton: true,
},
// ✅ Базовые тексты согласно Figma
title: {
text: "Новый экран",
font: "manrope" as const,
weight: "bold" as const,
align: "left" as const,
size: "2xl" as const,
color: "default" as const,
},
subtitle: {
text: "Добавьте детали справа",
font: "manrope" as const,
weight: "medium" as const,
color: "default" as const,
align: "left" as const,
size: "lg" as const,
},
// ✅ Единые настройки нижней кнопки
bottomActionButton: {
text: "Продолжить",
show: true,
},
// ✅ Навигация
navigation: {
defaultNextScreenId: undefined,
rules: [],
},
};
switch (template) { switch (template) {
case "info": case "info":
// Деструктурируем baseScreen исключая subtitle для InfoScreenDefinition return buildInfoDefaults(id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { subtitle, ...baseScreenWithoutSubtitle } = baseScreen;
return {
...baseScreenWithoutSubtitle,
template: "info",
title: {
text: "Заголовок информации",
font: "manrope" as const,
weight: "bold" as const,
align: "center" as const, // 🎯 Центрированный заголовок по умолчанию
size: "2xl" as const,
color: "default" as const,
},
// 🚫 Подзаголовок не включается (InfoScreenDefinition не поддерживает subtitle)
description: {
text: "Добавьте описание для информационного экрана. Используйте **жирный текст** для выделения важного.",
align: "center" as const, // 🎯 Центрированный текст
},
// 🚫 Иконка не добавляется по умолчанию - пользователь может добавить при необходимости
};
case "list": case "list":
return { return buildListDefaults(id);
...baseScreen,
template: "list",
list: {
selectionType: "single" as const,
options: [
{ id: "option-1", label: "Вариант 1" },
{ id: "option-2", label: "Вариант 2" },
],
},
};
case "form": case "form":
return { return buildFormDefaults(id);
...baseScreen,
template: "form",
fields: [
{
id: "field-1",
type: "text",
label: "Поле 1",
placeholder: "Введите значение",
required: true,
},
],
};
case "date": case "date":
return { return buildDateDefaults(id);
...baseScreen,
template: "date",
dateInput: {
monthLabel: "Месяц",
dayLabel: "День",
yearLabel: "Год",
monthPlaceholder: "ММ",
dayPlaceholder: "ДД",
yearPlaceholder: "ГГГГ",
showSelectedDate: true,
selectedDateFormat: "dd MMMM yyyy",
selectedDateLabel: "Выбранная дата:",
},
infoMessage: {
text: "Мы используем эту информацию только для анализа",
icon: "🔒",
},
};
case "coupon": case "coupon":
return { return buildCouponDefaults(id);
...baseScreen,
template: "coupon",
coupon: {
title: {
text: "Промокод на скидку",
font: "manrope" as const,
weight: "bold" as const,
},
offer: {
title: {
text: "Скидка 20%",
font: "manrope" as const,
weight: "bold" as const,
},
description: {
text: "На первую покупку",
font: "inter" as const,
weight: "medium" as const,
color: "muted" as const,
},
},
promoCode: {
text: "WELCOME20",
font: "geistMono" as const,
weight: "bold" as const,
},
footer: {
text: "Сохраните код или скопируйте",
font: "inter" as const,
weight: "medium" as const,
color: "muted" as const,
},
},
copiedMessage: "Промокод {code} скопирован!",
};
case "email": case "email":
return { return buildEmailDefaults(id);
...baseScreen,
template: "email",
emailInput: {
label: "Email адрес",
placeholder: "example@email.com",
},
};
case "loaders": case "loaders":
return { return buildLoadersDefaults(id);
...baseScreen,
template: "loaders",
header: {
show: false,
showBackButton: false,
},
progressbars: {
items: [
{
title: "Анализ ответов",
subtitle: "Обработка данных...",
processingTitle: "Анализируем ваши ответы...",
processingSubtitle: "Это займет несколько секунд",
completedTitle: "Готово!",
completedSubtitle: "Данные проанализированы",
},
{
title: "Создание портрета",
subtitle: "Построение результата...",
processingTitle: "Строим персональный портрет...",
processingSubtitle: "Почти готово",
completedTitle: "Готово!",
completedSubtitle: "Портрет создан",
},
],
transitionDuration: 3000,
},
};
case "soulmate": case "soulmate":
return { return buildSoulmateDefaults(id);
...baseScreen,
template: "soulmate",
header: {
show: false,
showBackButton: false,
},
bottomActionButton: {
text: "Получить полный анализ",
show: true,
},
};
default: default:
throw new Error(`Unknown template: ${template}`); throw new Error(`Unknown template: ${template}`);
} }

View File

@ -1,106 +0,0 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { ListOptionDefinition } from "@/lib/funnel/types";
export interface CreateTemplateScreenOptions {
templateId?: string;
screenId: string;
position: { x: number; y: number };
}
export interface BuilderTemplateDefinition {
id: string;
label: string;
description?: string;
create: (options: CreateTemplateScreenOptions, overrides?: Partial<BuilderScreen>) => BuilderScreen;
}
export const DEFAULT_TEMPLATE_ID = "list";
function cloneOptions(options: ListOptionDefinition[]): ListOptionDefinition[] {
return options.map((option) => ({ ...option }));
}
const LIST_TEMPLATE: BuilderTemplateDefinition = {
id: "list",
label: "Вопрос с вариантами",
create: ({ screenId, position }, overrides) => {
const base = {
id: screenId,
template: "list" as const,
header: {
progress: {
current: 1,
total: 1,
label: "1 of 1",
},
},
title: {
text: "Новый экран",
font: "manrope" as const,
weight: "bold" as const,
},
subtitle: {
text: "Опишите вопрос справа",
color: "muted" as const,
font: "inter" as const,
},
list: {
selectionType: "single" as const,
options: cloneOptions([
{ id: "option-1", label: "Вариант 1" },
{ id: "option-2", label: "Вариант 2" },
]),
},
navigation: {
defaultNextScreenId: undefined,
rules: [],
},
position,
};
if (!overrides) {
return base as BuilderScreen;
}
return {
...base,
...overrides,
list: ('list' in overrides && overrides.list)
? {
...base.list,
...overrides.list,
options: overrides.list.options ?? base.list.options,
}
: base.list,
navigation: overrides.navigation
? {
defaultNextScreenId:
overrides.navigation.defaultNextScreenId ?? base.navigation?.defaultNextScreenId,
rules: overrides.navigation.rules ?? base.navigation?.rules ?? [],
}
: base.navigation,
} as BuilderScreen;
},
};
const BUILDER_TEMPLATES: BuilderTemplateDefinition[] = [LIST_TEMPLATE];
export function getTemplateDefinition(templateId: string): BuilderTemplateDefinition {
return BUILDER_TEMPLATES.find((template) => template.id === templateId) ?? LIST_TEMPLATE;
}
export function createTemplateScreen(
options: CreateTemplateScreenOptions,
overrides?: Partial<BuilderScreen>
): BuilderScreen {
const definition = getTemplateDefinition(options.templateId ?? DEFAULT_TEMPLATE_ID);
return definition.create(options, overrides);
}
export function getTemplateOptions(): { id: string; label: string; description?: string }[] {
return BUILDER_TEMPLATES.map((template) => ({
id: template.id,
label: template.label,
description: template.description,
}));
}

View File

@ -1,13 +1,6 @@
import type { FunnelDefinition, ScreenDefinition } from "@/lib/funnel/types"; import type { FunnelDefinition, ScreenDefinition } from "@/lib/funnel/types";
export type BuilderScreenPosition = { export type BuilderScreen = ScreenDefinition;
x: number;
y: number;
};
export type BuilderScreen = ScreenDefinition & {
position: BuilderScreenPosition;
};
export interface BuilderFunnelState { export interface BuilderFunnelState {
meta: FunnelDefinition["meta"]; meta: FunnelDefinition["meta"];

View File

@ -1,5 +1,5 @@
import type { BuilderState } from "@/lib/admin/builder/context"; import type { BuilderState } from "@/lib/admin/builder/context";
import type { BuilderScreen, BuilderFunnelState, BuilderScreenPosition } from "@/lib/admin/builder/types"; import type { BuilderScreen, BuilderFunnelState } from "@/lib/admin/builder/types";
import type { import type {
FunnelDefinition, FunnelDefinition,
ScreenDefinition, ScreenDefinition,
@ -45,8 +45,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;
const screens = state.screens.map(({ position: _position, ...rest }) => rest);
const meta: FunnelDefinition["meta"] = { const meta: FunnelDefinition["meta"] = {
...state.meta, ...state.meta,
firstScreenId: state.meta.firstScreenId ?? state.screens[0]?.id, firstScreenId: state.meta.firstScreenId ?? state.screens[0]?.id,
@ -61,11 +60,10 @@ export function serializeBuilderState(state: BuilderFunnelState): FunnelDefiniti
export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderScreen>): BuilderScreen { export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderScreen>): BuilderScreen {
const copy = { const copy = {
...screen, ...screen,
position: { ...screen.position },
...(screen.template === "list" && 'list' in screen ? { ...(screen.template === "list" && 'list' in screen ? {
list: { list: {
...(screen as ListScreenDefinition & { position: BuilderScreenPosition }).list, ...(screen as ListScreenDefinition).list,
options: (screen as ListScreenDefinition & { position: BuilderScreenPosition }).list.options.map((option) => ({ ...option })), options: (screen as ListScreenDefinition).list.options.map((option) => ({ ...option })),
} }
} : {}), } : {}),
...(Array.isArray(screen.variants) ...(Array.isArray(screen.variants)

View File

@ -158,7 +158,6 @@ interface BuildLayoutQuestionOptions {
subtitleDefaults?: TypographyDefaults; subtitleDefaults?: TypographyDefaults;
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
actionButtonOptions?: BuildActionButtonOptions;
screenProgress?: { current: number; total: number }; screenProgress?: { current: number; total: number };
} }
@ -202,21 +201,21 @@ export function buildLayoutQuestionProps(
} }
// Отдельная функция для получения bottomActionButtonProps // Отдельная функция для получения bottomActionButtonProps
export function buildTemplateBottomActionButtonProps( export function buildTemplateBottomActionButtonProps(options: {
options: BuildLayoutQuestionOptions screen: ScreenDefinition;
) { actionButtonOptions: BuildActionButtonOptions;
const { }) {
screen, const { screen, actionButtonOptions } = options;
actionButtonOptions
} = options;
return actionButtonOptions ? buildBottomActionButtonProps( // Наличие actionButtonOptions — явный сигнал показать кнопку.
// Принудительно включаем кнопку независимо от screen.bottomActionButton.show
return buildBottomActionButtonProps(
actionButtonOptions, actionButtonOptions,
// Если передаются actionButtonOptions, это означает что кнопка должна показываться 'bottomActionButton' in screen
// Принудительно включаем её независимо от настроек экрана ? (screen.bottomActionButton?.show === false
'bottomActionButton' in screen ? ? { ...screen.bottomActionButton, show: true }
(screen.bottomActionButton?.show === false ? { ...screen.bottomActionButton, show: true } : screen.bottomActionButton) : screen.bottomActionButton)
: undefined : undefined
) : undefined; );
} }

View File

@ -2,14 +2,16 @@
import type { JSX } from "react"; import type { JSX } from "react";
import { ListTemplate } from "@/components/funnel/templates/ListTemplate"; import {
import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate"; ListTemplate,
import { DateTemplate } from "@/components/funnel/templates/DateTemplate"; InfoTemplate,
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate"; DateTemplate,
import { FormTemplate } from "@/components/funnel/templates/FormTemplate"; CouponTemplate,
import { EmailTemplate } from "@/components/funnel/templates/EmailTemplate"; FormTemplate,
import { LoadersTemplate } from "@/components/funnel/templates/LoadersTemplate"; EmailTemplate,
import { SoulmatePortraitTemplate } from "@/components/funnel/templates/SoulmatePortraitTemplate"; LoadersTemplate,
SoulmatePortraitTemplate,
} from "@/components/funnel/templates";
import type { import type {
ListScreenDefinition, ListScreenDefinition,
DateScreenDefinition, DateScreenDefinition,

View File

@ -16,6 +16,15 @@ export type TypographyVariant = {
className?: string; className?: string;
}; };
// Extended typography for titles/subtitles with show property
export interface TitleDefinition extends TypographyVariant {
show?: boolean; // Controls whether title should be displayed. Defaults to true.
}
export interface SubtitleDefinition extends TypographyVariant {
show?: boolean; // Controls whether subtitle should be displayed. Defaults to true.
}
export interface HeaderProgressDefinition { export interface HeaderProgressDefinition {
/** When both current and total provided, value is computed automatically (current / total * 100). */ /** When both current and total provided, value is computed automatically (current / total * 100). */
current?: number; current?: number;
@ -53,6 +62,8 @@ export interface BottomActionButtonDefinition {
cornerRadius?: "3xl" | "full"; cornerRadius?: "3xl" | "full";
/** Controls whether PrivacyTermsConsent should be shown under the button. Defaults to false. */ /** Controls whether PrivacyTermsConsent should be shown under the button. Defaults to false. */
showPrivacyTermsConsent?: boolean; showPrivacyTermsConsent?: boolean;
/** Controls whether gradient blur should be shown. Defaults to true. */
showGradientBlur?: boolean;
} }
export interface DefaultTexts { export interface DefaultTexts {
@ -62,6 +73,7 @@ export interface DefaultTexts {
} }
export interface NavigationConditionDefinition { export interface NavigationConditionDefinition {
screenId: string; screenId: string;
/** /**
@ -103,18 +115,20 @@ export interface ScreenVariantDefinition<T extends { id: string; template: strin
overrides: ScreenVariantOverrides<T>; overrides: ScreenVariantOverrides<T>;
} }
export interface IconDefinition {
type: "emoji" | "image";
value: string;
size?: "sm" | "md" | "lg" | "xl";
className?: string;
}
export interface InfoScreenDefinition { export interface InfoScreenDefinition {
id: string; id: string;
template: "info"; template: "info";
header?: HeaderDefinition; header?: HeaderDefinition;
title: TypographyVariant; title: TitleDefinition;
description?: TypographyVariant; description?: TypographyVariant;
icon?: { icon?: IconDefinition;
type: "emoji" | "image";
value: string; // emoji character or image URL/path
size?: "sm" | "md" | "lg" | "xl";
className?: string;
};
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<InfoScreenDefinition>[]; variants?: ScreenVariantDefinition<InfoScreenDefinition>[];
@ -137,16 +151,18 @@ export interface DateInputDefinition {
}; };
} }
export interface InfoMessageDefinition extends TypographyVariant {
icon?: string;
}
export interface DateScreenDefinition { export interface DateScreenDefinition {
id: string; id: string;
template: "date"; template: "date";
header?: HeaderDefinition; header?: HeaderDefinition;
title: TypographyVariant; title: TitleDefinition;
subtitle?: TypographyVariant; subtitle?: SubtitleDefinition;
dateInput: DateInputDefinition; dateInput: DateInputDefinition;
infoMessage?: TypographyVariant & { infoMessage?: InfoMessageDefinition;
icon?: string; // emoji or icon
};
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<DateScreenDefinition>[]; variants?: ScreenVariantDefinition<DateScreenDefinition>[];
@ -166,10 +182,10 @@ export interface CouponScreenDefinition {
id: string; id: string;
template: "coupon"; template: "coupon";
header?: HeaderDefinition; header?: HeaderDefinition;
title: TypographyVariant; title: TitleDefinition;
subtitle?: TypographyVariant; subtitle?: SubtitleDefinition;
coupon: CouponDefinition; coupon: CouponDefinition;
copiedMessage?: string; // "Промокод скопирован!" text copiedMessage?: string;
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<CouponScreenDefinition>[]; variants?: ScreenVariantDefinition<CouponScreenDefinition>[];
@ -198,8 +214,8 @@ export interface FormScreenDefinition {
id: string; id: string;
template: "form"; template: "form";
header?: HeaderDefinition; header?: HeaderDefinition;
title: TypographyVariant; title: TitleDefinition;
subtitle?: TypographyVariant; subtitle?: SubtitleDefinition;
fields: FormFieldDefinition[]; fields: FormFieldDefinition[];
validationMessages?: FormValidationMessages; validationMessages?: FormValidationMessages;
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
@ -212,8 +228,8 @@ export interface ListScreenDefinition {
id: string; id: string;
template: "list"; template: "list";
header?: HeaderDefinition; header?: HeaderDefinition;
title: TypographyVariant; title: TitleDefinition;
subtitle?: TypographyVariant; subtitle?: SubtitleDefinition;
list: { list: {
selectionType: SelectionType; selectionType: SelectionType;
options: ListOptionDefinition[]; options: ListOptionDefinition[];
@ -223,43 +239,48 @@ export interface ListScreenDefinition {
variants?: ScreenVariantDefinition<ListScreenDefinition>[]; variants?: ScreenVariantDefinition<ListScreenDefinition>[];
} }
export interface ImageDefinition {
src: string;
}
export interface EmailInputDefinition {
placeholder?: string;
label?: string;
}
// Email Screen Definition // Email Screen Definition
export interface EmailScreenDefinition { export interface EmailScreenDefinition {
id: string; id: string;
template: "email"; template: "email";
header?: HeaderDefinition; header?: HeaderDefinition;
title: TypographyVariant; title: TitleDefinition;
subtitle?: TypographyVariant; subtitle?: SubtitleDefinition;
emailInput: { emailInput: EmailInputDefinition;
placeholder?: string; image?: ImageDefinition;
label?: string;
};
image?: {
src: string; // Единственное настраиваемое поле - остальное зашито в коде
};
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<EmailScreenDefinition>[]; variants?: ScreenVariantDefinition<EmailScreenDefinition>[];
} }
// Loaders Screen Definition export interface ProgressbarDefinition {
items: Array<{
title?: string;
subtitle?: string;
processingTitle?: string;
processingSubtitle?: string;
completedTitle?: string;
completedSubtitle?: string;
}>;
transitionDuration?: number;
}
export interface LoadersScreenDefinition { export interface LoadersScreenDefinition {
id: string; id: string;
template: "loaders"; template: "loaders";
header?: HeaderDefinition; header?: HeaderDefinition;
title: TypographyVariant; title: TitleDefinition;
subtitle?: TypographyVariant; subtitle?: SubtitleDefinition;
progressbars: { progressbars: ProgressbarDefinition;
items: Array<{
title?: string;
subtitle?: string;
processingTitle?: string;
processingSubtitle?: string;
completedTitle?: string;
completedSubtitle?: string;
}>;
transitionDuration?: number; // в миллисекундах, по умолчанию 5000
};
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<LoadersScreenDefinition>[]; variants?: ScreenVariantDefinition<LoadersScreenDefinition>[];
@ -270,8 +291,8 @@ export interface SoulmatePortraitScreenDefinition {
id: string; id: string;
template: "soulmate"; template: "soulmate";
header?: HeaderDefinition; header?: HeaderDefinition;
title: TypographyVariant; title: TitleDefinition;
subtitle?: TypographyVariant; subtitle?: SubtitleDefinition;
description?: TypographyVariant; // 🎯 Настраиваемый текст описания description?: TypographyVariant; // 🎯 Настраиваемый текст описания
bottomActionButton?: BottomActionButtonDefinition; bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition; navigation?: NavigationDefinition;

View File

@ -86,12 +86,14 @@ const ListOptionDefinitionSchema = new Schema({
const NavigationConditionSchema = new Schema({ const NavigationConditionSchema = new Schema({
screenId: { type: String, required: true }, screenId: { type: String, required: true },
conditionType: { type: String, enum: ['options', 'values'], default: 'options' },
operator: { operator: {
type: String, type: String,
enum: ['includesAny', 'includesAll', 'includesExactly'], enum: ['includesAny', 'includesAll', 'includesExactly', 'equals'],
default: 'includesAny' default: 'includesAny'
}, },
optionIds: [{ type: String, required: true }] optionIds: [{ type: String }],
values: [{ type: String }],
}, { _id: false }); }, { _id: false });
const NavigationRuleSchema = new Schema({ const NavigationRuleSchema = new Schema({
@ -101,7 +103,8 @@ const NavigationRuleSchema = new Schema({
const NavigationDefinitionSchema = new Schema({ const NavigationDefinitionSchema = new Schema({
rules: [NavigationRuleSchema], rules: [NavigationRuleSchema],
defaultNextScreenId: String defaultNextScreenId: String,
isEndScreen: { type: Boolean, default: false },
}, { _id: false }); }, { _id: false });
const BottomActionButtonSchema = new Schema({ const BottomActionButtonSchema = new Schema({
@ -111,7 +114,8 @@ const BottomActionButtonSchema = new Schema({
type: String, type: String,
enum: ['3xl', 'full'], enum: ['3xl', 'full'],
default: '3xl' default: '3xl'
} },
showPrivacyTermsConsent: { type: Boolean, default: false },
}, { _id: false }); }, { _id: false });
// Схемы для различных типов экранов (используем Mixed для гибкости) // Схемы для различных типов экранов (используем Mixed для гибкости)
@ -146,7 +150,8 @@ const ScreenDefinitionSchema = new Schema({
}, },
emailInput: Schema.Types.Mixed, // email emailInput: Schema.Types.Mixed, // email
image: Schema.Types.Mixed, // email, soulmate image: Schema.Types.Mixed, // email, soulmate
loadersConfig: Schema.Types.Mixed, // loaders // loaders
progressbars: Schema.Types.Mixed, // preferred key used by runtime/templates
variants: [Schema.Types.Mixed] // variants для всех типов variants: [Schema.Types.Mixed] // variants для всех типов
}, { _id: false }); }, { _id: false });
@ -160,7 +165,8 @@ const FunnelMetaSchema = new Schema({
const DefaultTextsSchema = new Schema({ const DefaultTextsSchema = new Schema({
nextButton: { type: String, default: 'Next' }, nextButton: { type: String, default: 'Next' },
continueButton: { type: String, default: 'Continue' } continueButton: { type: String, default: 'Continue' },
privacyBanner: { type: String },
}, { _id: false }); }, { _id: false });
const FunnelDataSchema = new Schema({ const FunnelDataSchema = new Schema({