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:
co-authored by
Claude Opus 5
parent
60de4ef27c
commit
35f713a267
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user