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,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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user