commit
69f8811233
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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<
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
Пока нет элементов. Добавьте первый, нажав "+".
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-muted/10 p-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
Тексты и преимущества отображаются в шаблоне `SpecialOfferTemplate`. Основная нижняя кнопка
|
||||
настраивается выше в секции "Нижняя кнопка".
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -5,3 +5,4 @@ export { FormScreenConfig } from "./FormScreenConfig";
|
||||
export { ListScreenConfig } from "./ListScreenConfig";
|
||||
export { TemplateConfig } from "./TemplateConfig";
|
||||
export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
|
||||
export { SpecialOfferScreenConfig } from "./SpecialOfferScreenConfig";
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// };
|
||||
303
src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx
Normal file
303
src/components/funnel/templates/SpecialOffer/SpecialOffer.tsx
Normal 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)}¤cy=${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>
|
||||
);
|
||||
}
|
||||
1
src/components/funnel/templates/SpecialOffer/index.ts
Normal file
1
src/components/funnel/templates/SpecialOffer/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { SpecialOfferTemplate } from "./SpecialOffer";
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -30,3 +30,4 @@ export { buildEmailDefaults } from "./email";
|
||||
export { buildLoadersDefaults } from "./loaders";
|
||||
export { buildSoulmateDefaults } from "./soulmate";
|
||||
export { buildTrialPaymentDefaults } from "./trialPayment";
|
||||
export { buildSpecialOfferDefaults } from "./specialOffer";
|
||||
|
||||
32
src/lib/admin/builder/state/defaults/specialOffer.ts
Normal file
32
src/lib/admin/builder/state/defaults/specialOffer.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -26,6 +26,7 @@ export type BuilderAction =
|
||||
defaultNextScreenId?: string | null;
|
||||
rules?: NavigationRuleDefinition[];
|
||||
isEndScreen?: boolean;
|
||||
onBackScreenId?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
31
src/shared/utils/cookies.ts
Normal file
31
src/shared/utils/cookies.ts
Normal 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;
|
||||
}
|
||||
47
src/shared/utils/period.ts
Normal file
47
src/shared/utils/period.ts
Normal 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}`;
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user