add payment
This commit is contained in:
gofnnp 2025-10-05 23:11:55 +04:00
parent e6c4655306
commit fba0acaf0b
49 changed files with 3946 additions and 429 deletions

26
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@lottiefiles/dotlottie-react": "^0.17.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
@ -21,6 +22,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.2",
"idb": "^8.0.3",
"lucide-react": "^0.544.0",
"mongoose": "^8.18.2",
"next": "15.5.3",
@ -1711,6 +1713,24 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lottiefiles/dotlottie-react": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.17.4.tgz",
"integrity": "sha512-PsWq0l+Q/sGwnjWMiRJC1GUmsXFYB8zc5TacWblfaU9EQzqJzBeblk5rqtac/EDQi9QiXqpojPgWsofJX97swg==",
"license": "MIT",
"dependencies": {
"@lottiefiles/dotlottie-web": "0.54.0"
},
"peerDependencies": {
"react": "^17 || ^18 || ^19"
}
},
"node_modules/@lottiefiles/dotlottie-web": {
"version": "0.54.0",
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.54.0.tgz",
"integrity": "sha512-Jc/n4i9siOXo9/1CVhKkrWC8pxxsKqKwxYfrL4DFQP/cLUAeAO0TqFPQFx9Klh1m7T+/1RPFriycOcF8gW3ZtQ==",
"license": "MIT"
},
"node_modules/@mdx-js/react": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
@ -7510,6 +7530,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",

View File

@ -21,6 +21,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@lottiefiles/dotlottie-react": "^0.17.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
@ -33,6 +34,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.2",
"idb": "^8.0.3",
"lucide-react": "^0.544.0",
"mongoose": "^8.18.2",
"next": "15.5.3",

View File

@ -0,0 +1,11 @@
export default function PaymentLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<main className="p-4 pb-220 max-w-[560px] mx-auto relative min-h-dvh">
{children}
</main>
);
}

View File

@ -0,0 +1,24 @@
// import { getTranslations } from "next-intl/server";
import AnimatedInfoScreen from "@/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen";
import LottieAnimation from "@/components/widgets/LottieAnimation/LottieAnimation";
import { ROUTES } from "@/shared/constants/client-routes";
import { ELottieKeys } from "@/shared/constants/lottie";
export default async function PaymentFailed() {
// const t = await getTranslations("Payment.Error");
return (
<AnimatedInfoScreen
lottieAnimation={
<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />
}
// title={t("title")}
title="Payment failed"
animationTime={0}
animationTexts={[]}
buttonText="Try again"
nextRoute={ROUTES.home()}
/>
);
}

View File

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { createPaymentCheckout } from "@/entities/payment/api";
import { ROUTES } from "@/shared/constants/client-routes";
export async function GET(req: NextRequest) {
const productId = req.nextUrl.searchParams.get("productId");
const placementId = req.nextUrl.searchParams.get("placementId");
const paywallId = req.nextUrl.searchParams.get("paywallId");
const fbPixels = req.nextUrl.searchParams.get("fb_pixels");
const productPrice = req.nextUrl.searchParams.get("price");
const currency = req.nextUrl.searchParams.get("currency");
const data = await createPaymentCheckout({
productId: productId || "",
placementId: placementId || "",
paywallId: paywallId || "",
});
let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin);
if (!redirectUrl) {
redirectUrl = new URL(`${ROUTES.paymentFailed()}`, origin);
}
if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels);
if (productPrice) redirectUrl.searchParams.set("price", productPrice);
if (currency) redirectUrl.searchParams.set("currency", currency);
return NextResponse.redirect(redirectUrl, { status: 307 });
}

View File

@ -0,0 +1,31 @@
"use client";
import { ActionButton } from "@/components/ui/ActionButton/ActionButton";
// import { useRouter } from "next/navigation";
// import { useTranslations } from "next-intl";
import Typography from "@/components/ui/Typography/Typography";
// import { ROUTES } from "@/shared/constants/client-routes";
// import styles from "./Button.module.scss";
export default function PaymentSuccessButton() {
// const t = useTranslations("Payment.Success");
// const router = useRouter();
const handleNext = () => {
// router.push(ROUTES.additionalPurchases());
};
return (
<ActionButton
onClick={handleNext}
className="fixed bottom-[calc(0dvh+64px)] left-1/2 -translate-x-1/2 opacity-0 pointer-events-none max-w-[400px] w-[calc(100dvw-32px)] [animation:fadeIn_0.5s_ease-in-out_forwards] [animation-delay:2s]"
>
<Typography color="primary" size="xl" weight="bold">
{/* {t("button")} */}
Done
</Typography>
</ActionButton>
);
}

View File

@ -0,0 +1,24 @@
// import { getTranslations } from "next-intl/server";
import AnimatedInfoScreen from "@/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen";
import LottieAnimation from "@/components/widgets/LottieAnimation/LottieAnimation";
import PaymentSuccessButton from "./Button";
import { ELottieKeys } from "@/shared/constants/lottie";
export default async function PaymentSuccess() {
// const t = await getTranslations("Payment.Success");
return (
<>
<AnimatedInfoScreen
lottieAnimation={
<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />
}
// title={t("title")}
title="Payment successful"
/>
<PaymentSuccessButton />
</>
);
}

View File

