From 90126bbf3b16c22ca6149a86aa9318621d858135 Mon Sep 17 00:00:00 2001 From: gofnnp Date: Fri, 10 Oct 2025 19:57:46 +0400 Subject: [PATCH 1/2] special-offer add special offer page --- .../admin/builder/Canvas/constants.ts | 1 + .../admin/builder/dialogs/AddScreenDialog.tsx | 67 ++-- .../templates/SpecialOfferScreenConfig.tsx | 256 +++++++++++++++ .../builder/templates/TemplateConfig.tsx | 213 +++++++++---- .../admin/builder/templates/index.ts | 1 + .../SpecialOffer/SpecialOffer.stories.tsx | 108 +++++++ .../templates/SpecialOffer/SpecialOffer.tsx | 301 ++++++++++++++++++ .../funnel/templates/SpecialOffer/index.ts | 1 + .../TrialPaymentTemplate.tsx | 95 +----- src/components/funnel/templates/index.ts | 3 +- src/components/ui/MarkupText/MarkupText.tsx | 18 +- .../admin/builder/state/defaults/blocks.ts | 125 +++++++- src/lib/admin/builder/state/defaults/index.ts | 1 + .../builder/state/defaults/specialOffer.ts | 32 ++ src/lib/admin/builder/state/utils.ts | 10 +- src/lib/funnel/screenRenderer.tsx | 25 ++ src/lib/funnel/types.ts | 69 +++- src/lib/models/Funnel.ts | 1 + src/lib/text-markup.ts | 29 +- src/shared/utils/cookies.ts | 31 ++ src/shared/utils/period.ts | 47 +++ 21 files changed, 1219 insertions(+), 215 deletions(-) create mode 100644 src/components/admin/builder/templates/SpecialOfferScreenConfig.tsx create mode 100644 src/components/funnel/templates/SpecialOffer/SpecialOffer.stories.tsx create mode 100644 src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx create mode 100644 src/components/funnel/templates/SpecialOffer/index.ts create mode 100644 src/lib/admin/builder/state/defaults/specialOffer.ts create mode 100644 src/shared/utils/cookies.ts create mode 100644 src/shared/utils/period.ts diff --git a/src/components/admin/builder/Canvas/constants.ts b/src/components/admin/builder/Canvas/constants.ts index 205d8d6..3b68fae 100644 --- a/src/components/admin/builder/Canvas/constants.ts +++ b/src/components/admin/builder/Canvas/constants.ts @@ -13,6 +13,7 @@ export const TEMPLATE_TITLES: Record = { loaders: "Загрузка", soulmate: "Портрет партнера", trialPayment: "Trial Payment", + specialOffer: "Special Offer", }; export const OPERATOR_LABELS: Record< diff --git a/src/components/admin/builder/dialogs/AddScreenDialog.tsx b/src/components/admin/builder/dialogs/AddScreenDialog.tsx index 20b5350..d63f5bd 100644 --- a/src/components/admin/builder/dialogs/AddScreenDialog.tsx +++ b/src/components/admin/builder/dialogs/AddScreenDialog.tsx @@ -1,16 +1,17 @@ "use client"; import { useState } from "react"; -import { - List, - FormInput, - Info, - Calendar, +import { + List, + FormInput, + Info, + Calendar, Ticket, Loader, Heart, Mail, - CreditCard + CreditCard, + Gift, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -38,11 +39,12 @@ const TEMPLATE_OPTIONS = [ color: "bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400", }, { - template: "form" as const, + template: "form" as const, title: "Форма", description: "Ввод текстовых данных в поля", icon: FormInput, - color: "bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400", + color: + "bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400", }, { template: "email" as const, @@ -53,7 +55,7 @@ const TEMPLATE_OPTIONS = [ }, { template: "info" as const, - title: "Информация", + title: "Информация", description: "Отображение информации с кнопкой продолжить", icon: Info, color: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400", @@ -61,9 +63,11 @@ const TEMPLATE_OPTIONS = [ { template: "date" as const, title: "Дата рождения", - description: "Выбор даты (месяц, день, год) + автоматический расчет возраста", + description: + "Выбор даты (месяц, день, год) + автоматический расчет возраста", icon: Calendar, - color: "bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400", + color: + "bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400", }, { template: "loaders" as const, @@ -82,21 +86,36 @@ const TEMPLATE_OPTIONS = [ { template: "coupon" as const, title: "Купон", - description: "Отображение промокода и предложения", + description: "Отображение промокода и предложения", icon: Ticket, - color: "bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400", + color: + "bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400", }, { template: "trialPayment" as const, title: "Trial Payment", description: "Страница оплаты с пробным периодом", icon: CreditCard, - color: "bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400", + color: + "bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400", + }, + { + template: "specialOffer" as const, + title: "Special Offer", + description: "Страница с предложением", + icon: Gift, + color: "bg-pink-50 text-pink-600 dark:bg-pink-900/20 dark:text-pink-400", }, ] as const; -export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDialogProps) { - const [selectedTemplate, setSelectedTemplate] = useState(null); +export function AddScreenDialog({ + open, + onOpenChange, + onAddScreen, +}: AddScreenDialogProps) { + const [selectedTemplate, setSelectedTemplate] = useState< + ScreenDefinition["template"] | null + >(null); const handleAdd = () => { if (selectedTemplate) { @@ -113,7 +132,7 @@ export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDi return ( - + Выберите тип экрана @@ -130,18 +149,22 @@ export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDi ); diff --git a/src/components/admin/builder/templates/SpecialOfferScreenConfig.tsx b/src/components/admin/builder/templates/SpecialOfferScreenConfig.tsx new file mode 100644 index 0000000..bd459f1 --- /dev/null +++ b/src/components/admin/builder/templates/SpecialOfferScreenConfig.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput"; +import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; +import type { BuilderScreen } from "@/lib/admin/builder/types"; +import type { SpecialOfferScreenDefinition, TypographyVariant } from "@/lib/funnel/types"; + +interface SpecialOfferScreenConfigProps { + screen: BuilderScreen & { template: "specialOffer" }; + onUpdate: (updates: Partial) => void; +} + +function CollapsibleSection({ + title, + children, + storagePrefix = "special-offer", + defaultExpanded = false, +}: { + title: string; + children: React.ReactNode; + storagePrefix?: string; + defaultExpanded?: boolean; +}) { + const storageKey = `${storagePrefix}-section-${title.toLowerCase().replace(/\s+/g, "-")}`; + const [isExpanded, setIsExpanded] = useState(() => { + if (typeof window === "undefined") return defaultExpanded; + const stored = sessionStorage.getItem(storageKey); + return stored !== null ? JSON.parse(stored) : defaultExpanded; + }); + + const handleToggle = () => { + const next = !isExpanded; + setIsExpanded(next); + if (typeof window !== "undefined") { + sessionStorage.setItem(storageKey, JSON.stringify(next)); + } + }; + + return ( +
+ + {isExpanded &&
{children}
} +
+ ); +} + +export function SpecialOfferScreenConfig({ screen, onUpdate }: SpecialOfferScreenConfigProps) { + const specialScreen = screen as SpecialOfferScreenDefinition; + + const updateText = (field: keyof NonNullable, value: TypographyVariant | undefined) => { + const base = specialScreen.text ?? {}; + const next = { ...base, [field]: value } as NonNullable; + onUpdate({ text: next }); + }; + + const handleTextChange = ( + field: keyof NonNullable, + text: string + ) => { + const current = (specialScreen.text?.[field] ?? {}) as TypographyVariant; + const trimmed = text; // allow empty, higher-level validation can handle required rules + const nextValue: TypographyVariant | undefined = + trimmed.length > 0 ? { ...current, text: trimmed } : { ...current, text: "" }; + updateText(field, nextValue); + }; + + // description is a composite object: { trialPrice, text, oldTrialPrice } + const updateDescriptionPart = ( + part: "trialPrice" | "text" | "oldTrialPrice", + value: TypographyVariant | undefined + ) => { + const baseText = specialScreen.text ?? {}; + const currentDescription = baseText.description ?? {}; + const nextDescription = { + ...currentDescription, + [part]: value, + } as NonNullable["description"]; + + onUpdate({ text: { ...baseText, description: nextDescription } }); + }; + + const handleDescriptionTextChange = ( + part: "trialPrice" | "text" | "oldTrialPrice", + text: string + ) => { + const current = (specialScreen.text?.description?.[part] ?? {}) as TypographyVariant; + const nextValue: TypographyVariant | undefined = { ...current, text }; + updateDescriptionPart(part, nextValue); + }; + + const addAdvantageItem = () => { + const items = specialScreen.advantages?.items ?? []; + const next = [...items, { icon: { text: "" }, text: { text: "" } }]; + onUpdate({ advantages: { items: next } }); + }; + + const removeAdvantageItem = (index: number) => { + const items = specialScreen.advantages?.items ?? []; + const next = items.filter((_, i) => i !== index); + onUpdate({ advantages: { items: next } }); + }; + + const moveAdvantageItem = (index: number, direction: -1 | 1) => { + const items = [...(specialScreen.advantages?.items ?? [])]; + const target = index + direction; + if (target < 0 || target >= items.length) return; + const [cur] = items.splice(index, 1); + items.splice(target, 0, cur); + onUpdate({ advantages: { items } }); + }; + + const updateAdvantageItem = ( + index: number, + field: "icon" | "text", + text: string + ) => { + const items = specialScreen.advantages?.items ?? []; + const current = items[index] ?? { icon: { text: "" }, text: { text: "" } }; + const nextItem = { + ...current, + [field]: { ...(current[field] ?? {}), text }, + } as NonNullable["items"][number]; + const next = items.map((it, i) => (i === index ? nextItem : it)); + onUpdate({ advantages: { items: next } }); + }; + + return ( +
+ +
+ handleTextChange("title", e.target.value)} + /> + handleTextChange("subtitle", e.target.value)} + /> + handleDescriptionTextChange("trialPrice", e.target.value)} + /> + handleDescriptionTextChange("text", e.target.value)} + /> + handleDescriptionTextChange("oldTrialPrice", e.target.value)} + /> +
+
+ + +
+
+

Список преимуществ

+ +
+ +
+ {(specialScreen.advantages?.items ?? []).map((item, index) => ( +
+
+
Элемент {index + 1}
+
+ + + +
+
+ +
+ updateAdvantageItem(index, "icon", e.target.value)} + /> + updateAdvantageItem(index, "text", e.target.value)} + /> +
+
+ ))} +
+ + {(specialScreen.advantages?.items ?? []).length === 0 && ( +
+ Пока нет элементов. Добавьте первый, нажав "+". +
+ )} +
+
+ +
+

