Розробка магазину в Telegram Mini App на Next.js App Router: кошик, MainButton та баги WebView

Запуск интернет-магазин а всередині Telegram Mini App (TMA) на стеку Next.js App Router дозволяє об'єднати швидкодію серверного рендерингу та нативний клієнтський досвід Telegram. Однак при реалізації E-commerce сценаріїв розробники регулярно стикаються зі специфічними проблемами: розбіжностями в поведінці iOS WebView та Desktop-клієнта, коректним управлінням нативними кнопками MainButton/BackButton та обробкою нативної оплати Telegram Invoices.

Архітектура E-commerce Mini App на Next.js App Router

При побудові магазину на Next.js App Router важливо розмежовувати серверний код та клієнтські інтерактивні компоненти. Повноцінне контекстне оточення window.Telegram.WebApp доступне виключно на клієнті (CSR). На сервері (SSR/RSC) об'єкт window відсутній, тому при першій гідратації необхідно виключити помилки доступу до DOM.

Стандартний потік оформлення замовлення виглядає так:

  1. Користувач збирає товари в кошик (стан зберігається в клієнтському React State або Zustand).
  2. При зміні кошика викликається нативний Telegram.WebApp.MainButton, що відображає підсумкову суму.
  3. Клік по MainButton відправляє дані кошика на бекенд Next.js (Route Handler).
  4. Route Handler формує інвойс через метод createInvoiceLink Telegram Bot API і повертає URL оплати.
  5. Фронтенд викликає Telegram.WebApp.openInvoice, відкриваючи нативний платежный шлюз.

Ініціалізація WebApp SDK та обхід багів iOS / Desktop WebView

Найчастіші проблеми при верстці Mini App під Telegram — це схлопування робочої області на iOS при свайпі, некоректна висота екрана при появі екранної клавіатури та некоректні системні кольори на ПК-версії Telegram Desktop.

Для вирішення цих багів створюється кастомний хук ініціалізації, який розгортає вікно застосунку через expand(), фіксує реальну висоту viewportStableHeight у CSS-змінній і прокидає системну палітру Telegram у змінні теми.

'use client';

import { useEffect } from 'react';

declare global {
interface Window {
Telegram?: {
WebApp: any;
};
}
}

export function useTelegramInit() {
useEffect(() => {
if (typeof window === 'undefined' || !window.Telegram?.WebApp) return;

const webApp = window.Telegram.WebApp;

// Сообщаем Telegram, что приложение готово к отображению
webApp.ready();

// Принудительно расширяем WebView на весь экран
webApp.expand();

// Фикс для iOS: отслеживаем изменения viewport и задаем CSS-переменную
const updateViewport = () => {
const stableHeight = webApp.viewportStableHeight || window.innerHeight;
document.documentElement.style.setProperty('--tg-viewport-height', `${stableHeight}px`);
};

webApp.onEvent('viewportChanged', updateViewport);
updateViewport();

// Синхронизация цветовой схемы с клиентом Telegram
const theme = webApp.themeParams;
if (theme) {
const root = document.documentElement;
if (theme.bg_color) root.style.setProperty('--tg-theme-bg', theme.bg_color);
if (theme.text_color) root.style.setProperty('--tg-theme-text', theme.text_color);
if (theme.button_color) root.style.setProperty('--tg-theme-button', theme.button_color);
if (theme.button_text_color) root.style.setProperty('--tg-theme-button-text', theme.button_text_color);
if (theme.secondary_bg_color) root.style.setProperty('--tg-theme-secondary-bg', theme.secondary_bg_color);
}

return () => {
webApp.offEvent('viewportChanged', updateViewport);
};
}, []);
}

Важливо для iOS: Висота Viewport в iOS Safari WebView може динамічно змінюватися при скролі. Обов'язково використовуйте в CSS обчислене значення height: var(--tg-viewport-height, 100vh) для головного контейнера застосунку замість 100vh, щоб уникнути вильоту елементів за межі видимості.

Синхронізація кошика з MainButton та обробка замовлення

Для створення безшовного UX у мобільних пристроях оформление заказа слід прив'язувати до нижньої кнопки MainButton. У Telegram Desktop ця кнопка відображається зафіксованою внизу модального вікна WebApp.

При зміні складу кошика реактивний компонент повинен змінювати текст кнопки, перемикати її видимість та навішувати обробник відправки даних.

'use client';

import { useEffect, useState, useCallback } from 'react';

export interface CartItem {
id: string;
title: string;
price: number;
quantity: number;
}

interface CartProps {
items: CartItem[];
}

export function CheckoutCart({ items }: CartProps) {
const [loading, setLoading] = useState(false);
const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

const handleCheckout = useCallback(async () => {
if (items.length === 0 || loading) return;

const webApp = window.Telegram?.WebApp;
if (!webApp) return;

setLoading(true);
webApp.MainButton.showProgress(false);

try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items,
initData: webApp.initData,
}),
});

const data = await response.json();

if (data.ok && data.invoiceLink) {
// Открываем нативную оплату Telegram
webApp.openInvoice(data.invoiceLink, (status: string) => {
webApp.MainButton.hideProgress();
setLoading(false);

if (status === 'paid') {
webApp.showAlert('Оплата прошла успешно!');
webApp.close();
} else if (status === 'failed') {
webApp.showAlert('Не удалось завершить платеж.');
}
});
} else {
webApp.showAlert(data.error || 'Ошибка при создании счета');
webApp.MainButton.hideProgress();
setLoading(false);
}
} catch (err) {
console.error('Checkout error:', err);
webApp.showAlert('Сетевая ошибка. Попробуйте снова.');
webApp.MainButton.hideProgress();
setLoading(false);
}
}, [items, loading]);

