fix
This commit is contained in:
parent
d5bcfb0330
commit
22c6d513af
@ -9,45 +9,6 @@
|
|||||||
"nextButton": "Next",
|
"nextButton": "Next",
|
||||||
"continueButton": "Continue"
|
"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": [
|
"screens": [
|
||||||
{
|
{
|
||||||
"id": "intro-welcome",
|
"id": "intro-welcome",
|
||||||
@ -188,23 +149,20 @@
|
|||||||
"maxLength": "Максимум ${maxLength} символов",
|
"maxLength": "Максимум ${maxLength} символов",
|
||||||
"invalidFormat": "Неверный формат"
|
"invalidFormat": "Неверный формат"
|
||||||
},
|
},
|
||||||
"bottomActionButton": {
|
|
||||||
"text": "Continue"
|
|
||||||
},
|
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"defaultNextScreenId": "statistics-text"
|
"defaultNextScreenId": "statistics-text"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "statistics-text",
|
"id": "statistics-text",
|
||||||
"template": "text",
|
"template": "info",
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Which best represents your hair loss and goals?",
|
"text": "Which best represents your hair loss and goals?",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
"weight": "bold",
|
"weight": "bold",
|
||||||
"align": "center"
|
"align": "center"
|
||||||
},
|
},
|
||||||
"content": {
|
"description": {
|
||||||
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
|
"text": "По нашей статистике 51 % женщин Овнов доверяются эмоциям. Но одной чувствительности мало. Мы покажем, какие качества второй половинки дадут тепло и уверенность, и изобразим её портрет.",
|
||||||
"font": "inter",
|
"font": "inter",
|
||||||
"weight": "medium",
|
"weight": "medium",
|
||||||
@ -285,13 +243,6 @@
|
|||||||
{
|
{
|
||||||
"id": "analysis-target",
|
"id": "analysis-target",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 6,
|
|
||||||
"total": 15,
|
|
||||||
"label": "6 of 15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Кого анализируем?",
|
"text": "Кого анализируем?",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -367,13 +318,6 @@
|
|||||||
{
|
{
|
||||||
"id": "current-partner-age",
|
"id": "current-partner-age",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 4,
|
|
||||||
"total": 9,
|
|
||||||
"label": "4 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Возраст текущего партнера",
|
"text": "Возраст текущего партнера",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -423,13 +367,6 @@
|
|||||||
{
|
{
|
||||||
"id": "crush-age",
|
"id": "crush-age",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 4,
|
|
||||||
"total": 9,
|
|
||||||
"label": "4 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Возраст человека, который нравится",
|
"text": "Возраст человека, который нравится",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -460,6 +397,9 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"bottomActionButton": {
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
@ -479,13 +419,6 @@
|
|||||||
{
|
{
|
||||||
"id": "ex-partner-age",
|
"id": "ex-partner-age",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 4,
|
|
||||||
"total": 9,
|
|
||||||
"label": "4 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Возраст бывшего",
|
"text": "Возраст бывшего",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -535,13 +468,6 @@
|
|||||||
{
|
{
|
||||||
"id": "future-partner-age",
|
"id": "future-partner-age",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 4,
|
|
||||||
"total": 9,
|
|
||||||
"label": "4 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Возраст будущего партнера",
|
"text": "Возраст будущего партнера",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -591,13 +517,6 @@
|
|||||||
{
|
{
|
||||||
"id": "age-refine",
|
"id": "age-refine",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 5,
|
|
||||||
"total": 9,
|
|
||||||
"label": "5 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Уточните чуть точнее",
|
"text": "Уточните чуть точнее",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -631,13 +550,6 @@
|
|||||||
{
|
{
|
||||||
"id": "partner-ethnicity",
|
"id": "partner-ethnicity",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 6,
|
|
||||||
"total": 9,
|
|
||||||
"label": "6 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Этническая принадлежность твоей второй половинки?",
|
"text": "Этническая принадлежность твоей второй половинки?",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -687,13 +599,6 @@
|
|||||||
{
|
{
|
||||||
"id": "partner-eyes",
|
"id": "partner-eyes",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 7,
|
|
||||||
"total": 9,
|
|
||||||
"label": "7 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Что из этого «про глаза»?",
|
"text": "Что из этого «про глаза»?",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -735,13 +640,6 @@
|
|||||||
{
|
{
|
||||||
"id": "partner-hair-length",
|
"id": "partner-hair-length",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 8,
|
|
||||||
"total": 9,
|
|
||||||
"label": "8 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Выберите длину волос",
|
"text": "Выберите длину волос",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
@ -775,20 +673,13 @@
|
|||||||
{
|
{
|
||||||
"id": "burnout-support",
|
"id": "burnout-support",
|
||||||
"template": "list",
|
"template": "list",
|
||||||
"header": {
|
|
||||||
"progress": {
|
|
||||||
"current": 9,
|
|
||||||
"total": 9,
|
|
||||||
"label": "9 of 9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Когда ты выгораешь, тебе нужно чтобы партнёр...",
|
"text": "Когда ты выгораешь, тебе нужно чтобы партнёр...",
|
||||||
"font": "manrope",
|
"font": "manrope",
|
||||||
"weight": "bold"
|
"weight": "bold"
|
||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"selectionType": "single",
|
"selectionType": "multi",
|
||||||
"options": [
|
"options": [
|
||||||
{
|
{
|
||||||
"id": "reassure",
|
"id": "reassure",
|
||||||
@ -812,6 +703,10 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"bottomActionButton": {
|
||||||
|
"text": "Continue",
|
||||||
|
"show": false
|
||||||
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"defaultNextScreenId": "special-offer"
|
"defaultNextScreenId": "special-offer"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition";
|
import { loadFunnelDefinition } from "@/lib/funnel/loadFunnelDefinition";
|
||||||
|
|
||||||
interface FunnelRootPageProps {
|
interface FunnelRootPageProps {
|
||||||
params: {
|
params: Promise<{
|
||||||
funnelId: string;
|
funnelId: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function FunnelRootPage({ params }: FunnelRootPageProps) {
|
export default async function FunnelRootPage({ params }: FunnelRootPageProps) {
|
||||||
const { funnelId } = params;
|
const { funnelId } = await params;
|
||||||
|
|
||||||
let funnel;
|
let funnel;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -6,10 +6,9 @@ import { ListTemplate } from "@/components/funnel/templates/ListTemplate";
|
|||||||
import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate";
|
import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate";
|
||||||
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
|
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
|
||||||
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
|
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
|
||||||
import { TextTemplate } from "@/components/funnel/templates/TextTemplate";
|
|
||||||
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
||||||
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context";
|
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() {
|
export function BuilderPreview() {
|
||||||
const selectedScreen = useBuilderSelectedScreen();
|
const selectedScreen = useBuilderSelectedScreen();
|
||||||
@ -94,13 +93,6 @@ export function BuilderPreview() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "text":
|
|
||||||
return (
|
|
||||||
<TextTemplate
|
|
||||||
{...commonProps}
|
|
||||||
screen={selectedScreen as TextScreenDefinition}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "coupon":
|
case "coupon":
|
||||||
return (
|
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 { DateScreenConfig } from "./DateScreenConfig";
|
||||||
import { CouponScreenConfig } from "./CouponScreenConfig";
|
import { CouponScreenConfig } from "./CouponScreenConfig";
|
||||||
import { FormScreenConfig } from "./FormScreenConfig";
|
import { FormScreenConfig } from "./FormScreenConfig";
|
||||||
import { TextScreenConfig } from "./TextScreenConfig";
|
import { ListScreenConfig } from "./ListScreenConfig";
|
||||||
|
|
||||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
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 {
|
interface TemplateConfigProps {
|
||||||
screen: BuilderScreen;
|
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":
|
case "list":
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<ListScreenConfig
|
||||||
<div className="text-sm text-muted-foreground">
|
screen={screen as BuilderScreen & { template: "list" }}
|
||||||
List template configuration is available in the existing sidebar.
|
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
|
||||||
This is a legacy template that will be updated soon.
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
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 { DateScreenConfig } from "./DateScreenConfig";
|
||||||
export { CouponScreenConfig } from "./CouponScreenConfig";
|
export { CouponScreenConfig } from "./CouponScreenConfig";
|
||||||
export { FormScreenConfig } from "./FormScreenConfig";
|
export { FormScreenConfig } from "./FormScreenConfig";
|
||||||
export { TextScreenConfig } from "./TextScreenConfig";
|
export { ListScreenConfig } from "./ListScreenConfig";
|
||||||
export { TemplateConfig } from "./TemplateConfig";
|
export { TemplateConfig } from "./TemplateConfig";
|
||||||
|
|||||||
@ -9,25 +9,48 @@ import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate";
|
|||||||
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
|
import { DateTemplate } from "@/components/funnel/templates/DateTemplate";
|
||||||
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
|
||||||
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
|
import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
|
||||||
import { TextTemplate } from "@/components/funnel/templates/TextTemplate";
|
|
||||||
import { resolveNextScreenId } from "@/lib/funnel/navigation";
|
import { resolveNextScreenId } from "@/lib/funnel/navigation";
|
||||||
import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider";
|
import { useFunnelRuntime } from "@/lib/funnel/FunnelProvider";
|
||||||
import type {
|
import type {
|
||||||
FunnelDefinition,
|
FunnelDefinition,
|
||||||
InfoScreenDefinition,
|
|
||||||
DateScreenDefinition,
|
|
||||||
CouponScreenDefinition,
|
|
||||||
FormScreenDefinition,
|
|
||||||
TextScreenDefinition,
|
|
||||||
ListScreenDefinition,
|
ListScreenDefinition,
|
||||||
|
DateScreenDefinition,
|
||||||
|
FormScreenDefinition,
|
||||||
|
CouponScreenDefinition,
|
||||||
|
InfoScreenDefinition,
|
||||||
ScreenDefinition,
|
ScreenDefinition,
|
||||||
|
FunnelAnswers,
|
||||||
} from "@/lib/funnel/types";
|
} 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 {
|
interface FunnelRuntimeProps {
|
||||||
funnel: FunnelDefinition;
|
funnel: FunnelDefinition;
|
||||||
initialScreenId: string;
|
initialScreenId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemplateComponentProps = {
|
type TemplateComponentProps = {
|
||||||
screen: ScreenDefinition;
|
screen: ScreenDefinition;
|
||||||
selectedOptionIds: string[];
|
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;
|
const couponScreen = screen as CouponScreenDefinition;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -94,10 +117,12 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
|
|||||||
onContinue={onContinue}
|
onContinue={onContinue}
|
||||||
canGoBack={canGoBack}
|
canGoBack={canGoBack}
|
||||||
onBack={onBack}
|
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;
|
const formScreen = screen as FormScreenDefinition;
|
||||||
|
|
||||||
// For form screens, we store form data as JSON string in the first element
|
// 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}
|
onContinue={onContinue}
|
||||||
canGoBack={canGoBack}
|
canGoBack={canGoBack}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
/>
|
screenProgress={screenProgress}
|
||||||
);
|
defaultTexts={defaultTexts}
|
||||||
},
|
|
||||||
text: ({ screen, onContinue, canGoBack, onBack }) => {
|
|
||||||
const textScreen = screen as TextScreenDefinition;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextTemplate
|
|
||||||
screen={textScreen}
|
|
||||||
onContinue={onContinue}
|
|
||||||
canGoBack={canGoBack}
|
|
||||||
onBack={onBack}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -145,16 +160,34 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
|
|||||||
onContinue,
|
onContinue,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
onBack,
|
onBack,
|
||||||
|
screenProgress,
|
||||||
|
defaultTexts,
|
||||||
}) => {
|
}) => {
|
||||||
const listScreen = screen as ListScreenDefinition;
|
const listScreen = screen as ListScreenDefinition;
|
||||||
const selectionType = listScreen.list.selectionType;
|
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 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;
|
const actionDisabled = hasActionButton && isSelectionEmpty;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -169,9 +202,9 @@ const TEMPLATE_REGISTRY: Record<ScreenDefinition["template"], TemplateRenderer>
|
|||||||
onClick: actionDisabled ? undefined : onContinue,
|
onClick: actionDisabled ? undefined : onContinue,
|
||||||
}
|
}
|
||||||
: undefined}
|
: undefined}
|
||||||
showGradient={showGradient}
|
|
||||||
canGoBack={canGoBack}
|
canGoBack={canGoBack}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
|
screenProgress={screenProgress}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -202,14 +235,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
|
|
||||||
const selectedOptionIds = answers[currentScreen.id] ?? [];
|
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(() => {
|
useEffect(() => {
|
||||||
registerScreen(currentScreen.id);
|
registerScreen(currentScreen.id);
|
||||||
}, [currentScreen.id, registerScreen]);
|
}, [currentScreen.id, registerScreen]);
|
||||||
@ -232,6 +257,13 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
return [...history, currentScreen.id];
|
return [...history, currentScreen.id];
|
||||||
}, [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) => {
|
const goToScreen = (screenId: string | undefined) => {
|
||||||
if (!screenId) {
|
if (!screenId) {
|
||||||
return;
|
return;
|
||||||
@ -250,8 +282,41 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
prevSelectedIds.length !== ids.length ||
|
prevSelectedIds.length !== ids.length ||
|
||||||
prevSelectedIds.some((value, index) => value !== ids[index]);
|
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 = {
|
const nextAnswers = {
|
||||||
@ -263,21 +328,15 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
|
|||||||
delete nextAnswers[currentScreen.id];
|
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
|
// Auto-advance for single selection without action button
|
||||||
if (currentScreen.template === "list") {
|
if (shouldAutoAdvance) {
|
||||||
const listScreen = currentScreen as ListScreenDefinition;
|
const nextScreenId = resolveNextScreenId(currentScreen, nextAnswers, funnel.screens);
|
||||||
const selectionType = listScreen.list.selectionType;
|
goToScreen(nextScreenId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3,17 +3,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
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 { Coupon } from "@/components/widgets/Coupon/Coupon";
|
||||||
import Typography from "@/components/ui/Typography/Typography";
|
import Typography from "@/components/ui/Typography/Typography";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildHeaderProgress,
|
buildLayoutQuestionProps,
|
||||||
buildTypographyProps,
|
buildTypographyProps,
|
||||||
shouldShowBackButton,
|
|
||||||
shouldShowHeader,
|
|
||||||
} from "@/lib/funnel/mappers";
|
} from "@/lib/funnel/mappers";
|
||||||
import type { CouponScreenDefinition } from "@/lib/funnel/types";
|
import type { CouponScreenDefinition } from "@/lib/funnel/types";
|
||||||
|
|
||||||
@ -22,6 +17,7 @@ interface CouponTemplateProps {
|
|||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
canGoBack: boolean;
|
canGoBack: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
screenProgress?: { current: number; total: number };
|
||||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,12 +26,11 @@ export function CouponTemplate({
|
|||||||
onContinue,
|
onContinue,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
onBack,
|
onBack,
|
||||||
|
screenProgress,
|
||||||
defaultTexts,
|
defaultTexts,
|
||||||
}: CouponTemplateProps) {
|
}: CouponTemplateProps) {
|
||||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||||
|
|
||||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
|
||||||
const showHeader = shouldShowHeader(screen.header);
|
|
||||||
|
|
||||||
const handleCopyPromoCode = (code: string) => {
|
const handleCopyPromoCode = (code: string) => {
|
||||||
// Copy to clipboard
|
// Copy to clipboard
|
||||||
@ -48,50 +43,19 @@ export function CouponTemplate({
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
|
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||||
? {
|
screen,
|
||||||
children: screen.bottomActionButton.text,
|
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
|
||||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "center" },
|
||||||
onClick: onContinue,
|
canGoBack,
|
||||||
}
|
onBack,
|
||||||
: {
|
actionButtonOptions: {
|
||||||
children: defaultTexts?.continueButton || "Continue",
|
defaultText: defaultTexts?.continueButton || "Continue",
|
||||||
onClick: onContinue,
|
disabled: false,
|
||||||
};
|
onClick: onContinue,
|
||||||
|
},
|
||||||
const bottomActionButtonProps: BottomActionButtonProps = {
|
screenProgress,
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build coupon props from screen definition
|
// Build coupon props from screen definition
|
||||||
const couponProps = {
|
const couponProps = {
|
||||||
|
|||||||
@ -4,16 +4,11 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
|
|
||||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
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 Typography from "@/components/ui/Typography/Typography";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildHeaderProgress,
|
buildLayoutQuestionProps,
|
||||||
buildTypographyProps,
|
buildTypographyProps,
|
||||||
shouldShowBackButton,
|
|
||||||
shouldShowHeader,
|
|
||||||
} from "@/lib/funnel/mappers";
|
} from "@/lib/funnel/mappers";
|
||||||
import type { DateScreenDefinition } from "@/lib/funnel/types";
|
import type { DateScreenDefinition } from "@/lib/funnel/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -84,8 +79,6 @@ export function DateTemplate({
|
|||||||
const dayOptions = useMemo(() => generateDayOptions(month, year), [month, year]);
|
const dayOptions = useMemo(() => generateDayOptions(month, year), [month, year]);
|
||||||
const yearOptions = useMemo(() => generateYearOptions(), []);
|
const yearOptions = useMemo(() => generateYearOptions(), []);
|
||||||
|
|
||||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
|
||||||
const showHeader = shouldShowHeader(screen.header);
|
|
||||||
|
|
||||||
// Custom Select component matching TextInput styling
|
// Custom Select component matching TextInput styling
|
||||||
const SelectInput = ({
|
const SelectInput = ({
|
||||||
@ -174,56 +167,19 @@ export function DateTemplate({
|
|||||||
return null;
|
return null;
|
||||||
}, [month, day, year]);
|
}, [month, day, year]);
|
||||||
|
|
||||||
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
|
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||||
? {
|
screen,
|
||||||
children: screen.bottomActionButton.text,
|
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
|
||||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||||
onClick: onContinue,
|
canGoBack,
|
||||||
disabled: !isComplete,
|
onBack,
|
||||||
}
|
actionButtonOptions: {
|
||||||
: {
|
defaultText: defaultTexts?.nextButton || "Next",
|
||||||
children: defaultTexts?.nextButton || "Next",
|
disabled: !isComplete,
|
||||||
onClick: onContinue,
|
onClick: onContinue,
|
||||||
disabled: !isComplete,
|
},
|
||||||
};
|
screenProgress,
|
||||||
|
});
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutQuestion {...layoutQuestionProps}>
|
<LayoutQuestion {...layoutQuestionProps}>
|
||||||
|
|||||||
@ -3,15 +3,10 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
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 { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildHeaderProgress,
|
buildLayoutQuestionProps,
|
||||||
buildTypographyProps,
|
|
||||||
shouldShowBackButton,
|
|
||||||
} from "@/lib/funnel/mappers";
|
} from "@/lib/funnel/mappers";
|
||||||
import type { FormScreenDefinition } from "@/lib/funnel/types";
|
import type { FormScreenDefinition } from "@/lib/funnel/types";
|
||||||
|
|
||||||
@ -22,6 +17,7 @@ interface FormTemplateProps {
|
|||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
canGoBack: boolean;
|
canGoBack: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
screenProgress?: { current: number; total: number };
|
||||||
defaultTexts?: { nextButton?: string; continueButton?: string };
|
defaultTexts?: { nextButton?: string; continueButton?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,56 +28,48 @@ export function FormTemplate({
|
|||||||
onContinue,
|
onContinue,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
onBack,
|
onBack,
|
||||||
|
screenProgress,
|
||||||
defaultTexts,
|
defaultTexts,
|
||||||
}: FormTemplateProps) {
|
}: FormTemplateProps) {
|
||||||
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
|
const [localFormData, setLocalFormData] = useState<Record<string, string>>(formData);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
|
||||||
|
|
||||||
// Sync with external form data
|
// Sync with external form data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalFormData(formData);
|
setLocalFormData(formData);
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
// Update parent when local data changes
|
// Update external form data when local data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFormDataChange(localFormData);
|
onFormDataChange(localFormData);
|
||||||
}, [localFormData, onFormDataChange]);
|
}, [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);
|
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()) {
|
if (field.required && !value.trim()) {
|
||||||
const template = messages?.required || "${field} is required";
|
return screen.validationMessages?.required?.replace('${field}', field.label || field.id) || `${field.label || field.id} is required`;
|
||||||
return template.replace("${field}", field.label || field.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check max length
|
|
||||||
if (field.maxLength && value.length > field.maxLength) {
|
if (field.maxLength && value.length > field.maxLength) {
|
||||||
const template = messages?.maxLength || "Maximum ${maxLength} characters allowed";
|
return screen.validationMessages?.maxLength?.replace('${maxLength}', String(field.maxLength)) || `Maximum ${field.maxLength} characters allowed`;
|
||||||
return template.replace("${maxLength}", field.maxLength.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check validation pattern
|
if (field.validation?.pattern) {
|
||||||
if (field.validation?.pattern && value.trim()) {
|
|
||||||
const regex = new RegExp(field.validation.pattern);
|
const regex = new RegExp(field.validation.pattern);
|
||||||
if (!regex.test(value)) {
|
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) => {
|
const handleFieldChange = (fieldId: string, value: string) => {
|
||||||
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
|
setLocalFormData(prev => ({ ...prev, [fieldId]: value }));
|
||||||
|
|
||||||
// Clear error when user starts typing
|
// Clear error if field becomes valid
|
||||||
if (errors[fieldId]) {
|
if (errors[fieldId]) {
|
||||||
setErrors(prev => {
|
setErrors(prev => {
|
||||||
const newErrors = { ...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 => {
|
const isFormComplete = screen.fields.every(field => {
|
||||||
if (!field.required) return true;
|
|
||||||
const value = localFormData[field.id] || "";
|
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
|
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||||
? {
|
screen,
|
||||||
children: screen.bottomActionButton.text,
|
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
|
||||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||||
onClick: handleContinue,
|
canGoBack,
|
||||||
disabled: !isFormComplete,
|
onBack,
|
||||||
}
|
actionButtonOptions: {
|
||||||
: {
|
defaultText: defaultTexts?.continueButton || "Continue",
|
||||||
children: defaultTexts?.continueButton || "Continue",
|
disabled: !isFormComplete,
|
||||||
onClick: handleContinue,
|
onClick: handleContinue,
|
||||||
disabled: !isFormComplete,
|
|
||||||
};
|
|
||||||
|
|
||||||
const bottomActionButtonProps: BottomActionButtonProps = {
|
|
||||||
actionButtonProps,
|
|
||||||
};
|
|
||||||
|
|
||||||
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
|
|
||||||
headerProps: {
|
|
||||||
progressProps: buildHeaderProgress(screen.header?.progress),
|
|
||||||
onBack: showBackButton ? onBack : undefined,
|
|
||||||
showBackButton,
|
|
||||||
},
|
},
|
||||||
title:
|
screenProgress,
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutQuestion {...layoutQuestionProps}>
|
<LayoutQuestion {...layoutQuestionProps}>
|
||||||
|
|||||||
@ -4,16 +4,11 @@ import { useMemo } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
import { LayoutQuestion } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
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 Typography from "@/components/ui/Typography/Typography";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildHeaderProgress,
|
buildLayoutQuestionProps,
|
||||||
buildTypographyProps,
|
buildTypographyProps,
|
||||||
shouldShowBackButton,
|
|
||||||
shouldShowHeader,
|
|
||||||
} from "@/lib/funnel/mappers";
|
} from "@/lib/funnel/mappers";
|
||||||
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
import type { InfoScreenDefinition } from "@/lib/funnel/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -35,48 +30,18 @@ export function InfoTemplate({
|
|||||||
screenProgress,
|
screenProgress,
|
||||||
defaultTexts,
|
defaultTexts,
|
||||||
}: InfoTemplateProps) {
|
}: InfoTemplateProps) {
|
||||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||||
const showHeader = shouldShowHeader(screen.header);
|
screen,
|
||||||
|
titleDefaults: { font: "manrope", weight: "bold", align: "center" },
|
||||||
const actionButtonProps: ActionButtonProps | undefined = screen.bottomActionButton
|
canGoBack,
|
||||||
? {
|
onBack,
|
||||||
children: screen.bottomActionButton.text,
|
actionButtonOptions: {
|
||||||
cornerRadius: screen.bottomActionButton.cornerRadius,
|
defaultText: defaultTexts?.nextButton || "Next",
|
||||||
onClick: onContinue,
|
disabled: false,
|
||||||
}
|
onClick: onContinue,
|
||||||
: {
|
},
|
||||||
children: defaultTexts?.nextButton || "Next",
|
screenProgress,
|
||||||
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 iconSizeClasses = useMemo(() => {
|
const iconSizeClasses = useMemo(() => {
|
||||||
const size = screen.icon?.size ?? "xl";
|
const size = screen.icon?.size ?? "xl";
|
||||||
|
|||||||
@ -3,18 +3,14 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { Question } from "@/components/templates/Question/Question";
|
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 { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||||
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
|
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
|
||||||
import type { RadioAnswersListProps } from "@/components/widgets/RadioAnswersList/RadioAnswersList";
|
import type { RadioAnswersListProps } from "@/components/widgets/RadioAnswersList/RadioAnswersList";
|
||||||
import type { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
|
import type { SelectAnswersListProps } from "@/components/widgets/SelectAnswersList/SelectAnswersList";
|
||||||
import type { BottomActionButtonProps } from "@/components/widgets/BottomActionButton/BottomActionButton";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildHeaderProgress,
|
buildLayoutQuestionProps,
|
||||||
buildTypographyProps,
|
|
||||||
mapListOptionsToButtons,
|
mapListOptionsToButtons,
|
||||||
shouldShowBackButton,
|
|
||||||
} from "@/lib/funnel/mappers";
|
} from "@/lib/funnel/mappers";
|
||||||
import type { ListScreenDefinition } from "@/lib/funnel/types";
|
import type { ListScreenDefinition } from "@/lib/funnel/types";
|
||||||
|
|
||||||
@ -23,9 +19,9 @@ interface ListTemplateProps {
|
|||||||
selectedOptionIds: string[];
|
selectedOptionIds: string[];
|
||||||
onSelectionChange: (selectedIds: string[]) => void;
|
onSelectionChange: (selectedIds: string[]) => void;
|
||||||
actionButtonProps?: ActionButtonProps;
|
actionButtonProps?: ActionButtonProps;
|
||||||
showGradient: boolean;
|
|
||||||
canGoBack: boolean;
|
canGoBack: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
screenProgress?: { current: number; total: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringId(value: MainButtonProps["id"]): string | null {
|
function stringId(value: MainButtonProps["id"]): string | null {
|
||||||
@ -40,9 +36,9 @@ export function ListTemplate({
|
|||||||
selectedOptionIds,
|
selectedOptionIds,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
actionButtonProps,
|
actionButtonProps,
|
||||||
showGradient,
|
|
||||||
canGoBack,
|
canGoBack,
|
||||||
onBack,
|
onBack,
|
||||||
|
screenProgress,
|
||||||
}: ListTemplateProps) {
|
}: ListTemplateProps) {
|
||||||
const buttons = useMemo(
|
const buttons = useMemo(
|
||||||
() => mapListOptionsToButtons(screen.list.options, screen.list.selectionType),
|
() => mapListOptionsToButtons(screen.list.options, screen.list.selectionType),
|
||||||
@ -98,43 +94,27 @@ export function ListTemplate({
|
|||||||
onChangeSelectedAnswers: handleSelectChange,
|
onChangeSelectedAnswers: handleSelectChange,
|
||||||
};
|
};
|
||||||
|
|
||||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
// Определяем action button options для centralized логики только если кнопка нужна
|
||||||
|
const actionButtonOptions = actionButtonProps ? {
|
||||||
const effectiveBottomActionButtonProps: BottomActionButtonProps | undefined = showGradient
|
defaultText: actionButtonProps.children as string || "Next",
|
||||||
? actionButtonProps
|
disabled: actionButtonProps.disabled || false,
|
||||||
? { actionButtonProps }
|
onClick: () => {
|
||||||
: {}
|
if (actionButtonProps.onClick) {
|
||||||
: undefined;
|
actionButtonProps.onClick({} as React.MouseEvent<HTMLButtonElement>);
|
||||||
|
}
|
||||||
const layoutQuestionProps: Omit<LayoutQuestionProps, "children"> = {
|
|
||||||
headerProps: {
|
|
||||||
progressProps: buildHeaderProgress(screen.header?.progress),
|
|
||||||
onBack: showBackButton ? onBack : undefined,
|
|
||||||
showBackButton,
|
|
||||||
},
|
},
|
||||||
title:
|
} : undefined;
|
||||||
buildTypographyProps(screen.title, {
|
|
||||||
as: "h2",
|
|
||||||
defaults: {
|
const layoutQuestionProps = buildLayoutQuestionProps({
|
||||||
font: "manrope",
|
screen,
|
||||||
weight: "bold",
|
titleDefaults: { font: "manrope", weight: "bold", align: "left" },
|
||||||
align: "left",
|
subtitleDefaults: { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||||
},
|
canGoBack,
|
||||||
}) ?? {
|
onBack,
|
||||||
as: "h2",
|
actionButtonOptions: actionButtonOptions,
|
||||||
children: screen.title.text,
|
screenProgress,
|
||||||
},
|
});
|
||||||
subtitle: buildTypographyProps(screen.subtitle, {
|
|
||||||
as: "p",
|
|
||||||
defaults: {
|
|
||||||
font: "inter",
|
|
||||||
weight: "medium",
|
|
||||||
color: "muted",
|
|
||||||
align: "left",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
bottomActionButtonProps: effectiveBottomActionButtonProps,
|
|
||||||
};
|
|
||||||
|
|
||||||
const contentProps =
|
const contentProps =
|
||||||
contentType === "radio-answers-list" ? radioContent : selectContent;
|
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";
|
const shouldRenderBackButton = showBackButton && typeof onBack === "function";
|
||||||
|
|
||||||
return (
|
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">
|
<div className="w-full flex justify-left items-center min-h-9">
|
||||||
{shouldRenderBackButton && (
|
{shouldRenderBackButton && (
|
||||||
<Button
|
<Button
|
||||||
@ -33,9 +33,11 @@ function Header({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full flex justify-center items-center">
|
{progressProps && (
|
||||||
<Progress {...progressProps} />
|
<div className="w-full flex justify-center items-center mt-3">
|
||||||
</div>
|
<Progress {...progressProps} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ function LayoutQuestion({
|
|||||||
...props.style,
|
...props.style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Header {...headerProps} />
|
{headerProps && <Header {...headerProps} />}
|
||||||
<div className="w-full flex flex-col justify-center items-center p-6 pt-[30px]">
|
<div className="w-full flex flex-col justify-center items-center p-6 pt-[30px]">
|
||||||
{title && (
|
{title && (
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
MainButton,
|
MainButton,
|
||||||
MainButtonProps,
|
MainButtonProps,
|
||||||
} from "@/components/ui/MainButton/MainButton";
|
} from "@/components/ui/MainButton/MainButton";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
export interface RadioAnswersListProps extends React.ComponentProps<"div"> {
|
export interface RadioAnswersListProps extends React.ComponentProps<"div"> {
|
||||||
answers: MainButtonProps[];
|
answers: MainButtonProps[];
|
||||||
@ -25,6 +25,7 @@ function RadioAnswersList({
|
|||||||
const [selectedAnswer, setSelectedAnswer] = useState<MainButtonProps | null>(
|
const [selectedAnswer, setSelectedAnswer] = useState<MainButtonProps | null>(
|
||||||
activeAnswer
|
activeAnswer
|
||||||
);
|
);
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedAnswer(activeAnswer ?? null);
|
setSelectedAnswer(activeAnswer ?? null);
|
||||||
@ -36,6 +37,12 @@ function RadioAnswersList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// НЕ вызываем callback при первоначальной загрузке компонента
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onChangeSelectedAnswer?.(selectedAnswer);
|
onChangeSelectedAnswer?.(selectedAnswer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedAnswer]);
|
}, [selectedAnswer]);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
MainButton,
|
MainButton,
|
||||||
MainButtonProps,
|
MainButtonProps,
|
||||||
} from "@/components/ui/MainButton/MainButton";
|
} from "@/components/ui/MainButton/MainButton";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
|
||||||
export interface SelectAnswersListProps extends React.ComponentProps<"div"> {
|
export interface SelectAnswersListProps extends React.ComponentProps<"div"> {
|
||||||
answers: MainButtonProps[];
|
answers: MainButtonProps[];
|
||||||
@ -25,6 +25,7 @@ function SelectAnswersList({
|
|||||||
const [selectedAnswers, setSelectedAnswers] = useState<
|
const [selectedAnswers, setSelectedAnswers] = useState<
|
||||||
MainButtonProps[] | null
|
MainButtonProps[] | null
|
||||||
>(activeAnswers);
|
>(activeAnswers);
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedAnswers(activeAnswers ?? null);
|
setSelectedAnswers(activeAnswers ?? null);
|
||||||
@ -42,6 +43,12 @@ function SelectAnswersList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// НЕ вызываем callback при первоначальной загрузке компонента
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onChangeSelectedAnswers?.(selectedAnswers);
|
onChangeSelectedAnswers?.(selectedAnswers);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedAnswers]);
|
}, [selectedAnswers]);
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import type {
|
|||||||
TypographyVariant,
|
TypographyVariant,
|
||||||
BottomActionButtonDefinition,
|
BottomActionButtonDefinition,
|
||||||
ScreenDefinition,
|
ScreenDefinition,
|
||||||
ColorPalette,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
import type { LayoutQuestionProps } from "@/components/layout/LayoutQuestion/LayoutQuestion";
|
||||||
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
import type { ActionButtonProps } from "@/components/ui/ActionButton/ActionButton";
|
||||||
@ -137,10 +136,14 @@ export function buildBottomActionButtonProps(
|
|||||||
options: BuildActionButtonOptions,
|
options: BuildActionButtonOptions,
|
||||||
buttonDef?: BottomActionButtonDefinition
|
buttonDef?: BottomActionButtonDefinition
|
||||||
): BottomActionButtonProps | undefined {
|
): BottomActionButtonProps | undefined {
|
||||||
if (buttonDef?.show === false) {
|
// Если кнопка отключена и градиент явно отключен
|
||||||
|
if (buttonDef?.show === false && buttonDef?.showGradientBlur === false) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ВАЖНО: Если мы сюда дошли, значит логика FunnelRuntime уже решила
|
||||||
|
// что кнопка должна показываться (даже при show: false для multi selection)
|
||||||
|
// Поэтому всегда создаем actionButtonProps
|
||||||
const actionButtonProps = buildActionButtonProps(options, buttonDef);
|
const actionButtonProps = buildActionButtonProps(options, buttonDef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -154,7 +157,8 @@ interface BuildLayoutQuestionOptions {
|
|||||||
subtitleDefaults?: TypographyDefaults;
|
subtitleDefaults?: TypographyDefaults;
|
||||||
canGoBack: boolean;
|
canGoBack: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
actionButtonOptions: BuildActionButtonOptions;
|
actionButtonOptions?: BuildActionButtonOptions;
|
||||||
|
screenProgress?: { current: number; total: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLayoutQuestionProps(
|
export function buildLayoutQuestionProps(
|
||||||
@ -166,15 +170,26 @@ export function buildLayoutQuestionProps(
|
|||||||
subtitleDefaults = { font: "inter", weight: "medium", color: "muted", align: "left" },
|
subtitleDefaults = { font: "inter", weight: "medium", color: "muted", align: "left" },
|
||||||
canGoBack,
|
canGoBack,
|
||||||
onBack,
|
onBack,
|
||||||
actionButtonOptions
|
actionButtonOptions,
|
||||||
|
screenProgress
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
const showBackButton = shouldShowBackButton(screen.header, canGoBack);
|
||||||
const showHeader = shouldShowHeader(screen.header);
|
const showHeader = shouldShowHeader(screen.header);
|
||||||
|
|
||||||
|
const bottomActionButtonProps = actionButtonOptions ? buildBottomActionButtonProps(
|
||||||
|
actionButtonOptions,
|
||||||
|
'bottomActionButton' in screen ? screen.bottomActionButton : undefined
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headerProps: showHeader ? {
|
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,
|
onBack: showBackButton ? onBack : undefined,
|
||||||
showBackButton,
|
showBackButton,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
@ -189,117 +204,7 @@ export function buildLayoutQuestionProps(
|
|||||||
as: "p",
|
as: "p",
|
||||||
defaults: subtitleDefaults,
|
defaults: subtitleDefaults,
|
||||||
}) : undefined,
|
}) : undefined,
|
||||||
bottomActionButtonProps: buildBottomActionButtonProps(
|
bottomActionButtonProps,
|
||||||
actionButtonOptions,
|
|
||||||
'bottomActionButton' in screen ? screen.bottomActionButton : undefined
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"
|
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 {
|
export interface NavigationConditionDefinition {
|
||||||
screenId: string;
|
screenId: string;
|
||||||
@ -148,7 +99,6 @@ export interface InfoScreenDefinition {
|
|||||||
};
|
};
|
||||||
bottomActionButton?: BottomActionButtonDefinition;
|
bottomActionButton?: BottomActionButtonDefinition;
|
||||||
navigation?: NavigationDefinition;
|
navigation?: NavigationDefinition;
|
||||||
colorOverrides?: Partial<ColorPalette>; // Override colors for this screen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateInputDefinition {
|
export interface DateInputDefinition {
|
||||||
@ -176,7 +126,6 @@ export interface DateScreenDefinition {
|
|||||||
};
|
};
|
||||||
bottomActionButton?: BottomActionButtonDefinition;
|
bottomActionButton?: BottomActionButtonDefinition;
|
||||||
navigation?: NavigationDefinition;
|
navigation?: NavigationDefinition;
|
||||||
colorOverrides?: Partial<ColorPalette>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CouponDefinition {
|
export interface CouponDefinition {
|
||||||
@ -199,7 +148,6 @@ export interface CouponScreenDefinition {
|
|||||||
copiedMessage?: string; // "Промокод скопирован!" text
|
copiedMessage?: string; // "Промокод скопирован!" text
|
||||||
bottomActionButton?: BottomActionButtonDefinition;
|
bottomActionButton?: BottomActionButtonDefinition;
|
||||||
navigation?: NavigationDefinition;
|
navigation?: NavigationDefinition;
|
||||||
colorOverrides?: Partial<ColorPalette>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormFieldDefinition {
|
export interface FormFieldDefinition {
|
||||||
@ -231,19 +179,8 @@ export interface FormScreenDefinition {
|
|||||||
validationMessages?: FormValidationMessages;
|
validationMessages?: FormValidationMessages;
|
||||||
bottomActionButton?: BottomActionButtonDefinition;
|
bottomActionButton?: BottomActionButtonDefinition;
|
||||||
navigation?: NavigationDefinition;
|
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 {
|
export interface ListScreenDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
@ -257,11 +194,11 @@ export interface ListScreenDefinition {
|
|||||||
options: ListOptionDefinition[];
|
options: ListOptionDefinition[];
|
||||||
bottomActionButton?: BottomActionButtonDefinition;
|
bottomActionButton?: BottomActionButtonDefinition;
|
||||||
};
|
};
|
||||||
|
bottomActionButton?: BottomActionButtonDefinition;
|
||||||
navigation?: NavigationDefinition;
|
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 {
|
export interface FunnelMetaDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
@ -274,7 +211,6 @@ export interface FunnelMetaDefinition {
|
|||||||
export interface FunnelDefinition {
|
export interface FunnelDefinition {
|
||||||
meta: FunnelMetaDefinition;
|
meta: FunnelMetaDefinition;
|
||||||
defaultTexts?: DefaultTexts;
|
defaultTexts?: DefaultTexts;
|
||||||
colorPalette?: ColorPalette;
|
|
||||||
screens: ScreenDefinition[];
|
screens: ScreenDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user