From 7f8076733361c52017f146d063dd42c3613b9c3b Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Wed, 24 Dec 2025 21:42:17 +0300 Subject: [PATCH] utm --- src/app/[locale]/(payment)/payment/route.ts | 27 ++++ src/app/[locale]/auth/callback/route.ts | 4 + .../LandingButtonWrapper.tsx | 4 +- .../SpecialOfferButtonWrapper.tsx | 25 ++-- .../LandingButtonWrapper.tsx | 4 +- .../SpecialOfferButtonWrapper.tsx | 25 ++-- .../save-off/Button/Button.tsx | 4 +- .../secret-discount/Button/Button.tsx | 25 ++-- src/entities/payment/types.ts | 2 + src/shared/constants/client-routes.ts | 22 ++-- src/shared/utils/url.ts | 119 ++++++++++++++++++ 11 files changed, 227 insertions(+), 34 deletions(-) create mode 100644 src/shared/utils/url.ts diff --git a/src/app/[locale]/(payment)/payment/route.ts b/src/app/[locale]/(payment)/payment/route.ts index a1dde6d..fe7a8c9 100644 --- a/src/app/[locale]/(payment)/payment/route.ts +++ b/src/app/[locale]/(payment)/payment/route.ts @@ -3,6 +3,26 @@ import { NextRequest, NextResponse } from "next/server"; import { createPaymentCheckout } from "@/entities/payment/api"; import { ROUTES } from "@/shared/constants/client-routes"; +/** + * Decode URL-safe base64 state parameter to UTM object + */ +function decodeStateParam(state: string): Record | undefined { + if (!state) return undefined; + + try { + // Restore URL-safe base64 to standard base64 + let base64 = state.replace(/-/g, "+").replace(/_/g, "/"); + // Add padding if needed + while (base64.length % 4) { + base64 += "="; + } + const json = atob(base64); + return JSON.parse(json); + } catch { + return undefined; + } +} + export async function GET(req: NextRequest) { const productId = req.nextUrl.searchParams.get("productId"); const placementId = req.nextUrl.searchParams.get("placementId"); @@ -10,11 +30,18 @@ export async function GET(req: NextRequest) { const fbPixels = req.nextUrl.searchParams.get("fb_pixels"); const productPrice = req.nextUrl.searchParams.get("price"); const currency = req.nextUrl.searchParams.get("currency"); + const sessionId = req.nextUrl.searchParams.get("sessionId"); + const state = req.nextUrl.searchParams.get("state"); + + // Decode state param to get UTM + const utm = state ? decodeStateParam(state) : undefined; const data = await createPaymentCheckout({ productId: productId || "", placementId: placementId || "", paywallId: paywallId || "", + sessionId: sessionId || undefined, + utm, }); let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin); diff --git a/src/app/[locale]/auth/callback/route.ts b/src/app/[locale]/auth/callback/route.ts index f01bc56..1caf316 100644 --- a/src/app/[locale]/auth/callback/route.ts +++ b/src/app/[locale]/auth/callback/route.ts @@ -39,6 +39,8 @@ export async function GET(req: NextRequest) { const productPrice = searchParams.get("price"); const currency = searchParams.get("currency"); const nextUrl = searchParams.get("nextUrl"); + const sessionId = searchParams.get("sessionId"); + const state = searchParams.get("state"); const redirectUrl = new URL( `${nextUrl || ROUTES.payment()}`, @@ -51,6 +53,8 @@ export async function GET(req: NextRequest) { if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels); if (productPrice) redirectUrl.searchParams.set("price", productPrice); if (currency) redirectUrl.searchParams.set("currency", currency); + if (sessionId) redirectUrl.searchParams.set("sessionId", sessionId); + if (state) redirectUrl.searchParams.set("state", state); const trackingCookies = extractTrackingCookiesFromUrl(req.nextUrl); diff --git a/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.tsx b/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.tsx index 1fa66fd..d186645 100644 --- a/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.tsx +++ b/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.tsx @@ -7,6 +7,7 @@ import { Button, Typography } from "@/components/ui"; import { BlurComponent } from "@/components/widgets"; import { ROUTES } from "@/shared/constants/client-routes"; import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate"; +import { getCurrentUtmParams } from "@/shared/utils/url"; import styles from "./LandingButtonWrapper.module.scss"; @@ -17,7 +18,8 @@ export default function LandingButtonWrapper() { ); const handleContinue = () => { - router.push(ROUTES.emailMarketingCompatibilityV1SpecialOffer()); + const utmParams = getCurrentUtmParams(); + router.push(ROUTES.emailMarketingCompatibilityV1SpecialOffer(utmParams)); }; return ( diff --git a/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx b/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx index 50ee989..75c1d7e 100644 --- a/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx +++ b/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx @@ -7,6 +7,10 @@ import { Button, Typography } from "@/components/ui"; import { BlurComponent } from "@/components/widgets"; import { ROUTES } from "@/shared/constants/client-routes"; import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate"; +import { + getSessionIdFromUrl, + getStateParamForRedirect, +} from "@/shared/utils/url"; import styles from "./SpecialOfferButtonWrapper.module.scss"; @@ -27,13 +31,20 @@ export default function SpecialOfferButtonWrapper({ ); const openPaymentModal = () => { - router.push( - ROUTES.payment({ - productId, - placementId, - paywallId, - }) - ); + const state = getStateParamForRedirect(); + const sessionId = getSessionIdFromUrl(); + const params: Record = { + productId, + placementId, + paywallId, + }; + if (state) { + params.state = state; + } + if (sessionId) { + params.sessionId = sessionId; + } + router.push(ROUTES.payment(params)); }; return ( diff --git a/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx b/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx index d9353a2..72be07f 100644 --- a/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx +++ b/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx @@ -7,6 +7,7 @@ import { Button, Typography } from "@/components/ui"; import { BlurComponent } from "@/components/widgets"; import { ROUTES } from "@/shared/constants/client-routes"; import { translatePathEmailMarketingSoulmateV1 } from "@/shared/constants/translate"; +import { getCurrentUtmParams } from "@/shared/utils/url"; import styles from "./LandingButtonWrapper.module.scss"; @@ -15,7 +16,8 @@ export default function LandingButtonWrapper() { const t = useTranslations(translatePathEmailMarketingSoulmateV1("Landing")); const handleContinue = () => { - router.push(ROUTES.emailMarketingSoulmateV1SpecialOffer()); + const utmParams = getCurrentUtmParams(); + router.push(ROUTES.emailMarketingSoulmateV1SpecialOffer(utmParams)); }; return ( diff --git a/src/components/domains/email-marketing/soulmate/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx b/src/components/domains/email-marketing/soulmate/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx index 153f343..8a8ed35 100644 --- a/src/components/domains/email-marketing/soulmate/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx +++ b/src/components/domains/email-marketing/soulmate/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx @@ -7,6 +7,10 @@ import { Button, Typography } from "@/components/ui"; import { BlurComponent } from "@/components/widgets"; import { ROUTES } from "@/shared/constants/client-routes"; import { translatePathEmailMarketingSoulmateV1 } from "@/shared/constants/translate"; +import { + getSessionIdFromUrl, + getStateParamForRedirect, +} from "@/shared/utils/url"; import styles from "./SpecialOfferButtonWrapper.module.scss"; @@ -27,13 +31,20 @@ export default function SpecialOfferButtonWrapper({ ); const openPaymentModal = () => { - router.push( - ROUTES.payment({ - productId, - placementId, - paywallId, - }) - ); + const state = getStateParamForRedirect(); + const sessionId = getSessionIdFromUrl(); + const params: Record = { + productId, + placementId, + paywallId, + }; + if (state) { + params.state = state; + } + if (sessionId) { + params.sessionId = sessionId; + } + router.push(ROUTES.payment(params)); }; return ( diff --git a/src/components/domains/secret-discount/save-off/Button/Button.tsx b/src/components/domains/secret-discount/save-off/Button/Button.tsx index 2332476..1ef4d22 100644 --- a/src/components/domains/secret-discount/save-off/Button/Button.tsx +++ b/src/components/domains/secret-discount/save-off/Button/Button.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { Button, Typography } from "@/components/ui"; import { usePeriod } from "@/hooks/translations/usePeriod"; import { ROUTES } from "@/shared/constants/client-routes"; +import { getCurrentUtmParams } from "@/shared/utils/url"; import { PeriodType } from "@/types/period"; import styles from "./Button.module.scss"; @@ -24,7 +25,8 @@ export default function SaveOffButton({ const router = useRouter(); const handleNext = () => { - router.push(ROUTES.secretDiscount()); + const utmParams = getCurrentUtmParams(); + router.push(ROUTES.secretDiscount(utmParams)); }; return ( diff --git a/src/components/domains/secret-discount/secret-discount/Button/Button.tsx b/src/components/domains/secret-discount/secret-discount/Button/Button.tsx index b896d48..a67bbd6 100644 --- a/src/components/domains/secret-discount/secret-discount/Button/Button.tsx +++ b/src/components/domains/secret-discount/secret-discount/Button/Button.tsx @@ -6,6 +6,10 @@ import { useTranslations } from "next-intl"; import { Button, Typography } from "@/components/ui"; import { usePeriod } from "@/hooks/translations/usePeriod"; import { ROUTES } from "@/shared/constants/client-routes"; +import { + getSessionIdFromUrl, + getStateParamForRedirect, +} from "@/shared/utils/url"; import { PeriodType } from "@/types/period"; import styles from "./Button.module.scss"; @@ -30,13 +34,20 @@ export default function SecretDiscountButton({ const router = useRouter(); const handleNext = () => { - router.push( - ROUTES.payment({ - productId, - placementId, - paywallId, - }) - ); + const state = getStateParamForRedirect(); + const sessionId = getSessionIdFromUrl(); + const params: Record = { + productId, + placementId, + paywallId, + }; + if (state) { + params.state = state; + } + if (sessionId) { + params.sessionId = sessionId; + } + router.push(ROUTES.payment(params)); }; return ( diff --git a/src/entities/payment/types.ts b/src/entities/payment/types.ts index 314ee97..24fc584 100644 --- a/src/entities/payment/types.ts +++ b/src/entities/payment/types.ts @@ -4,6 +4,8 @@ export const CheckoutRequestSchema = z.object({ productId: z.string(), placementId: z.string(), paywallId: z.string(), + sessionId: z.string().optional(), + utm: z.record(z.string()).optional(), }); export type CheckoutRequest = z.infer; diff --git a/src/shared/constants/client-routes.ts b/src/shared/constants/client-routes.ts index 462b5a8..ad60df8 100644 --- a/src/shared/constants/client-routes.ts +++ b/src/shared/constants/client-routes.ts @@ -74,8 +74,10 @@ export const ROUTES = { paymentFailed: () => createRoute(["payment", "failed"]), // Secret Discount - saveOff: () => createRoute(["save-off"]), - secretDiscount: () => createRoute(["secret-discount"]), + saveOff: (queryParams?: Record) => + createRoute(["save-off"], queryParams), + secretDiscount: (queryParams?: Record) => + createRoute(["secret-discount"], queryParams), // Chat chat: (id?: string) => createRoute(["chat", id]), @@ -86,16 +88,16 @@ export const ROUTES = { additionalPurchases: (type?: string) => createRoute(["ap", type]), // Email Marketing Compatibility V1 - emailMarketingCompatibilityV1Landing: () => - createRoute([emailMarketingCompatibilityV1Prefix, "landing"]), - emailMarketingCompatibilityV1SpecialOffer: () => - createRoute([emailMarketingCompatibilityV1Prefix, "special-offer"]), + emailMarketingCompatibilityV1Landing: (queryParams?: Record) => + createRoute([emailMarketingCompatibilityV1Prefix, "landing"], queryParams), + emailMarketingCompatibilityV1SpecialOffer: (queryParams?: Record) => + createRoute([emailMarketingCompatibilityV1Prefix, "special-offer"], queryParams), // Email Marketing Soulmate V1 - emailMarketingSoulmateV1Landing: () => - createRoute([emailMarketingSoulmateV1Prefix, "landing"]), - emailMarketingSoulmateV1SpecialOffer: () => - createRoute([emailMarketingSoulmateV1Prefix, "special-offer"]), + emailMarketingSoulmateV1Landing: (queryParams?: Record) => + createRoute([emailMarketingSoulmateV1Prefix, "landing"], queryParams), + emailMarketingSoulmateV1SpecialOffer: (queryParams?: Record) => + createRoute([emailMarketingSoulmateV1Prefix, "special-offer"], queryParams), // // Compatibility // compatibilities: () => createRoute(["compatibilities"]), diff --git a/src/shared/utils/url.ts b/src/shared/utils/url.ts new file mode 100644 index 0000000..34155ba --- /dev/null +++ b/src/shared/utils/url.ts @@ -0,0 +1,119 @@ +// Params that should NOT be included in state (they are passed separately) +const EXCLUDED_STATE_PARAMS = [ + "paywallId", + "placementId", + "productId", + "jwtToken", + "price", + "currency", + "fb_pixels", + "sessionId", + "state", + // Tracking cookies (passed separately) + "_fbc", + "_fbp", + "_ym_uid", + "_ym_d", + "_ym_isad", + "_ym_visorc", + "yandexuid", + "ymex", +]; + +/** + * Parse current query params from URL + */ +export const parseQueryParams = (): Record => { + if (typeof window === "undefined") return {}; + + const params = new URLSearchParams(window.location.search); + const result: Record = {}; + + for (const [key, value] of params.entries()) { + result[key] = value; + } + + return result; +}; + +/** + * Get current query params that should be passed between screens and to payment + * Includes ALL params except internal ones (productId, placementId, etc.) + * Works with utm_*, fbclid, gclid, and any other marketing params + */ +export const getCurrentQueryParams = (): Record => { + if (typeof window === "undefined") return {}; + + const params = parseQueryParams(); + const utmParams: Record = {}; + + for (const [key, value] of Object.entries(params)) { + const isExcluded = + EXCLUDED_STATE_PARAMS.includes(key) || + key.startsWith("_ga") || + key.startsWith("_gid"); + + if (!isExcluded && value) { + utmParams[key] = value; + } + } + + return utmParams; +}; + +/** + * Encode params as base64 JSON for state parameter + * Uses URL-safe base64 encoding + */ +export const encodeStateParam = (params: Record): string => { + if (Object.keys(params).length === 0) return ""; + + try { + const json = JSON.stringify(params); + // Use btoa for base64, replace unsafe chars for URL + const base64 = btoa(json) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return base64; + } catch { + return ""; + } +}; + +/** + * Get base64-encoded state parameter with current query params + */ +export const getStateParamForRedirect = (): string => { + const params = getCurrentQueryParams(); + return encodeStateParam(params); +}; + +/** + * Get sessionId from current URL if present + */ +export const getSessionIdFromUrl = (): string | undefined => { + if (typeof window === "undefined") return undefined; + const params = new URLSearchParams(window.location.search); + return params.get("sessionId") || undefined; +}; + +/** + * Build URL with current query params preserved + */ +export const buildUrlWithQueryParams = (baseUrl: string): string => { + const params = getCurrentQueryParams(); + + if (Object.keys(params).length === 0) return baseUrl; + + const url = new URL(baseUrl, window.location.origin); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + return url.pathname + url.search; +}; + +// Backward compatibility aliases +export const getCurrentUtmParams = getCurrentQueryParams; +export const buildUrlWithUtmParams = buildUrlWithQueryParams;