Convert hotel landing to React + Tailwind v4 with amoCRM lead capture

Ports the single-file ЭкзоОтель page to the same architecture as the fitness
landing: Vite + React 19 + TypeScript + Tailwind v4 on the client, Express 5 for
the /api/leads/hotels endpoint, zod schema shared between the two.

The original page is kept in legacy/index.html as the visual reference. Its 15
inlined base64 images are extracted to files (the 1 MB HTML becomes ~318 KB of
JS plus assets loaded on demand), and its text is reproduced line for line —
verified with an innerText diff. Section heights stay within 0.6% at 375 and
1440 px; the drift comes from Inter actually loading, which the original asked
for but never served.

Three deliberate departures, documented in the README:
  * eyebrow and lead in the CTA block were dark teal on navy (3.4:1); they now
    match the other dark sections
  * hero fact values overflowed their 80px column into the label below 430px
  * "2025–2026г.." typo in the market source note

Leads reuse the fitness amoCRM integration: contact lookup by phone in every
spelling, deal in the first stage of pipeline 10980758, account fields matched
automatically with the rest written to a note. Rate limit, honeypot, and a
localStorage fallback so a lead survives the CRM being down. Vite runs on 5174
and the API on 3001 so both landings can run at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-08-28 14:17:10 +06:00
co-authored by Claude Opus 5
parent 60de4ef27c
commit 35f713a267
58 changed files with 8713 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import { useRef } from 'react'
import { BenefitsSection } from './components/BenefitsSection'
import { EconomicsSection } from './components/EconomicsSection'
import { EquipmentSection } from './components/EquipmentSection'
import { FormatsSection } from './components/FormatsSection'
import { GuestsSection } from './components/GuestsSection'
import { Hero } from './components/Hero'
import { RequestSection } from './components/RequestSection'
import { SiteFooter } from './components/SiteFooter'
import { SiteHeader } from './components/SiteHeader'
import { WhyExoSection } from './components/WhyExoSection'
import { useAnchorScroll } from './hooks/useAnchorScroll'
import { useScrolled } from './hooks/useScrollState'
export default function App() {
const headerRef = useRef<HTMLElement>(null)
const scrolled = useScrolled()
useAnchorScroll(headerRef)
return (
<>
<SiteHeader scrolled={scrolled} headerRef={headerRef} />
<main>
<Hero />
<BenefitsSection />
<EconomicsSection />
<GuestsSection />
<EquipmentSection />
<FormatsSection />
<WhyExoSection />
<RequestSection />
</main>
<SiteFooter />
</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+202
View File
@@ -0,0 +1,202 @@
import type { ReactNode } from 'react'
import { benefits, glossary, marketImplications } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, ScrollHint, Section, SectionHead } from './Layout'
export function BenefitsSection() {
return (
<Section id="benefits">
<Container>
<SectionHead
split
eyebrow="Бизнес-логика"
title="Почему это выгодно именно вашему отелю"
lead="Монетизируйте усталость гостя в моменте её проявления. Зачастую гость не планирует расслабление за месяц до отпуска. Эта потребность рождается внутри отеля — после активного дня или дороги, позволяя вам собирать дополнительную выручку без сложных предварительных продаж."
/>
<div aria-label="Преимущества для отеля" className="scroll-cards gap-[16px] md:grid-cols-4">
{benefits.map((benefit, index) => (
<BenefitCard key={benefit.num} {...benefit} index={index} />
))}
</div>
<ScrollHint>Листайте карточки в сторону</ScrollHint>
<MarketPanel />
</Container>
</Section>
)
}
function BenefitCard({ num, title, text, index }: (typeof benefits)[number] & { index: number }) {
const { ref, revealClass } = useReveal<HTMLElement>()
return (
<article
ref={ref}
style={{ transitionDelay: `${index * 80}ms` }}
className={cx(
'relative min-h-[190px] overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[25px] shadow-[0_15px_50px_rgb(10_37_64/0.055)]',
"after:absolute after:-right-[50px] after:-bottom-[65px] after:size-[150px] after:rounded-full after:bg-teal/[0.1] after:blur-[4px] after:content-['']",
revealClass,
)}
>
<span className="mb-[28px] block text-[13px] font-extrabold tracking-[0.12em] text-teal">{num}</span>
<h3 className="max-w-[290px]">{title}</h3>
<p className="mb-0 text-[14px] text-muted">{text}</p>
</article>
)
}
/**
* The market panel. Its charts animate off the `.is-visible` class this block
* gets when it scrolls in — see the `.chart-*` rules in index.css.
*/
function MarketPanel() {
const { ref, revealClass } = useReveal<HTMLDivElement>()
return (
<div
ref={ref}
aria-label="Рынок отелей 20252026"
className={cx(
'market-panel-surface mt-[24px] rounded-xl p-[clamp(22px,3vw,34px)] text-white shadow-deep',
revealClass,
)}
>
<div className="mb-[28px] grid gap-[12px] md:grid-cols-[1fr_1.15fr] md:items-end">
<h3 className="mb-0 text-[clamp(24px,3vw,34px)]">
Загрузка отелей снижается ценность каждого гостя становится важнее
</h3>
<p className="mb-0 max-w-[760px] text-white/[0.68]">
Гостевой поток ещё растёт, но предложение увеличивается быстрее, средняя загрузка в прогнозе 2026г. снижается
с 62,0% до 59,1%, а рост RevPAR отстаёт от роста ADR. Отелю сложнее расти только за счёт номера.
</p>
</div>
<div className="grid grid-cols-2 gap-[10px] sm:gap-[14px] md:grid-cols-4 tiny:grid-cols-1">
<MetricCard label="Гостевой поток, 2025" value="92 млн" foot={['2021 · 48', '+2,2% г/г']}>
<svg
role="img"
aria-label="Рост гостевого потока с 48 до 92 миллионов гостей с 2021 по 2025 год"
viewBox="0 0 280 90"
>
<line className="chart-gridline" x1="8" x2="272" y1="74" y2="74" />
<line className="chart-gridline" x1="8" x2="272" y1="42" y2="42" />
<polyline className="chart-line" points="14,69 76,47 138,31 200,22 266,18" />
<circle className="chart-dot" cx="14" cy="69" r="3" />
<circle className="chart-dot" cx="76" cy="47" r="3" />
<circle className="chart-dot" cx="138" cy="31" r="3" />
<circle className="chart-dot" cx="200" cy="22" r="3" />
<circle className="chart-dot" cx="266" cy="18" r="4" />
</svg>
</MetricCard>
<MetricCard label="Предложение на рынке" value="+14%" foot={['2024', '2025 · тыс. объектов']}>
<svg role="img" aria-label="Рост предложения с более чем 33 до 38 тысяч объектов" viewBox="0 0 280 90">
<line className="chart-gridline" x1="8" x2="272" y1="78" y2="78" />
<rect className="chart-bar chart-bar--muted" height="51" rx="7" width="62" x="58" y="27" />
<rect className="chart-bar" height="60" rx="7" width="62" x="165" y="18" />
<text fill="rgba(255,255,255,.58)" fontSize="10" textAnchor="middle" x="89" y="23">
&gt;33
</text>
<text fill="#39E0D2" fontSize="10" textAnchor="middle" x="196" y="14">
38
</text>
</svg>
</MetricCard>
<MetricCard label="Средняя загрузка, 2026г." value="2,9 п.п." foot={['2025г.', '2026г.']}>
<svg role="img" aria-label="Снижение средней загрузки с 62,0 до 59,1 процента" viewBox="0 0 280 90">
<line className="chart-gridline" x1="8" x2="272" y1="78" y2="78" />
<rect className="chart-bar chart-bar--muted" height="63" rx="7" width="62" x="58" y="15" />
<rect className="chart-bar" height="58" rx="7" width="62" x="165" y="20" />
<text fill="rgba(255,255,255,.58)" fontSize="10" textAnchor="middle" x="89" y="12">
62,0%
</text>
<text fill="#39E0D2" fontSize="10" textAnchor="middle" x="196" y="17">
59,1%
</text>
</svg>
</MetricCard>
<MetricCard label="Доходность номера, 2026г." value="разрыв роста" foot={['ADR', 'RevPAR']}>
<svg
role="img"
aria-label="Рост средней цены номера на 5,2 процента и выручки на номер на 2,5 процента"
viewBox="0 0 280 90"
>
<line className="chart-gridline" x1="8" x2="272" y1="78" y2="78" />
<rect className="chart-bar chart-bar--muted" height="60" rx="7" width="62" x="58" y="18" />
<rect className="chart-bar" height="29" rx="7" width="62" x="165" y="49" />
<text fill="rgba(255,255,255,.58)" fontSize="10" textAnchor="middle" x="89" y="15">
+5,2%
</text>
<text fill="#39E0D2" fontSize="10" textAnchor="middle" x="196" y="46">
+2,5%
</text>
</svg>
</MetricCard>
</div>
<div
aria-label="Что снижение загрузки означает для бизнеса отеля"
className="mt-[16px] grid gap-[10px] md:grid-cols-3"
>
{marketImplications.map((item) => (
<div key={item.title} className="rounded-[17px] border border-white/[0.11] bg-white/[0.055] p-[16px]">
<strong className="mb-[5px] block text-[13px] tracking-[0.02em] text-teal-bright">{item.title}</strong>
<span className="block text-[13px] leading-[1.5] text-white/[0.67]">{item.text}</span>
</div>
))}
</div>
<p className="mt-[14px] mb-0 text-[11px] text-white/[0.42]">
Показатели и формулировки из презентации «ЭкзоОтель», 20252026 гг. Влияние на LTV и привлечение гостей
зависит от упаковки, продвижения и операционной загрузки.
</p>
<div
aria-label="Расшифровка гостиничных показателей"
className="mt-[14px] grid gap-[8px] rounded-[16px] border border-navy/[0.1] bg-white px-[16px] py-[14px]"
>
{glossary.map((item) => (
<div
key={item.term}
className="grid grid-cols-[74px_1fr] items-start gap-[10px] text-[12px] leading-[1.45] text-muted narrow:grid-cols-1 narrow:gap-[2px]"
>
<strong className="font-[820] text-navy">{item.term}</strong>
<span>{item.text}</span>
</div>
))}
</div>
</div>
)
}
function MetricCard({
label,
value,
foot,
children,
}: {
label: string
value: string
foot: [string, string]
children: ReactNode
}) {
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">
<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>
</div>
{children}
<div className="flex justify-between gap-[8px] text-[10px] text-white/[0.44]">
<span>{foot[0]}</span>
<span>{foot[1]}</span>
</div>
</article>
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react'
import { cx } from '../lib/cx'
export type ButtonVariant = 'primary' | 'secondary' | 'call'
const base =
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[760] leading-none whitespace-nowrap ' +
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
'[&>svg]:size-[18px] [&>svg]:shrink-0 tiny:w-full'
/**
* Sizing gets its own slot rather than living in `base`. Tailwind resolves
* conflicting utilities by their order in the stylesheet, not by the order
* they appear in `className`, so a `min-h-[44px]` passed next to the default
* `min-h-[52px]` would silently lose. Replacing the slot means the losing
* class is never emitted in the first place.
*/
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'
const variants: Record<ButtonVariant, string> = {
primary:
'bg-teal text-navy shadow-[0_16px_45px_rgb(0_196_180/0.26)] hover:bg-teal-bright hover:shadow-[0_20px_55px_rgb(0_196_180/0.34)]',
secondary:
'border-white/[0.26] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.42] hover:bg-white/[0.14]',
call: 'border-white/[0.22] bg-white/[0.08] text-white backdrop-blur-[10px] hover:border-white/[0.38] hover:bg-white/[0.14] [&>svg]:size-[16px] compact:[&>svg]:size-[18px]',
}
export function buttonClass(variant: ButtonVariant, className?: string, size: string = defaultSize) {
return cx(base, size, variants[variant], className)
}
interface CommonProps {
variant?: ButtonVariant
/** Replaces the default min-height/padding pair. See `defaultSize` above. */
size?: string
className?: string
children: ReactNode
}
export function Button({
variant = 'primary',
size,
className,
children,
...rest
}: CommonProps & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'size'>) {
return (
<button className={buttonClass(variant, className, size)} {...rest}>
{children}
</button>
)
}
export function ButtonLink({
variant = 'primary',
size,
className,
children,
...rest
}: CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<a className={buttonClass(variant, className, size)} {...rest}>
{children}
</a>
)
}
+281
View File
@@ -0,0 +1,281 @@
import { useState } from 'react'
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 { Container, Section } from './Layout'
const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]'
const badge = 'grid size-[28px] place-items-center rounded-[10px] text-[11px]'
export function EconomicsSection() {
const head = useReveal<HTMLElement>()
const metrics = useReveal<HTMLDivElement>()
const loss = useReveal<HTMLElement>()
const gain = useReveal<HTMLElement>()
const bridge = useReveal<HTMLDivElement>()
return (
<Section id="hotel-economics" tone="none" className="overflow-hidden bg-econ-bg text-navy">
<div
aria-hidden="true"
className="pointer-events-none absolute -top-[270px] -right-[260px] size-[520px] rounded-full bg-teal/[0.13] blur-[18px]"
/>
<Container>
<header ref={head.ref} className={cx('relative z-1 mb-[28px] grid max-w-[920px] gap-[14px]', head.revealClass)}>
<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>
<p className="mb-0 max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-muted">
Средняя загрузка снижается с 62,0% до 59,1%, а средняя цена проданного номера растёт быстрее выручки на один
доступный номер. Это означает, что повышение стоимости проживания не полностью превращается в рост
доходности. ЭкзоОтель добавляет отдельный платный сервис к уже существующему гостевому потоку.
</p>
</header>
<div
ref={metrics.ref}
aria-label="Ключевые показатели экономики отеля"
className={cx('relative z-1 mb-[14px] grid grid-cols-2 gap-[10px] sm:grid-cols-4', metrics.revealClass)}
>
{econMetrics.map((metric) => (
<article
key={metric.label}
className="min-w-0 rounded-[18px] border border-teal/[0.26] bg-white/90 px-[16px] py-[17px] shadow-[0_12px_36px_rgb(4_31_50/0.055)] phone:px-[13px] phone:py-[15px]"
>
<strong className="block text-[clamp(22px,3vw,34px)] leading-[1.05] tracking-[-0.035em] text-econ-teal-deep tabular-nums">
{metric.value}
</strong>
<span className="mt-[7px] block text-[12px] leading-[1.32] font-[760] text-navy">{metric.label}</span>
<small className="mt-[4px] block text-[10px] leading-[1.35] text-muted">{metric.note}</small>
</article>
))}
</div>
<div className="relative z-1 grid gap-[14px] md:grid-cols-2">
<article
ref={loss.ref}
className={cx(
'econ-panel-loss min-w-0 rounded-[25px] p-[22px] text-white shadow-[0_24px_60px_rgb(4_25_41/0.16)] md:p-[27px] phone:p-[18px]',
loss.revealClass,
)}
>
<h3 className="mb-[17px] flex items-center gap-[11px] text-[20px] leading-[1.15] tracking-[-0.025em] text-white">
<i className="grid size-[34px] place-items-center rounded-[12px] bg-econ-red/[0.16] text-[17px] not-italic text-econ-red-pale">
</i>
Где деньги остаются нереализованными
</h3>
<div className="grid gap-[9px]">
{econLoss.map((item) => (
<div key={item.num} className={cx(panelItem, 'border border-white/[0.11] bg-white/[0.045]')}>
<b className={cx(badge, 'bg-econ-red/[0.16] text-econ-red-pale')}>{item.num}</b>
<div>
<strong className="mt-px mb-[4px] block text-[14px] leading-[1.25]">{item.title}</strong>
<span className="block text-[11px] leading-[1.43] text-white/[0.65]">{item.text}</span>
</div>
</div>
))}
</div>
</article>
<article
ref={gain.ref}
className={cx(
'min-w-0 rounded-[25px] border border-teal/[0.34] bg-white/[0.96] p-[22px] shadow-[0_24px_60px_rgb(4_31_50/0.075)] md:p-[27px] phone:p-[18px]',
gain.revealClass,
)}
>
<h3 className="mb-[17px] flex items-center gap-[11px] text-[20px] leading-[1.15] tracking-[-0.025em] text-navy">
<i className="grid size-[34px] place-items-center rounded-[12px] bg-econ-ice text-[17px] not-italic text-teal-ink">
</i>
На чём зарабатывает отель с Экзо
</h3>
<div className="grid gap-[9px]">
{econGain.map((item) => (
<div
key={item.num}
className={cx(panelItem, 'border border-navy/[0.095] bg-linear-to-b from-white to-[#F7FBFB]')}
>
<b className={cx(badge, 'bg-econ-ice text-teal-ink')}>{item.num}</b>
<div>
<strong className="mt-px mb-[4px] block text-[14px] leading-[1.25]">{item.title}</strong>
<span className="block text-[11px] leading-[1.43] text-muted">{item.text}</span>
</div>
</div>
))}
</div>
</article>
</div>
<div
ref={bridge.ref}
className={cx(
'relative z-1 my-[14px] grid gap-[10px] rounded-[19px] border border-teal/[0.28] bg-[linear-gradient(115deg,rgb(0_196_180/0.12),rgb(255_255_255/0.94))] px-[18px] py-[16px]',
bridge.revealClass,
)}
>
<strong className="text-[16px] leading-[1.25] text-navy">
Операционная база уже позволяет строить отдельную точку выручки
</strong>
<div className="grid grid-cols-2 gap-[8px] sm:grid-cols-4">
{econOps.map((op) => (
<div key={op.value} className="rounded-[14px] border border-teal/[0.22] bg-white px-[12px] py-[11px]">
<b className="block text-[17px] leading-[1.05] text-econ-teal-deep">{op.value}</b>
<span className="mt-[4px] block text-[9px] leading-[1.25] text-muted">{op.label}</span>
</div>
))}
</div>
<p className="mb-0 text-[11px] leading-[1.5] text-muted">
48 аппарат-часов технический ориентир: 4 аппарата × 12 часов. Фактическая пропускная способность зависит
от меню программ, сочетания процедур, санитарных интервалов и участия специалиста.
</p>
</div>
<RevenueCalculator />
</Container>
</Section>
)
}
/** `оплаченные программы в день × средний чек × рабочие дни = выручка в месяц` */
function RevenueCalculator() {
const { ref, revealClass } = useReveal<HTMLDivElement>()
const [programs, setPrograms] = useState('')
const [check, setCheck] = useState('')
const [days, setDays] = useState('30')
const programsValue = parseAmount(programs)
const checkValue = parseAmount(check)
const daysValue = parseAmount(days)
const ready = programsValue > 0 && checkValue > 0 && daysValue > 0
const volume = programsValue * daysValue
const month = volume * checkValue
return (
<div
ref={ref}
className={cx(
'econ-calc-surface relative z-1 mt-[14px] rounded-[25px] p-[22px] text-white shadow-[0_25px_70px_rgb(4_25_41/0.18)] md:p-[28px]',
revealClass,
)}
>
<div className="mb-[17px] grid gap-[6px]">
<span className="text-[10px] font-[850] tracking-[0.09em] text-teal-bright uppercase">Калькулятор сценария</span>
<h3 className="mb-0 text-[22px] leading-[1.15] text-white">
Посчитайте дополнительную выручку на своих тарифах
</h3>
<p className="mb-0 text-[11px] leading-[1.45] text-white/[0.58]">
Введите фактическое число оплаченных программ, средний чек и рабочие дни.
</p>
</div>
<div className="grid gap-[13px] md:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] md:items-end">
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 phone:grid-cols-1">
<CalcField
id="hotelPrograms"
label="Программ в день"
value={programs}
onChange={setPrograms}
placeholder="например, 8"
min={0}
step={1}
/>
<CalcField
id="hotelCheck"
label="Средний чек, ₽"
value={check}
onChange={setCheck}
placeholder="ваш тариф"
min={0}
step={100}
/>
<CalcField
id="hotelDays"
label="Рабочих дней в месяц"
value={days}
onChange={setDays}
min={1}
max={31}
step={1}
/>
</div>
<div className="grid grid-cols-2 gap-[9px] md:self-stretch phone:grid-cols-1">
<CalcResult
label="Дополнительная выручка / месяц"
value={ready ? formatMoney(month) : '—'}
note={ready ? `${formatNumber(volume)} программ в месяц` : 'Введите программы и чек'}
/>
<CalcResult
label="Дополнительная выручка / год"
value={ready ? formatMoney(month * 12) : '—'}
note="до вычета расходов"
/>
</div>
</div>
<p className="mt-[13px] mb-0 text-[9px] leading-[1.45] text-white/[0.43]">
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
рассчитываются после аудита отеля.
</p>
</div>
)
}
function CalcField({
id,
label,
value,
onChange,
placeholder,
min,
max,
step,
}: {
id: string
label: string
value: string
onChange: (value: string) => void
placeholder?: string
min?: number
max?: number
step?: number
}) {
return (
<div className="grid min-w-0 gap-[6px]">
<label htmlFor={id} className="text-[10px] font-[760] text-white/[0.72]">
{label}
</label>
<input
id={id}
type="number"
inputMode="numeric"
min={min}
max={max}
step={step}
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-[48px] w-full min-w-0 rounded-[13px] border border-white/[0.17] bg-white/[0.075] px-[13px] text-[15px] font-extrabold text-white tabular-nums outline-none placeholder:text-white/[0.28] focus:border-teal focus:shadow-[0_0_0_3px_rgb(0_196_180/0.13)]"
/>
</div>
)
}
function CalcResult({ label, value, note }: { label: string; value: string; note: string }) {
return (
<div className="min-w-0 rounded-[16px] border border-teal/[0.28] bg-teal/[0.09] p-[15px] md:flex md:flex-col md:justify-center">
<span className="block text-[9px] tracking-[0.055em] text-white/[0.56] uppercase">{label}</span>
<strong className="mt-[6px] block text-[clamp(19px,3vw,29px)] leading-[1.05] tracking-[-0.035em] whitespace-nowrap text-teal-bright tabular-nums phone:whitespace-normal">
{value}
</strong>
<small className="mt-[4px] block text-[9px] leading-[1.3] text-white/[0.48]">{note}</small>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { devices, hotelModel } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, ScrollHint, Section, SectionHead } from './Layout'
export function EquipmentSection() {
const model = useReveal<HTMLDivElement>()
const note = useReveal<HTMLDivElement>()
return (
<Section id="equipment" tone="none" className="equipment-shell overflow-hidden text-white">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-[-10%] -bottom-[160px] h-[320px] bg-teal/[0.12] blur-[100px]"
/>
<Container>
<SectionHead
split
tone="dark"
eyebrow="Что можно купить"
title="Комплект оборудования для отеля"
lead="Линейка из четырех аппаратов адаптируется под любой запрос клиента: от быстрых премиум-процедур в зоне отдыха до глубокой индивидуальной работы с оператором."
/>
<div className="scroll-cards relative z-1 gap-[16px] md:grid-cols-4">
{devices.map((device, index) => (
<DeviceCard key={device.name} {...device} index={index} />
))}
</div>
<ScrollHint tone="dark">Листайте оборудование в сторону</ScrollHint>
<div
ref={model.ref}
aria-label="Операционная модель кабинета восстановления в отеле"
className={cx(
'relative z-1 mt-[26px] rounded-xl border border-white/[0.13] bg-white/[0.06] p-[clamp(22px,3vw,32px)] shadow-[0_24px_70px_rgb(0_0_0/0.14)]',
model.revealClass,
)}
>
<div className="mb-[19px] grid gap-[9px]">
<h3 className="mb-0 text-[clamp(24px,3vw,34px)]">Как работает кабинет внутри отеля</h3>
<p className="mb-0 max-w-[820px] text-white/[0.67]">
Отель получает не просто четыре аппарата, а рабочую модель: помещение, расписание, меню программ, обучение
команды и измеримые показатели пилота.
</p>
</div>
<div className="grid grid-cols-3 gap-[10px] narrow:grid-cols-1">
{hotelModel.map((card) => (
<div
key={card.value}
className="min-h-[132px] rounded-[18px] border border-white/[0.1] bg-[#031523]/[0.28] p-[17px]"
>
<strong className="mb-[7px] block text-[clamp(21px,3vw,29px)] leading-none tracking-[-0.03em] text-teal-bright">
{card.value}
</strong>
<span className="block text-[12px] leading-[1.5] text-white/[0.67]">{card.text}</span>
</div>
))}
</div>
</div>
<div
ref={note.ref}
className={cx(
'relative z-1 mt-[22px] rounded-[16px] border border-white/[0.13] bg-white/[0.06] px-[18px] py-[16px] text-[12px] text-white/[0.6]',
note.revealClass,
)}
>
Гость покупает не название технологии, а понятный сервис: восстановление, лёгкость и премиальный опыт внутри
отеля. Медицинские формулировки и протоколы применяются только при наличии лицензии, профильных специалистов и
утверждённых протоколов.
</div>
</Container>
</Section>
)
}
function DeviceCard({ name, role, duration, tech, text, image, alt, index }: (typeof devices)[number] & { index: number }) {
const { ref, revealClass } = useReveal<HTMLElement>()
return (
<article
ref={ref}
style={{ transitionDelay: `${index * 80}ms` }}
className={cx(
'group relative min-h-[475px] overflow-hidden rounded-[26px] border border-white/[0.13] bg-white/[0.07] shadow-[0_24px_70px_rgb(0_0_0/0.18)] md:min-h-[510px]',
revealClass,
)}
>
<div className="relative h-[230px] overflow-hidden after:absolute after:inset-0 after:bg-[linear-gradient(180deg,transparent_40%,rgb(6_27_47/0.86))] after:content-[''] md:h-[245px]">
<img
alt={alt}
src={image}
width={1080}
height={720}
loading="lazy"
className="size-full object-cover transition-transform duration-700 ease-[cubic-bezier(.2,.8,.2,1)] group-hover:scale-[1.045]"
/>
</div>
<div className="px-[22px] pt-[2px] pb-[23px]">
<div className="relative z-2 -mt-[26px] flex flex-wrap items-center justify-between gap-[12px]">
<h3 className="mb-0 text-[27px]">{name}</h3>
<div className="flex min-h-[34px] w-[150px] shrink-0 items-center justify-center rounded-[12px] border border-teal/[0.28] bg-teal/[0.1] px-[10px] py-[7px] text-center text-[11px] leading-[1.15] font-[790] text-teal-bright [overflow-wrap:anywhere] compact:w-[142px]">
{role}
</div>
</div>
<span className="mt-[15px] mb-[14px] inline-flex rounded-full bg-teal/[0.12] px-[10px] py-[7px] text-[12px] font-[750] text-teal-bright">
{duration}
</span>
<p className="mb-[10px] text-[13px] leading-[1.45] font-[760] text-white">{tech}</p>
<p className="mb-0 text-[14px] text-white/[0.66]">{text}</p>
</div>
</article>
)
}
+63
View File
@@ -0,0 +1,63 @@
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)]'
function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; 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}
</label>
{children}
</div>
)
}
export function TextField({
id,
label,
full,
...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
return (
<Field id={id} label={label} full={full}>
<input id={id} className={cx(controlClass, 'h-[52px] px-[15px]')} {...rest} />
</Field>
)
}
export function TextareaField({
id,
label,
full,
...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>
)
}
/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
export function Honeypot({ value, onChange }: { value: string; onChange: (value: string) => void }) {
return (
<div aria-hidden="true" className="honeypot">
<label htmlFor="website">Не заполнять</label>
<input
autoComplete="off"
id="website"
name="website"
tabIndex={-1}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { programs, scenarios } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, ScrollHint, Section, SectionHead } from './Layout'
export function FormatsSection() {
const menu = useReveal<HTMLDivElement>()
return (
<Section id="formats" tone="white" compact>
<Container>
<SectionHead
split
eyebrow="Сценарии размещения"
title="Встраивается в маршрут гостя"
lead="Формат подстраивается под инфраструктуру отеля: отдельный SPA-кабинет, заметная капсула рядом с фитнесом, зона отдыха у бассейна или персональный сервис в номере."
/>
<div className="scroll-cards gap-[16px] md:grid-cols-4">
{scenarios.map((scenario, order) => (
<ScenarioCard key={scenario.title} {...scenario} order={order} />
))}
</div>
<ScrollHint>Листайте сценарии в сторону</ScrollHint>
<div
ref={menu.ref}
aria-label="Пример меню программ для гостей отеля"
className={cx(
'mt-[clamp(34px,5vw,54px)] rounded-xl border border-navy/[0.12] bg-[linear-gradient(145deg,#F7FCFC,#FFFFFF)] p-[clamp(22px,3vw,32px)] shadow-[0_18px_60px_rgb(10_37_64/0.06)]',
menu.revealClass,
)}
>
<div className="mb-[20px] grid gap-[9px]">
<h3 className="mb-0 text-[clamp(25px,3vw,36px)]">Продавать не аппарат, а понятную программу гостю</h3>
<p className="mb-0 max-w-[800px] text-muted">
Названия меню говорят языком сценария поездки: дорога, ноги, спина, активный день, SPA и время для себя.
</p>
</div>
<div className="scroll-cards gap-[12px] md:grid-cols-3">
{programs.map((program) => (
<article
key={program.title}
className="min-h-[220px] rounded-[19px] border border-teal/[0.23] bg-white p-[19px]"
>
<span className="mb-[14px] inline-flex rounded-full bg-teal/[0.11] px-[9px] py-[6px] text-[11px] font-[780] text-[#007E75]">
{program.time}
</span>
<h4 className="mb-[7px] text-[17px] leading-[1.2] text-navy">{program.title}</h4>
<div className="mt-[12px] grid gap-[10px]">
{program.devices.map((device) => (
<div
key={device.name}
className="grid gap-[3px] border-t border-navy/[0.09] pt-[10px] first:border-t-0 first:pt-0"
>
<strong className="text-[13px] font-extrabold text-navy">{device.name}</strong>
<span className="text-[12px] leading-[1.5] text-muted">{device.text}</span>
</div>
))}
</div>
</article>
))}
</div>
<ScrollHint>Листайте программы в сторону</ScrollHint>
</div>
</Container>
</Section>
)
}
function ScenarioCard({ index: label, title, text, image, alt, order }: (typeof scenarios)[number] & { order: number }) {
const { ref, revealClass } = useReveal<HTMLElement>()
return (
<article
ref={ref}
style={{ transitionDelay: `${order * 80}ms` }}
className={cx(
'group relative isolate min-h-[330px] overflow-hidden rounded-lg text-white shadow-[0_20px_60px_rgb(10_37_64/0.14)]',
"before:absolute before:inset-0 before:-z-2 before:bg-[linear-gradient(180deg,rgb(6_27_47/0.04)_25%,rgb(6_27_47/0.9)_100%)] before:content-['']",
"after:absolute after:inset-x-0 after:bottom-0 after:-z-1 after:h-[4px] after:origin-left after:scale-x-[0.22] after:bg-teal after:transition-transform after:duration-450 after:content-[''] group-hover:after:scale-x-100",
revealClass,
)}
>
<img
alt={alt}
src={image}
width={960}
height={640}
loading="lazy"
className="absolute inset-0 -z-3 size-full object-cover transition-transform duration-700 group-hover:scale-[1.04]"
/>
<div className="absolute inset-x-0 bottom-0 p-[24px]">
<span className="mb-[9px] block text-[12px] font-extrabold tracking-[0.12em] text-teal-bright">{label}</span>
<h3 className="mb-[8px]">{title}</h3>
<p className="mb-0 text-[13px] text-white/[0.7]">{text}</p>
</div>
</article>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { guestPortraits } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { guestIcons } from './Icons'
import { Container, ScrollHint, Section, SectionHead } from './Layout'
export function GuestsSection() {
return (
<Section id="guests" tone="white" compact>
<Container>
<SectionHead
eyebrow="Потребность гостя"
title="Портрет гостя отеля"
lead="Кабинет востребован у гостей, которые хотят восстановиться, не меняя привычный маршрут отдыха или поездки."
/>
<div className="scroll-cards gap-[16px] md:grid-cols-4">
{guestPortraits.map((portrait, index) => (
<PortraitCard key={portrait.title} {...portrait} index={index} />
))}
</div>
<ScrollHint>Листайте карточки в сторону</ScrollHint>
</Container>
</Section>
)
}
function PortraitCard({
icon,
title,
text,
index,
}: (typeof guestPortraits)[number] & { index: number }) {
const { ref, revealClass } = useReveal<HTMLElement>()
return (
<article
ref={ref}
style={{ transitionDelay: `${index * 80}ms` }}
className={cx(
'rounded-lg border border-teal/[0.28] bg-[linear-gradient(145deg,#FFFFFF,#F0FBFA)] p-[24px]',
revealClass,
)}
>
<div className="mb-[26px] grid size-[48px] place-items-center rounded-[15px] bg-teal/[0.14] text-navy [&>svg]:size-[24px]">
{guestIcons[icon]}
</div>
<h3>{title}</h3>
<p className="mb-0 text-[14px] text-muted">{text}</p>
</article>
)
}
+100
View File
@@ -0,0 +1,100 @@
import heroBg from '../assets/images/hero-bg.webp'
import { heroFacts } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
import { Container } from './Layout'
export function Hero() {
const kicker = useReveal<HTMLDivElement>()
const title = useReveal<HTMLHeadingElement>()
const subtitle = useReveal<HTMLParagraphElement>()
const actions = useReveal<HTMLDivElement>()
const facts = useReveal<HTMLDivElement>()
return (
<section
id="top"
aria-labelledby="hero-title"
className="relative isolate flex min-h-[760px] items-end overflow-hidden bg-navy-deep pt-[122px] pb-[48px] text-white
sm:min-h-[820px] sm:pb-[60px] md:min-h-[790px] md:items-center md:pt-[150px] md:pb-[78px]"
>
<div
aria-hidden="true"
className="absolute inset-0 -z-3 scale-[1.02] bg-cover bg-[position:60%_center]"
style={{ backgroundImage: `url(${heroBg})` }}
/>
<div aria-hidden="true" className="hero-scrim absolute inset-0 -z-2" />
<div
aria-hidden="true"
className="pointer-events-none absolute -right-[220px] -bottom-[180px] -z-1 size-[480px] rounded-full bg-teal/[0.38] blur-[100px]"
/>
<Container>
<div className="w-[min(100%,950px)]">
<div
ref={kicker.ref}
className={cx(
'mb-[22px] inline-flex items-center gap-[10px] rounded-full border border-white/[0.22] bg-[#051E2F]/[0.36] px-[13px] py-[9px]',
'text-[12px] font-[780] tracking-[0.1em] text-white/[0.84] uppercase backdrop-blur-[14px]',
kicker.revealClass,
)}
>
<i aria-hidden="true" className="size-[8px] rounded-full bg-teal shadow-[0_0_0_6px_rgb(0_196_180/0.14)]" />
Готовый формат внутри отеля
</div>
<h1 ref={title.ref} id="hero-title" className={cx('delay-[80ms]', title.revealClass)}>
ЭкзоОтель: Пространство восстановления для гостей
</h1>
<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]',
subtitle.revealClass,
)}
>
Кабинет восстановления, расслабления и дополнительной выручки внутри отеля
</p>
<div ref={actions.ref} className={cx('mb-[34px] flex flex-wrap gap-[12px] delay-[160ms]', actions.revealClass)}>
<ButtonLink href="#proposal">
Получить предложение
<ArrowRightIcon />
</ButtonLink>
<ButtonLink href="#equipment" variant="secondary">
Подобрать оборудование
</ButtonLink>
</div>
<div
ref={facts.ref}
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.
<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]"
>
<strong className="mb-[3px] block text-[clamp(20px,3vw,29px)] leading-[1.1] tracking-[-0.03em] tiny:mb-0 tiny:text-[21px]">
{fact.value}
</strong>
<span className="text-[12px] text-white/[0.62]">{fact.label}</span>
</div>
))}
</div>
</div>
</Container>
</section>
)
}
+99
View File
@@ -0,0 +1,99 @@
import type { ReactNode } from 'react'
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
function Icon({ children }: { children: ReactNode }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
{children}
</svg>
)
}
export function ArrowRightIcon() {
return (
<Icon>
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
</Icon>
)
}
export function PhoneIcon() {
return (
<Icon>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.86 19.86 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.9.35 1.78.68 2.62a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.46-1.25a2 2 0 0 1 2.11-.45c.84.33 1.72.56 2.62.68A2 2 0 0 1 22 16.92Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</Icon>
)
}
export function ChevronsIcon() {
return (
<Icon>
<path d="M8 7l-5 5 5 5M16 7l5 5-5 5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
</Icon>
)
}
/** Icons of the guest portraits, keyed by `guestPortraits[].icon`. */
export const guestIcons = {
road: (
<Icon>
<path d="M3 12h18M5 8h4l2-4h2l2 4h4l2 4-2 4h-4l-2 4h-2l-2-4H5l-2-4 2-4Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
</Icon>
),
active: (
<Icon>
<path
d="M13 5.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM10 22l1-6-3-3 2-5 4 1 2 3h4M5 22l2-6"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</Icon>
),
wellness: (
<Icon>
<path d="M12 21c4.5-3 7-6.2 7-10a7 7 0 0 0-14 0c0 3.8 2.5 7 7 10Z" stroke="currentColor" strokeWidth="1.7" />
<path
d="M9 11c1.8.1 3.2-.9 4-3 1.8 1.4 2.4 3.2 1.5 5.3-1 2.2-3.3 3.1-5.5 2"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</Icon>
),
business: (
<Icon>
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 9h18v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" stroke="currentColor" strokeWidth="1.7" />
<path d="M3 13h18M10 13v2h4v-2" stroke="currentColor" strokeWidth="1.7" />
</Icon>
),
}
/** Icons of the "почему Экзо" list, keyed by `whyItems[].icon`. */
export const whyIcons = {
tech: (
<Icon>
<path d="M12 3 3 7.5 12 12l9-4.5L12 3Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
<path d="M5 11v5.5L12 21l7-4.5V11" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
</Icon>
),
factory: (
<Icon>
<path d="M3 21V9l5 3V7l5 3V3h8v18H3Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.7" />
<path d="M16 7h2M16 11h2M7 16h2M12 16h2M17 16h2" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
</Icon>
),
launch: (
<Icon>
<path d="M4 4h16v16H4V4Z" stroke="currentColor" strokeWidth="1.7" />
<path d="m8 12 2.5 2.5L16 9" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
</Icon>
),
}
+148
View File
@@ -0,0 +1,148 @@
import type { ReactNode } from 'react'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { ChevronsIcon } from './Icons'
/** `.container` — 1200px max, 16px gutters that grow to 24px from 640px up. */
export function Container({ className, children }: { className?: string; children: ReactNode }) {
return (
<div
className={cx(
'mx-auto w-[min(calc(100%_-_32px),var(--container-page))] sm:w-[min(calc(100%_-_48px),var(--container-page))]',
className,
)}
>
{children}
</div>
)
}
/** Small uppercase kicker with the leading teal rule. */
export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<div
className={cx(
'mb-[16px] inline-flex items-center gap-[10px] text-[12px] font-extrabold tracking-[0.14em] uppercase',
"before:h-[2px] before:w-[26px] before:rounded-[2px] before:bg-teal before:content-['']",
tone === 'dark' ? 'text-teal-bright' : 'text-teal-ink',
className,
)}
>
{children}
</div>
)
}
type Tone = 'light' | 'dark'
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,
)}
>
{children}
</p>
)
}
/**
* `.section-head` — stacked by default; `split` moves the lead into a second
* column from 900px up, right-aligned against the heading.
*/
export function SectionHead({
eyebrow,
title,
lead,
tone = 'light',
split = false,
}: {
eyebrow: string
title: ReactNode
lead?: ReactNode
tone?: Tone
split?: boolean
}) {
const { ref, revealClass } = useReveal<HTMLDivElement>()
if (!split) {
return (
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2>{title}</h2>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div>
)
}
return (
<div
ref={ref}
className={cx(
'mb-[clamp(34px,5vw,54px)] grid gap-[18px] md:grid-cols-[1.02fr_0.78fr] md:items-end',
revealClass,
)}
>
<div>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2 className="mb-0">{title}</h2>
</div>
{lead ? (
<SectionLead tone={tone} className="md:justify-self-end">
{lead}
</SectionLead>
) : null}
</div>
)
}
const sectionTones = {
light: 'bg-paper',
white: 'bg-white',
dark: 'bg-navy text-white',
/** For sections that paint their own multi-layer background in index.css. */
none: '',
}
/** `.section` — the shared vertical rhythm and background variants. */
export function Section({
id,
tone = 'light',
compact = false,
className,
children,
}: {
id?: string
tone?: keyof typeof sectionTones
compact?: boolean
className?: string
children: ReactNode
}) {
return (
<section
id={id}
className={cx('relative', compact ? 'py-section-compact' : 'py-section', sectionTones[tone], className)}
>
{children}
</section>
)
}
/** "Листайте карточки в сторону" — only shown while the row still scrolls. */
export function ScrollHint({ tone = 'light', children }: { tone?: Tone; children: ReactNode }) {
return (
<div
aria-hidden="true"
className={cx(
'mt-[8px] flex items-center gap-[7px] text-[11px] md:hidden [&>svg]:size-[16px]',
tone === 'dark' ? 'text-white/[0.52]' : 'text-muted',
)}
>
<ChevronsIcon />
{children}
</div>
)
}
+152
View File
@@ -0,0 +1,152 @@
import { useState, type FormEvent } from 'react'
import { useReveal } from '../hooks/useReveal'
import { submitLead } from '../lib/lead'
import { cx } from '../lib/cx'
import { Button } from './Button'
import { Honeypot, TextField, TextareaField } from './FormField'
import { ArrowRightIcon } from './Icons'
const EMPTY = {
name: '',
company: '',
phone: '',
email: '',
comment: '',
}
type Status = { text: string; tone: 'idle' | 'success' | 'error' }
export function LeadForm() {
const { ref: formRef, revealClass } = useReveal<HTMLFormElement>()
const [values, setValues] = useState(EMPTY)
const [honeypot, setHoneypot] = useState('')
const [pending, setPending] = useState(false)
const [status, setStatus] = useState<Status>({ text: '', tone: 'idle' })
const set = (key: keyof typeof EMPTY) => (event: { target: { value: string } }) =>
setValues((current) => ({ ...current, [key]: event.target.value }))
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = formRef.current
if (!form) return
if (!form.checkValidity()) {
form.reportValidity()
setStatus({ text: 'Проверьте обязательные поля.', tone: 'error' })
return
}
if (honeypot) return
setPending(true)
setStatus({ text: '', tone: 'idle' })
const result = await submitLead('request', { ...values, website: honeypot })
if (result.ok) {
setValues(EMPTY)
setStatus({
text: 'Спасибо. Заявка отправлена — специалист подготовит предложение и свяжется с вами.',
tone: 'success',
})
} else {
setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
}
setPending(false)
}
return (
<form
ref={formRef}
id="leadForm"
noValidate
onSubmit={onSubmit}
className={cx(
'rounded-xl border border-white/[0.15] bg-white/[0.09] p-[clamp(22px,4vw,34px)] shadow-[0_32px_90px_rgb(0_0_0/0.2)] backdrop-blur-[18px] delay-[80ms]',
revealClass,
)}
>
<div className="grid gap-[14px] sm:grid-cols-2">
<TextField
id="name"
label="Имя"
name="name"
autoComplete="name"
minLength={2}
placeholder="Как к вам обращаться"
required
value={values.name}
onChange={set('name')}
/>
<TextField
id="company"
label="Компания / отель"
name="company"
autoComplete="organization"
minLength={2}
placeholder="Название отеля"
required
value={values.company}
onChange={set('company')}
/>
<TextField
id="phone"
label="Телефон"
name="phone"
type="tel"
autoComplete="tel"
inputMode="tel"
placeholder="+7 900 000-00-00"
required
value={values.phone}
onChange={set('phone')}
/>
<TextField
id="email"
label="Email"
name="email"
type="email"
autoComplete="email"
placeholder="name@hotel.ru"
required
value={values.email}
onChange={set('email')}
/>
<TextareaField
id="comment"
label="Комментарий"
name="comment"
full
placeholder="Город, категория отеля, наличие SPA / фитнес-зоны"
value={values.comment}
onChange={set('comment')}
/>
<Honeypot value={honeypot} onChange={setHoneypot} />
</div>
<p className="mt-[12px] mb-[16px] text-[11px] text-white/[0.48]">
Нажимая кнопку, вы подтверждаете согласие на обработку данных для подготовки предложения.
</p>
<Button type="submit" className="w-full border-0" disabled={pending} aria-busy={pending}>
<span>{pending ? 'Отправляем…' : 'Получить предложение'}</span>
{pending ? null : <ArrowRightIcon />}
</Button>
{/* Kept mounted so screen readers announce the result in place. */}
<div
role="status"
aria-live="polite"
className={cx(
status.text && 'mt-[14px] rounded-[12px] border px-[14px] py-[12px] text-[13px]',
status.tone === 'success' && 'border-teal/[0.25] bg-teal/[0.14] text-[#BFFBF4]',
status.tone === 'error' && 'border-[#FF5F5F]/[0.24] bg-[#FF5F5F]/[0.12] text-[#FFD5D5]',
)}
>
{status.text}
</div>
</form>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionLead } from './Layout'
import { LeadForm } from './LeadForm'
export function RequestSection() {
const copy = useReveal<HTMLDivElement>()
return (
<Section id="proposal" tone="none" className="cta-surface overflow-hidden text-white">
<div aria-hidden="true" className="cta-grid-overlay absolute inset-0 opacity-[0.23]" />
<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>
</div>
<LeadForm />
</Container>
</Section>
)
}
+22
View File
@@ -0,0 +1,22 @@
import logo from '../assets/images/exo-logo.png'
import { Container } from './Layout'
export function SiteFooter() {
return (
<footer className="bg-footer py-[26px] text-[12px] text-white/[0.5]">
<Container className="flex flex-col items-start gap-[17px] sm:flex-row sm:items-center sm:justify-between">
<img
alt="Экзо Групп — российские технологии реабилитации"
className="block h-auto w-[clamp(150px,16vw,180px)] max-w-[min(50vw,180px)] object-contain"
src={logo}
width={900}
height={204}
/>
<div className="flex flex-wrap gap-x-[20px] gap-y-[14px]">
<span>ЭкзоОтель · пространство восстановления для гостей</span>
<span>© {new Date().getFullYear()} ООО «Экзо Групп»</span>
</div>
</Container>
</footer>
)
}
+56
View File
@@ -0,0 +1,56 @@
import type { RefObject } from 'react'
import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content'
import { cx } from '../lib/cx'
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> }) {
return (
<header
ref={headerRef}
className={cx(
'fixed inset-x-0 top-0 z-50 py-[14px] transition-[background-color,box-shadow,backdrop-filter] duration-250 md:py-[18px]',
scrolled && 'bg-navy-deep/[0.86] shadow-[0_12px_40px_rgb(3_20_34/0.18)] backdrop-blur-[18px]',
)}
>
<Container className="flex items-center justify-between gap-[18px]">
<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]"
src={logo}
width={900}
height={204}
/>
</a>
<nav
aria-label="Основная навигация"
className="hidden items-center gap-[30px] text-[14px] font-[650] text-white/[0.76] md:flex"
>
{navLinks.map((link) => (
<a key={link.href} href={link.href} className="transition-colors duration-200 hover:text-white">
{link.label}
</a>
))}
</nav>
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]">
<a
href={contacts.phoneHref}
aria-label="Позвонить в Экзо Групп"
className={buttonClass('call', 'compact:px-[12px]', headerSize)}
>
<PhoneIcon />
<span className="compact:hidden">Звонок</span>
</a>
<ButtonLink href="#proposal" size={headerSize}>
Получить предложение
</ButtonLink>
</div>
</Container>
</header>
)
}
+80
View File
@@ -0,0 +1,80 @@
import whyVisual from '../assets/images/scenario-recovery.webp'
import { whyItems } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx'
import { whyIcons } from './Icons'
import { Container, Section, SectionHead } from './Layout'
export function WhyExoSection() {
const visual = useReveal<HTMLDivElement>()
return (
<Section id="why-exo">
<Container>
<SectionHead
eyebrow="Партнёр внедрения"
title="Почему Экзо Групп"
lead="Отель получает четыре аппарата, визуальную концепцию, меню программ, расчёт экономики, формат с одним специалистом, обучение, чек-листы запуска и сценарии для SPA и ресепшен."
/>
<div className="grid items-stretch gap-[30px] md:grid-cols-[1.08fr_0.92fr]">
<div
ref={visual.ref}
className={cx(
'relative isolate min-h-[360px] overflow-hidden rounded-xl bg-navy shadow-deep',
"before:absolute before:inset-0 before:-z-1 before:bg-[linear-gradient(155deg,rgb(6_27_47/0.18),rgb(6_27_47/0.88))] before:content-['']",
visual.revealClass,
)}
>
<img
alt="Пространство восстановления ЭкзоОтель рядом с фитнес-зоной"
src={whyVisual}
width={960}
height={640}
loading="lazy"
className="absolute inset-0 -z-2 size-full object-cover"
/>
<div className="absolute inset-x-[24px] bottom-[24px] max-w-[410px] rounded-[20px] border border-white/[0.16] bg-[#061B2F]/[0.62] p-[22px] text-white backdrop-blur-[14px]">
<strong className="mb-[6px] block text-[22px] leading-[1.2]">
Не просто оборудование готовое направление внутри отеля
</strong>
<span className="text-[13px] text-white/[0.66]">
Визуальная концепция, меню программ, формат с одним специалистом, обучение, чек-листы и сценарии для SPA
и ресепшен.
</span>
</div>
</div>
<div className="grid content-start gap-[12px]">
{whyItems.map((item, index) => (
<WhyItem key={item.title} {...item} index={index} />
))}
</div>
</div>
</Container>
</Section>
)
}
function WhyItem({ icon, title, text, index }: (typeof whyItems)[number] & { index: number }) {
const { ref, revealClass } = useReveal<HTMLElement>()
return (
<article
ref={ref}
style={{ transitionDelay: `${index * 80}ms` }}
className={cx(
'grid grid-cols-[48px_1fr] items-start gap-[15px] rounded-[18px] border border-navy/[0.12] bg-white p-[18px]',
revealClass,
)}
>
<div className="grid size-[48px] place-items-center rounded-[15px] bg-ice text-navy [&>svg]:size-[23px]">
{whyIcons[icon]}
</div>
<div>
<strong className="mb-[4px] block text-[16px]">{title}</strong>
<p className="mb-0 text-[13px] text-muted">{text}</p>
</div>
</article>
)
}
+340
View File
@@ -0,0 +1,340 @@
import deviceHydrogen from '../assets/images/device-hydrogen.webp'
import devicePress from '../assets/images/device-press.webp'
import deviceTecar from '../assets/images/device-tecar.webp'
import deviceTherapy from '../assets/images/device-therapy.webp'
import scenarioActive from '../assets/images/scenario-active.webp'
import scenarioRecovery from '../assets/images/scenario-recovery.webp'
import scenarioSpa from '../assets/images/scenario-spa.webp'
import scenarioWellness from '../assets/images/scenario-wellness.webp'
export const contacts = {
phone: '+7 939 717-80-80',
phoneHref: 'tel:+79397178080',
}
export const navLinks = [
{ href: '#benefits', label: 'Выгода' },
{ href: '#guests', label: 'Гости' },
{ href: '#equipment', label: 'Оборудование' },
{ href: '#formats', label: 'Форматы' },
]
export const heroFacts = [
{ value: '4 аппарата', label: 'единое пространство восстановления' },
{ value: '1 оператор', label: 'при дисциплинированном расписании' },
{ value: '12 часов', label: 'базовый режим работы кабинета' },
]
/* -------------------------------------------------------------------------- */
/* Выгода для отеля */
/* -------------------------------------------------------------------------- */
export const benefits = [
{
num: '01',
title: 'Новый источник выручки на текущем потоке гостей',
text: 'Дополнительная точка продаж внутри существующей инфраструктуры отеля.',
},
{
num: '02',
title: 'Премиальный сервис без расширения номерного фонда',
text: 'Современная зона дополняет SPA, wellness или фитнес-пространство.',
},
{
num: '03',
title: 'Причина остаться в SPA / фитнес-зоне дольше',
text: 'Готовое меню программ и дополнительные услуги к проживанию.',
},
{
num: '04',
title: 'Стимул для LTV и привлечения новых гостей',
text: 'Заметный wellness-tech сервис создаёт дополнительный повод выбрать отель, купить больше услуг во время проживания и вернуться за знакомым форматом отдыха.',
},
]
export const marketImplications = [
{
title: 'LTV текущего гостя',
text: 'Больше поводов купить SPA / wellness-услугу, включить восстановление в пакет проживания и вернуться за повторным опытом.',
},
{
title: 'Новые гости',
text: 'Выразительное пространство восстановления усиливает предложение отеля и становится дополнительным аргументом при выборе поездки.',
},
{
title: 'Текущий поток',
text: 'Дополнительная точка выручки внутри действующей инфраструктуры — без расширения номерного фонда.',
},
]
export const glossary = [
{ term: 'RevPAR', text: 'Revenue per Available Room — выручка на один доступный номер.' },
{ term: 'ADR', text: 'Average Daily Rate — средняя цена проданного номера.' },
{ term: 'LTV', text: 'Lifetime Value — совокупная ценность гостя за весь период отношений с отелем.' },
]
/* -------------------------------------------------------------------------- */
/* Экономика */
/* -------------------------------------------------------------------------- */
export const econMetrics = [
{ value: '2,9', label: 'процентного пункта снижения загрузки', note: 'с 62,0% до 59,1% в прогнозе на 2026 год' },
{
value: '2,7',
label: 'процентного пункта — разрыв темпов роста',
note: 'цена проданного номера +5,2%; выручка на доступный номер +2,5%',
},
{ value: '+14%', label: 'рост предложения', note: 'конкуренция за гостя усиливается' },
{ value: '92 млн', label: 'гостевой поток 2025', note: 'спрос есть — важна глубина монетизации' },
]
export const econLoss = [
{
num: '01',
title: 'Доход зависит в основном от номера',
text: 'При снижении загрузки пустой номер уже нельзя компенсировать одним повышением средней цены проданного номера.',
},
{
num: '02',
title: 'Потребность возникает, но не продаётся',
text: 'Усталость после дороги, лыж, прогулок или тренировок появляется уже в отеле — без recovery-меню чек не возникает.',
},
{
num: '03',
title: 'Дополнительный расход уходит наружу',
text: 'Гость покупает массаж и восстановление вне отеля либо вовсе отказывается от услуги.',
},
]
export const econGain = [
{
num: '01',
title: 'Разовые программы в момент спроса',
text: 'Гость покупает понятный сценарий: после дороги, для ног, спины, SPA или активного дня.',
},
{
num: '02',
title: 'Пакеты и upgrade к проживанию',
text: 'Recovery добавляется к SPA, фитнесу, premium-тарифу номера или программе активного отдыха.',
},
{
num: '03',
title: 'Повторный опыт и рост LTV',
text: 'Знакомый wellness-ритуал становится причиной купить больше услуг и вернуться в отель.',
},
]
export const econOps = [
{ value: '4 аппарата', label: 'разные сценарии гостя' },
{ value: '1 оператор', label: 'основное участие — ЭкзоТекар' },
{ value: '12 часов', label: 'базовый режим расписания' },
{ value: 'до 48', label: 'аппарат-часов в день до учёта буферов' },
]
/* -------------------------------------------------------------------------- */
/* Гости */
/* -------------------------------------------------------------------------- */
export const guestPortraits = [
{
icon: 'road',
title: 'Гость после дороги',
text: 'После перелёта или длительной поездки хочет снять усталость, напряжение в теле и быстрее включиться в отдых.',
},
{
icon: 'active',
title: 'Активный гость',
text: 'Выбирает лыжи, прогулки, экскурсии, бассейн, тренировки или хайкинг и восстанавливается между активностями.',
},
{
icon: 'wellness',
title: 'Wellness-гость',
text: 'Посещает SPA, массаж, бассейн или банный комплекс и воспринимает кабинет как дополнительный сервис восстановления.',
},
{
icon: 'business',
title: 'Деловой гость',
text: 'Ценит возможность уделить время себе, не выезжая из отеля и не перестраивая график встреч или командировки.',
},
] as const
/* -------------------------------------------------------------------------- */
/* Оборудование */
/* -------------------------------------------------------------------------- */
export const devices = [
{
name: 'ЭкзоТерапия',
role: 'Флагман SPA',
duration: '10 минут',
tech: 'Высокоинтенсивная импульсная магнитотерапия.',
text: 'Короткий 10-минутный ритуал для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
image: deviceTherapy,
alt: 'ЭкзоТерапия в премиальной SPA-капсуле',
},
{
name: 'ЭкзоТекар',
role: 'Работа специалиста',
duration: '2030 минут',
tech: 'Текар-терапия + ударно-волновая терапия + ультразвуковая терапия.',
text: 'Индивидуальная работа специалиста после активного дня — для шеи, спины, плеч или ног. Процедура занимает отдельный слот в расписании.',
image: deviceTecar,
alt: 'ЭкзоТекар в кабинете премиальных процедур',
},
{
name: 'ЭкзоПресс',
role: 'Потоковый формат',
duration: '2030 минут',
tech: 'Компрессионная терапия + тепловая терапия + холодовая терапия.',
text: 'Компрессия + тепло / холод для ног после дороги, перелёта, экскурсий и лыж. Потоковый формат, который не требует отдельного кабинета.',
image: devicePress,
alt: 'ЭкзоПресс у бассейна в SPA-зоне',
},
{
name: 'ЭкзоВодород',
role: 'Автономная пауза',
duration: '40 минут',
tech: 'Ингаляция молекулярным водородом',
text: 'Комфортная 40-минутная пауза в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
image: deviceHydrogen,
alt: 'ЭкзоВодород в спокойной wellness-зоне',
},
]
export const hotelModel = [
{ value: '1245 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
{
value: '1 оператор',
text: 'Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.',
},
{ value: '12 часов', text: 'Расписание с буферами на подготовку места, сопровождение гостя и санитарную обработку.' },
]
/* -------------------------------------------------------------------------- */
/* Форматы размещения и меню программ */
/* -------------------------------------------------------------------------- */
export const scenarios = [
{
index: '01 · SPA',
title: 'SPA-зона',
text: 'Формат 3-в-1 или приватный кабинет, где гость проходит несколько процедур подряд по записи.',
image: scenarioSpa,
alt: 'Приватный SPA-кабинет с оборудованием Экзо Групп',
},
{
index: '02 · Wellness',
title: 'Wellness-зона',
text: 'ЭкзоВодород или ЭкзоПресс в комнате отдыха, у бассейна, перед SPA или как персональный сервис в номер.',
image: scenarioWellness,
alt: 'ЭкзоВодород в wellness-зоне отдыха',
},
{
index: '03 · Recovery',
title: 'Recovery-зона',
text: 'Мягкая или стеклянная капсула рядом с фитнесом и SPA: технология заметна гостю, но процедура сохраняет приватность.',
image: scenarioRecovery,
alt: 'Стеклянная recovery-зона рядом с фитнесом',
},
{
index: '04 · Active day',
title: 'После активного дня',
text: 'Готовые программы после лыж, хайкинга, прогулок, бассейна, тренировки или экскурсии.',
image: scenarioActive,
alt: 'ЭкзоТерапия рядом с фитнес-зоной',
},
]
export const programs = [
{
time: '4060 минут',
title: 'Восстановление после дороги',
devices: [
{
name: 'ЭкзоВодород',
text: '40-минутная автономная пауза в wellness-зоне после перелёта или длительной поездки.',
},
{ name: 'ЭкзоПресс', text: 'Пассивный этап для ног после длительного сидения, дороги и перелёта.' },
],
},
{
time: '2030 минут',
title: 'Лёгкость в ногах',
devices: [
{
name: 'ЭкзоПресс',
text: 'Компрессионный, тепловой или холодовой формат после прогулок, экскурсий, лыж и долгого дня на ногах.',
},
{
name: 'ЭкзоТекар',
text: 'Помогает снять мышечное напряжение и ощущение тяжести, улучшить локальное восстановление мягких тканей после длительной ходьбы, активного дня или нагрузки на ноги.',
},
],
},
{
time: '4570 минут',
title: 'После активного дня',
devices: [
{
name: 'ЭкзоТекар',
text: 'Индивидуальная работа специалиста с локальными зонами после лыж, хайкинга, тренировки или бассейна.',
},
{ name: 'ЭкзоПресс', text: 'Пассивный этап для ног после продолжительной физической нагрузки.' },
],
},
{
time: '3550 минут',
title: 'Спина и шея',
devices: [
{
name: 'ЭкзоТекар',
text: 'Индивидуальная работа специалиста с областью спины, шеи и плеч по предварительной записи.',
},
{ name: 'ЭкзоТерапия', text: 'Короткий 10-минутный этап, который можно встроить в персональную программу гостя.' },
],
},
{
time: '6080 минут',
title: 'Время для себя',
devices: [
{ name: 'ЭкзоТерапия', text: 'Короткий премиальный этап восстановления внутри SPA-маршрута.' },
{ name: 'ЭкзоВодород', text: 'Автономная 40-минутная wellness-пауза в кресле.' },
{ name: 'ЭкзоПресс', text: 'Пассивная процедура, которую удобно сочетать со SPA и отдыхом.' },
],
},
{
time: 'до 90 минут',
title: 'Индивидуальная программа',
devices: [
{ name: 'ЭкзоТерапия', text: 'Короткий флагманский этап программы.' },
{ name: 'ЭкзоТекар', text: 'Индивидуальная работа специалиста с выбранными зонами.' },
{ name: 'ЭкзоПресс', text: 'Потоковый пассивный формат для ног.' },
{
name: 'ЭкзоВодород',
text: 'Автономная wellness-пауза; состав и последовательность выбираются под сценарий гостя и формат отеля.',
},
],
},
]
/* -------------------------------------------------------------------------- */
/* Почему Экзо */
/* -------------------------------------------------------------------------- */
export const whyItems = [
{
icon: 'tech',
title: 'Российские технологии',
text: 'Технологичный формат восстановления и премиального сервиса для гостиничной инфраструктуры.',
},
{
icon: 'factory',
title: 'Собственное производство в Тольятти',
text: 'Оборудование, визуальная концепция и модель внедрения собраны в единую систему Экзо Групп.',
},
{
icon: 'launch',
title: 'Готовая модель запуска',
text: 'Подбор формата, включение услуг в SPA-прайс и приложение, обучение операторов и ресепшен, сценарии продаж и материалы запуска.',
},
] as const
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, type RefObject } from 'react'
/**
* In-page anchors scroll to the target minus the fixed header, exactly as the
* original landing did. The header height is read at click time because it
* changes with the breakpoint and with the logo's fluid width.
*/
export function useAnchorScroll(headerRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const onClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return
const link = (event.target as Element | null)?.closest('a[href^="#"]')
const href = link?.getAttribute('href')
if (!href || href === '#') return
const target = document.querySelector(href)
if (!target) return
event.preventDefault()
const offset = (headerRef.current?.offsetHeight ?? 0) + 8
window.scrollTo({ top: target.getBoundingClientRect().top + window.scrollY - offset, behavior: 'smooth' })
}
document.addEventListener('click', onClick)
return () => document.removeEventListener('click', onClick)
}, [headerRef])
}
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useRef, useState } from 'react'
/**
* Scroll-in animation, matching the original page: fires once at 12% visibility
* and then stops observing. The `.reveal` / `.is-visible` pair lives in CSS so
* that `prefers-reduced-motion` can neutralise it in one place.
*/
export function useReveal<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const element = ref.current
if (!element || visible) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
setVisible(true)
observer.unobserve(entry.target)
}
},
{ threshold: 0.12 },
)
observer.observe(element)
return () => observer.disconnect()
}, [visible])
return { ref, visible, revealClass: visible ? 'reveal is-visible' : 'reveal' }
}
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react'
/**
* The header gains its blurred background as soon as the page leaves the very
* top — 18px, same threshold as the original landing.
*/
export function useScrolled(threshold = 18) {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > threshold)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [threshold])
return scrolled
}
+331
View File
@@ -0,0 +1,331 @@
@import "tailwindcss";
/* ---------------------------------------------------------------------------
Design tokens — ported 1:1 from the :root block of the original landing.
The default Tailwind palette and breakpoints are cleared so that only the
EXO design system is reachable from utility classes.
--------------------------------------------------------------------------- */
@theme {
--color-*: initial;
--color-transparent: transparent;
--color-current: currentColor;
--color-white: #ffffff;
--color-black: #000000;
/* Brand */
--color-navy: #0a2540;
--color-navy-deep: #061b2f;
--color-navy-3: #0e3352;
--color-teal: #00c4b4;
--color-teal-bright: #39e0d2;
--color-teal-ink: #008f84;
--color-ice: #eafbf8;
/* Surfaces & text */
--color-paper: #f5fafb;
--color-ink: #0a2540;
--color-muted: #60778b;
--color-footer: #041421;
/* Economics section palette (shared with the fitness landing) */
--color-econ-bg: #f4fafb;
--color-econ-teal-deep: #00a99a;
--color-econ-ice: #eaf9f7;
--color-econ-red: #f0656b;
--color-econ-red-pale: #ffd7d9;
/* Radii */
--radius-md: 18px;
--radius-lg: 24px;
--radius-xl: 32px;
/* Elevation */
--shadow-deep: 0 28px 90px rgb(3 26 43 / 0.18);
/* Layout rhythm */
--container-page: 1200px;
--spacing-section: clamp(68px, 8vw, 112px);
--spacing-section-compact: clamp(60px, 6vw, 88px);
/* Breakpoints — the original used 640 and 900 */
--breakpoint-*: initial;
--breakpoint-sm: 640px;
--breakpoint-md: 900px;
/* "Inter Variable" is the family Fontsource registers; the rest of the chain
is the original stack, kept for the swap window and for the few glyphs
(→ ↗ ↘) that Inter's subsets do not carry. */
--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));
@custom-variant narrow (@media (max-width: 780px));
@layer base {
html {
scroll-behavior: smooth;
background: var(--color-navy-deep);
}
body {
background-color: var(--color-paper);
color: var(--color-ink);
font-family: var(--font-sans);
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
::selection {
background: rgb(0 196 180 / 0.28);
color: var(--color-navy);
}
/* Preflight zeroes the type scale; the original relied on these exact values
for every heading on the page, so they are restored once, here. */
h1,
h2,
h3 {
text-wrap: balance;
}
h1 {
margin-bottom: 22px;
max-width: 900px;
font-size: clamp(42px, 8vw, 86px);
font-weight: 760;
line-height: 0.98;
letter-spacing: -0.055em;
}
h2 {
margin-bottom: 20px;
font-size: clamp(34px, 5vw, 58px);
font-weight: 740;
line-height: 1.03;
letter-spacing: -0.045em;
}
h3 {
margin-bottom: 10px;
font-size: clamp(21px, 2.4vw, 28px);
font-weight: bold;
line-height: 1.14;
letter-spacing: -0.025em;
}
h4 {
font-weight: bold;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
}
@layer components {
/* Scroll-triggered entrance. A class rather than utilities because it is
applied to ~40 elements and toggled from a shared IntersectionObserver. */
.reveal {
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.7s ease,
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Card rows that scroll horizontally below 900px and become a plain grid
above it. `grid-auto-columns` has no utility equivalent, and the row is
reused by five sections, so it stays a single component class. The desktop
column count is applied per section with `md:grid-cols-*`. */
.scroll-cards {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(82%, 1fr);
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-snap-type: inline mandatory;
scrollbar-width: none;
padding: 2px 1px 18px;
margin-right: -16px;
}
.scroll-cards::-webkit-scrollbar {
display: none;
}
.scroll-cards > * {
scroll-snap-align: start;
}
@media (min-width: 640px) {
.scroll-cards {
grid-auto-columns: minmax(43%, 1fr);
margin-right: -24px;
}
}
@media (min-width: 900px) {
.scroll-cards {
grid-auto-flow: row;
grid-auto-columns: auto;
overflow: visible;
margin-right: 0;
padding-bottom: 0;
}
}
/* Hero backdrop. Two stacked scrims that utilities can only express as an
unreadable arbitrary value; the horizontal one takes over from 900px. */
.hero-scrim {
background:
linear-gradient(180deg, rgb(4 21 35 / 0.44) 0%, rgb(4 21 35 / 0.18) 32%, rgb(4 21 35 / 0.86) 100%),
linear-gradient(90deg, rgb(4 21 35 / 0.92) 0%, rgb(4 21 35 / 0.69) 46%, rgb(4 21 35 / 0.1) 100%);
}
@media (min-width: 900px) {
.hero-scrim {
background: linear-gradient(90deg, rgb(4 21 35 / 0.95) 0%, rgb(4 21 35 / 0.68) 48%, rgb(4 21 35 / 0.08) 100%);
}
}
.market-panel-surface {
background:
radial-gradient(circle at 85% 15%, rgb(0 196 180 / 0.22), transparent 34%),
linear-gradient(135deg, var(--color-navy), var(--color-navy-deep));
}
.equipment-shell {
background:
radial-gradient(circle at 10% 20%, rgb(0 196 180 / 0.2), transparent 27%),
linear-gradient(180deg, var(--color-navy) 0%, var(--color-navy-deep) 100%);
}
.cta-surface {
background:
radial-gradient(circle at 8% 15%, rgb(0 196 180 / 0.22), transparent 28%),
radial-gradient(circle at 92% 88%, rgb(0 196 180 / 0.18), transparent 30%),
linear-gradient(135deg, var(--color-navy), var(--color-navy-deep));
}
/* Faint 52px grid, faded out towards the bottom of the CTA block. */
.cta-grid-overlay {
background-image:
linear-gradient(rgb(255 255 255 / 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.04) 1px, transparent 1px);
background-size: 52px 52px;
mask-image: linear-gradient(to bottom, black, transparent 76%);
}
.econ-panel-loss {
background:
radial-gradient(circle at 8% 0%, rgb(240 101 107 / 0.16), transparent 30%),
linear-gradient(145deg, var(--color-navy-deep), var(--color-navy));
}
.econ-calc-surface {
background:
radial-gradient(circle at 90% 0%, rgb(0 196 180 / 0.25), transparent 34%),
linear-gradient(135deg, var(--color-navy), var(--color-navy-deep));
}
/* Market charts. SVG presentation attributes with transitions that are
driven by the `.is-visible` state of the surrounding reveal. */
.chart-gridline {
stroke: rgb(255 255 255 / 0.1);
stroke-width: 1;
}
.chart-line {
fill: none;
stroke: var(--color-teal);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 500;
stroke-dashoffset: 500;
transition: stroke-dashoffset 1.1s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.chart-dot {
fill: var(--color-teal-bright);
opacity: 0;
transform-origin: center;
transition: opacity 0.35s ease 0.65s;
}
.chart-bar {
fill: var(--color-teal);
transform: scaleY(0);
transform-origin: bottom;
transform-box: fill-box;
transition: transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.chart-bar--muted {
fill: rgb(255 255 255 / 0.32);
}
.is-visible .chart-line {
stroke-dashoffset: 0;
}
.is-visible .chart-dot {
opacity: 1;
}
.is-visible .chart-bar {
transform: scaleY(1);
}
}
/* Honeypot: must stay focusable-but-invisible, so `hidden` is not an option. */
@utility honeypot {
position: absolute !important;
left: -9999px !important;
opacity: 0 !important;
pointer-events: none !important;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
.reveal {
opacity: 1;
transform: none;
}
.chart-line {
stroke-dashoffset: 0;
}
.chart-dot {
opacity: 1;
}
.chart-bar {
transform: scaleY(1);
}
}
+3
View File
@@ -0,0 +1,3 @@
export function cx(...values: (string | false | null | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
+9
View File
@@ -0,0 +1,9 @@
const money = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 })
export const formatNumber = (value: number) => money.format(value)
export const formatMoney = (value: number) => `${money.format(value)}`
/** Mirrors the original calculator's tolerant number parsing. */
export function parseAmount(raw: string): number {
return Number(String(raw || '').replace(/\s/g, '').replace(',', '.')) || 0
}
+84
View File
@@ -0,0 +1,84 @@
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead'
const ENDPOINT = '/api/leads/hotels'
const DRAFT_KEY = 'exo_hotel_lead_draft'
/** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> {
const params = new URLSearchParams(window.location.search)
const utm: Record<string, string> = {}
for (const key of utmKeys) {
const value = params.get(key)
if (value) utm[key] = value
}
return utm
}
export interface SubmitResult {
ok: boolean
error?: string
fields?: Record<string, string>
}
export async function submitLead(
form: LeadFormId,
values: Omit<LeadInput, 'form' | 'utm' | 'page' | 'referrer'>,
): Promise<SubmitResult> {
const payload: LeadInput = {
...values,
form,
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const body = (await response.json().catch(() => null)) as LeadResponse | null
if (!response.ok || !body?.ok) {
// The page must never lose a lead just because the CRM is down.
saveDraft(payload)
return {
ok: false,
error:
body && !body.ok
? body.error
: 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.',
fields: body && !body.ok ? body.fields : undefined,
}
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'hotel_lead_sent', form })
localStorage.removeItem(DRAFT_KEY)
return { ok: true }
} catch (error) {
saveDraft(payload)
console.warn('Lead endpoint error', error, payload)
return {
ok: false,
error:
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.',
}
}
}
function saveDraft(payload: LeadInput) {
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(payload))
} catch {
// Private-mode browsers throw on write; the visible error message is enough.
}
}
declare global {
interface Window {
dataLayer?: Record<string, unknown>[]
}
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// Self-hosted so the landing stays self-contained and makes no third-party
// request. Subsets are gated by unicode-range, so only latin, latin-ext and
// cyrillic are actually downloaded for this page.
import '@fontsource-variable/inter'
import App from './App'
import './index.css'
const container = document.getElementById('root')
if (!container) throw new Error('#root is missing from index.html')
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
)