diff --git a/prisma/migrations/20260813120000_add_user_gclid/migration.sql b/prisma/migrations/20260813120000_add_user_gclid/migration.sql new file mode 100644 index 0000000..c36d752 --- /dev/null +++ b/prisma/migrations/20260813120000_add_user_gclid/migration.sql @@ -0,0 +1,8 @@ +-- Идентификатор клика Google Ads на пользователе. +-- Рекламу по обучающим запросам ведём на бесплатный вход в школу, поэтому +-- конверсию «регистрация» отправляем в Google серверно (src/lib/ads-conversion.ts), +-- а не тегом на странице: после регистрации человека сразу редиректит в кабинет. +-- +-- IF NOT EXISTS — колонка уже заведена на проде вручную 20260813, чтобы включить +-- сбор до выката образа. На чистой базе миграция создаст её обычным порядком. +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gclid" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d8d1e7..0247bec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,10 @@ model User { utmSource String? utmMedium String? utmCampaign String? + // Идентификатор клика Google Ads. Нужен, чтобы отправить конверсию + // «регистрация» серверно: рекламный трафик ведём на бесплатный вход, + // и без него Google не узнает, какие клики дают студентов. + gclid String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 1588585..8094de9 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -37,6 +37,7 @@ export default async function RegisterPage({ utmSource={str(sp.utm_source)} utmMedium={str(sp.utm_medium)} utmCampaign={str(sp.utm_campaign)} + gclid={str(sp.gclid)} /> diff --git a/src/app/(auth)/register/register-form.tsx b/src/app/(auth)/register/register-form.tsx index d444aff..9c32bf2 100644 --- a/src/app/(auth)/register/register-form.tsx +++ b/src/app/(auth)/register/register-form.tsx @@ -30,9 +30,10 @@ interface Props { utmSource?: string; utmMedium?: string; utmCampaign?: string; + gclid?: string; } -export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl, archetype, utmSource, utmMedium, utmCampaign }: Props) { +export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl, archetype, utmSource, utmMedium, utmCampaign, gclid }: Props) { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -127,6 +128,7 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of utmSource, utmMedium, utmCampaign, + gclid, }), }); } catch { diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index ffd1f34..28477bc 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { sendRegistrationConversion } from "@/lib/ads-conversion"; // eslint-disable-next-line @typescript-eslint/no-require-imports const disposableDomains = require("disposable-email-domains") as string[]; @@ -37,7 +38,7 @@ export async function POST(request: NextRequest) { return jsonError("Неверный формат запроса", 400); } - const { name, email, password, website, cfTurnstileResponse, archetype, utmSource, utmMedium, utmCampaign } = body; + const { name, email, password, website, cfTurnstileResponse, archetype, utmSource, utmMedium, utmCampaign, gclid } = body; // Honeypot — bots fill this field, humans don't see it if (website) { @@ -104,7 +105,7 @@ export async function POST(request: NextRequest) { ]); // Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"]; - if (user && (archetype || utmSource || utmMedium || utmCampaign)) { + if (user && (archetype || utmSource || utmMedium || utmCampaign || gclid)) { await prisma.user.update({ where: { id: user.id }, data: { @@ -112,6 +113,7 @@ export async function POST(request: NextRequest) { utmSource: utmSource ? String(utmSource) : null, utmMedium: utmMedium ? String(utmMedium) : null, utmCampaign: utmCampaign ? String(utmCampaign) : null, + gclid: gclid ? String(gclid) : null, }, }); } @@ -129,6 +131,10 @@ export async function POST(request: NextRequest) { method: "free-signup", }, }); + // Конверсия в Google Ads — фоном, регистрацию не задерживаем и не роняем. + if (gclid) { + void sendRegistrationConversion(String(gclid)); + } } } catch (e) { console.error("free-enroll failed", e); diff --git a/src/lib/ads-conversion.ts b/src/lib/ads-conversion.ts new file mode 100644 index 0000000..ee19451 --- /dev/null +++ b/src/lib/ads-conversion.ts @@ -0,0 +1,95 @@ +/** + * Серверная отправка конверсии «регистрация» в Google Ads. + * + * Зачем серверно, а не тегом на странице: рекламный трафик по обучающим + * запросам ведём на бесплатный вход в школу, и Google должен видеть, какие + * клики превращаются в студентов. Тег на странице «спасибо» here не годится — + * после регистрации человека сразу редиректит в кабинет, и событие теряется + * (та же грабля, что была с покупками в payment-router, разбор 20260810). + * + * Всё gated по env: нет кредов — тихо ничего не делаем. Ошибки наружу не + * пробрасываются: аналитика не должна ронять регистрацию. + */ + +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_ADS_API = "https://googleads.googleapis.com/v24"; + +async function accessToken(): Promise { + const clientId = process.env.GADS_CLIENT_ID; + const clientSecret = process.env.GADS_CLIENT_SECRET; + const refreshToken = process.env.GADS_REFRESH_TOKEN; + if (!clientId || !clientSecret || !refreshToken) return null; + + const res = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + if (!res.ok) return null; + const data = (await res.json()) as { access_token?: string }; + return data.access_token ?? null; +} + +/** Формат Google Ads: "yyyy-mm-dd hh:mm:ss+hh:mm". Отдаём в Алматы (+05:00). */ +function conversionDateTime(when: Date): string { + const almaty = new Date(when.getTime() + 5 * 3600 * 1000); + const p = (n: number) => String(n).padStart(2, "0"); + return ( + `${almaty.getUTCFullYear()}-${p(almaty.getUTCMonth() + 1)}-${p(almaty.getUTCDate())} ` + + `${p(almaty.getUTCHours())}:${p(almaty.getUTCMinutes())}:${p(almaty.getUTCSeconds())}+05:00` + ); +} + +export async function sendRegistrationConversion(gclid: string): Promise { + const developerToken = process.env.GADS_DEVELOPER_TOKEN; + const customerId = process.env.GADS_CUSTOMER_ID; + const loginCustomerId = process.env.GADS_LOGIN_CUSTOMER_ID; + const actionId = process.env.GADS_SIGNUP_CONVERSION_ACTION_ID; + if (!gclid || !developerToken || !customerId || !actionId) return false; + + try { + const token = await accessToken(); + if (!token) return false; + + const res = await fetch( + `${GOOGLE_ADS_API}/customers/${customerId}:uploadClickConversions`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "developer-token": developerToken, + ...(loginCustomerId ? { "login-customer-id": loginCustomerId } : {}), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + conversions: [ + { + gclid, + conversionAction: `customers/${customerId}/conversionActions/${actionId}`, + conversionDateTime: conversionDateTime(new Date()), + }, + ], + partialFailure: true, + }), + } + ); + if (!res.ok) { + console.error("ads signup conversion failed", res.status, (await res.text()).slice(0, 300)); + return false; + } + const body = (await res.json()) as { partialFailureError?: unknown }; + if (body.partialFailureError) { + console.error("ads signup conversion partial failure", JSON.stringify(body.partialFailureError).slice(0, 300)); + return false; + } + return true; + } catch (e) { + console.error("ads signup conversion error", e); + return false; + } +}