add payment
This commit is contained in:
gofnnp 2025-10-06 21:33:18 +04:00
parent 19039ead3a
commit 21bedbcc53
13 changed files with 213 additions and 79 deletions

View File

@ -11,11 +11,13 @@ export async function GET(req: NextRequest) {
const productPrice = req.nextUrl.searchParams.get("price"); const productPrice = req.nextUrl.searchParams.get("price");
const currency = req.nextUrl.searchParams.get("currency"); const currency = req.nextUrl.searchParams.get("currency");
console.log("PRODUCT ID:", productId);
const data = await createPaymentCheckout({ const data = await createPaymentCheckout({
productId: productId || "", productId: productId || "",
placementId: placementId || "", placementId: placementId || "",
paywallId: paywallId || "", paywallId: paywallId || "",
}); });
console.log("DATA:", data);
let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin); let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin);
if (!redirectUrl) { if (!redirectUrl) {

View File

@ -31,7 +31,11 @@ function estimatePathLength(
const currentScreen = funnel.screens.find((s) => s.id === currentScreenId); const currentScreen = funnel.screens.find((s) => s.id === currentScreenId);
if (!currentScreen) break; if (!currentScreen) break;
const resolvedScreen = resolveScreenVariant(currentScreen, answers, funnel.screens); const resolvedScreen = resolveScreenVariant(
currentScreen,
answers,
funnel.screens
);
const nextScreenId = resolveNextScreenId( const nextScreenId = resolveNextScreenId(
resolvedScreen, resolvedScreen,
answers, answers,
@ -86,13 +90,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
createSession(); createSession();
}, [createSession]); }, [createSession]);
// useEffect(() => {
// // updateSession({
// // answers: answers,
// // });
// console.log("answers", answers);
// }, [answers]);
useEffect(() => { useEffect(() => {
registerScreen(currentScreen.id); registerScreen(currentScreen.id);
}, [currentScreen.id, registerScreen]); }, [currentScreen.id, registerScreen]);
@ -130,10 +127,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
}; };
const handleContinue = () => { const handleContinue = () => {
console.log({ if (answers[currentScreen.id] && currentScreen.template !== "email") {
[currentScreen.id]: answers[currentScreen.id],
});
if (answers[currentScreen.id]) {
updateSession({ updateSession({
answers: { answers: {
[currentScreen.id]: answers[currentScreen.id], [currentScreen.id]: answers[currentScreen.id],
@ -223,9 +217,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
// Auto-advance for single selection without action button // Auto-advance for single selection without action button
if (shouldAutoAdvance) { if (shouldAutoAdvance) {
console.log({
[currentScreen.id]: ids,
});
updateSession({ updateSession({
answers: { answers: {
[currentScreen.id]: ids, [currentScreen.id]: ids,

View File

@ -78,8 +78,10 @@ export function EmailTemplate({
} }
try { try {
await authorization(email); const token = await authorization(email);
onContinue(); if (token) {
onContinue();
}
} catch (err) { } catch (err) {
console.error("Authorization failed:", err); console.error("Authorization failed:", err);
} }

View File

@ -33,15 +33,16 @@ import {
} from "@/components/domains/TrialPayment/Cards"; } from "@/components/domains/TrialPayment/Cards";
import ProgressToSeeSoulmate from "@/components/domains/TrialPayment/ProgressToSeeSoulmate/ProgressToSeeSoulmate"; import ProgressToSeeSoulmate from "@/components/domains/TrialPayment/ProgressToSeeSoulmate/ProgressToSeeSoulmate";
import { buildTypographyProps } from "@/lib/funnel/mappers"; import { buildTypographyProps } from "@/lib/funnel/mappers";
// import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement"; import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement";
// import { useRouter } from "next/navigation"; import { Spinner } from "@/components/ui/spinner";
// import { ROUTES } from "@/shared/constants/client-routes"; import { Currency } from "@/shared/types";
import { getFormattedPrice } from "@/shared/utils/price";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/shared/constants/client-routes";
interface TrialPaymentTemplateProps { interface TrialPaymentTemplateProps {
funnel: FunnelDefinition; funnel: FunnelDefinition;
screen: TrialPaymentScreenDefinition; screen: TrialPaymentScreenDefinition;
// selectedEmail: string;
// onEmailChange: (email: string) => void;
onContinue: () => void; onContinue: () => void;
canGoBack: boolean; canGoBack: boolean;
onBack: () => void; onBack: () => void;
@ -50,36 +51,44 @@ interface TrialPaymentTemplateProps {
} }
export function TrialPaymentTemplate({ export function TrialPaymentTemplate({
// funnel, funnel,
screen, screen,
// selectedEmail,
// onEmailChange,
// onContinue,
canGoBack, canGoBack,
onBack, onBack,
screenProgress, screenProgress,
defaultTexts, defaultTexts,
}: TrialPaymentTemplateProps) { }: TrialPaymentTemplateProps) {
// const router = useRouter(); const router = useRouter();
// TODO: выбрать корректный paymentId для этого экрана (ключ из backend), временно "main_secret_discount"
// const paymentId = "main_secret_discount"; // TODO: выбрать корректный paymentId для этого экрана (ключ из backend), временно "main"
// const { placement } = usePaymentPlacement({ funnel, paymentId }); const paymentId = "main";
const { placement, isLoading } = usePaymentPlacement({ funnel, paymentId });
const trialInterval = placement?.trialInterval || 7;
const trialPeriod = placement?.trialPeriod;
const variant = placement?.variants?.[0];
const productId = variant?.id || "";
const placementId = placement?.placementId || "";
const paywallId = placement?.paywallId || "";
const trialPrice = variant?.trialPrice || 0;
const price = variant?.price || 0;
const oldPrice = variant?.price || 0;
const billingPeriod = placement?.billingPeriod;
const billingInterval = placement?.billingInterval || 1;
const currency = placement?.currency || Currency.USD;
console.log({ placement });
const handlePayClick = () => { const handlePayClick = () => {
// const productId = router.push(
// placement?.variants?.[0]?.id || placement?.variants?.[0]?.key || ""; ROUTES.payment({
// const placementId = placement?.placementId || ""; productId,
// const paywallId = placement?.paywallId || ""; placementId,
// if (productId && placementId && paywallId) { paywallId,
// router.push( })
// ROUTES.payment({ );
// productId,
// placementId,
// paywallId,
// })
// );
// }
}; };
const paymentSectionRef = useRef<HTMLDivElement | null>(null); const paymentSectionRef = useRef<HTMLDivElement | null>(null);
const scrollToPayment = () => { const scrollToPayment = () => {
@ -91,6 +100,86 @@ export function TrialPaymentTemplate({
} }
}; };
const formatPeriod = (
period: "DAY" | "WEEK" | "MONTH" | "YEAR" | undefined,
interval: number
) => {
if (!period) return `${interval} days`;
const unit =
period === "DAY"
? interval === 1
? "day"
: "days"
: period === "WEEK"
? interval === 1
? "week"
: "weeks"
: period === "MONTH"
? interval === 1
? "month"
: "months"
: interval === 1
? "year"
: "years";
return `${interval} ${unit}`;
};
const formattedTrialPrice = getFormattedPrice(trialPrice, currency);
const formattedBillingPrice = getFormattedPrice(price, currency);
const trialPeriodText = formatPeriod(trialPeriod, trialInterval);
const billingPeriodText = formatPeriod(billingPeriod, billingInterval);
const computeDiscountPercent = () => {
if (!oldPrice || !trialPrice || oldPrice <= 0) return undefined;
const ratio = 1 - trialPrice / oldPrice;
const percent = Math.max(0, Math.min(100, Math.round(ratio * 100)));
return String(percent);
};
const formatPeriodHyphen = (
period: "DAY" | "WEEK" | "MONTH" | "YEAR" | undefined,
interval: number
) => {
if (!period) return `${interval}-day`;
const unit =
period === "DAY"
? interval === 1
? "day"
: "days"
: period === "WEEK"
? interval === 1
? "week"
: "weeks"
: period === "MONTH"
? interval === 1
? "month"
: "months"
: interval === 1
? "year"
: "years";
return `${interval}-${unit}`;
};
const trialPeriodHyphenText = formatPeriodHyphen(trialPeriod, trialInterval);
const replacePlaceholders = (text: string | undefined) => {
if (!text) return "";
const values: Record<string, string> = {
trialPrice: formattedTrialPrice,
billingPrice: formattedBillingPrice,
oldPrice: getFormattedPrice(oldPrice || 0, currency),
discountPercent: computeDiscountPercent() ?? "",
trialPeriod: trialPeriodText,
billingPeriod: billingPeriodText,
trialPeriodHyphen: trialPeriodHyphenText,
};
let result = text;
for (const [key, value] of Object.entries(values)) {
result = result.replaceAll(`{{${key}}}`, value);
}
return result;
};
// Отключаем общий Header в TemplateLayout для этого экрана // Отключаем общий Header в TemplateLayout для этого экрана
const screenWithoutHeader = { const screenWithoutHeader = {
...screen, ...screen,
@ -126,6 +215,14 @@ export function TrialPaymentTemplate({
} }
); );
if (isLoading || !placement) {
return (
<div className="w-full min-h-dvh max-w-[560px] mx-auto flex items-center justify-center">
<Spinner className="size-8" />
</div>
);
}
return ( return (
<TemplateLayout <TemplateLayout
{...layoutProps} {...layoutProps}
@ -347,20 +444,26 @@ export function TrialPaymentTemplate({
{screen.tryForDays && ( {screen.tryForDays && (
<TryForDays <TryForDays
className="mt-[46px]" className="mt-[46px]"
title={buildTypographyProps(screen.tryForDays.title, { title={buildTypographyProps(
as: "h3", {
defaults: { font: "inter", weight: "bold", size: "xl" }, ...screen.tryForDays.title,
})} text: replacePlaceholders(screen.tryForDays.title?.text),
},
{
as: "h3",
defaults: { font: "inter", weight: "bold", size: "xl" },
}
)}
textListProps={ textListProps={
screen.tryForDays.textList screen.tryForDays.textList
? { ? {
listStyleType: "none", listStyleType: "none",
items: screen.tryForDays.textList.items.map( items: screen.tryForDays.textList.items.map(
(it) => (it) =>
buildTypographyProps(it, { buildTypographyProps(
as: "li", { ...it, text: replacePlaceholders(it.text) },
defaults: { font: "inter", size: "sm" }, { as: "li", defaults: { font: "inter", size: "sm" } }
})! )!
), ),
} }
: undefined : undefined
@ -397,21 +500,36 @@ export function TrialPaymentTemplate({
} }
), ),
price: buildTypographyProps( price: buildTypographyProps(
screen.totalPrice.priceContainer.price, {
...screen.totalPrice.priceContainer.price,
text: replacePlaceholders(
screen.totalPrice.priceContainer.price?.text
),
},
{ {
as: "span", as: "span",
defaults: { font: "inter", weight: "black" }, defaults: { font: "inter", weight: "black" },
} }
), ),
oldPrice: buildTypographyProps( oldPrice: buildTypographyProps(
screen.totalPrice.priceContainer.oldPrice, {
...screen.totalPrice.priceContainer.oldPrice,
text: replacePlaceholders(
screen.totalPrice.priceContainer.oldPrice?.text
),
},
{ {
as: "span", as: "span",
defaults: { font: "inter" }, defaults: { font: "inter" },
} }
), ),
discount: buildTypographyProps( discount: buildTypographyProps(
screen.totalPrice.priceContainer.discount, {
...screen.totalPrice.priceContainer.discount,
text: replacePlaceholders(
screen.totalPrice.priceContainer.discount?.text
),
},
{ {
as: "span", as: "span",
defaults: { font: "inter", weight: "bold", size: "sm" }, defaults: { font: "inter", weight: "bold", size: "sm" },
@ -530,10 +648,15 @@ export function TrialPaymentTemplate({
{screen.policy && ( {screen.policy && (
<Policy <Policy
className="mt-4" className="mt-4"
text={buildTypographyProps(screen.policy.text, { text={buildTypographyProps(
as: "p", screen.policy.text
defaults: { font: "inter", size: "xs" }, ? {
})} ...screen.policy.text,
text: replacePlaceholders(screen.policy.text.text),
}
: undefined,
{ as: "p", defaults: { font: "inter", size: "xs" } }
)}
/> />
)} )}

View File

@ -1,3 +1,5 @@
"use server";
import { http } from "@/shared/api/httpClient"; import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes"; import { API_ROUTES } from "@/shared/constants/api-routes";

View File

@ -39,7 +39,7 @@ export const CreateAuthorizeResponseSchema = z.object({
token: z.string(), token: z.string(),
userId: z.string().optional(), userId: z.string().optional(),
generatingVideo: z.boolean().optional(), generatingVideo: z.boolean().optional(),
videoId: z.string().optional(), videoId: z.string().nullable().optional(),
authCode: z.string().optional(), authCode: z.string().optional(),
}); });

View File

@ -39,8 +39,8 @@ export const useAuth = ({ funnelId }: IUseAuthProps) => {
timezone, timezone,
locale, locale,
email, email,
// source: funnelId, source: funnelId,
source: "aura.compatibility.v2", // source: "aura.compatibility.v2",
// profile: { // profile: {
// name: username || "", // name: username || "",
// gender: EGender[gender as keyof typeof EGender] || null, // gender: EGender[gender as keyof typeof EGender] || null,
@ -62,20 +62,7 @@ export const useAuth = ({ funnelId }: IUseAuthProps) => {
// feature: feature.includes("black") ? "ios" : feature, // feature: feature.includes("black") ? "ios" : feature,
}); });
}, },
[ [funnelId]
// birthPlace,
// birthdate,
// gender,
// locale,
// partnerBirthPlace,
// partnerBirthdate,
// partnerGender,
// partnerName,
// username,
// birthtime,
// checked,
// dateOfCheck,
]
); );
const authorization = useCallback( const authorization = useCallback(
@ -106,6 +93,7 @@ export const useAuth = ({ funnelId }: IUseAuthProps) => {
// authCode, // authCode,
} = await createAuthorization(payload); } = await createAuthorization(payload);
await setAuthTokenToCookie(token); await setAuthTokenToCookie(token);
return token;
// const { user: userMe } = await api.getMe({ token }); // const { user: userMe } = await api.getMe({ token });
// const userId = userIdFromApi || userMe?._id; // const userId = userIdFromApi || userMe?._id;
// if (userId?.length) { // if (userId?.length) {

View File

@ -51,7 +51,8 @@ export function usePaymentPlacement({
setPlacement(normalized); setPlacement(normalized);
} catch (e) { } catch (e) {
if (!isMounted) return; if (!isMounted) return;
const message = e instanceof Error ? e.message : "Failed to load payment placement"; const message =
e instanceof Error ? e.message : "Failed to load payment placement";
setError(message); setError(message);
} finally { } finally {
if (isMounted) setIsLoading(false); if (isMounted) setIsLoading(false);
@ -65,5 +66,3 @@ export function usePaymentPlacement({
return { placement, isLoading, error }; return { placement, isLoading, error };
} }

View File

@ -106,6 +106,7 @@ class HttpClient {
// ignore // ignore
} }
} }
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");

View File

@ -9,7 +9,7 @@ export function getClientAccessToken(): string | undefined {
if (typeof window === "undefined") return undefined; if (typeof window === "undefined") return undefined;
const cookies = document.cookie.split(";"); const cookies = document.cookie.split(";");
const accessTokenCookie = cookies.find(cookie => const accessTokenCookie = cookies.find((cookie) =>
cookie.trim().startsWith("accessToken=") cookie.trim().startsWith("accessToken=")
); );

View File

@ -0,0 +1,8 @@
import { Currency } from "@/shared/types";
export const symbolByCurrency: Record<string, string> = {
[Currency.USD]: "$",
[Currency.EUR]: "€",
[Currency.USD.toLowerCase()]: "$",
[Currency.EUR.toLowerCase()]: "€",
};

18
src/shared/utils/price.ts Normal file
View File

@ -0,0 +1,18 @@
import { Currency } from "@/shared/types";
import { symbolByCurrency } from "../constants/currency";
const addCurrency = (price: number | string, currency: Currency) => {
const symbol = symbolByCurrency[currency];
if ([Currency.EUR].includes(currency)) {
return `${price} ${symbol}`;
}
return `${symbol}${price}`;
};
export const getFormattedPrice = (
price: number,
currency: Currency,
precision = 2
) => {
return addCurrency((price / 100).toFixed(precision), currency);
};