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
+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>
)
}