Merge pull request #37 from WIT-LAB-LLC/develop

Develop
This commit is contained in:
pennyteenycat 2025-10-20 01:48:53 +02:00 committed by GitHub
commit 69f8811233
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1538 additions and 215 deletions

View File

@ -2322,6 +2322,12 @@
"cornerRadius": "3xl", "cornerRadius": "3xl",
"showPrivacyTermsConsent": false "showPrivacyTermsConsent": false
}, },
"navigation": {
"rules": [],
"defaultNextScreenId": "specialoffer",
"isEndScreen": true,
"onBackScreenId": "specialoffer"
},
"variants": [], "variants": [],
"headerBlock": { "headerBlock": {
"text": { "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"
}
}
]
}
} }
] ]
} }

View File

@ -13,6 +13,7 @@ export const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
loaders: "Загрузка", loaders: "Загрузка",
soulmate: "Портрет партнера", soulmate: "Портрет партнера",
trialPayment: "Trial Payment", trialPayment: "Trial Payment",
specialOffer: "Special Offer",
}; };
export const OPERATOR_LABELS: Record< export const OPERATOR_LABELS: Record<

View File

@ -143,6 +143,8 @@ export function BuilderSidebar() {
rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [], rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
isEndScreen: isEndScreen:
navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen, navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
onBackScreenId:
navigationUpdates.onBackScreenId ?? screen.navigation?.onBackScreenId,
}, },
}, },
}); });
@ -627,6 +629,32 @@ export function BuilderSidebar() {
</select> </select>
</label> </label>
)} )}
{/* Экран при попытке перехода назад */}
<label className="flex flex-col gap-2 mt-3">
<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.navigation?.onBackScreenId ?? ""}
onChange={(e) =>
updateNavigation(selectedScreen, {
...(selectedScreen.navigation ?? {}),
onBackScreenId: e.target.value || undefined,
})
}
>
<option value=""></option>
{screenOptions
.filter((s) => s.id !== selectedScreen.id)
.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</label>
</Section> </Section>
{selectedScreenIsListType && {selectedScreenIsListType &&

View File

@ -50,6 +50,9 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
isEndScreen: isEndScreen:
navigationUpdates.isEndScreen ?? navigationUpdates.isEndScreen ??
targetScreen.navigation?.isEndScreen, targetScreen.navigation?.isEndScreen,
onBackScreenId:
navigationUpdates.onBackScreenId ??
targetScreen.navigation?.onBackScreenId,
}, },
}, },
}); });
@ -152,6 +155,34 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
</select> </select>
</label> </label>
)} )}
{/* Экран при попытке перехода назад */}
{/* {!screen.navigation?.isEndScreen && ( */}
<label className="flex flex-col gap-2 mt-3">
<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={screen.navigation?.onBackScreenId ?? ""}
onChange={(e) =>
updateNavigation(screen, {
...(screen.navigation ?? {}),
onBackScreenId: e.target.value || undefined,
})
}
>
<option value=""></option>
{screenOptions
.filter((s) => s.id !== screen.id)
.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</label>
{/* )} */}
</Section> </Section>
{selectedScreenIsListType && !screen.navigation?.isEndScreen && ( {selectedScreenIsListType && !screen.navigation?.isEndScreen && (

View File

@ -10,7 +10,8 @@ import {
Loader, Loader,
Heart, Heart,
Mail, Mail,
CreditCard CreditCard,
Gift,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -42,7 +43,8 @@ const TEMPLATE_OPTIONS = [
title: "Форма", title: "Форма",
description: "Ввод текстовых данных в поля", description: "Ввод текстовых данных в поля",
icon: FormInput, 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, template: "email" as const,
@ -61,9 +63,11 @@ const TEMPLATE_OPTIONS = [
{ {
template: "date" as const, template: "date" as const,
title: "Дата рождения", title: "Дата рождения",
description: "Выбор даты (месяц, день, год) + автоматический расчет возраста", description:
"Выбор даты (месяц, день, год) + автоматический расчет возраста",
icon: Calendar, 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, template: "loaders" as const,
@ -84,19 +88,34 @@ const TEMPLATE_OPTIONS = [
title: "Купон", title: "Купон",
description: "Отображение промокода и предложения", description: "Отображение промокода и предложения",
icon: Ticket, 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, template: "trialPayment" as const,
title: "Trial Payment", title: "Trial Payment",
description: "Страница оплаты с пробным периодом", description: "Страница оплаты с пробным периодом",
icon: CreditCard, 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; ] as const;
export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDialogProps) { export function AddScreenDialog({
const [selectedTemplate, setSelectedTemplate] = useState<ScreenDefinition["template"] | null>(null); open,
onOpenChange,
onAddScreen,
}: AddScreenDialogProps) {
const [selectedTemplate, setSelectedTemplate] = useState<
ScreenDefinition["template"] | null
>(null);
const handleAdd = () => { const handleAdd = () => {
if (selectedTemplate) { if (selectedTemplate) {
@ -113,7 +132,7 @@ export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDi
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader> <DialogHeader>
<DialogTitle>Выберите тип экрана</DialogTitle> <DialogTitle>Выберите тип экрана</DialogTitle>
<DialogDescription> <DialogDescription>
@ -130,18 +149,22 @@ export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDi
<button <button
key={option.template} key={option.template}
onClick={() => setSelectedTemplate(option.template)} onClick={() => setSelectedTemplate(option.template)}
className={`flex items-start gap-4 rounded-lg border-2 p-4 text-left transition-all hover:bg-muted/50 ${ className={`cursor-pointer flex items-start gap-4 rounded-lg border-2 p-4 text-left transition-all hover:bg-muted/50 ${
isSelected isSelected
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "border-border hover:border-primary/40" : "border-border hover:border-primary/40"
}`} }`}
> >
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${option.color}`}> <div
className={`flex h-10 w-10 items-center justify-center rounded-lg ${option.color}`}
>
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-medium">{option.title}</h3> <h3 className="font-medium">{option.title}</h3>
<p className="text-sm text-muted-foreground">{option.description}</p> <p className="text-sm text-muted-foreground">
{option.description}
</p>
</div> </div>
</button> </button>
); );

View File

@ -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<SpecialOfferScreenDefinition>) => 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 (
<div className="space-y-3">
<button
type="button"
onClick={handleToggle}
className="flex w-full items-center gap-2 text-left text-sm font-medium text-foreground hover:text-primary transition-colors"
>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
{title}
</button>
{isExpanded && <div className="ml-6 space-y-3">{children}</div>}
</div>
);
}
export function SpecialOfferScreenConfig({ screen, onUpdate }: SpecialOfferScreenConfigProps) {
const specialScreen = screen as SpecialOfferScreenDefinition;
const updateText = (field: keyof NonNullable<SpecialOfferScreenDefinition["text"]>, value: TypographyVariant | undefined) => {
const base = specialScreen.text ?? {};
const next = { ...base, [field]: value } as NonNullable<SpecialOfferScreenDefinition["text"]>;
onUpdate({ text: next });
};
const handleTextChange = (
field: keyof NonNullable<SpecialOfferScreenDefinition["text"]>,
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<SpecialOfferScreenDefinition["text"]>["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<SpecialOfferScreenDefinition["advantages"]>["items"][number];
const next = items.map((it, i) => (i === index ? nextItem : it));
onUpdate({ advantages: { items: next } });
};
return (
<div className="space-y-6">
<CollapsibleSection title="Тексты">
<div className="space-y-3">
<TextAreaInput
label="Заголовок"
rows={2}
className="resize-y"
value={specialScreen.text?.title?.text ?? ""}
onChange={(e) => handleTextChange("title", e.target.value)}
/>
<TextAreaInput
label="Подзаголовок"
rows={2}
className="resize-y"
value={specialScreen.text?.subtitle?.text ?? ""}
onChange={(e) => handleTextChange("subtitle", e.target.value)}
/>
<TextInput
label="Цена trial (подстановка {{trialPrice}})"
value={specialScreen.text?.description?.trialPrice?.text ?? ""}
onChange={(e) => handleDescriptionTextChange("trialPrice", e.target.value)}
/>
<TextAreaInput
label="Описание (текст между ценами)"
rows={2}
className="resize-y"
value={specialScreen.text?.description?.text?.text ?? ""}
onChange={(e) => handleDescriptionTextChange("text", e.target.value)}
/>
<TextInput
label="Старая цена trial (подстановка {{oldTrialPrice}})"
value={specialScreen.text?.description?.oldTrialPrice?.text ?? ""}
onChange={(e) => handleDescriptionTextChange("oldTrialPrice", e.target.value)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="Преимущества" defaultExpanded>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground">Список преимуществ</h4>
<Button type="button" variant="outline" className="h-8 px-2 text-xs" onClick={addAdvantageItem}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3">
{(specialScreen.advantages?.items ?? []).map((item, index) => (
<div key={index} className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase text-muted-foreground">Элемент {index + 1}</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-md border border-border/70 p-1 text-muted-foreground transition hover:bg-muted"
onClick={() => moveAdvantageItem(index, -1)}
disabled={index === 0}
title="Переместить выше"
>
<ArrowUp className="h-4 w-4" />
</button>
<button
type="button"
className="rounded-md border border-border/70 p-1 text-muted-foreground transition hover:bg-muted"
onClick={() => moveAdvantageItem(index, 1)}
disabled={index === (specialScreen.advantages?.items?.length ?? 0) - 1}
title="Переместить ниже"
>
<ArrowDown className="h-4 w-4" />
</button>
<Button
type="button"
variant="ghost"
className="h-8 px-3 text-xs text-destructive"
onClick={() => removeAdvantageItem(index)}
aria-label="Удалить элемент"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-2">
<TextInput
label="Иконка (emoji/текст)"
value={item.icon?.text ?? ""}
onChange={(e) => updateAdvantageItem(index, "icon", e.target.value)}
/>
<TextAreaInput
label="Текст"
rows={2}
className="resize-y"
value={item.text?.text ?? ""}
onChange={(e) => updateAdvantageItem(index, "text", e.target.value)}
/>
</div>
</div>
))}
</div>
{(specialScreen.advantages?.items ?? []).length === 0 && (
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
Пока нет элементов. Добавьте первый, нажав &quot;+&quot;.
</div>
)}
</div>
</CollapsibleSection>
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-xs text-muted-foreground">
<p>
Тексты и преимущества отображаются в шаблоне `SpecialOfferTemplate`. Основная нижняя кнопка
настраивается выше в секции &quot;Нижняя кнопка&quot;.
</p>
</div>
</div>
);
}

View File

@ -30,7 +30,9 @@ import type {
BottomActionButtonDefinition, BottomActionButtonDefinition,
HeaderDefinition, HeaderDefinition,
TrialPaymentScreenDefinition, TrialPaymentScreenDefinition,
SpecialOfferScreenDefinition,
} from "@/lib/funnel/types"; } from "@/lib/funnel/types";
import { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";
const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"]; const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"];
@ -48,10 +50,12 @@ function CollapsibleSection({
children: React.ReactNode; children: React.ReactNode;
defaultExpanded?: boolean; defaultExpanded?: boolean;
}) { }) {
const storageKey = `template-section-${title.toLowerCase().replace(/\s+/g, '-')}`; const storageKey = `template-section-${title
.toLowerCase()
.replace(/\s+/g, "-")}`;
const [isExpanded, setIsExpanded] = useState(() => { const [isExpanded, setIsExpanded] = useState(() => {
if (typeof window === 'undefined') return defaultExpanded; if (typeof window === "undefined") return defaultExpanded;
const stored = sessionStorage.getItem(storageKey); const stored = sessionStorage.getItem(storageKey);
return stored !== null ? JSON.parse(stored) : defaultExpanded; return stored !== null ? JSON.parse(stored) : defaultExpanded;
@ -61,7 +65,7 @@ function CollapsibleSection({
const newExpanded = !isExpanded; const newExpanded = !isExpanded;
setIsExpanded(newExpanded); setIsExpanded(newExpanded);
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
sessionStorage.setItem(storageKey, JSON.stringify(newExpanded)); sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
} }
}; };
@ -88,13 +92,23 @@ function CollapsibleSection({
interface TypographyControlsProps { interface TypographyControlsProps {
label: string; label: string;
value: (TypographyVariant & { show?: boolean }) | undefined; value: (TypographyVariant & { show?: boolean }) | undefined;
onChange: (value: (TypographyVariant & { show?: boolean }) | undefined) => void; onChange: (
value: (TypographyVariant & { show?: boolean }) | undefined
) => void;
allowRemove?: boolean; allowRemove?: boolean;
showToggle?: boolean; // Показывать ли чекбокс "Показывать" showToggle?: boolean; // Показывать ли чекбокс "Показывать"
} }
function TypographyControls({ label, value, onChange, allowRemove = false, showToggle = false }: TypographyControlsProps) { function TypographyControls({
const storageKey = `typography-advanced-${label.toLowerCase().replace(/\s+/g, '-')}`; label,
value,
onChange,
allowRemove = false,
showToggle = false,
}: TypographyControlsProps) {
const storageKey = `typography-advanced-${label
.toLowerCase()
.replace(/\s+/g, "-")}`;
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [isHydrated, setIsHydrated] = 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({ onChange({
...value, ...value,
text: value?.text || "", text: value?.text || "",
@ -162,7 +179,7 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <label className="flex items-center gap-2 text-sm text-muted-foreground">
<input <input
type="checkbox" type="checkbox"
checked={value ? (value.show ?? true) : false} checked={value ? value.show ?? true : false}
onChange={(event) => handleShowToggle(event.target.checked)} onChange={(event) => handleShowToggle(event.target.checked)}
/> />
Показывать {label.toLowerCase()} Показывать {label.toLowerCase()}
@ -177,10 +194,14 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
onBlur={handleTextBlur} onBlur={handleTextBlur}
rows={2} rows={2}
className="resize-y" 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() === "") && ( {!allowRemove && (!value?.text || value.text.trim() === "") && (
<p className="text-xs text-destructive">Это поле обязательно для заполнения</p> <p className="text-xs text-destructive">
Это поле обязательно для заполнения
</p>
)} )}
</div> </div>
@ -191,13 +212,20 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
onClick={() => { onClick={() => {
const newShowAdvanced = !showAdvanced; const newShowAdvanced = !showAdvanced;
setShowAdvanced(newShowAdvanced); setShowAdvanced(newShowAdvanced);
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
sessionStorage.setItem(storageKey, JSON.stringify(newShowAdvanced)); sessionStorage.setItem(
storageKey,
JSON.stringify(newShowAdvanced)
);
} }
}} }}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition" className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition"
> >
{showAdvanced ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} {showAdvanced ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
Настройки оформления Настройки оформления
</button> </button>
@ -223,7 +251,9 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
<select <select
className="rounded border border-border bg-background px-2 py-1" className="rounded border border-border bg-background px-2 py-1"
value={value?.weight ?? ""} value={value?.weight ?? ""}
onChange={(e) => handleAdvancedChange("weight", e.target.value)} onChange={(e) =>
handleAdvancedChange("weight", e.target.value)
}
> >
<option value="">По умолчанию</option> <option value="">По умолчанию</option>
<option value="regular">Regular</option> <option value="regular">Regular</option>
@ -235,13 +265,14 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
</select> </select>
</label> </label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-muted-foreground">Выравнивание</span> <span className="text-muted-foreground">Выравнивание</span>
<select <select
className="rounded border border-border bg-background px-2 py-1" className="rounded border border-border bg-background px-2 py-1"
value={value?.align ?? ""} value={value?.align ?? ""}
onChange={(e) => handleAdvancedChange("align", e.target.value)} onChange={(e) =>
handleAdvancedChange("align", e.target.value)
}
> >
<option value="">По умолчанию</option> <option value="">По умолчанию</option>
<option value="left">Слева</option> <option value="left">Слева</option>
@ -298,7 +329,9 @@ function HeaderControls({ header, onChange }: HeaderControlsProps) {
<input <input
type="checkbox" type="checkbox"
checked={activeHeader.showBackButton !== false} checked={activeHeader.showBackButton !== false}
onChange={(event) => handleToggle("showBackButton", event.target.checked)} onChange={(event) =>
handleToggle("showBackButton", event.target.checked)
}
/> />
Показывать кнопку «Назад» Показывать кнопку «Назад»
</label> </label>
@ -314,10 +347,14 @@ interface ActionButtonControlsProps {
onChange: (value: BottomActionButtonDefinition | undefined) => void; onChange: (value: BottomActionButtonDefinition | undefined) => void;
} }
function ActionButtonControls({ label, value, onChange }: ActionButtonControlsProps) { function ActionButtonControls({
label,
value,
onChange,
}: ActionButtonControlsProps) {
// По умолчанию кнопка включена (show !== false) // По умолчанию кнопка включена (show !== false)
const isEnabled = value?.show !== false; const isEnabled = value?.show !== false;
const buttonText = value?.text || ''; const buttonText = value?.text || "";
const cornerRadius = value?.cornerRadius; const cornerRadius = value?.cornerRadius;
const showPrivacyTermsConsent = value?.showPrivacyTermsConsent ?? false; const showPrivacyTermsConsent = value?.showPrivacyTermsConsent ?? false;
@ -365,7 +402,12 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
}; };
// Убираем undefined поля для чистоты // Убираем undefined поля для чистоты
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) { if (
!newValue.text &&
!newValue.cornerRadius &&
newValue.show !== false &&
!newValue.showPrivacyTermsConsent
) {
onChange(undefined); onChange(undefined);
} else { } else {
onChange(newValue); onChange(newValue);
@ -381,7 +423,12 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
}; };
// Убираем undefined поля для чистоты // Убираем undefined поля для чистоты
if (!newValue.text && !newValue.cornerRadius && newValue.show !== false && !newValue.showPrivacyTermsConsent) { if (
!newValue.text &&
!newValue.cornerRadius &&
newValue.show !== false &&
!newValue.showPrivacyTermsConsent
) {
onChange(undefined); onChange(undefined);
} else { } else {
onChange(newValue); onChange(newValue);
@ -402,7 +449,9 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
{isEnabled && ( {isEnabled && (
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs"> <div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs">
<label className="flex flex-col gap-1 text-sm"> <label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-muted-foreground">Текст кнопки</span> <span className="font-medium text-muted-foreground">
Текст кнопки
</span>
<TextInput <TextInput
value={buttonText} value={buttonText}
onChange={(event) => handleTextChange(event.target.value)} onChange={(event) => handleTextChange(event.target.value)}
@ -411,7 +460,9 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
</label> </label>
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Скругление</span> <span className="font-medium text-muted-foreground">
Скругление
</span>
<select <select
className="rounded-lg border border-border bg-background px-2 py-1" className="rounded-lg border border-border bg-background px-2 py-1"
value={cornerRadius ?? ""} value={cornerRadius ?? ""}
@ -430,7 +481,9 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
<input <input
type="checkbox" type="checkbox"
checked={showPrivacyTermsConsent} checked={showPrivacyTermsConsent}
onChange={(event) => handlePrivacyTermsToggle(event.target.checked)} onChange={(event) =>
handlePrivacyTermsToggle(event.target.checked)
}
/> />
Показывать PrivacyTermsConsent под кнопкой Показывать PrivacyTermsConsent под кнопкой
</label> </label>
@ -463,7 +516,9 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
onUpdate({ header: value }); onUpdate({ header: value });
}; };
const handleButtonChange = (value: BottomActionButtonDefinition | undefined) => { const handleButtonChange = (
value: BottomActionButtonDefinition | undefined
) => {
onUpdate({ bottomActionButton: value }); onUpdate({ bottomActionButton: value });
}; };
@ -490,61 +545,93 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection title="Нижняя кнопка"> <CollapsibleSection title="Нижняя кнопка">
<ActionButtonControls label="Показывать основную кнопку" value={screen.bottomActionButton} onChange={handleButtonChange} /> <ActionButtonControls
label="Показывать основную кнопку"
value={screen.bottomActionButton}
onChange={handleButtonChange}
/>
</CollapsibleSection> </CollapsibleSection>
{template === "info" && ( {template === "info" && (
<InfoScreenConfig <InfoScreenConfig
screen={screen as BuilderScreen & { template: "info" }} screen={screen as BuilderScreen & { template: "info" }}
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void} onUpdate={
onUpdate as (updates: Partial<InfoScreenDefinition>) => void
}
/> />
)} )}
{template === "date" && ( {template === "date" && (
<DateScreenConfig <DateScreenConfig
screen={screen as BuilderScreen & { template: "date" }} screen={screen as BuilderScreen & { template: "date" }}
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void} onUpdate={
onUpdate as (updates: Partial<DateScreenDefinition>) => void
}
/> />
)} )}
{template === "coupon" && ( {template === "coupon" && (
<CouponScreenConfig <CouponScreenConfig
screen={screen as BuilderScreen & { template: "coupon" }} screen={screen as BuilderScreen & { template: "coupon" }}
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void} onUpdate={
onUpdate as (updates: Partial<CouponScreenDefinition>) => void
}
/> />
)} )}
{template === "form" && ( {template === "form" && (
<FormScreenConfig <FormScreenConfig
screen={screen as BuilderScreen & { template: "form" }} screen={screen as BuilderScreen & { template: "form" }}
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void} onUpdate={
onUpdate as (updates: Partial<FormScreenDefinition>) => void
}
/> />
)} )}
{template === "list" && ( {template === "list" && (
<ListScreenConfig <ListScreenConfig
screen={screen as BuilderScreen & { template: "list" }} screen={screen as BuilderScreen & { template: "list" }}
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void} onUpdate={
onUpdate as (updates: Partial<ListScreenDefinition>) => void
}
/> />
)} )}
{template === "email" && ( {template === "email" && (
<EmailScreenConfig <EmailScreenConfig
screen={screen as BuilderScreen & { template: "email" }} screen={screen as BuilderScreen & { template: "email" }}
onUpdate={onUpdate as (updates: Partial<EmailScreenDefinition>) => void} onUpdate={
onUpdate as (updates: Partial<EmailScreenDefinition>) => void
}
/> />
)} )}
{template === "loaders" && ( {template === "loaders" && (
<LoadersScreenConfig <LoadersScreenConfig
screen={screen as BuilderScreen & { template: "loaders" }} screen={screen as BuilderScreen & { template: "loaders" }}
onUpdate={onUpdate as (updates: Partial<LoadersScreenDefinition>) => void} onUpdate={
onUpdate as (updates: Partial<LoadersScreenDefinition>) => void
}
/> />
)} )}
{template === "soulmate" && ( {template === "soulmate" && (
<SoulmatePortraitScreenConfig <SoulmatePortraitScreenConfig
screen={screen as BuilderScreen & { template: "soulmate" }} screen={screen as BuilderScreen & { template: "soulmate" }}
onUpdate={onUpdate as (updates: Partial<SoulmatePortraitScreenDefinition>) => void} onUpdate={
onUpdate as (
updates: Partial<SoulmatePortraitScreenDefinition>
) => void
}
/> />
)} )}
{template === "trialPayment" && ( {template === "trialPayment" && (
<TrialPaymentScreenConfig <TrialPaymentScreenConfig
screen={screen as BuilderScreen & { template: "trialPayment" }} screen={screen as BuilderScreen & { template: "trialPayment" }}
onUpdate={onUpdate as (updates: Partial<TrialPaymentScreenDefinition>) => void} onUpdate={
onUpdate as (updates: Partial<TrialPaymentScreenDefinition>) => void
}
/>
)}
{template === "specialOffer" && (
<SpecialOfferScreenConfig
screen={screen as BuilderScreen & { template: "specialOffer" }}
onUpdate={
onUpdate as (updates: Partial<SpecialOfferScreenDefinition>) => void
}
/> />
)} )}
</div> </div>

View File

@ -5,3 +5,4 @@ export { FormScreenConfig } from "./FormScreenConfig";
export { ListScreenConfig } from "./ListScreenConfig"; export { ListScreenConfig } from "./ListScreenConfig";
export { TemplateConfig } from "./TemplateConfig"; export { TemplateConfig } from "./TemplateConfig";
export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig"; export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
export { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";

View File

@ -257,6 +257,14 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
}; };
const onBack = () => { const onBack = () => {
const backTarget: string | undefined = currentScreen.navigation?.onBackScreenId;
if (backTarget) {
// Переназначаем назад на конкретный экран без роста истории
router.replace(`/${funnel.meta.id}/${backTarget}`);
return;
}
const currentIndex = historyWithCurrent.lastIndexOf(currentScreen.id); const currentIndex = historyWithCurrent.lastIndexOf(currentScreen.id);
if (currentIndex > 0) { if (currentIndex > 0) {
@ -272,6 +280,61 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
router.back(); router.back();
}; };
// Перехват аппаратной/браузерной кнопки Назад, когда настроен onBackScreenId
useEffect(() => {
const backTarget: string | undefined = currentScreen.navigation?.onBackScreenId;
if (!backTarget) return;
// Флаг для предотвращения множественных срабатываний
let isRedirecting = false;
const pushTrap = () => {
try {
window.history.pushState(
{ __trap: true, __screenId: currentScreen.id },
"",
window.location.href
);
} catch {}
};
// Добавляем trap state в историю
pushTrap();
const handlePopState = () => {
// Проверяем наличие backTarget на момент события
const currentBackTarget = currentScreen.navigation?.onBackScreenId;
if (!currentBackTarget || isRedirecting) return;
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);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, [currentScreen.id, currentScreen.navigation?.onBackScreenId, funnel.meta.id, router]);
const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0; const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0;
return renderScreen({ return renderScreen({

View File

@ -0,0 +1,108 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { SpecialOfferTemplate } from "./SpecialOffer";
import { fn } from "storybook/test";
import type { SpecialOfferScreenDefinition } from "@/lib/funnel/types";
import { buildSpecialOfferDefaults } from "@/lib/admin/builder/state/defaults/specialOffer";
const defaultScreen = buildSpecialOfferDefaults(
"special-offer-screen-story"
) as SpecialOfferScreenDefinition;
/** SpecialOfferTemplate - экраны со специальным предложением */
const meta: Meta<typeof SpecialOfferTemplate> = {
title: "Funnel Templates/SpecialOfferTemplate",
component: SpecialOfferTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 8, total: 10 },
defaultTexts: {
nextButton: "GET 14-DAY TRIAL",
},
},
argTypes: {
screen: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onContinue: { action: "continue" },
onBack: { action: "back" },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
// /** Купон с показом прогресса */
// export const WithProgress: Story = {
// args: {
// screen: {
// ...defaultScreen,
// header: {
// show: true,
// showBackButton: true,
// showProgress: true, // Показываем прогресс
// },
// },
// },
// };
// /** Экран без header */
// export const WithoutHeader: Story = {
// args: {
// screen: {
// ...defaultScreen,
// header: {
// show: false,
// },
// },
// },
// };
// /** Купон с другой скидкой */
// export const CustomDiscount: Story = {
// args: {
// screen: {
// ...defaultScreen,
// coupon: {
// ...defaultScreen.coupon,
// offer: {
// title: {
// text: "50% OFF",
// font: "manrope",
// weight: "bold",
// align: "center",
// size: "3xl",
// color: "primary",
// },
// description: {
// text: "Скидка на первую покупку",
// font: "inter",
// weight: "medium",
// color: "muted",
// align: "center",
// size: "md",
// },
// },
// promoCode: {
// text: "FIRST50",
// font: "geistMono",
// weight: "bold",
// align: "center",
// size: "lg",
// color: "accent",
// },
// },
// },
// },
// };

View File

@ -0,0 +1,303 @@
"use client";
import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers";
import type {
DefaultTexts,
FunnelDefinition,
SpecialOfferScreenDefinition,
} from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement";
import { Spinner } from "@/components/ui/spinner";
import { Currency } from "@/shared/types";
import { useClientToken } from "@/hooks/auth/useClientToken";
import { getFormattedPrice } from "@/shared/utils/price";
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
import { useState } from "react";
import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
interface SpecialOfferProps {
funnel: FunnelDefinition;
screen: SpecialOfferScreenDefinition;
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: DefaultTexts;
}
export function SpecialOfferTemplate({
funnel,
screen,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}: SpecialOfferProps) {
const token = useClientToken();
const paymentId = "main_secret_discount";
const { placement, isLoading } = usePaymentPlacement({ funnel, paymentId });
const [isLoadingRedirect, setIsLoadingRedirect] = useState(false);
const trialInterval = placement?.trialInterval || 7;
const trialPeriod = placement?.trialPeriod;
const variant = placement?.variants?.[0];
const productId = variant?.id || "";
const placementId = placement?.placementId || "";
const paywallId = placement?.paywallId || "";
const trialPrice = variant?.trialPrice || 0;
const price = 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;
const paymentUrl = placement?.paymentUrl || "";
const formattedTrialPrice = getFormattedPrice(trialPrice, currency);
const formattedBillingPrice = getFormattedPrice(price, currency);
const trialPeriodText = formatPeriod(trialPeriod, trialInterval);
const billingPeriodText = formatPeriod(billingPeriod, billingInterval);
const trialPeriodHyphenText = formatPeriodHyphen(trialPeriod, trialInterval);
const oldTrialPeriodText = formatPeriod(oldTrialPeriod, oldTrialInterval);
const handlePayClick = () => {
if (isLoadingRedirect) {
return;
}
setIsLoadingRedirect(true);
const redirectUrl = `${paymentUrl}?paywallId=${paywallId}&placementId=${placementId}&productId=${productId}&jwtToken=${token}&price=${(
(trialPrice || 100) / 100
).toFixed(2)}&currency=${currency}&${getTrackingCookiesForRedirect()}`;
return window.location.replace(redirectUrl);
};
const computeDiscountPercent = () => {
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);
};
const replacePlaceholders = (text: string | undefined) => {
if (!text) return "";
const values: Record<string, string> = {
trialPrice: formattedTrialPrice,
billingPrice: formattedBillingPrice,
oldTrialPrice: getFormattedPrice(oldTrialPrice, currency),
discountPercent: computeDiscountPercent() ?? "",
trialPeriod: trialPeriodText,
billingPeriod: billingPeriodText,
trialPeriodHyphen: trialPeriodHyphenText,
oldTrialPeriod: oldTrialPeriodText,
};
let result = text;
for (const [key, value] of Object.entries(values)) {
result = result.replaceAll(`{{${key}}}`, value);
}
return result;
};
const textProps = {
title: buildTypographyProps<"h2">(
{
...screen.text.title,
text: replacePlaceholders(screen.text.title?.text),
},
{
as: "h2",
defaults: {
font: "inter",
weight: "bold",
size: "xl",
align: "center",
className: "mt-2.5 text-[#FF0707] leading-7",
},
}
),
subtitle: buildTypographyProps<"h3">(
{
...screen.text.subtitle,
text: replacePlaceholders(screen.text.subtitle?.text),
},
{
as: "h3",
defaults: {
font: "inter",
weight: "black",
align: "center",
className: "mt-[25px] text-[#1F2937] text-[36px]",
},
}
),
description: {
trialPrice: buildTypographyProps<"span">(
{
...screen.text.description,
text: replacePlaceholders(screen.text.description?.trialPrice?.text),
},
{
as: "span",
defaults: {
font: "inter",
weight: "extraBold",
align: "center",
className: "text-[26px] text-[#2A6AEE] inline-block",
},
}
),
text: buildTypographyProps<"p">(
{
...screen.text.description,
text: replacePlaceholders(screen.text.description?.text?.text),
},
{
as: "p",
defaults: {
font: "inter",
weight: "medium",
align: "center",
className: "mt-[11px] text-[22px] inline-block",
},
}
),
oldTrialPrice: buildTypographyProps<"span">(
{
...screen.text.description,
text: replacePlaceholders(
screen.text.description?.oldTrialPrice?.text
),
},
{
as: "span",
defaults: {
font: "inter",
weight: "medium",
align: "center",
className: "text-[22px] inline-block",
},
}
),
},
};
const advantagesProps = {
items: screen.advantages?.items.map((item) => {
return {
icon: buildTypographyProps<"span">(item.icon, {
as: "span",
defaults: {
font: "inter",
weight: "medium",
size: "md",
className: "text-[26px] leading-[39px] inline-block mr-1",
},
}),
text: buildTypographyProps<"p">(
{
...item.text,
text: replacePlaceholders(item.text?.text),
},
{
as: "p",
defaults: {
font: "inter",
weight: "medium",
size: "md",
className: "text-[17px] leading-[39px] inline-block",
},
}
),
};
}),
};
const layoutProps = createTemplateLayoutProps(
{
...screen,
header: {
...screen.header,
showProgress: false,
},
},
{ canGoBack, onBack },
screenProgress,
{
preset: "left",
actionButton: {
defaultText: replacePlaceholders(
defaultTexts?.nextButton || "GET {{trialPeriodHyphen}} TRIAL"
),
children: isLoadingRedirect ? (
<Spinner className="size-6" />
) : (
replacePlaceholders(
screen.bottomActionButton?.text ||
defaultTexts?.nextButton ||
"GET {{trialPeriodHyphen}} TRIAL"
)
),
disabled: false,
onClick: handlePayClick,
},
}
);
if (isLoading || !placement || !token) {
return (
<div className="w-full min-h-dvh max-w-[560px] mx-auto flex items-center justify-center">
<Spinner className="size-8" />
</div>
);
}
return (
<TemplateLayout
{...layoutProps}
contentProps={{ className: "pt-0" }}
childrenWrapperProps={{ className: "-mt-1" }}
>
<div className="w-full flex flex-col items-center justify-center">
<svg
width="104"
height="105"
viewBox="0 0 104 105"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.6953 14.725L45.7641 26.75H45.5H30.875C26.3859 26.75 22.75 23.1141 22.75 18.625C22.75 14.1359 26.3859 10.5 30.875 10.5H31.3219C34.3484 10.5 37.1719 12.1047 38.6953 14.725ZM13 18.625C13 21.55 13.7109 24.3125 14.95 26.75H6.5C2.90469 26.75 0 29.6547 0 33.25V46.25C0 49.8453 2.90469 52.75 6.5 52.75H97.5C101.095 52.75 104 49.8453 104 46.25V33.25C104 29.6547 101.095 26.75 97.5 26.75H89.05C90.2891 24.3125 91 21.55 91 18.625C91 8.75313 82.9969 0.75 73.125 0.75H72.6781C66.1984 0.75 60.1859 4.18281 56.8953 9.76875L52 18.1172L47.1047 9.78906C43.8141 4.18281 37.8016 0.75 31.3219 0.75H30.875C21.0031 0.75 13 8.75313 13 18.625ZM81.25 18.625C81.25 23.1141 77.6141 26.75 73.125 26.75H58.5H58.2359L65.3047 14.725C66.8484 12.1047 69.6516 10.5 72.6781 10.5H73.125C77.6141 10.5 81.25 14.1359 81.25 18.625ZM6.5 59.25V95C6.5 100.383 10.8672 104.75 16.25 104.75H45.5V59.25H6.5ZM58.5 104.75H87.75C93.1328 104.75 97.5 100.383 97.5 95V59.25H58.5V104.75Z"
fill="#FF0000"
/>
</svg>
{textProps.title && <Typography {...textProps.title} />}
{textProps.subtitle && <Typography {...textProps.subtitle} />}
{textProps.description && (
<Typography {...textProps.description.text}>
{textProps.description.trialPrice && (
<Typography {...textProps.description.trialPrice} />
)}
{textProps.description.text?.children}
{textProps.description.oldTrialPrice && (
<Typography {...textProps.description.oldTrialPrice} />
)}
</Typography>
)}
{advantagesProps.items && (
<ul className="mt-[25px] flex flex-col gap-[11px]">
{advantagesProps.items.map((item, index) => (
<Typography key={index} as="li" enableMarkup>
{item.icon && <Typography as="span" {...item.icon} />}
{item.text && <Typography {...item.text} />}
</Typography>
))}
</ul>
)}
</div>
</TemplateLayout>
);
}

View File

@ -0,0 +1 @@
export { SpecialOfferTemplate } from "./SpecialOffer";

View File

@ -38,38 +38,8 @@ import { Spinner } from "@/components/ui/spinner";
import { Currency } from "@/shared/types"; import { Currency } from "@/shared/types";
import { getFormattedPrice } from "@/shared/utils/price"; import { getFormattedPrice } from "@/shared/utils/price";
import { useClientToken } from "@/hooks/auth/useClientToken"; import { useClientToken } from "@/hooks/auth/useClientToken";
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
function getTrackingCookiesForRedirect() { import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
const cookieObj = Object.fromEntries(
document.cookie.split("; ").map((c) => c.split("="))
);
const result = Object.entries(cookieObj).filter(([key]) => {
return (
[
"_fbc",
"_fbp",
"_ym_uid",
"_ym_d",
"_ym_isad",
"_ym_visorc",
"yandexuid",
"ymex",
].includes(key) ||
key.startsWith("_ga") ||
key.startsWith("_gid")
);
});
const queryString = result
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
)
.join("&");
return queryString;
}
interface TrialPaymentTemplateProps { interface TrialPaymentTemplateProps {
funnel: FunnelDefinition; funnel: FunnelDefinition;
@ -133,34 +103,11 @@ export function TrialPaymentTemplate({
} }
}; };
const formatPeriod = (
period: "DAY" | "WEEK" | "MONTH" | "YEAR" | undefined,
interval: number
) => {
if (!period) return `${interval} days`;
const unit =
period === "DAY"
? interval === 1
? "day"
: "days"
: period === "WEEK"
? interval === 1
? "week"
: "weeks"
: period === "MONTH"
? interval === 1
? "month"
: "months"
: interval === 1
? "year"
: "years";
return `${interval} ${unit}`;
};
const formattedTrialPrice = getFormattedPrice(trialPrice, currency); const formattedTrialPrice = getFormattedPrice(trialPrice, currency);
const formattedBillingPrice = getFormattedPrice(price, currency); const formattedBillingPrice = getFormattedPrice(price, currency);
const trialPeriodText = formatPeriod(trialPeriod, trialInterval); const trialPeriodText = formatPeriod(trialPeriod, trialInterval);
const billingPeriodText = formatPeriod(billingPeriod, billingInterval); const billingPeriodText = formatPeriod(billingPeriod, billingInterval);
const trialPeriodHyphenText = formatPeriodHyphen(trialPeriod, trialInterval);
const computeDiscountPercent = () => { const computeDiscountPercent = () => {
if (!oldPrice || !trialPrice || oldPrice <= 0) return undefined; if (!oldPrice || !trialPrice || oldPrice <= 0) return undefined;
@ -169,32 +116,6 @@ export function TrialPaymentTemplate({
return String(percent); return String(percent);
}; };
const formatPeriodHyphen = (
period: "DAY" | "WEEK" | "MONTH" | "YEAR" | undefined,
interval: number
) => {
if (!period) return `${interval}-day`;
const unit =
period === "DAY"
? interval === 1
? "day"
: "days"
: period === "WEEK"
? interval === 1
? "week"
: "weeks"
: period === "MONTH"
? interval === 1
? "month"
: "months"
: interval === 1
? "year"
: "years";
return `${interval}-${unit}`;
};
const trialPeriodHyphenText = formatPeriodHyphen(trialPeriod, trialInterval);
const replacePlaceholders = (text: string | undefined) => { const replacePlaceholders = (text: string | undefined) => {
if (!text) return ""; if (!text) return "";
const values: Record<string, string> = { const values: Record<string, string> = {
@ -700,7 +621,9 @@ export function TrialPaymentTemplate({
> >
Privacy Policy Privacy Policy
</a> </a>
. You also acknowledge that your 1-week introductory plan to Wit Lab LLC, billed at $1.00, will automatically renew at $14.99 every 1 week unless canceled before the end of the trial period. . You also acknowledge that your 1-week introductory plan to Wit
Lab LLC, billed at $1.00, will automatically renew at $14.99 every
1 week unless canceled before the end of the trial period.
</Policy> </Policy>
</div> </div>
)} )}
@ -958,7 +881,11 @@ export function TrialPaymentTemplate({
// Open contact link from footer config // Open contact link from footer config
const contactUrl = screen.footer?.contacts?.email?.href; const contactUrl = screen.footer?.contacts?.email?.href;
if (contactUrl) { if (contactUrl) {
window.open(contactUrl, "_blank", "noopener,noreferrer"); window.open(
contactUrl,
"_blank",
"noopener,noreferrer"
);
} }
}, },
} }

View File

@ -8,6 +8,7 @@ export { CouponTemplate } from "./CouponTemplate";
export { LoadersTemplate } from "./LoadersTemplate"; export { LoadersTemplate } from "./LoadersTemplate";
export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate"; export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate";
export { TrialPaymentTemplate } from "./TrialPaymentTemplate/index"; export { TrialPaymentTemplate } from "./TrialPaymentTemplate/index";
export { SpecialOfferTemplate } from "./SpecialOffer/index";
// Layout Templates // Layout Templates
export { TemplateLayout } from "./layouts/TemplateLayout"; export { TemplateLayout } from "./layouts/TemplateLayout";

View File

@ -7,10 +7,11 @@ interface MarkupTextProps {
className?: string; className?: string;
as?: keyof React.JSX.IntrinsicElements; as?: keyof React.JSX.IntrinsicElements;
boldClassName?: string; boldClassName?: string;
strikeClassName?: string;
} }
/** /**
* Компонент для рендеринга текста с разметкой **bold** * Компонент для рендеринга текста с разметкой **bold** и ~~strike~~
* *
* Примеры использования: * Примеры использования:
* <MarkupText>Добро пожаловать в **WitLab**!</MarkupText> * <MarkupText>Добро пожаловать в **WitLab**!</MarkupText>
@ -21,7 +22,8 @@ export function MarkupText({
children, children,
className, className,
as: Component = "span", as: Component = "span",
boldClassName = "font-bold" boldClassName = "font-bold",
strikeClassName = "line-through"
}: MarkupTextProps) { }: MarkupTextProps) {
// Если текста нет, возвращаем пустой элемент // Если текста нет, возвращаем пустой элемент
if (!children || typeof children !== 'string') { if (!children || typeof children !== 'string') {
@ -50,6 +52,16 @@ export function MarkupText({
segment.content segment.content
); );
} }
if (segment.type === 'strike') {
return React.createElement(
'span',
{
key: index,
className: cn(strikeClassName)
},
segment.content
);
}
return React.createElement( return React.createElement(
React.Fragment, React.Fragment,
@ -83,7 +95,7 @@ export function MarkupPreview({ text }: { text: string }) {
</div> </div>
{hasTextMarkup(text) && ( {hasTextMarkup(text) && (
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded p-2"> <div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded p-2">
💡 <strong>Разметка обнаружена:</strong> Текст в **двойных звездочках** будет выделен жирным шрифтом. 💡 <strong>Разметка обнаружена:</strong> Текст в **двойных звездочках** жирный, в ~~двойных тильдах~~ зачёркнутый.
</div> </div>
)} )}
</div> </div>

View File

@ -19,7 +19,9 @@ import type {
/** /**
* Creates default header configuration * Creates default header configuration
*/ */
export function buildDefaultHeader(overrides?: Partial<HeaderDefinition>): HeaderDefinition { export function buildDefaultHeader(
overrides?: Partial<HeaderDefinition>
): HeaderDefinition {
return { return {
show: true, show: true,
showBackButton: true, showBackButton: true,
@ -31,7 +33,9 @@ export function buildDefaultHeader(overrides?: Partial<HeaderDefinition>): Heade
/** /**
* Creates default title configuration * Creates default title configuration
*/ */
export function buildDefaultTitle(overrides?: Partial<TitleDefinition>): TitleDefinition { export function buildDefaultTitle(
overrides?: Partial<TitleDefinition>
): TitleDefinition {
return { return {
show: true, show: true,
text: "Новый экран", text: "Новый экран",
@ -47,7 +51,9 @@ export function buildDefaultTitle(overrides?: Partial<TitleDefinition>): TitleDe
/** /**
* Creates default subtitle configuration * Creates default subtitle configuration
*/ */
export function buildDefaultSubtitle(overrides?: Partial<SubtitleDefinition>): SubtitleDefinition { export function buildDefaultSubtitle(
overrides?: Partial<SubtitleDefinition>
): SubtitleDefinition {
return { return {
show: true, show: true,
text: "Добавьте детали справа", text: "Добавьте детали справа",
@ -63,7 +69,9 @@ export function buildDefaultSubtitle(overrides?: Partial<SubtitleDefinition>): S
/** /**
* Creates default bottom action button configuration * Creates default bottom action button configuration
*/ */
export function buildDefaultBottomActionButton(overrides?: Partial<BottomActionButtonDefinition>): BottomActionButtonDefinition { export function buildDefaultBottomActionButton(
overrides?: Partial<BottomActionButtonDefinition>
): BottomActionButtonDefinition {
return { return {
show: true, show: true,
showGradientBlur: true, showGradientBlur: true,
@ -74,7 +82,9 @@ export function buildDefaultBottomActionButton(overrides?: Partial<BottomActionB
/** /**
* Creates default navigation configuration * Creates default navigation configuration
*/ */
export function buildDefaultNavigation(overrides?: Partial<NavigationDefinition>): NavigationDefinition { export function buildDefaultNavigation(
overrides?: Partial<NavigationDefinition>
): NavigationDefinition {
return { return {
defaultNextScreenId: undefined, defaultNextScreenId: undefined,
rules: [], rules: [],
@ -85,7 +95,9 @@ export function buildDefaultNavigation(overrides?: Partial<NavigationDefinition>
/** /**
* Creates default description configuration * Creates default description configuration
*/ */
export function buildDefaultDescription(overrides?: Partial<TypographyVariant>): TypographyVariant { export function buildDefaultDescription(
overrides?: Partial<TypographyVariant>
): TypographyVariant {
return { return {
text: "Добавьте описание для экрана", text: "Добавьте описание для экрана",
font: "manrope", font: "manrope",
@ -100,7 +112,9 @@ export function buildDefaultDescription(overrides?: Partial<TypographyVariant>):
/** /**
* Creates default icon configuration * Creates default icon configuration
*/ */
export function buildDefaultIcon(overrides?: Partial<IconDefinition>): IconDefinition { export function buildDefaultIcon(
overrides?: Partial<IconDefinition>
): IconDefinition {
return { return {
type: "emoji", type: "emoji",
value: "", value: "",
@ -112,7 +126,9 @@ export function buildDefaultIcon(overrides?: Partial<IconDefinition>): IconDefin
/** /**
* Creates default date input configuration * Creates default date input configuration
*/ */
export function buildDefaultDateInput(overrides?: Partial<DateInputDefinition>): DateInputDefinition { export function buildDefaultDateInput(
overrides?: Partial<DateInputDefinition>
): DateInputDefinition {
return { return {
monthLabel: "Месяц", monthLabel: "Месяц",
dayLabel: "День", dayLabel: "День",
@ -127,11 +143,12 @@ export function buildDefaultDateInput(overrides?: Partial<DateInputDefinition>):
}; };
} }
/** /**
* Creates default coupon configuration * Creates default coupon configuration
*/ */
export function buildDefaultCoupon(overrides?: Partial<CouponDefinition>): CouponDefinition { export function buildDefaultCoupon(
overrides?: Partial<CouponDefinition>
): CouponDefinition {
return { return {
title: { title: {
text: "Специальное предложение", text: "Специальное предложение",
@ -197,7 +214,9 @@ export function buildDefaultFormFields(): FormFieldDefinition[] {
/** /**
* Creates default form validation messages * Creates default form validation messages
*/ */
export function buildDefaultFormValidation(overrides?: Partial<FormValidationMessages>): FormValidationMessages { export function buildDefaultFormValidation(
overrides?: Partial<FormValidationMessages>
): FormValidationMessages {
return { return {
required: "Это поле обязательно для заполнения", required: "Это поле обязательно для заполнения",
...overrides, ...overrides,
@ -207,7 +226,9 @@ export function buildDefaultFormValidation(overrides?: Partial<FormValidationMes
/** /**
* Creates default progressbars configuration with sample data from Loaders.stories.tsx * Creates default progressbars configuration with sample data from Loaders.stories.tsx
*/ */
export function buildDefaultProgressbars(overrides?: Partial<ProgressbarDefinition>): ProgressbarDefinition { export function buildDefaultProgressbars(
overrides?: Partial<ProgressbarDefinition>
): ProgressbarDefinition {
return { return {
items: [ items: [
{ {
@ -244,7 +265,9 @@ export function buildDefaultCopiedMessage(): string {
/** /**
* Creates default image configuration * Creates default image configuration
*/ */
export function buildDefaultImage(overrides?: { src?: string }): { src: string } { export function buildDefaultImage(overrides?: { src?: string }): {
src: string;
} {
return { return {
src: "/female-portrait.jpg", src: "/female-portrait.jpg",
...overrides, ...overrides,
@ -254,10 +277,80 @@ export function buildDefaultImage(overrides?: { src?: string }): { src: string }
/** /**
* Creates default email input configuration * Creates default email input configuration
*/ */
export function buildDefaultEmailInput(overrides?: { label?: string; placeholder?: string }): { label: string; placeholder: string } { export function buildDefaultEmailInput(overrides?: {
label?: string;
placeholder?: string;
}): { label: string; placeholder: string } {
return { return {
label: "Email адрес", label: "Email адрес",
placeholder: "example@email.com", placeholder: "example@email.com",
...overrides, ...overrides,
}; };
} }
/**
* Creates default special offer page text configuration
*/
export function buildDefaultSpecialOfferText(overrides?: {
title?: TypographyVariant;
subtitle?: TypographyVariant;
description?: {
trialPrice?: TypographyVariant;
text?: TypographyVariant;
oldTrialPrice?: TypographyVariant;
};
}): {
title: TypographyVariant;
subtitle: TypographyVariant;
description: {
trialPrice?: TypographyVariant;
text?: TypographyVariant;
oldTrialPrice?: TypographyVariant;
};
} {
return {
title: { text: "Special Offer" },
subtitle: { text: "SAVE {{discountPercent}}% OFF!" },
description: {
trialPrice: { text: "{{trialPrice}}" },
text: { text: " instead of " },
oldTrialPrice: { text: "~~{{oldTrialPrice}}~~" },
},
...overrides,
};
}
/**
* Creates default special offer page advantages configuration
*/
export function buildDefaultSpecialOfferAdvantages(overrides?: {
items?: Array<{ icon: TypographyVariant; text: TypographyVariant }>;
}): { items: Array<{ icon: TypographyVariant; text: TypographyVariant }> } {
return {
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" },
},
],
...overrides,
};
}

View File

@ -30,3 +30,4 @@ export { buildEmailDefaults } from "./email";
export { buildLoadersDefaults } from "./loaders"; export { buildLoadersDefaults } from "./loaders";
export { buildSoulmateDefaults } from "./soulmate"; export { buildSoulmateDefaults } from "./soulmate";
export { buildTrialPaymentDefaults } from "./trialPayment"; export { buildTrialPaymentDefaults } from "./trialPayment";
export { buildSpecialOfferDefaults } from "./specialOffer";

View File

@ -0,0 +1,32 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
import {
buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultSpecialOfferText,
buildDefaultSpecialOfferAdvantages,
} from "./blocks";
export function buildSpecialOfferDefaults(id: string): BuilderScreen {
return {
id,
template: "specialOffer",
header: buildDefaultHeader({
showProgress: false,
}),
title: buildDefaultTitle({
show: false,
}),
subtitle: buildDefaultSubtitle({
show: false,
}),
text: buildDefaultSpecialOfferText(),
advantages: buildDefaultSpecialOfferAdvantages(),
bottomActionButton: buildDefaultBottomActionButton({
text: "GET {{trialPeriod}} TRIAL",
}),
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}

View File

@ -370,6 +370,9 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
defaultNextScreenId: navigation.defaultNextScreenId ?? undefined, defaultNextScreenId: navigation.defaultNextScreenId ?? undefined,
rules: navigation.rules ?? [], rules: navigation.rules ?? [],
isEndScreen: navigation.isEndScreen, isEndScreen: navigation.isEndScreen,
...("onBackScreenId" in navigation
? { onBackScreenId: navigation.onBackScreenId ?? undefined }
: {}),
}, },
} }
: screen : screen

View File

@ -26,6 +26,7 @@ export type BuilderAction =
defaultNextScreenId?: string | null; defaultNextScreenId?: string | null;
rules?: NavigationRuleDefinition[]; rules?: NavigationRuleDefinition[];
isEndScreen?: boolean; isEndScreen?: boolean;
onBackScreenId?: string | null;
}; };
}; };
} }

View File

@ -10,11 +10,15 @@ import { buildEmailDefaults } from "./defaults/email";
import { buildLoadersDefaults } from "./defaults/loaders"; import { buildLoadersDefaults } from "./defaults/loaders";
import { buildSoulmateDefaults } from "./defaults/soulmate"; import { buildSoulmateDefaults } from "./defaults/soulmate";
import { buildTrialPaymentDefaults } from "./defaults/trialPayment"; import { buildTrialPaymentDefaults } from "./defaults/trialPayment";
import { buildSpecialOfferDefaults } from "./defaults/specialOffer";
/** /**
* Marks the state as dirty if it has changed * Marks the state as dirty if it has changed
*/ */
export function withDirty(state: BuilderState, next: BuilderState): BuilderState { export function withDirty(
state: BuilderState,
next: BuilderState
): BuilderState {
if (next === state) { if (next === state) {
return state; return state;
} }
@ -60,6 +64,8 @@ export function createScreenByTemplate(
return buildSoulmateDefaults(id); return buildSoulmateDefaults(id);
case "trialPayment": case "trialPayment":
return buildTrialPaymentDefaults(id); return buildTrialPaymentDefaults(id);
case "specialOffer":
return buildSpecialOfferDefaults(id);
default: default:
throw new Error(`Unknown template: ${template}`); throw new Error(`Unknown template: ${template}`);
} }

View File

@ -87,6 +87,9 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderSc
navigation: screen.navigation navigation: screen.navigation
? { ? {
defaultNextScreenId: screen.navigation.defaultNextScreenId, defaultNextScreenId: screen.navigation.defaultNextScreenId,
...(screen.navigation.onBackScreenId
? { onBackScreenId: screen.navigation.onBackScreenId }
: {}),
rules: screen.navigation.rules?.map((rule) => ({ rules: screen.navigation.rules?.map((rule) => ({
nextScreenId: rule.nextScreenId, nextScreenId: rule.nextScreenId,
conditions: rule.conditions.map((condition) => ({ conditions: rule.conditions.map((condition) => ({

View File

@ -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()) { for (const [ruleIndex, rule] of (navigation.rules ?? []).entries()) {
if (!screenIds.has(rule.nextScreenId)) { if (!screenIds.has(rule.nextScreenId)) {
issues.push( issues.push(

View File

@ -2330,6 +2330,12 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"cornerRadius": "3xl", "cornerRadius": "3xl",
"showPrivacyTermsConsent": false "showPrivacyTermsConsent": false
}, },
"navigation": {
"rules": [],
"defaultNextScreenId": "specialoffer",
"isEndScreen": true,
"onBackScreenId": "specialoffer"
},
"variants": [], "variants": [],
"headerBlock": { "headerBlock": {
"text": { "text": {
@ -2755,6 +2761,81 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
] ]
} }
} }
},
{
"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"
}
}
]
}
} }
] ]
} }

View File

@ -12,6 +12,7 @@ import {
LoadersTemplate, LoadersTemplate,
SoulmatePortraitTemplate, SoulmatePortraitTemplate,
TrialPaymentTemplate, TrialPaymentTemplate,
SpecialOfferTemplate,
} from "@/components/funnel/templates"; } from "@/components/funnel/templates";
import type { import type {
ListScreenDefinition, ListScreenDefinition,
@ -27,6 +28,7 @@ import type {
DefaultTexts, DefaultTexts,
FunnelDefinition, FunnelDefinition,
FunnelAnswers, FunnelAnswers,
SpecialOfferScreenDefinition,
} from "@/lib/funnel/types"; } from "@/lib/funnel/types";
export interface ScreenRenderProps { export interface ScreenRenderProps {
@ -324,6 +326,29 @@ const TEMPLATE_REGISTRY: Record<
/> />
); );
}, },
specialOffer: ({
screen,
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
funnel,
}) => {
const specialOfferScreen = screen as SpecialOfferScreenDefinition;
return (
<SpecialOfferTemplate
funnel={funnel}
screen={specialOfferScreen}
onContinue={onContinue}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
defaultTexts={defaultTexts}
/>
);
},
}; };
export function renderScreen(props: ScreenRenderProps): JSX.Element { export function renderScreen(props: ScreenRenderProps): JSX.Element {

View File

@ -74,8 +74,6 @@ export interface DefaultTexts {
privacyBanner?: string; // "Мы не передаем личную информацию..." privacyBanner?: string; // "Мы не передаем личную информацию..."
} }
export interface NavigationConditionDefinition { export interface NavigationConditionDefinition {
screenId: string; screenId: string;
/** /**
@ -108,17 +106,25 @@ export interface NavigationDefinition {
defaultNextScreenId?: string; defaultNextScreenId?: string;
rules?: NavigationRuleDefinition[]; rules?: NavigationRuleDefinition[];
isEndScreen?: boolean; // Указывает что это финальный экран воронки isEndScreen?: boolean; // Указывает что это финальный экран воронки
/** Экран, на который нужно перейти при попытке возврата назад (UI/браузер) */
onBackScreenId?: string;
} }
// Рекурсивный Partial для глубоких вложенных объектов // Рекурсивный Partial для глубоких вложенных объектов
type DeepPartial<T> = T extends object ? { type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>; [P in keyof T]?: DeepPartial<T[P]>;
} : T; }
: T;
// Варианты могут переопределять любые поля экрана, включая вложенные объекты // Варианты могут переопределять любые поля экрана, включая вложенные объекты
type ScreenVariantOverrides<T> = DeepPartial<Omit<T, "id" | "template" | "variants">>; type ScreenVariantOverrides<T> = DeepPartial<
Omit<T, "id" | "template" | "variants">
>;
export interface ScreenVariantDefinition<T extends { id: string; template: string }> { export interface ScreenVariantDefinition<
T extends { id: string; template: string }
> {
conditions: NavigationConditionDefinition[]; conditions: NavigationConditionDefinition[];
overrides: ScreenVariantOverrides<T>; overrides: ScreenVariantOverrides<T>;
} }
@ -199,7 +205,6 @@ export interface DateInputDefinition {
registrationFieldKey?: string; registrationFieldKey?: string;
} }
export interface DateScreenDefinition { export interface DateScreenDefinition {
id: string; id: string;
template: "date"; template: "date";
@ -263,7 +268,6 @@ export interface FormScreenDefinition {
variants?: ScreenVariantDefinition<FormScreenDefinition>[]; variants?: ScreenVariantDefinition<FormScreenDefinition>[];
} }
export interface ListScreenDefinition { export interface ListScreenDefinition {
id: string; id: string;
template: "list"; template: "list";
@ -509,7 +513,44 @@ export interface TrialPaymentScreenDefinition {
}; };
} }
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition | EmailScreenDefinition | LoadersScreenDefinition | SoulmatePortraitScreenDefinition | TrialPaymentScreenDefinition; export interface SpecialOfferScreenDefinition {
id: string;
template: "specialOffer";
header?: HeaderDefinition;
title?: TitleDefinition;
subtitle?: SubtitleDefinition;
// coupon: CouponDefinition;
text: {
title?: TypographyVariant;
subtitle?: TypographyVariant;
description?: {
trialPrice?: TypographyVariant;
text?: TypographyVariant;
oldTrialPrice?: TypographyVariant;
};
};
advantages?: {
items: Array<{
icon?: TypographyVariant;
text?: TypographyVariant;
}>;
};
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<SpecialOfferScreenDefinition>[];
}
export type ScreenDefinition =
| InfoScreenDefinition
| DateScreenDefinition
| CouponScreenDefinition
| FormScreenDefinition
| ListScreenDefinition
| EmailScreenDefinition
| LoadersScreenDefinition
| SoulmatePortraitScreenDefinition
| TrialPaymentScreenDefinition
| SpecialOfferScreenDefinition;
export interface FunnelMetaDefinition { export interface FunnelMetaDefinition {
id: string; id: string;

View File

@ -145,6 +145,7 @@ const NavigationDefinitionSchema = new Schema(
rules: [NavigationRuleSchema], rules: [NavigationRuleSchema],
defaultNextScreenId: String, defaultNextScreenId: String,
isEndScreen: { type: Boolean, default: false }, isEndScreen: { type: Boolean, default: false },
onBackScreenId: String,
}, },
{ _id: false } { _id: false }
); );
@ -179,6 +180,7 @@ const ScreenDefinitionSchema = new Schema(
"loaders", "loaders",
"soulmate", "soulmate",
"trialPayment", "trialPayment",
"specialOffer",
], ],
required: true, required: true,
}, },

View File

@ -3,6 +3,7 @@
* *
* Поддерживаемые теги: * Поддерживаемые теги:
* **текст** - жирный текст * **текст** - жирный текст
* ~~текст~~ - зачёркнутый текст
* *
* Примеры использования: * Примеры использования:
* "Добро пожаловать в **WitLab**!" "Добро пожаловать в <strong>WitLab</strong>!" * "Добро пожаловать в **WitLab**!" "Добро пожаловать в <strong>WitLab</strong>!"
@ -10,7 +11,7 @@
*/ */
export interface TextMarkupSegment { export interface TextMarkupSegment {
type: 'text' | 'bold'; type: 'text' | 'bold' | 'strike';
content: string; content: string;
} }
@ -23,14 +24,16 @@ export function parseTextMarkup(text: string): TextMarkupSegment[] {
} }
const segments: TextMarkupSegment[] = []; const segments: TextMarkupSegment[] = [];
const boldRegex = /\*\*(.*?)\*\*/g; // Ищем как жирный (**...**), так и зачёркнутый (~~...~~) текст
const tokenRegex = /(\*\*(.*?)\*\*|~~(.*?)~~)/g;
let lastIndex = 0; let lastIndex = 0;
let match; let match;
while ((match = boldRegex.exec(text)) !== null) { while ((match = tokenRegex.exec(text)) !== null) {
const matchStart = match.index; const matchStart = match.index;
const matchEnd = boldRegex.lastIndex; const matchEnd = tokenRegex.lastIndex;
const boldContent = match[1]; const boldContent = match[2];
const strikeContent = match[3];
// Добавляем обычный текст перед жирным (если есть) // Добавляем обычный текст перед жирным (если есть)
if (matchStart > lastIndex) { if (matchStart > lastIndex) {
@ -40,9 +43,11 @@ export function parseTextMarkup(text: string): TextMarkupSegment[] {
} }
} }
// Добавляем жирный текст // Добавляем найденный сегмент
if (boldContent) { if (boldContent !== undefined) {
segments.push({ type: 'bold', content: boldContent }); segments.push({ type: 'bold', content: boldContent });
} else if (strikeContent !== undefined) {
segments.push({ type: 'strike', content: strikeContent });
} }
lastIndex = matchEnd; lastIndex = matchEnd;
@ -68,7 +73,7 @@ export function parseTextMarkup(text: string): TextMarkupSegment[] {
* Проверяет, содержит ли текст разметку * Проверяет, содержит ли текст разметку
*/ */
export function hasTextMarkup(text: string): boolean { export function hasTextMarkup(text: string): boolean {
return /\*\*(.*?)\*\*/g.test(text || ''); return /(\*\*(.*?)\*\*|~~(.*?)~~)/g.test(text || '');
} }
/** /**
@ -79,7 +84,9 @@ export function stripTextMarkup(text: string): string {
return text || ''; return text || '';
} }
return text.replace(/\*\*(.*?)\*\*/g, '$1'); return text
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/~~(.*?)~~/g, '$1');
} }
/** /**
@ -105,5 +112,9 @@ export const MARKUP_EXAMPLES = [
{ {
input: "Поздравляем, **Анна**! Ваш портрет готов.", input: "Поздравляем, **Анна**! Ваш портрет готов.",
description: "Выделение имени пользователя" description: "Выделение имени пользователя"
},
{
input: "Старая цена ~~1990₽~~, новая **990₽**",
description: "Комбинация зачёркнутого и жирного"
} }
] as const; ] as const;

View File

@ -0,0 +1,31 @@
export function getTrackingCookiesForRedirect() {
const cookieObj = Object.fromEntries(
document.cookie.split("; ").map((c) => c.split("="))
);
const result = Object.entries(cookieObj).filter(([key]) => {
return (
[
"_fbc",
"_fbp",
"_ym_uid",
"_ym_d",
"_ym_isad",
"_ym_visorc",
"yandexuid",
"ymex",
].includes(key) ||
key.startsWith("_ga") ||
key.startsWith("_gid")
);
});
const queryString = result
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`
)
.join("&");
return queryString;
}

View File

@ -0,0 +1,47 @@
export const formatPeriod = (
period: "DAY" | "WEEK" | "MONTH" | "YEAR" | undefined,
interval: number
) => {
if (!period) return `${interval} days`;
const unit =
period === "DAY"
? interval === 1
? "day"
: "days"
: period === "WEEK"
? interval === 1
? "week"
: "weeks"
: period === "MONTH"
? interval === 1
? "month"
: "months"
: interval === 1
? "year"
: "years";
return `${interval} ${unit}`;
};
export const formatPeriodHyphen = (
period: "DAY" | "WEEK" | "MONTH" | "YEAR" | undefined,
interval: number
) => {
if (!period) return `${interval}-day`;
const unit =
period === "DAY"
? interval === 1
? "day"
: "days"
: period === "WEEK"
? interval === 1
? "week"
: "weeks"
: period === "MONTH"
? interval === 1
? "month"
: "months"
: interval === 1
? "year"
: "years";
return `${interval}-${unit}`;
};