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>
173 lines
5.4 KiB
TypeScript
173 lines
5.4 KiB
TypeScript
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
|
import { clubFormats } from '../data/content'
|
|
import { submitLead } from '../lib/lead'
|
|
import { cx } from '../lib/cx'
|
|
import { Button } from './Button'
|
|
import { ConsentCheckbox, Honeypot, SelectField, TextField, TextareaField } from './FormField'
|
|
import { ArrowRightIcon } from './Icons'
|
|
|
|
const EMPTY = {
|
|
name: '',
|
|
company: '',
|
|
phone: '',
|
|
email: '',
|
|
city: '',
|
|
club_format: clubFormats[0],
|
|
comment: '',
|
|
}
|
|
|
|
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
|
|
|
|
export function LeadForm({ configuration, commentPrefill }: { configuration: string; commentPrefill: string }) {
|
|
const formRef = useRef<HTMLFormElement>(null)
|
|
const [values, setValues] = useState(EMPTY)
|
|
const [consent, setConsent] = useState(false)
|
|
const [honeypot, setHoneypot] = useState('')
|
|
const [pending, setPending] = useState(false)
|
|
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
|
|
|
|
// The constructor's "получить эту конфигурацию" CTA writes into the comment.
|
|
useEffect(() => {
|
|
if (commentPrefill) setValues((current) => ({ ...current, comment: commentPrefill }))
|
|
}, [commentPrefill])
|
|
|
|
const set = (key: keyof typeof EMPTY) => (event: { target: { value: string } }) =>
|
|
setValues((current) => ({ ...current, [key]: event.target.value }))
|
|
|
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
const form = formRef.current
|
|
if (!form) return
|
|
|
|
if (!form.checkValidity()) {
|
|
form.reportValidity()
|
|
setStatus({ text: 'Проверьте обязательные поля и согласие.', tone: 'error' })
|
|
return
|
|
}
|
|
|
|
if (honeypot) return
|
|
|
|
setPending(true)
|
|
setStatus({ text: '', tone: 'idle' })
|
|
|
|
const result = await submitLead('request', { ...values, configuration, website: honeypot })
|
|
|
|
if (result.ok) {
|
|
setValues(EMPTY)
|
|
setConsent(false)
|
|
setStatus({ text: 'Спасибо. Заявка отправлена — специалист свяжется с вами.', tone: 'success' })
|
|
} else {
|
|
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
|
|
}
|
|
|
|
setPending(false)
|
|
}
|
|
|
|
return (
|
|
<form
|
|
ref={formRef}
|
|
id="leadForm"
|
|
noValidate
|
|
onSubmit={onSubmit}
|
|
className="bg-white p-[clamp(24px,4vw,42px)] text-ink"
|
|
>
|
|
<h3 className="mb-[8px] text-[28px]">Получить конфигурацию</h3>
|
|
<p className="mb-[23px] text-[13px] text-muted">
|
|
Выбранный в конструкторе состав автоматически добавится в заявку.
|
|
</p>
|
|
|
|
<div className="grid gap-[12px] sm:grid-cols-[1fr_1fr]">
|
|
<TextField
|
|
id="name"
|
|
label="Имя *"
|
|
name="name"
|
|
autoComplete="name"
|
|
placeholder="Ваше имя"
|
|
required
|
|
value={values.name}
|
|
onChange={set('name')}
|
|
/>
|
|
<TextField
|
|
id="company"
|
|
label="Фитнес-клуб / сеть *"
|
|
name="company"
|
|
autoComplete="organization"
|
|
placeholder="Название клуба"
|
|
required
|
|
value={values.company}
|
|
onChange={set('company')}
|
|
/>
|
|
<TextField
|
|
id="phone"
|
|
label="Телефон *"
|
|
name="phone"
|
|
autoComplete="tel"
|
|
inputMode="tel"
|
|
placeholder="+7 999 000-00-00"
|
|
required
|
|
value={values.phone}
|
|
onChange={set('phone')}
|
|
/>
|
|
<TextField
|
|
id="email"
|
|
label="Email *"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
placeholder="name@company.ru"
|
|
required
|
|
value={values.email}
|
|
onChange={set('email')}
|
|
/>
|
|
<TextField
|
|
id="city"
|
|
label="Город"
|
|
name="city"
|
|
autoComplete="address-level2"
|
|
placeholder="Город проекта"
|
|
value={values.city}
|
|
onChange={set('city')}
|
|
/>
|
|
<SelectField
|
|
id="clubFormat"
|
|
label="Формат"
|
|
name="club_format"
|
|
options={clubFormats}
|
|
value={values.club_format}
|
|
onChange={set('club_format')}
|
|
/>
|
|
<TextareaField
|
|
id="comment"
|
|
label="Комментарий"
|
|
name="comment"
|
|
full
|
|
placeholder="Площадь, поток, задачи клуба, наличие медицинской лицензии"
|
|
value={values.comment}
|
|
onChange={set('comment')}
|
|
/>
|
|
</div>
|
|
|
|
<input name="configuration" type="hidden" value={configuration} readOnly />
|
|
<Honeypot value={honeypot} onChange={setHoneypot} />
|
|
<ConsentCheckbox checked={consent} onChange={setConsent} />
|
|
|
|
<Button type="submit" className="w-full" disabled={pending}>
|
|
{pending ? 'Отправляем…' : 'Получить предложение'}
|
|
{pending ? null : <ArrowRightIcon />}
|
|
</Button>
|
|
|
|
<div
|
|
aria-live="polite"
|
|
className={cx(
|
|
'mt-[11px] min-h-[20px] text-[12px]',
|
|
status.tone === 'success' && 'text-[#087F73]',
|
|
status.tone === 'error' && 'text-[#A14D12]',
|
|
status.tone === 'idle' && 'text-muted',
|
|
)}
|
|
>
|
|
{status.text}
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|