From f75185656f40c4806b039eb3f833f70cd0519d5e Mon Sep 17 00:00:00 2001 From: gofnnp Date: Tue, 8 Jul 2025 19:35:41 +0400 Subject: [PATCH] AW-493-additional-purchases add api using --- messages/en.json | 8 ++- .../add-consultant/loading.module.scss | 6 ++ .../add-consultant/loading.tsx | 11 +++ .../add-consultant/page.module.scss | 4 ++ .../add-consultant/page.tsx | 26 ++++++- .../add-guides/page.tsx | 54 ++++---------- .../AddConsultantButton.tsx | 68 ++++++++++++++++-- .../AddGuidesButton/AddGuidesButton.tsx | 64 +++++++++++++++-- .../ConsultationTable.module.scss | 1 - .../ConsultationTable/ConsultationTable.tsx | 40 +++++++++-- .../additional-purchases/Offer/Offer.tsx | 29 ++++---- .../additional-purchases/Offers/Offers.tsx | 55 +++++++++++--- .../ProductSelectionContext.tsx | 43 +++++++++++ .../domains/additional-purchases/index.ts | 13 +++- src/entities/payment/actions.ts | 34 +++++++++ src/entities/payment/api.ts | 16 +++++ src/entities/payment/types.ts | 34 +++++++++ src/entities/session/funnel/api.ts | 12 ++++ src/entities/session/funnel/loaders.ts | 41 +++++++++++ src/entities/session/funnel/types.ts | 61 ++++++++++++++++ src/hooks/payment/useSingleCheckout.ts | 71 +++++++++++++++++++ src/shared/constants/api-routes.ts | 4 ++ src/types/index.ts | 23 ++++++ 23 files changed, 623 insertions(+), 95 deletions(-) create mode 100644 src/app/[locale]/(additional-purchases)/add-consultant/loading.module.scss create mode 100644 src/app/[locale]/(additional-purchases)/add-consultant/loading.tsx create mode 100644 src/components/domains/additional-purchases/ProductSelectionContext.tsx create mode 100644 src/entities/payment/actions.ts create mode 100644 src/entities/session/funnel/api.ts create mode 100644 src/entities/session/funnel/loaders.ts create mode 100644 src/entities/session/funnel/types.ts create mode 100644 src/hooks/payment/useSingleCheckout.ts diff --git a/messages/en.json b/messages/en.json index 576353c..e1ce809 100644 --- a/messages/en.json +++ b/messages/en.json @@ -230,15 +230,19 @@ "one_time_price_offer": "One time price offer: ", "choose_from": "Choose from 80+ experts astrologers.", "original_price": "Original price: {oldPrice} ", - "save": "Economisez 50", + "save": "Economisez {discount}%", "get_my_consultation": "Get my consultation", - "skip_this_offer": "Skip this offer" + "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": { 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 index b81f29c..aaa1b2d 100644 --- a/src/app/[locale]/(additional-purchases)/add-consultant/page.module.scss +++ b/src/app/[locale]/(additional-purchases)/add-consultant/page.module.scss @@ -12,3 +12,7 @@ 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 index 6299b34..91dadbc 100644 --- a/src/app/[locale]/(additional-purchases)/add-consultant/page.tsx +++ b/src/app/[locale]/(additional-purchases)/add-consultant/page.tsx @@ -1,14 +1,25 @@ +import { Suspense } from "react"; import { useTranslations } from "next-intl"; import { AddConsultantButton, Caution, ConsultationTable, + ConsultationTableSkeleton, } from "@/components/domains/additional-purchases"; -import { Typography } from "@/components/ui"; +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"); @@ -26,8 +37,17 @@ export default function AddConsultant() { > {t("exclusive_offer")} - - + }> + + + + + ); } diff --git a/src/app/[locale]/(additional-purchases)/add-guides/page.tsx b/src/app/[locale]/(additional-purchases)/add-guides/page.tsx index ddedf00..f4bc1fa 100644 --- a/src/app/[locale]/(additional-purchases)/add-guides/page.tsx +++ b/src/app/[locale]/(additional-purchases)/add-guides/page.tsx @@ -1,58 +1,28 @@ +import { Suspense } from "react"; import { useTranslations } from "next-intl"; import { AddGuidesButton, Caution, - IOffer, 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 PRODUCTS: (Omit & { key: string })[] = [ - { - id: "67ae7c05b29427c9ae695039", - key: "main.ultra.pack", - type: "one_time", - price: 4999, - oldPrice: 2499.5, - }, - { - id: "67ae7c05b29427c9ae69503c", - key: "main.numerology.analysis", - type: "one_time", - price: 1499, - oldPrice: 749.5, - }, - { - id: "67ae7c05b29427c9ae69503e", - key: "main.tarot.reading", - type: "one_time", - price: 1999, - oldPrice: 999.5, - }, - { - id: "6839ece6960824e7bba3e7bb", - key: "main.money.reading", - type: "one_time", - price: 1999, - oldPrice: 999.5, - }, - { - id: "main_skip_offer", - key: "main.skip.offer", - type: "one_time", - price: 0, - oldPrice: 0, - }, -]; +const payload = { + funnel: ELocalesPlacement.CompatibilityV2, +}; export default function AddGuides() { const t = useTranslations("AdditionalPurchases.add-guides"); return ( - <> + {t("title")} @@ -60,11 +30,13 @@ export default function AddGuides() { {t("subtitle")} - + }> + + {t("description")} - + ); } diff --git a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx index 3c9eadb..cfa4659 100644 --- a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx +++ b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx @@ -1,17 +1,59 @@ "use client"; +import { use } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Button, Typography } from "@/components/ui"; +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"; -export default function AddConsultantButton() { +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()); @@ -19,12 +61,24 @@ export default function AddConsultantButton() { return ( - - ); diff --git a/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.module.scss b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.module.scss index 922d63d..11dee98 100644 --- a/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.module.scss +++ b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.module.scss @@ -1,5 +1,4 @@ .container { - margin: 12px auto 0; display: flex; flex-direction: column; -webkit-box-align: center; diff --git a/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx index 4993763..f1883a3 100644 --- a/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx +++ b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx @@ -1,17 +1,37 @@ import Image from "next/image"; -import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; -import { Typography } from "@/components/ui"; +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"; -export default function ConsultationTable() { - const t = useTranslations("AdditionalPurchases.add-consultant"); +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 price = getFormattedPrice(4985, currency); + 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 (
@@ -64,10 +84,12 @@ export default function ConsultationTable() { className={styles.oldPrice} > {t("original_price", { - oldPrice: getFormattedPrice(9999, currency), + oldPrice: oldPrice, })} - {t("save")} + {t("save", { + discount: discount, + })}
@@ -87,3 +109,7 @@ export default function ConsultationTable() {
); } + +export function ConsultationTableSkeleton() { + return ; +} diff --git a/src/components/domains/additional-purchases/Offer/Offer.tsx b/src/components/domains/additional-purchases/Offer/Offer.tsx index 05f61bf..3d4fd99 100644 --- a/src/components/domains/additional-purchases/Offer/Offer.tsx +++ b/src/components/domains/additional-purchases/Offer/Offer.tsx @@ -2,31 +2,28 @@ 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"; -export interface IOffer { - id: string; - productKey: string; - type: "one_time" | string; - price: number; - oldPrice: number; -} - -interface OfferProps extends IOffer { +interface OfferProps { + offer: IFunnelPaymentVariant; isActive: boolean; className?: string; onClick: () => void; } export default function Offer(props: OfferProps) { - const { id, productKey, isActive, price, oldPrice, className, onClick } = - props; + const { offer, isActive, className, onClick } = props; + + const { key, price, oldPrice } = offer; + + const productKey = key.replaceAll(".", "_"); const t = useTranslations( - `AdditionalPurchases.add-guides.products.${productKey?.replaceAll(".", "_")}` + `AdditionalPurchases.add-guides.products.${productKey}` ); const currency = Currency.USD; @@ -67,7 +64,7 @@ export default function Offer(props: OfferProps) { > {t("title")} - {!!subtitle?.length && id !== "main_skip_offer" && ( + {!!subtitle?.length && productKey !== "main_skip_offer" && ( )}
- {id !== "main_skip_offer" && ( + {productKey !== "main_skip_offer" && ( )} - {id === "main_skip_offer" && ( + {productKey === "main_skip_offer" && ( )} - {id !== "ultra_pack" && ( + {productKey !== "ultra_pack" && (
& { key: string })[]; + products: Promise; } export default function Offers({ products }: OffersProps) { - const [activeOffer, setActiveOffer] = useState(products[0].id); + 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 (
- {products.map(product => ( + {allOffers.map(offer => ( setActiveOffer(product.id)} + offer={offer} + key={offer.id} + isActive={activeOffer === offer.id} + onClick={() => 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 index 4a8fba5..b0f90ff 100644 --- a/src/components/domains/additional-purchases/index.ts +++ b/src/components/domains/additional-purchases/index.ts @@ -1,6 +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 } from "./ConsultationTable/ConsultationTable"; -export { type IOffer, default as Offer } from "./Offer/Offer"; -export { default as Offers } from "./Offers/Offers"; +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/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/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";