From 8af2e463b35b78b44d3825aba535336bb5a21f1b Mon Sep 17 00:00:00 2001 From: Yuriy Panov Date: Wed, 9 Sep 2026 01:30:47 +0600 Subject: [PATCH] analytics: count a successful form as the FORM_SUCCESS Metrika goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/README.md | 10 ++++++---- fitnes/src/lib/lead.ts | 20 ++++++++++++++++++++ hotel/src/lib/lead.ts | 20 ++++++++++++++++++++ medcenterphysio/src/lib/lead.ts | 20 ++++++++++++++++++++ medcenterstart/src/lib/lead.ts | 20 ++++++++++++++++++++ 5 files changed, 86 insertions(+), 4 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index 0c08bc7..b5e83b8 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 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. -- **Still open before launch** (not deployment blockers): conversions are not wired to - Metrika goals — `src/lib/lead.ts` pushes a plain custom `dataLayer` event, which - Metrika's `ecommerce: 'dataLayer'` does not read, so a JS goal or an explicit - `ym(..., 'reachGoal', ...)` call is still needed; and the forms show implicit 152-ФЗ +- **The `FORM_SUCCESS` goal has to exist in the Metrika UI.** `src/lib/lead.ts` fires + `ym(112352796, 'reachGoal', 'FORM_SUCCESS')` on every successful form submission, but + the goal itself is not part of the code: create it in the counter's settings as + «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. diff --git a/fitnes/src/lib/lead.ts b/fitnes/src/lib/lead.ts index 28073da..b9bbb41 100644 --- a/fitnes/src/lib/lead.ts +++ b/fitnes/src/lib/lead.ts @@ -9,6 +9,9 @@ import { const ENDPOINT = '/api/leads/fitness-centers' const DRAFT_KEY = 'exo_fitness_lead_draft' +/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */ +const METRIKA_ID = 112352796 + /** Query-string UTM tags, forwarded to amoCRM with the lead. */ function collectUtm(): Record { const params = new URLSearchParams(window.location.search) @@ -59,6 +62,8 @@ export async function submitLead(form: LeadFormId, values: Omit[] + ym?: (counterId: number, action: string, ...args: unknown[]) => void } } diff --git a/hotel/src/lib/lead.ts b/hotel/src/lib/lead.ts index 2c7bf7f..cccd595 100644 --- a/hotel/src/lib/lead.ts +++ b/hotel/src/lib/lead.ts @@ -9,6 +9,9 @@ import { const ENDPOINT = '/api/leads/hotels' const DRAFT_KEY = 'exo_hotel_lead_draft' +/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */ +const METRIKA_ID = 112352796 + /** Query-string UTM tags, forwarded to amoCRM with the lead. */ function collectUtm(): Record { const params = new URLSearchParams(window.location.search) @@ -62,6 +65,8 @@ export async function submitLead( window.dataLayer = window.dataLayer ?? [] window.dataLayer.push({ event: 'hotel_lead_sent', form }) + // leadId === 0 — ответ-обманка ханипота: сделки в amoCRM нет, конверсии тоже. + if (body.leadId !== 0) reachGoal('FORM_SUCCESS') localStorage.removeItem(DRAFT_KEY) return { ok: true } } 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 { interface Window { dataLayer?: Record[] + ym?: (counterId: number, action: string, ...args: unknown[]) => void } } diff --git a/medcenterphysio/src/lib/lead.ts b/medcenterphysio/src/lib/lead.ts index 3fb6aec..340fc76 100644 --- a/medcenterphysio/src/lib/lead.ts +++ b/medcenterphysio/src/lib/lead.ts @@ -9,6 +9,9 @@ import { const ENDPOINT = '/api/leads/medical-centers-existing-physio' const DRAFT_KEY = 'exo_medcenter_lead_draft' +/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */ +const METRIKA_ID = 112352796 + const FALLBACK_ERROR = 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.' @@ -62,6 +65,8 @@ export async function submitLead( window.dataLayer = window.dataLayer ?? [] window.dataLayer.push({ event: 'medcenter_lead_sent', form }) + // leadId === 0 — ответ-обманка ханипота: сделки в amoCRM нет, конверсии тоже. + if (body.leadId !== 0) reachGoal('FORM_SUCCESS') localStorage.removeItem(DRAFT_KEY) return { ok: true } } 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 { interface Window { dataLayer?: Record[] + ym?: (counterId: number, action: string, ...args: unknown[]) => void } } diff --git a/medcenterstart/src/lib/lead.ts b/medcenterstart/src/lib/lead.ts index 461d284..bc96a09 100644 --- a/medcenterstart/src/lib/lead.ts +++ b/medcenterstart/src/lib/lead.ts @@ -9,6 +9,9 @@ import { const ENDPOINT = '/api/leads/medical-centers-no-physio' const DRAFT_KEY = 'exo_medcenter_no_physio_lead_draft' +/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */ +const METRIKA_ID = 112352796 + const FALLBACK_ERROR = 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.' @@ -62,6 +65,8 @@ export async function submitLead( window.dataLayer = window.dataLayer ?? [] window.dataLayer.push({ event: 'medcenter_no_physio_lead_sent', form }) + // leadId === 0 — ответ-обманка ханипота: сделки в amoCRM нет, конверсии тоже. + if (body.leadId !== 0) reachGoal('FORM_SUCCESS') localStorage.removeItem(DRAFT_KEY) return { ok: true } } 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 { interface Window { dataLayer?: Record[] + ym?: (counterId: number, action: string, ...args: unknown[]) => void } }