Реализация онлайн-калькулятора на сайте
Онлайн-калькулятор — один из наиболее конвертирующих инструментов на сайте. Ипотечный калькулятор, расчёт стоимости услуг, калькулятор ROI — все они строятся по одной архитектуре, но дьявол в деталях: формулы, валидация, форматирование чисел, анимация результата.
Архитектура: формула как конфиг
Неправильный подход — зашить формулы прямо в обработчики событий. Правильный — отделить логику расчёта от UI:
// types.ts
interface CalculatorField {
id: string
label: string
type: 'number' | 'range' | 'select' | 'radio'
min?: number
max?: number
step?: number
defaultValue: number
unit?: string
options?: { label: string; value: number }[]
format?: 'currency' | 'percent' | 'number'
}
interface CalculatorConfig {
id: string
fields: CalculatorField[]
formula: (inputs: Record<string, number>) => CalculatorResult
resultFields: ResultField[]
}
interface CalculatorResult {
[key: string]: number
}
Ипотечный калькулятор: реализация
// mortgage-calculator.ts
export const mortgageCalculator: CalculatorConfig = {
id: 'mortgage',
fields: [
{ id: 'price', label: 'Стоимость недвижимости', type: 'number',
min: 500_000, max: 100_000_000, step: 100_000, defaultValue: 5_000_000,
format: 'currency' },
{ id: 'downPayment', label: 'Первоначальный взнос', type: 'range',
min: 10, max: 90, step: 1, defaultValue: 20, unit: '%', format: 'percent' },
{ id: 'rate', label: 'Процентная ставка', type: 'number',
min: 0.1, max: 30, step: 0.1, defaultValue: 11.5, unit: '% годовых', format: 'percent' },
{ id: 'term', label: 'Срок кредита', type: 'select',
defaultValue: 20,
options: [5, 10, 15, 20, 25, 30].map(y => ({ label: `${y} лет`, value: y })) },
],
formula: ({ price, downPayment, rate, term }) => {
const principal = price * (1 - downPayment / 100)
const monthlyRate = rate / 100 / 12
const months = term * 12
// Формула аннуитетного платежа
const payment = monthlyRate === 0
? principal / months
: principal * (monthlyRate * Math.pow(1 + monthlyRate, months))
/ (Math.pow(1 + monthlyRate, months) - 1)
const totalPayment = payment * months
const overpayment = totalPayment - principal
return { payment, totalPayment, overpayment, principal }
},
resultFields: [
{ id: 'payment', label: 'Ежемесячный платёж', format: 'currency', highlight: true },
{ id: 'totalPayment', label: 'Общая сумма выплат', format: 'currency' },
{ id: 'overpayment', label: 'Переплата', format: 'currency' },
{ id: 'principal', label: 'Сумма кредита', format: 'currency' },
],
}
React-компонент калькулятора
import { useState, useCallback, useMemo } from 'react'
function formatValue(value: number, format?: string): string {
switch (format) {
case 'currency':
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(value)
case 'percent':
return `${value.toFixed(1)}%`
default:
return new Intl.NumberFormat('ru-RU').format(value)
}
}
export function Calculator({ config }: { config: CalculatorConfig }) {
const [values, setValues] = useState<Record<string, number>>(
Object.fromEntries(config.fields.map(f => [f.id, f.defaultValue]))
)
const result = useMemo(() => {
try {
return config.formula(values)
} catch {
return null
}
}, [values, config])
const handleChange = useCallback((id: string, value: number) => {
setValues(prev => ({ ...prev, [id]: value }))
}, [])
return (
<div className="calculator">
<div className="calculator__inputs">
{config.fields.map(field => (
<CalculatorField
key={field.id}
field={field}
value={values[field.id]}
onChange={val => handleChange(field.id, val)}
/>
))}
</div>
{result && (
<div className="calculator__results">
{config.resultFields.map(rf => (
<div key={rf.id} className={`result-item ${rf.highlight ? 'result-item--highlight' : ''}`}>
<span className="result-item__label">{rf.label}</span>
<AnimatedNumber
value={result[rf.id]}
format={rf.format}
/>
</div>
))}
</div>
)}
</div>
)
}
Анимация изменения числа
import { useEffect, useRef, useState } from 'react'
function AnimatedNumber({ value, format }: { value: number; format?: string }) {
const [displayValue, setDisplayValue] = useState(value)
const animationRef = useRef<number>()
const startRef = useRef(value)
const startTimeRef = useRef<number>()
useEffect(() => {
const startValue = displayValue
startRef.current = startValue
startTimeRef.current = undefined
const duration = 400 // ms
const animate = (timestamp: number) => {
if (!startTimeRef.current) startTimeRef.current = timestamp
const elapsed = timestamp - startTimeRef.current
const progress = Math.min(elapsed / duration, 1)
// Easing: ease-out
const eased = 1 - Math.pow(1 - progress, 3)
const current = startValue + (value - startValue) * eased
setDisplayValue(current)
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate)
}
}
animationRef.current = requestAnimationFrame(animate)
return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current) }
}, [value])
return <span className="animated-number">{formatValue(displayValue, format)}</span>
}
Валидация ввода
function CalculatorField({ field, value, onChange }: FieldProps) {
const [rawValue, setRawValue] = useState(String(value))
const [error, setError] = useState('')
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value
setRawValue(raw)
const num = parseFloat(raw.replace(/\s/g, '').replace(',', '.'))
if (isNaN(num)) {
setError('Введите число')
return
}
if (field.min !== undefined && num < field.min) {
setError(`Минимум: ${formatValue(field.min, field.format)}`)
return
}
if (field.max !== undefined && num > field.max) {
setError(`Максимум: ${formatValue(field.max, field.format)}`)
return
}
setError('')
onChange(num)
}
// Синхронизируем при внешнем изменении (например, от range slider)
useEffect(() => {
setRawValue(String(value))
setError('')
}, [value])
return (
<div className={`field ${error ? 'field--error' : ''}`}>
<label htmlFor={field.id}>{field.label}</label>
<input
id={field.id}
type="text"
inputMode="decimal"
value={rawValue}
onChange={handleInput}
/>
{field.unit && <span className="field__unit">{field.unit}</span>}
{error && <span className="field__error">{error}</span>}
</div>
)
}
Сохранение результата и поделиться
// Кодируем параметры в URL для шаринга
function encodeCalcState(values: Record<string, number>): string {
const params = new URLSearchParams(
Object.entries(values).map(([k, v]) => [k, String(v)])
)
return params.toString()
}
function decodeCalcState(search: string): Record<string, number> {
const params = new URLSearchParams(search)
const result: Record<string, number> = {}
params.forEach((v, k) => { result[k] = parseFloat(v) })
return result
}
// При изменении значений обновляем URL без перезагрузки
function syncToUrl(values: Record<string, number>) {
const url = new URL(window.location.href)
url.search = encodeCalcState(values)
window.history.replaceState({}, '', url.toString())
}
Диаграмма распределения платежей
// Простая pie chart через SVG без библиотек
function PaymentPieChart({ principal, overpayment }: { principal: number; overpayment: number }) {
const total = principal + overpayment
const principalPct = principal / total
const angle = principalPct * 360
// SVG arc path
const r = 80
const cx = 100, cy = 100
const rad = (deg: number) => (deg - 90) * Math.PI / 180
const x = (deg: number) => cx + r * Math.cos(rad(deg))
const y = (deg: number) => cy + r * Math.sin(rad(deg))
const largeArc = angle > 180 ? 1 : 0
const path1 = `M ${cx} ${cy} L ${x(0)} ${y(0)} A ${r} ${r} 0 ${largeArc} 1 ${x(angle)} ${y(angle)} Z`
const path2 = `M ${cx} ${cy} L ${x(angle)} ${y(angle)} A ${r} ${r} 0 ${1 - largeArc} 1 ${x(360)} ${y(360)} Z`
return (
<svg viewBox="0 0 200 200" width="200" height="200">
<path d={path1} fill="#6366f1" />
<path d={path2} fill="#f43f5e" />
</svg>
)
}
Сроки
Простой калькулятор с 3–5 полями и одним результатом — 1 день. Ипотечный с анимацией, диаграммой, URL-шарингом и адаптивной вёрсткой — 2–3 дня. Конфигурируемая система с несколькими калькуляторами и управлением через CMS — 1 неделя.