@ -230,3 +230,13 @@
transform: scale(1.05);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@ -117,7 +117,7 @@ export function BuilderCanvas() {
const screenTitleMap = useMemo(() => {
return screens.reduce<Record<string, string>>((accumulator, screen) => {
accumulator[screen.id] = screen.title.text || screen.id;
accumulator[screen.id] = screen.title?.text || screen.id;
return accumulator;
}, {});
}, [screens]);
@ -189,7 +189,7 @@ export function BuilderCanvas() {
#{screen.id}
</span>
<span className="max-h-14 overflow-hidden break-words text-lg font-semibold leading-tight text-foreground">
{screen.title.text || "Без названия"}
{screen.title?.text || "Без названия"}
</span>
</div>
</div>

View File

@ -1,17 +1,24 @@
import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types";
import type {
ScreenDefinition,
NavigationConditionDefinition,
} from "@/lib/funnel/types";
export const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
list: "Список",
form: "Форма",
form: "Форма",
info: "Инфо",
date: "Дата",
coupon: "Купон",
email: "Email",
loaders: "Загрузка",
soulmate: "Портрет партнера",
trialPayment: "Trial Payment",
};
export const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
export const OPERATOR_LABELS: Record<
Exclude<NavigationConditionDefinition["operator"], undefined>,
string
> = {
includesAny: "любой из",
includesAll: "все из",
includesExactly: "точное совпадение",

View File

@ -6,7 +6,11 @@ import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { TemplateConfig } from "@/components/admin/builder/templates";
import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig";
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderDispatch,
useBuilderSelectedScreen,
useBuilderState,
} from "@/lib/admin/builder/context";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type {
NavigationRuleDefinition,
@ -24,7 +28,9 @@ export function BuilderSidebar() {
const dispatch = useBuilderDispatch();
const selectedScreen = useBuilderSelectedScreen();
const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel");
const [activeTab, setActiveTab] = useState<"funnel" | "screen">(
selectedScreen ? "screen" : "funnel"
);
const selectedScreenId = selectedScreen?.id ?? null;
useEffect(() => {
@ -37,27 +43,31 @@ export function BuilderSidebar() {
}, [selectedScreenId]);
// ✅ Оптимизированная validation - только критичные поля
const screenIds = useMemo(() => state.screens.map(s => s.id).join(','), [state.screens]);
const screenIds = useMemo(
() => state.screens.map((s) => s.id).join(","),
[state.screens]
);
const validation = useMemo(
() => validateBuilderState(state),
// eslint-disable-next-line react-hooks/exhaustive-deps -- Оптимизация: пересчитываем только при изменении критичных полей
[
state.meta.id,
state.meta.firstScreenId,
screenIds,
state.screens.length,
]
[state.meta.id, state.meta.firstScreenId, screenIds, state.screens.length]
);
const screenValidationIssues = useMemo(() => {
if (!selectedScreenId) {
return [] as ValidationIssues;
}
return validation.issues.filter((issue) => issue.screenId === selectedScreenId);
return validation.issues.filter(
(issue) => issue.screenId === selectedScreenId
);
}, [selectedScreenId, validation]);
const screenOptions = useMemo(
() => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })),
() =>
state.screens.map((screen) => ({
id: screen.id,
title: screen.title?.text,
})),
[state.screens]
);
@ -86,29 +96,29 @@ export function BuilderSidebar() {
if (newId === currentId) {
return;
}
// Разрешаем пустые ID для полного переименования
if (newId.trim() === "") {
// Просто обновляем на пустое значение, пользователь сможет ввести новое
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId }
}
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId },
},
});
return;
}
// Обновляем ID экрана
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId }
}
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId },
},
});
// Если это был первый экран в мета данных, обновляем и там
if (state.meta.firstScreenId === currentId) {
dispatch({ type: "set-meta", payload: { firstScreenId: newId } });
@ -128,15 +138,20 @@ export function BuilderSidebar() {
screenId: screen.id,
navigation: {
defaultNextScreenId:
navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId,
navigationUpdates.defaultNextScreenId ??
screen.navigation?.defaultNextScreenId,
rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
isEndScreen:
navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
},
},
});
};
const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => {
const handleDefaultNextChange = (
screenId: string,
nextScreenId: string | ""
) => {
const screen = getScreenById(screenId);
if (!screen) {
return;
@ -186,7 +201,11 @@ export function BuilderSidebar() {
updateRules(screenId, nextRules);
};
const handleRuleOptionToggle = (screenId: string, ruleIndex: number, optionId: string) => {
const handleRuleOptionToggle = (
screenId: string,
ruleIndex: number,
optionId: string
) => {
const screen = getScreenById(screenId);
if (!screen) {
return;
@ -220,7 +239,11 @@ export function BuilderSidebar() {
updateRules(screenId, nextRules);
};
const handleRuleNextScreenChange = (screenId: string, ruleIndex: number, nextScreenId: string) => {
const handleRuleNextScreenChange = (
screenId: string,
ruleIndex: number,
nextScreenId: string
) => {
const screen = getScreenById(screenId);
if (!screen) {
return;
@ -247,7 +270,10 @@ export function BuilderSidebar() {
const nextRules = [
...(screen.navigation?.rules ?? []),
{ nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] },
{
nextScreenId: state.screens[0]?.id ?? screen.id,
conditions: [defaultCondition],
},
];
updateNavigation(screen, { rules: nextRules });
};
@ -270,7 +296,10 @@ export function BuilderSidebar() {
dispatch({ type: "remove-screen", payload: { screenId } });
};
const handleTemplateUpdate = (screenId: string, updates: Partial<ScreenDefinition>) => {
const handleTemplateUpdate = (
screenId: string,
updates: Partial<ScreenDefinition>
) => {
dispatch({
type: "update-screen",
payload: {
@ -295,7 +324,9 @@ export function BuilderSidebar() {
});
};
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
const selectedScreenIsListType = selectedScreen
? isListScreen(selectedScreen)
: false;
return (
<div className="flex h-full flex-col">
@ -347,19 +378,27 @@ export function BuilderSidebar() {
<TextInput
label="Название"
value={state.meta.title ?? ""}
onChange={(event) => handleMetaChange("title", event.target.value)}
onChange={(event) =>
handleMetaChange("title", event.target.value)
}
/>
<TextInput
label="Описание"
value={state.meta.description ?? ""}
onChange={(event) => handleMetaChange("description", event.target.value)}
onChange={(event) =>
handleMetaChange("description", event.target.value)
}
/>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
<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={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
onChange={(event) => handleFirstScreenChange(event.target.value)}
onChange={(event) =>
handleFirstScreenChange(event.target.value)
}
>
{screenOptions.map((screen) => (
<option key={screen.id} value={screen.id}>
@ -370,18 +409,25 @@ export function BuilderSidebar() {
</label>
</Section>
<Section title="Дефолтные тексты" description="Текст кнопок и баннеров">
<Section
title="Дефолтные тексты"
description="Текст кнопок и баннеров"
>
<TextInput
label="Текст кнопки Next/Continue"
placeholder="Next"
value={state.defaultTexts?.nextButton ?? ""}
onChange={(event) => handleDefaultTextsChange("nextButton", event.target.value)}
onChange={(event) =>
handleDefaultTextsChange("nextButton", event.target.value)
}
/>
<TextInput
label="Баннер приватности"
placeholder="Мы не передаем личную информацию..."
value={state.defaultTexts?.privacyBanner ?? ""}
onChange={(event) => handleDefaultTextsChange("privacyBanner", event.target.value)}
onChange={(event) =>
handleDefaultTextsChange("privacyBanner", event.target.value)
}
/>
</Section>
@ -389,13 +435,22 @@ export function BuilderSidebar() {
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Всего экранов</span>
<span className="font-semibold text-foreground">{state.screens.length}</span>
<span className="font-semibold text-foreground">
{state.screens.length}
</span>
</div>
<div className="flex flex-col gap-1 text-xs">
{state.screens.map((screen, index) => (
<span key={screen.id} className="flex items-center justify-between">
<span className="truncate">{index + 1}. {screen.title.text}</span>
<span className="uppercase text-muted-foreground/80">{screen.template}</span>
<span
key={screen.id}
className="flex items-center justify-between"
>
<span className="truncate">
{index + 1}. {screen.title?.text}
</span>
<span className="uppercase text-muted-foreground/80">
{screen.template}
</span>
</span>
))}
</div>
@ -406,33 +461,42 @@ export function BuilderSidebar() {
<div className="flex flex-col gap-4">
{/* Валидация всегда вверху, без заголовка */}
<ValidationSummary issues={screenValidationIssues} />
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">#{selectedScreen.id}</span>
<span className="text-muted-foreground">
#{selectedScreen.id}
</span>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary uppercase">
{selectedScreen.template}
</span>
</div>
<span className="text-xs text-muted-foreground">
{state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length}
{state.screens.findIndex(
(screen) => screen.id === selectedScreen.id
) + 1}
/{state.screens.length}
</span>
</div>
</div>
<Section title="Общие данные">
<TextInput
label="ID экрана"
value={selectedScreen.id}
onChange={(event) => handleScreenIdChange(selectedScreen.id, event.target.value)}
<TextInput
label="ID экрана"
value={selectedScreen.id}
onChange={(event) =>
handleScreenIdChange(selectedScreen.id, event.target.value)
}
/>
</Section>
<Section title="Контент и оформление">
<TemplateConfig
screen={selectedScreen}
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
onUpdate={(updates) =>
handleTemplateUpdate(selectedScreen.id, updates)
}
/>
</Section>
@ -440,7 +504,9 @@ export function BuilderSidebar() {
<ScreenVariantsConfig
screen={selectedScreen}
allScreens={state.screens}
onChange={(variants) => handleVariantsChange(selectedScreen.id, variants)}
onChange={(variants) =>
handleVariantsChange(selectedScreen.id, variants)
}
/>
</Section>
@ -451,12 +517,16 @@ export function BuilderSidebar() {
type="checkbox"
checked={selectedScreen.navigation?.isEndScreen ?? false}
onChange={(e) => {
updateNavigation(selectedScreen, { isEndScreen: e.target.checked });
updateNavigation(selectedScreen, {
isEndScreen: e.target.checked,
});
}}
className="rounded border-border"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">Финальный экран</span>
<span className="text-sm font-medium text-foreground">
Финальный экран
</span>
<span className="text-xs text-muted-foreground">
Этот экран завершает воронку (переход не требуется)
</span>
@ -466,11 +536,18 @@ export function BuilderSidebar() {
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
{!selectedScreen.navigation?.isEndScreen && (
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<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?.defaultNextScreenId ?? ""}
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
onChange={(event) =>
handleDefaultNextChange(
selectedScreen.id,
event.target.value
)
}
>
<option value=""></option>
{screenOptions
@ -485,114 +562,163 @@ export function BuilderSidebar() {
)}
</Section>
{selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
<Section title="Правила переходов" description="Условная навигация">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
</p>
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={() => handleAddRule(selectedScreen)}>
<span className="text-lg leading-none">+</span>
</Button>
</div>
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Правил пока нет
{selectedScreenIsListType &&
!selectedScreen.navigation?.isEndScreen && (
<Section
title="Правила переходов"
description="Условная навигация"
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости
от выбора.
</p>
<Button
className="h-8 w-8 p-0 flex items-center justify-center"
onClick={() => handleAddRule(selectedScreen)}
>
<span className="text-lg leading-none">+</span>
</Button>
</div>
)}
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
<div
key={ruleIndex}
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
<Button
variant="ghost"
className="h-8 px-2 text-destructive hover:bg-destructive/10"
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
>
<Trash2 className="h-3 w-3 mr-1" />
<span className="text-xs">Удалить</span>
</Button>
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Правил пока нет
</div>
<label className="flex flex-col gap-2">
<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={rule.conditions[0]?.operator ?? "includesAny"}
onChange={(event) =>
handleRuleOperatorChange(
selectedScreen.id,
ruleIndex,
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
)
}
>
<option value="includesAny">contains any</option>
<option value="includesAll">contains all</option>
<option value="includesExactly">exact match</option>
</select>
</label>
)}
{selectedScreen.template === "list" ? (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
const isChecked = condition.optionIds?.includes(option.id) ?? false;
return (
<label key={option.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isChecked}
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
/>
<span>
{option.label}
<span className="text-muted-foreground"> ({option.id})</span>
</span>
</label>
);
})}
{(selectedScreen.navigation?.rules ?? []).map(
(rule, ruleIndex) => (
<div
key={ruleIndex}
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Правило {ruleIndex + 1}
</span>
<Button
variant="ghost"
className="h-8 px-2 text-destructive hover:bg-destructive/10"
onClick={() =>
handleRemoveRule(selectedScreen.id, ruleIndex)
}
>
<Trash2 className="h-3 w-3 mr-1" />
<span className="text-xs">Удалить</span>
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
Навигационные правила с вариантами ответа доступны только для экранов со списком.
</div>
)}
<label className="flex flex-col gap-2">
<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={rule.nextScreenId}
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
<label className="flex flex-col gap-2">
<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={
rule.conditions[0]?.operator ?? "includesAny"
}
onChange={(event) =>
handleRuleOperatorChange(
selectedScreen.id,
ruleIndex,
event.target
.value as NavigationRuleDefinition["conditions"][0]["operator"]
)
}
>
<option value="includesAny">contains any</option>
<option value="includesAll">contains all</option>
<option value="includesExactly">
exact match
</option>
))}
</select>
</label>
</div>
))}
</div>
</Section>
)}
</select>
</label>
{selectedScreen.template === "list" ? (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">
Варианты ответа
</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
const isChecked =
condition.optionIds?.includes(option.id) ??
false;
return (
<label
key={option.id}
className="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
checked={isChecked}
onChange={() =>
handleRuleOptionToggle(
selectedScreen.id,
ruleIndex,
option.id
)
}
/>
<span>
{option.label}
<span className="text-muted-foreground">
{" "}
({option.id})
</span>
</span>
</label>
);
})}
</div>
</div>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
Навигационные правила с вариантами ответа доступны
только для экранов со списком.
</div>
)}
<label className="flex flex-col gap-2">
<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={rule.nextScreenId}
onChange={(event) =>
handleRuleNextScreenChange(
selectedScreen.id,
ruleIndex,
event.target.value
)
}
>
{screenOptions
.filter(
(screen) => screen.id !== selectedScreen.id
)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</div>
)
)}
</div>
</Section>
)}
<Section title="Управление">
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="mb-3 text-sm text-muted-foreground">
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
Удаление экрана нельзя отменить. Все связи с этим экраном
будут потеряны.
</p>
<Button
variant="destructive"
@ -601,7 +727,9 @@ export function BuilderSidebar() {
onClick={() => handleDeleteScreen(selectedScreen.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
{state.screens.length <= 1
? "Нельзя удалить последний экран"
: "Удалить экран"}
</Button>
</div>
</Section>

View File

@ -1,6 +1,9 @@
import { useMemo } from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderDispatch,
useBuilderState,
} from "@/lib/admin/builder/context";
import { Section } from "./Section";
import type { BuilderScreen } from "@/lib/admin/builder/types";
@ -9,7 +12,11 @@ export function FunnelSettingsPanel() {
const dispatch = useBuilderDispatch();
const screenOptions = useMemo(
() => state.screens.map((screen: BuilderScreen) => ({ id: screen.id, title: screen.title.text })),
() =>
state.screens.map((screen: BuilderScreen) => ({
id: screen.id,
title: screen.title?.text,
})),
[state.screens]
);
@ -71,13 +78,17 @@ export function FunnelSettingsPanel() {
label='Кнопка "Next"'
placeholder="Next"
value={state.defaultTexts?.nextButton || ""}
onChange={(e) => handleDefaultTextsChange("nextButton", e.target.value)}
onChange={(e) =>
handleDefaultTextsChange("nextButton", e.target.value)
}
/>
<TextInput
label='Кнопка "Continue"'
placeholder="Continue"
value={state.defaultTexts?.continueButton || ""}
onChange={(e) => handleDefaultTextsChange("continueButton", e.target.value)}
onChange={(e) =>
handleDefaultTextsChange("continueButton", e.target.value)
}
/>
</div>
</Section>

View File

@ -1,7 +1,10 @@
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderDispatch,
useBuilderState,
} from "@/lib/admin/builder/context";
import { Section } from "./Section";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
@ -10,7 +13,9 @@ interface NavigationPanelProps {
screen: BuilderScreen;
}
function isListScreen(screen: BuilderScreen): screen is BuilderScreen & { template: "list" } {
function isListScreen(
screen: BuilderScreen
): screen is BuilderScreen & { template: "list" } {
return screen.template === "list";
}
@ -19,7 +24,7 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
const dispatch = useBuilderDispatch();
const screenOptions = useMemo(
() => state.screens.map((s) => ({ id: s.id, title: s.title.text })),
() => state.screens.map((s) => ({ id: s.id, title: s.title?.text })),
[state.screens]
);
@ -38,15 +43,22 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
screenId: targetScreen.id,
navigation: {
defaultNextScreenId:
navigationUpdates.defaultNextScreenId ?? targetScreen.navigation?.defaultNextScreenId,
rules: navigationUpdates.rules ?? targetScreen.navigation?.rules ?? [],
isEndScreen: navigationUpdates.isEndScreen ?? targetScreen.navigation?.isEndScreen,
navigationUpdates.defaultNextScreenId ??
targetScreen.navigation?.defaultNextScreenId,
rules:
navigationUpdates.rules ?? targetScreen.navigation?.rules ?? [],
isEndScreen:
navigationUpdates.isEndScreen ??
targetScreen.navigation?.isEndScreen,
},
},
});
};
const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => {
const handleDefaultNextChange = (
screenId: string,
nextScreenId: string | ""
) => {
const targetScreen = getScreenById(screenId);
if (!targetScreen) return;
@ -64,7 +76,9 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
const handleAddRule = (targetScreen: BuilderScreen) => {
const rules = targetScreen.navigation?.rules ?? [];
const firstScreenOption = screenOptions.find(s => s.id !== targetScreen.id);
const firstScreenOption = screenOptions.find(
(s) => s.id !== targetScreen.id
);
updateRules(targetScreen.id, [
...rules,
{
@ -105,7 +119,9 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
className="rounded border-border"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">Финальный экран</span>
<span className="text-sm font-medium text-foreground">
Финальный экран
</span>
<span className="text-xs text-muted-foreground">
Этот экран завершает воронку (переход не требуется)
</span>
@ -115,11 +131,15 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
{/* Обычная навигация - показываем только если НЕ финальный экран */}
{!screen.navigation?.isEndScreen && (
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<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?.defaultNextScreenId ?? ""}
onChange={(e) => handleDefaultNextChange(screen.id, e.target.value)}
onChange={(e) =>
handleDefaultNextChange(screen.id, e.target.value)
}
>
<option value=""></option>
{screenOptions
@ -139,7 +159,8 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
Направляйте пользователей на разные экраны в зависимости от
выбора.
</p>
<Button
className="h-8 w-8 p-0 flex items-center justify-center"
@ -176,7 +197,10 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
<div className="text-xs text-muted-foreground">
{/* Здесь должна быть полная логика редактирования правил */}
{/* Для краткости оставляем только структуру */}
<p>Правило {ruleIndex + 1} - редактирование правил сохранено в оригинальном компоненте</p>
<p>
Правило {ruleIndex + 1} - редактирование правил сохранено в
оригинальном компоненте
</p>
</div>
</div>
))}

View File

@ -3,7 +3,10 @@ import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { TemplateConfig } from "@/components/admin/builder/templates";
import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderDispatch,
useBuilderState,
} from "@/lib/admin/builder/context";
import { Section } from "./Section";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { ScreenDefinition } from "@/lib/funnel/types";
@ -46,14 +49,20 @@ export function ScreenSettingsPanel({ screen }: ScreenSettingsPanelProps) {
}
};
const handleTemplateUpdate = (screenId: string, updates: Partial<ScreenDefinition>) => {
const handleTemplateUpdate = (
screenId: string,
updates: Partial<ScreenDefinition>
) => {
dispatch({
type: "update-screen",
payload: { screenId, screen: updates },
});
};
const handleVariantsChange = (screenId: string, variants: BuilderScreen["variants"]) => {
const handleVariantsChange = (
screenId: string,
variants: BuilderScreen["variants"]
) => {
dispatch({
type: "update-screen",
payload: { screenId, screen: { variants } },
@ -70,9 +79,11 @@ export function ScreenSettingsPanel({ screen }: ScreenSettingsPanelProps) {
<div className="flex items-center justify-between px-4 py-3 border-b border-border/60 bg-muted/30">
<div className="flex-1">
<div className="text-sm font-semibold text-foreground truncate">
{screen.title.text || "Без названия"}
{screen.title?.text || "Без названия"}
</div>
<span className="text-xs text-muted-foreground">{screen.template}</span>
<span className="text-xs text-muted-foreground">
{screen.template}
</span>
</div>
<Button
variant="ghost"

View File

@ -9,7 +9,8 @@ import {
Ticket,
Loader,
Heart,
Mail
Mail,
CreditCard
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -85,6 +86,13 @@ const TEMPLATE_OPTIONS = [
icon: Ticket,
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",
},
] as const;
export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDialogProps) {

View File

@ -2,7 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderSelectedScreen,
useBuilderState,
} from "@/lib/admin/builder/context";
import { renderScreen } from "@/lib/funnel/screenRenderer";
import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants";
import { PreviewErrorBoundary } from "@/components/admin/ErrorBoundary";
@ -20,7 +23,9 @@ export function BuilderPreview() {
const selectedScreen = useBuilderSelectedScreen();
const builderState = useBuilderState();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(null);
const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(
null
);
useEffect(() => {
if (!selectedScreen) {
@ -39,15 +44,20 @@ export function BuilderPreview() {
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedIds((prev) => {
if (prev.length === ids.length && prev.every((value, index) => value === ids[index])) {
if (
prev.length === ids.length &&
prev.every((value, index) => value === ids[index])
) {
return prev;
}
return ids;
});
}, []);
const variants = useMemo(() => selectedScreen?.variants ?? [], [selectedScreen]);
const variants = useMemo(
() => selectedScreen?.variants ?? [],
[selectedScreen]
);
useEffect(() => {
setPreviewVariantIndex(null);
@ -70,6 +80,7 @@ export function BuilderPreview() {
return mergeScreenWithOverrides(selectedScreen, variant.overrides ?? {});
}, [previewVariantIndex, selectedScreen, variants]);
const renderScreenPreview = useCallback(() => {
if (!previewScreen) return null;
@ -94,10 +105,11 @@ export function BuilderPreview() {
answers: {}, // Mock empty answers для превью
});
} catch (error) {
console.error('Error rendering preview:', error);
console.error("Error rendering preview:", error);
return (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border text-sm text-muted-foreground">
Ошибка при отображении превью: {error instanceof Error ? error.message : 'Неизвестная ошибка'}
Ошибка при отображении превью:{" "}
{error instanceof Error ? error.message : "Неизвестная ошибка"}
</div>
);
}
@ -106,7 +118,13 @@ export function BuilderPreview() {
const preview = useMemo(() => {
if (!previewScreen) {
return (
<div className="flex items-center justify-center mx-auto" style={{ height: `${PREVIEW_DIMENSIONS.EMPTY_HEIGHT}px`, width: `${PREVIEW_DIMENSIONS.WIDTH}px` }}>
<div
className="flex items-center justify-center mx-auto"
style={{
height: `${PREVIEW_DIMENSIONS.EMPTY_HEIGHT}px`,
width: `${PREVIEW_DIMENSIONS.WIDTH}px`,
}}
>
<div className="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground w-full h-full">
Выберите экран для предпросмотра
</div>
@ -128,9 +146,17 @@ export function BuilderPreview() {
</span>
<select
className="rounded-md border border-border bg-background px-2 py-1 text-xs"
value={previewVariantIndex === null ? "base" : String(previewVariantIndex)}
value={
previewVariantIndex === null
? "base"
: String(previewVariantIndex)
}
onChange={(event) =>
setPreviewVariantIndex(event.target.value === "base" ? null : Number(event.target.value))
setPreviewVariantIndex(
event.target.value === "base"
? null
: Number(event.target.value)
)
}
>
<option value="base">Основной экран</option>
@ -143,7 +169,8 @@ export function BuilderPreview() {
</div>
{previewVariantIndex !== null && (
<div className="mt-2 rounded border border-blue-200 bg-blue-50 px-2 py-1 text-[11px] text-blue-700 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
Превью принудительно показывает вариант. В реальной воронке он показывается только при выполнении условий.
Превью принудительно показывает вариант. В реальной воронке
он показывается только при выполнении условий.
</div>
)}
</div>
@ -155,10 +182,10 @@ export function BuilderPreview() {
style={{
height: PREVIEW_HEIGHT,
width: PREVIEW_WIDTH,
overflow: 'hidden', // Hide anything that goes outside
contain: 'layout style paint', // CSS containment
isolation: 'isolate', // Create new stacking context
transform: 'translateZ(0)' // Force new layer
overflow: "hidden", // Hide anything that goes outside
contain: "layout style paint", // CSS containment
isolation: "isolate", // Create new stacking context
transform: "translateZ(0)", // Force new layer
}}
>
{/* Screen Content with scroll - wrapped in Error Boundary */}

View File

@ -11,6 +11,7 @@ import { ListScreenConfig } from "./ListScreenConfig";
import { EmailScreenConfig } from "./EmailScreenConfig";
import { LoadersScreenConfig } from "./LoadersScreenConfig";
import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig";
import { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
@ -28,6 +29,7 @@ import type {
TypographyVariant,
BottomActionButtonDefinition,
HeaderDefinition,
TrialPaymentScreenDefinition,
} from "@/lib/funnel/types";
const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"];
@ -539,6 +541,12 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
onUpdate={onUpdate as (updates: Partial<SoulmatePortraitScreenDefinition>) => void}
/>
)}
{template === "trialPayment" && (
<TrialPaymentScreenConfig
screen={screen as BuilderScreen & { template: "trialPayment" }}
onUpdate={onUpdate as (updates: Partial<TrialPaymentScreenDefinition>) => void}
/>
)}
</div>
);
}

View File

@ -0,0 +1,717 @@
"use client";
import React from "react";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { TrialPaymentScreenDefinition } from "@/lib/funnel/types";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import { Button } from "@/components/ui/button";
import { Trash } from "lucide-react";
interface TrialPaymentScreenConfigProps {
screen: BuilderScreen & { template: "trialPayment" };
onUpdate: (updates: Partial<TrialPaymentScreenDefinition>) => void;
}
export function TrialPaymentScreenConfig({ screen, onUpdate }: TrialPaymentScreenConfigProps) {
const updateHeaderBlock = (updates: Partial<NonNullable<TrialPaymentScreenDefinition["headerBlock"]>>) => {
onUpdate({ headerBlock: { ...screen.headerBlock, ...updates } });
};
const updateUnlock = (
updates: Partial<NonNullable<TrialPaymentScreenDefinition["unlockYourSketch"]>>
) => {
onUpdate({ unlockYourSketch: { ...screen.unlockYourSketch, ...updates } });
};
const updatePaymentButtons = (
index: number,
field: "text" | "icon" | "primary",
value: string | boolean
) => {
const current = screen.paymentButtons?.buttons ?? [];
const buttons = current.map((b, i) =>
i === index
? {
...b,
...(field === "text" ? { text: String(value) } : {}),
...(field === "icon" ? { icon: String(value) as "pay" | "google" | "card" } : {}),
...(field === "primary" ? { primary: Boolean(value) } : {}),
}
: b
);
onUpdate({ paymentButtons: { buttons } });
};
const updateFooterContacts = (
field: "email" | "address" | "title",
value: { href: string; text: string } | { text: string }
) => {
const next = { ...(screen.footer?.contacts ?? {}) } as NonNullable<TrialPaymentScreenDefinition["footer"]>["contacts"];
if (field === "email") next!.email = value as { href: string; text: string };
if (field === "address") next!.address = value as { text: string };
if (field === "title") next!.title = { text: (value as { text: string }).text };
onUpdate({ footer: { ...screen.footer, contacts: next } });
};
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Header Block</h4>
<div className="space-y-3">
<TextInput
label="Текст"
value={screen.headerBlock?.text?.text ?? ""}
onChange={(e) => updateHeaderBlock({ text: { ...(screen.headerBlock?.text ?? {}), text: e.target.value } })}
/>
<TextInput
label="Таймер (сек)"
type="number"
value={String(screen.headerBlock?.timerSeconds ?? 600)}
onChange={(e) => updateHeaderBlock({ timerSeconds: Number(e.target.value) })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Reviews</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.reviews?.title?.text ?? ""}
onChange={(e) => onUpdate({ reviews: { title: { text: e.target.value }, items: screen.reviews?.items ?? [] } })}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Items</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.reviews?.items ?? [];
const items = [
...current,
{ name: { text: "" }, text: { text: "" }, rating: 5 },
];
onUpdate({ reviews: { ...screen.reviews, items } });
}}
>
Добавить
</Button>
</div>
{(screen.reviews?.items ?? []).map((r, idx) => (
<div key={idx} className="space-y-2 border border-border/60 rounded-md p-3">
<TextInput
label={`Review #${idx + 1} name`}
value={r.name.text}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, name: { text: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextInput
label="Date"
value={r.date?.text ?? ""}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, date: { text: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextAreaInput
label="Text (supports **bold**)"
rows={3}
value={r.text.text}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, text: { text: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextInput
label="Avatar src"
value={r.avatar?.src ?? ""}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, avatar: { src: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextInput
label="Portrait src"
value={r.portrait?.src ?? ""}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, portrait: { src: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextInput
label="Photo src"
value={r.photo?.src ?? ""}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, photo: { src: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<div className="space-y-2">
<TextInput
label="Rating (1-5)"
type="number"
value={String(r.rating ?? 5)}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, rating: Number(e.target.value) } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.reviews?.items ?? [];
const items = current.filter((_, i) => i !== idx);
onUpdate({ reviews: { ...screen.reviews, items } });
}}
aria-label="Удалить отзыв"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Common Questions</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.commonQuestions?.title?.text ?? ""}
onChange={(e) => onUpdate({ commonQuestions: { title: { text: e.target.value }, items: screen.commonQuestions?.items ?? [] } })}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Items</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.commonQuestions?.items ?? [];
const items = [...current, { question: "", answer: "" }];
onUpdate({ commonQuestions: { ...screen.commonQuestions, items } });
}}
>
Добавить
</Button>
</div>
{(screen.commonQuestions?.items ?? []).map((q, idx) => (
<div key={idx} className="grid grid-cols-1 gap-2 border border-border/60 rounded-md p-3">
<TextInput
label={`Question #${idx + 1}`}
value={q.question}
onChange={(e) => {
const current = screen.commonQuestions?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, question: e.target.value } : v));
onUpdate({ commonQuestions: { ...screen.commonQuestions, items } });
}}
/>
<TextAreaInput
label="Answer"
rows={2}
value={q.answer}
onChange={(e) => {
const current = screen.commonQuestions?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, answer: e.target.value } : v));
onUpdate({ commonQuestions: { ...screen.commonQuestions, items } });
}}
/>
<div className="flex items-center justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.commonQuestions?.items ?? [];
const items = current.filter((_, i) => i !== idx);
onUpdate({ commonQuestions: { ...screen.commonQuestions, items } });
}}
aria-label="Удалить вопрос"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Progress To See Soulmate</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.progressToSeeSoulmate?.title?.text ?? ""}
onChange={(e) => onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, title: { text: e.target.value } } })}
/>
<TextInput
label="Progress value (0-100)"
type="number"
value={String(screen.progressToSeeSoulmate?.progress?.value ?? 0)}
onChange={(e) => onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, progress: { value: Number(e.target.value) } } })}
/>
<TextInput
label="Left text"
value={screen.progressToSeeSoulmate?.leftText?.text ?? ""}
onChange={(e) => onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, leftText: { text: e.target.value } } })}
/>
<TextInput
label="Right text"
value={screen.progressToSeeSoulmate?.rightText?.text ?? ""}
onChange={(e) => onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, rightText: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Steps To See Soulmate</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Steps</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = [
...current,
{ title: { text: "" }, description: { text: "" }, icon: "questions" as const, isActive: false },
];
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
>
Добавить шаг
</Button>
</div>
{(screen.stepsToSeeSoulmate?.steps ?? []).map((step, idx) => (
<div key={idx} className="grid grid-cols-1 gap-2 border border-border/60 rounded-md p-3">
<div className="space-y-3">
<TextInput
label={`Step #${idx + 1} title`}
value={step.title.text}
onChange={(e) => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.map((s, i) => (i === idx ? { ...s, title: { text: e.target.value } } : s));
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
/>
<TextInput
label="Icon (questions|profile|sketch|astro|chat)"
value={(step.icon ?? "") as string}
onChange={(e) => {
const icon = e.target.value as "questions" | "profile" | "sketch" | "astro" | "chat";
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.map((s, i) => (i === idx ? { ...s, icon } : s));
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
/>
</div>
<TextAreaInput
label="Description"
rows={2}
value={step.description.text}
onChange={(e) => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.map((s, i) => (i === idx ? { ...s, description: { text: e.target.value } } : s));
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
/>
<div className="flex items-center justify-between">
<label className="text-xs flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(step.isActive)}
onChange={(e) => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.map((s, i) => (i === idx ? { ...s, isActive: e.target.checked } : s));
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
/>
Active
</label>
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.filter((_, i) => i !== idx);
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
aria-label="Удалить шаг"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Money Back Guarantee</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.moneyBackGuarantee?.title?.text ?? ""}
onChange={(e) => onUpdate({ moneyBackGuarantee: { ...screen.moneyBackGuarantee, title: { text: e.target.value } } })}
/>
<TextAreaInput
label="Text"
value={screen.moneyBackGuarantee?.text?.text ?? ""}
onChange={(e) => onUpdate({ moneyBackGuarantee: { ...screen.moneyBackGuarantee, text: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Policy</h4>
<div className="grid grid-cols-1 gap-3">
<TextAreaInput
label="Text"
rows={3}
value={screen.policy?.text?.text ?? ""}
onChange={(e) => onUpdate({ policy: { text: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Users&apos; Portraits</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.usersPortraits?.title?.text ?? ""}
onChange={(e) => onUpdate({ usersPortraits: { ...screen.usersPortraits, title: { text: e.target.value } } })}
/>
<TextInput
label="Button text"
value={screen.usersPortraits?.buttonText ?? ""}
onChange={(e) => onUpdate({ usersPortraits: { ...screen.usersPortraits, buttonText: e.target.value } })}
/>
</div>
<div className="space-y-2 mt-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Images</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.usersPortraits?.images ?? [];
const images = [...current, { src: "" }];
onUpdate({ usersPortraits: { ...screen.usersPortraits, images } });
}}
>
Добавить
</Button>
</div>
{(screen.usersPortraits?.images ?? []).map((img, idx) => (
<div key={idx} className="space-y-2">
<TextInput
label={`Image #${idx + 1} src`}
value={img.src}
onChange={(e) => {
const current = screen.usersPortraits?.images ?? [];
const images = current.map((v, i) => (i === idx ? { ...v, src: e.target.value } : v));
onUpdate({ usersPortraits: { ...screen.usersPortraits, images } });
}}
/>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.usersPortraits?.images ?? [];
const images = current.filter((_, i) => i !== idx);
onUpdate({ usersPortraits: { ...screen.usersPortraits, images } });
}}
aria-label="Удалить изображение"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Joined Today With Avatars</h4>
<div className="space-y-3">
<TextInput
label="Count"
value={screen.joinedTodayWithAvatars?.count?.text ?? ""}
onChange={(e) => onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, count: { text: e.target.value } } })}
/>
<TextInput
label="Text"
value={screen.joinedTodayWithAvatars?.text?.text ?? ""}
onChange={(e) => onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, text: { text: e.target.value } } })}
/>
</div>
<div className="space-y-2 mt-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Avatars</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.joinedTodayWithAvatars?.avatars?.images ?? [];
const images = [...current, { src: "" }];
onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, avatars: { images } } });
}}
>
Добавить
</Button>
</div>
{(screen.joinedTodayWithAvatars?.avatars?.images ?? []).map((img, idx) => (
<div key={idx} className="space-y-2">
<TextInput
label={`Avatar #${idx + 1} src`}
value={img.src}
onChange={(e) => {
const current = screen.joinedTodayWithAvatars?.avatars?.images ?? [];
const images = current.map((v, i) => (i === idx ? { ...v, src: e.target.value } : v));
onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, avatars: { images } } });
}}
/>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.joinedTodayWithAvatars?.avatars?.images ?? [];
const images = current.filter((_, i) => i !== idx);
onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, avatars: { images } } });
}}
aria-label="Удалить аватар"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Try For Days</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.tryForDays?.title?.text ?? ""}
onChange={(e) => onUpdate({ tryForDays: { ...screen.tryForDays, title: { text: e.target.value } } })}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Items</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.tryForDays?.textList?.items ?? [];
const items = [...current, { text: "" }];
onUpdate({ tryForDays: { ...screen.tryForDays, textList: { items } } });
}}
>
Добавить
</Button>
</div>
{(screen.tryForDays?.textList?.items ?? []).map((it, idx) => (
<div key={idx} className="space-y-2">
<TextAreaInput
label={`Item #${idx + 1}`}
rows={2}
value={it.text}
onChange={(e) => {
const current = screen.tryForDays?.textList?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, text: e.target.value } : v));
onUpdate({ tryForDays: { ...screen.tryForDays, textList: { items } } });
}}
/>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.tryForDays?.textList?.items ?? [];
const items = current.filter((_, i) => i !== idx);
onUpdate({ tryForDays: { ...screen.tryForDays, textList: { items } } });
}}
aria-label="Удалить элемент"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Total Price</h4>
<div className="space-y-3">
<TextInput
label="Coupon title"
value={screen.totalPrice?.couponContainer?.title?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: { ...(screen.totalPrice?.couponContainer ?? {}), title: { text: e.target.value } }, priceContainer: screen.totalPrice?.priceContainer } })}
/>
<TextInput
label="Coupon button text"
value={screen.totalPrice?.couponContainer?.buttonText ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: { ...(screen.totalPrice?.couponContainer ?? {}), buttonText: e.target.value }, priceContainer: screen.totalPrice?.priceContainer } })}
/>
<TextInput
label="Price title"
value={screen.totalPrice?.priceContainer?.title?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), title: { text: e.target.value } } } })}
/>
<TextInput
label="Price"
value={screen.totalPrice?.priceContainer?.price?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), price: { text: e.target.value } } } })}
/>
<TextInput
label="Old price"
value={screen.totalPrice?.priceContainer?.oldPrice?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), oldPrice: { text: e.target.value } } } })}
/>
<TextInput
label="Discount"
value={screen.totalPrice?.priceContainer?.discount?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), discount: { text: e.target.value } } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Joined Today</h4>
<div className="space-y-3">
<TextInput
label="Count"
value={screen.joinedToday?.count?.text ?? ""}
onChange={(e) => onUpdate({ joinedToday: { ...screen.joinedToday, count: { text: e.target.value } } })}
/>
<TextInput
label="Text"
value={screen.joinedToday?.text?.text ?? ""}
onChange={(e) => onUpdate({ joinedToday: { ...screen.joinedToday, text: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Trusted By Over</h4>
<div className="space-y-3">
<TextInput
label="Text"
value={screen.trustedByOver?.text?.text ?? ""}
onChange={(e) => onUpdate({ trustedByOver: { text: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Finding The One Guide</h4>
<div className="space-y-3">
<TextInput
label="Emoji"
value={screen.findingOneGuide?.header?.emoji?.text ?? ""}
onChange={(e) => onUpdate({ findingOneGuide: { ...screen.findingOneGuide, header: { ...(screen.findingOneGuide?.header ?? {}), emoji: { text: e.target.value } } } })}
/>
<TextInput
label="Title"
value={screen.findingOneGuide?.header?.title?.text ?? ""}
onChange={(e) => onUpdate({ findingOneGuide: { ...screen.findingOneGuide, header: { ...(screen.findingOneGuide?.header ?? {}), title: { text: e.target.value } } } })}
/>
<TextAreaInput
label="Text"
value={screen.findingOneGuide?.text?.text ?? ""}
onChange={(e) => onUpdate({ findingOneGuide: { ...screen.findingOneGuide, text: { text: e.target.value } } })}
/>
<TextInput
label="Blur text"
value={screen.findingOneGuide?.blur?.text?.text ?? ""}
onChange={(e) => onUpdate({ findingOneGuide: { ...screen.findingOneGuide, blur: { ...(screen.findingOneGuide?.blur ?? {}), text: { text: e.target.value }, icon: "lock" } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Unlock Your Sketch</h4>
<div className="space-y-3">
<TextInput label="Заголовок" value={screen.unlockYourSketch?.title?.text ?? ""} onChange={(e) => updateUnlock({ title: { text: e.target.value } })} />
<TextInput label="Подзаголовок" value={screen.unlockYourSketch?.subtitle?.text ?? ""} onChange={(e) => updateUnlock({ subtitle: { text: e.target.value } })} />
<TextInput label="Изображение" value={screen.unlockYourSketch?.image?.src ?? ""} onChange={(e) => updateUnlock({ image: { src: e.target.value } })} />
<TextInput label="Текст на блюре" value={screen.unlockYourSketch?.blur?.text?.text ?? ""} onChange={(e) => updateUnlock({ blur: { ...(screen.unlockYourSketch?.blur ?? {}), text: { text: e.target.value }, icon: "lock" } as NonNullable<TrialPaymentScreenDefinition["unlockYourSketch"]>["blur"] })} />
<TextInput label="Текст кнопки" value={screen.unlockYourSketch?.buttonText ?? ""} onChange={(e) => updateUnlock({ buttonText: e.target.value })} />
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Payment Buttons</h4>
{(screen.paymentButtons?.buttons ?? []).map((b, i) => (
<div key={i} className="space-y-2 mb-2">
<TextInput label={`Текст #${i + 1}`} value={b.text} onChange={(e) => updatePaymentButtons(i, "text", e.target.value)} />
<TextInput label="Иконка (pay|google|card)" value={("icon" in b ? (b as { icon?: "pay"|"google"|"card" }).icon ?? "" : "")} onChange={(e) => updatePaymentButtons(i, "icon", e.target.value)} />
<label className="text-xs flex items-center gap-2"><input type="checkbox" checked={("primary" in b ? (b as { primary?: boolean }).primary ?? false : false)} onChange={(e) => updatePaymentButtons(i, "primary", e.target.checked)} /> Primary</label>
</div>
))}
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Footer / Contacts</h4>
<div className="space-y-3">
<TextInput label="Email" value={screen.footer?.contacts?.email?.text ?? screen.footer?.contacts?.email?.href ?? ""} onChange={(e) => updateFooterContacts("email", { href: e.target.value, text: e.target.value })} />
<TextAreaInput label="Адрес" value={screen.footer?.contacts?.address?.text ?? ""} onChange={(e) => updateFooterContacts("address", { text: e.target.value })} />
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Still Have Questions</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.stillHaveQuestions?.title?.text ?? ""}
onChange={(e) => onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, title: { text: e.target.value } } })}
/>
<TextInput
label="Action button"
value={screen.stillHaveQuestions?.actionButtonText ?? ""}
onChange={(e) => onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, actionButtonText: e.target.value } })}
/>
<TextInput
label="Contact button"
value={screen.stillHaveQuestions?.contactButtonText ?? ""}
onChange={(e) => onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, contactButtonText: e.target.value } })}
/>
</div>
</div>
</div>
);
}

