Checkout in a Vue 3 Mini App: Pinia Cart, localStorage Sync, and PHP cURL Payments

Building web applications (Mini Apps) inside Telegram unlocks powerful opportunities for e-commerce: the user completes a purchase without leaving the messenger, while the retailer gets high conversion rates due to the absence of complex authorization. However, when designing the user experience, a frontend developer faces a number of specific challenges: cart persistence during accidental WebView closures, seamless integration of Telegram's native UI (MainButton, HapticFeedback), and secure transmission of order items to the backend for invoicing.

In this article, we will take a detailed look at building a catalog module using Vue 3 and Pinia with state persistence in localStorage, as well as describe a server-side PHP handler that verifies data authenticity and generates a payment link via Telegram Bot API.

Client-side State Architecture: Pinia and localStorage

When working within the Telegram WebView container, users can accidentally swipe away the application window or minimize the messenger. If the shopping cart is stored solely in RAM (Vue reactivity), any crash or tab reload will result in the loss of selected items. This critically reduces conversion rates for e-commerce projects.

The solution is a hybrid approach: the cart state is managed by a reactive Pinia store, while every change in items or quantities is synchronized with localStorage. Upon application initialization, the store reads the saved JSON and restores the customer's state.

import { defineStore } from 'pinia';

const LOCAL_STORAGE_KEY = 'tma_cart_items_v1';

export const useCartStore = defineStore('cart', {
state: () => ({
items: JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '[]')
}),
getters: {
totalItems: (state) => state.items.reduce((acc, item) => acc + item.quantity, 0),
totalPrice: (state) => state.items.reduce((acc, item) => acc + item.price * item.quantity, 0)
},
actions: {
saveToStorage() {
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.items));
} catch (e) {
console.error('Ошибка записи в localStorage', e);
}
},
addItem(product) {
const existing = this.items.find(i => i.id === product.id);
if (existing) {
existing.quantity += 1;
} else {
this.items.push({
id: product.id,
title: product.title,
price: product.price,
quantity: 1
});
}
this.saveToStorage();
},
removeItem(productId) {
this.items = this.items.filter(i => i.id !== productId);
this.saveToStorage();
},
updateQuantity(productId, quantity) {
const item = this.items.find(i => i.id === productId);
if (item) {
item.quantity = quantity;
if (item.quantity <= 0) {
this.removeItem(productId);
return;
}
}
this.saveToStorage();
},
clearCart() {
this.items = [];
localStorage.removeItem(LOCAL_STORAGE_KEY);
}
}
});

Please note: product prices in the store should preferably be stored in the smallest currency units (kopecks, cents) as integers (integer). This prevents precision issues with floating-point math (float) typical for JavaScript and PHP.

Integration with Telegram WebApp SDK and MainButton Management

The Telegram Mini Apps UI pattern involves using the native MainButton located at the bottom of the messenger screen instead of the standard web "Checkout" button. This creates the feel of interacting with a native mobile app.

To synchronize the cart state with the native button, reactive tracking of the totalItems getter is organized via Vue watch. When the cart is not empty, MainButton is displayed with text showing the order total. Clicking the button triggers the checkout process.

<template>
<div class="catalog">
<h2>Каталог товаров</h2>
<div v-for="product in products" :key="product.id" class="product-item">
<div class="product-info">
<strong>{{ product.title }}</strong>
<span>{{ (product.price / 100).toFixed(2) }} ₽</span>
</div>
<button @click="addToCart(product)

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.