+
+ {screen.soulmatePortraitsDelivered && (
+
({
+ imageProps: a.src
+ ? { src: a.src, alt: a.alt ?? "" }
+ : undefined,
+ fallbackProps: a.fallbackText
+ ? {
+ children: (
+
+ {a.fallbackText}
+
+ ),
+ className: "bg-background",
+ }
+ : undefined,
+ className: a.fallbackText ? "w-fit px-1" : undefined,
+ })
+ ),
+ }
+ : undefined
+ }
+ />
+ )}
+
+ {screen.description &&
+ (() => {
+ const descProps = buildTypographyProps(screen.description, {
+ as: "p",
+ defaults: {
+ align: "center",
+ font: "inter",
+ size: "md",
+ weight: "bold",
+ className: "text-[25px] font-bold",
+ },
+ });
+ if (!descProps) return null;
+ const { children, ...rest } = descProps;
+ return (
+
+ {children}
+
+ );
+ })()}
+ {screen.textList && (
+
+ {screen.textList.items.map((item, index) => {
+ const itemProps = buildTypographyProps(item, {
+ as: "li",
+ defaults: { font: "inter", weight: "medium", size: "md" },
+ });
+ if (!itemProps) return null;
+ const { children, ...rest } = itemProps;
+ return (
+
+ {children}
+
+ );
+ })}
+
+ )}
+
);
diff --git a/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.stories.tsx b/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.stories.tsx
new file mode 100644
index 0000000..c7a04d7
--- /dev/null
+++ b/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.stories.tsx
@@ -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
= {
+ 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;
+
+export const Default: Story = {};
+
+
diff --git a/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.tsx b/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.tsx
new file mode 100644
index 0000000..057884f
--- /dev/null
+++ b/src/components/funnel/templates/TrialPaymentTemplate/TrialPaymentTemplate.tsx
@@ -0,0 +1,993 @@
+"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 { Spinner } from "@/components/ui/spinner";
+import { Currency } from "@/shared/types";
+import { getFormattedPrice } from "@/shared/utils/price";
+import { useRouter } from "next/navigation";
+import { ROUTES } from "@/shared/constants/client-routes";
+
+interface TrialPaymentTemplateProps {
+ funnel: FunnelDefinition;
+ screen: TrialPaymentScreenDefinition;
+ onContinue: () => void;
+ canGoBack: boolean;
+ onBack: () => void;
+ screenProgress?: { current: number; total: number };
+ defaultTexts?: DefaultTexts;
+}
+
+export function TrialPaymentTemplate({
+ funnel,
+ screen,
+ canGoBack,
+ onBack,
+ screenProgress,
+ defaultTexts,
+}: TrialPaymentTemplateProps) {
+ const router = useRouter();
+
+ // TODO: выбрать корректный paymentId для этого экрана (ключ из backend), временно "main"
+ const paymentId = "main";
+ const { placement, isLoading } = usePaymentPlacement({ funnel, paymentId });
+
+ const trialInterval = placement?.trialInterval || 7;
+ const trialPeriod = placement?.trialPeriod;
+ const variant = placement?.variants?.[0];
+ const productId = variant?.id || "";
+ const placementId = placement?.placementId || "";
+ const paywallId = placement?.paywallId || "";
+ const trialPrice = variant?.trialPrice || 0;
+ const price = variant?.price || 0;
+ const oldPrice = variant?.price || 0;
+ const billingPeriod = placement?.billingPeriod;
+ const billingInterval = placement?.billingInterval || 1;
+ const currency = placement?.currency || Currency.USD;
+
+ console.log({ placement });
+
+ const handlePayClick = () => {
+ router.push(
+ ROUTES.payment({
+ productId,
+ placementId,
+ paywallId,
+ })
+ );
+ };
+
+ const paymentSectionRef = useRef(null);
+
+ const scrollToPayment = () => {
+ if (paymentSectionRef.current) {
+ paymentSectionRef.current.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ }
+ };
+
+ const formatPeriod = (
+ period: "DAY" | "WEEK" | "MONTH" | "YEAR" | undefined,
+ interval: number
+ ) => {
+ if (!period) return `${interval} days`;
+ const unit =
+ period === "DAY"
+ ? interval === 1
+ ? "day"
+ : "days"
+ : period === "WEEK"
+ ? interval === 1
+ ? "week"
+ : "weeks"
+ : period === "MONTH"
+ ? interval === 1
+ ? "month"
+ : "months"
+ : interval === 1
+ ? "year"
+ : "years";
+ return `${interval} ${unit}`;
+ };
+
+ const formattedTrialPrice = getFormattedPrice(trialPrice, currency);
+ const formattedBillingPrice = getFormattedPrice(price, currency);
+ const trialPeriodText = formatPeriod(trialPeriod, trialInterval);
+ const billingPeriodText = formatPeriod(billingPeriod, billingInterval);
+
+ const computeDiscountPercent = () => {
+ if (!oldPrice || !trialPrice || oldPrice <= 0) return undefined;
+ const ratio = 1 - trialPrice / oldPrice;
+ const percent = Math.max(0, Math.min(100, Math.round(ratio * 100)));
+ return String(percent);
+ };
+
+ const formatPeriodHyphen = (
+ period: "DAY" | "WEEK" | "MONTH" | "YEAR" | undefined,
+ interval: number
+ ) => {
+ if (!period) return `${interval}-day`;
+ const unit =
+ period === "DAY"
+ ? interval === 1
+ ? "day"
+ : "days"
+ : period === "WEEK"
+ ? interval === 1
+ ? "week"
+ : "weeks"
+ : period === "MONTH"
+ ? interval === 1
+ ? "month"
+ : "months"
+ : interval === 1
+ ? "year"
+ : "years";
+ return `${interval}-${unit}`;
+ };
+
+ const trialPeriodHyphenText = formatPeriodHyphen(trialPeriod, trialInterval);
+
+ const replacePlaceholders = (text: string | undefined) => {
+ if (!text) return "";
+ const values: Record = {
+ trialPrice: formattedTrialPrice,
+ billingPrice: formattedBillingPrice,
+ oldPrice: getFormattedPrice(oldPrice || 0, currency),
+ discountPercent: computeDiscountPercent() ?? "",
+ trialPeriod: trialPeriodText,
+ billingPeriod: billingPeriodText,
+ trialPeriodHyphen: trialPeriodHyphenText,
+ };
+ let result = text;
+ for (const [key, value] of Object.entries(values)) {
+ result = result.replaceAll(`{{${key}}}`, value);
+ }
+ return result;
+ };
+
+ // Отключаем общий 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,
+ },
+ }
+ );
+
+ if (isLoading || !placement) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header block */}
+ {screen.headerBlock && (
+
+ )}
+
+ {/* UnlockYourSketch section */}
+ {screen.unlockYourSketch && (
+
+
+
+ ) : undefined,
+ }}
+ button={
+ screen.unlockYourSketch.buttonText
+ ? {
+ children: screen.unlockYourSketch.buttonText,
+ onClick: scrollToPayment,
+ }
+ : undefined
+ }
+ />
+ )}
+
+ {screen.joinedToday && (
+
+
+
+ }
+ />
+ )}
+
+ {screen.trustedByOver && (
+
+
+
+ }
+ />
+ )}
+
+ {screen.findingOneGuide && (
+
+
+
+ ) : undefined,
+ }}
+ />
+ )}
+
+ {screen.tryForDays && (
+
+ buildTypographyProps(
+ { ...it, text: replacePlaceholders(it.text) },
+ { as: "li", defaults: { font: "inter", size: "sm" } }
+ )!
+ ),
+ }
+ : undefined
+ }
+ />
+ )}
+
+ {screen.totalPrice && (
+
+ )}
+
+ {screen.paymentButtons && (
+
+
{
+ const icon =
+ b.icon === "pay" ? (
+
+ ) : b.icon === "google" ? (
+
+ ) : b.icon === "card" ? (
+
+ ) : undefined;
+
+ const className = b.primary ? "bg-primary" : undefined;
+
+ return {
+ children: b.text,
+ icon,
+ className,
+ onClick: handlePayClick,
+ };
+ })}
+ />
+
+ )}
+
+ {screen.moneyBackGuarantee && (
+
+ )}
+
+ {screen.policy && (
+
+ )}
+
+ {screen.usersPortraits && (
+ ({
+ src: img.src,
+ alt: "user portrait",
+ }))}
+ button={
+ screen.usersPortraits.buttonText
+ ? {
+ children: screen.usersPortraits.buttonText,
+ onClick: scrollToPayment,
+ }
+ : undefined
+ }
+ />
+ )}
+
+ {screen.joinedTodayWithAvatars && (
+ ({
+ 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 && (
+
+ )}
+
+ {screen.stepsToSeeSoulmate && (
+ ({
+ 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" ? (
+
+ ) : s.icon === "profile" ? (
+
+ ) : s.icon === "sketch" ? (
+
+ ) : s.icon === "astro" ? (
+
+ ) : s.icon === "chat" ? (
+
+ ) : null,
+ isActive: s.isActive ?? false,
+ }))}
+ button={
+ screen.stepsToSeeSoulmate.buttonText
+ ? {
+ children: screen.stepsToSeeSoulmate.buttonText,
+ onClick: scrollToPayment,
+ }
+ : undefined
+ }
+ />
+ )}
+
+ {screen.reviews && (
+ ({
+ 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 && (
+ ({
+ value: `q-${index}`,
+ trigger: { children: q.question },
+ content: { children: q.answer },
+ }))}
+ accordionProps={{ defaultValue: "q-0", type: "single" }}
+ />
+ )}
+
+ {screen.stillHaveQuestions && (
+
+ )}
+
+ {screen.footer && (
+
+
+ );
+}
diff --git a/src/components/funnel/templates/TrialPaymentTemplate/index.ts b/src/components/funnel/templates/TrialPaymentTemplate/index.ts
new file mode 100644
index 0000000..94deec1
--- /dev/null
+++ b/src/components/funnel/templates/TrialPaymentTemplate/index.ts
@@ -0,0 +1,3 @@
+export { TrialPaymentTemplate } from "./TrialPaymentTemplate";
+
+
diff --git a/src/components/funnel/templates/index.ts b/src/components/funnel/templates/index.ts
index cf85da7..77bde3e 100644
--- a/src/components/funnel/templates/index.ts
+++ b/src/components/funnel/templates/index.ts
@@ -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";
diff --git a/src/components/funnel/templates/layouts/TemplateLayout.tsx b/src/components/funnel/templates/layouts/TemplateLayout.tsx
index 2867abd..f739998 100644
--- a/src/components/funnel/templates/layouts/TemplateLayout.tsx
+++ b/src/components/funnel/templates/layouts/TemplateLayout.tsx
@@ -16,35 +16,57 @@ interface TemplateLayoutProps {
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
-
+
// Настройки template
titleDefaults?: {
font?: "manrope" | "inter" | "geistSans" | "geistMono";
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
align?: "left" | "center" | "right";
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
- color?: "default" | "primary" | "secondary" | "destructive" | "success" | "card" | "accent" | "muted";
+ color?:
+ | "default"
+ | "primary"
+ | "secondary"
+ | "destructive"
+ | "success"
+ | "card"
+ | "accent"
+ | "muted";
+ className?: string;
};
subtitleDefaults?: {
font?: "manrope" | "inter" | "geistSans" | "geistMono";
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
- color?: "default" | "primary" | "secondary" | "destructive" | "success" | "card" | "accent" | "muted";
+ color?:
+ | "default"
+ | "primary"
+ | "secondary"
+ | "destructive"
+ | "success"
+ | "card"
+ | "accent"
+ | "muted";
align?: "left" | "center" | "right";
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
+ className?: string;
};
actionButtonOptions?: {
defaultText: string;
disabled: boolean;
onClick: () => void;
};
-
+
// Дополнительные props для BottomActionButton
childrenAboveButton?: React.ReactNode;
childrenUnderButton?: React.ReactNode;
-
+
// Дополнительные props для Title
childrenAboveTitle?: React.ReactNode;
-
+
+ // Переопределения стилей LayoutQuestion (контент и обертка контента)
+ contentProps?: React.ComponentProps<"div">;
+ childrenWrapperProps?: React.ComponentProps<"div">;
+
// Контент template
children: React.ReactNode;
}
@@ -58,18 +80,30 @@ export function TemplateLayout({
canGoBack,
onBack,
screenProgress,
- titleDefaults = { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" },
- subtitleDefaults = { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
+ titleDefaults = {
+ font: "manrope",
+ weight: "bold",
+ align: "left",
+ size: "2xl",
+ color: "default",
+ },
+ subtitleDefaults = {
+ font: "manrope",
+ weight: "medium",
+ color: "default",
+ align: "left",
+ size: "lg",
+ },
actionButtonOptions,
childrenAboveButton,
childrenUnderButton,
childrenAboveTitle,
+ contentProps,
+ childrenWrapperProps,
children,
}: TemplateLayoutProps) {
// 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON
- const {
- elementRef: bottomActionButtonRef,
- } = useDynamicSize({
+ const { elementRef: bottomActionButtonRef } = useDynamicSize({
defaultHeight: 132,
});
@@ -92,20 +126,20 @@ export function TemplateLayout({
: undefined;
// 🎯 Автоматически создаем PrivacyTermsConsent с фиксированными настройками
- const shouldShowPrivacyTermsConsent =
- 'bottomActionButton' in screen &&
+ const shouldShowPrivacyTermsConsent =
+ "bottomActionButton" in screen &&
screen.bottomActionButton?.showPrivacyTermsConsent === true;
const autoPrivacyTermsConsent = shouldShowPrivacyTermsConsent ? (
-
) : null;
@@ -121,13 +155,18 @@ export function TemplateLayout({
// 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ
return (
-
+
{children}
{bottomActionButtonProps && (
- , "title" | "content"> {
@@ -55,7 +57,7 @@ function LayoutQuestion({
weight="bold"
{...title}
align={title.align ?? "left"}
- className={cn(title.className, "w-full text-[25px] leading-[38px]")}
+ className={cn("w-full text-[25px] leading-[38px]", title.className)}
/>
)}
@@ -66,8 +68,8 @@ function LayoutQuestion({
{...subtitle}
align={subtitle.align ?? "left"}
className={cn(
- subtitle.className,
- "w-full mt-2.5 text-[17px] leading-[26px]"
+ "w-full mt-2.5 text-[17px] leading-[26px]",
+ subtitle.className
)}
/>
)}
@@ -83,4 +85,4 @@ function LayoutQuestion({
);
}
-export { LayoutQuestion };
\ No newline at end of file
+export { LayoutQuestion };
diff --git a/src/components/ui/GPTAnimationText/GPTAnimationText.module.css b/src/components/ui/GPTAnimationText/GPTAnimationText.module.css
new file mode 100644
index 0000000..3a1e944
--- /dev/null
+++ b/src/components/ui/GPTAnimationText/GPTAnimationText.module.css
@@ -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;
+ }
+}
diff --git a/src/components/ui/GPTAnimationText/GPTAnimationText.tsx b/src/components/ui/GPTAnimationText/GPTAnimationText.tsx
new file mode 100644
index 0000000..b43c153
--- /dev/null
+++ b/src/components/ui/GPTAnimationText/GPTAnimationText.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+import styles from "./GPTAnimationText.module.css";
+
+interface GPTAnimationTextProps {
+ points: Array;
+ totalAnimationTime: number;
+}
+
+function GPTAnimationText({
+ points,
+ totalAnimationTime,
+}: GPTAnimationTextProps) {
+ const listRef = useRef>([]);
+ 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 (
+
+ {points.map((element, index) => (
+
{
+ listRef.current[index] = el;
+ }}
+ style={{
+ animationDuration: `${totalAnimationTime / points.length}ms`,
+ animationDelay: `${index * (totalAnimationTime / points.length)}ms`,
+ }}
+ >
+ {element}
+
+
+ ))}
+
+ );
+}
+
+export default GPTAnimationText;
diff --git a/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx b/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx
new file mode 100644
index 0000000..0e523bc
--- /dev/null
+++ b/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx
@@ -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 (
+
+ {lottieAnimation}
+
+ {title}
+
+ {!!animationTexts?.length && animationTime && (
+
+ )}
+ {nextRoute && buttonText && (
+
+ {buttonText}
+
+ )}
+
+ );
+}
diff --git a/src/components/widgets/LottieAnimation/LottieAnimation.tsx b/src/components/widgets/LottieAnimation/LottieAnimation.tsx
new file mode 100644
index 0000000..e409942
--- /dev/null
+++ b/src/components/widgets/LottieAnimation/LottieAnimation.tsx
@@ -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 (
+
+ {animationData && (
+
+ )}
+
+ );
+}
diff --git a/src/entities/payment/actions.ts b/src/entities/payment/actions.ts
new file mode 100644
index 0000000..8f3fa7b
--- /dev/null
+++ b/src/entities/payment/actions.ts
@@ -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> {
+ try {
+ const response = await http.post(
+ 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 };
+ }
+}
diff --git a/src/entities/payment/api.ts b/src/entities/payment/api.ts
new file mode 100644
index 0000000..d1635f6
--- /dev/null
+++ b/src/entities/payment/api.ts
@@ -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(API_ROUTES.paymentCheckout(), payload, {
+ schema: CheckoutResponseSchema,
+ revalidate: 0,
+ });
+}
+
+export async function createSinglePaymentCheckout(
+ payload: SingleCheckoutRequest
+) {
+ return http.post(
+ API_ROUTES.paymentSingleCheckout(),
+ payload,
+ {
+ schema: SingleCheckoutResponseSchema,
+ revalidate: 0,
+ }
+ );
+}
diff --git a/src/entities/payment/types.ts b/src/entities/payment/types.ts
new file mode 100644
index 0000000..314ee97
--- /dev/null
+++ b/src/entities/payment/types.ts
@@ -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;
+
+export const CheckoutResponseSchema = z.object({
+ status: z.string(),
+ invoiceId: z.string(),
+ paymentUrl: z.string().url(),
+});
+export type CheckoutResponse = z.infer;
+
+export const PaymentInfoSchema = z.object({
+ productId: z.string(),
+ key: z.string(),
+ isAutoTopUp: z.boolean().optional(),
+});
+export type PaymentInfo = z.infer;
+
+export const SingleCheckoutRequestSchema = z.object({
+ paymentInfo: PaymentInfoSchema,
+ return_url: z.string().optional(),
+ pageUrl: z.string().optional(),
+});
+export type SingleCheckoutRequest = z.infer;
+
+export const SingleCheckoutSuccessSchema = z.object({
+ payment: z.object({
+ status: z.string(),
+ invoiceId: z.string(),
+ paymentUrl: z.string().url().optional(),
+ }),
+});
+export type SingleCheckoutSuccess = z.infer;
+
+export const SingleCheckoutErrorSchema = z.object({
+ status: z.string(),
+ message: z.string(),
+});
+export type SingleCheckoutError = z.infer;
+
+export const SingleCheckoutResponseSchema = z.union([
+ SingleCheckoutSuccessSchema,
+ SingleCheckoutErrorSchema,
+]);
+export type SingleCheckoutResponse = z.infer<
+ typeof SingleCheckoutResponseSchema
+>;
diff --git a/src/entities/session/funnel/api.ts b/src/entities/session/funnel/api.ts
new file mode 100644
index 0000000..7bf1758
--- /dev/null
+++ b/src/entities/session/funnel/api.ts
@@ -0,0 +1,14 @@
+"use server";
+
+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(API_ROUTES.funnel(), payload, {
+ tags: ["funnel"],
+ schema: FunnelResponseSchema,
+ revalidate: 0,
+ });
+};
diff --git a/src/entities/session/funnel/loaders.ts b/src/entities/session/funnel/loaders.ts
new file mode 100644
index 0000000..875cf3f
--- /dev/null
+++ b/src/entities/session/funnel/loaders.ts
@@ -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 ?? [])
+// );
diff --git a/src/entities/session/funnel/serverActions.ts b/src/entities/session/funnel/serverActions.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/entities/session/funnel/types.ts b/src/entities/session/funnel/types.ts
new file mode 100644
index 0000000..067b4af
--- /dev/null
+++ b/src/entities/session/funnel/types.ts
@@ -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;
+export type IFunnelPaymentProperty = z.infer<
+ typeof FunnelPaymentPropertySchema
+>;
+export type IFunnelPaymentVariant = z.infer;
+export type IFunnelPaymentPlacement = z.infer<
+ typeof FunnelPaymentPlacementSchema
+>;
+export type IFunnel = z.infer;
+export type FunnelResponse = z.infer;
diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts
index 05a2c2a..0591c47 100644
--- a/src/entities/user/types.ts
+++ b/src/entities/user/types.ts
@@ -39,7 +39,7 @@ export const CreateAuthorizeResponseSchema = z.object({
token: z.string(),
userId: z.string().optional(),
generatingVideo: z.boolean().optional(),
- videoId: z.string().optional(),
+ videoId: z.string().nullable().optional(),
authCode: z.string().optional(),
});
diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts
index 9616e06..bc4fae5 100644
--- a/src/hooks/auth/useAuth.ts
+++ b/src/hooks/auth/useAuth.ts
@@ -39,8 +39,8 @@ export const useAuth = ({ funnelId }: IUseAuthProps) => {
timezone,
locale,
email,
- // source: funnelId,
- source: "aura.compatibility.v2",
+ source: funnelId,
+ // source: "aura.compatibility.v2",
// profile: {
// name: username || "",
// gender: EGender[gender as keyof typeof EGender] || null,
@@ -60,22 +60,10 @@ export const useAuth = ({ funnelId }: IUseAuthProps) => {
sign: true,
signDate: new Date().toISOString(),
// feature: feature.includes("black") ? "ios" : feature,
+ feature: "stripe"
});
},
- [
- // birthPlace,
- // birthdate,
- // gender,
- // locale,
- // partnerBirthPlace,
- // partnerBirthdate,
- // partnerGender,
- // partnerName,
- // username,
- // birthtime,
- // checked,
- // dateOfCheck,
- ]
+ [funnelId]
);
const authorization = useCallback(
@@ -106,6 +94,7 @@ export const useAuth = ({ funnelId }: IUseAuthProps) => {
// authCode,
} = await createAuthorization(payload);
await setAuthTokenToCookie(token);
+ return token;
// const { user: userMe } = await api.getMe({ token });
// const userId = userIdFromApi || userMe?._id;
// if (userId?.length) {
diff --git a/src/hooks/lottie/useLottie.ts b/src/hooks/lottie/useLottie.ts
new file mode 100644
index 0000000..504b8fc
--- /dev/null
+++ b/src/hooks/lottie/useLottie.ts
@@ -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();
+ 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(
+ 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(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]
+ );
+};
diff --git a/src/hooks/payment/usePaymentPlacement.ts b/src/hooks/payment/usePaymentPlacement.ts
new file mode 100644
index 0000000..eec6d58
--- /dev/null
+++ b/src/hooks/payment/usePaymentPlacement.ts
@@ -0,0 +1,68 @@
+"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(
+ null
+ );
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(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 };
+}
diff --git a/src/hooks/session/useSession.ts b/src/hooks/session/useSession.ts
index 49d2b98..55ce631 100644
--- a/src/hooks/session/useSession.ts
+++ b/src/hooks/session/useSession.ts
@@ -54,6 +54,7 @@ export const useSession = ({ funnelId }: IUseSessionProps) => {
try {
const utm = parseQueryParams();
const sessionParams = {
+ feature: "stripe",
locale,
timezone,
// source: funnelId,
@@ -102,7 +103,10 @@ export const useSession = ({ funnelId }: IUseSessionProps) => {
}
const result = await updateSessionApi({
sessionId: _sessionId,
- data,
+ data: {
+ feature: "stripe",
+ ...data,
+ },
});
return result;
} catch (error) {
diff --git a/src/lib/admin/builder/state/defaults/index.ts b/src/lib/admin/builder/state/defaults/index.ts
index c0268fc..7fe5fd7 100644
--- a/src/lib/admin/builder/state/defaults/index.ts
+++ b/src/lib/admin/builder/state/defaults/index.ts
@@ -29,3 +29,4 @@ export { buildCouponDefaults } from "./coupon";
export { buildEmailDefaults } from "./email";
export { buildLoadersDefaults } from "./loaders";
export { buildSoulmateDefaults } from "./soulmate";
+export { buildTrialPaymentDefaults } from "./trialPayment";
diff --git a/src/lib/admin/builder/state/defaults/soulmate.ts b/src/lib/admin/builder/state/defaults/soulmate.ts
index 0e83235..96abdb6 100644
--- a/src/lib/admin/builder/state/defaults/soulmate.ts
+++ b/src/lib/admin/builder/state/defaults/soulmate.ts
@@ -1,11 +1,11 @@
import type { BuilderScreen } from "@/lib/admin/builder/types";
-import {
- buildDefaultHeader,
+import {
+ buildDefaultHeader,
buildDefaultTitle,
buildDefaultSubtitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
- buildDefaultDescription
+ buildDefaultDescription,
} from "./blocks";
export function buildSoulmateDefaults(id: string): BuilderScreen {
@@ -19,12 +19,39 @@ export function buildSoulmateDefaults(id: string): BuilderScreen {
title: buildDefaultTitle(),
subtitle: buildDefaultSubtitle(),
bottomActionButton: buildDefaultBottomActionButton({
- text: "Получить полный анализ",
+ text: "Continue",
+ showPrivacyTermsConsent: true,
}),
description: buildDefaultDescription({
- text: "Ваш персональный портрет почти готов.",
+ text: "Готов увидеть, кто твоя настоящая Родственная душа?",
align: "center",
}),
+ soulmatePortraitsDelivered: {
+ image: "/soulmate-portrait-delivered-male.jpg",
+ text: {
+ text: "soulmate portraits delivered today",
+ font: "inter",
+ weight: "medium",
+ size: "sm",
+ color: "primary",
+ },
+ avatars: [
+ { src: "/avatars/male-1.jpg", alt: "Male 1" },
+ { src: "/avatars/male-2.jpg", alt: "Male 2" },
+ { src: "/avatars/male-3.jpg", alt: "Male 3" },
+ { src: "", fallbackText: "900+" },
+ ],
+ },
+ textList: {
+ items: [
+ {
+ text: "Всего 2 минуты — и Портрет откроет того, кто связан с тобой судьбой.",
+ },
+ { text: "Поразительная точность 99%." },
+ { text: "Тебя ждёт неожиданное открытие." },
+ { text: "Осталось лишь осмелиться взглянуть." },
+ ],
+ },
navigation: buildDefaultNavigation(),
} as BuilderScreen;
}
diff --git a/src/lib/admin/builder/state/defaults/trialPayment.ts b/src/lib/admin/builder/state/defaults/trialPayment.ts
new file mode 100644
index 0000000..3231c0d
--- /dev/null
+++ b/src/lib/admin/builder/state/defaults/trialPayment.ts
@@ -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" },
+ ],
+ },
+ },
+ };
+}
+
+
diff --git a/src/lib/admin/builder/state/utils.ts b/src/lib/admin/builder/state/utils.ts
index 0312c28..96ce21f 100644
--- a/src/lib/admin/builder/state/utils.ts
+++ b/src/lib/admin/builder/state/utils.ts
@@ -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}`);
}
diff --git a/src/lib/funnel/mappers.tsx b/src/lib/funnel/mappers.tsx
index fe52558..806ad26 100644
--- a/src/lib/funnel/mappers.tsx
+++ b/src/lib/funnel/mappers.tsx
@@ -1,6 +1,7 @@
import type { TypographyProps } from "@/components/ui/Typography/Typography";
import type { MainButtonProps } from "@/components/ui/MainButton/MainButton";
import { hasTextMarkup } from "@/lib/text-markup";
+import { cn } from "@/lib/utils";
import type {
HeaderDefinition,
@@ -24,7 +25,9 @@ type TypographyAs =
| "h4"
| "h5"
| "h6"
- | "div";
+ | "div"
+ | "li"
+ | "address";
interface TypographyDefaults {
font?: TypographyVariant["font"];
@@ -32,6 +35,7 @@ interface TypographyDefaults {
size?: TypographyVariant["size"];
align?: TypographyVariant["align"];
color?: TypographyVariant["color"];
+ className?: string;
}
interface BuildTypographyOptions {
@@ -67,7 +71,7 @@ export function buildTypographyProps(
size: variant.size ?? defaults?.size,
align: variant.align ?? defaults?.align,
color: variant.color ?? defaults?.color,
- className: variant.className,
+ className: cn(defaults?.className, variant.className),
enableMarkup: hasTextMarkup(variant.text || ""), // 🎨 АВТОМАТИЧЕСКИ включаем разметку если обнаружена
} as TypographyProps;
}
diff --git a/src/lib/funnel/screenRenderer.tsx b/src/lib/funnel/screenRenderer.tsx
index 5d9431f..f12ea81 100644
--- a/src/lib/funnel/screenRenderer.tsx
+++ b/src/lib/funnel/screenRenderer.tsx
@@ -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 (
+
+ );
+ },
};
export function renderScreen(props: ScreenRenderProps): JSX.Element {
diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts
index 7f00112..85dde32 100644
--- a/src/lib/funnel/types.ts
+++ b/src/lib/funnel/types.ts
@@ -326,13 +326,177 @@ export interface SoulmatePortraitScreenDefinition {
header?: HeaderDefinition;
title: TitleDefinition;
subtitle?: SubtitleDefinition;
- description?: TypographyVariant; // 🎯 Настраиваемый текст описания
+ description?: TypographyVariant; // Настраиваемый текст описания
+ soulmatePortraitsDelivered?: {
+ image?: string;
+ text?: TypographyVariant;
+ avatars?: Array<{
+ src: string;
+ alt?: string;
+ fallbackText?: string;
+ }>;
+ };
+ textList?: {
+ items: TypographyVariant[];
+ };
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;
variants?: ScreenVariantDefinition[];
}
-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[];
+ // Минимальные секции для первого шага миграции
+ 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;
diff --git a/src/lib/models/Funnel.ts b/src/lib/models/Funnel.ts
index 441ba16..0491972 100644
--- a/src/lib/models/Funnel.ts
+++ b/src/lib/models/Funnel.ts
@@ -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,290 @@ 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;
+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",
},
- message: 'Text field cannot be empty if provided'
- }
+ },
+ show: { type: Boolean, default: true }, // поддержка флага видимости
+ 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({
- // Основные данные воронки
- 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(
+ {
+ // Основные данные воронки
+ 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 +327,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 +345,50 @@ 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 = mongoose.models.Funnel || mongoose.model('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 =
+ mongoose.models.Funnel || mongoose.model("Funnel", FunnelSchema);
export default FunnelModel;
diff --git a/src/shared/api/httpClient.ts b/src/shared/api/httpClient.ts
index 46f4741..d0f3482 100644
--- a/src/shared/api/httpClient.ts
+++ b/src/shared/api/httpClient.ts
@@ -106,6 +106,7 @@ class HttpClient {
// ignore
}
}
+
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
headers.set("Content-Type", "application/json");
diff --git a/src/shared/auth/token.ts b/src/shared/auth/token.ts
index 10b28e4..cef0e20 100644
--- a/src/shared/auth/token.ts
+++ b/src/shared/auth/token.ts
@@ -9,7 +9,7 @@ export function getClientAccessToken(): string | undefined {
if (typeof window === "undefined") return undefined;
const cookies = document.cookie.split(";");
- const accessTokenCookie = cookies.find(cookie =>
+ const accessTokenCookie = cookies.find((cookie) =>
cookie.trim().startsWith("accessToken=")
);
diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts
index 2df4c7d..8feda64 100644
--- a/src/shared/constants/api-routes.ts
+++ b/src/shared/constants/api-routes.ts
@@ -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),
};
diff --git a/src/shared/constants/client-routes.ts b/src/shared/constants/client-routes.ts
new file mode 100644
index 0000000..50cce37
--- /dev/null
+++ b/src/shared/constants/client-routes.ts
@@ -0,0 +1,22 @@
+const ROOT_ROUTE = "/";
+
+const createRoute = (
+ segments: Array,
+ queryParams?: Record
+): 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) =>
+ createRoute(["payment"], queryParams),
+ paymentSuccess: () => createRoute(["payment", "success"]),
+ paymentFailed: () => createRoute(["payment", "failed"]),
+};
diff --git a/src/shared/constants/currency.ts b/src/shared/constants/currency.ts
new file mode 100644
index 0000000..5f7bcd9
--- /dev/null
+++ b/src/shared/constants/currency.ts
@@ -0,0 +1,8 @@
+import { Currency } from "@/shared/types";
+
+export const symbolByCurrency: Record = {
+ [Currency.USD]: "$",
+ [Currency.EUR]: "€",
+ [Currency.USD.toLowerCase()]: "$",
+ [Currency.EUR.toLowerCase()]: "€",
+};
diff --git a/src/shared/constants/lottie.ts b/src/shared/constants/lottie.ts
new file mode 100644
index 0000000..0ba5723
--- /dev/null
+++ b/src/shared/constants/lottie.ts
@@ -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",
+};
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts
new file mode 100644
index 0000000..db54500
--- /dev/null
+++ b/src/shared/types/index.ts
@@ -0,0 +1,11 @@
+export type ActionResponse = {
+ data: T | null;
+ error: string | null;
+};
+
+export enum Currency {
+ USD = "USD",
+ EUR = "EUR",
+ usd = "usd",
+ eur = "eur",
+}
diff --git a/src/shared/utils/indexedDB/index.ts b/src/shared/utils/indexedDB/index.ts
new file mode 100644
index 0000000..86c10e5
--- /dev/null
+++ b/src/shared/utils/indexedDB/index.ts
@@ -0,0 +1,52 @@
+"use client";
+
+import { IDBPDatabase, openDB } from "idb";
+
+export enum EObjectStores {
+ Lottie = "lottie",
+}
+
+const objectStores: EObjectStores[] = [EObjectStores.Lottie];
+
+let dbPromise: Promise | 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(
+ store: EObjectStores,
+ key: string
+): Promise {
+ return (await getDB()).get(store, key);
+}
+async function set(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;
diff --git a/src/shared/utils/price.ts b/src/shared/utils/price.ts
new file mode 100644
index 0000000..fc5f5c7
--- /dev/null
+++ b/src/shared/utils/price.ts
@@ -0,0 +1,18 @@
+import { Currency } from "@/shared/types";
+import { symbolByCurrency } from "../constants/currency";
+
+const addCurrency = (price: number | string, currency: Currency) => {
+ const symbol = symbolByCurrency[currency];
+ if ([Currency.EUR].includes(currency)) {
+ return `${price} ${symbol}`;
+ }
+ return `${symbol}${price}`;
+};
+
+export const getFormattedPrice = (
+ price: number,
+ currency: Currency,
+ precision = 2
+) => {
+ return addCurrency((price / 100).toFixed(precision), currency);
+};