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
{children}
; +} 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 ( +
+ Love +
+ + {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")} + +
+ spiritualist +
+
+
+ + {t.rich("one_time_price_offer", { + price: () => ( + + {price} + + ), + })} + + + {t("original_price", { + oldPrice: oldPrice, + })} + + {t("save", { + discount: discount, + })} + + +
+
+ + {t("choose_from")} + + + astrologers +
+
+
+ ); +} + +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 && ( + Checkmark + )} +
+
+ + {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";