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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user