This commit is contained in:
dev.daminik00 2025-09-26 02:19:22 +02:00
parent d5bcfb0330
commit 22c6d513af
20 changed files with 478 additions and 953 deletions

View File

@ -9,45 +9,6 @@
"nextButton": "Next",
"continueButton": "Continue"
},
"colorPalette": {
"text": {
"primary": "#1E293B",
"secondary": "#475569",
"muted": "#64748B",
"accent": "#3B82F6",
"success": "#10B981",
"error": "#EF4444",
"warning": "#F59E0B"
},
"background": {
"primary": "#FFFFFF",
"secondary": "#F8FAFC",
"accent": "#EFF6FF",
"success": "#ECFDF5",
"error": "#FEF2F2",
"warning": "#FFFBEB"
},
"button": {
"primary": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
"primaryText": "#FFFFFF",
"secondary": "#F1F5F9",
"secondaryText": "#334155",
"disabled": "#E2E8F0",
"disabledText": "#94A3B8"
},
"border": {
"primary": "#E2E8F0",
"accent": "#3B82F6",
"success": "#10B981",
"error": "#EF4444"
},
"shadow": {
"light": "rgba(0, 0, 0, 0.05)",
"medium": "rgba(0, 0, 0, 0.1)",
"heavy": "rgba(0, 0, 0, 0.15)",
"colored": "rgba(59, 130, 246, 0.3)"
}
},
"screens": [
{
"id": "intro-welcome",
@ -188,23 +149,20 @@
"maxLength": "Максимум ${maxLength} символов",
"invalidFormat": "Неверный формат"
},
"bottomActionButton": {
"text": "Continue"
},
"navigation": {
"defaultNextScreenId": "statistics-text"
}
},
{
"id": "statistics-text",
"template": "text",
"template": "info",
"title": {
"text": "Which best represents your hair loss and goals?",
"font": "manrope",
"weight": "bold",
"align": "center"
},
"content": {
"description": {
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
"font": "inter",
"weight": "medium",
@ -285,13 +243,6 @@
{
"id": "analysis-target",
"template": "list",
"header": {
"progress": {
"current": 6,
"total": 15,
"label": "6 of 15"
}
},
"title": {
"text": "Кого анализируем?",
"font": "manrope",
@ -367,13 +318,6 @@
{
"id": "current-partner-age",
"template": "list",
"header": {
"progress": {
"current": 4,
"total": 9,
"label": "4 of 9"
}
},
"title": {
"text": "Возраст текущего партнера",
"font": "manrope",
@ -423,13 +367,6 @@
{
"id": "crush-age",
"template": "list",
"header": {
"progress": {
"current": 4,
"total": 9,
"label": "4 of 9"
}
},
"title": {
"text": "Возраст человека, который нравится",
"font": "manrope",
@ -460,6 +397,9 @@
}
]
},
"bottomActionButton": {
"show": false
},
"navigation": {
"rules": [
{
@ -479,13 +419,6 @@
{
"id": "ex-partner-age",
"template": "list",
"header": {
"progress": {
"current": 4,
"total": 9,
"label": "4 of 9"
}
},
"title": {
"text": "Возраст бывшего",
"font": "manrope",
@ -535,13 +468,6 @@
{
"id": "future-partner-age",
"template": "list",
"header": {
"progress": {
"current": 4,
"total": 9,
"label": "4 of 9"
}
},
"title": {
"text": "Возраст будущего партнера",
"font": "manrope",
@ -591,13 +517,6 @@
{
"id": "age-refine",
"template": "list",
"header": {
"progress": {
"current": 5,
"total": 9,
"label": "5 of 9"
}
},
"title": {
"text": "Уточните чуть точнее",
"font": "manrope",
@ -631,13 +550,6 @@
{
"id": "partner-ethnicity",
"template": "list",
"header": {
"progress": {
"current": 6,
"total": 9,
"label": "6 of 9"
}
},
"title": {
"text": "Этническая принадлежность твоей второй половинки?",
"font": "manrope",
@ -687,13 +599,6 @@
{
"id": "partner-eyes",
"template": "list",
"header": {
"progress": {
"current": 7,
"total": 9,
"label": "7 of 9"
}
},
"title": {
"text": "Что из этого «про глаза»?",
"font": "manrope",
@ -735,13 +640,6 @@
{
"id": "partner-hair-length",
"template": "list",
"header": {
"progress": {
"current": 8,
"total": 9,
"label": "8 of 9"
}
},
"title": {
"text": "Выберите длину волос",
"font": "manrope",
@ -775,20 +673,13 @@
{
"id": "burnout-support",
"template": "list",
"header": {
"progress": {
"current": 9,
"total": 9,
"label": "9 of 9"
}
},
"title": {
"text": "Когда ты выгораешь, тебе нужно чтобы партнёр...",
"font": "manrope",
"weight": "bold"
},
"list": {
"selectionType": "single",
"selectionType": "multi",
"options": [
{
"id": "reassure",
@ -812,6 +703,10 @@
}
]
},
"bottomActionButton": {
"text": "Continue",
"show": false
},
"navigation": {
"defaultNextScreenId": "special-offer"
}

View File

@ -3,13 +3,13 @@ import { notFound, redirect } from "next/navigation";
import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition";
interface FunnelRootPageProps {
params: {
params: Promise<{
funnelId: string;
};
}>;
}
export default async function FunnelRootPage({ params }: FunnelRootPageProps) {
const { funnelId } = params;
const { funnelId } = await params;
let funnel;
try {

View File

@ -6,10 +6,9 @@ import { ListTemplate } from "@/components/funnel/templates/ListTemplate";
import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate";
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
import { TextTemplate } from "@/components/funnel/templates/TextTemplate";
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context";
import type { ListScreenDefinition, InfoScreenDefinition, DateScreenDefinition, FormScreenDefinition, TextScreenDefinition, CouponScreenDefinition } from "@/lib/funnel/types";
import type { ListScreenDefinition, InfoScreenDefinition, DateScreenDefinition, FormScreenDefinition, CouponScreenDefinition } from "@/lib/funnel/types";
export function BuilderPreview() {
const selectedScreen = useBuilderSelectedScreen();
@ -94,13 +93,6 @@ export function BuilderPreview() {
/>
);
case "text":
return (
<TextTemplate
{...commonProps}
screen={selectedScreen as TextScreenDefinition}
/>
);
case "coupon":
return (

View File

@ -0,0 +1,206 @@
"use client";
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 type { BuilderScreen } from "@/lib/admin/builder/types";
interface ListScreenConfigProps {
screen: BuilderScreen & { template: "list" };
onUpdate: (updates: Partial<ListScreenDefinition>) => void;
}
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,
};
onUpdate({
list: {
...listScreen.list,
options: newOptions,
}
});
};
const handleAddOption = () => {
const newOptions = [...listScreen.list.options];
newOptions.push({
id: `option-${Date.now()}`,
label: "New Option",
});
onUpdate({
list: {
...listScreen.list,
options: newOptions,
}
});
};
const handleRemoveOption = (index: number) => {
const newOptions = listScreen.list.options.filter((_, i) => i !== index);
onUpdate({
list: {
...listScreen.list,
options: newOptions,
}
});
};
const handleBottomActionButtonChange = (text: string) => {
onUpdate({
list: {
...listScreen.list,
bottomActionButton: text ? {
text,
show: true,
} : undefined,
}
});
};
return (
<div className="space-y-6">
{/* Title Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<TextInput
placeholder="Enter screen title"
value={listScreen.title?.text || ""}
onChange={(e) => handleTitleChange(e.target.value)}
/>
</div>
{/* Subtitle Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Subtitle (Optional)</label>
<TextInput
placeholder="Enter screen subtitle"
value={listScreen.subtitle?.text || ""}
onChange={(e) => handleSubtitleChange(e.target.value)}
/>
</div>
{/* Selection Type */}
<div className="space-y-2">
<label className="text-sm font-medium">Selection Type</label>
<div className="flex gap-2">
<Button
variant={listScreen.list.selectionType === "single" ? "default" : "outline"}
onClick={() => handleSelectionTypeChange("single")}
className="h-8 px-3 text-sm"
>
Single
</Button>
<Button
variant={listScreen.list.selectionType === "multi" ? "default" : "outline"}
onClick={() => handleSelectionTypeChange("multi")}
className="h-8 px-3 text-sm"
>
Multi
</Button>
</div>
</div>
{/* Options */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Options</label>
<Button
variant="outline"
onClick={handleAddOption}
className="h-7 px-3 text-xs"
>
<Plus className="w-4 h-4 mr-1" />
Add Option
</Button>
</div>
<div className="space-y-2">
{listScreen.list.options.map((option, index) => (
<div key={option.id} className="flex gap-2 items-center">
<div className="flex-1">
<TextInput
placeholder="Option ID"
value={option.id}
onChange={(e) => handleOptionChange(index, "id", e.target.value)}
/>
</div>
<div className="flex-[2]">
<TextInput
placeholder="Option Label"
value={option.label}
onChange={(e) => handleOptionChange(index, "label", e.target.value)}
/>
</div>
<Button
variant="outline"
onClick={() => handleRemoveOption(index)}
className="h-8 px-2"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
</div>
{/* Bottom Action Button */}
<div className="space-y-2">
<label className="text-sm font-medium">Bottom Action Button (Optional)</label>
<TextInput
placeholder="Button text (leave empty for auto-behavior)"
value={listScreen.list.bottomActionButton?.text || ""}
onChange={(e) => handleBottomActionButtonChange(e.target.value)}
/>
<div className="text-xs text-muted-foreground">
{listScreen.list.selectionType === "multi"
? "Multi selection always shows a button"
: "Single selection: empty = auto-advance, filled = manual button"}
</div>
</div>
</div>
);
}

View File

@ -4,10 +4,10 @@ import { InfoScreenConfig } from "./InfoScreenConfig";
import { DateScreenConfig } from "./DateScreenConfig";
import { CouponScreenConfig } from "./CouponScreenConfig";
import { FormScreenConfig } from "./FormScreenConfig";
import { TextScreenConfig } from "./TextScreenConfig";
import { ListScreenConfig } from "./ListScreenConfig";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { ScreenDefinition, InfoScreenDefinition, DateScreenDefinition, CouponScreenDefinition, FormScreenDefinition, TextScreenDefinition } from "@/lib/funnel/types";
import type { ScreenDefinition, InfoScreenDefinition, DateScreenDefinition, CouponScreenDefinition, FormScreenDefinition, ListScreenDefinition } from "@/lib/funnel/types";
interface TemplateConfigProps {
screen: BuilderScreen;
@ -50,22 +50,12 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
/>
);
case "text":
return (
<TextScreenConfig
screen={screen as BuilderScreen & { template: "text" }}
onUpdate={onUpdate as (updates: Partial<TextScreenDefinition>) => void}
/>
);
case "list":
return (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
List template configuration is available in the existing sidebar.
This is a legacy template that will be updated soon.
</div>
</div>
<ListScreenConfig
screen={screen as BuilderScreen & { template: "list" }}
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
/>
);
default:

View File

@ -1,198 +0,0 @@
"use client";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { TextScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
interface TextScreenConfigProps {
screen: BuilderScreen & { template: "text" };
onUpdate: (updates: Partial<TextScreenDefinition>) => void;
}
export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
const textScreen = screen as TextScreenDefinition & { position: { x: number; y: number } };
return (
<div className="space-y-4">
{/* Title Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<TextInput
placeholder="Enter screen title"
value={textScreen.title?.text || ""}
onChange={(e) => onUpdate({
title: {
...textScreen.title,
text: e.target.value,
font: textScreen.title?.font || "manrope",
weight: textScreen.title?.weight || "bold",
align: textScreen.title?.align || "center",
}
})}
/>
<div className="grid grid-cols-3 gap-2">
<select
className="rounded border border-border bg-background px-2 py-1 text-sm"
value={textScreen.title?.font || "manrope"}
onChange={(e) => onUpdate({
title: {
...textScreen.title,
text: textScreen.title?.text || "",
font: e.target.value as "manrope" | "inter" | "geistSans" | "geistMono",
}
})}
>
<option value="manrope">Manrope</option>
<option value="inter">Inter</option>
</select>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm"
value={textScreen.title?.weight || "bold"}
onChange={(e) => onUpdate({
title: {
...textScreen.title,
text: textScreen.title?.text || "",
weight: e.target.value as "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black",
}
})}
>
<option value="medium">Medium</option>
<option value="bold">Bold</option>
<option value="semibold">Semibold</option>
</select>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm"
value={textScreen.title?.align || "center"}
onChange={(e) => onUpdate({
title: {
...textScreen.title,
text: textScreen.title?.text || "",
align: e.target.value as "center" | "left" | "right",
}
})}
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
</div>
{/* Content Configuration */}
<div className="space-y-2">
<label className="text-sm font-medium">Content</label>
<textarea
className="w-full rounded border border-border bg-background px-3 py-2 text-sm min-h-[100px] resize-y"
placeholder="Enter the main content text. This can be multiple paragraphs, statistics, or any text content you want to display."
value={textScreen.content?.text || ""}
onChange={(e) => onUpdate({
content: {
...textScreen.content,
text: e.target.value,
font: textScreen.content?.font || "inter",
weight: textScreen.content?.weight || "medium",
color: textScreen.content?.color || "default",
align: textScreen.content?.align || "center",
}
})}
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Font</label>
<select
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
value={textScreen.content?.font || "inter"}
onChange={(e) => onUpdate({
content: {
...textScreen.content,
text: textScreen.content?.text || "",
font: e.target.value as "manrope" | "inter" | "geistSans" | "geistMono",
}
})}
>
<option value="manrope">Manrope</option>
<option value="inter">Inter</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Weight</label>
<select
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
value={textScreen.content?.weight || "medium"}
onChange={(e) => onUpdate({
content: {
...textScreen.content,
text: textScreen.content?.text || "",
weight: e.target.value as "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black",
}
})}
>
<option value="medium">Medium</option>
<option value="bold">Bold</option>
<option value="semibold">Semibold</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Color</label>
<select
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
value={textScreen.content?.color || "default"}
onChange={(e) => onUpdate({
content: {
...textScreen.content,
text: textScreen.content?.text || "",
color: e.target.value as "default" | "primary" | "secondary" | "accent" | "destructive" | "success" | "muted",
}
})}
>
<option value="default">Default</option>
<option value="muted">Muted</option>
<option value="accent">Accent</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Align</label>
<select
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
value={textScreen.content?.align || "center"}
onChange={(e) => onUpdate({
content: {
...textScreen.content,
text: textScreen.content?.text || "",
align: e.target.value as "center" | "left" | "right",
}
})}
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
</div>
</div>
{/* Bottom Action Button */}
<div className="space-y-2">
<label className="text-sm font-medium">Button Text (Optional)</label>
<TextInput
placeholder="Next"
value={textScreen.bottomActionButton?.text || ""}
onChange={(e) => onUpdate({
bottomActionButton: e.target.value ? {
text: e.target.value,
} : undefined
})}
/>
</div>
</div>
);
}

View File

@ -2,5 +2,5 @@ export { InfoScreenConfig } from "./InfoScreenConfig";
export { DateScreenConfig } from "./DateScreenConfig";
export { CouponScreenConfig } from "./CouponScreenConfig";
export { FormScreenConfig } from "./FormScreenConfig";
export { TextScreenConfig } from "./TextScreenConfig";
export { ListScreenConfig } from "./ListScreenConfig";
export { TemplateConfig } from "./TemplateConfig";

View File

@ -9,25 +9,48 @@ import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate";
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
import { TextTemplate } from "@/components/funnel/templates/TextTemplate";
import { resolveNextScreenId } from "@/lib/funnel/navigation";
import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider";
import type {
FunnelDefinition,
InfoScreenDefinition,
DateScreenDefinition,
CouponScreenDefinition,
FormScreenDefinition,
TextScreenDefinition,
ListScreenDefinition,
DateScreenDefinition,
FormScreenDefinition,
CouponScreenDefinition,
InfoScreenDefinition,
ScreenDefinition,
FunnelAnswers,
} from "@/lib/funnel/types";
// Функция для оценки длины пути пользователя на основе текущих ответов
function estimatePathLength(funnel: FunnelDefinition, answers: FunnelAnswers): number {
const visited = new Set<string>();
let currentScreenId = funnel.meta.firstScreenId || funnel.screens[0]?.id;
// Симулируем прохождение воронки с текущими ответами
while (currentScreenId && !visited.has(currentScreenId)) {
visited.add(currentScreenId);
const currentScreen = funnel.screens.find(s => s.id === currentScreenId);
if (!currentScreen) break;
const nextScreenId = resolveNextScreenId(currentScreen, answers, funnel.screens);
// Если достигли конца или зацикливание
if (!nextScreenId || visited.has(nextScreenId)) {
break;
}
currentScreenId = nextScreenId;
}
return visited.size;
}
interface FunnelRuntimeProps {
funnel: FunnelDefinition;
initialScreenId: string;
}
type TemplateComponentProps = {
screen: ScreenDefinition;
selectedOptionIds: string[];
@ -85,7 +108,7 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
/>
);
},
coupon: ({ screen, onContinue, canGoBack, onBack }) => {
coupon: ({ screen, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
const couponScreen = screen as CouponScreenDefinition;
return (
@ -94,10 +117,12 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
onContinue={onContinue}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
defaultTexts={defaultTexts}
/>
);
},
form: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack }) => {
form: ({ screen, selectedOptionIds, onSelectionChange, onContinue, canGoBack, onBack, screenProgress, defaultTexts }) => {
const formScreen = screen as FormScreenDefinition;
// For form screens, we store form data as JSON string in the first element
@ -123,18 +148,8 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
onContinue={onContinue}
canGoBack={canGoBack}
onBack={onBack}
/>
);
},
text: ({ screen, onContinue, canGoBack, onBack }) => {
const textScreen = screen as TextScreenDefinition;
return (
<TextTemplate
screen={textScreen}
onContinue={onContinue}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
defaultTexts={defaultTexts}
/>
);
},
@ -145,16 +160,34 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}) => {
const listScreen = screen as ListScreenDefinition;
const selectionType = listScreen.list.selectionType;
const actionConfig =
listScreen.list.bottomActionButton ??
(selectionType === "multi" ? { text: "Next" } : undefined);
const hasActionButton = Boolean(actionConfig);
const isSelectionEmpty = selectedOptionIds.length === 0;
const showGradient = true;
// Особая логика для multi selection: даже при show: false кнопка появляется при выборе
const bottomActionButton = listScreen.list.bottomActionButton || listScreen.bottomActionButton;
const isButtonExplicitlyDisabled = bottomActionButton?.show === false;
let hasActionButton: boolean;
if (selectionType === "multi") {
// Для multi: если кнопка отключена, она появляется только при выборе
if (isButtonExplicitlyDisabled) {
hasActionButton = !isSelectionEmpty; // Показать кнопку если что-то выбрано
} else {
hasActionButton = true; // Показать кнопку всегда (стандартное поведение)
}
} else {
// Для single: как раньше - кнопка есть если не отключена явно
hasActionButton = !isButtonExplicitlyDisabled;
}
const actionConfig = hasActionButton
? (bottomActionButton ?? { text: defaultTexts?.nextButton || "Next" })
: undefined;
const actionDisabled = hasActionButton && isSelectionEmpty;
return (
@ -169,9 +202,9 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
onClick: actionDisabled ? undefined : onContinue,
}
: undefined}
showGradient={showGradient}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
/>
);
},
@ -202,14 +235,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
const selectedOptionIds = answers[currentScreen.id] ?? [];
// Calculate automatic progress
const screenProgress = useMemo(() => {
const total = funnel.screens.length;
const currentIndex = funnel.screens.findIndex(screen => screen.id === currentScreen.id);
const current = currentIndex >= 0 ? currentIndex + 1 : 1;
return { current, total };
}, [currentScreen.id, funnel]);
useEffect(() => {
registerScreen(currentScreen.id);
}, [currentScreen.id, registerScreen]);
@ -232,6 +257,13 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
return [...history, currentScreen.id];
}, [history, currentScreen.id]);
// Calculate automatic progress based on user's actual path
const screenProgress = useMemo(() => {
const total = estimatePathLength(funnel, answers);
const current = historyWithCurrent.length; // Номер текущего экрана = количество посещенных
return { current, total };
}, [historyWithCurrent.length, funnel, answers]);
const goToScreen = (screenId: string | undefined) => {
if (!screenId) {
return;
@ -250,8 +282,41 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
prevSelectedIds.length !== ids.length ||
prevSelectedIds.some((value, index) => value !== ids[index]);
if (!hasChanged) {
return;
// Check if this is a single selection list without action button
const shouldAutoAdvance = currentScreen.template === "list" && (() => {
const listScreen = currentScreen as ListScreenDefinition;
const selectionType = listScreen.list.selectionType;
// Используем ту же логику что и в list template
const bottomActionButton = listScreen.list.bottomActionButton || listScreen.bottomActionButton;
const isButtonExplicitlyDisabled = bottomActionButton?.show === false;
const isSelectionEmpty = ids.length === 0;
let hasActionButton: boolean;
if (selectionType === "multi") {
// Для multi: если кнопка отключена, она появляется только при выборе
if (isButtonExplicitlyDisabled) {
hasActionButton = !isSelectionEmpty; // Показать кнопку если что-то выбрано
} else {
hasActionButton = true; // Показать кнопку всегда (стандартное поведение)
}
} else {
// Для single: как раньше - кнопка есть если не отключена явно
hasActionButton = !isButtonExplicitlyDisabled;
}
return selectionType === "single" && !hasActionButton && ids.length > 0;
})();
// ПРАВИЛЬНОЕ РЕШЕНИЕ: Автопереход ТОЛЬКО при изменении значения
// Это исключает автопереход при возврате назад, когда компоненты
// восстанавливают состояние и вызывают callbacks без реального изменения
const shouldProceed = hasChanged;
if (!shouldProceed) {
return; // Блокируем программные вызовы useEffect без изменений
}
const nextAnswers = {
@ -263,21 +328,15 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
delete nextAnswers[currentScreen.id];
}
setAnswers(currentScreen.id, ids);
// Only save answers if they actually changed
if (hasChanged) {
setAnswers(currentScreen.id, ids);
}
// Auto-advance only applies to list screens with single selection
if (currentScreen.template === "list") {
const listScreen = currentScreen as ListScreenDefinition;
const selectionType = listScreen.list.selectionType;
const hasActionButton = Boolean(
listScreen.list.bottomActionButton ??
(selectionType === "multi" ? { text: "Next" } : undefined)
);
if (selectionType === "single" && !hasActionButton && ids.length > 0) {
const nextScreenId = resolveNextScreenId(currentScreen, nextAnswers, funnel.screens);
goToScreen(nextScreenId);
}
// Auto-advance for single selection without action button
if (shouldAutoAdvance) {
const nextScreenId = resolveNextScreenId(currentScreen, nextAnswers, funnel.screens);
goToScreen(nextScreenId);
}
};

View File

@ -3,17 +3,12 @@
import { useState } from "react";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
import { Coupon } from "@/components/widgets/Coupon/Coupon";
import Typography from "@/components/ui/Typography/Typography";
import {
buildHeaderProgress,
buildLayoutQuestionProps,
buildTypographyProps,
shouldShowBackButton,
shouldShowHeader,
} from "@/lib/funnel/mappers";
import type { CouponScreenDefinition } from "@/lib/funnel/types";
@ -22,6 +17,7 @@ interface CouponTemplateProps {
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string };
}
@ -30,12 +26,11 @@ export function CouponTemplate({
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}: CouponTemplateProps) {
const [copiedCode, setCopiedCode] = useState<string | null>(null);
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
const showHeader = shouldShowHeader(screen.header);
const handleCopyPromoCode = (code: string) => {
// Copy to clipboard
@ -48,50 +43,19 @@ export function CouponTemplate({
}, 2000);
};
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
? {
children: screen.bottomActionButton.text,
cornerRadius: screen.bottomActionButton.cornerRadius,
onClick: onContinue,
}
: {
children: defaultTexts?.continueButton || "Continue",
onClick: onContinue,
};
const bottomActionButtonProps: BottomActionButtonProps = {
actionButtonProps,
};
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
headerProps: showHeader ? {
progressProps: buildHeaderProgress(screen.header?.progress),
onBack: showBackButton ? onBack : undefined,
showBackButton,
} : undefined,
title:
buildTypographyProps(screen.title, {
as: "h2",
defaults: {
font: "manrope",
weight: "bold",
align: "center",
},
}) ?? {
as: "h2",
children: screen.title.text,
},
subtitle: screen.subtitle ? buildTypographyProps(screen.subtitle, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "muted",
align: "center",
},
}) : undefined,
bottomActionButtonProps,
};
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "center" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.continueButton || "Continue",
disabled: false,
onClick: onContinue,
},
screenProgress,
});
// Build coupon props from screen definition
const couponProps = {

View File

@ -4,16 +4,11 @@ import { useState, useEffect, useMemo } from "react";
import NextImage from "next/image";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
import Typography from "@/components/ui/Typography/Typography";
import {
buildHeaderProgress,
buildLayoutQuestionProps,
buildTypographyProps,
shouldShowBackButton,
shouldShowHeader,
} from "@/lib/funnel/mappers";
import type { DateScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
@ -84,8 +79,6 @@ export function DateTemplate({
const dayOptions = useMemo(() => generateDayOptions(month, year), [month, year]);
const yearOptions = useMemo(() => generateYearOptions(), []);
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
const showHeader = shouldShowHeader(screen.header);
// Custom Select component matching TextInput styling
const SelectInput = ({
@ -174,56 +167,19 @@ export function DateTemplate({
return null;
}, [month, day, year]);
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
? {
children: screen.bottomActionButton.text,
cornerRadius: screen.bottomActionButton.cornerRadius,
onClick: onContinue,
disabled: !isComplete,
}
: {
children: defaultTexts?.nextButton || "Next",
onClick: onContinue,
disabled: !isComplete,
};
const bottomActionButtonProps: BottomActionButtonProps = {
actionButtonProps,
};
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
headerProps: showHeader ? {
progressProps: screenProgress ? buildHeaderProgress({
current: screenProgress.current,
total: screenProgress.total,
label: `${screenProgress.current} of ${screenProgress.total}`
}) : buildHeaderProgress(screen.header?.progress),
onBack: showBackButton ? onBack : undefined,
showBackButton,
} : undefined,
title:
buildTypographyProps(screen.title, {
as: "h2",
defaults: {
font: "manrope",
weight: "bold",
align: "left",
},
}) ?? {
as: "h2",
children: screen.title.text,
},
subtitle: screen.subtitle ? buildTypographyProps(screen.subtitle, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "muted",
align: "left",
},
}) : undefined,
bottomActionButtonProps,
};
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.nextButton || "Next",
disabled: !isComplete,
onClick: onContinue,
},
screenProgress,
});
return (
<LayoutQuestion {...layoutQuestionProps}>

View File

@ -3,15 +3,10 @@
import { useState, useEffect } from "react";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import {
buildHeaderProgress,
buildTypographyProps,
shouldShowBackButton,
buildLayoutQuestionProps,
} from "@/lib/funnel/mappers";
import type { FormScreenDefinition } from "@/lib/funnel/types";
@ -22,6 +17,7 @@ interface FormTemplateProps {
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: { nextButton?: string; continueButton?: string };
}
@ -32,56 +28,48 @@ export function FormTemplate({
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}: FormTemplateProps) {
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
const [errors, setErrors] = useState<Record<string, string>>({});
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
// Sync with external form data
useEffect(() => {
setLocalFormData(formData);
}, [formData]);
// Update parent when local data changes
// Update external form data when local data changes
useEffect(() => {
onFormDataChange(localFormData);
}, [localFormData, onFormDataChange]);
const validateField = (fieldId: string, value: string) => {
const validateField = (fieldId: string, value: string): string | null => {
const field = screen.fields.find(f => f.id === fieldId);
if (!field) return "";
if (!field) return null;
const messages = screen.validationMessages;
// Check required
if (field.required && !value.trim()) {
const template = messages?.required || "${field} is required";
return template.replace("${field}", field.label || field.id);
return screen.validationMessages?.required?.replace('${field}', field.label || field.id) || `${field.label || field.id} is required`;
}
// Check max length
if (field.maxLength && value.length > field.maxLength) {
const template = messages?.maxLength || "Maximum ${maxLength} characters allowed";
return template.replace("${maxLength}", field.maxLength.toString());
return screen.validationMessages?.maxLength?.replace('${maxLength}', String(field.maxLength)) || `Maximum ${field.maxLength} characters allowed`;
}
// Check validation pattern
if (field.validation?.pattern && value.trim()) {
if (field.validation?.pattern) {
const regex = new RegExp(field.validation.pattern);
if (!regex.test(value)) {
return field.validation.message || messages?.invalidFormat || "Invalid format";
return field.validation.message || screen.validationMessages?.invalidFormat || "Invalid format";
}
}
return "";
return null;
};
const handleFieldChange = (fieldId: string, value: string) => {
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
// Clear error when user starts typing
// Clear error if field becomes valid
if (errors[fieldId]) {
setErrors(prev => {
const newErrors = { ...prev };
@ -112,59 +100,27 @@ export function FormTemplate({
}
};
// Check if form is complete (all required fields filled)
const isFormComplete = screen.fields.every(field => {
if (!field.required) return true;
const value = localFormData[field.id] || "";
return value.trim().length > 0;
if (field.required) {
return value.trim().length > 0;
}
return true;
});
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
? {
children: screen.bottomActionButton.text,
cornerRadius: screen.bottomActionButton.cornerRadius,
onClick: handleContinue,
disabled: !isFormComplete,
}
: {
children: defaultTexts?.continueButton || "Continue",
onClick: handleContinue,
disabled: !isFormComplete,
};
const bottomActionButtonProps: BottomActionButtonProps = {
actionButtonProps,
};
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
headerProps: {
progressProps: buildHeaderProgress(screen.header?.progress),
onBack: showBackButton ? onBack : undefined,
showBackButton,
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.continueButton || "Continue",
disabled: !isFormComplete,
onClick: handleContinue,
},
title:
buildTypographyProps(screen.title, {
as: "h2",
defaults: {
font: "manrope",
weight: "bold",
align: "left",
},
}) ?? {
as: "h2",
children: screen.title.text,
},
subtitle: screen.subtitle ? buildTypographyProps(screen.subtitle, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "muted",
align: "left",
},
}) : undefined,
bottomActionButtonProps,
};
screenProgress,
});
return (
<LayoutQuestion {...layoutQuestionProps}>

View File

@ -4,16 +4,11 @@ import { useMemo } from "react";
import Image from "next/image";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
import Typography from "@/components/ui/Typography/Typography";
import {
buildHeaderProgress,
buildLayoutQuestionProps,
buildTypographyProps,
shouldShowBackButton,
shouldShowHeader,
} from "@/lib/funnel/mappers";
import type { InfoScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
@ -35,48 +30,18 @@ export function InfoTemplate({
screenProgress,
defaultTexts,
}: InfoTemplateProps) {
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
const showHeader = shouldShowHeader(screen.header);
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
? {
children: screen.bottomActionButton.text,
cornerRadius: screen.bottomActionButton.cornerRadius,
onClick: onContinue,
}
: {
children: defaultTexts?.nextButton || "Next",
onClick: onContinue,
};
const bottomActionButtonProps: BottomActionButtonProps = {
actionButtonProps,
};
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
headerProps: showHeader ? {
progressProps: screenProgress ? buildHeaderProgress({
current: screenProgress.current,
total: screenProgress.total,
label: `${screenProgress.current} of ${screenProgress.total}`
}) : buildHeaderProgress(screen.header?.progress),
onBack: showBackButton ? onBack : undefined,
showBackButton,
} : undefined,
title:
buildTypographyProps(screen.title, {
as: "h2",
defaults: {
font: "manrope",
weight: "bold",
align: "center",
},
}) ?? {
as: "h2",
children: screen.title.text,
},
bottomActionButtonProps,
};
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
canGoBack,
onBack,
actionButtonOptions: {
defaultText: defaultTexts?.nextButton || "Next",
disabled: false,
onClick: onContinue,
},
screenProgress,
});
const iconSizeClasses = useMemo(() => {
const size = screen.icon?.size ?? "xl";

View File

@ -3,18 +3,14 @@
import { useMemo } from "react";
import { Question } from "@/components/templates/Question/Question";
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
import type { RadioAnswersListProps } from "@/components/widgets/RadioAnswersList/RadioAnswersList";
import type { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
import {
buildHeaderProgress,
buildTypographyProps,
buildLayoutQuestionProps,
mapListOptionsToButtons,
shouldShowBackButton,
} from "@/lib/funnel/mappers";
import type { ListScreenDefinition } from "@/lib/funnel/types";
@ -23,9 +19,9 @@ interface ListTemplateProps {
selectedOptionIds: string[];
onSelectionChange: (selectedIds: string[]) => void;
actionButtonProps?: ActionButtonProps;
showGradient: boolean;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
}
function stringId(value: MainButtonProps["id"]): string | null {
@ -40,9 +36,9 @@ export function ListTemplate({
selectedOptionIds,
onSelectionChange,
actionButtonProps,
showGradient,
canGoBack,
onBack,
screenProgress,
}: ListTemplateProps) {
const buttons = useMemo(
() => mapListOptionsToButtons(screen.list.options, screen.list.selectionType),
@ -98,43 +94,27 @@ export function ListTemplate({
onChangeSelectedAnswers: handleSelectChange,
};
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
const effectiveBottomActionButtonProps: BottomActionButtonProps | undefined = showGradient
? actionButtonProps
? { actionButtonProps }
: {}
: undefined;
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
headerProps: {
progressProps: buildHeaderProgress(screen.header?.progress),
onBack: showBackButton ? onBack : undefined,
showBackButton,
// Определяем action button options для centralized логики только если кнопка нужна
const actionButtonOptions = actionButtonProps ? {
defaultText: actionButtonProps.children as string || "Next",
disabled: actionButtonProps.disabled || false,
onClick: () => {
if (actionButtonProps.onClick) {
actionButtonProps.onClick({} as React.MouseEvent<HTMLButtonElement>);
}
},
title:
buildTypographyProps(screen.title, {
as: "h2",
defaults: {
font: "manrope",
weight: "bold",
align: "left",
},
}) ?? {
as: "h2",
children: screen.title.text,
},
subtitle: buildTypographyProps(screen.subtitle, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "muted",
align: "left",
},
}),
bottomActionButtonProps: effectiveBottomActionButtonProps,
};
} : undefined;
const layoutQuestionProps = buildLayoutQuestionProps({
screen,
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
canGoBack,
onBack,
actionButtonOptions: actionButtonOptions,
screenProgress,
});
const contentProps =
contentType === "radio-answers-list" ? radioContent : selectContent;

View File

@ -1,97 +0,0 @@
"use client";
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
import Typography from "@/components/ui/Typography/Typography";
import {
buildHeaderProgress,
buildTypographyProps,
shouldShowBackButton,
} from "@/lib/funnel/mappers";
import type { TextScreenDefinition } from "@/lib/funnel/types";
interface TextTemplateProps {
screen: TextScreenDefinition;
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
}
export function TextTemplate({
screen,
onContinue,
canGoBack,
onBack,
}: TextTemplateProps) {
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
? {
children: screen.bottomActionButton.text,
cornerRadius: screen.bottomActionButton.cornerRadius,
onClick: onContinue,
}
: {
children: "Continue",
onClick: onContinue,
};
const bottomActionButtonProps: BottomActionButtonProps = {
actionButtonProps,
};
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
headerProps: {
progressProps: buildHeaderProgress(screen.header?.progress),
onBack: showBackButton ? onBack : undefined,
showBackButton,
},
title:
buildTypographyProps(screen.title, {
as: "h2",
defaults: {
font: "manrope",
weight: "bold",
align: "center",
},
}) ?? {
as: "h2",
children: screen.title.text,
},
bottomActionButtonProps,
};
return (
<LayoutQuestion {...layoutQuestionProps}>
<div className="w-full flex flex-col items-center justify-center text-center mt-[40px]">
{/* Content Text */}
<div className="max-w-[320px] mx-auto">
<Typography
as="p"
font="inter"
weight="medium"
color="default"
size="lg"
align="center"
{...buildTypographyProps(screen.content, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "default",
align: "center",
size: "lg",
},
})}
className="leading-[26px] text-slate-700"
>
{screen.content.text}
</Typography>
</div>
</div>
</LayoutQuestion>
);
}

View File

@ -21,7 +21,7 @@ function Header({
const shouldRenderBackButton = showBackButton && typeof onBack === "function";
return (
<header className={cn("w-full p-6 pb-3", className)} {...props}>
<header className={cn("w-full p-6 pb-3 min-h-[96px]", className)} {...props}>
<div className="w-full flex justify-left items-center min-h-9">
{shouldRenderBackButton && (
<Button
@ -33,9 +33,11 @@ function Header({
</Button>
)}
</div>
<div className="w-full flex justify-center items-center">
<Progress {...progressProps} />
</div>
{progressProps && (
<div className="w-full flex justify-center items-center mt-3">
<Progress {...progressProps} />
</div>
)}
</header>
);
}

View File

@ -50,7 +50,7 @@ function LayoutQuestion({
...props.style,
}}
>
<Header {...headerProps} />
{headerProps && <Header {...headerProps} />}
<div className="w-full flex flex-col justify-center items-center p-6 pt-[30px]">
{title && (
<Typography

View File

@ -5,7 +5,7 @@ import {
MainButton,
MainButtonProps,
} from "@/components/ui/MainButton/MainButton";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
export interface RadioAnswersListProps extends React.ComponentProps<"div"> {
answers: MainButtonProps[];
@ -25,6 +25,7 @@ function RadioAnswersList({
const [selectedAnswer, setSelectedAnswer] = useState<MainButtonProps | null>(
activeAnswer
);
const isInitialMount = useRef(true);
useEffect(() => {
setSelectedAnswer(activeAnswer ?? null);
@ -36,6 +37,12 @@ function RadioAnswersList({
};
useEffect(() => {
// НЕ вызываем callback при первоначальной загрузке компонента
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onChangeSelectedAnswer?.(selectedAnswer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAnswer]);

View File

@ -5,7 +5,7 @@ import {
MainButton,
MainButtonProps,
} from "@/components/ui/MainButton/MainButton";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
export interface SelectAnswersListProps extends React.ComponentProps<"div"> {
answers: MainButtonProps[];
@ -25,6 +25,7 @@ function SelectAnswersList({
const [selectedAnswers, setSelectedAnswers] = useState<
MainButtonProps[] | null
>(activeAnswers);
const isInitialMount = useRef(true);
useEffect(() => {
setSelectedAnswers(activeAnswers ?? null);
@ -42,6 +43,12 @@ function SelectAnswersList({
};
useEffect(() => {
// НЕ вызываем callback при первоначальной загрузке компонента
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onChangeSelectedAnswers?.(selectedAnswers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAnswers]);

View File

@ -9,7 +9,6 @@ import type {
TypographyVariant,
BottomActionButtonDefinition,
ScreenDefinition,
ColorPalette,
} from "./types";
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
@ -137,10 +136,14 @@ export function buildBottomActionButtonProps(
options: BuildActionButtonOptions,
buttonDef?: BottomActionButtonDefinition
): BottomActionButtonProps | undefined {
if (buttonDef?.show === false) {
// Если кнопка отключена и градиент явно отключен
if (buttonDef?.show === false && buttonDef?.showGradientBlur === false) {
return undefined;
}
// ВАЖНО: Если мы сюда дошли, значит логика FunnelRuntime уже решила
// что кнопка должна показываться (даже при show: false для multi selection)
// Поэтому всегда создаем actionButtonProps
const actionButtonProps = buildActionButtonProps(options, buttonDef);
return {
@ -154,7 +157,8 @@ interface BuildLayoutQuestionOptions {
subtitleDefaults?: TypographyDefaults;
canGoBack: boolean;
onBack: () => void;
actionButtonOptions: BuildActionButtonOptions;
actionButtonOptions?: BuildActionButtonOptions;
screenProgress?: { current: number; total: number };
}
export function buildLayoutQuestionProps(
@ -166,15 +170,26 @@ export function buildLayoutQuestionProps(
subtitleDefaults = { font: "inter", weight: "medium", color: "muted", align: "left" },
canGoBack,
onBack,
actionButtonOptions
actionButtonOptions,
screenProgress
} = options;
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
const showHeader = shouldShowHeader(screen.header);
const bottomActionButtonProps = actionButtonOptions ? buildBottomActionButtonProps(
actionButtonOptions,
'bottomActionButton' in screen ? screen.bottomActionButton : undefined
) : undefined;
return {
headerProps: showHeader ? {
progressProps: buildHeaderProgress(screen.header?.progress),
progressProps: screenProgress ? buildHeaderProgress({
current: screenProgress.current,
total: screenProgress.total,
label: `${screenProgress.current} of ${screenProgress.total}`
}) : buildHeaderProgress(screen.header?.progress),
onBack: showBackButton ? onBack : undefined,
showBackButton,
} : undefined,
@ -189,117 +204,7 @@ export function buildLayoutQuestionProps(
as: "p",
defaults: subtitleDefaults,
}) : undefined,
bottomActionButtonProps: buildBottomActionButtonProps(
actionButtonOptions,
'bottomActionButton' in screen ? screen.bottomActionButton : undefined
),
bottomActionButtonProps,
};
}
// Color system utilities
const DEFAULT_COLOR_PALETTE: ColorPalette = {
text: {
primary: "#1E293B",
secondary: "#475569",
muted: "#64748B",
accent: "#3B82F6",
success: "#10B981",
error: "#EF4444",
warning: "#F59E0B",
},
background: {
primary: "#FFFFFF",
secondary: "#F8FAFC",
accent: "#EFF6FF",
success: "#ECFDF5",
error: "#FEF2F2",
warning: "#FFFBEB",
},
button: {
primary: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
primaryText: "#FFFFFF",
secondary: "#F1F5F9",
secondaryText: "#334155",
disabled: "#E2E8F0",
disabledText: "#94A3B8",
},
border: {
primary: "#E2E8F0",
accent: "#3B82F6",
success: "#10B981",
error: "#EF4444",
},
shadow: {
light: "rgba(0, 0, 0, 0.05)",
medium: "rgba(0, 0, 0, 0.1)",
heavy: "rgba(0, 0, 0, 0.15)",
colored: "rgba(59, 130, 246, 0.3)",
},
};
export function resolveColorPalette(
funnelPalette?: ColorPalette,
screenOverrides?: Partial<ColorPalette>
): ColorPalette {
// Deep merge: Default -> Funnel -> Screen overrides
const basePalette = {
text: { ...DEFAULT_COLOR_PALETTE.text, ...funnelPalette?.text },
background: { ...DEFAULT_COLOR_PALETTE.background, ...funnelPalette?.background },
button: { ...DEFAULT_COLOR_PALETTE.button, ...funnelPalette?.button },
border: { ...DEFAULT_COLOR_PALETTE.border, ...funnelPalette?.border },
shadow: { ...DEFAULT_COLOR_PALETTE.shadow, ...funnelPalette?.shadow },
};
if (!screenOverrides) return basePalette;
return {
text: { ...basePalette.text, ...screenOverrides.text },
background: { ...basePalette.background, ...screenOverrides.background },
button: { ...basePalette.button, ...screenOverrides.button },
border: { ...basePalette.border, ...screenOverrides.border },
shadow: { ...basePalette.shadow, ...screenOverrides.shadow },
};
}
export function getCSSVariables(palette: ColorPalette): Record<string, string> {
const cssVars: Record<string, string> = {};
// Text colors
if (palette.text?.primary) cssVars['--funnel-text-primary'] = palette.text.primary;
if (palette.text?.secondary) cssVars['--funnel-text-secondary'] = palette.text.secondary;
if (palette.text?.muted) cssVars['--funnel-text-muted'] = palette.text.muted;
if (palette.text?.accent) cssVars['--funnel-text-accent'] = palette.text.accent;
if (palette.text?.success) cssVars['--funnel-text-success'] = palette.text.success;
if (palette.text?.error) cssVars['--funnel-text-error'] = palette.text.error;
if (palette.text?.warning) cssVars['--funnel-text-warning'] = palette.text.warning;
// Background colors
if (palette.background?.primary) cssVars['--funnel-bg-primary'] = palette.background.primary;
if (palette.background?.secondary) cssVars['--funnel-bg-secondary'] = palette.background.secondary;
if (palette.background?.accent) cssVars['--funnel-bg-accent'] = palette.background.accent;
if (palette.background?.success) cssVars['--funnel-bg-success'] = palette.background.success;
if (palette.background?.error) cssVars['--funnel-bg-error'] = palette.background.error;
if (palette.background?.warning) cssVars['--funnel-bg-warning'] = palette.background.warning;
// Button colors
if (palette.button?.primary) cssVars['--funnel-btn-primary'] = palette.button.primary;
if (palette.button?.primaryText) cssVars['--funnel-btn-primary-text'] = palette.button.primaryText;
if (palette.button?.secondary) cssVars['--funnel-btn-secondary'] = palette.button.secondary;
if (palette.button?.secondaryText) cssVars['--funnel-btn-secondary-text'] = palette.button.secondaryText;
if (palette.button?.disabled) cssVars['--funnel-btn-disabled'] = palette.button.disabled;
if (palette.button?.disabledText) cssVars['--funnel-btn-disabled-text'] = palette.button.disabledText;
// Border colors
if (palette.border?.primary) cssVars['--funnel-border-primary'] = palette.border.primary;
if (palette.border?.accent) cssVars['--funnel-border-accent'] = palette.border.accent;
if (palette.border?.success) cssVars['--funnel-border-success'] = palette.border.success;
if (palette.border?.error) cssVars['--funnel-border-error'] = palette.border.error;
// Shadow colors
if (palette.shadow?.light) cssVars['--funnel-shadow-light'] = palette.shadow.light;
if (palette.shadow?.medium) cssVars['--funnel-shadow-medium'] = palette.shadow.medium;
if (palette.shadow?.heavy) cssVars['--funnel-shadow-heavy'] = palette.shadow.heavy;
if (palette.shadow?.colored) cssVars['--funnel-shadow-colored'] = palette.shadow.colored;
return cssVars;
}

View File

@ -62,56 +62,7 @@ export interface DefaultTexts {
continueButton?: string; // "Continue"
}
// Color system for consistent theming
export interface TextColors {
primary?: string; // Main text color - #1E293B
secondary?: string; // Secondary text - #475569
muted?: string; // Muted/disabled text - #64748B
accent?: string; // Accent/highlight text - #3B82F6
success?: string; // Success messages - #10B981
error?: string; // Error messages - #EF4444
warning?: string; // Warning messages - #F59E0B
}
export interface BackgroundColors {
primary?: string; // Main background - #FFFFFF
secondary?: string; // Secondary background - #F8FAFC
accent?: string; // Accent background - #EFF6FF
success?: string; // Success background - #ECFDF5
error?: string; // Error background - #FEF2F2
warning?: string; // Warning background - #FFFBEB
}
export interface ButtonColors {
primary?: string; // Primary button background - gradient or solid
primaryText?: string; // Primary button text - #FFFFFF
secondary?: string; // Secondary button background - #F1F5F9
secondaryText?: string; // Secondary button text - #334155
disabled?: string; // Disabled button background - #E2E8F0
disabledText?: string; // Disabled button text - #94A3B8
}
export interface BorderColors {
primary?: string; // Main borders - #E2E8F0
accent?: string; // Accent borders - #3B82F6
success?: string; // Success borders - #10B981
error?: string; // Error borders - #EF4444
}
export interface ShadowColors {
light?: string; // Light shadow - rgba(0, 0, 0, 0.05)
medium?: string; // Medium shadow - rgba(0, 0, 0, 0.1)
heavy?: string; // Heavy shadow - rgba(0, 0, 0, 0.15)
colored?: string; // Colored shadow (for buttons) - rgba(59, 130, 246, 0.3)
}
export interface ColorPalette {
text?: TextColors;
background?: BackgroundColors;
button?: ButtonColors;
border?: BorderColors;
shadow?: ShadowColors;
}
export interface NavigationConditionDefinition {
screenId: string;
@ -148,7 +99,6 @@ export interface InfoScreenDefinition {
};
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
colorOverrides?: Partial<ColorPalette>; // Override colors for this screen
}
export interface DateInputDefinition {
@ -176,7 +126,6 @@ export interface DateScreenDefinition {
};
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
colorOverrides?: Partial<ColorPalette>;
}
export interface CouponDefinition {
@ -199,7 +148,6 @@ export interface CouponScreenDefinition {
copiedMessage?: string; // "Промокод скопирован!" text
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
colorOverrides?: Partial<ColorPalette>;
}
export interface FormFieldDefinition {
@ -231,19 +179,8 @@ export interface FormScreenDefinition {
validationMessages?: FormValidationMessages;
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
colorOverrides?: Partial<ColorPalette>;
}
export interface TextScreenDefinition {
id: string;
template: "text";
header?: HeaderDefinition;
title: TypographyVariant;
content: TypographyVariant;
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
colorOverrides?: Partial<ColorPalette>;
}
export interface ListScreenDefinition {
id: string;
@ -257,11 +194,11 @@ export interface ListScreenDefinition {
options: ListOptionDefinition[];
bottomActionButton?: BottomActionButtonDefinition;
};
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
colorOverrides?: Partial<ColorPalette>;
}
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | TextScreenDefinition | ListScreenDefinition;
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition;
export interface FunnelMetaDefinition {
id: string;
@ -274,7 +211,6 @@ export interface FunnelMetaDefinition {
export interface FunnelDefinition {
meta: FunnelMetaDefinition;
defaultTexts?: DefaultTexts;
colorPalette?: ColorPalette;
screens: ScreenDefinition[];
}