useEffect(() => {
if (typeof window === 'undefined' || !window.Telegram?.WebApp) return;
const webApp = window.Telegram.WebApp;
const mainButton = webApp.MainButton;

if (items.length > 0) {
mainButton.setText(`ОПЛАТИТЬ (${totalPrice} ₽)`);
mainButton.show();
mainButton.enable();

mainButton.onClick(handleCheckout);
} else {
mainButton.hide();
}

return () => {
mainButton.offClick(handleCheckout);
};
}, [items, totalPrice, handleCheckout]);

return (
<div className="p-4 rounded-lg bg-[var(--tg-theme-secondary-bg,#f4f4f5)]">
<h3 className="font-semibold text-lg mb-2">Ваш заказ</h3>
{items.length === 0 ? (
<p className="text-sm opacity-70">Корзина пуста</p>
) : (
<ul className="space-y-2">
{items.map((item) => (
<li key={item.id} className="flex justify-between text-sm">
<span>{item.title} x {item.quantity}</span>
<span className="font-medium">{item.price * item.quantity} ₽</span>
</li>
))}
</ul>
)}
</div>
);
}

Генерація посилання на оплату (createInvoiceLink) у Route Handler

Для безпечного проведення платежів токен платіжного провайдера (наприклад, ЮKassa, Сбербанк або Telegram Stars) та бот-токен не повинні потрапляти на клієнтську сторону. Генерация ссылки на оплату відбувається виключно на сервері через Telegram Bot API метод createInvoiceLink.

Створюємо Route Handler app/api/checkout/route.ts у Next.js App Router:

import { NextResponse } from 'next/server';

interface ReqItem {
id: string;
title: string;
price: number;
quantity: number;
}

export async function POST(request: Request) {
try {
const body = await request.json();
const { items, initData } = body;

if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ ok: false, error: 'Корзина пуста' }, { status: 400 });
}

const botToken = process.env.BOT_TOKEN;
const providerToken = process.env.PAYMENT_PROVIDER_TOKEN;

if (!botToken || !providerToken) {
return NextResponse.json(
{ ok: false, error: 'Конфигурация платежей не настроена на сервере' },
{ status: 500 }
);
}

// Формируем позиции чека. Для RUB суммы указываются в копейках (целые числа)
const prices = items.map((item: ReqItem) => ({
label: `${item.title} x${item.quantity}`,
amount: Math.round(item.price * item.quantity * 100),
}));

const totalAmount = items.reduce((acc, i) => acc + i.price * i.quantity, 0);

const payload = {
title: 'Оплата заказа в Mini App',
description: `Заказ из товаров в количестве: ${items.length} шт.`,
payload: JSON.stringify({
order_id: `ORD-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`,
createdAt: Date.now(),
}),
provider_token: providerToken,
currency: 'RUB',
prices: prices,
};

const tgResponse = await fetch(`https://api.telegram.org/bot${botToken}/createInvoiceLink`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});

const tgData = await tgResponse.json();

if (!tgResponse.ok || !tgData.ok) {
console.error('Telegram createInvoiceLink error:', tgData);
return NextResponse.json(
{ ok: false, error: tgData.description || 'Ошибка Telegram API' },
{ status: 502 }
);
}

return NextResponse.json({
ok: true,
invoiceLink: tgData.result,
});
} catch (err) {
console.error('Checkout API Route error:', err);
return NextResponse.json({ ok: false, error: 'Внутренняя ошибка сервера' }, { status: 500 });
}
}

Відмінності в поведінці та баги: iOS WebView vs Telegram Desktop

1. Обробка нативної кнопки BackButton при навігації

У Telegram Desktop при кліку по BackButton клієнт очікує явного приховування кнопки через webApp.BackButton.hide() при переході на головний екран застосунку. В іншому випадку кнопка «Назад» залишиться висіти у шапці вікна навіть на головній сторінці магазину. На iOS кнопка зникає автоматично тільки при закритті застосунку. Контролюйте видимість кнопки через usePathname() з next/navigation.

2. Зміна теми та прозорість прозорих фонів (Transparent Background)

При перемиканні тем оформлення (Dark/Light) у Desktop-клієнті CSS-змінні кольору оновлюються миттєво, але фон самого вікна може залишатися білим до виклику перемальовування. Щоб уникнути білих спалахів на iOS та Desktop, явно задайте стилі для тегів html та body:

body {
background-color: var(--tg-theme-bg, #ffffff);
color: var(--tg-theme-text, #000000);
min-height: var(--tg-viewport-height, 100vh);
overflow-x: hidden;
}

3. Закриття WebView після успішної оплати

На iOS при виклику методу webApp.close() одразу після закриття нативного шлюзу оплати openInvoice іноді відбувається фриз інтерфейсу, якщо вікно платежу ще не встигло повністю завершити анімацію приховування. Рекомендується ставити мінімальний таймаут у 150-200 мс перед викликом методу close(), або давати користувачеві фідбек через екран подяки з окремою кнопкою закриття.

Потрібна допомога з професійною розробкою Telegram Mini Apps та автоматизацією продажів будь-якої складності? Звертайтеся до веб-студії BotCreator.

Нові статті — у Telegram

Розбираємо, що автоматизувати в бізнесі та як це працює на практиці. Без спаму.