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>
199 lines
6.6 KiB
TypeScript
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>[]
|
|
}
|
|
}
|