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:
2026-08-13 11:25:26 +05:00
parent 20a25695bc
commit 8fc9708233
6 changed files with 119 additions and 3 deletions
+1
View File
@@ -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)}
/>
</div>
</div>
+3 -1
View File
@@ -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 {
+8 -2
View File
@@ -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);