hotel: apply the v7 design and add the callback dialog
Ports legacy/hotel v7 into the React landing. - Copy: nav is Экономика / Для гостя / Интерьер / Сервис, and «Интерьер» now comes before «Сервис». The hero keeps one button and gets the 2 500 ₽ / 600 000 ₽ / 7,2 млн ₽ facts. New headings and leads for the interior, service and request blocks. Session lengths 10–15 and 30–60 min, area 12–36 m². - Calculator counts sessions and gains the ЭкзоКлиник benchmark and a «Получить консультацию» button. - Request form: company and email are optional. The server schema already treats them as optional. - v8 phone type scale (≤620px), a 380px `micro` step, and a `slim` (≤640px) variant. The max-width variants are now declared widest first, so the narrower one wins where two apply. - The market charts' SVGs stay inline, as in the mockup. The header «Звонок» button opens the «Перезвоним вам» dialog from the fitness landing (name, phone, consent). The server accepts form 'callback', names the deal «Обратный звонок — <имя>» and tags it «обратный звонок». The phone link inside the dialog still reports a call click. With Inter substituted into the mockup, the page text matches it line for line (except the fixed «2025–2026 гг.» typo), and every section height matches at 360–1440 px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7ce8bee320
commit
1ee5431b92
+29
-18
@@ -67,18 +67,26 @@ 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 заранее не нужно.
|
||||||
|
|
||||||
|
Форм две. Основная — блок «Следующий шаг» внизу страницы: обязательны только
|
||||||
|
имя и телефон, отель, email и комментарий по желанию; сделка называется
|
||||||
|
`ЭкзоОтель — <отель или имя>`. Вторая — модальное окно «Перезвоним вам», его
|
||||||
|
открывает кнопка «Звонок» в шапке (то же окно, что на фитнес-лендинге): имя,
|
||||||
|
телефон и согласие на обработку данных. Сделка называется
|
||||||
|
`Обратный звонок — <имя>`, в поле или примечании «Форма» — «Быстрый обратный
|
||||||
|
звонок».
|
||||||
|
|
||||||
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
|
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
|
||||||
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
|
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
|
||||||
видит телефон для связи.
|
видит телефон для связи.
|
||||||
|
|
||||||
### 4. Что происходит при клике на телефон
|
### 4. Что происходит при клике на телефон
|
||||||
|
|
||||||
Кнопка звонка (в шапке) помимо набора номера отправляет
|
Ссылка с номером в модальном окне «Перезвоним вам» помимо набора номера отправляет
|
||||||
`POST /api/leads/hotels/call` — маячком `navigator.sendBeacon`, чтобы
|
`POST /api/leads/hotels/call` — маячком `navigator.sendBeacon`, чтобы
|
||||||
запрос пережил переход браузера на `tel:`.
|
запрос пережил переход браузера на `tel:`.
|
||||||
|
|
||||||
@@ -112,14 +120,15 @@ index.html точка входа Vite (meta, Open Graph, JSON-LD)
|
|||||||
src/
|
src/
|
||||||
components/ секции лендинга и UI-примитивы
|
components/ секции лендинга и UI-примитивы
|
||||||
data/content.ts весь текстовый контент страницы
|
data/content.ts весь текстовый контент страницы
|
||||||
hooks/ useReveal, useScrolled, useAnchorScroll
|
hooks/ useReveal, useScrolled, useAnchorScroll, useBodyLock, useEscapeKey
|
||||||
lib/ отправка заявки, форматирование чисел
|
lib/ отправка заявки, форматирование чисел
|
||||||
assets/images/ 11 изображений, извлечённых из исходного HTML
|
assets/images/ 11 изображений, извлечённых из исходного 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/hotel v7/ макет v7 — текущий визуальный эталон
|
||||||
public/ favicon.png и og-image.jpg
|
public/ favicon.png и og-image.jpg
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -129,30 +138,32 @@ public/ favicon.png и og-image.jpg
|
|||||||
`@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена
|
`@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена
|
||||||
(`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
|
(`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
|
||||||
Брейкпоинты соответствуют исходной вёрстке: `sm` 640, `md` 900, плюс
|
Брейкпоинты соответствуют исходной вёрстке: `sm` 640, `md` 900, плюс
|
||||||
`max-width`-варианты `tiny` (430), `phone` (480), `compact` (620),
|
`max-width`-варианты `narrow` (780), `slim` (640), `compact` (620), `phone` (480),
|
||||||
`narrow` (780).
|
`tiny` (430), `micro` (380). Tailwind выводит пользовательские варианты в порядке
|
||||||
|
объявления, поэтому они объявлены от широкого к узкому: где действуют два,
|
||||||
|
побеждает более узкий — как в каскаде макета.
|
||||||
|
|
||||||
Ряды карточек (выгоды, гости, оборудование, сценарии, программы) до 900px
|
Ряды карточек (выгоды, гости, оборудование, сценарии, программы) до 900px
|
||||||
скроллятся по горизонтали со snap, выше — становятся сеткой. Это единственный
|
скроллятся по горизонтали со snap, выше — становятся сеткой. Это единственный
|
||||||
крупный кусок вёрстки, оставшийся классом (`.scroll-cards` в `index.css`):
|
крупный кусок вёрстки, оставшийся классом (`.scroll-cards` в `index.css`):
|
||||||
`grid-auto-columns` не выражается утилитами, а сам ряд используется пять раз.
|
`grid-auto-columns` не выражается утилитами, а сам ряд используется пять раз.
|
||||||
|
|
||||||
### Соответствие исходной вёрстке
|
### Соответствие макету
|
||||||
|
|
||||||
Текст страницы совпадает с `legacy/index.html` строка в строку (проверено
|
Визуальный эталон — макет `legacy/hotel v7/`. Сам макет Inter не подключает и
|
||||||
диффом `innerText`), высоты секций — в пределах 0,6% на 375 и 1440 px.
|
рисуется системным шрифтом, поэтому сверять его с сайтом нужно, подставив в него
|
||||||
Расхождения дают метрики настоящего Inter: исходная страница просила Inter, но
|
Inter Variable. На одном шрифте текст страницы совпадает с макетом строка в строку
|
||||||
нигде его не подключала и рисовалась системным фолбэком.
|
(дифф `innerText`), а высоты всех секций и общая высота страницы — попиксельно на
|
||||||
|
360 / 375 / 430 / 480 / 620 / 640 / 780 / 900 / 1120 / 1440 px.
|
||||||
|
|
||||||
Три отличия сделаны намеренно:
|
Намеренные отличия от макета:
|
||||||
|
|
||||||
* `.eyebrow` и `.lead` в блоке заявки исходно оставались тёмными
|
* `.eyebrow` в блоке заявки в макете остаётся тёмным (`#008F84`) на тёмно-синем
|
||||||
(`#008F84` и `#60778B`) на тёмно-синем фоне — контраст 3,4–3,8:1. Здесь они
|
фоне. Здесь он светлый, как в остальных тёмных секциях.
|
||||||
светлые, как в остальных тёмных секциях.
|
|
||||||
* На экранах ≤430px значение в карточке hero-факта («4 аппарата», «1 оператор»)
|
|
||||||
не помещалось в колонку 80px и наезжало на подпись. Колонка расширена до
|
|
||||||
124px.
|
|
||||||
* В подписи к рыночному блоку исправлена опечатка «2025–2026г..» → «2025–2026 гг.».
|
* В подписи к рыночному блоку исправлена опечатка «2025–2026г..» → «2025–2026 гг.».
|
||||||
|
* Модальное окно «Перезвоним вам» взято с фитнес-лендинга (светлые поля,
|
||||||
|
согласие отдельным чекбоксом), а не вёрстка `.exo-callback` из макета; маски
|
||||||
|
телефона, как и на фитнесе, нет. Тексты окна — из макета.
|
||||||
|
|
||||||
### Шрифты
|
### Шрифты
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +152,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
|
||||||
|
|||||||
+9
-3
@@ -1,5 +1,6 @@
|
|||||||
import { useRef } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { BenefitsSection } from './components/BenefitsSection'
|
import { BenefitsSection } from './components/BenefitsSection'
|
||||||
|
import { CallbackModal } from './components/CallbackModal'
|
||||||
import { EconomicsSection } from './components/EconomicsSection'
|
import { EconomicsSection } from './components/EconomicsSection'
|
||||||
import { EquipmentSection } from './components/EquipmentSection'
|
import { EquipmentSection } from './components/EquipmentSection'
|
||||||
import { FormatsSection } from './components/FormatsSection'
|
import { FormatsSection } from './components/FormatsSection'
|
||||||
@@ -17,22 +18,27 @@ export default function App() {
|
|||||||
const scrolled = useScrolled()
|
const scrolled = useScrolled()
|
||||||
useAnchorScroll(headerRef)
|
useAnchorScroll(headerRef)
|
||||||
|
|
||||||
|
const [callbackOpen, setCallbackOpen] = useState(false)
|
||||||
|
const openCallback = useCallback(() => setCallbackOpen(true), [])
|
||||||
|
const closeCallback = useCallback(() => setCallbackOpen(false), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SiteHeader scrolled={scrolled} headerRef={headerRef} />
|
<SiteHeader scrolled={scrolled} headerRef={headerRef} onCallbackClick={openCallback} />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
<BenefitsSection />
|
<BenefitsSection />
|
||||||
<EconomicsSection />
|
<EconomicsSection />
|
||||||
<GuestsSection />
|
<GuestsSection />
|
||||||
<EquipmentSection />
|
|
||||||
<FormatsSection />
|
<FormatsSection />
|
||||||
|
<EquipmentSection />
|
||||||
<WhyExoSection />
|
<WhyExoSection />
|
||||||
<RequestSection />
|
<RequestSection />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
|
<CallbackModal open={callbackOpen} onClose={closeCallback} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,8 +186,10 @@ function MetricCard({
|
|||||||
foot: [string, string]
|
foot: [string, string]
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
// The chart stays inline, on the baseline, as in the design: preflight makes
|
||||||
|
// SVGs blocks, which drops the line gap under it and shortens the card by ~7px.
|
||||||
return (
|
return (
|
||||||
<article className="min-h-[190px] overflow-hidden rounded-[19px] border border-white/[0.12] bg-white/[0.07] p-[18px] [&>svg]:h-[88px] [&>svg]:w-full [&>svg]:overflow-visible">
|
<article className="min-h-[190px] overflow-hidden rounded-[19px] border border-white/[0.12] bg-white/[0.07] p-[18px] [&>svg]:inline [&>svg]:h-[88px] [&>svg]:w-full [&>svg]:overflow-visible [&>svg]:align-baseline">
|
||||||
<div className="flex min-h-[44px] items-start justify-between gap-[8px] text-[12px] font-[650] text-white/[0.72]">
|
<div className="flex min-h-[44px] items-start justify-between gap-[8px] text-[12px] font-[650] text-white/[0.72]">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<strong className="text-[16px] font-[820] whitespace-nowrap text-teal-bright">{value}</strong>
|
<strong className="text-[16px] font-[820] whitespace-nowrap text-teal-bright">{value}</strong>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const base =
|
|||||||
const defaultSize = 'min-h-[52px] px-[22px]'
|
const defaultSize = 'min-h-[52px] px-[22px]'
|
||||||
|
|
||||||
/** `.btn--header` — smaller, and never stretched to the full width on phones. */
|
/** `.btn--header` — smaller, and never stretched to the full width on phones. */
|
||||||
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] tiny:w-auto'
|
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] tiny:w-auto micro:px-[13px] micro:text-[12px]'
|
||||||
|
|
||||||
const variants: Record<ButtonVariant, string> = {
|
const variants: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary:
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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 { reportCallClick, submitLead } from '../lib/lead'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
const firstFieldRef = 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 (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 w-full max-w-[420px] 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
|
||||||
|
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(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Honeypot id="callbackWebsite" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { econGain, econLoss, econMetrics, econOps } from '../data/content'
|
|||||||
import { useReveal } from '../hooks/useReveal'
|
import { useReveal } from '../hooks/useReveal'
|
||||||
import { formatMoney, formatNumber, parseAmount } from '../lib/format'
|
import { formatMoney, formatNumber, parseAmount } from '../lib/format'
|
||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
|
import { ButtonLink } from './Button'
|
||||||
|
import { ArrowRightIcon } from './Icons'
|
||||||
import { Container, Section } from './Layout'
|
import { Container, Section } from './Layout'
|
||||||
|
|
||||||
const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]'
|
const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]'
|
||||||
@@ -27,7 +29,7 @@ export function EconomicsSection() {
|
|||||||
<span className="flex items-center gap-[10px] text-[11px] font-[850] tracking-[0.095em] text-teal-ink uppercase before:h-[2px] before:w-[28px] before:rounded-[3px] before:bg-teal before:content-['']">
|
<span className="flex items-center gap-[10px] text-[11px] font-[850] tracking-[0.095em] text-teal-ink uppercase before:h-[2px] before:w-[28px] before:rounded-[3px] before:bg-teal before:content-['']">
|
||||||
Экономика текущего гостевого потока
|
Экономика текущего гостевого потока
|
||||||
</span>
|
</span>
|
||||||
<h2 className="mb-0 text-[clamp(31px,5vw,58px)] leading-[1.02] tracking-[-0.048em] text-navy phone:text-[34px]">
|
<h2 className="mb-0 text-[clamp(31px,5vw,58px)] leading-[1.02] tracking-[-0.048em] text-navy compact:text-[32px] compact:leading-[1.04] compact:tracking-[-0.038em]">
|
||||||
Номер уже продан. Следующая выручка — внутри маршрута гостя
|
Номер уже продан. Следующая выручка — внутри маршрута гостя
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-0 max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-muted">
|
<p className="mb-0 max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-muted">
|
||||||
@@ -171,7 +173,7 @@ function RevenueCalculator() {
|
|||||||
Посчитайте дополнительную выручку на своих тарифах
|
Посчитайте дополнительную выручку на своих тарифах
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-0 text-[11px] leading-[1.45] text-white/[0.58]">
|
<p className="mb-0 text-[11px] leading-[1.45] text-white/[0.58]">
|
||||||
Введите фактическое число оплаченных программ, средний чек и рабочие дни.
|
Введите фактическое число оплаченных сеансов, средний чек сеанса и рабочие дни.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,7 +181,7 @@ function RevenueCalculator() {
|
|||||||
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 phone:grid-cols-1">
|
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 phone:grid-cols-1">
|
||||||
<CalcField
|
<CalcField
|
||||||
id="hotelPrograms"
|
id="hotelPrograms"
|
||||||
label="Программ в день"
|
label="Сеансов в день"
|
||||||
value={programs}
|
value={programs}
|
||||||
onChange={setPrograms}
|
onChange={setPrograms}
|
||||||
placeholder="например, 8"
|
placeholder="например, 8"
|
||||||
@@ -188,10 +190,10 @@ function RevenueCalculator() {
|
|||||||
/>
|
/>
|
||||||
<CalcField
|
<CalcField
|
||||||
id="hotelCheck"
|
id="hotelCheck"
|
||||||
label="Средний чек, ₽"
|
label="Средний чек сеанса, ₽"
|
||||||
value={check}
|
value={check}
|
||||||
onChange={setCheck}
|
onChange={setCheck}
|
||||||
placeholder="ваш тариф"
|
placeholder="например, 2500"
|
||||||
min={0}
|
min={0}
|
||||||
step={100}
|
step={100}
|
||||||
/>
|
/>
|
||||||
@@ -210,7 +212,7 @@ function RevenueCalculator() {
|
|||||||
<CalcResult
|
<CalcResult
|
||||||
label="Дополнительная выручка / месяц"
|
label="Дополнительная выручка / месяц"
|
||||||
value={ready ? formatMoney(month) : '—'}
|
value={ready ? formatMoney(month) : '—'}
|
||||||
note={ready ? `${formatNumber(volume)} программ в месяц` : 'Введите программы и чек'}
|
note={ready ? `${formatNumber(volume)} сеансов в месяц` : 'Введите сеансы и чек'}
|
||||||
/>
|
/>
|
||||||
<CalcResult
|
<CalcResult
|
||||||
label="Дополнительная выручка / год"
|
label="Дополнительная выручка / год"
|
||||||
@@ -220,6 +222,29 @@ function RevenueCalculator() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label="Ориентир среднего чека"
|
||||||
|
className="mt-[18px] rounded-[18px] border border-teal/[0.28] bg-[linear-gradient(135deg,rgb(0_196_180/0.08),rgb(255_255_255/0.04))] px-[20px] py-[18px] shadow-[inset_0_1px_0_rgb(255_255_255/0.08)] slim:p-[16px]"
|
||||||
|
>
|
||||||
|
<span className="mb-[7px] block text-[11px] font-extrabold tracking-[0.11em] text-teal uppercase">
|
||||||
|
Ориентир для расчёта
|
||||||
|
</span>
|
||||||
|
<p className="mb-0 text-[14px] leading-[1.55]">
|
||||||
|
Средний чек сеанса составляет <strong className="text-[1.08em] whitespace-nowrap text-teal">2 500 ₽</strong>,
|
||||||
|
по данным нашей клиники «ЭкзоКлиник» в городе Тольятти за период 2025г.–2026г.
|
||||||
|
</p>
|
||||||
|
<b className="mt-[7px] block text-[12px] leading-[1.45] opacity-[0.72]">
|
||||||
|
Средний чек сеанса может меняться в зависимости от региона и сезонных предложений.
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-[18px] flex">
|
||||||
|
<ButtonLink href="#proposal" className="min-w-[230px] slim:w-full slim:min-w-0">
|
||||||
|
Получить консультацию
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="mt-[13px] mb-0 text-[9px] leading-[1.45] text-white/[0.43]">
|
<p className="mt-[13px] mb-0 text-[9px] leading-[1.45] text-white/[0.43]">
|
||||||
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
|
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
|
||||||
рассчитываются после аудита отеля.
|
рассчитываются после аудита отеля.
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ export function EquipmentSection() {
|
|||||||
<SectionHead
|
<SectionHead
|
||||||
split
|
split
|
||||||
tone="dark"
|
tone="dark"
|
||||||
eyebrow="Что можно купить"
|
eyebrow="Модель работы"
|
||||||
title="Комплект оборудования для отеля"
|
title="Как работает сервис восстановления для гостя"
|
||||||
lead="Линейка из четырех аппаратов адаптируется под любой запрос клиента: от быстрых премиум-процедур в зоне отдыха до глубокой индивидуальной работы с оператором."
|
lead="Ниже показана логика работы каждой технологии: для какого сценария гостя она подходит, сколько длится сеанс и требуется ли участие специалиста. Так отель заранее понимает, как встроить сервис в SPA-меню, расписание и загрузку команды."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="scroll-cards relative z-1 gap-[16px] md:grid-cols-4">
|
<div className="scroll-cards relative z-1 gap-[16px] md:grid-cols-4">
|
||||||
|
|||||||
@@ -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-[#041624]/[0.44] text-white outline-none ' +
|
* dialog, the same fields as the fitness landing's.
|
||||||
'transition-[border-color,box-shadow,background-color] duration-200 placeholder:text-white/[0.34] ' +
|
*/
|
||||||
'focus:border-teal focus:bg-[#041624]/[0.62] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.13)]'
|
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-[#041624]/[0.44] px-[15px] text-white outline-none ' +
|
||||||
|
'transition-[border-color,box-shadow,background-color] duration-200 placeholder:text-white/[0.34] ' +
|
||||||
|
'focus:border-teal focus:bg-[#041624]/[0.62] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.13)]',
|
||||||
|
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-[680] text-white/[0.74]',
|
||||||
|
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-[680] text-white/[0.74]">
|
<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, 'h-[52px] px-[15px]')} {...rest} />
|
<input id={id} className={cx(controlClass[tone], tone === 'glass' ? '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-[105px] resize-y px-[15px] py-[13px]')} {...rest} />
|
<textarea id={id} className={cx(controlClass.glass, 'min-h-[105px] resize-y py-[13px]')} {...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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ export function FormatsSection() {
|
|||||||
<Container>
|
<Container>
|
||||||
<SectionHead
|
<SectionHead
|
||||||
split
|
split
|
||||||
eyebrow="Сценарии размещения"
|
eyebrow="Решение для вашего бизнеса"
|
||||||
title="Встраивается в маршрут гостя"
|
title="Отель — не больница. Пространство восстановления должно продолжать интерьер"
|
||||||
lead="Формат подстраивается под инфраструктуру отеля: отдельный SPA-кабинет, заметная капсула рядом с фитнесом, зона отдыха у бассейна или персональный сервис в номере."
|
titleClassName="formats-title"
|
||||||
|
lead="Оборудование Экзо Групп интегрируется в SPA, wellness- или фитнес-зону и оформляется в стилистике вашего отеля. На фотографиях ниже — варианты интерьера и размещения, где аппараты становятся частью премиального сервиса, а не выглядят как медицинский кабинет."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="scroll-cards gap-[16px] md:grid-cols-4">
|
<div className="scroll-cards gap-[16px] md:grid-cols-4">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function Hero() {
|
|||||||
<p
|
<p
|
||||||
ref={subtitle.ref}
|
ref={subtitle.ref}
|
||||||
className={cx(
|
className={cx(
|
||||||
'mb-[30px] max-w-[730px] text-[clamp(19px,2.2vw,27px)] leading-[1.42] text-white/[0.82] delay-[160ms]',
|
'mb-[30px] max-w-[730px] text-[clamp(19px,2.2vw,27px)] leading-[1.42] text-white/[0.82] delay-[160ms] compact:text-[18px] compact:leading-[1.46]',
|
||||||
subtitle.revealClass,
|
subtitle.revealClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -64,29 +64,26 @@ export function Hero() {
|
|||||||
Получить предложение
|
Получить предложение
|
||||||
<ArrowRightIcon />
|
<ArrowRightIcon />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
<ButtonLink href="#equipment" variant="secondary">
|
|
||||||
Подобрать оборудование
|
|
||||||
</ButtonLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={facts.ref}
|
ref={facts.ref}
|
||||||
aria-label="Ключевые параметры формата"
|
aria-label="Ключевая экономика сценария"
|
||||||
className={cx(
|
className={cx(
|
||||||
'grid max-w-[700px] grid-cols-3 gap-[10px] delay-[240ms] sm:gap-[12px] tiny:max-w-[330px] tiny:grid-cols-1',
|
'grid max-w-[700px] grid-cols-3 gap-[10px] delay-[240ms] sm:gap-[12px] tiny:max-w-[330px] tiny:grid-cols-1',
|
||||||
facts.revealClass,
|
facts.revealClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{heroFacts.map((fact) => (
|
{heroFacts.map((fact) => (
|
||||||
// Under 430px the fact turns into a value/label row. The original used an
|
// Under 430px the fact turns into a value/label row. The value column
|
||||||
// 80px column, which the widest value overflowed into the label; 124px
|
// grows with its content, so «600 000 ₽ / мес.» stays on one line.
|
||||||
// keeps every value on one line.
|
|
||||||
<div
|
<div
|
||||||
key={fact.value}
|
key={fact.value}
|
||||||
className="min-h-[82px] rounded-[18px] border border-white/[0.16] bg-[#051D2F]/[0.42] px-[16px] py-[15px] backdrop-blur-[16px]
|
className="min-h-[82px] rounded-[18px] border border-white/[0.16] bg-[#051D2F]/[0.42] px-[16px] py-[15px] backdrop-blur-[16px]
|
||||||
tiny:grid tiny:min-h-0 tiny:grid-cols-[124px_1fr] tiny:items-center tiny:gap-[10px]"
|
tiny:grid tiny:min-h-0 tiny:grid-cols-[minmax(118px,auto)_1fr] tiny:items-center tiny:gap-[10px]
|
||||||
|
micro:grid-cols-[minmax(105px,auto)_1fr]"
|
||||||
>
|
>
|
||||||
<strong className="mb-[3px] block text-[clamp(20px,3vw,29px)] leading-[1.1] tracking-[-0.03em] tiny:mb-0 tiny:text-[21px]">
|
<strong className="mb-[3px] block text-[clamp(19px,2.55vw,27px)] leading-[1.1] tracking-[-0.03em] whitespace-nowrap tiny:mb-0 tiny:text-[18px] micro:text-[17px]">
|
||||||
{fact.value}
|
{fact.value}
|
||||||
</strong>
|
</strong>
|
||||||
<span className="text-[12px] text-white/[0.62]">{fact.label}</span>
|
<span className="text-[12px] text-white/[0.62]">{fact.label}</span>
|
||||||
|
|||||||
@@ -35,15 +35,16 @@ export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone;
|
|||||||
|
|
||||||
type Tone = 'light' | 'dark'
|
type Tone = 'light' | 'dark'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `.lead` type without width or colour, for the one lead that needs its own of
|
||||||
|
* both. Passing those to `SectionLead` instead would not work: Tailwind orders
|
||||||
|
* conflicting utilities by the stylesheet, not by `className`.
|
||||||
|
*/
|
||||||
|
export const leadText = 'mb-0 text-[clamp(18px,2vw,22px)] leading-[1.55] compact:text-[17px] compact:leading-[1.52]'
|
||||||
|
|
||||||
export function SectionLead({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
export function SectionLead({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p className={cx(leadText, 'max-w-[760px]', tone === 'dark' ? 'text-white/[0.72]' : 'text-muted', className)}>
|
||||||
className={cx(
|
|
||||||
'mb-0 max-w-[760px] text-[clamp(18px,2vw,22px)] leading-[1.55]',
|
|
||||||
tone === 'dark' ? 'text-white/[0.72]' : 'text-muted',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
@@ -56,12 +57,14 @@ export function SectionLead({ tone = 'light', className, children }: { tone?: To
|
|||||||
export function SectionHead({
|
export function SectionHead({
|
||||||
eyebrow,
|
eyebrow,
|
||||||
title,
|
title,
|
||||||
|
titleClassName,
|
||||||
lead,
|
lead,
|
||||||
tone = 'light',
|
tone = 'light',
|
||||||
split = false,
|
split = false,
|
||||||
}: {
|
}: {
|
||||||
eyebrow: string
|
eyebrow: string
|
||||||
title: ReactNode
|
title: ReactNode
|
||||||
|
titleClassName?: string
|
||||||
lead?: ReactNode
|
lead?: ReactNode
|
||||||
tone?: Tone
|
tone?: Tone
|
||||||
split?: boolean
|
split?: boolean
|
||||||
@@ -72,7 +75,7 @@ export function SectionHead({
|
|||||||
return (
|
return (
|
||||||
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
|
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
|
||||||
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
||||||
<h2>{title}</h2>
|
<h2 className={titleClassName}>{title}</h2>
|
||||||
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
|
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -88,7 +91,7 @@ export function SectionHead({
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
||||||
<h2 className="mb-0">{title}</h2>
|
<h2 className={titleClassName}>{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
{lead ? (
|
{lead ? (
|
||||||
<SectionLead tone={tone} className="md:justify-self-end">
|
<SectionLead tone={tone} className="md:justify-self-end">
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export function LeadForm() {
|
|||||||
autoComplete="organization"
|
autoComplete="organization"
|
||||||
minLength={2}
|
minLength={2}
|
||||||
placeholder="Название отеля"
|
placeholder="Название отеля"
|
||||||
required
|
|
||||||
value={values.company}
|
value={values.company}
|
||||||
onChange={set('company')}
|
onChange={set('company')}
|
||||||
/>
|
/>
|
||||||
@@ -110,7 +109,6 @@ export function LeadForm() {
|
|||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
placeholder="name@hotel.ru"
|
placeholder="name@hotel.ru"
|
||||||
required
|
|
||||||
value={values.email}
|
value={values.email}
|
||||||
onChange={set('email')}
|
onChange={set('email')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useReveal } from '../hooks/useReveal'
|
import { useReveal } from '../hooks/useReveal'
|
||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
import { Container, Eyebrow, Section, SectionLead } from './Layout'
|
import { Container, Eyebrow, Section, leadText } from './Layout'
|
||||||
import { LeadForm } from './LeadForm'
|
import { LeadForm } from './LeadForm'
|
||||||
|
|
||||||
export function RequestSection() {
|
export function RequestSection() {
|
||||||
@@ -13,11 +13,14 @@ export function RequestSection() {
|
|||||||
<Container className="relative grid items-start gap-[34px] md:grid-cols-[0.95fr_1.05fr] md:gap-[clamp(44px,6vw,88px)]">
|
<Container className="relative grid items-start gap-[34px] md:grid-cols-[0.95fr_1.05fr] md:gap-[clamp(44px,6vw,88px)]">
|
||||||
<div ref={copy.ref} className={cx('pt-[4px]', copy.revealClass)}>
|
<div ref={copy.ref} className={cx('pt-[4px]', copy.revealClass)}>
|
||||||
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
|
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
|
||||||
<h2 className="max-w-[660px]">Подберём оборудование под формат вашего отеля</h2>
|
<h2 className="max-w-[660px] compact:text-[34px] compact:leading-[1.04] compact:tracking-[-0.035em]">
|
||||||
<SectionLead tone="dark" className="max-w-[620px]">
|
Подберём решение под формат вашего отеля
|
||||||
Оставьте контакты — специалист свяжется, ответит на вопросы и подготовит коммерческое предложение с расчётом
|
</h2>
|
||||||
окупаемости и меню программ.
|
<p className={cx(leadText, 'max-w-[620px] text-white/[0.76]')}>
|
||||||
</SectionLead>
|
Оставьте контакты — наш специалист свяжется с Вами, ответит на вопросы и подготовит коммерческое
|
||||||
|
предложение, исходя из{' '}
|
||||||
|
<strong>доступной площади, специфики инфраструктуры и ваших бизнес-задач.</strong>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LeadForm />
|
<LeadForm />
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import type { RefObject } from 'react'
|
import type { RefObject } from 'react'
|
||||||
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 { 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'
|
||||||
|
|
||||||
export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerRef: RefObject<HTMLElement | null> }) {
|
export function SiteHeader({
|
||||||
|
scrolled,
|
||||||
|
headerRef,
|
||||||
|
onCallbackClick,
|
||||||
|
}: {
|
||||||
|
scrolled: boolean
|
||||||
|
headerRef: RefObject<HTMLElement | null>
|
||||||
|
onCallbackClick: () => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
ref={headerRef}
|
ref={headerRef}
|
||||||
@@ -20,7 +27,7 @@ export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerR
|
|||||||
<a className="inline-flex min-w-0 items-center" href="#top" aria-label="Экзо Групп — на первый экран">
|
<a className="inline-flex min-w-0 items-center" href="#top" aria-label="Экзо Групп — на первый экран">
|
||||||
<img
|
<img
|
||||||
alt="Экзо Групп — российские технологии реабилитации"
|
alt="Экзо Групп — российские технологии реабилитации"
|
||||||
className="block h-auto w-[clamp(172px,20vw,232px)] max-w-full object-contain object-left compact:w-[150px]"
|
className="block h-auto w-[clamp(172px,20vw,232px)] max-w-full object-contain object-left compact:w-[150px] micro:w-[132px]"
|
||||||
src={logo}
|
src={logo}
|
||||||
width={900}
|
width={900}
|
||||||
height={204}
|
height={204}
|
||||||
@@ -38,16 +45,16 @@ export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerR
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]">
|
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px] micro:gap-[5px]">
|
||||||
<a
|
<button
|
||||||
href={contacts.phoneHref}
|
type="button"
|
||||||
onClick={reportCallClick}
|
onClick={onCallbackClick}
|
||||||
aria-label="Позвонить в Экзо Групп"
|
aria-label="Заказать обратный звонок"
|
||||||
className={buttonClass('call', 'compact:px-[12px]', headerSize)}
|
className={buttonClass('call', 'compact:px-[12px]', headerSize)}
|
||||||
>
|
>
|
||||||
<PhoneIcon />
|
<PhoneIcon />
|
||||||
<span className="compact:hidden">Звонок</span>
|
<span className="compact:hidden">Звонок</span>
|
||||||
</a>
|
</button>
|
||||||
<ButtonLink href="#proposal" size={headerSize}>
|
<ButtonLink href="#proposal" size={headerSize}>
|
||||||
Получить предложение
|
Получить предложение
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
|
|||||||
+12
-12
@@ -13,16 +13,16 @@ export const contacts = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{ href: '#benefits', label: 'Выгода' },
|
{ href: '#hotel-economics', label: 'Экономика' },
|
||||||
{ href: '#guests', label: 'Гости' },
|
{ href: '#guests', label: 'Для гостя' },
|
||||||
{ href: '#equipment', label: 'Оборудование' },
|
{ href: '#formats', label: 'Интерьер' },
|
||||||
{ href: '#formats', label: 'Форматы' },
|
{ href: '#equipment', label: 'Сервис' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const heroFacts = [
|
export const heroFacts = [
|
||||||
{ value: '4 аппарата', label: 'единое пространство восстановления' },
|
{ value: '2 500 ₽', label: 'средний чек сеанса по данным «ЭкзоКлиник»' },
|
||||||
{ value: '1 оператор', label: 'при дисциплинированном расписании' },
|
{ value: '600 000 ₽ / мес.', label: 'сценарий: 8 сеансов в день × 30 дней' },
|
||||||
{ value: '12 часов', label: 'базовый режим работы кабинета' },
|
{ value: '7,2 млн ₽ / год', label: 'тот же сценарий, до вычета расходов' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -166,9 +166,9 @@ export const devices = [
|
|||||||
{
|
{
|
||||||
name: 'ЭкзоТерапия',
|
name: 'ЭкзоТерапия',
|
||||||
role: 'Флагман SPA',
|
role: 'Флагман SPA',
|
||||||
duration: '10 минут',
|
duration: '10–15 минут',
|
||||||
tech: 'Высокоинтенсивная импульсная магнитотерапия.',
|
tech: 'Высокоинтенсивная импульсная магнитотерапия.',
|
||||||
text: 'Короткий 10-минутный ритуал для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
|
text: 'Короткий сеанс 10–15 минут для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
|
||||||
image: deviceTherapy,
|
image: deviceTherapy,
|
||||||
alt: 'ЭкзоТерапия в премиальной SPA-капсуле',
|
alt: 'ЭкзоТерапия в премиальной SPA-капсуле',
|
||||||
},
|
},
|
||||||
@@ -193,16 +193,16 @@ export const devices = [
|
|||||||
{
|
{
|
||||||
name: 'ЭкзоВодород',
|
name: 'ЭкзоВодород',
|
||||||
role: 'Автономная пауза',
|
role: 'Автономная пауза',
|
||||||
duration: '40 минут',
|
duration: '30–60 минут',
|
||||||
tech: 'Ингаляция молекулярным водородом',
|
tech: 'Ингаляция молекулярным водородом',
|
||||||
text: 'Комфортная 40-минутная пауза в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
|
text: 'Комфортная пауза 30–60 минут в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
|
||||||
image: deviceHydrogen,
|
image: deviceHydrogen,
|
||||||
alt: 'ЭкзоВодород в спокойной wellness-зоне',
|
alt: 'ЭкзоВодород в спокойной wellness-зоне',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const hotelModel = [
|
export const hotelModel = [
|
||||||
{ value: '12–45 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
|
{ value: '12–36 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
|
||||||
{
|
{
|
||||||
value: '1 оператор',
|
value: '1 оператор',
|
||||||
text: 'Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.',
|
text: 'Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.',
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
+67
-6
@@ -26,6 +26,8 @@
|
|||||||
--color-ink: #0a2540;
|
--color-ink: #0a2540;
|
||||||
--color-muted: #60778b;
|
--color-muted: #60778b;
|
||||||
--color-footer: #041421;
|
--color-footer: #041421;
|
||||||
|
/* Light inputs of the callback dialog (shared with the fitness landing) */
|
||||||
|
--color-field: #f7fafb;
|
||||||
|
|
||||||
/* Economics section palette (shared with the fitness landing) */
|
/* Economics section palette (shared with the fitness landing) */
|
||||||
--color-econ-bg: #f4fafb;
|
--color-econ-bg: #f4fafb;
|
||||||
@@ -58,13 +60,18 @@
|
|||||||
--font-sans: "Inter Variable", Inter, Manrope, "Segoe UI", Arial, sans-serif;
|
--font-sans: "Inter Variable", Inter, Manrope, "Segoe UI", Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The original had four `max-width` media queries that do not line up with the
|
/* The design's `max-width` media queries do not line up with the min-width
|
||||||
min-width scale above; they are exposed as named variants instead of
|
scale above; they are exposed as named variants instead of arbitrary values
|
||||||
arbitrary values so the intent stays readable in the markup. */
|
so the intent stays readable in the markup. Tailwind emits custom variants in
|
||||||
@custom-variant tiny (@media (max-width: 430px));
|
declaration order, so they go widest first: where two apply, the narrower
|
||||||
@custom-variant phone (@media (max-width: 480px));
|
one wins, as in the design's own cascade. */
|
||||||
@custom-variant compact (@media (max-width: 620px));
|
|
||||||
@custom-variant narrow (@media (max-width: 780px));
|
@custom-variant narrow (@media (max-width: 780px));
|
||||||
|
/* Overlaps `sm` at exactly 640px, as the design's `max-width: 640px` does. */
|
||||||
|
@custom-variant slim (@media (max-width: 640px));
|
||||||
|
@custom-variant compact (@media (max-width: 620px));
|
||||||
|
@custom-variant phone (@media (max-width: 480px));
|
||||||
|
@custom-variant tiny (@media (max-width: 430px));
|
||||||
|
@custom-variant micro (@media (max-width: 380px));
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
@@ -83,6 +90,16 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.is-locked {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Direct `#hash` loads land below the fixed header; in-page clicks are
|
||||||
|
offset by useAnchorScroll instead. */
|
||||||
|
section[id] {
|
||||||
|
scroll-margin-top: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: rgb(0 196 180 / 0.28);
|
background: rgb(0 196 180 / 0.28);
|
||||||
color: var(--color-navy);
|
color: var(--color-navy);
|
||||||
@@ -125,6 +142,26 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v7 phone type scale. Headings that carry their own size utilities (the
|
||||||
|
economics and request blocks) restate it with `compact:` variants. */
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
section[id] {
|
||||||
|
scroll-margin-top: 82px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(36px, 10vw, 40px);
|
||||||
|
line-height: 1.01;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(29px, 8.3vw, 33px);
|
||||||
|
line-height: 1.06;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
a {
|
a {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@@ -202,6 +239,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The «Интерьер» heading is longer than the rest and gets its own, smaller
|
||||||
|
scale, stepping down at 760px and then joining the phone scale at 620px. */
|
||||||
|
.formats-title {
|
||||||
|
max-width: 760px;
|
||||||
|
font-size: clamp(28px, 3.25vw, 48px);
|
||||||
|
line-height: 1.06;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.formats-title {
|
||||||
|
font-size: clamp(26px, 8vw, 36px);
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.formats-title {
|
||||||
|
font-size: clamp(29px, 8.3vw, 33px);
|
||||||
|
line-height: 1.06;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.market-panel-surface {
|
.market-panel-surface {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 85% 15%, rgb(0 196 180 / 0.22), transparent 34%),
|
radial-gradient(circle at 85% 15%, rgb(0 196 180 / 0.22), transparent 34%),
|
||||||
|
|||||||
Reference in New Issue
Block a user