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 { const params = new URLSearchParams(window.location.search) const utm: Record = {} 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 } export async function submitLead(form: LeadFormId, values: Omit): Promise { 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[] ym?: (counterId: number, action: string, ...args: unknown[]) => void _tmr?: Record[] } }