medcenterstart: apply the v2 design and add the callback dialog
v2 differs from legacy/new only by the callback dialog; the calculator and the new phone number were already in the React landing. The header «Звонок» button opens the «Перезвоним вам» dialog from the fitness landing (name, phone, consent). Unlike fitness, the phone field keeps this landing's +7 mask and refuses fewer than 11 digits, as the mockup does. The server accepts form 'callback', names the deal «Обратный звонок — <имя>» and tags it «обратный звонок». The phone link inside the dialog and in the section sheet still reports a call click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3fdc52f707
commit
160f615390
@@ -65,7 +65,7 @@ npm run amo:check
|
|||||||
email дописывается в карточку.
|
email дописывается в карточку.
|
||||||
2. Если контакта нет — создаётся новый с именем, телефоном и email.
|
2. Если контакта нет — создаётся новый с именем, телефоном и email.
|
||||||
3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**,
|
3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**,
|
||||||
с тегами `AMO_LEAD_TAGS` + тег `заявка с сайта`.
|
с тегами `AMO_LEAD_TAGS` + тег формы (`заявка с сайта` / `обратный звонок`).
|
||||||
4. Данные, для которых в аккаунте есть подходящее поле (компания, направления
|
4. Данные, для которых в аккаунте есть подходящее поле (компания, направления
|
||||||
центра, `utm_*` и т.д.), пишутся в поля; всё остальное — в примечание к
|
центра, `utm_*` и т.д.), пишутся в поля; всё остальное — в примечание к
|
||||||
сделке. Ничего настраивать в amoCRM заранее не нужно.
|
сделке. Ничего настраивать в amoCRM заранее не нужно.
|
||||||
@@ -73,13 +73,20 @@ npm run amo:check
|
|||||||
Эндпоинт — `POST /api/leads/medical-centers-no-physio` (адрес взят из исходного
|
Эндпоинт — `POST /api/leads/medical-centers-no-physio` (адрес взят из исходного
|
||||||
лендинга).
|
лендинга).
|
||||||
|
|
||||||
|
Форм две. Основная — блок «Следующий шаг» внизу страницы: обязательны имя и
|
||||||
|
телефон; сделка называется `Физиотерапия — <центр или имя>`. Вторая — модальное
|
||||||
|
окно «Перезвоним вам», его открывает кнопка «Звонок» в шапке (то же окно, что на
|
||||||
|
фитнес-лендинге): имя, телефон и согласие на обработку данных. Сделка называется
|
||||||
|
`Обратный звонок — <имя>`, в поле или примечании «Форма» — «Быстрый обратный
|
||||||
|
звонок».
|
||||||
|
|
||||||
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
|
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
|
||||||
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
|
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
|
||||||
видит телефон для связи.
|
видит телефон для связи.
|
||||||
|
|
||||||
### 4. Что происходит при клике на телефон
|
### 4. Что происходит при клике на телефон
|
||||||
|
|
||||||
Кнопка звонка (в шапке и в мобильном меню разделов) помимо набора номера отправляет
|
Ссылка с номером (в окне «Перезвоним вам» и в мобильном меню разделов) помимо набора номера отправляет
|
||||||
`POST /api/leads/medical-centers-no-physio/call` — маячком `navigator.sendBeacon`, чтобы
|
`POST /api/leads/medical-centers-no-physio/call` — маячком `navigator.sendBeacon`, чтобы
|
||||||
запрос пережил переход браузера на `tel:`.
|
запрос пережил переход браузера на `tel:`.
|
||||||
|
|
||||||
@@ -113,15 +120,19 @@ index.html точка входа Vite (meta, Open Graph, JSON-LD)
|
|||||||
src/
|
src/
|
||||||
components/ секции лендинга и UI-примитивы
|
components/ секции лендинга и UI-примитивы
|
||||||
data/content.ts весь текстовый контент страницы
|
data/content.ts весь текстовый контент страницы
|
||||||
hooks/ появление секций при скролле, состояние шапки
|
hooks/ появление секций при скролле, состояние шапки, блокировка
|
||||||
|
прокрутки и Escape для модального окна
|
||||||
lib/ отправка заявки, маска телефона
|
lib/ отправка заявки, маска телефона
|
||||||
assets/images/ 12 изображений, извлечённых из исходного HTML
|
assets/images/ 12 изображений, извлечённых из исходного HTML
|
||||||
index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
|
index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
|
||||||
shared/lead.ts схема заявки (zod), общая для клиента и сервера
|
shared/lead.ts схема заявки (zod), общая для клиента и сервера
|
||||||
server/src/ Express API + клиент amoCRM
|
server/src/ Express API + клиент amoCRM
|
||||||
scripts/amo-check.ts диагностика подключения к amoCRM
|
scripts/amo-check.ts диагностика подключения к amoCRM
|
||||||
legacy/index.html исходный однофайловый лендинг (визуальный эталон)
|
legacy/index.html исходный однофайловый лендинг (первая версия дизайна)
|
||||||
legacy/README.md исходная инструкция к однофайловой версии
|
legacy/README.md исходная инструкция к однофайловой версии
|
||||||
|
legacy/new/ макет с калькулятором
|
||||||
|
legacy/v2/ макет v2 — текущий визуальный эталон (legacy/new + окно
|
||||||
|
обратного звонка)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Дизайн-система
|
### Дизайн-система
|
||||||
@@ -191,6 +202,11 @@ honeypot-поля, которой в оригинале нет.
|
|||||||
скрыта утилитой `honeypot`, а не `display:none`, иначе поле перестанет быть
|
скрыта утилитой `honeypot`, а не `display:none`, иначе поле перестанет быть
|
||||||
фокусируемым и ловушка не сработает.
|
фокусируемым и ловушка не сработает.
|
||||||
|
|
||||||
|
* **Окно «Перезвоним вам»** из макета v2 взято с фитнес-лендинга (светлые поля,
|
||||||
|
согласие отдельным чекбоксом, без надзаголовка «Обратный звонок»), а не
|
||||||
|
вёрстка `.exo-callback` из макета. Тексты окна и маска телефона — из макета:
|
||||||
|
без 11 цифр номера форма не отправляется, как и основная.
|
||||||
|
|
||||||
### Что воспроизведено как есть
|
### Что воспроизведено как есть
|
||||||
|
|
||||||
* Блок `.system-bottom` в секции «Готовое направление» сохраняет верхнюю
|
* Блок `.system-bottom` в секции «Готовое направление» сохраняет верхнюю
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
|
|||||||
comment: payload.comment,
|
comment: payload.comment,
|
||||||
page: payload.page,
|
page: payload.page,
|
||||||
referrer: payload.referrer,
|
referrer: payload.referrer,
|
||||||
form: 'Заявка на запуск физиотерапии с нуля',
|
form: payload.form === 'callback' ? 'Быстрый обратный звонок' : 'Заявка на запуск физиотерапии с нуля',
|
||||||
...payload.utm,
|
...payload.utm,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,8 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
|
|||||||
|
|
||||||
/** Lead title shown in the pipeline. */
|
/** Lead title shown in the pipeline. */
|
||||||
export function buildLeadName(payload: LeadPayload): string {
|
export function buildLeadName(payload: LeadPayload): string {
|
||||||
return `Физиотерапия — ${payload.company ?? payload.name}`
|
const who = payload.company ?? payload.name
|
||||||
|
return payload.form === 'callback' ? `Обратный звонок — ${who}` : `Физиотерапия — ${who}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export class LeadService {
|
|||||||
name: buildLeadName(payload),
|
name: buildLeadName(payload),
|
||||||
contactId,
|
contactId,
|
||||||
fields,
|
fields,
|
||||||
tags: ['заявка с сайта'],
|
tags: [payload.form === 'callback' ? 'обратный звонок' : 'заявка с сайта'],
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. Everything the account has no field for lands in a readable note.
|
// 3. Everything the account has no field for lands in a readable note.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
|
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
|
||||||
export const leadFormIds = ['request'] as const
|
export const leadFormIds = ['request', 'callback'] as const
|
||||||
export type LeadFormId = (typeof leadFormIds)[number]
|
export type LeadFormId = (typeof leadFormIds)[number]
|
||||||
|
|
||||||
export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
|
export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { CallbackModal } from './components/CallbackModal'
|
||||||
import { EquipmentSection } from './components/EquipmentSection'
|
import { EquipmentSection } from './components/EquipmentSection'
|
||||||
import { Hero } from './components/Hero'
|
import { Hero } from './components/Hero'
|
||||||
import { LaunchSection } from './components/LaunchSection'
|
import { LaunchSection } from './components/LaunchSection'
|
||||||
@@ -13,9 +15,13 @@ import { ThumbBar } from './components/ThumbBar'
|
|||||||
import { WhyExoSection } from './components/WhyExoSection'
|
import { WhyExoSection } from './components/WhyExoSection'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [callbackOpen, setCallbackOpen] = useState(false)
|
||||||
|
const openCallback = useCallback(() => setCallbackOpen(true), [])
|
||||||
|
const closeCallback = useCallback(() => setCallbackOpen(false), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader />
|
<SiteHeader onCallbackClick={openCallback} />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
@@ -33,6 +39,7 @@ export default function App() {
|
|||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
|
|
||||||
<ThumbBar />
|
<ThumbBar />
|
||||||
|
<CallbackModal open={callbackOpen} onClose={closeCallback} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
|
import { contacts } from '../data/content'
|
||||||
|
import { useBodyLock } from '../hooks/useBodyLock'
|
||||||
|
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { reportCallClick, submitLead } from '../lib/lead'
|
||||||
|
import { formatRuPhone, isCompleteRuPhone } from '../lib/phone'
|
||||||
|
import { Button } from './Button'
|
||||||
|
import { ConsentCheckbox, Honeypot, TextField } from './FormField'
|
||||||
|
import { ArrowRightIcon } from './Icons'
|
||||||
|
|
||||||
|
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact second entry point: name + phone only. Leads land in the same
|
||||||
|
* amoCRM pipeline, tagged so they can be told apart from the full request.
|
||||||
|
* Same dialog as the fitness landing's; the phone mask is this landing's own.
|
||||||
|
*/
|
||||||
|
export function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
const firstFieldRef = useRef<HTMLInputElement>(null)
|
||||||
|
const phoneRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
|
const [consent, setConsent] = useState(false)
|
||||||
|
const [honeypot, setHoneypot] = useState('')
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
|
||||||
|
|
||||||
|
const close = useCallback(() => onClose(), [onClose])
|
||||||
|
useBodyLock(open)
|
||||||
|
useEscapeKey(open, close)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) firstFieldRef.current?.focus()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
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 (!isCompleteRuPhone(phone)) {
|
||||||
|
setStatus({ text: 'Укажите полный номер телефона.', tone: 'error' })
|
||||||
|
phoneRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (honeypot) return
|
||||||
|
|
||||||
|
setPending(true)
|
||||||
|
setStatus({ text: '', tone: 'idle' })
|
||||||
|
|
||||||
|
const result = await submitLead('callback', { name, phone, website: honeypot })
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setName('')
|
||||||
|
setPhone('')
|
||||||
|
setConsent(false)
|
||||||
|
setStatus({ text: 'Спасибо. Мы перезвоним в ближайшее рабочее время.', tone: 'success' })
|
||||||
|
} else {
|
||||||
|
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="callbackTitle"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) close()
|
||||||
|
}}
|
||||||
|
className="fixed inset-0 z-100 flex items-center justify-center bg-[#020D16]/[0.92] p-[18px] backdrop-blur-[10px]"
|
||||||
|
>
|
||||||
|
<div className="relative max-h-[calc(100svh_-_36px)] w-full max-w-[420px] overflow-y-auto rounded-[28px] bg-white p-[clamp(24px,4vw,34px)] text-ink shadow-deep">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
onClick={close}
|
||||||
|
className="absolute top-[14px] right-[14px] size-[38px] cursor-pointer rounded-full border border-navy/15 text-[22px] text-muted hover:border-teal hover:text-navy"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 id="callbackTitle" className="mb-[8px] text-[26px]">
|
||||||
|
Перезвоним вам
|
||||||
|
</h3>
|
||||||
|
<p className="mb-[20px] text-[13px] text-muted">
|
||||||
|
Оставьте имя и телефон — наш специалист свяжется с вами и ответит на вопросы. Или позвоните сами:{' '}
|
||||||
|
<a
|
||||||
|
className="font-extrabold text-navy underline underline-offset-[3px]"
|
||||||
|
href={contacts.phoneHref}
|
||||||
|
onClick={reportCallClick}
|
||||||
|
>
|
||||||
|
{contacts.phone}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form ref={formRef} noValidate onSubmit={onSubmit}>
|
||||||
|
<div className="grid gap-[12px]">
|
||||||
|
<TextField
|
||||||
|
ref={firstFieldRef}
|
||||||
|
tone="light"
|
||||||
|
id="callbackName"
|
||||||
|
label="Имя *"
|
||||||
|
name="name"
|
||||||
|
autoComplete="name"
|
||||||
|
placeholder="Ваше имя"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
ref={phoneRef}
|
||||||
|
tone="light"
|
||||||
|
id="callbackPhone"
|
||||||
|
label="Телефон *"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
placeholder="+7 999 000-00-00"
|
||||||
|
required
|
||||||
|
value={phone}
|
||||||
|
onChange={(event) => setPhone(formatRuPhone(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Honeypot id="callbackWebsite" value={honeypot} onChange={setHoneypot} />
|
||||||
|
<ConsentCheckbox checked={consent} onChange={setConsent} />
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={pending} aria-busy={pending}>
|
||||||
|
<span>{pending ? 'Отправляем…' : 'Жду звонка'}</span>
|
||||||
|
{pending ? null : <ArrowRightIcon />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,44 @@
|
|||||||
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
|
|
||||||
/** Glass controls on the navy CTA panel. */
|
/**
|
||||||
const controlClass =
|
* `glass` — controls on the navy CTA panel; `light` — on the white callback
|
||||||
'w-full rounded-[14px] border border-white/[0.18] bg-white/[0.08] text-white outline-none ' +
|
* dialog, the same fields as the fitness landing's.
|
||||||
'transition-[border-color,background-color,box-shadow] duration-200 placeholder:text-white/[0.42] ' +
|
*/
|
||||||
'focus:border-teal focus:bg-white/[0.12] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.12)]'
|
type Tone = 'glass' | 'light'
|
||||||
|
|
||||||
function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; children: ReactNode }) {
|
const controlClass: Record<Tone, string> = {
|
||||||
|
glass:
|
||||||
|
'w-full rounded-[14px] border border-white/[0.18] bg-white/[0.08] px-[15px] text-white outline-none ' +
|
||||||
|
'transition-[border-color,background-color,box-shadow] duration-200 placeholder:text-white/[0.42] ' +
|
||||||
|
'focus:border-teal focus:bg-white/[0.12] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.12)]',
|
||||||
|
light:
|
||||||
|
'w-full rounded-[15px] border border-navy/15 bg-field px-[15px] py-[14px] text-ink outline-none ' +
|
||||||
|
'transition-[border-color,box-shadow,background-color] duration-200 ' +
|
||||||
|
'focus:border-teal focus:bg-white focus:shadow-[0_0_0_4px_rgb(0_196_180/0.10)]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelClass: Record<Tone, string> = {
|
||||||
|
glass: 'text-[12px] font-[750] text-white/[0.7]',
|
||||||
|
light: 'text-[11px] font-extrabold text-muted',
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
full,
|
||||||
|
tone,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
full?: boolean
|
||||||
|
tone: Tone
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cx('grid gap-[7px]', full && 'sm:col-span-full')}>
|
<div className={cx('grid gap-[7px]', full && 'sm:col-span-full')}>
|
||||||
<label htmlFor={id} className="text-[12px] font-[750] text-white/[0.7]">
|
<label htmlFor={id} className={labelClass[tone]}>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{children}
|
{children}
|
||||||
@@ -22,11 +50,12 @@ export function TextField({
|
|||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
full,
|
full,
|
||||||
|
tone = 'glass',
|
||||||
...rest
|
...rest
|
||||||
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
|
}: { id: string; label: string; full?: boolean; tone?: Tone } & ComponentPropsWithRef<'input'>) {
|
||||||
return (
|
return (
|
||||||
<Field id={id} label={label} full={full}>
|
<Field id={id} label={label} full={full} tone={tone}>
|
||||||
<input id={id} className={cx(controlClass, 'min-h-[52px] px-[15px]')} {...rest} />
|
<input id={id} className={cx(controlClass[tone], tone === 'glass' ? 'min-h-[52px]' : 'h-[51px]')} {...rest} />
|
||||||
</Field>
|
</Field>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -38,20 +67,29 @@ export function TextareaField({
|
|||||||
...rest
|
...rest
|
||||||
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
|
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
|
||||||
return (
|
return (
|
||||||
<Field id={id} label={label} full={full}>
|
<Field id={id} label={label} full={full} tone="glass">
|
||||||
<textarea id={id} className={cx(controlClass, 'min-h-[104px] resize-y px-[15px] pt-[14px]')} {...rest} />
|
<textarea id={id} className={cx(controlClass.glass, 'min-h-[104px] resize-y pt-[14px]')} {...rest} />
|
||||||
</Field>
|
</Field>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
|
/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
|
||||||
export function Honeypot({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
export function Honeypot({
|
||||||
|
id = 'website',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
/** Must differ per form: the request form and the callback dialog can be on screen together. */
|
||||||
|
id?: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div aria-hidden="true" className="honeypot">
|
<div aria-hidden="true" className="honeypot">
|
||||||
<label htmlFor="website">Не заполнять</label>
|
<label htmlFor={id}>Не заполнять</label>
|
||||||
<input
|
<input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
id="website"
|
id={id}
|
||||||
name="website"
|
name="website"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -61,3 +99,18 @@ export function Honeypot({ value, onChange }: { value: string; onChange: (value:
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ConsentCheckbox({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="my-[13px] grid grid-cols-[18px_1fr] gap-[9px] text-[10px] text-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
required
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
className="mt-[2px] mb-[3px] accent-teal"
|
||||||
|
/>
|
||||||
|
<span>Согласен на обработку персональных данных и получение обратного звонка.</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import logo from '../assets/images/exo-logo.png'
|
import logo from '../assets/images/exo-logo.png'
|
||||||
import { contacts, navLinks } from '../data/content'
|
import { navLinks } from '../data/content'
|
||||||
import { useScrolled } from '../hooks/useScrollState'
|
import { useScrolled } from '../hooks/useScrollState'
|
||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
import { reportCallClick } from '../lib/lead'
|
|
||||||
import { ButtonLink, buttonClass, headerSize } from './Button'
|
import { ButtonLink, buttonClass, headerSize } from './Button'
|
||||||
import { PhoneIcon } from './Icons'
|
import { PhoneIcon } from './Icons'
|
||||||
import { Container } from './Layout'
|
import { Container } from './Layout'
|
||||||
@@ -13,8 +12,10 @@ import { Container } from './Layout'
|
|||||||
* Below 980px it carries a logo and a call button and nothing else. The nav is
|
* Below 980px it carries a logo and a call button and nothing else. The nav is
|
||||||
* not shown at that width and the proposal button has moved to <ThumbBar />,
|
* not shown at that width and the proposal button has moved to <ThumbBar />,
|
||||||
* so there is nothing left up here to compete with the page.
|
* so there is nothing left up here to compete with the page.
|
||||||
|
*
|
||||||
|
* «Звонок» opens the callback dialog rather than dialling straight away.
|
||||||
*/
|
*/
|
||||||
export function SiteHeader() {
|
export function SiteHeader({ onCallbackClick }: { onCallbackClick: () => void }) {
|
||||||
const scrolled = useScrolled()
|
const scrolled = useScrolled()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,15 +48,16 @@ export function SiteHeader() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-[10px] phone:gap-[6px]">
|
<div className="flex shrink-0 items-center gap-[10px] phone:gap-[6px]">
|
||||||
<a
|
<button
|
||||||
href={contacts.phoneHref}
|
type="button"
|
||||||
onClick={reportCallClick}
|
onClick={onCallbackClick}
|
||||||
aria-label="Позвонить в Экзо Групп"
|
aria-label="Заказать обратный звонок"
|
||||||
|
aria-haspopup="dialog"
|
||||||
className={buttonClass('call', 'phone:min-h-[40px] phone:px-[13px] [&>svg]:phone:size-[18px]', headerSize)}
|
className={buttonClass('call', 'phone:min-h-[40px] phone:px-[13px] [&>svg]:phone:size-[18px]', headerSize)}
|
||||||
>
|
>
|
||||||
<PhoneIcon />
|
<PhoneIcon />
|
||||||
<span className="phone:hidden">Звонок</span>
|
<span className="phone:hidden">Звонок</span>
|
||||||
</a>
|
</button>
|
||||||
{/* `max-lg:hidden`, not `hidden lg:inline-flex`: `inline-flex` from
|
{/* `max-lg:hidden`, not `hidden lg:inline-flex`: `inline-flex` from
|
||||||
the button base is emitted after `hidden` in the utility layer and
|
the button base is emitted after `hidden` in the utility layer and
|
||||||
would win at every width. */}
|
would win at every width. */}
|
||||||
|
|||||||
@@ -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,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])
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
--color-paper: #f5fafb;
|
--color-paper: #f5fafb;
|
||||||
--color-ink: #0a2540;
|
--color-ink: #0a2540;
|
||||||
--color-muted: #60778b;
|
--color-muted: #60778b;
|
||||||
|
/* Inputs on the white callback dialog. */
|
||||||
|
--color-field: #f7fafb;
|
||||||
--color-footer: #041524;
|
--color-footer: #041524;
|
||||||
/* The dark "сценарии выручки" band sits between navy and navy-deep. */
|
/* The dark "сценарии выручки" band sits between navy and navy-deep. */
|
||||||
--color-scenario: #071f35;
|
--color-scenario: #071f35;
|
||||||
@@ -83,6 +85,10 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.is-locked {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* The fixed header is 78px tall; anchors must clear it. */
|
/* The fixed header is 78px tall; anchors must clear it. */
|
||||||
section[id] {
|
section[id] {
|
||||||
scroll-margin-top: 78px;
|
scroll-margin-top: 78px;
|
||||||
|
|||||||
Reference in New Issue
Block a user