analytics: count a successful form as the FORM_SUCCESS Metrika goal

The counter was in place but nothing reached it: src/lib/lead.ts only pushed
a custom dataLayer event, which Metrika's ecommerce: 'dataLayer' ignores, so
form conversions were not counted at all.

All five forms — including the fitnes callback modal — funnel through the one
success branch in submitLead(), so a single ym(112352796, 'reachGoal',
'FORM_SUCCESS') per landing covers every one of them. The counter id is
hardcoded to match the inline snippet in index.html; two sources for it would
drift. The call is optional-chained and wrapped in try/catch, because losing
analytics must never cost a lead.

The honeypot is the one success that is not a conversion: the server answers
bots with 200 {ok:true, leadId:0} so they learn nothing, and leadId === 0 is
what keeps that answer out of the goal.

The goal itself still has to be created in the counter's settings as a
JavaScript event named FORM_SUCCESS — noted in deploy/README.md, replacing
the entry that said conversions were unwired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-09-09 01:30:47 +06:00
co-authored by Claude Opus 5
parent e13854e918
commit 8af2e463b3
5 changed files with 86 additions and 4 deletions
+6 -4
View File
@@ -243,8 +243,10 @@ Also enable reg.ru VPS snapshots — a full-image restore beats rebuilding under
- **One Metrika counter for four domains.** All four landings carry the same - **One Metrika counter for four domains.** All four landings carry the same
Yandex.Metrika counter (`112352796`) in `index.html`, so reports mix the domains and Yandex.Metrika counter (`112352796`) in `index.html`, so reports mix the domains and
every domain has to be listed in the counter's settings, or its hits get filtered. every domain has to be listed in the counter's settings, or its hits get filtered.
- **Still open before launch** (not deployment blockers): conversions are not wired to - **The `FORM_SUCCESS` goal has to exist in the Metrika UI.** `src/lib/lead.ts` fires
Metrika goals — `src/lib/lead.ts` pushes a plain custom `dataLayer` event, which `ym(112352796, 'reachGoal', 'FORM_SUCCESS')` on every successful form submission, but
Metrika's `ecommerce: 'dataLayer'` does not read, so a JS goal or an explicit the goal itself is not part of the code: create it in the counter's settings as
`ym(..., 'reachGoal', ...)` call is still needed; and the forms show implicit 152-ФЗ «JavaScript-событие» with the identifier `FORM_SUCCESS`, or the hits arrive and no
report ever shows them. The honeypot answer (`leadId: 0`) deliberately does not count.
- **Still open before launch** (not a deployment blocker): the forms show implicit 152-ФЗ
consent text with no link to a published privacy policy. consent text with no link to a published privacy policy.
+20
View File
@@ -9,6 +9,9 @@ import {
const ENDPOINT = '/api/leads/fitness-centers' const ENDPOINT = '/api/leads/fitness-centers'
const DRAFT_KEY = 'exo_fitness_lead_draft' const DRAFT_KEY = 'exo_fitness_lead_draft'
/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */
const METRIKA_ID = 112352796
/** Query-string UTM tags, forwarded to amoCRM with the lead. */ /** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> { function collectUtm(): Record<string, string> {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -59,6 +62,8 @@ export async function submitLead(form: LeadFormId, values: Omit<LeadInput, 'form
window.dataLayer = window.dataLayer ?? [] window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'fitness_lead_sent', form, configuration: values.configuration }) window.dataLayer.push({ event: 'fitness_lead_sent', form, configuration: values.configuration })
// leadId === 0 — ответ-обманка ханипота: сделки в amoCRM нет, конверсии тоже.
if (body.leadId !== 0) reachGoal('FORM_SUCCESS')
localStorage.removeItem(DRAFT_KEY) localStorage.removeItem(DRAFT_KEY)
return { ok: true } return { ok: true }
} catch (error) { } catch (error) {
@@ -80,6 +85,20 @@ function saveDraft(payload: LeadInput) {
} }
} }
/**
* Цель Метрики. `ym` объявляется синхронно инлайн-снипетом и до загрузки tag.js
* копит вызовы в очереди, так что ждать загрузки счётчика не нужно; опциональный
* вызов — страховка на случай блокировщика, вырезавшего снипет целиком.
*/
function reachGoal(goal: string) {
try {
window.ym?.(METRIKA_ID, 'reachGoal', goal)
} catch (error) {
// Аналитика не имеет права ломать отправку формы.
console.warn('Metrika reachGoal failed', error)
}
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */ /* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -153,5 +172,6 @@ export function reportCallClick(): void {
declare global { declare global {
interface Window { interface Window {
dataLayer?: Record<string, unknown>[] dataLayer?: Record<string, unknown>[]
ym?: (counterId: number, action: string, ...args: unknown[]) => void
} }
} }
+20
View File
@@ -9,6 +9,9 @@ import {
const ENDPOINT = '/api/leads/hotels' const ENDPOINT = '/api/leads/hotels'
const DRAFT_KEY = 'exo_hotel_lead_draft' const DRAFT_KEY = 'exo_hotel_lead_draft'
/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */
const METRIKA_ID = 112352796
/** Query-string UTM tags, forwarded to amoCRM with the lead. */ /** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> { function collectUtm(): Record<string, string> {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -62,6 +65,8 @@ export async function submitLead(
window.dataLayer = window.dataLayer ?? [] window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'hotel_lead_sent', form }) window.dataLayer.push({ event: 'hotel_lead_sent', form })
// leadId === 0 — ответ-обманка ханипота: сделки в amoCRM нет, конверсии тоже.
if (body.leadId !== 0) reachGoal('FORM_SUCCESS')
localStorage.removeItem(DRAFT_KEY) localStorage.removeItem(DRAFT_KEY)
return { ok: true } return { ok: true }
} catch (error) { } catch (error) {
@@ -83,6 +88,20 @@ function saveDraft(payload: LeadInput) {
} }
} }
/**
* Цель Метрики. `ym` объявляется синхронно инлайн-снипетом и до загрузки tag.js
* копит вызовы в очереди, так что ждать загрузки счётчика не нужно; опциональный
* вызов — страховка на случай блокировщика, вырезавшего снипет целиком.
*/
function reachGoal(goal: string) {
try {
window.ym?.(METRIKA_ID, 'reachGoal', goal)
} catch (error) {
// Аналитика не имеет права ломать отправку формы.
console.warn('Metrika reachGoal failed', error)
}
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */ /* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -156,5 +175,6 @@ export function reportCallClick(): void {
declare global { declare global {
interface Window { interface Window {
dataLayer?: Record<string, unknown>[] dataLayer?: Record<string, unknown>[]
ym?: (counterId: number, action: string, ...args: unknown[]) => void
} }
} }
+20
View File
@@ -9,6 +9,9 @@ import {
const ENDPOINT = '/api/leads/medical-centers-existing-physio' const ENDPOINT = '/api/leads/medical-centers-existing-physio'
const DRAFT_KEY = 'exo_medcenter_lead_draft' const DRAFT_KEY = 'exo_medcenter_lead_draft'
/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */
const METRIKA_ID = 112352796
const FALLBACK_ERROR = const FALLBACK_ERROR =
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.' 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.'
@@ -62,6 +65,8 @@ export async function submitLead(
window.dataLayer = window.dataLayer ?? [] window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'medcenter_lead_sent', form }) window.dataLayer.push({ event: 'medcenter_lead_sent', form })
// leadId === 0 — ответ-обманка ханипота: сделки в amoCRM нет, конверсии тоже.
if (body.leadId !== 0) reachGoal('FORM_SUCCESS')
localStorage.removeItem(DRAFT_KEY) localStorage.removeItem(DRAFT_KEY)
return { ok: true } return { ok: true }
} catch (error) { } catch (error) {
@@ -79,6 +84,20 @@ function saveDraft(payload: LeadInput) {
} }
} }
/**
* Цель Метрики. `ym` объявляется синхронно инлайн-снипетом и до загрузки tag.js
* копит вызовы в очереди, так что ждать загрузки счётчика не нужно; опциональный
* вызов — страховка на случай блокировщика, вырезавшего снипет целиком.
*/
function reachGoal(goal: string) {
try {
window.ym?.(METRIKA_ID, 'reachGoal', goal)
} catch (error) {
// Аналитика не имеет права ломать отправку формы.
console.warn('Metrika reachGoal failed', error)
}
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */ /* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -152,5 +171,6 @@ export function reportCallClick(): void {
declare global { declare global {
interface Window { interface Window {
dataLayer?: Record<string, unknown>[] dataLayer?: Record<string, unknown>[]
ym?: (counterId: number, action: string, ...args: unknown[]) => void
} }
} }
+20
View File
@@ -9,6 +9,9 @@ import {
const ENDPOINT = '/api/leads/medical-centers-no-physio' const ENDPOINT = '/api/leads/medical-centers-no-physio'
const DRAFT_KEY = 'exo_medcenter_no_physio_lead_draft' const DRAFT_KEY = 'exo_medcenter_no_physio_lead_draft'
/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */
const METRIKA_ID = 112352796
const FALLBACK_ERROR = const FALLBACK_ERROR =
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.' 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.'
@@ -62,6 +65,8 @@ export async function submitLead(
window.dataLayer = window.dataLayer ?? [] window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'medcenter_no_physio_lead_sent', form }) window.dataLayer.push({ event: 'medcenter_no_physio_lead_sent', form })
// leadId === 0 — ответ-обманка ханипота: сделки в amoCRM нет, конверсии тоже.
if (body.leadId !== 0) reachGoal('FORM_SUCCESS')
localStorage.removeItem(DRAFT_KEY) localStorage.removeItem(DRAFT_KEY)
return { ok: true } return { ok: true }
} catch (error) { } catch (error) {
@@ -79,6 +84,20 @@ function saveDraft(payload: LeadInput) {
} }
} }
/**
* Цель Метрики. `ym` объявляется синхронно инлайн-снипетом и до загрузки tag.js
* копит вызовы в очереди, так что ждать загрузки счётчика не нужно; опциональный
* вызов — страховка на случай блокировщика, вырезавшего снипет целиком.
*/
function reachGoal(goal: string) {
try {
window.ym?.(METRIKA_ID, 'reachGoal', goal)
} catch (error) {
// Аналитика не имеет права ломать отправку формы.
console.warn('Metrika reachGoal failed', error)
}
}
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */ /* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -152,5 +171,6 @@ export function reportCallClick(): void {
declare global { declare global {
interface Window { interface Window {
dataLayer?: Record<string, unknown>[] dataLayer?: Record<string, unknown>[]
ym?: (counterId: number, action: string, ...args: unknown[]) => void
} }
} }