П/ВИН

Разработка калькулятора для сайта: лендинг на Next.js

·8 мин чтения

«Вот код, сверстай его на моём домене» - на эту задачу я заложил десять минут, а провозился через три сессии. Один файл, пара хуков, красивый тёмный дизайн - что тут сложного. А в реальности из присланного React-сниппета вырос отдельный Next.js-проект: мобильная оптимизация, многоязычный калькулятор на 970 строк и фикс исчезающего курсора, который преследовал меня всё это время. Оказалось, что разработка калькулятора для сайта - это не «сверстать чужой код», а полноценный продукт на test.pashavin.ru со своими граблями, и вот что я из них вынес.

С чего всё началось

Ко мне прилетел компонент — лендинг для саммита в нише wellness и сетевого маркетинга. Тёмная тема, анимации появления секций через IntersectionObserver, иконки из lucide-react, вёрстка целиком на утилитарных классах Tailwind CSS без внешней UI-библиотеки. Чистый React, написанный явно «в вакууме» — без привязки к фреймворку, с кастомными хуками вроде такого:

const useIntersectionObserver = (options) => {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const targetRef = useRef(null);
 
  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) setIsIntersecting(true);
    }, options);
    // ...
  }, []);
};

Задача формулировалась как «сверстать на домене test.pashavin.ru/h4u». Но прежде чем что-то верстать, я полез смотреть, что вообще крутится на этом домене. И тут первый урок: никогда не деплой поверх чужого, не проверив, что там уже живёт.

Проблема №1: домен был занят

Оказалось, что test.pashavin.ru уже проксировался на 127.0.0.1:3099, где работал прод другого проекта — ветеринарный сайт. То есть запрос на /h4u фактически нагружал бы чужое приложение. Я остановился и предложил два варианта: либо отдельный поддомен, либо отдельное приложение на свободном порту с переключением прокси. Пользователь подтвердил, что старый проект уже переехал на свой домен, и мы пошли по второму пути.

Это важный момент в любой инфраструктурной работе: один порт — одно приложение, и смешивать роуты разных проектов через общий прокси — прямой путь к боли при отладке. Лучше потратить десять минут на чистую изоляцию, чем потом гадать, почему «иногда отдаёт не то».

Решение: отдельный Next.js-проект

Я создал новый проект h4b-landing на Next.js 16 с App Router и standalone-сборкой. Standalone здесь принципиален: он собирает приложение в самодостаточный бандл с минимальным набором зависимостей, который удобно гонять как systemd-сервис без таскания всей node_modules.

