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:
Yuriy Panov
2026-08-28 13:21:48 +06:00
co-authored by Claude Opus 5
commit 60de4ef27c
69 changed files with 8625 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from 'react'
/** Freezes background scrolling while a modal is open. */
export function useBodyLock(locked: boolean) {
useEffect(() => {
if (!locked) return
document.body.classList.add('is-locked')
return () => document.body.classList.remove('is-locked')
}, [locked])
}
+106
View File
@@ -0,0 +1,106 @@
import { useCallback, useMemo, useState } from 'react'
import { deviceIds, devices, presets, type DeviceId, type PresetId } from '../data/devices'
const MAX_IN_THREE_MODE = 3
export type ConstructorState = ReturnType<typeof useConstructor>
/**
* The 3-vs-5 device picker. Selection order is preserved (the original used a
* Set and relied on its insertion order), because it drives the pill list and
* decides which device is dropped when a fourth one is picked.
*/
export function useConstructor() {
const [mode, setModeState] = useState<3 | 5>(3)
const [selected, setSelected] = useState<DeviceId[]>(presets.recovery.ids)
const [preset, setPreset] = useState<PresetId | ''>('recovery')
const toggleDevice = useCallback(
(id: DeviceId) => {
if (mode === 5) return
setPreset('')
setSelected((current) => {
if (current.includes(id)) return current.filter((item) => item !== id)
const next = current.length >= MAX_IN_THREE_MODE ? current.slice(1) : current
return [...next, id]
})
},
[mode],
)
const setMode = useCallback((next: 3 | 5) => {
setModeState(next)
if (next === 5) {
setSelected(deviceIds)
setPreset('')
} else {
setSelected(presets.recovery.ids)
setPreset('recovery')
}
}, [])
const applyPreset = useCallback((id: PresetId) => {
setModeState(3)
setPreset(id)
setSelected(presets[id].ids)
}, [])
const reset = useCallback(() => {
setModeState(3)
setPreset('recovery')
setSelected(presets.recovery.ids)
}, [])
return useMemo(() => {
const names = selected.map((id) => devices[id].name)
const configString = names.join(', ')
const matchedPreset = Object.entries(presets).find(
([, value]) => value.ids.length === selected.length && value.ids.every((id) => selected.includes(id)),
)
const benefits: string[] = []
for (const id of selected) {
for (const benefit of devices[id].benefits) {
if (!benefits.includes(benefit)) benefits.push(benefit)
}
}
return {
mode,
selected,
preset,
names,
toggleDevice,
setMode,
applyPreset,
reset,
benefits: benefits.slice(0, 4),
isValid: mode === 5 || selected.length === MAX_IN_THREE_MODE,
hint:
mode === 5
? 'Полная линейка выбрана'
: selected.length === MAX_IN_THREE_MODE
? 'Конфигурация собрана'
: `Выберите ещё ${MAX_IN_THREE_MODE - selected.length}`,
tag: mode === 5 ? 'Полная EXO-платформа' : 'Конструктор на 3 аппарата',
number: mode === 5 ? '05' : String(selected.length).padStart(2, '0'),
title:
mode === 5
? 'Полная зона — 5 аппаратов'
: matchedPreset
? `${matchedPreset[1].title} — 3 аппарата`
: 'Персональный микс — 3 аппарата',
subtitle:
mode === 5
? 'Полный маршрут: active medical-core, premium recovery, потоковые и автономные процедуры.'
: matchedPreset
? matchedPreset[1].subtitle
: 'Состав собран под выбранные вами приоритеты. Итоговая логика уточняется после аудита клуба.',
/** Hidden form field sent to amoCRM. */
configurationValue: `${mode} аппарата: ${configString}`,
/** Pre-filled into the comment box by the "get this configuration" CTA. */
requestComment: `Интересует конфигурация: ${mode} аппарата — ${configString}`,
}
}, [mode, selected, preset, toggleDevice, setMode, applyPreset, reset])
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect } from 'react'
export function useEscapeKey(active: boolean, onEscape: () => void) {
useEffect(() => {
if (!active) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onEscape()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [active, onEscape])
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from 'react'
/**
* Scroll-in animation, matching the original page: fires once at 12% visibility
* and then stops observing. The `.reveal` / `.is-visible` pair lives in CSS so
* that `prefers-reduced-motion` can neutralise it in one place.
*/
export function useReveal<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const element = ref.current
if (!element || visible) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
setVisible(true)
observer.unobserve(entry.target)
}
},
{ threshold: 0.12 },
)
observer.observe(element)
return () => observer.disconnect()
}, [visible])
return { ref, visible, revealClass: visible ? 'reveal is-visible' : 'reveal' }
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
/**
* Two scroll-derived flags from the original page:
* - `scrolled` — header gets its blurred background past 35px
* - `showMobileCta` — sticky CTA appears in the middle of the page only
*/
export function useScrollState() {
const [state, setState] = useState({ scrolled: false, showMobileCta: false })
useEffect(() => {
const onScroll = () => {
const y = window.scrollY
setState({
scrolled: y > 35,
showMobileCta: y > window.innerHeight * 0.75 && y < document.body.scrollHeight - window.innerHeight * 1.25,
})
}
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
return state
}