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