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",
"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"
}
}
]
}
}
]
}

View File

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

View File

@ -143,6 +143,8 @@ export function BuilderSidebar() {
rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
isEndScreen:
navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
onBackScreenId:
navigationUpdates.onBackScreenId ?? screen.navigation?.onBackScreenId,
},
},
});
@ -627,6 +629,32 @@ export function BuilderSidebar() {
</select>
</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>
{selectedScreenIsListType &&

View File

@ -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) {
</select>
</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>
{selectedScreenIsListType && !screen.navigation?.isEndScreen && (

View File

@ -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<ScreenDefinition["template"] | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl overflow-y-auto max-h-[90vh]">
<DialogHeader>
<DialogTitle>Выберите тип экрана</DialogTitle>
<DialogDescription>
@ -130,18 +149,22 @@ export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDi
<button
key={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 ${
isSelected
? "border-primary bg-primary/5"
className={`cursor-pointer flex items-start gap-4 rounded-lg border-2 p-4 text-left transition-all hover:bg-muted/50 ${
isSelected
? "border-primary bg-primary/5"
: "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" />
</div>
<div className="flex-1">
<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>
</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,
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
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={value ? (value.show ?? true) : false}
checked={value ? value.show ?? true : false}
onChange={(event) => handleShowToggle(event.target.checked)}
/>
Показывать {label.toLowerCase()}
</label>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{label}</label>
<TextAreaInput
value={value?.text ?? ""}
<TextAreaInput
value={value?.text ?? ""}
onChange={(event) => 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() === "") && (
<p className="text-xs text-destructive">Это поле обязательно для заполнения</p>
<p className="text-xs text-destructive">
Это поле обязательно для заполнения
</p>
)}
</div>
@ -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 ? <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>
{(isHydrated ? showAdvanced : false) && (
<div className="ml-4 grid grid-cols-2 gap-2 text-xs">
<label className="flex flex-col gap-1">
@ -217,13 +245,15 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
<option value="geistMono">Geist Mono</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-muted-foreground">Толщина</span>
<select
className="rounded border border-border bg-background px-2 py-1"
value={value?.weight ?? ""}
onChange={(e) => handleAdvancedChange("weight", e.target.value)}
onChange={(e) =>
handleAdvancedChange("weight", e.target.value)
}
>
<option value="">По умолчанию</option>
<option value="regular">Regular</option>
@ -234,14 +264,15 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
<option value="black">Black</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-muted-foreground">Выравнивание</span>
<select
className="rounded border border-border bg-background px-2 py-1"
value={value?.align ?? ""}
onChange={(e) => handleAdvancedChange("align", e.target.value)}
onChange={(e) =>
handleAdvancedChange("align", e.target.value)
}
>
<option value="">По умолчанию</option>
<option value="left">Слева</option>
@ -298,7 +329,9 @@ function HeaderControls({ header, onChange }: HeaderControlsProps) {
<input
type="checkbox"
checked={activeHeader.showBackButton !== false}
onChange={(event) => handleToggle("showBackButton", event.target.checked)}
onChange={(event) =>
handleToggle("showBackButton", event.target.checked)
}
/>
Показывать кнопку «Назад»
</label>
@ -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 && (
<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">
<span className="font-medium text-muted-foreground">Текст кнопки</span>
<TextInput
value={buttonText}
<span className="font-medium text-muted-foreground">
Текст кнопки
</span>
<TextInput
value={buttonText}
onChange={(event) => handleTextChange(event.target.value)}
placeholder="Оставьте пустым для дефолтного текста"
/>
</label>
<label className="flex flex-col gap-1">
<span className="font-medium text-muted-foreground">Скругление</span>
<span className="font-medium text-muted-foreground">
Скругление
</span>
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={cornerRadius ?? ""}
@ -430,7 +481,9 @@ function ActionButtonControls({ label, value, onChange }: ActionButtonControlsPr
<input
type="checkbox"
checked={showPrivacyTermsConsent}
onChange={(event) => handlePrivacyTermsToggle(event.target.checked)}
onChange={(event) =>
handlePrivacyTermsToggle(event.target.checked)
}
/>
Показывать PrivacyTermsConsent под кнопкой
</label>
@ -463,25 +516,27 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
onUpdate({ header: value });
};
const handleButtonChange = (value: BottomActionButtonDefinition | undefined) => {
const handleButtonChange = (
value: BottomActionButtonDefinition | undefined
) => {
onUpdate({ bottomActionButton: value });
};
return (
<div className="space-y-4">
<CollapsibleSection title="Заголовок и подзаголовок">
<TypographyControls
label="Заголовок"
value={screen.title}
<TypographyControls
label="Заголовок"
value={screen.title}
onChange={handleTitleChange}
showToggle
showToggle
/>
<TypographyControls
label="Подзаголовок"
value={"subtitle" in screen ? screen.subtitle : undefined}
onChange={handleSubtitleChange}
allowRemove
showToggle
<TypographyControls
label="Подзаголовок"
value={"subtitle" in screen ? screen.subtitle : undefined}
onChange={handleSubtitleChange}
allowRemove
showToggle
/>
</CollapsibleSection>
@ -490,61 +545,93 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
</CollapsibleSection>
<CollapsibleSection title="Нижняя кнопка">
<ActionButtonControls label="Показывать основную кнопку" value={screen.bottomActionButton} onChange={handleButtonChange} />
<ActionButtonControls
label="Показывать основную кнопку"
value={screen.bottomActionButton}
onChange={handleButtonChange}
/>
</CollapsibleSection>
{template === "info" && (
<InfoScreenConfig
screen={screen as BuilderScreen & { template: "info" }}
onUpdate={onUpdate as (updates: Partial<InfoScreenDefinition>) => void}
onUpdate={
onUpdate as (updates: Partial<InfoScreenDefinition>) => void
}
/>
)}
{template === "date" && (
<DateScreenConfig
screen={screen as BuilderScreen & { template: "date" }}
onUpdate={onUpdate as (updates: Partial<DateScreenDefinition>) => void}
onUpdate={
onUpdate as (updates: Partial<DateScreenDefinition>) => void
}
/>
)}
{template === "coupon" && (
<CouponScreenConfig
screen={screen as BuilderScreen & { template: "coupon" }}
onUpdate={onUpdate as (updates: Partial<CouponScreenDefinition>) => void}
onUpdate={
onUpdate as (updates: Partial<CouponScreenDefinition>) => void
}
/>
)}
{template === "form" && (
<FormScreenConfig
screen={screen as BuilderScreen & { template: "form" }}
onUpdate={onUpdate as (updates: Partial<FormScreenDefinition>) => void}
onUpdate={
onUpdate as (updates: Partial<FormScreenDefinition>) => void
}
/>
)}
{template === "list" && (
<ListScreenConfig
screen={screen as BuilderScreen & { template: "list" }}
onUpdate={onUpdate as (updates: Partial<ListScreenDefinition>) => void}
onUpdate={
onUpdate as (updates: Partial<ListScreenDefinition>) => void
}
/>
)}
{template === "email" && (
<EmailScreenConfig
screen={screen as BuilderScreen & { template: "email" }}
onUpdate={onUpdate as (updates: Partial<EmailScreenDefinition>) => void}
onUpdate={
onUpdate as (updates: Partial<EmailScreenDefinition>) => void
}
/>
)}
{template === "loaders" && (
<LoadersScreenConfig
screen={screen as BuilderScreen & { template: "loaders" }}
onUpdate={onUpdate as (updates: Partial<LoadersScreenDefinition>) => void}
onUpdate={
onUpdate as (updates: Partial<LoadersScreenDefinition>) => void
}
/>
)}
{template === "soulmate" && (
<SoulmatePortraitScreenConfig
screen={screen as BuilderScreen & { template: "soulmate" }}
onUpdate={onUpdate as (updates: Partial<SoulmatePortraitScreenDefinition>) => void}
onUpdate={
onUpdate as (
updates: Partial<SoulmatePortraitScreenDefinition>
) => void
}
/>
)}
{template === "trialPayment" && (
<TrialPaymentScreenConfig
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>

View File

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

View File

@ -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,61 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
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;
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 { getFormattedPrice } from "@/shared/utils/price";
import { useClientToken } from "@/hooks/auth/useClientToken";
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;
}
import { formatPeriod, formatPeriodHyphen } from "@/shared/utils/period";
import { getTrackingCookiesForRedirect } from "@/shared/utils/cookies";
interface TrialPaymentTemplateProps {
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 formattedBillingPrice = getFormattedPrice(price, currency);
const trialPeriodText = formatPeriod(trialPeriod, trialInterval);
const billingPeriodText = formatPeriod(billingPeriod, billingInterval);
const trialPeriodHyphenText = formatPeriodHyphen(trialPeriod, trialInterval);
const computeDiscountPercent = () => {
if (!oldPrice || !trialPrice || oldPrice <= 0) return undefined;
@ -169,32 +116,6 @@ export function TrialPaymentTemplate({
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) => {
if (!text) return "";
const values: Record<string, string> = {
@ -700,7 +621,9 @@ export function TrialPaymentTemplate({
>
Privacy Policy
</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>
</div>
)}
@ -958,7 +881,11 @@ export function TrialPaymentTemplate({
// Open contact link from footer config
const contactUrl = screen.footer?.contacts?.email?.href;
if (contactUrl) {
window.open(contactUrl, "_blank", "noopener,noreferrer");
window.open(
contactUrl,
"_blank",
"noopener,noreferrer"
);
}
},
}

View File

@ -1,6 +1,6 @@
// Funnel Templates - каждый в своей папке с stories
export { InfoTemplate } from "./InfoTemplate";
export { ListTemplate } from "./ListTemplate";
export { ListTemplate } from "./ListTemplate";
export { DateTemplate } from "./DateTemplate";
export { FormTemplate } from "./FormTemplate";
export { EmailTemplate } from "./EmailTemplate";
@ -8,6 +8,7 @@ export { CouponTemplate } from "./CouponTemplate";
export { LoadersTemplate } from "./LoadersTemplate";
export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate";
export { TrialPaymentTemplate } from "./TrialPaymentTemplate/index";
export { SpecialOfferTemplate } from "./SpecialOffer/index";
// Layout Templates
export { TemplateLayout } from "./layouts/TemplateLayout";

View File

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

View File

@ -19,7 +19,9 @@ import type {
/**
* Creates default header configuration
*/
export function buildDefaultHeader(overrides?: Partial<HeaderDefinition>): HeaderDefinition {
export function buildDefaultHeader(
overrides?: Partial<HeaderDefinition>
): HeaderDefinition {
return {
show: true,
showBackButton: true,
@ -31,7 +33,9 @@ export function buildDefaultHeader(overrides?: Partial<HeaderDefinition>): Heade
/**
* Creates default title configuration
*/
export function buildDefaultTitle(overrides?: Partial<TitleDefinition>): TitleDefinition {
export function buildDefaultTitle(
overrides?: Partial<TitleDefinition>
): TitleDefinition {
return {
show: true,
text: "Новый экран",
@ -47,7 +51,9 @@ export function buildDefaultTitle(overrides?: Partial<TitleDefinition>): TitleDe
/**
* Creates default subtitle configuration
*/
export function buildDefaultSubtitle(overrides?: Partial<SubtitleDefinition>): SubtitleDefinition {
export function buildDefaultSubtitle(
overrides?: Partial<SubtitleDefinition>
): SubtitleDefinition {
return {
show: true,
text: "Добавьте детали справа",
@ -63,7 +69,9 @@ export function buildDefaultSubtitle(overrides?: Partial<SubtitleDefinition>): S
/**
* Creates default bottom action button configuration
*/
export function buildDefaultBottomActionButton(overrides?: Partial<BottomActionButtonDefinition>): BottomActionButtonDefinition {
export function buildDefaultBottomActionButton(
overrides?: Partial<BottomActionButtonDefinition>
): BottomActionButtonDefinition {
return {
show: true,
showGradientBlur: true,
@ -74,7 +82,9 @@ export function buildDefaultBottomActionButton(overrides?: Partial<BottomActionB
/**
* Creates default navigation configuration
*/
export function buildDefaultNavigation(overrides?: Partial<NavigationDefinition>): NavigationDefinition {
export function buildDefaultNavigation(
overrides?: Partial<NavigationDefinition>
): NavigationDefinition {
return {
defaultNextScreenId: undefined,
rules: [],
@ -85,7 +95,9 @@ export function buildDefaultNavigation(overrides?: Partial<NavigationDefinition>
/**
* Creates default description configuration
*/
export function buildDefaultDescription(overrides?: Partial<TypographyVariant>): TypographyVariant {
export function buildDefaultDescription(
overrides?: Partial<TypographyVariant>
): TypographyVariant {
return {
text: "Добавьте описание для экрана",
font: "manrope",
@ -100,7 +112,9 @@ export function buildDefaultDescription(overrides?: Partial<TypographyVariant>):
/**
* Creates default icon configuration
*/
export function buildDefaultIcon(overrides?: Partial<IconDefinition>): IconDefinition {
export function buildDefaultIcon(
overrides?: Partial<IconDefinition>
): IconDefinition {
return {
type: "emoji",
value: "",
@ -112,10 +126,12 @@ export function buildDefaultIcon(overrides?: Partial<IconDefinition>): IconDefin
/**
* Creates default date input configuration
*/
export function buildDefaultDateInput(overrides?: Partial<DateInputDefinition>): DateInputDefinition {
export function buildDefaultDateInput(
overrides?: Partial<DateInputDefinition>
): DateInputDefinition {
return {
monthLabel: "Месяц",
dayLabel: "День",
dayLabel: "День",
yearLabel: "Год",
monthPlaceholder: "ММ",
dayPlaceholder: "ДД",
@ -127,11 +143,12 @@ export function buildDefaultDateInput(overrides?: Partial<DateInputDefinition>):
};
}
/**
* Creates default coupon configuration
*/
export function buildDefaultCoupon(overrides?: Partial<CouponDefinition>): CouponDefinition {
export function buildDefaultCoupon(
overrides?: Partial<CouponDefinition>
): CouponDefinition {
return {
title: {
text: "Специальное предложение",
@ -197,7 +214,9 @@ export function buildDefaultFormFields(): FormFieldDefinition[] {
/**
* Creates default form validation messages
*/
export function buildDefaultFormValidation(overrides?: Partial<FormValidationMessages>): FormValidationMessages {
export function buildDefaultFormValidation(
overrides?: Partial<FormValidationMessages>
): FormValidationMessages {
return {
required: "Это поле обязательно для заполнения",
...overrides,
@ -207,7 +226,9 @@ export function buildDefaultFormValidation(overrides?: Partial<FormValidationMes
/**
* 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 {
items: [
{
@ -217,7 +238,7 @@ export function buildDefaultProgressbars(overrides?: Partial<ProgressbarDefiniti
completedSubtitle: "Complete",
},
{
processingTitle: "Portrait of the Soulmate",
processingTitle: "Portrait of the Soulmate",
processingSubtitle: "Processing...",
completedTitle: "Portrait of the Soulmate",
completedSubtitle: "Complete",
@ -244,7 +265,9 @@ export function buildDefaultCopiedMessage(): string {
/**
* Creates default image configuration
*/
export function buildDefaultImage(overrides?: { src?: string }): { src: string } {
export function buildDefaultImage(overrides?: { src?: string }): {
src: string;
} {
return {
src: "/female-portrait.jpg",
...overrides,
@ -254,10 +277,80 @@ export function buildDefaultImage(overrides?: { src?: string }): { src: string }
/**
* 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 {
label: "Email адрес",
placeholder: "example@email.com",
...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 { buildSoulmateDefaults } from "./soulmate";
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,
rules: navigation.rules ?? [],
isEndScreen: navigation.isEndScreen,
...("onBackScreenId" in navigation
? { onBackScreenId: navigation.onBackScreenId ?? undefined }
: {}),
},
}
: screen

View File

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

View File

@ -10,11 +10,15 @@ import { buildEmailDefaults } from "./defaults/email";
import { buildLoadersDefaults } from "./defaults/loaders";
import { buildSoulmateDefaults } from "./defaults/soulmate";
import { buildTrialPaymentDefaults } from "./defaults/trialPayment";
import { buildSpecialOfferDefaults } from "./defaults/specialOffer";
/**
* 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) {
return state;
}
@ -38,7 +42,7 @@ export function generateScreenId(existing: string[]): string {
* Creates a new screen based on template with sensible defaults
*/
export function createScreenByTemplate(
template: ScreenDefinition["template"],
template: ScreenDefinition["template"],
id: string
): BuilderScreen {
switch (template) {
@ -60,6 +64,8 @@ export function createScreenByTemplate(
return buildSoulmateDefaults(id);
case "trialPayment":
return buildTrialPaymentDefaults(id);
case "specialOffer":
return buildSpecialOfferDefaults(id);
default:
throw new Error(`Unknown template: ${template}`);
}

View File

@ -87,6 +87,9 @@ export function cloneScreen(screen: BuilderScreen, overrides?: Partial<BuilderSc
navigation: screen.navigation
? {
defaultNextScreenId: screen.navigation.defaultNextScreenId,
...(screen.navigation.onBackScreenId
? { onBackScreenId: screen.navigation.onBackScreenId }
: {}),
rules: screen.navigation.rules?.map((rule) => ({
nextScreenId: rule.nextScreenId,
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()) {
if (!screenIds.has(rule.nextScreenId)) {
issues.push(

View File

@ -2330,6 +2330,12 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [],
"defaultNextScreenId": "specialoffer",
"isEndScreen": true,
"onBackScreenId": "specialoffer"
},
"variants": [],
"headerBlock": {
"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,
SoulmatePortraitTemplate,
TrialPaymentTemplate,
SpecialOfferTemplate,
} from "@/components/funnel/templates";
import type {
ListScreenDefinition,
@ -27,6 +28,7 @@ import type {
DefaultTexts,
FunnelDefinition,
FunnelAnswers,
SpecialOfferScreenDefinition,
} from "@/lib/funnel/types";
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 {

View File

@ -74,13 +74,11 @@ export interface DefaultTexts {
privacyBanner?: string; // "Мы не передаем личную информацию..."
}
export interface NavigationConditionDefinition {
screenId: string;
/**
* Тип условия:
* - options: проверка выбранных опций в списках
* - options: проверка выбранных опций в списках
* - values: проверка конкретных значений (зодиак, email, дата, etc.)
*/
conditionType?: "options" | "values";
@ -91,10 +89,10 @@ export interface NavigationConditionDefinition {
* - equals: точное совпадение значения (для одиночных значений)
*/
operator?: "includesAny" | "includesAll" | "includesExactly" | "equals";
// Для list экранов (legacy, но поддерживается)
optionIds?: string[];
// Для любых экранов - универсальные значения
values?: string[];
}
@ -108,17 +106,25 @@ export interface NavigationDefinition {
defaultNextScreenId?: string;
rules?: NavigationRuleDefinition[];
isEndScreen?: boolean; // Указывает что это финальный экран воронки
/** Экран, на который нужно перейти при попытке возврата назад (UI/браузер) */
onBackScreenId?: string;
}
// Рекурсивный Partial для глубоких вложенных объектов
type DeepPartial<T> = T extends object ? {
[P in keyof T]?: DeepPartial<T[P]>;
} : T;
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: 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[];
overrides: ScreenVariantOverrides<T>;
}
@ -143,7 +149,7 @@ export interface VariableMapping {
/**
* Определение переменной для динамической подстановки в текст.
* Синтаксис использования: {{variableName}}
*
*
* Пример:
* - name: "gender"
* - mappings: [
@ -199,7 +205,6 @@ export interface DateInputDefinition {
registrationFieldKey?: string;
}
export interface DateScreenDefinition {
id: string;
template: "date";
@ -247,7 +252,7 @@ export interface FormFieldDefinition {
}
export interface FormValidationMessages {
required?: string; // "${field} is required"
required?: string; // "${field} is required"
}
export interface FormScreenDefinition {
@ -263,7 +268,6 @@ export interface FormScreenDefinition {
variants?: ScreenVariantDefinition<FormScreenDefinition>[];
}
export interface ListScreenDefinition {
id: string;
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 {
id: string;

View File

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

View File

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