+ Тексты и преимущества отображаются в шаблоне `SpecialOfferTemplate`. Основная нижняя кнопка + настраивается выше в секции "Нижняя кнопка". +

+
+
+ ); +} + + diff --git a/src/components/admin/builder/templates/TemplateConfig.tsx b/src/components/admin/builder/templates/TemplateConfig.tsx index 8c43356..50b32b6 100644 --- a/src/components/admin/builder/templates/TemplateConfig.tsx +++ b/src/components/admin/builder/templates/TemplateConfig.tsx @@ -30,7 +30,9 @@ import type { BottomActionButtonDefinition, HeaderDefinition, TrialPaymentScreenDefinition, + SpecialOfferScreenDefinition, } from "@/lib/funnel/types"; +import { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig"; const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"]; @@ -48,11 +50,13 @@ function CollapsibleSection({ children: React.ReactNode; defaultExpanded?: boolean; }) { - const storageKey = `template-section-${title.toLowerCase().replace(/\s+/g, '-')}`; - + const storageKey = `template-section-${title + .toLowerCase() + .replace(/\s+/g, "-")}`; + const [isExpanded, setIsExpanded] = useState(() => { - if (typeof window === 'undefined') return defaultExpanded; - + if (typeof window === "undefined") return defaultExpanded; + const stored = sessionStorage.getItem(storageKey); return stored !== null ? JSON.parse(stored) : defaultExpanded; }); @@ -60,8 +64,8 @@ function CollapsibleSection({ const handleToggle = () => { const newExpanded = !isExpanded; setIsExpanded(newExpanded); - - if (typeof window !== 'undefined') { + + if (typeof window !== "undefined") { sessionStorage.setItem(storageKey, JSON.stringify(newExpanded)); } }; @@ -88,14 +92,24 @@ function CollapsibleSection({ interface TypographyControlsProps { label: string; value: (TypographyVariant & { show?: boolean }) | undefined; - onChange: (value: (TypographyVariant & { show?: boolean }) | undefined) => void; + onChange: ( + value: (TypographyVariant & { show?: boolean }) | undefined + ) => void; allowRemove?: boolean; showToggle?: boolean; // Показывать ли чекбокс "Показывать" } -function TypographyControls({ label, value, onChange, allowRemove = false, showToggle = false }: TypographyControlsProps) { - const storageKey = `typography-advanced-${label.toLowerCase().replace(/\s+/g, '-')}`; - +function TypographyControls({ + label, + value, + onChange, + allowRemove = false, + showToggle = false, +}: TypographyControlsProps) { + const storageKey = `typography-advanced-${label + .toLowerCase() + .replace(/\s+/g, "-")}`; + const [showAdvanced, setShowAdvanced] = useState(false); const [isHydrated, setIsHydrated] = useState(false); @@ -124,7 +138,10 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT } }; - const handleAdvancedChange = (field: keyof TypographyVariant, fieldValue: string) => { + const handleAdvancedChange = ( + field: keyof TypographyVariant, + fieldValue: string + ) => { onChange({ ...value, text: value?.text || "", @@ -162,25 +179,29 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT )} - +
- handleTextChange(event.target.value)} onBlur={handleTextBlur} rows={2} className="resize-y" - aria-invalid={!allowRemove && (!value?.text || value.text.trim() === "")} + aria-invalid={ + !allowRemove && (!value?.text || value.text.trim() === "") + } /> {!allowRemove && (!value?.text || value.text.trim() === "") && ( -

Это поле обязательно для заполнения

+

+ Это поле обязательно для заполнения +

)}
@@ -191,16 +212,23 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT onClick={() => { const newShowAdvanced = !showAdvanced; setShowAdvanced(newShowAdvanced); - if (typeof window !== 'undefined') { - sessionStorage.setItem(storageKey, JSON.stringify(newShowAdvanced)); + if (typeof window !== "undefined") { + sessionStorage.setItem( + storageKey, + JSON.stringify(newShowAdvanced) + ); } }} className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition" > - {showAdvanced ? : } + {showAdvanced ? ( + + ) : ( + + )} Настройки оформления - + {(isHydrated ? showAdvanced : false) && (
- + - - + @@ -314,10 +347,14 @@ interface ActionButtonControlsProps { onChange: (value: BottomActionButtonDefinition | undefined) => void; } -function ActionButtonControls({ label, value, onChange }: ActionButtonControlsProps) { +function ActionButtonControls({ + label, + value, + onChange, +}: ActionButtonControlsProps) { // По умолчанию кнопка включена (show !== false) const isEnabled = value?.show !== false; - const buttonText = value?.text || ''; + const buttonText = value?.text || ""; const cornerRadius = value?.cornerRadius; const showPrivacyTermsConsent = value?.showPrivacyTermsConsent ?? false; @@ -340,13 +377,13 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr const handleTextChange = (text: string) => { if (!isEnabled) return; - + const trimmedText = text.trim(); const newValue = { ...value, text: trimmedText || undefined, }; - + // Убираем undefined поля для чистоты if (!newValue.text && !newValue.cornerRadius && newValue.show !== false) { onChange(undefined); @@ -357,15 +394,20 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr const handleRadiusChange = (radius: string) => { if (!isEnabled) return; - + const newRadius = (radius as "3xl" | "full") || undefined; const newValue = { ...value, cornerRadius: newRadius, }; - + // Убираем undefined поля для чистоты - if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) { + if ( + !newValue.text && + !newValue.cornerRadius && + newValue.show !== false && + !newValue.showPrivacyTermsConsent + ) { onChange(undefined); } else { onChange(newValue); @@ -374,14 +416,19 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr const handlePrivacyTermsToggle = (checked: boolean) => { if (!isEnabled) return; - + const newValue = { ...value, showPrivacyTermsConsent: checked || undefined, }; - + // Убираем undefined поля для чистоты - if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) { + if ( + !newValue.text && + !newValue.cornerRadius && + newValue.show !== false && + !newValue.showPrivacyTermsConsent + ) { onChange(undefined); } else { onChange(newValue); @@ -402,16 +449,20 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr {isEnabled && (
)} + + {/* Экран при попытке перехода назад */} + {selectedScreenIsListType && diff --git a/src/components/admin/builder/Sidebar/NavigationPanel.tsx b/src/components/admin/builder/Sidebar/NavigationPanel.tsx index b13e6b0..05ae9b8 100644 --- a/src/components/admin/builder/Sidebar/NavigationPanel.tsx +++ b/src/components/admin/builder/Sidebar/NavigationPanel.tsx @@ -50,6 +50,9 @@ export function NavigationPanel({ screen }: NavigationPanelProps) { isEndScreen: navigationUpdates.isEndScreen ?? targetScreen.navigation?.isEndScreen, + onBackScreenId: + navigationUpdates.onBackScreenId ?? + targetScreen.navigation?.onBackScreenId, }, }, }); @@ -152,6 +155,34 @@ export function NavigationPanel({ screen }: NavigationPanelProps) { )} + + {/* Экран при попытке перехода назад */} + {/* {!screen.navigation?.isEndScreen && ( */} + + {/* )} */} {selectedScreenIsListType && !screen.navigation?.isEndScreen && ( diff --git a/src/components/funnel/FunnelRuntime.tsx b/src/components/funnel/FunnelRuntime.tsx index d518d73..6640939 100644 --- a/src/components/funnel/FunnelRuntime.tsx +++ b/src/components/funnel/FunnelRuntime.tsx @@ -257,6 +257,14 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { }; const onBack = () => { + const backTarget: string | undefined = currentScreen.navigation?.onBackScreenId; + + if (backTarget) { + // Переназначаем назад на конкретный экран без роста истории + router.replace(`/${funnel.meta.id}/${backTarget}`); + return; + } + const currentIndex = historyWithCurrent.lastIndexOf(currentScreen.id); if (currentIndex > 0) { @@ -272,6 +280,36 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { router.back(); }; + // Перехват аппаратной/браузерной кнопки Назад, когда настроен onBackScreenId + useEffect(() => { + const backTarget: string | undefined = currentScreen.navigation?.onBackScreenId; + if (!backTarget) return; + + const pushTrap = () => { + try { + window.history.pushState({ __trap: true }, "", window.location.href); + } catch {} + }; + + pushTrap(); + + function isTrapState(state: unknown): state is { __trap?: boolean } { + return typeof state === "object" && state !== null && "__trap" in (state as Record); + } + + const handlePopState = (e: PopStateEvent) => { + if (isTrapState(e.state) && e.state.__trap) { + pushTrap(); + router.replace(`/${funnel.meta.id}/${backTarget}`); + } + }; + + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, [currentScreen.id, currentScreen.navigation?.onBackScreenId, funnel.meta.id, router]); + const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0; return renderScreen({ diff --git a/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx b/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx index 17e97b2..c158657 100644 --- a/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx +++ b/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx @@ -38,7 +38,7 @@ export function SpecialOfferTemplate({ defaultTexts, }: SpecialOfferProps) { const token = useClientToken(); - const paymentId = "main"; + const paymentId = "main_secret_discount"; const { placement, isLoading } = usePaymentPlacement({ funnel, paymentId }); const [isLoadingRedirect, setIsLoadingRedirect] = useState(false); diff --git a/src/lib/admin/builder/state/reducer.ts b/src/lib/admin/builder/state/reducer.ts index 28f5577..a9c876e 100644 --- a/src/lib/admin/builder/state/reducer.ts +++ b/src/lib/admin/builder/state/reducer.ts @@ -370,6 +370,9 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil defaultNextScreenId: navigation.defaultNextScreenId ?? undefined, rules: navigation.rules ?? [], isEndScreen: navigation.isEndScreen, + ...("onBackScreenId" in navigation + ? { onBackScreenId: navigation.onBackScreenId ?? undefined } + : {}), }, } : screen diff --git a/src/lib/admin/builder/state/types.ts b/src/lib/admin/builder/state/types.ts index 231782c..29da528 100644 --- a/src/lib/admin/builder/state/types.ts +++ b/src/lib/admin/builder/state/types.ts @@ -26,6 +26,7 @@ export type BuilderAction = defaultNextScreenId?: string | null; rules?: NavigationRuleDefinition[]; isEndScreen?: boolean; + onBackScreenId?: string | null; }; }; } diff --git a/src/lib/admin/builder/utils.ts b/src/lib/admin/builder/utils.ts index 699fd24..f674f2d 100644 --- a/src/lib/admin/builder/utils.ts +++ b/src/lib/admin/builder/utils.ts @@ -87,6 +87,9 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial ({ nextScreenId: rule.nextScreenId, conditions: rule.conditions.map((condition) => ({ diff --git a/src/lib/admin/builder/validation.ts b/src/lib/admin/builder/validation.ts index 9174d81..b28803f 100644 --- a/src/lib/admin/builder/validation.ts +++ b/src/lib/admin/builder/validation.ts @@ -99,6 +99,29 @@ function validateNavigation(screen: BuilderScreen, state: BuilderState, issues: ); } + // Проверка onBackScreenId + const onBackScreenId: string | undefined = navigation.onBackScreenId as string | undefined; + if (onBackScreenId) { + if (!screenIds.has(onBackScreenId)) { + issues.push( + createIssue( + "error", + `Экран \`${screen.id}\` ссылается на несуществующий back-экран \`${onBackScreenId}\``, + { screenId: screen.id } + ) + ); + } + if (onBackScreenId === screen.id) { + issues.push( + createIssue( + "warning", + `Экран \`${screen.id}\`: выбран тот же экран для перехода назад (нет смысла)`, + { screenId: screen.id } + ) + ); + } + } + for (const [ruleIndex, rule] of (navigation.rules ?? []).entries()) { if (!screenIds.has(rule.nextScreenId)) { issues.push( diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index d46aa4e..9781207 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -106,6 +106,8 @@ export interface NavigationDefinition { defaultNextScreenId?: string; rules?: NavigationRuleDefinition[]; isEndScreen?: boolean; // Указывает что это финальный экран воронки + /** Экран, на который нужно перейти при попытке возврата назад (UI/браузер) */ + onBackScreenId?: string; } // Рекурсивный Partial для глубоких вложенных объектов diff --git a/src/lib/models/Funnel.ts b/src/lib/models/Funnel.ts index 75e282c..cc46e23 100644 --- a/src/lib/models/Funnel.ts +++ b/src/lib/models/Funnel.ts @@ -145,6 +145,7 @@ const NavigationDefinitionSchema = new Schema( rules: [NavigationRuleSchema], defaultNextScreenId: String, isEndScreen: { type: Boolean, default: false }, + onBackScreenId: String, }, { _id: false } );