Rename medcenter landings and add the revenue calculator
Rename the two medcenter landings to their audience names: medcenter -> medcenterphysio, medcenterpersonal -> medcenterstart. Alongside the rename: - add a RevenueCalculator section to both landings; - rework the copy and figures in src/data/content.ts; - simplify the lead form: drop the "cabinet_state" and "profile" selects (along with SelectField and the matching fields in shared/lead.ts, lead-mapper.ts and amo-check.ts) and make company and email optional; - add the legacy/new static prototypes for both landings; - add pnpm-lock.yaml to medcenterstart (package-lock.json is still there too). deploy/apps.conf and deploy/README.md still refer to the old directory names and need a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
be7741e269
commit
0732aa2096
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react'
|
||||
import { calculatorCopy, calculatorFields, calculatorResults } from '../data/content'
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
|
||||
const money = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 })
|
||||
const decimal = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
|
||||
type FieldKey = (typeof calculatorFields)[number]['key']
|
||||
|
||||
/**
|
||||
* Values are held as strings, not numbers: clearing a field has to leave it
|
||||
* empty while the visitor retypes, and coercing on every keystroke would snap
|
||||
* it back to 0. Anything that is not a positive finite number reads as 0 and
|
||||
* blanks the dependent results.
|
||||
*/
|
||||
const initialValues = Object.fromEntries(
|
||||
calculatorFields.map((field) => [field.key, String(field.initial)]),
|
||||
) as Record<FieldKey, string>
|
||||
|
||||
function toNumber(raw: string): number {
|
||||
const value = Number(raw.replace(/\s/g, '').replace(',', '.'))
|
||||
return Number.isFinite(value) && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
const rubles = (value: number) => (value ? `${money.format(value)} ₽` : '—')
|
||||
|
||||
export function RevenueCalculator() {
|
||||
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
||||
const [values, setValues] = useState(initialValues)
|
||||
|
||||
const courses = toNumber(values.courses)
|
||||
const averageCheck = toNumber(values.averageCheck)
|
||||
const investment = toNumber(values.investment)
|
||||
|
||||
const month = courses && averageCheck ? Math.round(courses * averageCheck) : 0
|
||||
const year = month * 12
|
||||
const payback = month && investment ? investment / month : null
|
||||
const roi = month && investment ? ((year - investment) / investment) * 100 : null
|
||||
|
||||
const results: Record<(typeof calculatorResults)[number]['key'], string> = {
|
||||
month: rubles(month),
|
||||
year: rubles(year),
|
||||
payback: payback === null ? '—' : `${decimal.format(payback)} мес.`,
|
||||
roi: roi === null || !Number.isFinite(roi) ? '—' : `${money.format(Math.round(roi))}%`,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'mb-[30px] rounded-[22px] border border-teal-bright/[0.34] p-[clamp(18px,2.2vw,26px)]',
|
||||
'bg-[linear-gradient(135deg,rgb(0_196_180/0.08),rgb(255_255_255/0.03))] shadow-[0_18px_46px_rgb(0_0_0/0.14)]',
|
||||
'narrow:rounded-[18px] narrow:p-[16px]',
|
||||
revealClass,
|
||||
)}
|
||||
>
|
||||
<div className="mb-[17px] grid items-start gap-[18px] narrow:mb-[16px] narrow:gap-[8px] lg:grid-cols-[minmax(0,1fr)_minmax(260px,0.72fr)] lg:items-end">
|
||||
<div>
|
||||
<span className="mb-[6px] block text-[10px] font-[850] tracking-[0.12em] text-teal-bright uppercase">
|
||||
{calculatorCopy.eyebrow}
|
||||
</span>
|
||||
<h3 className="text-[clamp(20px,2.15vw,28px)] leading-[1.1] tracking-[-0.025em] text-white narrow:text-[19px] narrow:leading-[1.15]">
|
||||
{calculatorCopy.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[13px] leading-[1.45] text-white/[0.61] narrow:text-[11.5px] narrow:leading-[1.4] lg:max-w-[430px] lg:text-right">
|
||||
{calculatorCopy.lead}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-[12px] grid gap-[12px] sm:grid-cols-2 lg:grid-cols-3">
|
||||
{calculatorFields.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="min-w-0 rounded-[15px] border border-white/[0.13] bg-[rgb(5_28_47/0.58)] p-[14px] narrow:p-[12px] sm:last:col-span-2 lg:last:col-span-1"
|
||||
>
|
||||
<label
|
||||
htmlFor={`calc-${field.key}`}
|
||||
className="mb-[7px] block text-[11px] leading-[1.3] font-[760] text-white/[0.78] narrow:text-[10.5px]"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`calc-${field.key}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={field.step}
|
||||
aria-label={field.ariaLabel}
|
||||
value={values[field.key]}
|
||||
onChange={(event) =>
|
||||
setValues((current) => ({ ...current, [field.key]: event.target.value }))
|
||||
}
|
||||
className="h-[46px] w-full rounded-[11px] border border-teal-bright/[0.42] bg-white px-[13px] text-[clamp(17px,1.85vw,22px)] font-[850] tabular-nums text-[#071f35] outline-none
|
||||
transition-[border-color,box-shadow] duration-200 focus:border-teal-bright focus:shadow-[0_0_0_3px_rgb(69_228_215/0.12)] narrow:h-[44px] narrow:text-[18px]"
|
||||
/>
|
||||
<small className="mt-[7px] block text-[10px] leading-[1.35] text-white/[0.45] narrow:text-[9.5px] narrow:leading-[1.3]">
|
||||
{field.hint}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" className="grid gap-[12px] sm:grid-cols-2 lg:grid-cols-4">
|
||||
{calculatorResults.map((result) => (
|
||||
<div
|
||||
key={result.key}
|
||||
className={cx(
|
||||
'flex min-h-[104px] min-w-0 flex-col justify-between rounded-[15px] border p-[14px] narrow:min-h-[84px] narrow:p-[12px]',
|
||||
result.accent
|
||||
? 'border-teal-bright/[0.48] bg-[linear-gradient(145deg,rgb(0_196_180/0.18),rgb(7_31_53/0.98))]'
|
||||
: 'border-white/[0.13] bg-[linear-gradient(145deg,rgb(14_59_91/0.96),rgb(7_31_53/0.96))]',
|
||||
)}
|
||||
>
|
||||
<span className="text-[9.5px] leading-[1.3] font-[760] tracking-[0.035em] text-white/[0.63] uppercase narrow:leading-[1.25]">
|
||||
{result.label}
|
||||
</span>
|
||||
<strong
|
||||
className={cx(
|
||||
'my-[8px] mb-[4px] block text-[clamp(18px,2vw,27px)] leading-none tracking-[-0.03em] whitespace-nowrap tabular-nums narrow:text-[20px]',
|
||||
result.accent ? 'text-teal-bright' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{results[result.key]}
|
||||
</strong>
|
||||
<small className="text-[9.5px] leading-[1.3] text-white/[0.43] narrow:text-[9px]">{result.hint}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-[13px] text-[11px] leading-[1.45] text-white/[0.52] narrow:text-[10px] narrow:leading-[1.4]">
|
||||
<strong className="font-[850] text-white/[0.78]">Как читать расчёт:</strong> {calculatorCopy.caption}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user