Convert fitness landing to React + Tailwind v4 with amoCRM lead capture
Port the single-file landing (2.6 MB of inlined CSS, JS and base64 images, kept as fitnes/legacy/index.html) to Vite + React 19 + TypeScript, with an Express API that files every form submission into amoCRM pipeline 10980758. - Extract the 14 embedded images to src/assets/images and public/ - Rebuild the design system as Tailwind v4 @theme tokens; the stock palette and breakpoints are cleared so only the EXO scale is reachable from utilities - Split the page into 15 components; all copy moves to src/data - Lead endpoint: find-or-create the contact (Russian phone spellings compared on the last 10 digits), create the lead in the pipeline's first stage, map the fields the account already has and put the rest in a note. If amoCRM is unreachable the payload is logged and kept in localStorage rather than lost. - Add a callback modal as a second entry point, tagged separately in the pipeline - Self-host Inter Variable so the layout's 760/850/900 weights render as real weights instead of snapping to bold Fidelity was checked by comparing section offsets and heights against the original at 375/480/640/900/1120/1440 px; every section and the total page height matched exactly. Loading Inter deliberately changes text metrics, so the byte-exact comparison holds against the pre-font build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead'
|
||||
|
||||
const ENDPOINT = '/api/leads/fitness-centers'
|
||||
const DRAFT_KEY = 'exo_fitness_lead_draft'
|
||||
|
||||
/** 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 939 717-80-80.',
|
||||
fields: body && !body.ok ? body.fields : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
window.dataLayer = window.dataLayer ?? []
|
||||
window.dataLayer.push({ event: 'fitness_lead_sent', form, configuration: values.configuration })
|
||||
localStorage.removeItem(DRAFT_KEY)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
saveDraft(payload)
|
||||
console.warn('Lead endpoint error', error, payload)
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: Record<string, unknown>[]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user