Разработка одностраничного Checkout для интернет-магазина
Одностраничный checkout размещает все этапы оформления заказа на одном экране — без переходов между шагами. Это снижает количество HTTP-запросов, устраняет потерю данных при навигации браузера и сокращает воспринимаемое время оформления. В среднем конверсия выше на 10–25% по сравнению с многошаговым вариантом на мобильных устройствах. Разработка занимает 5–8 рабочих дней.
Компоновка экрана
Стандартный layout одностраничного checkout:
┌─────────────────────────────┬──────────────────────┐
│ Контакты │ │
│ Адрес доставки │ Состав заказа │
│ Способ доставки │ Промокод │
│ Способ оплаты │ Итоговая сумма │
│ [Оформить заказ] │ │
└─────────────────────────────┴──────────────────────┘
На мобильных — одна колонка, правая панель с итогом прокручивается вверх после состава заказа. Sticky-кнопка «Оформить» фиксируется внизу экрана.
Динамическое обновление правой панели
Правая панель пересчитывается при каждом изменении: выбор метода доставки меняет итоговую сумму, ввод промокода — скидку. Используем debounced реактивность:
const { shipping, coupon } = useCheckoutStore();
const { data: summary } = useQuery({
queryKey: ['checkout-summary', shipping?.id, coupon],
queryFn: () => api.post('/checkout/summary', { shipping_id: shipping?.id, coupon }),
staleTime: 30_000,
enabled: !!shipping,
});
Запрос к серверу идёт только при реальных изменениях, не на каждый keystroke в поле адреса.
Условная видимость полей
Поля оплаты и доставки появляются по мере заполнения предыдущих блоков. Логика управляется состоянием формы:
const { watch } = useFormContext();
const email = watch('contact.email');
const addressFilled = watch(['address.city', 'address.street', 'address.house'])
.every(Boolean);
return (
<>
<ContactBlock />
{email && <AddressBlock />}
{addressFilled && <ShippingBlock />}
{selectedShipping && <PaymentBlock />}
</>
);
Такой подход снижает когнитивную нагрузку — пользователь не видит весь экран сразу, а заполняет последовательно.
Валидация без блокировки
В одностраничном checkout важно не блокировать кнопку «Оформить» до полного заполнения формы — это создаёт ощущение тупика. Вместо этого:
- Поля валидируются при
onBlur, неonChange - Кнопка всегда активна
- При клике срабатывает
trigger()из React Hook Form, подсвечиваются все незаполненные поля и страница скроллится к первой ошибке
const handleSubmit = async () => {
const valid = await form.trigger();
if (!valid) {
const firstError = Object.keys(form.formState.errors)[0];
document.querySelector(`[name="${firstError}"]`)?.scrollIntoView({ behavior: 'smooth' });
return;
}
await placeOrder(form.getValues());
};
Автозаполнение из профиля
Для авторизованных пользователей форма предзаполняется данными из профиля:
useEffect(() => {
if (user) {
form.reset({
contact: { email: user.email, phone: user.phone },
address: user.defaultAddress ?? {},
});
}
}, [user]);
Если у пользователя несколько сохранённых адресов — показываем dropdown «Выбрать сохранённый адрес», при выборе подставляем поля.
Inline-выбор доставки
Методы доставки отображаются карточками с иконкой перевозчика, ценой и сроком. При переключении между методами правая панель немедленно обновляет итог. Если адрес ещё не введён — показываем skeleton-заглушки с «от X ₽».
<RadioGroup value={selectedShipping?.id} onValueChange={handleShippingChange}>
{shippingOptions.map(option => (
<RadioGroupItem key={option.id} value={option.id}>
<span>{option.carrier_name}</span>
<span>{option.estimated_days} дн.</span>
<span className="font-bold">{option.price === 0 ? 'Бесплатно' : `${option.price} ₽`}</span>
</RadioGroupItem>
))}
</RadioGroup>
Встроенные платёжные формы
Для банковских карт используем JS SDK платёжного провайдера (ЮKassa, Тinkoff, CloudPayments). Форма карты — iframe провайдера прямо внутри checkout, без редиректа:
// CloudPayments
const widget = new cp.CloudPayments();
widget.pay('charge', {
publicId: PUBLIC_ID,
amount: summary.total,
currency: 'RUB',
invoiceId: order.id,
email: form.getValues('contact.email'),
}, {
onSuccess: (options) => router.push(`/orders/${order.id}/confirmation`),
onFail: (reason) => toast.error(`Платёж не прошёл: ${reason}`),
});
Производительность
Одностраничный checkout весит больше многошагового — все блоки монтируются сразу. Оптимизации:
- Code splitting платёжного SDK — загружается только при выборе метода «Карта»
- Lazy import компонентов карты, не нужных при первом рендере
-
Preconnect к API DaData и платёжного провайдера в
<head>
Целевое время до интерактивности — менее 2 секунд на 4G.
Сохранение прогресса
Данные формы сохраняются в sessionStorage через Zustand persist. Если пользователь случайно закрыл вкладку — при возврате форма восстанавливается. TTL сессии — 2 часа, после этого черновик удаляется.







