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:
Yuriy Panov
2026-09-11 19:00:20 +06:00
co-authored by Claude Opus 5
parent 7ce8bee320
commit 1ee5431b92
21 changed files with 454 additions and 109 deletions
+9 -3
View File
@@ -1,5 +1,6 @@
import { useRef } from 'react'
import { useCallback, useRef, useState } from 'react'
import { BenefitsSection } from './components/BenefitsSection'
import { CallbackModal } from './components/CallbackModal'
import { EconomicsSection } from './components/EconomicsSection'
import { EquipmentSection } from './components/EquipmentSection'
import { FormatsSection } from './components/FormatsSection'
@@ -17,22 +18,27 @@ export default function App() {
const scrolled = useScrolled()
useAnchorScroll(headerRef)
const [callbackOpen, setCallbackOpen] = useState(false)
const openCallback = useCallback(() => setCallbackOpen(true), [])
const closeCallback = useCallback(() => setCallbackOpen(false), [])
return (
<>
<SiteHeader scrolled={scrolled} headerRef={headerRef} />
<SiteHeader scrolled={scrolled} headerRef={headerRef} onCallbackClick={openCallback} />
<main>
<Hero />
<BenefitsSection />
<EconomicsSection />
<GuestsSection />
<EquipmentSection />
<FormatsSection />
<EquipmentSection />
<WhyExoSection />
<RequestSection />
</main>
<SiteFooter />
<CallbackModal open={callbackOpen} onClose={closeCallback} />
</>
)
}
+3 -1
View File
@@ -186,8 +186,10 @@ function MetricCard({
foot: [string, string]
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 (
<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]">
<span>{label}</span>
<strong className="text-[16px] font-[820] whitespace-nowrap text-teal-bright">{value}</strong>
+1 -1
View File
@@ -18,7 +18,7 @@ const base =
const defaultSize = 'min-h-[52px] px-[22px]'
/** `.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> = {
primary:
+155
View File
@@ -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>
)
}
+31 -6
View File
@@ -3,6 +3,8 @@ import { econGain, econLoss, econMetrics, econOps } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { formatMoney, formatNumber, parseAmount } from '../lib/format'
import { cx } from '../lib/cx'
import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
import { Container, Section } from './Layout'
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>
<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>
<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>
<p className="mb-0 text-[11px] leading-[1.45] text-white/[0.58]">
Введите фактическое число оплаченных программ, средний чек и рабочие дни.
Введите фактическое число оплаченных сеансов, средний чек сеанса и рабочие дни.
</p>
</div>
@@ -179,7 +181,7 @@ function RevenueCalculator() {
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 phone:grid-cols-1">
<CalcField
id="hotelPrograms"
label="Программ в день"
label="Сеансов в день"
value={programs}
onChange={setPrograms}
placeholder="например, 8"
@@ -188,10 +190,10 @@ function RevenueCalculator() {
/>
<CalcField
id="hotelCheck"
label="Средний чек, ₽"
label="Средний чек сеанса, ₽"
value={check}
onChange={setCheck}
placeholder="ваш тариф"
placeholder="например, 2500"
min={0}
step={100}
/>
@@ -210,7 +212,7 @@ function RevenueCalculator() {
<CalcResult
label="Дополнительная выручка / месяц"
value={ready ? formatMoney(month) : '—'}
note={ready ? `${formatNumber(volume)} программ в месяц` : 'Введите программы и чек'}
note={ready ? `${formatNumber(volume)} сеансов в месяц` : 'Введите сеансы и чек'}
/>
<CalcResult
label="Дополнительная выручка / год"
@@ -220,6 +222,29 @@ function RevenueCalculator() {
</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]">
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
рассчитываются после аудита отеля.
+3 -3
View File
@@ -18,9 +18,9 @@ export function EquipmentSection() {
<SectionHead
split
tone="dark"
eyebrow="Что можно купить"
title="Комплект оборудования для отеля"
lead="Линейка из четырех аппаратов адаптируется под любой запрос клиента: от быстрых премиум-процедур в зоне отдыха до глубокой индивидуальной работы с оператором."
eyebrow="Модель работы"
title="Как работает сервис восстановления для гостя"
lead="Ниже показана логика работы каждой технологии: для какого сценария гостя она подходит, сколько длится сеанс и требуется ли участие специалиста. Так отель заранее понимает, как встроить сервис в SPA-меню, расписание и загрузку команды."
/>
<div className="scroll-cards relative z-1 gap-[16px] md:grid-cols-4">
+68 -15
View File
@@ -1,16 +1,44 @@
import type { ComponentPropsWithRef, ReactNode } from 'react'
import { cx } from '../lib/cx'
/** Glass controls on the navy CTA panel. */
const controlClass =
'w-full rounded-[14px] border border-white/[0.18] bg-[#041624]/[0.44] 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)]'
/**
* `glass` — controls on the navy CTA panel; `light` — on the white callback
* dialog, the same fields as the fitness landing's.
*/
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 (
<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>
{children}
@@ -22,11 +50,12 @@ export function TextField({
id,
label,
full,
tone = 'glass',
...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
}: { id: string; label: string; full?: boolean; tone?: Tone } & ComponentPropsWithRef<'input'>) {
return (
<Field id={id} label={label} full={full}>
<input id={id} className={cx(controlClass, 'h-[52px] px-[15px]')} {...rest} />
<Field id={id} label={label} full={full} tone={tone}>
<input id={id} className={cx(controlClass[tone], tone === 'glass' ? 'h-[52px]' : 'h-[51px]')} {...rest} />
</Field>
)
}
@@ -38,20 +67,29 @@ export function TextareaField({
...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
return (
<Field id={id} label={label} full={full}>
<textarea id={id} className={cx(controlClass, 'min-h-[105px] resize-y px-[15px] py-[13px]')} {...rest} />
<Field id={id} label={label} full={full} tone="glass">
<textarea id={id} className={cx(controlClass.glass, 'min-h-[105px] resize-y py-[13px]')} {...rest} />
</Field>
)
}
/** 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 (
<div aria-hidden="true" className="honeypot">
<label htmlFor="website">Не заполнять</label>
<label htmlFor={id}>Не заполнять</label>
<input
autoComplete="off"
id="website"
id={id}
name="website"
tabIndex={-1}
type="text"
@@ -61,3 +99,18 @@ export function Honeypot({ value, onChange }: { value: string; onChange: (value:
</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>
)
}
+4 -3
View File
@@ -11,9 +11,10 @@ export function FormatsSection() {
<Container>
<SectionHead
split
eyebrow="Сценарии размещения"
title="Встраивается в маршрут гостя"
lead="Формат подстраивается под инфраструктуру отеля: отдельный SPA-кабинет, заметная капсула рядом с фитнесом, зона отдыха у бассейна или персональный сервис в номере."
eyebrow="Решение для вашего бизнеса"
title="Отель — не больница. Пространство восстановления должно продолжать интерьер"
titleClassName="formats-title"
lead="Оборудование Экзо Групп интегрируется в SPA, wellness- или фитнес-зону и оформляется в стилистике вашего отеля. На фотографиях ниже — варианты интерьера и размещения, где аппараты становятся частью премиального сервиса, а не выглядят как медицинский кабинет."
/>
<div className="scroll-cards gap-[16px] md:grid-cols-4">
+7 -10
View File
@@ -52,7 +52,7 @@ export function Hero() {
<p
ref={subtitle.ref}
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,
)}
>
@@ -64,29 +64,26 @@ export function Hero() {
Получить предложение
<ArrowRightIcon />
</ButtonLink>
<ButtonLink href="#equipment" variant="secondary">
Подобрать оборудование
</ButtonLink>
</div>
<div
ref={facts.ref}
aria-label="Ключевые параметры формата"
aria-label="Ключевая экономика сценария"
className={cx(
'grid max-w-[700px] grid-cols-3 gap-[10px] delay-[240ms] sm:gap-[12px] tiny:max-w-[330px] tiny:grid-cols-1',
facts.revealClass,
)}
>
{heroFacts.map((fact) => (
// Under 430px the fact turns into a value/label row. The original used an
// 80px column, which the widest value overflowed into the label; 124px
// keeps every value on one line.
// Under 430px the fact turns into a value/label row. The value column
// grows with its content, so «600 000 ₽ / мес.» stays on one line.
<div
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]
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}
</strong>
<span className="text-[12px] text-white/[0.62]">{fact.label}</span>
+12 -9
View File
@@ -35,15 +35,16 @@ export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone;
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 }) {
return (
<p
className={cx(
'mb-0 max-w-[760px] text-[clamp(18px,2vw,22px)] leading-[1.55]',
tone === 'dark' ? 'text-white/[0.72]' : 'text-muted',
className,
)}
>
<p className={cx(leadText, 'max-w-[760px]', tone === 'dark' ? 'text-white/[0.72]' : 'text-muted', className)}>
{children}
</p>
)
@@ -56,12 +57,14 @@ export function SectionLead({ tone = 'light', className, children }: { tone?: To
export function SectionHead({
eyebrow,
title,
titleClassName,
lead,
tone = 'light',
split = false,
}: {
eyebrow: string
title: ReactNode
titleClassName?: string
lead?: ReactNode
tone?: Tone
split?: boolean
@@ -72,7 +75,7 @@ export function SectionHead({
return (
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2>{title}</h2>
<h2 className={titleClassName}>{title}</h2>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div>
)
@@ -88,7 +91,7 @@ export function SectionHead({
>
<div>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2 className="mb-0">{title}</h2>
<h2 className={titleClassName}>{title}</h2>
</div>
{lead ? (
<SectionLead tone={tone} className="md:justify-self-end">
-2
View File
@@ -87,7 +87,6 @@ export function LeadForm() {
autoComplete="organization"
minLength={2}
placeholder="Название отеля"
required
value={values.company}
onChange={set('company')}
/>
@@ -110,7 +109,6 @@ export function LeadForm() {
type="email"
autoComplete="email"
placeholder="name@hotel.ru"
required
value={values.email}
onChange={set('email')}
/>
+9 -6
View File
@@ -1,6 +1,6 @@
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionLead } from './Layout'
import { Container, Eyebrow, Section, leadText } from './Layout'
import { LeadForm } from './LeadForm'
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)]">
<div ref={copy.ref} className={cx('pt-[4px]', copy.revealClass)}>
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
<h2 className="max-w-[660px]">Подберём оборудование под формат вашего отеля</h2>
<SectionLead tone="dark" className="max-w-[620px]">
Оставьте контакты специалист свяжется, ответит на вопросы и подготовит коммерческое предложение с расчётом
окупаемости и меню программ.
</SectionLead>
<h2 className="max-w-[660px] compact:text-[34px] compact:leading-[1.04] compact:tracking-[-0.035em]">
Подберём решение под формат вашего отеля
</h2>
<p className={cx(leadText, 'max-w-[620px] text-white/[0.76]')}>
Оставьте контакты наш специалист свяжется с Вами, ответит на вопросы и подготовит коммерческое
предложение, исходя из{' '}
<strong>доступной площади, специфики инфраструктуры и ваших бизнес-задач.</strong>
</p>
</div>
<LeadForm />
+17 -10
View File
@@ -1,13 +1,20 @@
import type { RefObject } from 'react'
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 { reportCallClick } from '../lib/lead'
import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons'
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 (
<header
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="Экзо Групп — на первый экран">
<img
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}
width={900}
height={204}
@@ -38,16 +45,16 @@ export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerR
))}
</nav>
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]">
<a
href={contacts.phoneHref}
onClick={reportCallClick}
aria-label="Позвонить в Экзо Групп"
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px] micro:gap-[5px]">
<button
type="button"
onClick={onCallbackClick}
aria-label="Заказать обратный звонок"
className={buttonClass('call', 'compact:px-[12px]', headerSize)}
>
<PhoneIcon />
<span className="compact:hidden">Звонок</span>
</a>
</button>
<ButtonLink href="#proposal" size={headerSize}>
Получить предложение
</ButtonLink>
+12 -12
View File
@@ -13,16 +13,16 @@ export const contacts = {
}
export const navLinks = [
{ href: '#benefits', label: 'Выгода' },
{ href: '#guests', label: 'Гости' },
{ href: '#equipment', label: 'Оборудование' },
{ href: '#formats', label: 'Форматы' },
{ href: '#hotel-economics', label: 'Экономика' },
{ href: '#guests', label: 'Для гостя' },
{ href: '#formats', label: 'Интерьер' },
{ href: '#equipment', label: 'Сервис' },
]
export const heroFacts = [
{ value: '4 аппарата', label: 'единое пространство восстановления' },
{ value: '1 оператор', label: 'при дисциплинированном расписании' },
{ value: '12 часов', label: 'базовый режим работы кабинета' },
{ value: '2 500 ₽', label: 'средний чек сеанса по данным «ЭкзоКлиник»' },
{ value: '600 000 ₽ / мес.', label: 'сценарий: 8 сеансов в день × 30 дней' },
{ value: '7,2 млн ₽ / год', label: 'тот же сценарий, до вычета расходов' },
]
/* -------------------------------------------------------------------------- */
@@ -166,9 +166,9 @@ export const devices = [
{
name: 'ЭкзоТерапия',
role: 'Флагман SPA',
duration: '10 минут',
duration: '1015 минут',
tech: 'Высокоинтенсивная импульсная магнитотерапия.',
text: 'Короткий 10-минутный ритуал для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
text: 'Короткий сеанс 1015 минут для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
image: deviceTherapy,
alt: 'ЭкзоТерапия в премиальной SPA-капсуле',
},
@@ -193,16 +193,16 @@ export const devices = [
{
name: 'ЭкзоВодород',
role: 'Автономная пауза',
duration: '40 минут',
duration: '3060 минут',
tech: 'Ингаляция молекулярным водородом',
text: 'Комфортная 40-минутная пауза в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
text: 'Комфортная пауза 3060 минут в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
image: deviceHydrogen,
alt: 'ЭкзоВодород в спокойной wellness-зоне',
},
]
export const hotelModel = [
{ value: '1245 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
{ value: '1236 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
{
value: '1 оператор',
text: 'Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.',
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from 'react'
/** Freezes background scrolling while a modal is open. */
export function useBodyLock(locked: boolean) {
useEffect(() => {
if (!locked) return
document.body.classList.add('is-locked')
return () => document.body.classList.remove('is-locked')
}, [locked])
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect } from 'react'
export function useEscapeKey(active: boolean, onEscape: () => void) {
useEffect(() => {
if (!active) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onEscape()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [active, onEscape])
}
+67 -6
View File
@@ -26,6 +26,8 @@
--color-ink: #0a2540;
--color-muted: #60778b;
--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) */
--color-econ-bg: #f4fafb;
@@ -58,13 +60,18 @@
--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
min-width scale above; they are exposed as named variants instead of
arbitrary values so the intent stays readable in the markup. */
@custom-variant tiny (@media (max-width: 430px));
@custom-variant phone (@media (max-width: 480px));
@custom-variant compact (@media (max-width: 620px));
/* The design's `max-width` media queries do not line up with the min-width
scale above; they are exposed as named variants instead of arbitrary values
so the intent stays readable in the markup. Tailwind emits custom variants in
declaration order, so they go widest first: where two apply, the narrower
one wins, as in the design's own cascade. */
@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 {
html {
@@ -83,6 +90,16 @@
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 {
background: rgb(0 196 180 / 0.28);
color: var(--color-navy);
@@ -125,6 +142,26 @@
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,
a {
-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 {
background:
radial-gradient(circle at 85% 15%, rgb(0 196 180 / 0.22), transparent 34%),