Ports the single-file MedCsFiz page — the medical centre that already runs physiotherapy and wants the existing room to earn more — to the same architecture as the fitness and hotel landings: Vite + React 19 + TypeScript + Tailwind v4 on the client, Express 5 for the API, zod schema shared between the two. The original stays in legacy/index.html as the visual reference. Its 4 MB of inlined CSS, JS and base64 images become 12 asset files (664 KB) plus a bundle loaded on demand; the two 1.5 MB / 750 KB device PNGs are recompressed to webp. The page is split into 18 components with all copy moved to src/data/content.ts. Leads reuse the fitness amoCRM integration: contact lookup by phone in every Russian 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. Endpoint is /api/leads/medical-centers-existing-physio; Vite runs on 5173 and the API on 3000. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
96 lines
2.7 KiB
TypeScript
96 lines
2.7 KiB
TypeScript
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-white/[0.08] text-white outline-none ' +
|
|
'transition-[border-color,background-color,box-shadow] duration-200 placeholder:text-white/[0.42] ' +
|
|
'focus:border-teal focus:bg-white/[0.12] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.12)]'
|
|
|
|
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-[750] text-white/[0.7]">
|
|
{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, 'min-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-[104px] resize-y px-[15px] pt-[14px]')} {...rest} />
|
|
</Field>
|
|
)
|
|
}
|
|
|
|
export function SelectField({
|
|
id,
|
|
label,
|
|
full,
|
|
placeholder,
|
|
options,
|
|
...rest
|
|
}: {
|
|
id: string
|
|
label: string
|
|
full?: boolean
|
|
placeholder: string
|
|
options: readonly string[]
|
|
} & ComponentPropsWithRef<'select'>) {
|
|
return (
|
|
<Field id={id} label={label} full={full}>
|
|
<select id={id} className={cx(controlClass, 'select-caret min-h-[52px] px-[15px]')} {...rest}>
|
|
{/* Options inherit the dark control colours in some browsers, so the
|
|
palette is restated on them. */}
|
|
<option value="" className="bg-white text-navy">
|
|
{placeholder}
|
|
</option>
|
|
{options.map((option) => (
|
|
<option key={option} value={option} className="bg-white text-navy">
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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>
|
|
)
|
|
}
|