This commit is contained in:
dev.daminik00 2025-09-25 23:47:49 +02:00
parent 84fb57ab60
commit d5bcfb0330
16 changed files with 270 additions and 319 deletions

View File

@ -5,80 +5,28 @@ import { useCallback, useState } from "react";
import { BuilderLayout } from "@/components/admin/builder/BuilderLayout";
import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar";
import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas";
import { BuilderPreview } from "@/components/admin/builder/BuilderPreview";
import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar";
import {
BuilderProvider,
useBuilderDispatch,
useBuilderState,
} from "@/lib/admin/builder/context";
import {
serializeBuilderState,
deserializeFunnelDefinition,
} from "@/lib/admin/builder/utils";
function ExportModal({ json, onClose }: { json: string; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-2xl rounded-2xl bg-background p-6 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Экспорт JSON</h2>
<button
type="button"
className="text-sm text-muted-foreground"
onClick={onClose}
>
Закрыть
</button>
</div>
<p className="mt-2 text-sm text-muted-foreground">
Скопируйте JSON и используйте в `public/funnels/*.json`.
</p>
<textarea
className="mt-4 h-72 w-full resize-none rounded-xl border border-border bg-muted/30 p-4 font-mono text-xs"
readOnly
value={json}
/>
</div>
</div>
);
}
function BuilderView() {
const dispatch = useBuilderDispatch();
const state = useBuilderState();
const [exportJson, setExportJson] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState<boolean>(true);
const handleNew = useCallback(() => {
dispatch({ type: "reset" });
}, [dispatch]);
const handleExport = useCallback(() => {
const json = JSON.stringify(serializeBuilderState(state), null, 2);
setExportJson(json);
}, [state]);
const handleLoad = useCallback(
(json: string) => {
try {
const parsed = JSON.parse(json);
const builderState = deserializeFunnelDefinition(parsed);
dispatch({ type: "reset", payload: builderState });
} catch (err) {
setError(err instanceof Error ? err.message : "Некорректный JSON файл");
}
},
[dispatch]
);
const handleLoadError = useCallback((message: string) => {
setError(message);
const handleTogglePreview = useCallback(() => {
setShowPreview((prev: boolean) => !prev);
}, []);
const handleTogglePreview = useCallback(() => {
setShowPreview(prev => !prev);
const handleLoadError = useCallback((message: string) => {
console.error("Load error:", message);
}, []);
return (
@ -93,26 +41,18 @@ function BuilderView() {
}
sidebar={<BuilderSidebar />}
canvas={<BuilderCanvas />}
preview={<BuilderPreview />}
showPreview={showPreview}
onTogglePreview={handleTogglePreview}
/>
{exportJson && (
<ExportModal
json={exportJson}
onClose={() => setExportJson(null)}
/>
)}
{error && (
<div className="fixed bottom-6 right-6 z-50 max-w-sm rounded-xl border border-destructive bg-destructive/10 p-4 shadow-lg">
<div className="fixed bottom-6 right-6 z-50 max-w-sm rounded-xl border border-border bg-background p-4 shadow-lg">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-destructive">{error}</p>
<span className="text-sm">Export JSON готов</span>
<button
type="button"
className="text-xs text-destructive underline"
onClick={() => setError(null)}
className="text-xs text-muted-foreground underline"
onClick={() => setExportJson(null)}
>
Закрыть
</button>

View File

@ -1,10 +1,10 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import React, { useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { ListScreenDefinition, ScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
const CARD_WIDTH = 280;
@ -37,7 +37,7 @@ export function BuilderCanvas() {
e.preventDefault();
if (!dragStateRef.current) return;
const { screenId, dragStartIndex, currentIndex } = dragStateRef.current;
const { dragStartIndex, currentIndex } = dragStateRef.current;
if (dragStartIndex !== currentIndex) {
dispatch({
@ -63,39 +63,16 @@ export function BuilderCanvas() {
dispatch({ type: "add-screen" });
}, [dispatch]);
const renderArrows = () => {
const arrows: JSX.Element[] = [];
screens.forEach((screen, index) => {
const nextIndex = index + 1;
if (nextIndex < screens.length) {
const startX = (index + 1) * (CARD_WIDTH + CARD_GAP) - CARD_GAP / 2;
const endX = startX + CARD_GAP;
const y = CARD_HEIGHT / 2;
arrows.push(
<div
key={`arrow-${index}`}
className="absolute flex items-center justify-center z-10"
style={{
left: startX,
top: y - 12,
width: CARD_GAP,
height: 24,
}}
>
<div className="flex items-center w-full">
<div className="flex-1 border-t-2 border-primary/60 border-dashed"></div>
<div className="w-0 h-0 border-l-[6px] border-l-primary border-t-[4px] border-t-transparent border-b-[4px] border-b-transparent ml-1"></div>
</div>
</div>
);
}
});
return arrows;
// Helper functions for type checking
const hasSubtitle = (screen: ScreenDefinition): screen is ScreenDefinition & { subtitle: { text: string } } => {
return 'subtitle' in screen && screen.subtitle !== undefined;
};
const isListScreen = (screen: ScreenDefinition): screen is ListScreenDefinition => {
return screen.template === 'list';
};
return (
<div ref={containerRef} className="h-full w-full overflow-auto bg-slate-50 dark:bg-slate-900">
{/* Header with Add Button */}
@ -153,30 +130,30 @@ export function BuilderCanvas() {
<h3 className="text-base font-semibold leading-5 text-foreground mb-2">
{screen.title.text || "Без названия"}
</h3>
{(screen as any).subtitle?.text && (
<p className="text-xs text-muted-foreground mb-3">{(screen as any).subtitle.text}</p>
{hasSubtitle(screen) && (
<p className="text-xs text-muted-foreground mb-3">{screen.subtitle.text}</p>
)}
{/* List Screen Details */}
{(screen as any).list && (
{isListScreen(screen) && (
<div className="text-xs text-muted-foreground space-y-1">
<div className="flex items-center justify-between">
<span>Тип выбора:</span>
<span className="font-medium text-foreground">
{(screen as any).list.selectionType === "single" ? "Single" : "Multi"}
{screen.list.selectionType === "single" ? "Single" : "Multi"}
</span>
</div>
<div>
<span className="font-medium text-foreground">Опции: {(screen as any).list.options.length}</span>
<span className="font-medium text-foreground">Опции: {screen.list.options.length}</span>
<div className="mt-1 flex flex-wrap gap-1">
{(screen as any).list.options.slice(0, 2).map((option: any) => (
{screen.list.options.slice(0, 2).map((option) => (
<span key={option.id} className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[10px]">
{option.label}
</span>
))}
{(screen as any).list.options.length > 2 && (
{screen.list.options.length > 2 && (
<span className="text-muted-foreground text-[10px]">
+{(screen as any).list.options.length - 2} ещё
+{screen.list.options.length - 2} ещё
</span>
)}
</div>

View File

@ -9,18 +9,17 @@ import { FormTemplate } from "@/components/funnel/templates/FormTemplate";
import { TextTemplate } from "@/components/funnel/templates/TextTemplate";
import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate";
import { useBuilderSelectedScreen } from "@/lib/admin/builder/context";
import type { ListScreenDefinition, InfoScreenDefinition, DateScreenDefinition, FormScreenDefinition, TextScreenDefinition, CouponScreenDefinition } from "@/lib/funnel/types";
export function BuilderPreview() {
const selectedScreen = useBuilderSelectedScreen();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [formData, setFormData] = useState<Record<string, string>>({});
const [dateData, setDateData] = useState<[number, number, number]>([0, 0, 0]);
useEffect(() => {
if (!selectedScreen) {
setSelectedIds([]);
setFormData({});
setDateData([0, 0, 0]);
return;
}
@ -45,9 +44,6 @@ export function BuilderPreview() {
setFormData(data);
}, []);
const handleDateChange = useCallback((data: [number, number, number]) => {
setDateData(data);
}, []);
const renderScreenPreview = useCallback(() => {
if (!selectedScreen) return null;
@ -64,7 +60,7 @@ export function BuilderPreview() {
return (
<ListTemplate
{...commonProps}
screen={selectedScreen as any}
screen={selectedScreen as ListScreenDefinition}
selectedOptionIds={selectedIds}
onSelectionChange={handleSelectionChange}
/>
@ -74,7 +70,7 @@ export function BuilderPreview() {
return (
<InfoTemplate
{...commonProps}
screen={selectedScreen as any}
screen={selectedScreen as InfoScreenDefinition}
/>
);
@ -82,7 +78,7 @@ export function BuilderPreview() {
return (
<DateTemplate
{...commonProps}
screen={selectedScreen as any}
screen={selectedScreen as DateScreenDefinition}
selectedDate={{ month: "", day: "", year: "" }}
onDateChange={() => {}}
/>
@ -92,7 +88,7 @@ export function BuilderPreview() {
return (
<FormTemplate
{...commonProps}
screen={selectedScreen as any}
screen={selectedScreen as FormScreenDefinition}
formData={formData}
onFormDataChange={handleFormChange}
/>
@ -102,7 +98,7 @@ export function BuilderPreview() {
return (
<TextTemplate
{...commonProps}
screen={selectedScreen as any}
screen={selectedScreen as TextScreenDefinition}
/>
);
@ -110,14 +106,14 @@ export function BuilderPreview() {
return (
<CouponTemplate
{...commonProps}
screen={selectedScreen as any}
screen={selectedScreen as CouponScreenDefinition}
/>
);
default:
return (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border text-sm text-muted-foreground">
Предпросмотр для типа &ldquo;{(selectedScreen as any).template}&rdquo; не поддерживается.
Предпросмотр для данного типа экрана не поддерживается.
</div>
);
}

View File

@ -10,6 +10,15 @@ import type { NavigationRuleDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
import { validateBuilderState } from "@/lib/admin/builder/validation";
// Type guards для безопасной работы с разными типами экранов
function isListScreen(screen: BuilderScreen): screen is BuilderScreen & { list: { selectionType: "single" | "multi"; options: Array<{ id: string; label: string; description?: string; emoji?: string; }> } } {
return screen.template === "list" && "list" in screen;
}
function hasSubtitle(screen: BuilderScreen): screen is BuilderScreen & { subtitle?: { text: string; color?: string; font?: string; } } {
return "subtitle" in screen;
}
function Section({
title,
description,
@ -30,9 +39,6 @@ function Section({
);
}
function Divider() {
return <div className="h-px w-full bg-border/80" />;
}
function ValidationSummary() {
const state = useBuilderState();
@ -92,9 +98,13 @@ export function BuilderSidebar() {
const updateList = (
screen: BuilderScreen,
listUpdates: Partial<BuilderScreen["list"]>
listUpdates: Partial<{ selectionType: "single" | "multi"; options: Array<{ id: string; label: string; description?: string; emoji?: string; }> }>
) => {
const nextList: BuilderScreen["list"] = {
if (!isListScreen(screen)) {
return;
}
const nextList = {
...screen.list,
...listUpdates,
selectionType: listUpdates.selectionType ?? screen.list.selectionType,
@ -131,10 +141,10 @@ export function BuilderSidebar() {
const handleSelectionTypeChange = (
screenId: string,
selectionType: BuilderScreen["list"]["selectionType"]
selectionType: "single" | "multi"
) => {
const screen = getScreenById(screenId);
if (!screen) {
if (!screen || !isListScreen(screen)) {
return;
}
@ -176,7 +186,7 @@ export function BuilderSidebar() {
value: string
) => {
const screen = getScreenById(screenId);
if (!screen) {
if (!screen || !isListScreen(screen)) {
return;
}
@ -188,6 +198,10 @@ export function BuilderSidebar() {
};
const handleAddOption = (screen: BuilderScreen) => {
if (!isListScreen(screen)) {
return;
}
const nextIndex = screen.list.options.length + 1;
const options = [
...screen.list.options,
@ -201,6 +215,10 @@ export function BuilderSidebar() {
};
const handleRemoveOption = (screen: BuilderScreen, index: number) => {
if (!isListScreen(screen)) {
return;
}
const options = screen.list.options.filter((_, optionIndex) => optionIndex !== index);
updateList(screen, { options });
};
@ -304,6 +322,10 @@ export function BuilderSidebar() {
};
const handleAddRule = (screen: BuilderScreen) => {
if (!isListScreen(screen)) {
return;
}
const defaultCondition: NavigationRuleDefinition["conditions"][number] = {
screenId: screen.id,
operator: "includesAny",
@ -389,7 +411,7 @@ export function BuilderSidebar() {
}
// Показываем настройки выбранного экрана
const isListScreen = selectedScreen.template === "list" && "list" in selectedScreen;
const selectedScreenIsListType = isListScreen(selectedScreen);
return (
<div className="p-6">
@ -425,7 +447,7 @@ export function BuilderSidebar() {
/>
<TextInput
label="Подзаголовок"
value={selectedScreen.subtitle?.text ?? ""}
value={hasSubtitle(selectedScreen) ? selectedScreen.subtitle?.text ?? "" : ""}
onChange={(event) => handleSubtitleChange(selectedScreen.id, event.target.value)}
/>
<div className="rounded-lg border border-border/60 bg-muted/30 p-3">
@ -439,7 +461,7 @@ export function BuilderSidebar() {
</div>
</Section>
{isListScreen && (
{selectedScreenIsListType && (
<Section title="Варианты ответа" description="Настройки опций">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
@ -447,11 +469,11 @@ export function BuilderSidebar() {
<span className="text-sm font-medium text-muted-foreground">Тип выбора</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={(selectedScreen as any).list.selectionType}
value={selectedScreenIsListType ? selectedScreen.list.selectionType : "single"}
onChange={(event) =>
handleSelectionTypeChange(
selectedScreen.id,
event.target.value as any
event.target.value as "single" | "multi"
)
}
>
@ -461,13 +483,13 @@ export function BuilderSidebar() {
</label>
<Button
className="h-8 px-3 text-xs"
onClick={() => handleAddOption(selectedScreen as any)}
onClick={() => handleAddOption(selectedScreen)}
>
Добавить
</Button>
</div>
<div className="flex flex-col gap-4">
{(selectedScreen as any).list.options.map((option: any, index: number) => (
{selectedScreenIsListType && selectedScreen.list.options.map((option, index) => (
<div
key={option.id}
className={cn(
@ -477,11 +499,11 @@ export function BuilderSidebar() {
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">Опция {index + 1}</span>
{(selectedScreen as any).list.options.length > 1 && (
{selectedScreenIsListType && selectedScreen.list.options.length > 1 && (
<Button
variant="ghost"
className="text-destructive"
onClick={() => handleRemoveOption(selectedScreen as any, index)}
onClick={() => handleRemoveOption(selectedScreen, index)}
>
Удалить
</Button>
@ -536,14 +558,14 @@ export function BuilderSidebar() {
</div>
</Section>
{isListScreen && (
{selectedScreenIsListType && (
<Section title="Правила переходов" description="Условная навигация">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
</p>
<Button className="h-8 px-3 text-xs" onClick={() => handleAddRule(selectedScreen as any)}>
<Button className="h-8 px-3 text-xs" onClick={() => handleAddRule(selectedScreen)}>
Добавить правило
</Button>
</div>
@ -591,7 +613,7 @@ export function BuilderSidebar() {
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{(selectedScreen as any).list?.options?.map((option: any) => {
{selectedScreenIsListType && selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
const isChecked = condition.optionIds?.includes(option.id) ?? false;
return (

View File

@ -10,7 +10,7 @@ interface CouponScreenConfigProps {
}
export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps) {
const couponScreen = screen as CouponScreenDefinition & { position: any };
const couponScreen = screen as CouponScreenDefinition & { position: { x: number; y: number } };
return (
<div className="space-y-4">
@ -20,10 +20,10 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
<TextInput
placeholder="You're Lucky!"
value={couponScreen.title?.text || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
title: {
...couponScreen.title,
text: value,
text: e.target.value,
font: couponScreen.title?.font || "manrope",
weight: couponScreen.title?.weight || "bold",
align: couponScreen.title?.align || "center",
@ -38,10 +38,10 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
<TextInput
placeholder="You got an exclusive 94% discount"
value={couponScreen.subtitle?.text || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
subtitle: {
...couponScreen.subtitle,
text: value,
text: e.target.value,
font: couponScreen.subtitle?.font || "inter",
weight: couponScreen.subtitle?.weight || "medium",
align: couponScreen.subtitle?.align || "center",
@ -55,28 +55,44 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
<h3 className="text-sm font-semibold">Coupon Details</h3>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Discount Title</label>
<label className="text-xs font-medium text-muted-foreground">Offer Title</label>
<TextInput
placeholder="94% OFF"
value={couponScreen.coupon?.discountTitle || ""}
onChange={(value) => onUpdate({
value={couponScreen.coupon?.offer?.title?.text || ""}
onChange={(e) => onUpdate({
coupon: {
...couponScreen.coupon,
discountTitle: value,
offer: {
...couponScreen.coupon?.offer,
title: {
...couponScreen.coupon?.offer?.title,
text: e.target.value,
font: couponScreen.coupon?.offer?.title?.font || "manrope",
weight: couponScreen.coupon?.offer?.title?.weight || "bold",
}
}
}
})}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Discount Description</label>
<label className="text-xs font-medium text-muted-foreground">Offer Description</label>
<TextInput
placeholder="HAIR LOSS SPECIALIST"
value={couponScreen.coupon?.discountDescription || ""}
onChange={(value) => onUpdate({
value={couponScreen.coupon?.offer?.description?.text || ""}
onChange={(e) => onUpdate({
coupon: {
...couponScreen.coupon,
discountDescription: value,
offer: {
...couponScreen.coupon?.offer,
description: {
...couponScreen.coupon?.offer?.description,
text: e.target.value,
font: couponScreen.coupon?.offer?.description?.font || "inter",
weight: couponScreen.coupon?.offer?.description?.weight || "medium",
}
}
}
})}
/>
@ -86,11 +102,16 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
<label className="text-xs font-medium text-muted-foreground">Promo Code</label>
<TextInput
placeholder="HAIR50"
value={couponScreen.coupon?.promoCode || ""}
onChange={(value) => onUpdate({
value={couponScreen.coupon?.promoCode?.text || ""}
onChange={(e) => onUpdate({
coupon: {
...couponScreen.coupon,
promoCode: value,
promoCode: {
...couponScreen.coupon?.promoCode,
text: e.target.value,
font: couponScreen.coupon?.promoCode?.font || "manrope",
weight: couponScreen.coupon?.promoCode?.weight || "bold",
}
}
})}
/>
@ -100,11 +121,16 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
<label className="text-xs font-medium text-muted-foreground">Footer Text</label>
<TextInput
placeholder="Click to copy promocode"
value={couponScreen.coupon?.footerText || ""}
onChange={(value) => onUpdate({
value={couponScreen.coupon?.footer?.text || ""}
onChange={(e) => onUpdate({
coupon: {
...couponScreen.coupon,
footerText: value,
footer: {
...couponScreen.coupon?.footer,
text: e.target.value,
font: couponScreen.coupon?.footer?.font || "inter",
weight: couponScreen.coupon?.footer?.weight || "medium",
}
}
})}
/>
@ -117,9 +143,9 @@ export function CouponScreenConfig({ screen, onUpdate }: CouponScreenConfigProps
<TextInput
placeholder="Continue"
value={couponScreen.bottomActionButton?.text || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
bottomActionButton: {
text: value || "Continue",
text: e.target.value || "Continue",
}
})}
/>

View File

@ -10,7 +10,7 @@ interface DateScreenConfigProps {
}
export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
const dateScreen = screen as DateScreenDefinition & { position: any };
const dateScreen = screen as DateScreenDefinition & { position: { x: number; y: number } };
return (
<div className="space-y-4">
@ -20,10 +20,10 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="When were you born?"
value={dateScreen.title?.text || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
title: {
...dateScreen.title,
text: value,
text: e.target.value,
font: dateScreen.title?.font || "manrope",
weight: dateScreen.title?.weight || "bold",
}
@ -37,9 +37,9 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="Enter subtitle"
value={dateScreen.subtitle?.text || ""}
onChange={(value) => onUpdate({
subtitle: value ? {
text: value,
onChange={(e) => onUpdate({
subtitle: e.target.value ? {
text: e.target.value,
font: dateScreen.subtitle?.font || "inter",
weight: dateScreen.subtitle?.weight || "medium",
color: dateScreen.subtitle?.color || "muted",
@ -58,10 +58,10 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="Month"
value={dateScreen.dateInput?.monthLabel || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
monthLabel: value,
monthLabel: e.target.value,
}
})}
/>
@ -72,10 +72,10 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="Day"
value={dateScreen.dateInput?.dayLabel || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
dayLabel: value,
dayLabel: e.target.value,
}
})}
/>
@ -86,10 +86,10 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="Year"
value={dateScreen.dateInput?.yearLabel || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
yearLabel: value,
yearLabel: e.target.value,
}
})}
/>
@ -102,10 +102,10 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="MM"
value={dateScreen.dateInput?.monthPlaceholder || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
monthPlaceholder: value,
monthPlaceholder: e.target.value,
}
})}
/>
@ -116,10 +116,10 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="DD"
value={dateScreen.dateInput?.dayPlaceholder || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
dayPlaceholder: value,
dayPlaceholder: e.target.value,
}
})}
/>
@ -130,10 +130,10 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="YYYY"
value={dateScreen.dateInput?.yearPlaceholder || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
dateInput: {
...dateScreen.dateInput,
yearPlaceholder: value,
yearPlaceholder: e.target.value,
}
})}
/>
@ -147,9 +147,9 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="We protect your personal data"
value={dateScreen.infoMessage?.text || ""}
onChange={(value) => onUpdate({
infoMessage: value ? {
text: value,
onChange={(e) => onUpdate({
infoMessage: e.target.value ? {
text: e.target.value,
icon: dateScreen.infoMessage?.icon || "🔒",
} : undefined
})}
@ -159,10 +159,10 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="🔒"
value={dateScreen.infoMessage.icon}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
infoMessage: {
text: dateScreen.infoMessage?.text || "",
icon: value,
icon: e.target.value,
}
})}
/>
@ -175,9 +175,9 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
<TextInput
placeholder="Next"
value={dateScreen.bottomActionButton?.text || ""}
onChange={(value) => onUpdate({
bottomActionButton: value ? {
text: value,
onChange={(e) => onUpdate({
bottomActionButton: e.target.value ? {
text: e.target.value,
} : undefined
})}
/>

View File

@ -11,7 +11,7 @@ interface FormScreenConfigProps {
}
export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
const formScreen = screen as FormScreenDefinition & { position: any };
const formScreen = screen as FormScreenDefinition & { position: { x: number; y: number } };
const updateField = (index: number, updates: Partial<FormFieldDefinition>) => {
const newFields = [...(formScreen.fields || [])];
@ -46,10 +46,10 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<TextInput
placeholder="Enter your details"
value={formScreen.title?.text || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
title: {
...formScreen.title,
text: value,
text: e.target.value,
font: formScreen.title?.font || "manrope",
weight: formScreen.title?.weight || "bold",
}
@ -63,9 +63,9 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<TextInput
placeholder="Please fill in all fields"
value={formScreen.subtitle?.text || ""}
onChange={(value) => onUpdate({
subtitle: value ? {
text: value,
onChange={(e) => onUpdate({
subtitle: e.target.value ? {
text: e.target.value,
font: formScreen.subtitle?.font || "inter",
weight: formScreen.subtitle?.weight || "medium",
color: formScreen.subtitle?.color || "muted",
@ -79,7 +79,6 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Form Fields</h3>
<Button
size="sm"
onClick={addField}
className="h-7 px-3 text-xs"
>
@ -92,7 +91,6 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Field {index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeField(index)}
className="h-6 px-2 text-xs text-red-600 hover:text-red-700"
@ -107,7 +105,7 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<TextInput
placeholder="field_id"
value={field.id}
onChange={(value) => updateField(index, { id: value })}
onChange={(e) => updateField(index, { id: e.target.value })}
/>
</div>
@ -116,7 +114,7 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<select
className="w-full rounded border border-border bg-background px-2 py-1 text-sm"
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value as any })}
onChange={(e) => updateField(index, { type: e.target.value as FormFieldDefinition['type'] })}
>
<option value="text">Text</option>
<option value="email">Email</option>
@ -131,7 +129,7 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<TextInput
placeholder="Field Label"
value={field.label}
onChange={(value) => updateField(index, { label: value })}
onChange={(e) => updateField(index, { label: e.target.value })}
/>
</div>
@ -140,7 +138,7 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<TextInput
placeholder="Enter placeholder"
value={field.placeholder || ""}
onChange={(value) => updateField(index, { placeholder: value })}
onChange={(e) => updateField(index, { placeholder: e.target.value })}
/>
</div>
@ -171,7 +169,7 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
{(!formScreen.fields || formScreen.fields.length === 0) && (
<div className="text-center py-4 text-sm text-muted-foreground">
No fields added yet. Click "Add Field" to get started.
No fields added yet. Click &quot;Add Field&quot; to get started.
</div>
)}
</div>
@ -182,9 +180,9 @@ export function FormScreenConfig({ screen, onUpdate }: FormScreenConfigProps) {
<TextInput
placeholder="Continue"
value={formScreen.bottomActionButton?.text || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
bottomActionButton: {
text: value || "Continue",
text: e.target.value || "Continue",
}
})}
/>

View File

@ -1,9 +1,7 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { InfoScreenDefinition } from "@/lib/funnel/types";
import type { InfoScreenDefinition, TypographyVariant } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
interface InfoScreenConfigProps {
@ -12,7 +10,7 @@ interface InfoScreenConfigProps {
}
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
const infoScreen = screen as InfoScreenDefinition & { position: any };
const infoScreen = screen as InfoScreenDefinition & { position: { x: number; y: number } };
return (
<div className="space-y-4">
@ -22,10 +20,10 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
<TextInput
placeholder="Enter screen title"
value={infoScreen.title?.text || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
title: {
...infoScreen.title,
text: value,
text: e.target.value,
font: infoScreen.title?.font || "manrope",
weight: infoScreen.title?.weight || "bold",
align: infoScreen.title?.align || "center",
@ -41,7 +39,7 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
title: {
...infoScreen.title,
text: infoScreen.title?.text || "",
font: e.target.value as any,
font: e.target.value as TypographyVariant['font'],
}
})}
>
@ -56,7 +54,7 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
title: {
...infoScreen.title,
text: infoScreen.title?.text || "",
weight: e.target.value as any,
weight: e.target.value as TypographyVariant['weight'],
}
})}
>
@ -73,9 +71,9 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
<TextInput
placeholder="Enter screen description"
value={infoScreen.description?.text || ""}
onChange={(value) => onUpdate({
description: value ? {
text: value,
onChange={(e) => onUpdate({
description: e.target.value ? {
text: e.target.value,
font: infoScreen.description?.font || "inter",
weight: infoScreen.description?.weight || "medium",
align: infoScreen.description?.align || "center",
@ -112,11 +110,11 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
onChange={(e) => onUpdate({
icon: infoScreen.icon ? {
...infoScreen.icon,
size: e.target.value as any,
size: e.target.value as "sm" | "md" | "lg" | "xl",
} : {
type: "emoji",
value: "❤️",
size: e.target.value as any,
size: e.target.value as "sm" | "md" | "lg" | "xl",
}
})}
>
@ -130,10 +128,10 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
<TextInput
placeholder={infoScreen.icon?.type === "image" ? "Image URL" : "Emoji (e.g., ❤️)"}
value={infoScreen.icon?.value || ""}
onChange={(value) => onUpdate({
icon: value ? {
onChange={(e) => onUpdate({
icon: e.target.value ? {
type: infoScreen.icon?.type || "emoji",
value,
value: e.target.value,
size: infoScreen.icon?.size || "lg",
} : undefined
})}
@ -146,9 +144,9 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
<TextInput
placeholder="Next"
value={infoScreen.bottomActionButton?.text || ""}
onChange={(value) => onUpdate({
bottomActionButton: value ? {
text: value,
onChange={(e) => onUpdate({
bottomActionButton: e.target.value ? {
text: e.target.value,
} : undefined
})}
/>

View File

@ -7,7 +7,7 @@ import { FormScreenConfig } from "./FormScreenConfig";
import { TextScreenConfig } from "./TextScreenConfig";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { ScreenDefinition } from "@/lib/funnel/types";
import type { ScreenDefinition, InfoScreenDefinition, DateScreenDefinition, CouponScreenDefinition, FormScreenDefinition, TextScreenDefinition } from "@/lib/funnel/types";
interface TemplateConfigProps {
screen: BuilderScreen;
@ -21,40 +21,40 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
case "info":
return (
<InfoScreenConfig
screen={screen as any}
onUpdate={onUpdate as any}
screen={screen as BuilderScreen & { template: "info" }}
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void}
/>
);
case "date":
return (
<DateScreenConfig
screen={screen as any}
onUpdate={onUpdate as any}
screen={screen as BuilderScreen & { template: "date" }}
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
/>
);
case "coupon":
return (
<CouponScreenConfig
screen={screen as any}
onUpdate={onUpdate as any}
screen={screen as BuilderScreen & { template: "coupon" }}
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
/>
);
case "form":
return (
<FormScreenConfig
screen={screen as any}
onUpdate={onUpdate as any}
screen={screen as BuilderScreen & { template: "form" }}
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
/>
);
case "text":
return (
<TextScreenConfig
screen={screen as any}
onUpdate={onUpdate as any}
screen={screen as BuilderScreen & { template: "text" }}
onUpdate={onUpdate as (updates: Partial<TextScreenDefinition>) => void}
/>
);

View File

@ -10,7 +10,7 @@ interface TextScreenConfigProps {
}
export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
const textScreen = screen as TextScreenDefinition & { position: any };
const textScreen = screen as TextScreenDefinition & { position: { x: number; y: number } };
return (
<div className="space-y-4">
@ -20,10 +20,10 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
<TextInput
placeholder="Enter screen title"
value={textScreen.title?.text || ""}
onChange={(value) => onUpdate({
onChange={(e) => onUpdate({
title: {
...textScreen.title,
text: value,
text: e.target.value,
font: textScreen.title?.font || "manrope",
weight: textScreen.title?.weight || "bold",
align: textScreen.title?.align || "center",
@ -39,7 +39,7 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
title: {
...textScreen.title,
text: textScreen.title?.text || "",
font: e.target.value as any,
font: e.target.value as "manrope" | "inter" | "geistSans" | "geistMono",
}
})}
>
@ -54,7 +54,7 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
title: {
...textScreen.title,
text: textScreen.title?.text || "",
weight: e.target.value as any,
weight: e.target.value as "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black",
}
})}
>
@ -70,7 +70,7 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
title: {
...textScreen.title,
text: textScreen.title?.text || "",
align: e.target.value as any,
align: e.target.value as "center" | "left" | "right",
}
})}
>
@ -110,7 +110,7 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
content: {
...textScreen.content,
text: textScreen.content?.text || "",
font: e.target.value as any,
font: e.target.value as "manrope" | "inter" | "geistSans" | "geistMono",
}
})}
>
@ -128,7 +128,7 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
content: {
...textScreen.content,
text: textScreen.content?.text || "",
weight: e.target.value as any,
weight: e.target.value as "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black",
}
})}
>
@ -149,7 +149,7 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
content: {
...textScreen.content,
text: textScreen.content?.text || "",
color: e.target.value as any,
color: e.target.value as "default" | "primary" | "secondary" | "accent" | "destructive" | "success" | "muted",
}
})}
>
@ -168,7 +168,7 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
content: {
...textScreen.content,
text: textScreen.content?.text || "",
align: e.target.value as any,
align: e.target.value as "center" | "left" | "right",
}
})}
>
@ -186,9 +186,9 @@ export function TextScreenConfig({ screen, onUpdate }: TextScreenConfigProps) {
<TextInput
placeholder="Next"
value={textScreen.bottomActionButton?.text || ""}
onChange={(value) => onUpdate({
bottomActionButton: value ? {
text: value,
onChange={(e) => onUpdate({
bottomActionButton: e.target.value ? {
text: e.target.value,
} : undefined
})}
/>

View File

@ -181,23 +181,6 @@ function getScreenById(funnel: FunnelDefinition, screenId: string) {
return funnel.screens.find((screen) => screen.id === screenId);
}
function calculateScreenProgress(
currentScreenId: string,
funnel: FunnelDefinition,
answers: Record<string, string[]>
): { current: number; total: number } {
// Total is always the same - total number of screens in funnel
const total = funnel.screens.length;
// Find current screen index in the screens array
const currentIndex = funnel.screens.findIndex(screen => screen.id === currentScreenId);
const current = currentIndex >= 0 ? currentIndex + 1 : 1;
return {
current,
total,
};
}
function getCurrentTemplateRenderer(screen: ScreenDefinition): TemplateRenderer {
const renderer = TEMPLATE_REGISTRY[screen.template];
@ -221,8 +204,11 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
// Calculate automatic progress
const screenProgress = useMemo(() => {
return calculateScreenProgress(currentScreen.id, funnel, answers);
}, [currentScreen.id, funnel, answers]);
const total = funnel.screens.length;
const currentIndex = funnel.screens.findIndex(screen => screen.id === currentScreen.id);
const current = currentIndex >= 0 ? currentIndex + 1 : 1;
return { current, total };
}, [currentScreen.id, funnel]);
useEffect(() => {
registerScreen(currentScreen.id);

View File

@ -10,7 +10,6 @@ import type { BottomActionButtonProps } from "@/components/widgets/BottomActionB
import Typography from "@/components/ui/Typography/Typography";
import {
buildLayoutQuestionProps,
buildHeaderProgress,
buildTypographyProps,
shouldShowBackButton,

View File

@ -5,7 +5,9 @@ import { createContext, useContext, useMemo, useReducer, type ReactNode } from "
import type {
BuilderFunnelState,
BuilderScreen,
BuilderScreenPosition,
} from "@/lib/admin/builder/types";
import type { ListScreenDefinition } from "@/lib/funnel/types";
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
interface BuilderState extends BuilderFunnelState {
@ -121,7 +123,7 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat
}
case "add-screen": {
const nextId = generateScreenId(state.screens.map((s) => s.id));
const newScreen: BuilderScreen = {
const baseScreen = {
...INITIAL_SCREEN,
id: nextId,
position: {
@ -129,23 +131,27 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat
y: (action.payload?.position?.y ?? 120) + state.screens.length * 20,
},
...action.payload,
list: {
...INITIAL_SCREEN.list,
...(action.payload?.list ?? {}),
options:
action.payload?.list?.options && action.payload.list.options.length > 0
? action.payload.list.options
: INITIAL_SCREEN.list.options.map((option, index) => ({
...option,
id: `option-${index + 1}`,
})),
},
navigation: {
defaultNextScreenId: action.payload?.navigation?.defaultNextScreenId,
rules: action.payload?.navigation?.rules ?? [],
},
};
const newScreen: BuilderScreen = action.payload?.template === "list" ? {
...baseScreen,
template: "list" as const,
list: {
selectionType: "single" as const,
options: action.payload?.list?.options && action.payload.list.options.length > 0
? action.payload.list.options
: [
{ id: "option-1", label: "Вариант 1" },
{ id: "option-2", label: "Вариант 2" },
],
...(action.payload?.list ?? {}),
},
} : baseScreen as BuilderScreen;
return withDirty(state, {
...state,
screens: [...state.screens, newScreen],
@ -182,22 +188,23 @@ function builderReducer(state: BuilderState, action: BuilderAction): BuilderStat
...state,
screens: state.screens.map((current) =>
current.id === screenId
? {
? ({
...current,
...screen,
title: screen.title ? { ...current.title, ...screen.title } : current.title,
subtitle:
screen.subtitle !== undefined
? screen.subtitle
: current.subtitle,
list: screen.list
? {
...current.list,
...screen.list,
options: screen.list.options ?? current.list.options,
}
: current.list,
}
...(('subtitle' in screen && screen.subtitle !== undefined) ? {
subtitle: screen.subtitle
} : ('subtitle' in current) ? {
subtitle: current.subtitle
} : {}),
...(current.template === "list" && 'list' in screen && screen.list ? {
list: {
...(current as ListScreenDefinition & { position: BuilderScreenPosition }).list,
...screen.list,
options: screen.list.options ?? (current as ListScreenDefinition & { position: BuilderScreenPosition }).list.options,
}
} : {}),
} as BuilderScreen)
: current
),
});

View File

@ -24,9 +24,9 @@ const LIST_TEMPLATE: BuilderTemplateDefinition = {
id: "list",
label: "Вопрос с вариантами",
create: ({ screenId, position }, overrides) => {
const base: BuilderScreen = {
const base = {
id: screenId,
template: "list",
template: "list" as const,
header: {
progress: {
current: 1,
@ -36,16 +36,16 @@ const LIST_TEMPLATE: BuilderTemplateDefinition = {
},
title: {
text: "Новый экран",
font: "manrope",
weight: "bold",
font: "manrope" as const,
weight: "bold" as const,
},
subtitle: {
text: "Опишите вопрос справа",
color: "muted",
font: "inter",
color: "muted" as const,
font: "inter" as const,
},
list: {
selectionType: "single",
selectionType: "single" as const,
options: cloneOptions([
{ id: "option-1", label: "Вариант 1" },
{ id: "option-2", label: "Вариант 2" },
@ -59,13 +59,13 @@ const LIST_TEMPLATE: BuilderTemplateDefinition = {
};
if (!overrides) {
return base;
return base as BuilderScreen;
}
return {
...base,
...overrides,
list: overrides.list
list: ('list' in overrides && overrides.list)
? {
...base.list,
...overrides.list,
@ -79,7 +79,7 @@ const LIST_TEMPLATE: BuilderTemplateDefinition = {
rules: overrides.navigation.rules ?? base.navigation?.rules ?? [],
}
: base.navigation,
};
} as BuilderScreen;
},
};

View File

@ -1,15 +1,15 @@
import type { BuilderState } from "@/lib/admin/builder/context";
import type { BuilderScreen, BuilderFunnelState } from "@/lib/admin/builder/types";
import type { FunnelDefinition, ListScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen, BuilderFunnelState, BuilderScreenPosition } from "@/lib/admin/builder/types";
import type { FunnelDefinition, ScreenDefinition, ListScreenDefinition } from "@/lib/funnel/types";
function withPositions(screens: ListScreenDefinition[]): BuilderScreen[] {
function withPositions(screens: ScreenDefinition[]): BuilderScreen[] {
return screens.map((screen, index) => ({
...screen,
position: {
x: 120 + (index % 4) * 240,
y: 120 + Math.floor(index / 4) * 200,
},
}));
})) as BuilderScreen[];
}
export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderState {
@ -24,7 +24,7 @@ export function deserializeFunnelDefinition(funnel: FunnelDefinition): BuilderSt
}
export function serializeBuilderState(state: BuilderFunnelState): FunnelDefinition {
const screens = state.screens.map(({ position, ...rest }) => rest);
const screens = state.screens.map(({ position: _position, ...rest }) => rest);
const meta: FunnelDefinition["meta"] = {
...state.meta,
firstScreenId: state.meta.firstScreenId ?? state.screens[0]?.id,
@ -37,13 +37,15 @@ export function serializeBuilderState(state: BuilderFunnelState): FunnelDefiniti
}
export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderScreen>): BuilderScreen {
const copy: BuilderScreen = {
const copy = {
...screen,
position: { ...screen.position },
list: {
...screen.list,
options: screen.list.options.map((option) => ({ ...option })),
},
...(screen.template === "list" && 'list' in screen ? {
list: {
...(screen as ListScreenDefinition & { position: BuilderScreenPosition }).list,
options: (screen as ListScreenDefinition & { position: BuilderScreenPosition }).list.options.map((option) => ({ ...option })),
}
} : {}),
navigation: screen.navigation
? {
defaultNextScreenId: screen.navigation.defaultNextScreenId,
@ -57,12 +59,12 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderSc
})),
}
: undefined,
};
} as BuilderScreen;
return overrides ? { ...copy, ...overrides } : copy;
return overrides ? { ...copy, ...overrides } as BuilderScreen : copy;
}
export function toBuilderFunnelState(state: BuilderState): BuilderFunnelState {
const { isDirty, selectedScreenId, ...rest } = state;
const { isDirty: _isDirty, selectedScreenId: _selectedScreenId, ...rest } = state;
return rest;
}

View File

@ -45,8 +45,8 @@ function validateOptionIds(screen: BuilderScreen, issues: BuilderValidationIssue
return;
}
const screenWithList = screen as any;
const duplicates = collectDuplicateIds(screenWithList.list.options.map((option: any) => option.id));
const screenWithList = screen as { list: { options: { id: string }[] } };
const duplicates = collectDuplicateIds(screenWithList.list.options.map((option) => option.id));
for (const duplicateId of duplicates) {
issues.push(
createIssue(
@ -136,8 +136,8 @@ function validateNavigation(screen: BuilderScreen, state: BuilderState, issues:
continue;
}
const referenceScreenWithList = referenceScreen as any;
const availableOptionIds = new Set(referenceScreenWithList.list.options.map((option: any) => option.id));
const referenceScreenWithList = referenceScreen as { list: { options: { id: string }[] } };
const availableOptionIds = new Set(referenceScreenWithList.list.options.map((option) => option.id));
const missingOptionIds = (condition.optionIds ?? []).filter((optionId) => !availableOptionIds.has(optionId));
if (missingOptionIds.length > 0) {
issues.push(