feat(ads): конверсия «регистрация» в Google Ads по gclid
Рекламу по обучающим запросам (obsidian как пользоваться, obsidian заметки) ведём на бесплатный вход в школу, а не на продажу: порог входа нулевой, а из 19 бесплатных регистраций 6 дошли до покупки (32%). Чтобы Google видел, какие клики дают студентов: - User.gclid — идентификатор клика рядом с уже собираемыми UTM; - gclid прокидывается из URL через page → форму → API (той же цепочкой); - src/lib/ads-conversion.ts — серверная отправка конверсии 7719084914 (UPLOAD_CLICKS). Серверно, а не тегом: после регистрации сразу редирект в кабинет, тег на странице «спасибо» не сработает — грабля из разбора платежей 20260810. Всё gated по env (GADS_*): нет кредов — тихо не отправляем, регистрацию не роняем. Миграция идемпотентна: колонка заведена на проде вручную.
This commit is contained in:
@@ -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;
|
||||||
@@ -29,6 +29,10 @@ model User {
|
|||||||
utmSource String?
|
utmSource String?
|
||||||
utmMedium String?
|
utmMedium String?
|
||||||
utmCampaign String?
|
utmCampaign String?
|
||||||
|
// Идентификатор клика Google Ads. Нужен, чтобы отправить конверсию
|
||||||
|
// «регистрация» серверно: рекламный трафик ведём на бесплатный вход,
|
||||||
|
// и без него Google не узнает, какие клики дают студентов.
|
||||||
|
gclid String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default async function RegisterPage({
|
|||||||
utmSource={str(sp.utm_source)}
|
utmSource={str(sp.utm_source)}
|
||||||
utmMedium={str(sp.utm_medium)}
|
utmMedium={str(sp.utm_medium)}
|
||||||
utmCampaign={str(sp.utm_campaign)}
|
utmCampaign={str(sp.utm_campaign)}
|
||||||
|
gclid={str(sp.gclid)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ interface Props {
|
|||||||
utmSource?: string;
|
utmSource?: string;
|
||||||
utmMedium?: string;
|
utmMedium?: string;
|
||||||
utmCampaign?: 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 [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -127,6 +128,7 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
|
|||||||
utmSource,
|
utmSource,
|
||||||
utmMedium,
|
utmMedium,
|
||||||
utmCampaign,
|
utmCampaign,
|
||||||
|
gclid,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendRegistrationConversion } from "@/lib/ads-conversion";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const disposableDomains = require("disposable-email-domains") as string[];
|
const disposableDomains = require("disposable-email-domains") as string[];
|
||||||
@@ -37,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return jsonError("Неверный формат запроса", 400);
|
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
|
// Honeypot — bots fill this field, humans don't see it
|
||||||
if (website) {
|
if (website) {
|
||||||
@@ -104,7 +105,7 @@ export async function POST(request: NextRequest) {
|
|||||||
]);
|
]);
|
||||||
// Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации
|
// Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации
|
||||||
const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"];
|
const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"];
|
||||||
if (user && (archetype || utmSource || utmMedium || utmCampaign)) {
|
if (user && (archetype || utmSource || utmMedium || utmCampaign || gclid)) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -112,6 +113,7 @@ export async function POST(request: NextRequest) {
|
|||||||
utmSource: utmSource ? String(utmSource) : null,
|
utmSource: utmSource ? String(utmSource) : null,
|
||||||
utmMedium: utmMedium ? String(utmMedium) : null,
|
utmMedium: utmMedium ? String(utmMedium) : null,
|
||||||
utmCampaign: utmCampaign ? String(utmCampaign) : null,
|
utmCampaign: utmCampaign ? String(utmCampaign) : null,
|
||||||
|
gclid: gclid ? String(gclid) : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,10 @@ export async function POST(request: NextRequest) {
|
|||||||
method: "free-signup",
|
method: "free-signup",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Конверсия в Google Ads — фоном, регистрацию не задерживаем и не роняем.
|
||||||
|
if (gclid) {
|
||||||
|
void sendRegistrationConversion(String(gclid));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("free-enroll failed", e);
|
console.error("free-enroll failed", e);
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user