fix
This commit is contained in:
parent
d5bcfb0330
commit
22c6d513af
@ -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"
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 (
|
||||
|
||||
206
src/components/admin/builder/templates/ListScreenConfig.tsx
Normal file
206
src/components/admin/builder/templates/ListScreenConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user