diff --git a/messages/en.json b/messages/en.json
index 8abf933..e1ce809 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -215,5 +215,75 @@
},
"ActionFieldsForm": {
"required_field": "This field is required"
+ },
+ "AdditionalPurchases": {
+ "caution": {
+ "title": "Caution!",
+ "description": "To prevent double charges please don`t close the page and don`t go back."
+ },
+ "add-consultant": {
+ "title": "More for you",
+ "exclusive_offer": "Exclusive offer recommended for you to achieve your goals faster",
+ "your_unique_consultation": "Your unique individual consultation",
+ "30-minute": "30-minute private consultation with an expert",
+ "unlock_profound": "Unlock profound insights into your personality, relationships, career trajectory, and life's pivotal moments through astrology, empowering you to make informed decisions and achieve greater fulfillment.",
+ "one_time_price_offer": "One time price offer: ",
+ "choose_from": "Choose from 80+ experts astrologers.",
+ "original_price": "Original price: {oldPrice} ",
+ "save": "Economisez {discount}%",
+ "get_my_consultation": "Get my consultation",
+ "skip_this_offer": "Skip this offer",
+ "payment_error": "Something went wrong. Please try again later."
+ },
+ "add-guides": {
+ "title": "Choose your sign-up offer 🔥",
+ "subtitle": "Available only now",
+ "description": "*You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.",
+ "button": "Get my copy",
+ "payment_error": "Something went wrong. Please try again later.",
+ "select_product_error": "Please select a product",
+ "skip_offer": "Skip offer",
+
+ "products": {
+ "main_ultra_pack": {
+ "title": "ULTRA PACK",
+ "discount": "{discount}% OFF",
+ "subtitle": "(3 in 1 + 2 secret bonus reading)",
+ "price": " ( regular price )",
+ "emoji": "star_struck.webp"
+ },
+ "main_numerology_analysis": {
+ "title": "NUMEROLOGY ANALYSIS",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "input_numbers.webp"
+ },
+ "main_tarot_reading": {
+ "title": "TAROT READING",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "sunset.webp"
+ },
+ "main_palmistry_guide": {
+ "title": "PALMISTRY GUIDE",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "rised_hand.webp"
+ },
+ "main_money_reading": {
+ "title": "MONEY READING",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "money.png"
+ },
+ "main_skip_offer": {
+ "title": "SKIP OFFER",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "subtitle": "You are missing out on both readings",
+ "emoji": "rised_hand.webp"
+ }
+ }
+ }
}
}
diff --git a/public/check-mark-1.svg b/public/check-mark-1.svg
new file mode 100644
index 0000000..94e3f61
--- /dev/null
+++ b/public/check-mark-1.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/public/emoji/input_numbers.webp b/public/emoji/input_numbers.webp
new file mode 100644
index 0000000..e340b5b
Binary files /dev/null and b/public/emoji/input_numbers.webp differ
diff --git a/public/emoji/money.png b/public/emoji/money.png
new file mode 100644
index 0000000..b3750f0
Binary files /dev/null and b/public/emoji/money.png differ
diff --git a/public/emoji/rised_hand.webp b/public/emoji/rised_hand.webp
new file mode 100644
index 0000000..c4412bd
Binary files /dev/null and b/public/emoji/rised_hand.webp differ
diff --git a/public/emoji/smiling-face-with-hearts.webp b/public/emoji/smiling-face-with-hearts.webp
new file mode 100644
index 0000000..49cbd68
Binary files /dev/null and b/public/emoji/smiling-face-with-hearts.webp differ
diff --git a/public/emoji/star_struck.webp b/public/emoji/star_struck.webp
new file mode 100644
index 0000000..a7246cd
Binary files /dev/null and b/public/emoji/star_struck.webp differ
diff --git a/public/emoji/sunset.webp b/public/emoji/sunset.webp
new file mode 100644
index 0000000..3add8d8
Binary files /dev/null and b/public/emoji/sunset.webp differ
diff --git a/public/paywall__astrologers-image.png b/public/paywall__astrologers-image.png
new file mode 100644
index 0000000..921ccde
Binary files /dev/null and b/public/paywall__astrologers-image.png differ
diff --git a/public/paywall__spiritist-spiritualist.png b/public/paywall__spiritist-spiritualist.png
new file mode 100644
index 0000000..32004d0
Binary files /dev/null and b/public/paywall__spiritist-spiritualist.png differ
diff --git a/src/app/[locale]/(additional-purchases)/add-consultant/loading.module.scss b/src/app/[locale]/(additional-purchases)/add-consultant/loading.module.scss
new file mode 100644
index 0000000..9a4e274
--- /dev/null
+++ b/src/app/[locale]/(additional-purchases)/add-consultant/loading.module.scss
@@ -0,0 +1,6 @@
+.loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100dvh;
+}
diff --git a/src/app/[locale]/(additional-purchases)/add-consultant/loading.tsx b/src/app/[locale]/(additional-purchases)/add-consultant/loading.tsx
new file mode 100644
index 0000000..0cd6c20
--- /dev/null
+++ b/src/app/[locale]/(additional-purchases)/add-consultant/loading.tsx
@@ -0,0 +1,11 @@
+import { Spinner } from "@/components/ui";
+
+import styles from "./loading.module.scss";
+
+export default function AddConsultantLoading() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/[locale]/(additional-purchases)/add-consultant/page.module.scss b/src/app/[locale]/(additional-purchases)/add-consultant/page.module.scss
new file mode 100644
index 0000000..aaa1b2d
--- /dev/null
+++ b/src/app/[locale]/(additional-purchases)/add-consultant/page.module.scss
@@ -0,0 +1,18 @@
+.title.title {
+ color: #086de5;
+ margin-top: 24px;
+ margin-bottom: 6px;
+ line-height: 150%;
+}
+
+.exclusiveOffer.exclusiveOffer {
+ width: 100%;
+ background-color: #e7f5fd;
+ padding: 8px 16px;
+ border-radius: 8px;
+ line-height: 125%;
+}
+
+.consultationTable.consultationTable {
+ margin-top: 16px;
+}
diff --git a/src/app/[locale]/(additional-purchases)/add-consultant/page.tsx b/src/app/[locale]/(additional-purchases)/add-consultant/page.tsx
new file mode 100644
index 0000000..91dadbc
--- /dev/null
+++ b/src/app/[locale]/(additional-purchases)/add-consultant/page.tsx
@@ -0,0 +1,53 @@
+import { Suspense } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ AddConsultantButton,
+ Caution,
+ ConsultationTable,
+ ConsultationTableSkeleton,
+} from "@/components/domains/additional-purchases";
+import { Card, Typography } from "@/components/ui";
+import {
+ loadFunnelProducts,
+ loadFunnelProperties,
+} from "@/entities/session/funnel/loaders";
+import { ELocalesPlacement } from "@/types";
+
+import styles from "./page.module.scss";
+
+const payload = {
+ funnel: ELocalesPlacement.CompatibilityV2,
+};
+
+export default function AddConsultant() {
+ const t = useTranslations("AdditionalPurchases.add-consultant");
+
+ return (
+ <>
+
+
+ {t("title")}
+
+
+ {t("exclusive_offer")}
+
+ }>
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/[locale]/(additional-purchases)/add-guides/page.module.scss b/src/app/[locale]/(additional-purchases)/add-guides/page.module.scss
new file mode 100644
index 0000000..c6db0c8
--- /dev/null
+++ b/src/app/[locale]/(additional-purchases)/add-guides/page.module.scss
@@ -0,0 +1,18 @@
+.title {
+ font-size: 18px;
+ line-height: 135%;
+ margin-top: 16px;
+}
+
+.subtitle {
+ color: #066fdf;
+ margin-block: 8px;
+ line-height: 135%;
+}
+
+.description {
+ display: block;
+ margin: 20px auto;
+ font-size: 10px;
+ line-height: 125%;
+}
diff --git a/src/app/[locale]/(additional-purchases)/add-guides/page.tsx b/src/app/[locale]/(additional-purchases)/add-guides/page.tsx
new file mode 100644
index 0000000..f4bc1fa
--- /dev/null
+++ b/src/app/[locale]/(additional-purchases)/add-guides/page.tsx
@@ -0,0 +1,42 @@
+import { Suspense } from "react";
+import { useTranslations } from "next-intl";
+
+import {
+ AddGuidesButton,
+ Caution,
+ Offers,
+ OffersSkeleton,
+ ProductSelectionProvider,
+} from "@/components/domains/additional-purchases";
+import { Typography } from "@/components/ui";
+import { loadFunnelProducts } from "@/entities/session/funnel/loaders";
+import { ELocalesPlacement } from "@/types";
+
+import styles from "./page.module.scss";
+
+const payload = {
+ funnel: ELocalesPlacement.CompatibilityV2,
+};
+
+export default function AddGuides() {
+ const t = useTranslations("AdditionalPurchases.add-guides");
+
+ return (
+
+
+
+ {t("title")}
+
+
+ {t("subtitle")}
+
+ }>
+
+
+
+ {t("description")}
+
+
+
+ );
+}
diff --git a/src/app/[locale]/(additional-purchases)/layout.module.scss b/src/app/[locale]/(additional-purchases)/layout.module.scss
new file mode 100644
index 0000000..9f967e9
--- /dev/null
+++ b/src/app/[locale]/(additional-purchases)/layout.module.scss
@@ -0,0 +1,7 @@
+.layout {
+ position: relative;
+ padding: 24px;
+ padding-bottom: 120px;
+ min-height: 100dvh;
+ height: fit-content;
+}
diff --git a/src/app/[locale]/(additional-purchases)/layout.tsx b/src/app/[locale]/(additional-purchases)/layout.tsx
new file mode 100644
index 0000000..d817bfd
--- /dev/null
+++ b/src/app/[locale]/(additional-purchases)/layout.tsx
@@ -0,0 +1,9 @@
+import styles from "./layout.module.scss";
+
+export default function AdditionalPurchasesLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return ;
+}
diff --git a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.module.scss b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.module.scss
new file mode 100644
index 0000000..cf8be48
--- /dev/null
+++ b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.module.scss
@@ -0,0 +1,26 @@
+.container.container {
+ display: flex;
+ flex-direction: column;
+ -webkit-box-align: center;
+ align-items: center;
+ width: 100%;
+ height: fit-content;
+ max-width: 560px;
+ position: fixed;
+ bottom: 0dvh;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-top: 0px;
+ padding-bottom: 20px;
+ padding-inline: 15px;
+ z-index: 5;
+}
+
+.button.button {
+ padding-block: 16px;
+}
+
+.skipButton.skipButton {
+ background-color: transparent;
+ text-decoration: underline;
+}
diff --git a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx
new file mode 100644
index 0000000..cfa4659
--- /dev/null
+++ b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { use } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { Button, Spinner, Typography } from "@/components/ui";
+import { BlurComponent } from "@/components/widgets";
+import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
+import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
+import { useToast } from "@/providers/toast-provider";
+import { ROUTES } from "@/shared/constants/client-routes";
+
+import styles from "./AddConsultantButton.module.scss";
+
+interface AddConsultantButtonProps {
+ products: Promise;
+}
+
+export default function AddConsultantButton({
+ products,
+}: AddConsultantButtonProps) {
+ const router = useRouter();
+ const t = useTranslations("AdditionalPurchases.add-consultant");
+ const { addToast } = useToast();
+
+ const product = use(products)?.[0];
+
+ const { handleSingleCheckout, isLoading } = useSingleCheckout({
+ onSuccess: () => {
+ router.push(ROUTES.addGuides());
+ },
+ onError: _error => {
+ addToast({
+ variant: "error",
+ message: t("payment_error"),
+ duration: 5000,
+ });
+ },
+ });
+
+ const handleGetConsultation = () => {
+ if (!product) {
+ addToast({
+ variant: "error",
+ message: t("payment_error"),
+ duration: 5000,
+ });
+ return;
+ }
+
+ handleSingleCheckout({
+ productId: product.id,
+ key: product.key,
+ });
+ };
+
+ const handleSkipOffer = () => {
+ router.push(ROUTES.addGuides());
+ };
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.module.scss b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.module.scss
new file mode 100644
index 0000000..28a4f02
--- /dev/null
+++ b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.module.scss
@@ -0,0 +1,14 @@
+.container {
+ position: fixed;
+ bottom: calc(0dvh + 16px);
+ left: 50%;
+ transform: translateX(-50%);
+ width: 100%;
+ padding-inline: 24px;
+ max-width: 560px;
+ height: fit-content;
+}
+
+.button {
+ padding-block: 16px;
+}
diff --git a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx
new file mode 100644
index 0000000..f6855c1
--- /dev/null
+++ b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { Button, Spinner, Typography } from "@/components/ui";
+import { BlurComponent } from "@/components/widgets";
+import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
+import { useToast } from "@/providers/toast-provider";
+import { ROUTES } from "@/shared/constants/client-routes";
+
+import { useProductSelection } from "../ProductSelectionContext";
+
+import styles from "./AddGuidesButton.module.scss";
+
+export default function AddGuidesButton() {
+ const t = useTranslations("AdditionalPurchases.add-guides");
+ const router = useRouter();
+ const { addToast } = useToast();
+ const { selectedProduct } = useProductSelection();
+
+ const { handleSingleCheckout, isLoading } = useSingleCheckout({
+ onSuccess: () => {
+ router.push(ROUTES.home());
+ },
+ onError: _error => {
+ addToast({
+ variant: "error",
+ message: t("payment_error"),
+ duration: 5000,
+ });
+ },
+ });
+
+ const handlePurchase = () => {
+ if (!selectedProduct) {
+ addToast({
+ variant: "error",
+ message: t("select_product_error"),
+ duration: 5000,
+ });
+ return;
+ }
+
+ handleSingleCheckout({
+ productId: selectedProduct.id,
+ key: selectedProduct.key,
+ });
+ };
+
+ const handleSkipOffer = () => {
+ router.push(ROUTES.home());
+ };
+
+ const isSkipOffer = selectedProduct?.id === "main_skip_offer";
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/domains/additional-purchases/Caution/Caution.module.scss b/src/components/domains/additional-purchases/Caution/Caution.module.scss
new file mode 100644
index 0000000..e60ff52
--- /dev/null
+++ b/src/components/domains/additional-purchases/Caution/Caution.module.scss
@@ -0,0 +1,20 @@
+.container.container {
+ width: 100%;
+ padding: 20px;
+ background-color: #aaddff;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 15px;
+ height: min-content;
+ margin-top: 12px;
+}
+
+.title.title {
+ line-height: 135%;
+}
+
+.text.text {
+ line-height: 125%;
+}
diff --git a/src/components/domains/additional-purchases/Caution/Caution.tsx b/src/components/domains/additional-purchases/Caution/Caution.tsx
new file mode 100644
index 0000000..cfb0f12
--- /dev/null
+++ b/src/components/domains/additional-purchases/Caution/Caution.tsx
@@ -0,0 +1,37 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { Typography } from "@/components/ui";
+
+import styles from "./Caution.module.scss";
+
+function Caution() {
+ const t = useTranslations("AdditionalPurchases.caution");
+
+ return (
+
+
+
+
+ {t("title")}
+
+
+ {t("description")}
+
+
+
+ );
+}
+
+export default Caution;
diff --git a/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.module.scss b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.module.scss
new file mode 100644
index 0000000..11dee98
--- /dev/null
+++ b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.module.scss
@@ -0,0 +1,82 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ -webkit-box-align: center;
+ align-items: center;
+ position: relative;
+ border-radius: 16px;
+ width: 100%;
+
+ & > .title {
+ font-size: 18px;
+ line-height: 135%;
+ margin-bottom: 16px;
+ margin-top: 0;
+ }
+}
+
+.header {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+
+ & > .textContainer {
+ & > .title {
+ color: #1b6acb;
+ line-height: 125%;
+ margin-bottom: 8px;
+ }
+
+ & > .text {
+ font-size: 13px;
+ line-height: 125%;
+ }
+ }
+
+ & > img {
+ height: 100px;
+ }
+}
+
+.line {
+ width: 100%;
+ height: 1px;
+ background: rgb(203, 203, 203);
+}
+
+.footer {
+ margin-top: 16px;
+ width: 100%;
+
+ & > .oneTimePrice {
+ line-height: 24px;
+ }
+
+ & > .oldPrice {
+ line-height: 24px;
+ margin-bottom: 16px;
+
+ & > .save {
+ color: #1b6acb;
+ }
+ }
+}
+
+.chooseContainer {
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ margin-top: 16px;
+
+ & > .chooseText {
+ color: #066fdf;
+ line-height: 125%;
+ }
+}
diff --git a/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx
new file mode 100644
index 0000000..f1883a3
--- /dev/null
+++ b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx
@@ -0,0 +1,115 @@
+import Image from "next/image";
+import { getTranslations } from "next-intl/server";
+
+import { Skeleton, Typography } from "@/components/ui";
+import {
+ IFunnelPaymentProperty,
+ IFunnelPaymentVariant,
+} from "@/entities/session/funnel/types";
+import { getFormattedPrice } from "@/shared/utils/price";
+import { Currency } from "@/types";
+
+import styles from "./ConsultationTable.module.scss";
+
+interface ConsultationTableProps {
+ products: Promise;
+ properties: Promise;
+}
+
+export default async function ConsultationTable({
+ products,
+ properties,
+}: ConsultationTableProps) {
+ const t = await getTranslations("AdditionalPurchases.add-consultant");
+ const currency = Currency.USD;
+
+ const product = (await products)?.[0];
+ const discount =
+ (await properties)?.find(p => p.key === "discount")?.value ?? 0;
+
+ const price = getFormattedPrice(product?.price ?? 0, currency);
+ const oldPrice = getFormattedPrice(
+ (Number(product?.price) / (Number(discount) || 100)) * 100,
+ currency
+ );
+
+ return (
+
+
+ {t("your_unique_consultation")}
+
+
+
+
+ {t("30-minute")}
+
+
+ {t("unlock_profound")}
+
+
+
+
+
+
+
+ {t.rich("one_time_price_offer", {
+ price: () => (
+
+ {price}
+
+ ),
+ })}
+
+
+ {t("original_price", {
+ oldPrice: oldPrice,
+ })}
+
+ {t("save", {
+ discount: discount,
+ })}
+
+
+
+
+
+ {t("choose_from")}
+
+
+
+
+
+
+ );
+}
+
+export function ConsultationTableSkeleton() {
+ return ;
+}
diff --git a/src/components/domains/additional-purchases/Offer/Offer.module.scss b/src/components/domains/additional-purchases/Offer/Offer.module.scss
new file mode 100644
index 0000000..bf3c98e
--- /dev/null
+++ b/src/components/domains/additional-purchases/Offer/Offer.module.scss
@@ -0,0 +1,92 @@
+.container.container {
+ display: grid;
+ grid-template-columns: 24px 1fr auto;
+ -webkit-box-align: center;
+ align-items: center;
+ -webkit-box-pack: justify;
+ justify-content: space-between;
+ padding: 12px 16px;
+ margin-inline: auto;
+ max-width: 450px;
+ width: 100%;
+}
+
+.container.active {
+ background: #066fdf;
+ border: 2px solid #066fdf;
+}
+
+.mark {
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ -webkit-box-align: center;
+ align-items: center;
+ -webkit-box-pack: center;
+ justify-content: center;
+ border: 1px solid #aaddff;
+ background: #fff;
+}
+
+.textContainer {
+ display: flex;
+ flex-direction: column;
+ -webkit-box-flex: 1;
+ flex-grow: 1;
+ margin-left: 16px;
+
+ & > * {
+ word-break: break-all;
+ }
+
+ & > .title {
+ text-transform: uppercase;
+ line-height: 135%;
+ }
+
+ & > .subtitle {
+ line-height: 135%;
+ margin-bottom: 5px;
+ }
+}
+
+.priceContainer {
+ font-size: 12px;
+ font-weight: 600;
+ color: rgb(79, 79, 79);
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.description {
+ max-width: 140px;
+}
+
+.oldPrice {
+ text-decoration: line-through;
+}
+
+.discountContainer {
+ background: #a7ddff;
+ border-radius: 4px;
+ display: inline-block;
+ margin-left: 4px;
+ padding-inline: 4px;
+
+ & > .discount {
+ color: #066fdf;
+ line-height: 170%;
+ }
+}
+
+.emoji {
+ display: inline-block;
+ background-size: contain;
+ background-position: center center;
+ background-repeat: no-repeat;
+ width: 24px;
+ height: 24px;
+ margin-left: 3px;
+}
diff --git a/src/components/domains/additional-purchases/Offer/Offer.tsx b/src/components/domains/additional-purchases/Offer/Offer.tsx
new file mode 100644
index 0000000..3d4fd99
--- /dev/null
+++ b/src/components/domains/additional-purchases/Offer/Offer.tsx
@@ -0,0 +1,143 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { Card, Typography } from "@/components/ui";
+import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
+import { getFormattedPrice } from "@/shared/utils/price";
+import { Currency } from "@/types";
+
+import styles from "./Offer.module.scss";
+
+interface OfferProps {
+ offer: IFunnelPaymentVariant;
+ isActive: boolean;
+ className?: string;
+ onClick: () => void;
+}
+
+export default function Offer(props: OfferProps) {
+ const { offer, isActive, className, onClick } = props;
+
+ const { key, price, oldPrice } = offer;
+
+ const productKey = key.replaceAll(".", "_");
+
+ const t = useTranslations(
+ `AdditionalPurchases.add-guides.products.${productKey}`
+ );
+
+ const currency = Currency.USD;
+
+ const subtitle = t.has("subtitle") ? t("subtitle") : undefined;
+
+ const discount = (((oldPrice || 0) - price) / (oldPrice || 0)) * 100;
+
+ const emoji = t.has("emoji") ? t("emoji") : undefined;
+
+ const typographyColor = isActive ? "white" : "default";
+
+ return (
+
+
+ {/* TODO: add icon after merge with chat */}
+ {isActive && (
+
+ )}
+
+
+
+ {t("title")}
+
+ {!!subtitle?.length && productKey !== "main_skip_offer" && (
+
+ {subtitle}
+
+ )}
+
+ {productKey !== "main_skip_offer" && (
+
+ {t.rich("price", {
+ price: () => (
+
+ {getFormattedPrice(price, currency)}
+
+ ),
+ oldPrice: () => (
+
+ {getFormattedPrice(oldPrice || 0, currency)}
+
+ ),
+ })}
+
+ )}
+
+ {productKey === "main_skip_offer" && (
+
+ {subtitle}
+
+ )}
+
+ {productKey !== "ultra_pack" && (
+
+
+ {t("discount", {
+ discount: discount || 0,
+ })}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/domains/additional-purchases/Offers/Offers.module.scss b/src/components/domains/additional-purchases/Offers/Offers.module.scss
new file mode 100644
index 0000000..41e3f46
--- /dev/null
+++ b/src/components/domains/additional-purchases/Offers/Offers.module.scss
@@ -0,0 +1,6 @@
+.container {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
diff --git a/src/components/domains/additional-purchases/Offers/Offers.tsx b/src/components/domains/additional-purchases/Offers/Offers.tsx
new file mode 100644
index 0000000..754e754
--- /dev/null
+++ b/src/components/domains/additional-purchases/Offers/Offers.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { use, useEffect, useState } from "react";
+
+import { Skeleton } from "@/components/ui";
+import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
+
+import { useProductSelection } from "../ProductSelectionContext";
+
+import styles from "./Offers.module.scss";
+
+import { Offer } from "..";
+
+interface OffersProps {
+ products: Promise;
+}
+
+export default function Offers({ products }: OffersProps) {
+ const offers = use(products);
+ const [allOffers, setAllOffers] = useState([]);
+ const [activeOffer, setActiveOffer] = useState("");
+ const { setSelectedProduct } = useProductSelection();
+
+ useEffect(() => {
+ const skipOffer: IFunnelPaymentVariant = {
+ id: "main_skip_offer",
+ key: "main.skip.offer",
+ type: "one_time",
+ price: 0,
+ oldPrice: 0,
+ };
+
+ const offersWithSkip = [...offers, skipOffer];
+ setAllOffers(offersWithSkip);
+ setActiveOffer(offers[0]?.id || skipOffer.id);
+
+ // Устанавливаем первый продукт как выбранный по умолчанию
+ if (offers[0]) {
+ setSelectedProduct(offers[0]);
+ }
+ }, [offers, setSelectedProduct]);
+
+ const handleOfferClick = (offer: IFunnelPaymentVariant) => {
+ setActiveOffer(offer.id);
+ setSelectedProduct(offer);
+ };
+
+ return (
+
+ {allOffers.map(offer => (
+ handleOfferClick(offer)}
+ />
+ ))}
+
+ );
+}
+
+export function OffersSkeleton() {
+ return ;
+}
diff --git a/src/components/domains/additional-purchases/ProductSelectionContext.tsx b/src/components/domains/additional-purchases/ProductSelectionContext.tsx
new file mode 100644
index 0000000..1840194
--- /dev/null
+++ b/src/components/domains/additional-purchases/ProductSelectionContext.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { createContext, ReactNode, useContext, useState } from "react";
+
+import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
+
+interface ProductSelectionContextType {
+ selectedProduct: IFunnelPaymentVariant | null;
+ setSelectedProduct: (product: IFunnelPaymentVariant | null) => void;
+}
+
+const ProductSelectionContext = createContext<
+ ProductSelectionContextType | undefined
+>(undefined);
+
+interface ProductSelectionProviderProps {
+ children: ReactNode;
+}
+
+export function ProductSelectionProvider({
+ children,
+}: ProductSelectionProviderProps) {
+ const [selectedProduct, setSelectedProduct] =
+ useState(null);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useProductSelection() {
+ const context = useContext(ProductSelectionContext);
+ if (!context) {
+ throw new Error(
+ "useProductSelection must be used within ProductSelectionProvider"
+ );
+ }
+ return context;
+}
diff --git a/src/components/domains/additional-purchases/index.ts b/src/components/domains/additional-purchases/index.ts
new file mode 100644
index 0000000..b0f90ff
--- /dev/null
+++ b/src/components/domains/additional-purchases/index.ts
@@ -0,0 +1,13 @@
+export { default as AddConsultantButton } from "./AddConsultantButton/AddConsultantButton";
+export { default as AddGuidesButton } from "./AddGuidesButton/AddGuidesButton";
+export { default as Caution } from "./Caution/Caution";
+export {
+ default as ConsultationTable,
+ ConsultationTableSkeleton,
+} from "./ConsultationTable/ConsultationTable";
+export { default as Offer } from "./Offer/Offer";
+export { default as Offers, OffersSkeleton } from "./Offers/Offers";
+export {
+ ProductSelectionProvider,
+ useProductSelection,
+} from "./ProductSelectionContext";
diff --git a/src/components/ui/Card/Card.tsx b/src/components/ui/Card/Card.tsx
index bf16fa5..f8a4fd1 100644
--- a/src/components/ui/Card/Card.tsx
+++ b/src/components/ui/Card/Card.tsx
@@ -1,17 +1,12 @@
-import { ReactNode } from "react";
import clsx from "clsx";
import styles from "./Card.module.scss";
-type CardProps = {
- children: ReactNode;
- className?: string;
- style?: React.CSSProperties;
-};
+type CardProps = React.HTMLAttributes;
-export default function Card({ children, className, style }: CardProps) {
+export default function Card({ children, className, ...props }: CardProps) {
return (
-
+
{children}
);
diff --git a/src/components/widgets/BlurComponent/BlurComponent.module.scss b/src/components/widgets/BlurComponent/BlurComponent.module.scss
index 0b5e7fd..ee40430 100644
--- a/src/components/widgets/BlurComponent/BlurComponent.module.scss
+++ b/src/components/widgets/BlurComponent/BlurComponent.module.scss
@@ -5,6 +5,11 @@
text-align: center;
text-align: -webkit-center;
+ & > * {
+ position: relative;
+ z-index: 10;
+ }
+
.gradientBlur {
position: absolute;
z-index: 5;
diff --git a/src/entities/payment/actions.ts b/src/entities/payment/actions.ts
new file mode 100644
index 0000000..5ebc11a
--- /dev/null
+++ b/src/entities/payment/actions.ts
@@ -0,0 +1,34 @@
+"use server";
+
+import { http } from "@/shared/api/httpClient";
+import { API_ROUTES } from "@/shared/constants/api-routes";
+import { ActionResponse } from "@/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) {
+ // eslint-disable-next-line no-console
+ 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
index 222880f..d1635f6 100644
--- a/src/entities/payment/api.ts
+++ b/src/entities/payment/api.ts
@@ -5,6 +5,9 @@ import {
CheckoutRequest,
CheckoutResponse,
CheckoutResponseSchema,
+ SingleCheckoutRequest,
+ SingleCheckoutResponse,
+ SingleCheckoutResponseSchema,
} from "./types";
export async function createPaymentCheckout(payload: CheckoutRequest) {
@@ -13,3 +16,16 @@ export async function createPaymentCheckout(payload: CheckoutRequest) {
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
index 81a0a49..9c2147d 100644
--- a/src/entities/payment/types.ts
+++ b/src/entities/payment/types.ts
@@ -13,3 +13,37 @@ export const CheckoutResponseSchema = z.object({
paymentUrl: z.string().url(),
});
export type CheckoutResponse = z.infer;
+
+export const PaymentInfoSchema = z.object({
+ productId: z.string(),
+ key: z.string(),
+});
+export type PaymentInfo = z.infer;
+
+export const SingleCheckoutRequestSchema = z.object({
+ paymentInfo: PaymentInfoSchema,
+ return_url: z.string().optional(),
+});
+export type SingleCheckoutRequest = z.infer;
+
+export const SingleCheckoutSuccessSchema = z.object({
+ payment: z.object({
+ status: z.string(),
+ invoiceId: z.string(),
+ }),
+});
+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..1f75d41
--- /dev/null
+++ b/src/entities/session/funnel/api.ts
@@ -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(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..a803266
--- /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/types.ts b/src/entities/session/funnel/types.ts
new file mode 100644
index 0000000..f6916a3
--- /dev/null
+++ b/src/entities/session/funnel/types.ts
@@ -0,0 +1,61 @@
+import { z } from "zod";
+
+import { Currency, ELocalesPlacement } from "../../../types";
+
+// Request schemas
+export const FunnelRequestSchema = z.object({
+ funnel: z.nativeEnum(ELocalesPlacement),
+});
+
+// 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.nativeEnum(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(),
+});
+
+export const FunnelSchema = z.object({
+ currency: z.nativeEnum(Currency),
+ funnel: z.nativeEnum(ELocalesPlacement),
+ locale: z.string(),
+ payment: z.record(z.string(), FunnelPaymentPlacementSchema.nullable()),
+});
+
+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/hooks/payment/useSingleCheckout.ts b/src/hooks/payment/useSingleCheckout.ts
new file mode 100644
index 0000000..72295f7
--- /dev/null
+++ b/src/hooks/payment/useSingleCheckout.ts
@@ -0,0 +1,71 @@
+"use client";
+
+import { useCallback, useMemo, useState } from "react";
+
+import { performSingleCheckout } from "@/entities/payment/actions";
+import { PaymentInfo, SingleCheckoutRequest } from "@/entities/payment/types";
+
+interface UseSingleCheckoutOptions {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+}
+
+export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { onSuccess, onError } = options;
+
+ const handleSingleCheckout = useCallback(
+ async (paymentInfo: PaymentInfo) => {
+ if (isLoading) return;
+
+ setIsLoading(true);
+
+ try {
+ const payload: SingleCheckoutRequest = {
+ paymentInfo,
+ };
+
+ const response = await performSingleCheckout(payload);
+
+ if (response.error) {
+ onError?.(response.error);
+ return;
+ }
+
+ if (!response.data) {
+ onError?.("Payment failed");
+ return;
+ }
+
+ if ("payment" in response.data) {
+ const { status } = response.data.payment;
+
+ if (status === "paid") {
+ onSuccess?.();
+ } else {
+ onError?.("Payment status is not paid");
+ }
+ } else {
+ const errorMessage = response.data.message || "Payment failed";
+ onError?.(errorMessage);
+ }
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Payment failed";
+ onError?.(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [onSuccess, onError, isLoading]
+ );
+
+ return useMemo(
+ () => ({
+ handleSingleCheckout,
+ isLoading,
+ }),
+ [handleSingleCheckout, isLoading]
+ );
+}
diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts
index 4dd374e..327fd9d 100644
--- a/src/shared/constants/api-routes.ts
+++ b/src/shared/constants/api-routes.ts
@@ -15,6 +15,7 @@ export const API_ROUTES = {
dashboard: () => createRoute(["dashboard"]),
subscriptions: () => createRoute(["payment", "subscriptions"], ROOT_ROUTE_V3),
paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2),
+ paymentSingleCheckout: () => createRoute(["payment", "checkout"]),
usersMe: () => createRoute(["users", "me"], ROOT_ROUTE),
compatibilityActionFields: (id: string) =>
createRoute(["dashboard", "compatibility-actions", id, "fields"]),
@@ -28,4 +29,7 @@ export const API_ROUTES = {
["payment", "subscriptions", subscriptionId, action],
ROOT_ROUTE_V3
),
+
+ // session
+ funnel: () => createRoute(["session", "funnel"], ROOT_ROUTE_V2),
};
diff --git a/src/shared/constants/client-routes.ts b/src/shared/constants/client-routes.ts
index e1e5de2..9b78da3 100644
--- a/src/shared/constants/client-routes.ts
+++ b/src/shared/constants/client-routes.ts
@@ -63,6 +63,10 @@ export const ROUTES = {
// Chat
chat: () => createRoute(["chat"]),
+ // Additional Purchases
+ addConsultant: () => createRoute(["add-consultant"]),
+ addGuides: () => createRoute(["add-guides"]),
+
// // Compatibility
// compatibilities: () => createRoute(["compatibilities"]),
diff --git a/src/types/index.ts b/src/types/index.ts
index ec343fa..cf0cbe3 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -60,3 +60,26 @@ export type ActionResponse = {
data: T | null;
error: string | null;
};
+
+export enum ELocalesPlacement {
+ V0 = "v0", // Main site version
+ V1 = "v1",
+ PalmistryV0 = "palmistry-v0",
+ PalmistryV01 = "palmistry-v0_1",
+ PalmistryV1 = "palmistry-v1",
+ PalmistryV11 = "palmistry-v1_1",
+ Chats = "chats",
+ EmailMarketingCompatibilityV1 = "email-marketing-comp-v1",
+ EmailMarketingPalmistryV2 = "email-marketing-palmistry-v2",
+ EmailMarketingCompatibilityV2 = "email-marketing-comp-v2",
+ EmailMarketingCompatibilityV3 = "email-marketing-comp-v3",
+ EmailMarketingCompatibilityV4 = "email-marketing-comp-v4",
+ CompatibilityV2 = "compatibility-v2",
+ CompatibilityV3 = "compatibility-v3",
+ CompatibilityV4 = "compatibility-v4",
+ EmailGenerator = "email-generator",
+ Profile = "profile",
+ RetainingFunnel = "retaining-funnel",
+}
+
+export type PeriodType = "DAY" | "WEEK" | "MONTH" | "YEAR";