View File

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

View File

@ -47,7 +47,7 @@ export function EmailTemplate({
defaultTexts,
}: EmailTemplateProps) {
const { authorization, isLoading, error } = useAuth({
funnelId: funnel.meta.id,
funnelId: funnel?.meta?.id ?? "preview",
});
const [isTouched, setIsTouched] = useState(false);

View File

@ -0,0 +1,266 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { TrialPaymentTemplate } from "./TrialPaymentTemplate";
import { fn } from "storybook/test";
import type { TrialPaymentScreenDefinition } from "@/lib/funnel/types";
const defaultScreen: TrialPaymentScreenDefinition = {
id: "trial-payment-screen-story",
template: "trialPayment",
title: { text: "" },
subtitle: { text: "" },
bottomActionButton: { show: false, showPrivacyTermsConsent: false },
headerBlock: {
timerSeconds: 600,
text: { text: "⚠️ Your sketch expires soon!" },
timer: { text: "" },
},
unlockYourSketch: {
title: { text: "Unlock Your Sketch" },
subtitle: { text: "Just One Click to Reveal Your Match!" },
image: { src: "/trial-payment/portrait-female.jpg" },
blur: { text: { text: "Unlock to reveal your personalized portrait" }, icon: "lock" },
buttonText: "Get Me Soulmate Sketch",
},
joinedToday: {
count: { text: "954" },
text: { text: "Joined today" },
},
trustedByOver: {
text: { text: "Trusted by over 355,000 people." },
},
findingOneGuide: {
header: {
emoji: { text: "❤️" },
title: { text: "Finding the One Guide" },
},
text: {
text:
"You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're. You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're",
},
blur: { text: { text: "Чтобы открыть весь отчёт, нужен полный доступ." }, icon: "lock" },
},
tryForDays: {
title: { text: "Попробуйте в течение 7 дней!" },
textList: {
items: [
{ text: "Receive a hand-drawn sketch of your soulmate, crafted by a trained AI-model." },
{ text: "Reveal the path to your soulmate with the Finding the One guide." },
{ text: "Talk to live experts and get guidance on finding your soulmate." },
{ text: "Start your 7-day trial for just $1.00 — then only $14.50/week for full access." },
{ text: "Cancel anytime—just 24 hours before renewal." },
],
},
},
totalPrice: {
couponContainer: {
title: { text: "Coupon\nCode" },
buttonText: "SOULMATE94",
},
priceContainer: {
title: { text: "Total" },
price: { text: "$1.00" },
oldPrice: { text: "$14.99" },
discount: { text: "94% discount applied" },
},
},
paymentButtons: {
buttons: [
{ text: "Pay", icon: "pay" },
{ text: "Pay", icon: "google" },
{ text: "Credit or debit card", icon: "card", primary: true },
],
},
moneyBackGuarantee: {
title: { text: "30-DAY MONEY-BACK GUARANTEE" },
text: { text: "If you don't receive your soulmate sketch, we'll refund your money!" },
},
policy: {
text: { text: "By clicking Continue, you agree to our Terms of Use & Service and Privacy Policy. You also acknowledge that your 1 week introductory plan to Respontika, billed at $1.00, will automatically renew at $14.50 every 1 week unless canceled before the end of the trial period." },
},
usersPortraits: {
title: { text: "Our Users' Soulmate Portraits" },
images: [
{ src: "/trial-payment/users-portraits/1.jpg" },
{ src: "/trial-payment/users-portraits/2.jpg" },
{ src: "/trial-payment/users-portraits/3.jpg" },
],
buttonText: "Get me soulmate sketch",
},
joinedTodayWithAvatars: {
count: { text: "954" },
text: { text: "people joined today" },
avatars: {
images: [
{ src: "/trial-payment/avatars/1.jpg" },
{ src: "/trial-payment/avatars/2.jpg" },
{ src: "/trial-payment/avatars/3.jpg" },
{ src: "/trial-payment/avatars/4.jpg" },
{ src: "/trial-payment/avatars/5.jpg" },
],
},
},
progressToSeeSoulmate: {
title: { text: "See Your Soulmate Just One Step Away" },
progress: { value: 92 },
leftText: { text: "Step 2 of 5" },
rightText: { text: "99% Complete" },
},
stepsToSeeSoulmate: {
steps: [
{
title: { text: "Questions Answered" },
description: { text: "You've provided all the necessary information about your preferences and personality." },
icon: "questions",
isActive: true,
},
{
title: { text: "Profile Analysis" },
description: { text: "Our advanced system is creating your perfect soulmate profile." },
icon: "profile",
isActive: true,
},
{
title: { text: "Sketch Creation" },
description: { text: "Your personalized soulmate sketch will be created." },
icon: "sketch",
isActive: false,
},
{
title: { text: "Астрологические Идеи" },
description: { text: "Уникальные астрологические рекомендации, усиливающие совместимость." },
icon: "astro",
isActive: false,
},
{
title: { text: "Персонализированный чат с экспертом" },
description: { text: "Персональные советы от экспертов по отношениям." },
icon: "chat",
isActive: false,
},
],
buttonText: "Show Me My Soulmate",
},
reviews: {
title: { text: "Loved and Trusted Worldwide" },
items: [
{
name: { text: "Jennifer Wilson 🇺🇸" },
text: { text: "**“Я увидела свои ошибки… и нашла мужа”**\nПортрет сразу зацепил — было чувство, что я уже где-то его видела. Но настоящий перелом произошёл после гайда: я поняла, почему снова и снова выбирала «не тех». И самое удивительное — вскоре я познакомилась с мужчиной, который оказался точной копией того самого портрета. Сейчас он мой муж, и когда мы сравнили рисунок с его фото, сходство было просто вау." },
avatar: { src: "/trial-payment/reviews/avatars/1.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/1.jpg" },
photo: { src: "/trial-payment/reviews/photos/1.jpg" },
rating: 5,
date: { text: "1 day ago" },
},
{
name: { text: "Amanda Davis 🇨🇦" },
text: { text: "**“Я поняла своего партнёра лучше за один вечер, чем за несколько лет”**\nПрошла тест ради интереса — портрет нас удивил. Но настоящий прорыв случился, когда я прочитала гайд о второй половинке. Там были точные подсказки о том, как мы можем поддерживать друг друга. Цена смешная, а ценность огромная: теперь у нас меньше недопониманий и больше тепла." },
avatar: { src: "/trial-payment/reviews/avatars/2.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/2.jpg" },
photo: { src: "/trial-payment/reviews/photos/2.jpg" },
rating: 5,
date: { text: "4 days ago" },
},
{
name: { text: "Michael Johnson 🇬🇧" },
text: { text: "**“Увидел её лицо — и мурашки по коже”**\nКогда пришёл результат теста и показали портрет, я реально замер. Это была та самая девушка, с которой я начал встречаться пару недель назад. И гайд прямо описал, почему мы тянемся друг к другу. Честно, я не ожидал такого совпадения." },
avatar: { src: "/trial-payment/reviews/avatars/3.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/3.jpg" },
photo: { src: "/trial-payment/reviews/photos/3.jpg" },
rating: 5,
date: { text: "1 week ago" },
},
],
},
commonQuestions: {
title: { text: "Common Questions" },
items: [
{
question: "When will I receive my sketch?",
answer:
"Your personalized soulmate sketch will be delivered within 24-48 hours after completing your order. You'll receive an email notification when it's ready for viewing in your account.",
},
{
question: "How do I cancel my subscription?",
answer:
"You can cancel anytime from your account settings. Make sure to cancel at least 24 hours before the renewal date to avoid being charged.",
},
{
question: "How accurate are the readings?",
answer:
"Our readings are based on a combination of your answers and advanced pattern analysis. While they provide valuable insights, they are intended for guidance and entertainment purposes.",
},
{
question: "Is my data secure and private?",
answer:
"Yes. We follow strict data protection standards. Your data is encrypted and never shared with third parties without your consent.",
},
],
},
stillHaveQuestions: {
title: { text: "Still have questions? We're here to help!" },
actionButtonText: "Get me Soulmate Sketch",
contactButtonText: "Contact Support",
},
footer: {
title: { text: "WIT LAB ©" },
contacts: {
title: { text: "CONTACTS" },
email: { href: "support@witlab.com", text: "support@witlab.com" },
address: { text: "Wit Lab 2108 N ST STE N SACRAMENTO, CA95816, US" },
},
legal: {
title: { text: "LEGAL" },
links: [
{ href: "https://witlab.com/terms", text: "Terms of Service" },
{ href: "https://witlab.com/privacy", text: "Privacy Policy" },
{ href: "https://witlab.com/refund", text: "Refund Policy" },
],
copyright: {
text:
"Copyright © 2025 Wit Lab™. All rights reserved. All trademarks referenced herein are the properties of their respective owners.",
},
},
paymentMethods: {
title: { text: "PAYMENT METHODS" },
methods: [
{ src: "/trial-payment/payment-methods/visa.svg", alt: "visa" },
{ src: "/trial-payment/payment-methods/mastercard.svg", alt: "mastercard" },
{ src: "/trial-payment/payment-methods/discover.svg", alt: "discover" },
{ src: "/trial-payment/payment-methods/apple.svg", alt: "apple" },
{ src: "/trial-payment/payment-methods/google.svg", alt: "google" },
{ src: "/trial-payment/payment-methods/paypal.svg", alt: "paypal" },
],
},
},
};
const meta: Meta<typeof TrialPaymentTemplate> = {
title: "Funnel Templates/TrialPaymentTemplate",
component: TrialPaymentTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 8, total: 10 },
defaultTexts: {
nextButton: "Continue",
continueButton: "Continue",
},
},
argTypes: {
screen: { control: { type: "object" } },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@ -0,0 +1,870 @@
"use client";
import type {
TrialPaymentScreenDefinition,
DefaultTexts,
FunnelDefinition,
} from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
import { cn } from "@/lib/utils";
import { useRef } from "react";
import {
Header,
JoinedToday,
TrustedByOver,
JoinedTodayWithAvatars,
} from "@/components/domains/TrialPayment";
import { UnlockYourSketch } from "@/components/domains/TrialPayment/Cards";
import {
FindingOneGuide,
TryForDays,
TotalPrice,
PaymentButtons,
UsersPortraits,
} from "@/components/domains/TrialPayment/Cards";
import { MoneyBackGuarantee, Policy } from "@/components/domains/TrialPayment";
import {
StepsToSeeSoulmate,
Reviews,
CommonQuestions,
StillHaveQuestions,
Footer,
} from "@/components/domains/TrialPayment/Cards";
import ProgressToSeeSoulmate from "@/components/domains/TrialPayment/ProgressToSeeSoulmate/ProgressToSeeSoulmate";
import { buildTypographyProps } from "@/lib/funnel/mappers";
// import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement";
// import { useRouter } from "next/navigation";
// import { ROUTES } from "@/shared/constants/client-routes";
interface TrialPaymentTemplateProps {
funnel: FunnelDefinition;
screen: TrialPaymentScreenDefinition;
// selectedEmail: string;
// onEmailChange: (email: string) => void;
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: DefaultTexts;
}
export function TrialPaymentTemplate({
// funnel,
screen,
// selectedEmail,
// onEmailChange,
// onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}: TrialPaymentTemplateProps) {
// const router = useRouter();
// TODO: выбрать корректный paymentId для этого экрана (ключ из backend), временно "main_secret_discount"
// const paymentId = "main_secret_discount";
// const { placement } = usePaymentPlacement({ funnel, paymentId });
const handlePayClick = () => {
// const productId =
// placement?.variants?.[0]?.id || placement?.variants?.[0]?.key || "";
// const placementId = placement?.placementId || "";
// const paywallId = placement?.paywallId || "";
// if (productId && placementId && paywallId) {
// router.push(
// ROUTES.payment({
// productId,
// placementId,
// paywallId,
// })
// );
// }
};
const paymentSectionRef = useRef<HTMLDivElement | null>(null);
const scrollToPayment = () => {
if (paymentSectionRef.current) {
paymentSectionRef.current.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
};
// Отключаем общий Header в TemplateLayout для этого экрана
const screenWithoutHeader = {
...screen,
header: {
...(screen.header || {}),
show: false,
showBackButton: false,
showProgress: false,
},
} as typeof screen;
// Убираем title/subtitle из общего Layout для этого экрана
const screenForLayout = {
...screenWithoutHeader,
title: undefined,
subtitle: undefined,
} as typeof screenWithoutHeader;
const layoutProps = createTemplateLayoutProps(
screenForLayout,
{ canGoBack, onBack },
screenProgress,
{
preset: "center",
actionButton:
screen.bottomActionButton?.show === false
? undefined
: {
defaultText: defaultTexts?.nextButton || "Continue",
disabled: false,
onClick: scrollToPayment,
},
}
);
return (
<TemplateLayout
{...layoutProps}
contentProps={{ className: "p-0 pt-0" }}
childrenWrapperProps={{ className: "mt-0" }}
>
<div
className={cn(
"w-full min-h-dvh max-w-[560px] mx-auto bg-trial-payment-secondary",
"px-[17px] py-1.5 pb-[82px]",
"flex flex-col items-center"
)}
>
{/* Header block */}
{screen.headerBlock && (
<Header
className="mt-3"
text={buildTypographyProps(screen.headerBlock.text, {
as: "p",
defaults: { font: "inter", weight: "semiBold", size: "sm" },
})}
timer={buildTypographyProps(screen.headerBlock.timer, {
as: "span",
defaults: { font: "inter", weight: "bold", size: "2xl" },
})}
timerHookProps={{
initialSeconds: screen.headerBlock.timerSeconds ?? 600,
}}
button={{
children: defaultTexts?.continueButton || "Continue",
onClick: scrollToPayment,
}}
/>
)}
{/* UnlockYourSketch section */}
{screen.unlockYourSketch && (
<UnlockYourSketch
title={buildTypographyProps(screen.unlockYourSketch.title, {
as: "h3",
defaults: { font: "inter", weight: "bold", color: "default" },
})}
subtitle={buildTypographyProps(screen.unlockYourSketch.subtitle, {
as: "p",
defaults: {
font: "inter",
weight: "semiBold",
size: "xl",
color: "default",
align: "center",
},
})}
image={
screen.unlockYourSketch.image
? { src: screen.unlockYourSketch.image.src, alt: "portrait" }
: undefined
}
blur={{
text: {
...(buildTypographyProps(screen.unlockYourSketch.blur?.text, {
as: "p",
defaults: {
font: "inter",
weight: "semiBold",
align: "center",
},
}) ?? { as: "p", children: "" }),
className: "text-[#A16207]",
},
icon:
screen.unlockYourSketch.blur?.icon === "lock" ? (
<svg
width="18"
height="21"
viewBox="0 0 18 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.625 6.125V8H11.875V6.125C11.875 4.39844 10.4766 3 8.75 3C7.02344 3 5.625 4.39844 5.625 6.125ZM3.125 8V6.125C3.125 3.01953 5.64453 0.5 8.75 0.5C11.8555 0.5 14.375 3.01953 14.375 6.125V8H15C16.3789 8 17.5 9.12109 17.5 10.5V18C17.5 19.3789 16.3789 20.5 15 20.5H2.5C1.12109 20.5 0 19.3789 0 18V10.5C0 9.12109 1.12109 8 2.5 8H3.125Z"
fill="#A16207"
/>
</svg>
) : undefined,
}}
button={
screen.unlockYourSketch.buttonText
? {
children: screen.unlockYourSketch.buttonText,
onClick: scrollToPayment,
}
: undefined
}
/>
)}
{screen.joinedToday && (
<JoinedToday
className="mt-[18px]"
count={buildTypographyProps(screen.joinedToday.count, {
as: "span",
defaults: {
font: "inter",
weight: "bold",
size: "sm",
align: "center",
color: "muted",
},
})}
text={buildTypographyProps(screen.joinedToday.text, {
as: "p",
defaults: {
font: "inter",
size: "sm",
align: "center",
color: "muted",
},
})}
icon={
<svg
width="15"
height="13"
viewBox="0 0 15 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.23906 7.96384L7.18008 12.5767C7.38516 12.7681 7.65586 12.8748 7.9375 12.8748C8.21914 12.8748 8.48984 12.7681 8.69492 12.5767L13.6359 7.96384C14.4672 7.19001 14.9375 6.10447 14.9375 4.9697V4.81111C14.9375 2.89978 13.5566 1.27009 11.6727 0.95564C10.4258 0.747827 9.15703 1.15525 8.26562 2.04666L7.9375 2.37478L7.60938 2.04666C6.71797 1.15525 5.44922 0.747827 4.20234 0.95564C2.31836 1.27009 0.9375 2.89978 0.9375 4.81111V4.9697C0.9375 6.10447 1.40781 7.19001 2.23906 7.96384Z"
fill="#1047A2"
/>
</svg>
}
/>
)}
{screen.trustedByOver && (
<TrustedByOver
className="mt-[9px]"
text={buildTypographyProps(screen.trustedByOver.text, {
as: "p",
defaults: {
font: "inter",
size: "sm",
align: "center",
color: "muted",
},
})}
icon={
<svg
width="19"
height="15"
viewBox="0 0 19 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.6875 0.75C5.26766 0.75 5.82406 0.980468 6.2343 1.3907C6.64453 1.80094 6.875 2.35734 6.875 2.9375C6.875 3.51766 6.64453 4.07406 6.2343 4.4843C5.82406 4.89453 5.26766 5.125 4.6875 5.125C4.10734 5.125 3.55094 4.89453 3.1407 4.4843C2.73047 4.07406 2.5 3.51766 2.5 2.9375C2.5 2.35734 2.73047 1.80094 3.1407 1.3907C3.55094 0.980468 4.10734 0.75 4.6875 0.75ZM14.75 0.75C15.3302 0.75 15.8866 0.980468 16.2968 1.3907C16.707 1.80094 16.9375 2.35734 16.9375 2.9375C16.9375 3.51766 16.707 4.07406 16.2968 4.4843C15.8866 4.89453 15.3302 5.125 14.75 5.125C14.1698 5.125 13.6134 4.89453 13.2032 4.4843C12.793 4.07406 12.5625 3.51766 12.5625 2.9375C12.5625 2.35734 12.793 1.80094 13.2032 1.3907C13.6134 0.980468 14.1698 0.75 14.75 0.75ZM0.75 8.91758C0.75 7.30703 2.05703 6 3.66758 6H4.83516C5.26992 6 5.68281 6.0957 6.05469 6.26523C6.01914 6.46211 6.00273 6.66719 6.00273 6.875C6.00273 7.91953 6.46211 8.85742 7.18672 9.5C7.18125 9.5 7.17578 9.5 7.16758 9.5H1.33242C1.0125 9.5 0.75 9.2375 0.75 8.91758ZM11.8324 9.5C11.827 9.5 11.8215 9.5 11.8133 9.5C12.5406 8.85742 12.9973 7.91953 12.9973 6.875C12.9973 6.66719 12.9781 6.46484 12.9453 6.26523C13.3172 6.09297 13.7301 6 14.1648 6H15.3324C16.943 6 18.25 7.30703 18.25 8.91758C18.25 9.24023 17.9875 9.5 17.6676 9.5H11.8324ZM6.875 6.875C6.875 6.17881 7.15156 5.51113 7.64384 5.01884C8.13613 4.52656 8.80381 4.25 9.5 4.25C10.1962 4.25 10.8639 4.52656 11.3562 5.01884C11.8484 5.51113 12.125 6.17881 12.125 6.875C12.125 7.57119 11.8484 8.23887 11.3562 8.73116C10.8639 9.22344 10.1962 9.5 9.5 9.5C8.80381 9.5 8.13613 9.22344 7.64384 8.73116C7.15156 8.23887 6.875 7.57119 6.875 6.875ZM4.25 14.0199C4.25 12.0074 5.88242 10.375 7.89492 10.375H11.1051C13.1176 10.375 14.75 12.0074 14.75 14.0199C14.75 14.4219 14.4246 14.75 14.0199 14.75H4.98008C4.57812 14.75 4.25 14.4246 4.25 14.0199Z"
fill="#1047A2"
/>
</svg>
}
/>
)}
{screen.findingOneGuide && (
<FindingOneGuide
className="mt-[22px]"
header={{
emoji: buildTypographyProps(
screen.findingOneGuide.header?.emoji,
{
as: "span",
defaults: { size: "2xl" },
}
),
title: buildTypographyProps(
screen.findingOneGuide.header?.title,
{
as: "h3",
defaults: { font: "inter", weight: "bold" },
}
),
}}
text={buildTypographyProps(screen.findingOneGuide.text, {
as: "p",
defaults: { font: "inter", size: "sm", color: "muted" },
})}
blur={{
text: {
...(buildTypographyProps(screen.findingOneGuide.blur?.text, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
align: "center",
},
}) ?? { as: "p", children: "" }),
className: "text-[#A16207]",
},
icon:
screen.findingOneGuide.blur?.icon === "lock" ? (
<svg
width="18"
height="21"
viewBox="0 0 18 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.625 6.125V8H11.875V6.125C11.875 4.39844 10.4766 3 8.75 3C7.02344 3 5.625 4.39844 5.625 6.125ZM3.125 8V6.125C3.125 3.01953 5.64453 0.5 8.75 0.5C11.8555 0.5 14.375 3.01953 14.375 6.125V8H15C16.3789 8 17.5 9.12109 17.5 10.5V18C17.5 19.3789 16.3789 20.5 15 20.5H2.5C1.12109 20.5 0 19.3789 0 18V10.5C0 9.12109 1.12109 8 2.5 8H3.125Z"
fill="#A16207"
/>
</svg>
) : undefined,
}}
/>
)}
{screen.tryForDays && (
<TryForDays
className="mt-[46px]"
title={buildTypographyProps(screen.tryForDays.title, {
as: "h3",
defaults: { font: "inter", weight: "bold", size: "xl" },
})}
textListProps={
screen.tryForDays.textList
? {
listStyleType: "none",
items: screen.tryForDays.textList.items.map(
(it) =>
buildTypographyProps(it, {
as: "li",
defaults: { font: "inter", size: "sm" },
})!
),
}
: undefined
}
/>
)}
{screen.totalPrice && (
<TotalPrice
className="mt-[46px]"
couponContainer={{
title: buildTypographyProps(
screen.totalPrice.couponContainer.title,
{
as: "h4",
defaults: { font: "inter", weight: "semiBold" },
}
),
button: screen.totalPrice.couponContainer.buttonText
? {
children: screen.totalPrice.couponContainer.buttonText,
onClick: scrollToPayment,
}
: undefined,
}}
priceContainer={
screen.totalPrice.priceContainer
? {
title: buildTypographyProps(
screen.totalPrice.priceContainer.title,
{
as: "h4",
defaults: { font: "inter", weight: "bold", size: "xl" },
}
),
price: buildTypographyProps(
screen.totalPrice.priceContainer.price,
{
as: "span",
defaults: { font: "inter", weight: "black" },
}
),
oldPrice: buildTypographyProps(
screen.totalPrice.priceContainer.oldPrice,
{
as: "span",
defaults: { font: "inter" },
}
),
discount: buildTypographyProps(
screen.totalPrice.priceContainer.discount,
{
as: "span",
defaults: { font: "inter", weight: "bold", size: "sm" },
}
),
}
: undefined
}
/>
)}
{screen.paymentButtons && (
<div ref={paymentSectionRef} className="w-full">
<PaymentButtons
className="mt-[46px]"
buttons={screen.paymentButtons.buttons.map((b) => {
const icon =
b.icon === "pay" ? (
<svg
width="19"
height="24"
viewBox="0 0 19 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_49_113)">
<path
d="M15.8141 12.5953C15.8047 10.875 16.5828 9.57656 18.1578 8.62031C17.2766 7.35938 15.9453 6.66563 14.1875 6.52969C12.5234 6.39844 10.7047 7.5 10.0391 7.5C9.33594 7.5 7.72344 6.57656 6.45781 6.57656C3.84219 6.61875 1.0625 8.6625 1.0625 12.8203C1.0625 14.0484 1.2875 15.3172 1.7375 16.6266C2.3375 18.3469 4.50312 22.5656 6.7625 22.4953C7.94375 22.4672 8.77812 21.6563 10.3156 21.6563C11.8062 21.6563 12.5797 22.4953 13.8969 22.4953C16.175 22.4625 18.1344 18.6281 18.7062 16.9031C15.65 15.4641 15.8141 12.6844 15.8141 12.5953ZM13.1609 4.89844C14.4406 3.37969 14.3234 1.99688 14.2859 1.5C13.1562 1.56563 11.8484 2.26875 11.1031 3.13594C10.2828 4.06406 9.8 5.2125 9.90313 6.50625C11.1266 6.6 12.2422 5.97188 13.1609 4.89844Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_49_113">
<path d="M0.875 0H18.875V24H0.875V0Z" fill="white" />
</clipPath>
</defs>
</svg>
) : b.icon === "google" ? (
<svg
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
width="24"
height="24"
viewBox="0 0 48 48"
>
<path
fill="#FFC107"
d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"
></path>
<path
fill="#FF3D00"
d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"
></path>
<path
fill="#4CAF50"
d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"
></path>
<path
fill="#1976D2"
d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"
></path>
</svg>
) : b.icon === "card" ? (
<svg
width="19"
height="16"
viewBox="0 0 19 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_49_125)">
<path
d="M16.0312 2.5C16.3062 2.5 16.5312 2.725 16.5312 3V4H1.53125V3C1.53125 2.725 1.75625 2.5 2.03125 2.5H16.0312ZM16.5312 7V13C16.5312 13.275 16.3062 13.5 16.0312 13.5H2.03125C1.75625 13.5 1.53125 13.275 1.53125 13V7H16.5312ZM2.03125 1C0.928125 1 0.03125 1.89688 0.03125 3V13C0.03125 14.1031 0.928125 15 2.03125 15H16.0312C17.1344 15 18.0312 14.1031 18.0312 13V3C18.0312 1.89688 17.1344 1 16.0312 1H2.03125ZM3.78125 10.5C3.36562 10.5 3.03125 10.8344 3.03125 11.25C3.03125 11.6656 3.36562 12 3.78125 12H5.28125C5.69688 12 6.03125 11.6656 6.03125 11.25C6.03125 10.8344 5.69688 10.5 5.28125 10.5H3.78125ZM7.78125 10.5C7.36562 10.5 7.03125 10.8344 7.03125 11.25C7.03125 11.6656 7.36562 12 7.78125 12H11.2812C11.6969 12 12.0312 11.6656 12.0312 11.25C12.0312 10.8344 11.6969 10.5 11.2812 10.5H7.78125Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_49_125">
<path
d="M0.03125 0H18.0312V16H0.03125V0Z"
fill="white"
/>
</clipPath>
</defs>
</svg>
) : undefined;
const className = b.primary ? "bg-primary" : undefined;
return {
children: b.text,
icon,
className,
onClick: handlePayClick,
};
})}
/>
</div>
)}
{screen.moneyBackGuarantee && (
<MoneyBackGuarantee
className="mt-[17px]"
title={buildTypographyProps(screen.moneyBackGuarantee.title, {
as: "h4",
defaults: { font: "inter", weight: "bold", size: "sm" },
})}
text={buildTypographyProps(screen.moneyBackGuarantee.text, {
as: "p",
defaults: { font: "inter", weight: "medium", size: "xs" },
})}
/>
)}
{screen.policy && (
<Policy
className="mt-4"
text={buildTypographyProps(screen.policy.text, {
as: "p",
defaults: { font: "inter", size: "xs" },
})}
/>
)}
{screen.usersPortraits && (
<UsersPortraits
className="mt-12"
title={buildTypographyProps(screen.usersPortraits.title, {
as: "h3",
defaults: {
font: "inter",
weight: "bold",
size: "2xl",
align: "center",
},
})}
imgs={screen.usersPortraits.images?.map((img) => ({
src: img.src,
alt: "user portrait",
}))}
button={
screen.usersPortraits.buttonText
? {
children: screen.usersPortraits.buttonText,
onClick: scrollToPayment,
}
: undefined
}
/>
)}
{screen.joinedTodayWithAvatars && (
<JoinedTodayWithAvatars
className="mt-[22px]"
avatars={
screen.joinedTodayWithAvatars.avatars
? {
avatars: screen.joinedTodayWithAvatars.avatars.images.map(
(img) => ({
imageProps: { src: img.src, alt: "avatar" },
})
),
}
: undefined
}
count={buildTypographyProps(screen.joinedTodayWithAvatars.count, {
as: "span",
defaults: { font: "inter", weight: "bold", size: "sm" },
})}
text={buildTypographyProps(screen.joinedTodayWithAvatars.text, {
as: "p",
defaults: { font: "inter", weight: "semiBold", size: "sm" },
})}
/>
)}
{screen.progressToSeeSoulmate && (
<ProgressToSeeSoulmate
className="mt-12"
title={
buildTypographyProps(screen.progressToSeeSoulmate.title, {
as: "h3",
defaults: { font: "inter", weight: "bold", align: "center" },
}) ?? { as: "h3", children: "" }
}
progress={{
value: screen.progressToSeeSoulmate.progress?.value ?? 0,
}}
progressText={{
leftText: buildTypographyProps(
screen.progressToSeeSoulmate.leftText,
{
as: "span",
defaults: { font: "inter", size: "sm", weight: "medium" },
}
),
rightText: buildTypographyProps(
screen.progressToSeeSoulmate.rightText,
{
as: "span",
defaults: { font: "inter", size: "sm" },
}
),
}}
/>
)}
{screen.stepsToSeeSoulmate && (
<StepsToSeeSoulmate
className="mt-12"
steps={screen.stepsToSeeSoulmate.steps.map((s) => ({
title: buildTypographyProps(s.title, {
as: "h4",
defaults: { font: "inter", weight: "semiBold", size: "sm" },
})!,
description: buildTypographyProps(s.description, {
as: "p",
defaults: { font: "inter", size: "xs" },
})!,
icon:
s.icon === "questions" ? (
<svg
width="12"
height="15"
viewBox="0 0 12 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 0.75C4.85703 0.75 3.88359 1.48008 3.52539 2.5H2.5C1.53477 2.5 0.75 3.28477 0.75 4.25V13C0.75 13.9652 1.53477 14.75 2.5 14.75H9.5C10.4652 14.75 11.25 13.9652 11.25 13V4.25C11.25 3.28477 10.4652 2.5 9.5 2.5H8.47461C8.11641 1.48008 7.14297 0.75 6 0.75ZM6 2.5C6.23206 2.5 6.45462 2.59219 6.61872 2.75628C6.78281 2.92038 6.875 3.14294 6.875 3.375C6.875 3.60706 6.78281 3.82962 6.61872 3.99372C6.45462 4.15781 6.23206 4.25 6 4.25C5.76794 4.25 5.54538 4.15781 5.38128 3.99372C5.21719 3.82962 5.125 3.60706 5.125 3.375C5.125 3.14294 5.21719 2.92038 5.38128 2.75628C5.54538 2.59219 5.76794 2.5 6 2.5ZM3.64297 7.01992C3.85898 6.41016 4.43867 6 5.08672 6H6.68086C7.63516 6 8.40625 6.77383 8.40625 7.72539C8.40625 8.34336 8.07539 8.91484 7.53945 9.22383L6.65625 9.72969C6.65078 10.0852 6.3582 10.375 6 10.375C5.63633 10.375 5.34375 10.0824 5.34375 9.71875V9.34961C5.34375 9.11445 5.46953 8.89844 5.67461 8.78086L6.88594 8.08633C7.01445 8.0125 7.09375 7.87578 7.09375 7.72813C7.09375 7.49844 6.90781 7.31523 6.68086 7.31523H5.08672C4.99375 7.31523 4.91172 7.37266 4.88164 7.46016L4.8707 7.49297C4.75039 7.83477 4.37305 8.0125 4.03398 7.89219C3.69492 7.77188 3.51445 7.39453 3.63477 7.05547L3.6457 7.02266L3.64297 7.01992ZM5.125 12.125C5.125 11.8929 5.21719 11.6704 5.38128 11.5063C5.54538 11.3422 5.76794 11.25 6 11.25C6.23206 11.25 6.45462 11.3422 6.61872 11.5063C6.78281 11.6704 6.875 11.8929 6.875 12.125C6.875 12.3571 6.78281 12.5796 6.61872 12.7437C6.45462 12.9078 6.23206 13 6 13C5.76794 13 5.54538 12.9078 5.38128 12.7437C5.21719 12.5796 5.125 12.3571 5.125 12.125Z"
fill="white"
/>
</svg>
) : s.icon === "profile" ? (
<svg
width="14"
height="15"
viewBox="0 0 14 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.03125 0.75C5.87617 0.75 6.5625 1.43633 6.5625 2.28125V13.2188C6.5625 14.0637 5.87617 14.75 5.03125 14.75C4.24102 14.75 3.59023 14.1512 3.5082 13.3801C3.36602 13.4184 3.21563 13.4375 3.0625 13.4375C2.09727 13.4375 1.3125 12.6527 1.3125 11.6875C1.3125 11.4852 1.34805 11.2883 1.41094 11.1078C0.585156 10.7961 0 9.99766 0 9.0625C0 8.19023 0.511328 7.43555 1.25234 7.08555C1.01445 6.7875 0.875 6.41016 0.875 6C0.875 5.16055 1.46563 4.46055 2.25312 4.28828C2.20938 4.13789 2.1875 3.97656 2.1875 3.8125C2.1875 2.99492 2.75078 2.30586 3.5082 2.11445C3.59023 1.34883 4.24102 0.75 5.03125 0.75ZM8.96875 0.75C9.75898 0.75 10.407 1.34883 10.4918 2.11445C11.252 2.30586 11.8125 2.99219 11.8125 3.8125C11.8125 3.97656 11.7906 4.13789 11.7469 4.28828C12.5344 4.45781 13.125 5.16055 13.125 6C13.125 6.41016 12.9855 6.7875 12.7477 7.08555C13.4887 7.43555 14 8.19023 14 9.0625C14 9.99766 13.4148 10.7961 12.5891 11.1078C12.652 11.2883 12.6875 11.4852 12.6875 11.6875C12.6875 12.6527 11.9027 13.4375 10.9375 13.4375C10.7844 13.4375 10.634 13.4184 10.4918 13.3801C10.4098 14.1512 9.75898 14.75 8.96875 14.75C8.12383 14.75 7.4375 14.0637 7.4375 13.2188V2.28125C7.4375 1.43633 8.12383 0.75 8.96875 0.75Z"
fill="white"
/>
</svg>
) : s.icon === "sketch" ? (
<svg
width="14"
height="15"
viewBox="0 0 14 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 7.75C14 7.77461 14 7.79922 14 7.82383C13.9891 8.82188 13.0813 9.5 12.0832 9.5H9.40625C8.68164 9.5 8.09375 10.0879 8.09375 10.8125C8.09375 10.9055 8.10469 10.9957 8.12109 11.0832C8.17852 11.3621 8.29883 11.6301 8.41641 11.9008C8.5832 12.2781 8.74727 12.6527 8.74727 13.0492C8.74727 13.9187 8.15664 14.709 7.28711 14.7445C7.19141 14.7473 7.0957 14.75 6.99727 14.75C3.13359 14.75 0 11.6164 0 7.75C0 3.88359 3.13359 0.75 7 0.75C10.8664 0.75 14 3.88359 14 7.75ZM3.5 8.625C3.5 8.39294 3.40781 8.17038 3.24372 8.00628C3.07962 7.84219 2.85706 7.75 2.625 7.75C2.39294 7.75 2.17038 7.84219 2.00628 8.00628C1.84219 8.17038 1.75 8.39294 1.75 8.625C1.75 8.85706 1.84219 9.07962 2.00628 9.24372C2.17038 9.40781 2.39294 9.5 2.625 9.5C2.85706 9.5 3.07962 9.40781 3.24372 9.24372C3.40781 9.07962 3.5 8.85706 3.5 8.625ZM3.5 6C3.73206 6 3.95462 5.90781 4.11872 5.74372C4.28281 5.57962 4.375 5.35706 4.375 5.125C4.375 4.89294 4.28281 4.67038 4.11872 4.50628C3.95462 4.34219 3.73206 4.25 3.5 4.25C3.26794 4.25 3.04538 4.34219 2.88128 4.50628C2.71719 4.67038 2.625 4.89294 2.625 5.125C2.625 5.35706 2.71719 5.57962 2.88128 5.74372C3.04538 5.90781 3.26794 6 3.5 6ZM7.875 3.375C7.875 3.14294 7.78281 2.92038 7.61872 2.75628C7.45462 2.59219 7.23206 2.5 7 2.5C6.76794 2.5 6.54538 2.59219 6.38128 2.75628C6.21719 2.92038 6.125 3.14294 6.125 3.375C6.125 3.60706 6.21719 3.82962 6.38128 3.99372C6.54538 4.15781 6.76794 4.25 7 4.25C7.23206 4.25 7.45462 4.15781 7.61872 3.99372C7.78281 3.82962 7.875 3.60706 7.875 3.375ZM10.5 6C10.7321 6 10.9546 5.90781 11.1187 5.74372C11.2828 5.57962 11.375 5.35706 11.375 5.125C11.375 4.89294 11.2828 4.67038 11.1187 4.50628C10.9546 4.34219 10.7321 6 10.5 6Z"
fill="#6B7280"
/>
</svg>
) : s.icon === "astro" ? (
<svg
width="12"
height="13"
viewBox="0 0 12 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.86133 0.625C3.48438 0.625 0.75 3.36758 0.75 6.75C0.75 10.1324 3.48438 12.875 6.86133 12.875C8.51836 12.875 10.0195 12.2133 11.1215 11.1414C11.2582 11.0074 11.2937 10.7996 11.2063 10.6301C11.1187 10.4605 10.9301 10.3648 10.7414 10.3977C10.4734 10.4441 10.2 10.4688 9.91836 10.4688C7.26875 10.4688 5.11953 8.31406 5.11953 5.65625C5.11953 3.85703 6.10391 2.29023 7.56133 1.46445C7.72813 1.36875 7.81289 1.17734 7.77187 0.991406C7.73086 0.805469 7.57227 0.666016 7.38086 0.649609C7.20859 0.635938 7.03633 0.627734 6.86133 0.627734V0.625Z"
fill="#6B7280"
/>
</svg>
) : s.icon === "chat" ? (
<svg
width="18"
height="15"
viewBox="0 0 18 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.93755 10.375C9.07935 10.375 11.6251 8.22031 11.6251 5.5625C11.6251 2.90469 9.07935 0.75 5.93755 0.75C2.79576 0.75 0.250055 2.90469 0.250055 5.5625C0.250055 6.61797 0.652008 7.59414 1.33287 8.38984C1.23716 8.64687 1.09498 8.87383 0.944586 9.06523C0.813336 9.23477 0.679352 9.36602 0.580914 9.45625C0.531696 9.5 0.49068 9.53555 0.463336 9.55742C0.449664 9.56836 0.438727 9.57656 0.433258 9.5793L0.427789 9.58477C0.277399 9.69688 0.211774 9.89375 0.27193 10.0715C0.332086 10.2492 0.498883 10.375 0.687555 10.375C1.28365 10.375 1.88521 10.2219 2.3856 10.0332C2.63716 9.9375 2.87232 9.83086 3.0774 9.72148C3.91685 10.1371 4.89302 10.375 5.93755 10.375ZM12.5001 5.5625C12.5001 8.6332 9.79029 10.9465 6.58013 11.2227C7.24459 13.257 9.44849 14.75 12.0626 14.75C13.1071 14.75 14.0833 14.5121 14.9254 14.0965C15.1305 14.2059 15.3629 14.3125 15.6145 14.4082C16.1149 14.5969 16.7165 14.75 17.3126 14.75C17.5012 14.75 17.6708 14.627 17.7282 14.4465C17.7856 14.266 17.7227 14.0691 17.5696 13.957L17.5641 13.9516C17.5586 13.9461 17.5477 13.9406 17.534 13.9297C17.5067 13.9078 17.4657 13.875 17.4165 13.8285C17.318 13.7383 17.184 13.607 17.0528 13.4375C16.9024 13.2461 16.7602 13.0164 16.6645 12.7621C17.3454 11.9691 17.7473 10.993 17.7473 9.93477C17.7473 7.39727 15.4258 5.31641 12.4809 5.13594C12.4919 5.27539 12.4973 5.41758 12.4973 5.55977L12.5001 5.5625Z"
fill="#6B7280"
/>
</svg>
) : null,
isActive: s.isActive ?? false,
}))}
button={
screen.stepsToSeeSoulmate.buttonText
? {
children: screen.stepsToSeeSoulmate.buttonText,
onClick: scrollToPayment,
}
: undefined
}
/>
)}
{screen.reviews && (
<Reviews
className="mt-12"
title={buildTypographyProps(screen.reviews.title, {
as: "h3",
defaults: { font: "inter", weight: "bold", align: "center" },
})}
reviews={screen.reviews.items.map((r) => ({
name: buildTypographyProps(r.name, {
as: "span",
defaults: { font: "inter", weight: "semiBold", size: "sm" },
}),
text: buildTypographyProps(r.text, {
as: "p",
defaults: { font: "inter", size: "sm" },
}),
date: buildTypographyProps(r.date, {
as: "span",
defaults: { font: "inter", size: "xs" },
}),
avatar: r.avatar
? {
imageProps: { src: r.avatar.src, alt: "avatar" },
}
: undefined,
stars: r.rating ? { value: r.rating } : undefined,
portrait: r.portrait
? { src: r.portrait.src, alt: "Portrait" }
: undefined,
photo: r.photo ? { src: r.photo.src, alt: "Photo" } : undefined,
}))}
/>
)}
{screen.commonQuestions && (
<CommonQuestions
className="mt-[31px]"
title={buildTypographyProps(screen.commonQuestions.title, {
as: "h3",
defaults: {
font: "inter",
weight: "bold",
size: "2xl",
align: "center",
},
})}
questions={screen.commonQuestions.items.map((q, index) => ({
value: `q-${index}`,
trigger: { children: q.question },
content: { children: q.answer },
}))}
accordionProps={{ defaultValue: "q-0", type: "single" }}
/>
)}
{screen.stillHaveQuestions && (
<StillHaveQuestions
className="mt-8"
title={buildTypographyProps(screen.stillHaveQuestions.title, {
as: "h3",
defaults: { font: "inter", size: "sm" },
})}
actionButton={
screen.stillHaveQuestions.actionButtonText
? {
children: screen.stillHaveQuestions.actionButtonText,
onClick: scrollToPayment,
}
: undefined
}
contactButton={
screen.stillHaveQuestions.contactButtonText
? {
children: screen.stillHaveQuestions.contactButtonText,
onClick: scrollToPayment,
}
: undefined
}
/>
)}
{screen.footer && (
<Footer
className="mt-[60px]"
title={buildTypographyProps(screen.footer.title, {
as: "h3",
defaults: {
font: "inter",
weight: "bold",
size: "2xl",
align: "center",
},
})}
contacts={
screen.footer.contacts
? {
title: buildTypographyProps(screen.footer.contacts.title, {
as: "h3",
defaults: { font: "inter", weight: "bold" },
}),
email: screen.footer.contacts.email
? {
href: screen.footer.contacts.email.href,
children: screen.footer.contacts.email.text,
}
: undefined,
address: buildTypographyProps(
screen.footer.contacts.address,
{
as: "address",
defaults: { font: "inter", size: "sm" },
}
),
}
: undefined
}
legal={
screen.footer.legal
? {
title: buildTypographyProps(screen.footer.legal.title, {
as: "h3",
defaults: { font: "inter", weight: "bold" },
}),
links:
screen.footer.legal.links?.map((l) => ({
href: l.href,
children: l.text,
})) || [],
copyright: buildTypographyProps(
screen.footer.legal.copyright,
{ as: "p", defaults: { font: "inter", size: "xs" } }
),
}
: undefined
}
paymentMethods={
screen.footer.paymentMethods
? {
title: buildTypographyProps(
screen.footer.paymentMethods.title,
{ as: "h3", defaults: { font: "inter", weight: "bold" } }
),
methods:
screen.footer.paymentMethods.methods?.map((m) => ({
src: m.src,
alt: m.alt,
})) || [],
}
: undefined
}
/>
)}
</div>
</TemplateLayout>
);
}

View File

@ -0,0 +1,3 @@
export { TrialPaymentTemplate } from "./TrialPaymentTemplate";

View File

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

View File

@ -45,6 +45,10 @@ interface TemplateLayoutProps {
// Дополнительные props для Title
childrenAboveTitle?: React.ReactNode;
// Переопределения стилей LayoutQuestion (контент и обертка контента)
contentProps?: React.ComponentProps<"div">;
childrenWrapperProps?: React.ComponentProps<"div">;
// Контент template
children: React.ReactNode;
}
@ -64,6 +68,8 @@ export function TemplateLayout({
childrenAboveButton,
childrenUnderButton,
childrenAboveTitle,
contentProps,
childrenWrapperProps,
children,
}: TemplateLayoutProps) {
// 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON
@ -121,7 +127,12 @@ export function TemplateLayout({
// 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ
return (
<div className="w-full">
<LayoutQuestion {...layoutQuestionProps} childrenAboveTitle={childrenAboveTitle}>
<LayoutQuestion
{...layoutQuestionProps}
childrenAboveTitle={childrenAboveTitle}
contentProps={contentProps}
childrenWrapperProps={childrenWrapperProps}
>
{children}
</LayoutQuestion>

View File

@ -0,0 +1,82 @@
.list {
position: relative;
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
/* gap: 32px; */
font-size: 20px;
/* color: #1A6697; */
color: #acacac;
line-height: 25px;
text-align: center;
overflow: hidden;
}
.list > .item {
transition: margin-top 0.5s ease-in-out;
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
display: block;
background: var(--background);
/* padding: 16px 0; */
overflow: hidden;
opacity: 0;
animation: list-item ease-in-out forwards;
}
.list > .item > .line {
display: block;
height: 100%;
width: 64px;
background: linear-gradient(
to right,
#acacac 0%,
#333333 50%,
#acacac 100%
);
top: 0;
left: 50%;
position: absolute;
mix-blend-mode: color-burn;
filter: blur(3px);
animation: line-move cubic-bezier(0.65, 0, 0.46, 1.02) infinite;
}
.list > .item > .text {
position: relative;
color: #000;
z-index: 1;
}
@keyframes line-move {
0% {
left: -64px;
}
100% {
left: 100%;
}
}
@keyframes list-item {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./GPTAnimationText.module.css";
interface GPTAnimationTextProps {
points: Array<string>;
totalAnimationTime: number;
}
function GPTAnimationText({
points,
totalAnimationTime,
}: GPTAnimationTextProps) {
const listRef = useRef<Array<HTMLParagraphElement | null>>([]);
const [listHeight, setListHeight] = useState(0);
useEffect(() => {
let maxHeight = 0;
listRef.current.forEach(item => {
if (item?.offsetHeight && item.offsetHeight > maxHeight) {
maxHeight = item.offsetHeight;
}
});
setListHeight(maxHeight);
}, [listRef]);
return (
<div
className={styles.list}
style={{
height: `${listHeight}px`,
}}
>
{points.map((element, index) => (
<p
key={element}
className={styles.item}
ref={el => {
listRef.current[index] = el;
}}
style={{
animationDuration: `${totalAnimationTime / points.length}ms`,
animationDelay: `${index * (totalAnimationTime / points.length)}ms`,
}}
>
{element}
<span
className={styles.line}
style={{
animationDuration: `${totalAnimationTime / points.length}ms`,
}}
/>
</p>
))}
</div>
);
}
export default GPTAnimationText;

View File

@ -0,0 +1,53 @@
import Link from "next/link";
import GPTAnimationText from "@/components/ui/GPTAnimationText/GPTAnimationText";
import Typography from "@/components/ui/Typography/Typography";
import { ActionButton } from "@/components/ui/ActionButton/ActionButton";
interface AnimatedInfoScreenProps {
lottieAnimation: React.ReactNode;
title: string;
animationTime?: number;
animationTexts?: string[];
buttonText?: string;
nextRoute?: string;
}
export default async function AnimatedInfoScreen({
lottieAnimation,
title,
animationTime,
animationTexts,
buttonText,
nextRoute,
}: AnimatedInfoScreenProps) {
return (
<div className="w-full flex flex-col items-center px-7">
{lottieAnimation}
<Typography
as="h1"
weight="bold"
className="mt-8 mb-[50px] text-[27px] leading-[40px] text-center"
>
{title}
</Typography>
{!!animationTexts?.length && animationTime && (
<GPTAnimationText
points={animationTexts}
totalAnimationTime={animationTime}
/>
)}
{nextRoute && buttonText && (
<ActionButton
asChild
className="w-full mt-[126px] sticky bottom-[calc(0dvh+16px)] opacity-0 [animation:fadeIn_0.5s_ease-in-out_forwards] pointer-events-none"
style={
animationTime ? { animationDelay: `${animationTime}ms` } : undefined
}
>
<Link href={nextRoute}>{buttonText}</Link>
</ActionButton>
)}
</div>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import {
DotLottieReact,
DotLottieReactProps,
} from "@lottiefiles/dotlottie-react";
import clsx from "clsx";
import { useLottie } from "@/hooks/lottie/useLottie";
import { ELottieKeys } from "@/shared/constants/lottie";
interface LottieAnimationProps {
loadKey: ELottieKeys;
width?: number | string;
height?: number | string;
className?: string;
animationProps?: DotLottieReactProps;
}
export default function LottieAnimation({
loadKey,
width = 80,
height = 80,
className,
animationProps,
}: LottieAnimationProps) {
const { animationData } = useLottie({
loadKey,
});
return (
<div style={{ width: width, height: height }} className={className}>
{animationData && (
<DotLottieReact
style={{ width: width, height: height }}
data={animationData}
autoplay
width={width}
height={height}
{...animationProps}
className={clsx(animationProps?.className, "ym-hide-content")}
/>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
"use server";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import { ActionResponse } from "@/shared/types";
import {
SingleCheckoutRequest,
SingleCheckoutResponse,
SingleCheckoutResponseSchema,
} from "./types";
export async function performSingleCheckout(
payload: SingleCheckoutRequest
): Promise<ActionResponse<SingleCheckoutResponse>> {
try {
const response = await http.post<SingleCheckoutResponse>(
API_ROUTES.paymentSingleCheckout(),
payload,
{
schema: SingleCheckoutResponseSchema,
revalidate: 0,
}
);
return { data: response, error: null };
} catch (error) {
console.error("Failed to perform single checkout:", error);
const errorMessage =
error instanceof Error ? error.message : "Something went wrong.";
return { data: null, error: errorMessage };
}
}

View File

@ -0,0 +1,31 @@
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import {
CheckoutRequest,
CheckoutResponse,
CheckoutResponseSchema,
SingleCheckoutRequest,
SingleCheckoutResponse,
SingleCheckoutResponseSchema,
} from "./types";
export async function createPaymentCheckout(payload: CheckoutRequest) {
return http.post<CheckoutResponse>(API_ROUTES.paymentCheckout(), payload, {
schema: CheckoutResponseSchema,
revalidate: 0,
});
}
export async function createSinglePaymentCheckout(
payload: SingleCheckoutRequest
) {
return http.post<SingleCheckoutResponse>(
API_ROUTES.paymentSingleCheckout(),
payload,
{
schema: SingleCheckoutResponseSchema,
revalidate: 0,
}
);
}

View File

@ -0,0 +1,52 @@
import { z } from "zod";
export const CheckoutRequestSchema = z.object({
productId: z.string(),
placementId: z.string(),
paywallId: z.string(),
});
export type CheckoutRequest = z.infer<typeof CheckoutRequestSchema>;
export const CheckoutResponseSchema = z.object({
status: z.string(),
invoiceId: z.string(),
paymentUrl: z.string().url(),
});
export type CheckoutResponse = z.infer<typeof CheckoutResponseSchema>;
export const PaymentInfoSchema = z.object({
productId: z.string(),
key: z.string(),
isAutoTopUp: z.boolean().optional(),
});
export type PaymentInfo = z.infer<typeof PaymentInfoSchema>;
export const SingleCheckoutRequestSchema = z.object({
paymentInfo: PaymentInfoSchema,
return_url: z.string().optional(),
pageUrl: z.string().optional(),
});
export type SingleCheckoutRequest = z.infer<typeof SingleCheckoutRequestSchema>;
export const SingleCheckoutSuccessSchema = z.object({
payment: z.object({
status: z.string(),
invoiceId: z.string(),
paymentUrl: z.string().url().optional(),
}),
});
export type SingleCheckoutSuccess = z.infer<typeof SingleCheckoutSuccessSchema>;
export const SingleCheckoutErrorSchema = z.object({
status: z.string(),
message: z.string(),
});
export type SingleCheckoutError = z.infer<typeof SingleCheckoutErrorSchema>;
export const SingleCheckoutResponseSchema = z.union([
SingleCheckoutSuccessSchema,
SingleCheckoutErrorSchema,
]);
export type SingleCheckoutResponse = z.infer<
typeof SingleCheckoutResponseSchema
>;

View File

@ -0,0 +1,12 @@
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import { FunnelRequest, FunnelResponse, FunnelResponseSchema } from "./types";
export const getFunnel = async (payload: FunnelRequest) => {
return http.post<FunnelResponse>(API_ROUTES.funnel(), payload, {
tags: ["funnel"],
schema: FunnelResponseSchema,
revalidate: 0,
});
};

View File

@ -0,0 +1,41 @@
import { cache } from "react";
import { getFunnel } from "./api";
import type { FunnelRequest } from "./types";
export const loadFunnel = cache((payload: FunnelRequest) => getFunnel(payload));
export const loadFunnelData = cache((payload: FunnelRequest) =>
loadFunnel(payload).then(d => d.data)
);
export const loadFunnelStatus = cache((payload: FunnelRequest) =>
loadFunnel(payload).then(d => d.status)
);
export const loadFunnelCurrency = cache((payload: FunnelRequest) =>
loadFunnelData(payload).then(d => d.currency)
);
export const loadFunnelLocale = cache((payload: FunnelRequest) =>
loadFunnelData(payload).then(d => d.locale)
);
export const loadFunnelPayment = cache((payload: FunnelRequest) =>
loadFunnelData(payload).then(d => d.payment)
);
export const loadFunnelPaymentById = cache(
(payload: FunnelRequest, paymentId: string) =>
loadFunnelData(payload).then(d => d.payment[paymentId])
);
// export const loadFunnelProducts = cache(
// (payload: FunnelRequest, paymentId: string) =>
// loadFunnelPaymentById(payload, paymentId).then(d => d?.variants ?? [])
// );
// export const loadFunnelProperties = cache(
// (payload: FunnelRequest, paymentId: string) =>
// loadFunnelPaymentById(payload, paymentId).then(d => d?.properties ?? [])
// );

View File

@ -0,0 +1,70 @@
import { z } from "zod";
import { Currency } from "@/shared/types";
// Request schemas
export const FunnelRequestSchema = z.object({
// funnel: z.enum(ELocalesPlacement),
funnel: z.string(),
});
// Response schemas
export const FunnelPaymentPropertySchema = z.object({
key: z.string(),
value: z.union([z.string(), z.number()]),
});
export const FunnelPaymentVariantSchema = z.object({
id: z.string(),
key: z.string(),
type: z.string(),
price: z.number(),
oldPrice: z.number().optional(),
trialPrice: z.number().optional(),
});
export const FunnelPaymentPlacementSchema = z.object({
price: z.number().optional(),
currency: z.enum(Currency).optional(),
billingPeriod: z.enum(["DAY", "WEEK", "MONTH", "YEAR"]).optional(),
billingInterval: z.number().optional(),
trialPeriod: z.enum(["DAY", "WEEK", "MONTH", "YEAR"]).optional(),
trialInterval: z.number().optional(),
placementId: z.string().optional(),
paywallId: z.string().optional(),
properties: z.array(FunnelPaymentPropertySchema).optional(),
variants: z.array(FunnelPaymentVariantSchema).optional(),
paymentUrl: z.string().optional(),
type: z.string().optional(),
});
export const FunnelSchema = z.object({
currency: z.enum(Currency),
// funnel: z.enum(ELocalesPlacement),
funnel: z.string(),
locale: z.string(),
payment: z.record(
z.string(),
z.union([
FunnelPaymentPlacementSchema.nullable(),
z.array(FunnelPaymentPlacementSchema),
])
),
});
export const FunnelResponseSchema = z.object({
status: z.union([z.literal("success"), z.string()]),
data: FunnelSchema,
});
// Type exports
export type FunnelRequest = z.infer<typeof FunnelRequestSchema>;
export type IFunnelPaymentProperty = z.infer<
typeof FunnelPaymentPropertySchema
>;
export type IFunnelPaymentVariant = z.infer<typeof FunnelPaymentVariantSchema>;
export type IFunnelPaymentPlacement = z.infer<
typeof FunnelPaymentPlacementSchema
>;
export type IFunnel = z.infer<typeof FunnelSchema>;
export type FunnelResponse = z.infer<typeof FunnelResponseSchema>;

View File

@ -0,0 +1,88 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Data } from "@lottiefiles/dotlottie-react";
import indexedDB, { EObjectStores } from "@/shared/utils/indexedDB";
import { ELottieKeys, lottieUrls } from "@/shared/constants/lottie";
interface IUseLottieProps {
preloadKey?: ELottieKeys;
loadKey?: ELottieKeys;
}
export const useLottie = ({ preloadKey, loadKey }: IUseLottieProps) => {
const [animationData, setAnimationData] = useState<Data>();
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const getAnimationDataFromLottie = async (key: ELottieKeys) => {
try {
const animation = await fetch(lottieUrls[key]);
if (!animation.ok) {
throw new Error(`HTTP error! status: ${animation.status}`);
}
const arrayBuffer = await animation.arrayBuffer();
return arrayBuffer;
} catch (error) {
console.error("Error loading animation:", error);
setIsError(true);
return null;
}
};
const preload = useCallback(async (key: ELottieKeys) => {
console.log("preload", key);
const arrayBuffer = await getAnimationDataFromLottie(key);
indexedDB.set(EObjectStores.Lottie, key, arrayBuffer);
}, []);
const load = useCallback(async (key: ELottieKeys) => {
setIsLoading(true);
setIsError(false);
try {
const animationFromDB = await indexedDB.get<ArrayBuffer>(
EObjectStores.Lottie,
key
);
if (animationFromDB) {
setAnimationData(animationFromDB);
setIsLoading(false);
return;
}
const arrayBuffer = await getAnimationDataFromLottie(key);
if (!arrayBuffer) {
setIsLoading(false);
return;
}
setAnimationData(arrayBuffer);
await indexedDB.set<ArrayBuffer>(EObjectStores.Lottie, key, arrayBuffer);
} catch (error) {
console.error("Error in load process:", error);
setIsError(true);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (preloadKey) {
preload(preloadKey);
}
if (loadKey) {
load(loadKey);
}
}, [load, loadKey, preload, preloadKey]);
return useMemo(
() => ({
animationData,
isError,
isLoading,
}),
[animationData, isError, isLoading]
);
};

View File

@ -0,0 +1,69 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { FunnelDefinition } from "@/lib/funnel/types";
import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
import type { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
interface UsePaymentPlacementArgs {
funnel: FunnelDefinition;
paymentId: string;
}
interface UsePaymentPlacementResult {
placement: IFunnelPaymentPlacement | null;
isLoading: boolean;
error: string | null;
}
export function usePaymentPlacement({
funnel,
paymentId,
}: UsePaymentPlacementArgs): UsePaymentPlacementResult {
const [placement, setPlacement] = useState<IFunnelPaymentPlacement | null>(
null
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const funnelKey = useMemo(() => funnel?.meta?.id ?? "", [funnel]);
useEffect(() => {
let isMounted = true;
if (!funnelKey || !paymentId) return;
(async () => {
try {
setIsLoading(true);
setError(null);
const data = await loadFunnelPaymentById(
{ funnel: funnelKey },
paymentId
);
// Normalize union: record value can be IFunnelPaymentPlacement or IFunnelPaymentPlacement[] or null
const normalized: IFunnelPaymentPlacement | null = Array.isArray(data)
? data[0] ?? null
: data ?? null;
if (!isMounted) return;
setPlacement(normalized);
} catch (e) {
if (!isMounted) return;
const message = e instanceof Error ? e.message : "Failed to load payment placement";
setError(message);
} finally {
if (isMounted) setIsLoading(false);
}
})();
return () => {
isMounted = false;
};
}, [funnelKey, paymentId]);
return { placement, isLoading, error };
}

View File

@ -29,3 +29,4 @@ export { buildCouponDefaults } from "./coupon";
export { buildEmailDefaults } from "./email";
export { buildLoadersDefaults } from "./loaders";
export { buildSoulmateDefaults } from "./soulmate";
export { buildTrialPaymentDefaults } from "./trialPayment";

View File

@ -0,0 +1,208 @@
import { nanoid } from "nanoid";
import type { TrialPaymentScreenDefinition } from "@/lib/funnel/types";
import {
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultImage,
} from "./blocks";
export function buildTrialPaymentDefaults(id?: string): TrialPaymentScreenDefinition {
return {
id: id || `trial-${nanoid(6)}`,
template: "trialPayment",
header: { show: false },
title: buildDefaultTitle({ show: false }),
subtitle: buildDefaultSubtitle({ show: false }),
bottomActionButton: buildDefaultBottomActionButton({ show: false }),
navigation: buildDefaultNavigation(),
headerBlock: {
text: { text: "⚠️ Your sketch expires soon!" },
timer: { text: "" },
timerSeconds: 600,
},
unlockYourSketch: {
title: { text: "Unlock Your Sketch" },
subtitle: { text: "Just One Click to Reveal Your Match!" },
image: buildDefaultImage({ src: "/trial-payment/portrait-female.jpg" }),
blur: { text: { text: "Unlock to reveal your personalized portrait" }, icon: "lock" },
buttonText: "Get Me Soulmate Sketch",
},
joinedToday: { count: { text: "954" }, text: { text: "Joined today" } },
trustedByOver: { text: { text: "Trusted by over 355,000 people." } },
findingOneGuide: {
header: { emoji: { text: "❤️" }, title: { text: "Finding the One Guide" } },
text: { text: "You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're. You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're" },
blur: { text: { text: "Чтобы открыть весь отчёт, нужен полный доступ." }, icon: "lock" },
},
tryForDays: {
title: { text: "Попробуйте в течение 7 дней!" },
textList: {
items: [
{ text: "Receive a hand-drawn sketch of your soulmate." },
{ text: "Reveal the path with the guide." },
{ text: "Talk to live experts and get guidance." },
{ text: "Start your 7-day trial for just $1.00." },
{ text: "Cancel anytime—just 24 hours before renewal." },
],
},
},
totalPrice: {
couponContainer: { title: { text: "Coupon\nCode" }, buttonText: "SOULMATE94" },
priceContainer: {
title: { text: "Total" },
price: { text: "$1.00" },
oldPrice: { text: "$14.99" },
discount: { text: "94% discount applied" },
},
},
paymentButtons: {
buttons: [
{ text: "Pay", icon: "pay" },
{ text: "Pay", icon: "google" },
{ text: "Credit or debit card", icon: "card", primary: true },
],
},
moneyBackGuarantee: {
title: { text: "30-DAY MONEY-BACK GUARANTEE" },
text: { text: "If you don't receive your soulmate sketch, we'll refund your money!" },
},
policy: {
text: { text: "By clicking Continue, you agree to our Terms of Use & Service and Privacy Policy. You also acknowledge that your 1 week introductory plan to Respontika, billed at $1.00, will automatically renew at $14.50 every 1 week unless canceled before the end of the trial period." },
},
usersPortraits: {
title: { text: "Our Users' Soulmate Portraits" },
images: [
{ src: "/trial-payment/users-portraits/1.jpg" },
{ src: "/trial-payment/users-portraits/2.jpg" },
{ src: "/trial-payment/users-portraits/3.jpg" },
],
buttonText: "Get me soulmate sketch",
},
joinedTodayWithAvatars: {
count: { text: "954" },
text: { text: "people joined today" },
avatars: {
images: [
{ src: "/trial-payment/avatars/1.jpg" },
{ src: "/trial-payment/avatars/2.jpg" },
{ src: "/trial-payment/avatars/3.jpg" },
{ src: "/trial-payment/avatars/4.jpg" },
{ src: "/trial-payment/avatars/5.jpg" },
],
},
},
progressToSeeSoulmate: {
title: { text: "See Your Soulmate Just One Step Away" },
progress: { value: 92 },
leftText: { text: "Step 2 of 5" },
rightText: { text: "99% Complete" },
},
stepsToSeeSoulmate: {
steps: [
{ title: { text: "Questions Answered" }, description: { text: "You've provided all the necessary information." }, icon: "questions", isActive: true },
{ title: { text: "Profile Analysis" }, description: { text: "Creating your perfect soulmate profile." }, icon: "profile", isActive: true },
{ title: { text: "Sketch Creation" }, description: { text: "Your personalized soulmate sketch will be created." }, icon: "sketch" },
{ title: { text: "Астрологические Идеи" }, description: { text: "Уникальные астрологические рекомендации." }, icon: "astro" },
{ title: { text: "Персонализированный чат с экспертом" }, description: { text: "Персональные советы." }, icon: "chat" },
],
buttonText: "Show Me My Soulmate",
},
reviews: {
title: { text: "Loved and Trusted Worldwide" },
items: [
{
name: { text: "Jennifer Wilson 🇺🇸" },
text: { text: "**“Я увидела свои ошибки… и нашла мужа”**\nПортрет сразу зацепил — было чувство, что я уже где-то его видела. Но настоящий перелом произошёл после гайда: я поняла, почему снова и снова выбирала «не тех». И самое удивительное — вскоре я познакомилась с мужчиной, который оказался точной копией того самого портрета. Сейчас он мой муж, и когда мы сравнили рисунок с его фото, сходство было просто вау." },
avatar: { src: "/trial-payment/reviews/avatars/1.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/1.jpg" },
photo: { src: "/trial-payment/reviews/photos/1.jpg" },
rating: 5,
date: { text: "1 day ago" },
},
{
name: { text: "Amanda Davis 🇨🇦" },
text: { text: "**“Я поняла своего партнёра лучше за один вечер, чем за несколько лет”**\nПрошла тест ради интереса — портрет нас удивил. Но настоящий прорыв случился, когда я прочитала гайд о второй половинке. Там были точные подсказки о том, как мы можем поддерживать друг друга. Цена смешная, а ценность огромная: теперь у нас меньше недопониманий и больше тепла." },
avatar: { src: "/trial-payment/reviews/avatars/2.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/2.jpg" },
photo: { src: "/trial-payment/reviews/photos/2.jpg" },
rating: 5,
date: { text: "4 days ago" },
},
{
name: { text: "Michael Johnson 🇬🇧" },
text: { text: "**“Увидел её лицо — и мурашки по коже”**\nКогда пришёл результат теста и показали портрет, я реально замер. Это была та самая девушка, с которой я начал встречаться пару недель назад. И гайд прямо описал, почему мы тянемся друг к другу. Честно, я не ожидал такого совпадения." },
avatar: { src: "/trial-payment/reviews/avatars/3.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/3.jpg" },
photo: { src: "/trial-payment/reviews/photos/3.jpg" },
rating: 5,
date: { text: "1 week ago" },
},
],
},
stillHaveQuestions: {
title: { text: "Still have questions? We're here to help!" },
actionButtonText: "Get me Soulmate Sketch",
contactButtonText: "Contact Support",
},
commonQuestions: {
title: { text: "Common Questions" },
items: [
{
question: "When will I receive my sketch?",
answer:
"Your personalized soulmate sketch will be delivered within 24-48 hours after completing your order. You'll receive an email notification when it's ready for viewing in your account.",
},
{
question: "How do I cancel my subscription?",
answer:
"You can cancel anytime from your account settings. Make sure to cancel at least 24 hours before the renewal date to avoid being charged.",
},
{
question: "How accurate are the readings?",
answer:
"Our readings are based on a combination of your answers and advanced pattern analysis. While they provide valuable insights, they are intended for guidance and entertainment purposes.",
},
{
question: "Is my data secure and private?",
answer:
"Yes. We follow strict data protection standards. Your data is encrypted and never shared with third parties without your consent.",
},
],
},
footer: {
title: { text: "WIT LAB ©" },
contacts: {
title: { text: "CONTACTS" },
email: { href: "support@witlab.com", text: "support@witlab.com" },
address: { text: "Wit Lab 2108 N ST STE N SACRAMENTO, CA95816, US" },
},
legal: {
title: { text: "LEGAL" },
links: [
{ href: "https://witlab.com/terms", text: "Terms of Service" },
{ href: "https://witlab.com/privacy", text: "Privacy Policy" },
{ href: "https://witlab.com/refund", text: "Refund Policy" },
],
copyright: {
text:
"Copyright © 2025 Wit Lab™. All rights reserved. All trademarks referenced herein are the properties of their respective owners.",
},
},
paymentMethods: {
title: { text: "PAYMENT METHODS" },
methods: [
{ src: "/trial-payment/payment-methods/visa.svg", alt: "visa" },
{ src: "/trial-payment/payment-methods/mastercard.svg", alt: "mastercard" },
{ src: "/trial-payment/payment-methods/discover.svg", alt: "discover" },
{ src: "/trial-payment/payment-methods/apple.svg", alt: "apple" },
{ src: "/trial-payment/payment-methods/google.svg", alt: "google" },
{ src: "/trial-payment/payment-methods/paypal.svg", alt: "paypal" },
],
},
},
};
}

View File

@ -9,6 +9,7 @@ import { buildCouponDefaults } from "./defaults/coupon";
import { buildEmailDefaults } from "./defaults/email";
import { buildLoadersDefaults } from "./defaults/loaders";
import { buildSoulmateDefaults } from "./defaults/soulmate";
import { buildTrialPaymentDefaults } from "./defaults/trialPayment";
/**
* Marks the state as dirty if it has changed
@ -57,6 +58,8 @@ export function createScreenByTemplate(
return buildLoadersDefaults(id);
case "soulmate":
return buildSoulmateDefaults(id);
case "trialPayment":
return buildTrialPaymentDefaults(id);
default:
throw new Error(`Unknown template: ${template}`);
}

View File

@ -24,7 +24,9 @@ type TypographyAs =
| "h4"
| "h5"
| "h6"
| "div";
| "div"
| "li"
| "address";
interface TypographyDefaults {
font?: TypographyVariant["font"];

View File

@ -11,6 +11,7 @@ import {
EmailTemplate,
LoadersTemplate,
SoulmatePortraitTemplate,
TrialPaymentTemplate,
} from "@/components/funnel/templates";
import type {
ListScreenDefinition,
@ -21,6 +22,7 @@ import type {
EmailScreenDefinition,
LoadersScreenDefinition,
SoulmatePortraitScreenDefinition,
TrialPaymentScreenDefinition,
ScreenDefinition,
DefaultTexts,
FunnelDefinition,
@ -297,6 +299,29 @@ const TEMPLATE_REGISTRY: Record<
/>
);
},
trialPayment: ({
screen,
onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
funnel,
}) => {
const trialPaymentScreen = screen as TrialPaymentScreenDefinition;
return (
<TrialPaymentTemplate
screen={trialPaymentScreen}
onContinue={onContinue}
canGoBack={canGoBack}
onBack={onBack}
screenProgress={screenProgress}
defaultTexts={defaultTexts}
funnel={funnel}
/>
);
},
};
export function renderScreen(props: ScreenRenderProps): JSX.Element {

View File

@ -332,7 +332,159 @@ export interface SoulmatePortraitScreenDefinition {
variants?: ScreenVariantDefinition<SoulmatePortraitScreenDefinition>[];
}
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition | EmailScreenDefinition | LoadersScreenDefinition | SoulmatePortraitScreenDefinition;
// TrialPayment Screen Definition (лендинг оплаты с большим количеством секций)
export interface TrialPaymentScreenDefinition {
id: string;
template: "trialPayment";
header?: HeaderDefinition;
// В TrialPayment заголовок и подзаголовок используются опционально сверху экрана
title?: TitleDefinition;
subtitle?: SubtitleDefinition;
// Глобальная нижняя кнопка экрана
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition<TrialPaymentScreenDefinition>[];
// Минимальные секции для первого шага миграции
headerBlock?: {
text?: TypographyVariant;
timer?: TypographyVariant;
timerSeconds?: number;
};
unlockYourSketch?: {
title?: TypographyVariant;
subtitle?: TypographyVariant;
image?: ImageDefinition;
blur?: {
text?: TypographyVariant;
icon?: "lock";
};
buttonText?: string;
};
joinedToday?: {
count?: TypographyVariant;
text?: TypographyVariant;
};
trustedByOver?: {
text?: TypographyVariant;
};
findingOneGuide?: {
header?: {
emoji?: TypographyVariant;
title?: TypographyVariant;
};
text?: TypographyVariant;
blur?: {
text?: TypographyVariant;
icon?: "lock";
};
};
tryForDays?: {
title?: TypographyVariant;
textList?: {
items: TypographyVariant[];
};
};
totalPrice?: {
couponContainer: {
title?: TypographyVariant;
buttonText?: string;
};
priceContainer?: {
title?: TypographyVariant;
price?: TypographyVariant;
oldPrice?: TypographyVariant;
discount?: TypographyVariant;
};
};
paymentButtons?: {
buttons: Array<{
text: string;
icon?: "pay" | "google" | "card";
primary?: boolean;
}>;
};
moneyBackGuarantee?: {
title?: TypographyVariant;
text?: TypographyVariant;
};
policy?: {
text?: TypographyVariant;
};
usersPortraits?: {
title?: TypographyVariant;
images?: ImageDefinition[];
buttonText?: string;
};
joinedTodayWithAvatars?: {
count?: TypographyVariant;
text?: TypographyVariant;
avatars?: {
// minimal: только пути
images: ImageDefinition[];
};
};
progressToSeeSoulmate?: {
title?: TypographyVariant;
progress?: {
value: number;
};
leftText?: TypographyVariant;
rightText?: TypographyVariant;
};
stepsToSeeSoulmate?: {
steps: Array<{
title: TypographyVariant;
description: TypographyVariant;
icon?: "questions" | "profile" | "sketch" | "astro" | "chat";
isActive?: boolean;
}>;
buttonText?: string;
};
reviews?: {
title?: TypographyVariant;
items: Array<{
// минимальный набор, дальше расширим
name: TypographyVariant;
text: TypographyVariant;
avatar?: ImageDefinition;
rating?: number;
date?: TypographyVariant;
portrait?: ImageDefinition;
photo?: ImageDefinition;
}>;
};
commonQuestions?: {
title?: TypographyVariant;
items: Array<{
question: string;
answer: string;
}>;
};
stillHaveQuestions?: {
title?: TypographyVariant;
actionButtonText?: string;
contactButtonText?: string;
};
footer?: {
title?: TypographyVariant;
contacts?: {
title?: TypographyVariant;
email?: { href: string; text: string };
address?: TypographyVariant;
};
legal?: {
title?: TypographyVariant;
links?: Array<{ href: string; text: string }>;
copyright?: TypographyVariant;
};
paymentMethods?: {
title?: TypographyVariant;
methods?: Array<{ src: string; alt: string }>;
};
};
}
export type ScreenDefinition = InfoScreenDefinition | DateScreenDefinition | CouponScreenDefinition | FormScreenDefinition | ListScreenDefinition | EmailScreenDefinition | LoadersScreenDefinition | SoulmatePortraitScreenDefinition | TrialPaymentScreenDefinition;
export interface FunnelMetaDefinition {
id: string;

View File

@ -1,29 +1,29 @@
import mongoose, { Schema, Document, Model } from 'mongoose';
import type { FunnelDefinition } from '@/lib/funnel/types';
import mongoose, { Schema, Document, Model } from "mongoose";
import type { FunnelDefinition } from "@/lib/funnel/types";
// Extend FunnelDefinition with MongoDB specific fields
export interface IFunnel extends Document {
// Основные данные воронки
funnelData: FunnelDefinition;
// Метаданные для админки
name: string; // Человеко-читаемое имя для каталога
description?: string;
status: 'draft' | 'published' | 'archived';
status: "draft" | "published" | "archived";
// Система версий и истории
version: number;
parentFunnelId?: string; // Для создания копий
// Timestamps
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
// Пользовательские данные
createdBy?: string; // User ID in future
lastModifiedBy?: string;
// Статистика использования
usage: {
totalViews: number;
@ -33,231 +33,291 @@ export interface IFunnel extends Document {
}
// Вложенные схемы для валидации структуры данных воронки
const TypographyVariantSchema = new Schema({
text: {
type: String,
// НЕ required - позволяет { show: false } без текста
validate: {
validator: function(v: string | undefined): boolean {
// Если текст указан, он не может быть пустым
if (v === undefined || v === null) return true;
return v.trim().length > 0;
},
message: 'Text field cannot be empty if provided'
}
const TypographyVariantSchema = new Schema(
{
text: {
type: String,
// НЕ required - позволяет { show: false } без текста
validate: {
validator: function(v: string | undefined): boolean {
// Если текст указан, он не может быть пустым
if (v === undefined || v === null) return true;
return v.trim().length > 0;
},
message: 'Text field cannot be empty if provided'
}
},
show: { type: Boolean, default: true }, // Добавляем поддержку show флага
font: {
type: String,
enum: ["manrope", "inter", "geistSans", "geistMono"],
default: "manrope",
},
weight: {
type: String,
enum: ["regular", "medium", "semiBold", "bold", "extraBold", "black"],
default: "regular",
},
size: {
type: String,
enum: ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"],
default: "md",
},
align: {
type: String,
enum: ["center", "left", "right"],
default: "center",
},
color: {
type: String,
enum: [
"default",
"primary",
"secondary",
"destructive",
"success",
"card",
"accent",
"muted",
],
default: "default",
},
className: String,
},
show: { type: Boolean, default: true }, // Добавляем поддержку show флага
font: {
type: String,
enum: ['manrope', 'inter', 'geistSans', 'geistMono'],
default: 'manrope'
},
weight: {
type: String,
enum: ['regular', 'medium', 'semiBold', 'bold', 'extraBold', 'black'],
default: 'regular'
},
size: {
type: String,
enum: ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'],
default: 'md'
},
align: {
type: String,
enum: ['center', 'left', 'right'],
default: 'center'
},
color: {
type: String,
enum: ['default', 'primary', 'secondary', 'destructive', 'success', 'card', 'accent', 'muted'],
default: 'default'
},
className: String
}, { _id: false });
{ _id: false }
);
const HeaderDefinitionSchema = new Schema({
progress: {
current: Number,
total: Number,
value: Number,
label: String,
className: String
const HeaderDefinitionSchema = new Schema(
{
progress: {
current: Number,
total: Number,
value: Number,
label: String,
className: String,
},
showBackButton: { type: Boolean, default: true },
show: { type: Boolean, default: true },
},
showBackButton: { type: Boolean, default: true },
show: { type: Boolean, default: true }
}, { _id: false });
{ _id: false }
);
const ListOptionDefinitionSchema = new Schema({
id: { type: String, required: true },
label: { type: String, required: true },
description: String,
emoji: String,
value: String,
disabled: { type: Boolean, default: false }
}, { _id: false });
const NavigationConditionSchema = new Schema({
screenId: { type: String, required: true },
conditionType: { type: String, enum: ['options', 'values'], default: 'options' },
operator: {
type: String,
enum: ['includesAny', 'includesAll', 'includesExactly', 'equals'],
default: 'includesAny'
const ListOptionDefinitionSchema = new Schema(
{
id: { type: String, required: true },
label: { type: String, required: true },
description: String,
emoji: String,
value: String,
disabled: { type: Boolean, default: false },
},
optionIds: [{ type: String }],
values: [{ type: String }],
}, { _id: false });
{ _id: false }
);
const NavigationRuleSchema = new Schema({
conditions: [NavigationConditionSchema],
nextScreenId: { type: String, required: true }
}, { _id: false });
const NavigationDefinitionSchema = new Schema({
rules: [NavigationRuleSchema],
defaultNextScreenId: String,
isEndScreen: { type: Boolean, default: false },
}, { _id: false });
const BottomActionButtonSchema = new Schema({
show: { type: Boolean, default: true },
text: String,
cornerRadius: {
type: String,
enum: ['3xl', 'full'],
default: '3xl'
const NavigationConditionSchema = new Schema(
{
screenId: { type: String, required: true },
conditionType: {
type: String,
enum: ["options", "values"],
default: "options",
},
operator: {
type: String,
enum: ["includesAny", "includesAll", "includesExactly", "equals"],
default: "includesAny",
},
optionIds: [{ type: String }],
values: [{ type: String }],
},
showPrivacyTermsConsent: { type: Boolean, default: false },
}, { _id: false });
{ _id: false }
);
const NavigationRuleSchema = new Schema(
{
conditions: [NavigationConditionSchema],
nextScreenId: { type: String, required: true },
},
{ _id: false }
);
const NavigationDefinitionSchema = new Schema(
{
rules: [NavigationRuleSchema],
defaultNextScreenId: String,
isEndScreen: { type: Boolean, default: false },
},
{ _id: false }
);
const BottomActionButtonSchema = new Schema(
{
show: { type: Boolean, default: true },
text: String,
cornerRadius: {
type: String,
enum: ["3xl", "full"],
default: "3xl",
},
showPrivacyTermsConsent: { type: Boolean, default: false },
},
{ _id: false }
);
// Схемы для различных типов экранов (используем Mixed для гибкости)
const ScreenDefinitionSchema = new Schema({
id: { type: String, required: true },
template: {
type: String,
enum: ['info', 'date', 'coupon', 'form', 'list', 'email', 'loaders', 'soulmate'],
required: true
},
header: HeaderDefinitionSchema,
title: { type: TypographyVariantSchema, required: true },
subtitle: TypographyVariantSchema,
bottomActionButton: BottomActionButtonSchema,
navigation: NavigationDefinitionSchema,
// Специфичные для template поля (используем Mixed для максимальной гибкости)
description: TypographyVariantSchema, // info, soulmate
icon: Schema.Types.Mixed, // info
variables: [Schema.Types.Mixed], // info - динамические переменные для подстановки в текст
dateInput: Schema.Types.Mixed, // date
infoMessage: Schema.Types.Mixed, // date
coupon: Schema.Types.Mixed, // coupon
copiedMessage: String, // coupon
fields: [Schema.Types.Mixed], // form
validationMessages: Schema.Types.Mixed, // form
list: { // list
selectionType: {
type: String,
enum: ['single', 'multi']
const ScreenDefinitionSchema = new Schema(
{
id: { type: String, required: true },
template: {
type: String,
enum: [
"info",
"date",
"coupon",
"form",
"list",
"email",
"loaders",
"soulmate",
"trialPayment",
],
required: true,
},
options: [ListOptionDefinitionSchema]
},
emailInput: Schema.Types.Mixed, // email
image: Schema.Types.Mixed, // email, soulmate
// loaders
progressbars: Schema.Types.Mixed, // preferred key used by runtime/templates
variants: [Schema.Types.Mixed] // variants для всех типов
}, { _id: false });
header: HeaderDefinitionSchema,
title: { type: TypographyVariantSchema, required: true },
subtitle: TypographyVariantSchema,
bottomActionButton: BottomActionButtonSchema,
navigation: NavigationDefinitionSchema,
const FunnelMetaSchema = new Schema({
id: { type: String, required: true },
version: String,
title: String,
description: String,
firstScreenId: String
}, { _id: false });
const DefaultTextsSchema = new Schema({
nextButton: { type: String, default: 'Next' },
privacyBanner: { type: String },
}, { _id: false });
const FunnelDataSchema = new Schema({
meta: { type: FunnelMetaSchema, required: true },
defaultTexts: DefaultTextsSchema,
screens: [ScreenDefinitionSchema]
}, { _id: false });
const FunnelSchema = new Schema<IFunnel>({
// Основные данные воронки
funnelData: {
type: FunnelDataSchema,
required: true,
validate: {
validator: function(v: FunnelDefinition): boolean {
// Базовая валидация структуры
return Boolean(v?.meta && v.meta.id && Array.isArray(v.screens));
// Специфичные для template поля (используем Mixed для максимальной гибкости)
description: TypographyVariantSchema, // info, soulmate
icon: Schema.Types.Mixed, // info
variables: [Schema.Types.Mixed], // info - динамические переменные для подстановки в текст
dateInput: Schema.Types.Mixed, // date
infoMessage: Schema.Types.Mixed, // date
coupon: Schema.Types.Mixed, // coupon
copiedMessage: String, // coupon
fields: [Schema.Types.Mixed], // form
validationMessages: Schema.Types.Mixed, // form
list: {
// list
selectionType: {
type: String,
enum: ["single", "multi"],
},
message: 'Invalid funnel data structure'
}
options: [ListOptionDefinitionSchema],
},
emailInput: Schema.Types.Mixed, // email
image: Schema.Types.Mixed, // email, soulmate
// loaders
progressbars: Schema.Types.Mixed, // preferred key used by runtime/templates
variants: [Schema.Types.Mixed], // variants для всех типов
},
// Метаданные для админки
name: {
type: String,
required: true,
trim: true,
maxlength: 200
{ _id: false, strict: false }
);
const FunnelMetaSchema = new Schema(
{
id: { type: String, required: true },
version: String,
title: String,
description: String,
firstScreenId: String,
},
description: {
type: String,
trim: true,
maxlength: 1000
{ _id: false }
);
const DefaultTextsSchema = new Schema(
{
nextButton: { type: String, default: "Next" },
privacyBanner: { type: String },
},
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft',
required: true
{ _id: false }
);
const FunnelDataSchema = new Schema(
{
meta: { type: FunnelMetaSchema, required: true },
defaultTexts: DefaultTextsSchema,
screens: [ScreenDefinitionSchema],
},
// Система версий
version: {
type: Number,
default: 1,
min: 1
{ _id: false }
);
const FunnelSchema = new Schema<IFunnel>(
{
// Основные данные воронки
funnelData: {
type: FunnelDataSchema,
required: true,
validate: {
validator: function (v: FunnelDefinition): boolean {
// Базовая валидация структуры
return Boolean(v?.meta && v.meta.id && Array.isArray(v.screens));
},
message: "Invalid funnel data structure",
},
},
// Метаданные для админки
name: {
type: String,
required: true,
trim: true,
maxlength: 200,
},
description: {
type: String,
trim: true,
maxlength: 1000,
},
status: {
type: String,
enum: ["draft", "published", "archived"],
default: "draft",
required: true,
},
// Система версий
version: {
type: Number,
default: 1,
min: 1,
},
parentFunnelId: {
type: Schema.Types.ObjectId,
ref: "Funnel",
},
// Пользовательские данные
createdBy: String, // В будущем можно заменить на ObjectId ref на User
lastModifiedBy: String,
// Статистика
usage: {
totalViews: { type: Number, default: 0, min: 0 },
totalCompletions: { type: Number, default: 0, min: 0 },
lastUsed: Date,
},
// Timestamps
publishedAt: Date,
},
parentFunnelId: {
type: Schema.Types.ObjectId,
ref: 'Funnel'
},
// Пользовательские данные
createdBy: String, // В будущем можно заменить на ObjectId ref на User
lastModifiedBy: String,
// Статистика
usage: {
totalViews: { type: Number, default: 0, min: 0 },
totalCompletions: { type: Number, default: 0, min: 0 },
lastUsed: Date
},
// Timestamps
publishedAt: Date
}, {
timestamps: true, // Автоматически добавляет createdAt и updatedAt
collection: 'funnels'
});
{
timestamps: true, // Автоматически добавляет createdAt и updatedAt
collection: "funnels",
}
);
// Индексы для производительности
FunnelSchema.index({ 'funnelData.meta.id': 1 }); // Для поиска по ID воронки
FunnelSchema.index({ "funnelData.meta.id": 1 }); // Для поиска по ID воронки
FunnelSchema.index({ status: 1, updatedAt: -1 }); // Для каталога воронок
FunnelSchema.index({ name: 'text', description: 'text' }); // Для поиска по тексту
FunnelSchema.index({ name: "text", description: "text" }); // Для поиска по тексту
FunnelSchema.index({ createdBy: 1 }); // Для фильтра по автору
FunnelSchema.index({ 'usage.lastUsed': -1 }); // Для сортировки по использованию
FunnelSchema.index({ "usage.lastUsed": -1 }); // Для сортировки по использованию
// Методы модели
FunnelSchema.methods.toPublicJSON = function(this: IFunnel) {
FunnelSchema.methods.toPublicJSON = function (this: IFunnel) {
return {
_id: this._id,
name: this.name,
@ -268,14 +328,17 @@ FunnelSchema.methods.toPublicJSON = function(this: IFunnel) {
updatedAt: this.updatedAt,
publishedAt: this.publishedAt,
usage: this.usage,
funnelData: this.funnelData
funnelData: this.funnelData,
};
};
FunnelSchema.methods.incrementUsage = function(this: IFunnel, type: 'view' | 'completion') {
if (type === 'view') {
FunnelSchema.methods.incrementUsage = function (
this: IFunnel,
type: "view" | "completion"
) {
if (type === "view") {
this.usage.totalViews += 1;
} else if (type === 'completion') {
} else if (type === "completion") {
this.usage.totalCompletions += 1;
}
this.usage.lastUsed = new Date();
@ -283,35 +346,45 @@ FunnelSchema.methods.incrementUsage = function(this: IFunnel, type: 'view' | 'co
};
// Статические методы
FunnelSchema.statics.findPublished = function() {
return this.find({ status: 'published' }).sort({ publishedAt: -1 });
FunnelSchema.statics.findPublished = function () {
return this.find({ status: "published" }).sort({ publishedAt: -1 });
};
FunnelSchema.statics.findByFunnelId = function(funnelId: string) {
return this.findOne({ 'funnelData.meta.id': funnelId });
FunnelSchema.statics.findByFunnelId = function (funnelId: string) {
return this.findOne({ "funnelData.meta.id": funnelId });
};
// Pre-save хуки
FunnelSchema.pre('save', function(next) {
FunnelSchema.pre("save", function (next) {
// Автоматически устанавливаем publishedAt при первой публикации
if (this.status === 'published' && !this.publishedAt) {
if (this.status === "published" && !this.publishedAt) {
this.publishedAt = new Date();
}
// Валидация: firstScreenId должен существовать в screens
if (this.funnelData.meta.firstScreenId) {
const firstScreenExists = this.funnelData.screens.some(
screen => screen.id === this.funnelData.meta.firstScreenId
(screen) => screen.id === this.funnelData.meta.firstScreenId
);
if (!firstScreenExists) {
return next(new Error('firstScreenId must reference an existing screen'));
return next(new Error("firstScreenId must reference an existing screen"));
}
}
next();
});
// Экспорт модели с проверкой на существование
const FunnelModel: Model<IFunnel> = mongoose.models.Funnel || mongoose.model<IFunnel>('Funnel', FunnelSchema);
// В dev окружении пересоздаём модель, чтобы подтянуть изменения схемы (enums и т.п.)
if (process.env.NODE_ENV !== "production" && typeof mongoose.models.Funnel !== "undefined") {
try {
(mongoose as unknown as { deleteModel: (name: string) => void }).deleteModel("Funnel");
} catch {
// no-op
}
}
const FunnelModel: Model<IFunnel> =
mongoose.models.Funnel || mongoose.model<IFunnel>("Funnel", FunnelSchema);
export default FunnelModel;

View File

@ -11,4 +11,7 @@ const createRoute = (
export const API_ROUTES = {
session: (id?: string) => createRoute(["session", id], ROOT_ROUTE_V2),
authorization: () => createRoute(["users", "auth"]),
paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2),
paymentSingleCheckout: () => createRoute(["payment", "checkout"]),
funnel: () => createRoute(["session", "funnel"], ROOT_ROUTE_V2),
};

View File

@ -0,0 +1,22 @@
const ROOT_ROUTE = "/";
const createRoute = (
segments: Array<string | undefined>,
queryParams?: Record<string, string>
): string => {
const url = ROOT_ROUTE + segments.filter(Boolean).join("/");
if (queryParams) {
return url + "?" + new URLSearchParams(queryParams).toString();
}
return url;
};
export const ROUTES = {
home: () => createRoute([]),
// Payment
payment: (queryParams?: Record<string, string>) =>
createRoute(["payment"], queryParams),
paymentSuccess: () => createRoute(["payment", "success"]),
paymentFailed: () => createRoute(["payment", "failed"]),
};

View File

@ -0,0 +1,77 @@
export enum ELottieKeys {
goal = "goal",
magnifyingGlassAndPlanet = "magnifyingGlassAndPlanet",
scalesNeutral = "scalesNeutral",
scalesHead = "scalesHead",
scalesHeart = "scalesHeart",
compass = "compass",
handWithStars = "handWithStars",
key = "key",
cloudAndStars = "cloudAndStars",
darts = "darts",
umbrella = "umbrella",
hourglass = "hourglass",
lightBulb = "lightBulb",
sun = "sun",
handSymbols = "handSymbols",
scalesNeutralPalmistry = "scalesNeutralPalmistry",
scalesHeadPalmistry = "scalesHeadPalmistry",
scalesHeartPalmistry = "scalesHeartPalmistry",
letScan = "letScan",
letScanDark = "letScanDark",
scannedPhoto = "scannedPhoto",
loaderCheckMark = "loaderCheckMark",
loaderCheckMark2 = "loaderCheckMark2",
confetti = "confetti",
}
export const lottieUrls = {
[ELottieKeys.goal]:
"https://lottie.host/a86e1531-7028-4688-a836-ea9d71dafa3b/Pe5G1g9s9L.lottie",
[ELottieKeys.magnifyingGlassAndPlanet]:
"https://lottie.host/beaa1dc6-cd60-4bbe-a222-c039b04c630f/ZktoTHROIW.lottie",
[ELottieKeys.scalesNeutral]:
"https://lottie.host/ddd2cb46-d62f-4808-a10d-1dd5ce8d42d2/6hgUBBGjaJ.lottie",
[ELottieKeys.scalesHead]:
"https://lottie.host/19fe41d7-d26f-431c-b063-8e123ce3d57a/HiucMMidQT.lottie",
[ELottieKeys.scalesHeart]:
"https://lottie.host/9eb3f7a1-83c2-495a-9342-c234bfebc40c/0T90l2xSWl.lottie",
[ELottieKeys.compass]:
"https://lottie.host/15b235d7-b8c9-487f-8d65-73143afc9ecc/czTjX9Lwp1.lottie",
[ELottieKeys.handWithStars]:
"https://lottie.host/25105d46-cc0a-4f76-9ad0-5e64e3eb0e52/OenfEsMruV.lottie",
[ELottieKeys.key]:
"https://lottie.host/a80ec293-6f3d-4d21-a19e-9dfb40b86a14/clQys1OEAL.lottie",
[ELottieKeys.cloudAndStars]:
"https://lottie.host/6010e02c-da90-4089-982c-177f3b5dbc05/fXkYv6hGPc.lottie",
[ELottieKeys.darts]:
"https://lottie.host/c3856d09-bfe9-44de-8712-f935f5deed67/rtD0j4YfnN.lottie",
[ELottieKeys.umbrella]:
"https://lottie.host/e353e80c-fd4a-4eca-a930-d9bf923466e0/G4sxbtkhIA.lottie",
[ELottieKeys.hourglass]:
"https://lottie.host/c1b52c33-1a3c-4759-9c5d-090ed2a62c77/IqHW4RCqVH.lottie",
[ELottieKeys.lightBulb]:
"https://lottie.host/07e33753-d13c-4469-ad33-26e57017b0ec/qMVfYwwLqs.lottie",
[ELottieKeys.sun]:
"https://lottie.host/8ae9682d-93d3-4988-8745-e7134daed217/lZG1RZgqaP.lottie",
[ELottieKeys.handSymbols]:
"https://lottie.host/ae56bb19-96e6-4147-ac94-6c9a5a24bd9d/bDBUSdzN5e.lottie",
[ELottieKeys.scalesNeutralPalmistry]:
"https://lottie.host/9027e5a7-d5e8-4e60-b097-ba4bf099b433/UsCKDjKVUr.lottie",
[ELottieKeys.scalesHeadPalmistry]:
"https://lottie.host/d16336c4-2622-48f8-b361-8d9d50b3c8a6/wWSM7JMCHu.lottie",
[ELottieKeys.scalesHeartPalmistry]:
"https://lottie.host/fa931c2d-07f5-4c57-a4bb-8302b411ecca/zy9ag3MyMe.lottie",
[ELottieKeys.letScan]:
"https://lottie.host/77c3c34b-4c1e-4cab-87f4-40d7534fea3d/wMg1wqtSS6.lottie", //"https://lottie.host/f87184ec-aa5e-4cf4-82a5-9ab5e60c22d5/qpgweCSCtn.lottie",
[ELottieKeys.letScanDark]:
"https://lottie.host/71623941-9182-4d58-8a1d-cb05cc5732ad/fEXKgPZQYq.lottie", //"https://lottie.host/c890243e-c61a-4e76-8b93-e8d24b25dd97/leetT4srXt.lottie",
[ELottieKeys.scannedPhoto]:
"https://lottie.host/0570b1a3-2441-486e-909b-bc2a6ceb692b/KAHTUVUb8C.lottie",
[ELottieKeys.loaderCheckMark]:
"https://lottie.host/c29ba802-17b4-4ddb-a733-5385b91394f2/qnFaLSA5p3.lottie",
[ELottieKeys.loaderCheckMark2]:
"https://lottie.host/6e249251-0469-43b2-9582-822e8f701ce2/sjRwaq20Dr.lottie",
[ELottieKeys.confetti]:
"https://lottie.host/ee592a75-4a56-4d3b-b671-b0695715a021/NYbdrg8EEb.lottie",
};

11
src/shared/types/index.ts Normal file
View File

@ -0,0 +1,11 @@
export type ActionResponse<T> = {
data: T | null;
error: string | null;
};
export enum Currency {
USD = "USD",
EUR = "EUR",
usd = "usd",
eur = "eur",
}

View File

@ -0,0 +1,52 @@
"use client";
import { IDBPDatabase, openDB } from "idb";
export enum EObjectStores {
Lottie = "lottie",
}
const objectStores: EObjectStores[] = [EObjectStores.Lottie];
let dbPromise: Promise<IDBPDatabase> | null = null;
function getDB() {
if (typeof window === "undefined") {
throw new Error("IndexedDB is unavailable on the server.");
}
if (!dbPromise) {
dbPromise = openDB("wit-store", 1, {
upgrade(db) {
db.createObjectStore("lottie");
},
});
}
return dbPromise;
}
async function get<T>(
store: EObjectStores,
key: string
): Promise<T | undefined> {
return (await getDB()).get(store, key);
}
async function set<T>(store: EObjectStores, key: string, val: T) {
return (await getDB()).put(store, val, key);
}
async function del(store: EObjectStores, key: string) {
return (await getDB()).delete(store, key);
}
async function clear() {
return Promise.all(objectStores.map(async s => (await getDB()).clear(s)));
}
async function keys() {
return Promise.all(
objectStores.map(async s => ({
objectStore: s,
keys: await (await getDB()).getAllKeys(s),
}))
);
}
const indexedDBService = { get, set, del, clear, keys };
export default indexedDBService;