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 currency = req.nextUrl.searchParams.get("currency");
console.log("PRODUCT ID:", productId);
const data = await createPaymentCheckout({
productId: productId || "",
placementId: placementId || "",
paywallId: paywallId || "",
});
console.log("DATA:", data);
let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin);
if (!redirectUrl) {

View File

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

View File

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

View File

@ -33,15 +33,16 @@ import {
} from "@/components/domains/TrialPayment/Cards";
import ProgressToSeeSoulmate from "@/components/domains/TrialPayment/ProgressToSeeSoulmate/ProgressToSeeSoulmate";
import { buildTypographyProps } from "@/lib/funnel/mappers";
// import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement";
// import { useRouter } from "next/navigation";
// import { ROUTES } from "@/shared/constants/client-routes";
import { usePaymentPlacement } from "@/hooks/payment/usePaymentPlacement";
import { Spinner } from "@/components/ui/spinner";
import { Currency } from "@/shared/types";
import { getFormattedPrice } from "@/shared/utils/price";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/shared/constants/client-routes";
interface TrialPaymentTemplateProps {
funnel: FunnelDefinition;
screen: TrialPaymentScreenDefinition;
// selectedEmail: string;
// onEmailChange: (email: string) => void;
onContinue: () => void;
canGoBack: boolean;
onBack: () => void;
@ -50,36 +51,44 @@ interface TrialPaymentTemplateProps {
}
export function TrialPaymentTemplate({
// funnel,
funnel,
screen,
// selectedEmail,
// onEmailChange,
// onContinue,
canGoBack,
onBack,
screenProgress,
defaultTexts,
}: TrialPaymentTemplateProps) {
// const router = useRouter();
// TODO: выбрать корректный paymentId для этого экрана (ключ из backend), временно "main_secret_discount"
// const paymentId = "main_secret_discount";
// const { placement } = usePaymentPlacement({ funnel, paymentId });
const router = useRouter();
// TODO: выбрать корректный paymentId для этого экрана (ключ из backend), временно "main"
const paymentId = "main";
const { placement, isLoading } = usePaymentPlacement({ funnel, paymentId });
const trialInterval = placement?.trialInterval || 7;
const trialPeriod = placement?.trialPeriod;
const variant = placement?.variants?.[0];
const productId = variant?.id || "";
const placementId = placement?.placementId || "";
const paywallId = placement?.paywallId || "";
const trialPrice = variant?.trialPrice || 0;
const price = variant?.price || 0;
const oldPrice = variant?.price || 0;
const billingPeriod = placement?.billingPeriod;
const billingInterval = placement?.billingInterval || 1;
const currency = placement?.currency || Currency.USD;
console.log({ placement });
const handlePayClick = () => {
// const productId =
// placement?.variants?.[0]?.id || placement?.variants?.[0]?.key || "";
// const placementId = placement?.placementId || "";
// const paywallId = placement?.paywallId || "";
// if (productId && placementId && paywallId) {
// router.push(
// ROUTES.payment({
// productId,
// placementId,
// paywallId,
// })
// );
// }
router.push(
ROUTES.payment({
productId,
placementId,
paywallId,
})
);
};
const paymentSectionRef = useRef<HTMLDivElement | null>(null);
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 для этого экрана
const screenWithoutHeader = {
...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 (
<TemplateLayout
{...layoutProps}
@ -347,20 +444,26 @@ export function TrialPaymentTemplate({
{screen.tryForDays && (
<TryForDays
className="mt-[46px]"
title={buildTypographyProps(screen.tryForDays.title, {
as: "h3",
defaults: { font: "inter", weight: "bold", size: "xl" },
})}
title={buildTypographyProps(
{
...screen.tryForDays.title,
text: replacePlaceholders(screen.tryForDays.title?.text),
},
{
as: "h3",
defaults: { font: "inter", weight: "bold", size: "xl" },
}
)}
textListProps={
screen.tryForDays.textList
? {
listStyleType: "none",
items: screen.tryForDays.textList.items.map(
(it) =>
buildTypographyProps(it, {
as: "li",
defaults: { font: "inter", size: "sm" },
})!
buildTypographyProps(
{ ...it, text: replacePlaceholders(it.text) },
{ as: "li", defaults: { font: "inter", size: "sm" } }
)!
),
}
: undefined
@ -397,21 +500,36 @@ export function TrialPaymentTemplate({
}
),
price: buildTypographyProps(
screen.totalPrice.priceContainer.price,
{
...screen.totalPrice.priceContainer.price,
text: replacePlaceholders(
screen.totalPrice.priceContainer.price?.text
),
},
{
as: "span",
defaults: { font: "inter", weight: "black" },
}
),
oldPrice: buildTypographyProps(
screen.totalPrice.priceContainer.oldPrice,
{
...screen.totalPrice.priceContainer.oldPrice,
text: replacePlaceholders(
screen.totalPrice.priceContainer.oldPrice?.text
),
},
{
as: "span",
defaults: { font: "inter" },
}
),
discount: buildTypographyProps(
screen.totalPrice.priceContainer.discount,
{
...screen.totalPrice.priceContainer.discount,
text: replacePlaceholders(
screen.totalPrice.priceContainer.discount?.text
),
},
{
as: "span",
defaults: { font: "inter", weight: "bold", size: "sm" },
@ -530,10 +648,15 @@ export function TrialPaymentTemplate({
{screen.policy && (
<Policy
className="mt-4"
text={buildTypographyProps(screen.policy.text, {
as: "p",
defaults: { font: "inter", size: "xs" },
})}
text={buildTypographyProps(
screen.policy.text
? {
...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 { API_ROUTES } from "@/shared/constants/api-routes";

View File

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

View File

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

View File

@ -51,7 +51,8 @@ export function usePaymentPlacement({
setPlacement(normalized);
} catch (e) {
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);
} finally {
if (isMounted) setIsLoading(false);
@ -65,5 +66,3 @@ export function usePaymentPlacement({
return { placement, isLoading, error };
}

View File

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

View File

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

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);
};