Присланный код пришлось адаптировать под App Router. Поскольку компонент использует состояние и эффекты, в начало файла лёг директив 'use client' — без него Next.js по умолчанию рендерит как серверный компонент, и хуки падают. Заодно проставил типы и поправил cleanup в useEffect (отписку от observer'а — иначе утечка наблюдателей при размонтировании).

'use client';
import { useState, useEffect, useRef } from 'react';
 
export default function H4ULanding() {
  // адаптированный код лендинга
}

Дальше — рутина деплоя: npm install, npm run build, поднял systemd-сервис h4b-landing.service на 127.0.0.1:3098 с логами в отдельный файл, и переключил Caddy с порта 3099 на 3098. Корневой роут / сделал редиректом на /h4u. Через несколько минут страница отвечала 200 и контент рендерился. Первая версия — в проде.

Проблема №2: страницы тормозили на мобильных

Когда пришёл запрос сделать вторую версию лендинга (с кастомной фоновой картинкой в hero-блоке), всплыла куда более серьёзная проблема: обе страницы открывались очень долго, особенно на телефонах. Здесь начинается самая интересная часть — оптимизация.

Я разобрал причины тормозов по пунктам:

  1. Render-blocking шрифты. Шрифты тянулись напрямую из Google Fonts через <link>, что блокирует первую отрисовку. Переключил на next/font — он самостоятельно хостит шрифты и инлайнит CSS, убирая блокирующий внешний запрос.
  2. Тяжёлые эффекты на мобильных. На странице был NoiseOverlay (шумовой оверлей) и куча backdrop-blur. Backdrop-blur — один из самых дорогих CSS-эффектов для GPU, особенно на слабых мобильных чипах. Я отключил их в мобильной версии, оставив только на десктопе.
  3. Невынесенные общие секции. Две версии страницы дублировали разметку. Я вынес общие секции в переиспользуемые компоненты, а сами страницы /h4u и /h4u-v2 сделал тонкими обёртками — разница только в источнике hero-картинки (Unsplash против локального файла в public/).

Отдельно пользователь попросил блоки «Идентификация» и «Что получу» на мобильных превратить в горизонтальный слайдер. Это решилось чистым CSS через scroll-snap, без единой строчки JavaScript:

.slider {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
}
.no-scrollbar::-webkit-scrollbar { display: none; }

Каждая карточка получала scroll-snap-align: start, и свайп аккуратно «прилипал» к границам. CSS scroll-snap — недооценённая штука: то, для чего раньше тащили карусельные библиотеки на килобайты JS, сегодня делается нативно браузером.

Тонкость с типографикой на высоких экранах

В ходе работы над hero-блоком всплыла любопытная деталь вёрстки. Изначально размеры шрифтов задавались через clamp() с привязкой только к ширине вьюпорта (vw). На вытянутых телефонах вроде iPhone 12 Pro Max текст из-за этого выглядел непропорционально мелким — ширина-то небольшая, а высоты вагон.

Решение — масштабировать шрифт сразу по двум осям через max(vw, svh):

/* было: только ширина */
font-size: clamp(1.5rem, 2vw, 3rem);
 
/* стало: ширина И высота viewport */
font-size: clamp(1.5rem, max(2vw, 1.4svh), 3rem);

Единица svh (small viewport height) учитывает реальную видимую высоту с поправкой на адресную строку мобильного браузера. Комбинация max(vw, svh) гарантирует, что текст растёт пропорционально и на широких, и на высоких экранах. Мелочь, но именно из таких мелочей складывается ощущение «продукт сделан аккуратно».

Второй продукт: калькулятор Quantex

Внутри того же кодбейза вырос отдельный роут /quantex — интерактивный калькулятор доходности для сетевого маркетинга на 970 строк. Это уже не статичный лендинг, а полноценное приложение с формой, рангами и формулой распределения фонда.

Через какое-то время калькулятор понадобилось перенести из h4b-landing в основной проект pashavin.ru, в папку app/test/. И тут вылез конфликт настроек TypeScript: у pashavin.ru в tsconfig стоит strict: true, а калькулятор был написан с implicit any в параметрах функций. Точечно фиксить 20+ мест в чужом коде смысла не имело, поэтому я добавил // @ts-nocheck в начало файла. Это осознанный компромисс: когда переносишь legacy-код в строгий проект, прагматичнее изолировать его одной директивой, чем переписывать ради линтера то, что и так работает.

Затем была интересная история с бизнес-логикой. Пользователь уточнил формулу глобального пула: лидеры получают премии долями из Лидерского фонда (15% от общего фонда выплат). Изначально в калькуляторе было поле «сколько всего долей выдано» — пользователь вводил число руками. Я зашил композицию долей прямо в формулу, привязав её к числу команд:

Лидеров А    = companyTeams × 1
Ст. Лидеров  = floor(companyTeams / 5)   // доля × 3
Амбассадоров = floor(companyTeams / 30)  // доля × 5

shareValue = leadershipFund / totalShares
myIncome   = myShares × shareValue

При companyTeams = 30 получалось ровно 30×1 + 6×3 + 1×5 = 53 доли — эталонное значение из примера пользователя. Лишнее поле ввода убрал: чем меньше пользователь вводит руками, тем меньше шансов на ошибку и тем чище UX.

Проблема №3: исчезающий курсор и мультиязычность

Последняя сессия принесла два запроса. Первый — баг: на странице калькулятора пропадал кастомный курсор. Причина оказалась структурной: глобально в body стоит cursor: none, а компонент <CustomCursor /> живёт только внутри layout группы (main). Роут /test/quantex находится вне этой группы — значит, системный курсор скрыт, а кастомный не отрисован. Чистая пустота. Фикс — добавить app/test/layout.tsx со своим <CustomCursor />, ровно как это уже было решено для соседней страницы. Вложенные layout'ы в Next.js как раз для этого и существуют.

Второй запрос — кнопки перевода всего контента на английский и индонезийский. В QuantexApp.tsx оказалось 128 уникальных русских фраз. Это уже серьёзный i18n, и хардкодить тернарники по всему файлу было бы катастрофой. Я вынес все строки в отдельный файл-словарь, добавил функцию t() и переключатель языков RU/EN/ID:

const dict = {
  ru: { leaderFund: 'Лидерский фонд', ... },
  en: { leaderFund: 'Leadership Fund', ... },
  id: { leaderFund: 'Dana Pemimpin', ... },
};
const t = (key) => dict[lang][key] ?? key;

По умолчанию остаётся русский, три кнопки переключают язык на лету. Никаких тяжёлых i18n-фреймворков — для одной страницы с фиксированным набором строк простой словарь + t() гораздо легче и прозрачнее.

Результат

Из одного присланного React-сниппета вырос живой продукт: два варианта лендинга (/h4u, /h4u-v2) с мобильной оптимизацией и многоязычный калькулятор Quantex на pashavin.ru/quantex. Все страницы отдают 200, контент рендерится, сборка чистая. Шрифты больше не блокируют отрисовку, тяжёлые эффекты убраны с мобильных, слайдеры работают на нативном scroll-snap, типографика масштабируется по двум осям, курсор на месте, а контент переключается между тремя языками.

Выводы

Простая задача почти никогда не простая. «Сверстай код на домене» обернулось отдельным проектом, тремя сессиями и десятком инфраструктурных решений. Это нормально — важно с самого начала разведать территорию (что уже на домене, какие настройки в tsconfig, где живут layout'ы), а не бросаться верстать. Десять минут разведки экономят часы отладки.

Изоляция важнее экономии. Когда оказалось, что домен занят чужим проектом, соблазн «подмешать роут» был велик. Но отдельное приложение на своём порту с отдельным systemd-сервисом и отдельным блоком в прокси — это чистота, которую потом не приходится распутывать. Один порт — одно приложение.

Производительность — это не магия, а список причин. Тормоза на мобильных разложились на конкретные пункты: render-blocking шрифты, дорогой backdrop-blur, дублирование разметки. Каждый лечится точечно. Современный CSS (scroll-snap, clamp, svh, next/font) закрывает большинство задач, ради которых раньше тащили JavaScript-библиотеки — и закрывает быстрее.

Прагматизм бьёт перфекционизм при работе с чужим кодом. @ts-nocheck вместо переписывания 20 функций, словарь вместо i18n-фреймворка, зашитая формула вместо лишнего поля ввода — каждый раз я выбирал минимальное изменение, дающее результат. Senior-подход — это не «сделать максимально правильно везде», а «понять, где правильность реально нужна, а где достаточно работающего и читаемого решения». Чужой код переносишь — изолируй его границей, а не вычищай ради линтера то, что и так работает.

Паша Вин
Паша Вин

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.