Files
exodevices/fitnes/src/lib/lead.ts
T
yuriy.pandClaude Opus 5 b32e7da506 Add the Top.Mail.Ru counter to all four landings with a lead goal
The counter (id 3795059) was only in fitnes and only counted pageViews.
Carry the snippet over to hotel, medcenterstart and medcenterphysio — the
same counter serves all four, as the Metrika one already does — and fire
goal 'form' from submitLead(), the single place both the request form and
the callback dialog pass through.

The goal sits inside the existing `leadId !== 0` guard: that response is
the honeypot decoy, so a bot submission must not register a conversion,
in Top.Mail.Ru no more than in Metrika.

The noscript pixel goes in the body next to the Metrika one, per 9af36cc;
the fitnes one, added in the head, moves down to join it.

Note that fitnes/index.html also carries a Prettier pass over the whole
file — the reflowed Metrika block is formatting only, no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:08:29 +06:00

199 lines
6.6 KiB
TypeScript

import {
utmKeys,
type CallClickInput,
type LeadFormId,
type LeadInput,
type LeadResponse,
} from '../../shared/lead'
const ENDPOINT = '/api/leads/fitness-centers'
const DRAFT_KEY = 'exo_fitness_lead_draft'
/** Тот же счётчик, что и в инлайн-снипете index.html — один на все четыре лендинга. */
const METRIKA_ID = 112352796
/** Счётчик Top.Mail.Ru — тот же, что в инлайн-снипете index.html. */
const TMR_ID = '3795059'
/** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> {
const params = new URLSearchParams(window.location.search)
const utm: Record<string, string> = {}
for (const key of utmKeys) {
const value = params.get(key)
if (value) utm[key] = value
}
return utm
}
export interface SubmitResult {
ok: boolean
error?: string
fields?: Record<string, string>
}
export async function submitLead(form: LeadFormId, values: Omit<LeadInput, 'form' | 'utm' | 'page' | 'referrer'>): Promise<SubmitResult> {
const payload: LeadInput = {
...values,
form,
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const body = (await response.json().catch(() => null)) as LeadResponse | null
if (!response.ok || !body?.ok) {
// The page must never lose a lead just because the CRM is down.
saveDraft(payload)
return {
ok: false,
error:
body && !body.ok
? body.error
: 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.',
fields: body && !body.ok ? body.fields : undefined,
}
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'fitness_lead_sent', form, configuration: values.configuration })
// leadId === 0 — ответ-обманка ханипота: сделки в amoCRM нет, конверсии тоже.
if (body.leadId !== 0) {
reachGoal('FORM_SUCCESS')
tmrReachGoal('form')
}
localStorage.removeItem(DRAFT_KEY)
return { ok: true }
} catch (error) {
saveDraft(payload)
console.warn('Lead endpoint error', error, payload)
return {
ok: false,
error:
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.',
}
}
}
function saveDraft(payload: LeadInput) {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload))
} catch {
// Private-mode browsers throw on write; the visible error message is enough.
}
}
/**
* Цель Метрики. `ym` объявляется синхронно инлайн-снипетом и до загрузки tag.js
* копит вызовы в очереди, так что ждать загрузки счётчика не нужно; опциональный
* вызов — страховка на случай блокировщика, вырезавшего снипет целиком.
*/
function reachGoal(goal: string) {
try {
window.ym?.(METRIKA_ID, 'reachGoal', goal)
} catch (error) {
// Аналитика не имеет права ломать отправку формы.
console.warn('Metrika reachGoal failed', error)
}
}
/**
* Цель Top.Mail.Ru. `_tmr` — обычная очередь-массив: пуши, сделанные до загрузки
* code.js, счётчик разбирает сам, поэтому ждать загрузку не нужно.
*/
function tmrReachGoal(goal: string) {
try {
window._tmr = window._tmr ?? []
window._tmr.push({ type: 'reachGoal', id: TMR_ID, goal })
} catch (error) {
// Аналитика не имеет права ломать отправку формы.
console.warn('Top.Mail.Ru reachGoal failed', error)
}
}
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
const CALL_ENDPOINT = `${ENDPOINT}/call`
const CALL_SENT_KEY = 'exo_fitness_call_click_sent'
/** Session-scoped so a visitor who redials is still one lead, not three. */
function callAlreadyReported(): boolean {
try {
return sessionStorage.getItem(CALL_SENT_KEY) === '1'
} catch {
// Private-mode browsers throw on access; one extra lead beats none.
return false
}
}
function markCallReported() {
try {
sessionStorage.setItem(CALL_SENT_KEY, '1')
} catch {
// See above.
}
}
/**
* Registers a tap on the phone button as an amoCRM lead.
*
* Fire-and-forget by necessity: the same click hands the page to `tel:`, so the
* request has to outlive the document. `sendBeacon` queues it in the browser
* process itself; `keepalive` is the fallback for the few browsers without it.
* Nothing here may throw or await — the dialler must open instantly.
*/
export function reportCallClick(): void {
if (callAlreadyReported()) return
markCallReported()
const payload: CallClickInput = {
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
// A Blob, not a string: it is what gives the beacon its JSON content type.
const beacon = new Blob([JSON.stringify(payload)], { type: 'application/json' })
// sendBeacon returns false when the browser refuses to queue the payload, and
// a few throw instead of returning anything. Either way, fetch picks it up.
let queued = false
try {
queued = navigator.sendBeacon?.(CALL_ENDPOINT, beacon) ?? false
} catch {
queued = false
}
if (!queued) {
void fetch(CALL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch((error: unknown) => {
console.warn('Call click endpoint error', error)
})
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'fitness_call_click' })
}
declare global {
interface Window {
dataLayer?: Record<string, unknown>[]
ym?: (counterId: number, action: string, ...args: unknown[]) => void
_tmr?: Record<string, unknown>[]
}
}