From 90126bbf3b16c22ca6149a86aa9318621d858135 Mon Sep 17 00:00:00 2001 From: gofnnp Date: Fri, 10 Oct 2025 19:57:46 +0400 Subject: [PATCH 1/4] 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 } ); From 4a97bf67f693f818a6f8ee5015d59084b02a6293 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Mon, 20 Oct 2025 00:22:59 +0200 Subject: [PATCH 3/4] add screen to funnel --- public/funnels/soulmate.json | 81 +++++++++++++++++++ .../templates/SpecialOffer/SpecialOffer.tsx | 11 ++- src/lib/funnel/bakedFunnels.ts | 81 +++++++++++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) diff --git a/public/funnels/soulmate.json b/public/funnels/soulmate.json index 6b46418..9ebc35b 100644 --- a/public/funnels/soulmate.json +++ b/public/funnels/soulmate.json @@ -2322,6 +2322,12 @@ "cornerRadius": "3xl", "showPrivacyTermsConsent": false }, + "navigation": { + "rules": [], + "defaultNextScreenId": "specialoffer", + "isEndScreen": true, + "onBackScreenId": "specialoffer" + }, "variants": [], "headerBlock": { "text": { @@ -2747,6 +2753,81 @@ ] } } + }, + { + "id": "specialoffer", + "template": "specialOffer", + "header": { + "showBackButton": false, + "show": true + }, + "title": { + "text": "Special Offer", + "show": false, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "GET {{trialPeriod}} TRIAL", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "isEndScreen": true + }, + "variants": [], + "text": { + "title": { + "text": "Special Offer" + }, + "subtitle": { + "text": "SAVE {{discountPercent}}% OFF!" + }, + "description": { + "trialPrice": { + "text": "{{trialPrice}}" + }, + "text": { + "text": " instead of " + }, + "oldTrialPrice": { + "text": "~~{{oldTrialPrice}}~~" + } + } + }, + "advantages": { + "items": [ + { + "icon": { + "text": "🔥" + }, + "text": { + "text": " **{{trialPeriodHyphen}}** trial instead of ~~{{oldTrialPeriod}}~~" + } + }, + { + "icon": { + "text": "💝" + }, + "text": { + "text": " Get **{{discountPercent}}%** off your Soulmate Sketch" + } + }, + { + "icon": { + "text": "💌" + }, + "text": { + "text": " Includes the **“Finding the One”** Guide" + } + } + ] + } } ] } \ No newline at end of file diff --git a/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx b/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx index c158657..5e3fa29 100644 --- a/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx +++ b/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx @@ -51,6 +51,9 @@ export function SpecialOfferTemplate({ const trialPrice = variant?.trialPrice || 0; const price = variant?.price || 0; const oldPrice = variant?.price || 0; + const oldTrialPeriod = "DAY"; // TODO: from main product + const oldTrialInterval = 7; // TODO: from main product + const oldTrialPrice = 100; // TODO: from main product const billingPeriod = placement?.billingPeriod; const billingInterval = placement?.billingInterval || 1; const currency = placement?.currency || Currency.USD; @@ -61,7 +64,7 @@ export function SpecialOfferTemplate({ const trialPeriodText = formatPeriod(trialPeriod, trialInterval); const billingPeriodText = formatPeriod(billingPeriod, billingInterval); const trialPeriodHyphenText = formatPeriodHyphen(trialPeriod, trialInterval); - const oldTrialPeriodText = formatPeriod(trialPeriod, 7); + const oldTrialPeriodText = formatPeriod(oldTrialPeriod, oldTrialInterval); const handlePayClick = () => { if (isLoadingRedirect) { @@ -75,8 +78,8 @@ export function SpecialOfferTemplate({ }; const computeDiscountPercent = () => { - if (!oldPrice || !trialPrice || oldPrice <= 0) return undefined; - const ratio = 1 - trialPrice / oldPrice; + if (!oldTrialPrice || !trialPrice || oldTrialPrice <= 0) return undefined; + const ratio = 1 - trialPrice / oldTrialPrice; const percent = Math.max(0, Math.min(100, Math.round(ratio * 100))); return String(percent); }; @@ -86,7 +89,7 @@ export function SpecialOfferTemplate({ const values: Record = { trialPrice: formattedTrialPrice, billingPrice: formattedBillingPrice, - oldTrialPrice: getFormattedPrice(oldPrice || 0, currency), + oldTrialPrice: getFormattedPrice(oldTrialPrice, currency), discountPercent: computeDiscountPercent() ?? "", trialPeriod: trialPeriodText, billingPeriod: billingPeriodText, diff --git a/src/lib/funnel/bakedFunnels.ts b/src/lib/funnel/bakedFunnels.ts index 66ed7a4..2bcbf06 100644 --- a/src/lib/funnel/bakedFunnels.ts +++ b/src/lib/funnel/bakedFunnels.ts @@ -2330,6 +2330,12 @@ export const BAKED_FUNNELS: Record = { "cornerRadius": "3xl", "showPrivacyTermsConsent": false }, + "navigation": { + "rules": [], + "defaultNextScreenId": "specialoffer", + "isEndScreen": true, + "onBackScreenId": "specialoffer" + }, "variants": [], "headerBlock": { "text": { @@ -2755,6 +2761,81 @@ export const BAKED_FUNNELS: Record = { ] } } + }, + { + "id": "specialoffer", + "template": "specialOffer", + "header": { + "showBackButton": false, + "show": true + }, + "title": { + "text": "Special Offer", + "show": false, + "font": "manrope", + "weight": "bold", + "size": "2xl", + "align": "left", + "color": "default" + }, + "bottomActionButton": { + "show": true, + "text": "GET {{trialPeriod}} TRIAL", + "cornerRadius": "3xl", + "showPrivacyTermsConsent": false + }, + "navigation": { + "rules": [], + "isEndScreen": true + }, + "variants": [], + "text": { + "title": { + "text": "Special Offer" + }, + "subtitle": { + "text": "SAVE {{discountPercent}}% OFF!" + }, + "description": { + "trialPrice": { + "text": "{{trialPrice}}" + }, + "text": { + "text": " instead of " + }, + "oldTrialPrice": { + "text": "~~{{oldTrialPrice}}~~" + } + } + }, + "advantages": { + "items": [ + { + "icon": { + "text": "🔥" + }, + "text": { + "text": " **{{trialPeriodHyphen}}** trial instead of ~~{{oldTrialPeriod}}~~" + } + }, + { + "icon": { + "text": "💝" + }, + "text": { + "text": " Get **{{discountPercent}}%** off your Soulmate Sketch" + } + }, + { + "icon": { + "text": "💌" + }, + "text": { + "text": " Includes the **“Finding the One”** Guide" + } + } + ] + } } ] } From 36c45f35218eb6f4206d117deef63469f0e9537d Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Mon, 20 Oct 2025 01:15:37 +0200 Subject: [PATCH 4/4] add screen to funnel --- src/components/funnel/FunnelRuntime.tsx | 43 +++++++++++++++---- .../templates/SpecialOffer/SpecialOffer.tsx | 1 - 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/components/funnel/FunnelRuntime.tsx b/src/components/funnel/FunnelRuntime.tsx index 6640939..defad4c 100644 --- a/src/components/funnel/FunnelRuntime.tsx +++ b/src/components/funnel/FunnelRuntime.tsx @@ -285,23 +285,48 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) { const backTarget: string | undefined = currentScreen.navigation?.onBackScreenId; if (!backTarget) return; + // Флаг для предотвращения множественных срабатываний + let isRedirecting = false; + const pushTrap = () => { try { - window.history.pushState({ __trap: true }, "", window.location.href); + window.history.pushState( + { __trap: true, __screenId: currentScreen.id }, + "", + window.location.href + ); } catch {} }; + // Добавляем trap state в историю pushTrap(); - function isTrapState(state: unknown): state is { __trap?: boolean } { - return typeof state === "object" && state !== null && "__trap" in (state as Record); - } + const handlePopState = () => { + // Проверяем наличие backTarget на момент события + const currentBackTarget = currentScreen.navigation?.onBackScreenId; + if (!currentBackTarget || isRedirecting) return; - const handlePopState = (e: PopStateEvent) => { - if (isTrapState(e.state) && e.state.__trap) { - pushTrap(); - router.replace(`/${funnel.meta.id}/${backTarget}`); - } + isRedirecting = true; + + // Перемещаемся вперед на 1 шаг чтобы отменить переход назад + window.history.go(1); + + // Небольшая задержка для завершения history.go + setTimeout(() => { + try { + // Выполняем редирект на целевой экран + router.replace(`/${funnel.meta.id}/${currentBackTarget}`); + + // Сбрасываем флаг + setTimeout(() => { + isRedirecting = false; + }, 100); + } catch (error) { + // Fallback: если router.replace не сработал, используем нативную навигацию + console.error('Router replace failed, using fallback navigation:', error); + window.location.href = `/${funnel.meta.id}/${currentBackTarget}`; + } + }, 10); }; window.addEventListener("popstate", handlePopState); diff --git a/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx b/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx index 5e3fa29..ab5c1aa 100644 --- a/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx +++ b/src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx @@ -50,7 +50,6 @@ export function SpecialOfferTemplate({ const paywallId = placement?.paywallId || ""; const trialPrice = variant?.trialPrice || 0; const price = variant?.price || 0; - const oldPrice = variant?.price || 0; const oldTrialPeriod = "DAY"; // TODO: from main product const oldTrialInterval = 7; // TODO: from main product const oldTrialPrice = 100; // TODO: from main product