Building a Vue Catalog Mini App with Persistent Cart and Telegram Checkout
This tutorial walks you through creating a Telegram Mini App frontend built with Vue 3, featuring a dynamic product catalog, a persistent shopping cart managed by Pinia and backed by localStorage, and a checkout flow that can either generate an invoice via a Telegram bot or redirect to an external payment provider.
We assume you have a Telegram bot already set up (via @meta/telegram SDK or BotFather) and that your backend exposes endpoints for invoicing and payment processing. The frontend is a self-contained .vue package that communicates with the webhook using the standard updateMessageId pattern.
1. Project Setup
First, scaffold a Vue 3 project with the official template:
npm create vite@latest telegram-catalog-miniapp --template vue
cd telegram-catalog-miniapp
npm install
Install the Telegram client SDK and Pinia:
npm install @meta/telegram @meta/telegram-bot-sdk pinia
Configure environment variables for your bot token and webhook URL in .env:
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTU
WEBHOOK_URL=https://your-domain.com/webhook
PAYMENT_GATEWAY_API_KEY=sk_test_xxxxx
2. Telegram Client Integration
The core of the Mini App is the @meta/telegram SDK. We initialize it once per session and use its getUpdates() method to receive messages from the server-side webhook.
// src/store/cart.js
import { defineStore } from 'pinia'
import { inject, ref } from 'vue'
const TELEGRAM_CLIENT = inject('telegramClient')
// Initialize the client when the app starts
const initTelegram = async () => {
const token = process.env.TELEGRAM_BOT_TOKEN
if (!token) throw new Error('Telegram token missing')
await TELEGRAM_CLIENT.init({ token })
}
export const useCart = defineStore('cart', {
state: () => ({
items: [],
total: 0,
}),
actions: {
addToCart(product) {
// If item already exists, increase quantity; otherwise push new entry
const existing = this.items.find(i => i.id === product.id)
if (existing) {
existing.quantity += 1
} else {
this.items.push({
...product,
quantity: 1
})
}
this.total = this.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
},
removeFromCart(id) {
this.items = this.items.filter(item => item.id !== id)
this.total = this.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
},
clearCart() {
this.items = []
this.total = 0
}
}
})
Persist the cart to localStorage so users retain their selections across sessions even when the Mini App is closed.
// src/stores/cart.js (persistence layer)
import { useCart } from './cart'
const saveCart = (items) => {
try {
localStorage.setItem('telegram-catalog-cart', JSON.stringify(items))
} catch (e) {
console.warn('Failed to persist cart', e)
}
}
const loadCart = () => {
try {
const raw = localStorage.getItem('telegram-catalog-cart')
return raw ? JSON.parse(raw) : []
} catch (e) {
console.error('Failed to load cart', e)
return []
}
}
export function usePersistedCart() {
const cartState = useCart()
const persisted = loadCart()
cartState.items = persisted
return { ...cartState, persisted }
}
3. Product Catalog Component
The catalog displays products fetched from your backend. Each product card allows adding/removing items to the cart.
<!-- src/components/ProductCatalog.vue -->
<template>
<div class="catalog">
<h1>Shop</h1>
<div v-for="product" :key="product.id" class="product-card">
<img :src="product.image" alt="Product" />
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<span class="price">{{ formatPrice(product.price) }}</span>
<button @click="addToCart(product)">Add to Cart</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useCart } from '@/stores/cart'
import { useTelegram } from '@/store/telegram' // see below
const product = ref(null)
const cart = useCart()
onMounted(() => {
// Fetch products from your API
fetch('/api/products')
.then(r => r.json())
.then(data => {
product.value = data[0] // or iterate over all
})
.catch(e => console.error('Failed to load products', e))
})
const addToCart = (prod) => {
cart.addToCart(prod)
// Optionally refresh cart state
const persisted = usePersistedCart().persisted
cart.items = persisted
}
const formatPrice = (price) => new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(price)
</script>
</vue>
4. Telegram Client Store
We wrap the Telegram client initialization and message handling in a store so the UI can react to updates without direct imports.
// src/store/telegram.js
import { defineStore } from 'pinia'
import { inject, watch } from 'vue'
const TELEGRAM_CLIENT = inject('telegramClient')
// Listen for incoming updates
watch(
() => TELEGRAM_CLIENT.getUpdates(),
(updates) => {
if (updates.length > 0) {
updates.forEach(update => {
switch (update.type) {
case 'message':
handleIncomingMessage(update.message)
break
case 'callback_query':
handleCallback(update.callback_query)
break
default:
console.log('Unknown event', update.type)
}
})
}
}
)
async function handleIncomingMessage(message) {
const chatId = message.chat.id
// For simplicity, we route all messages to the cart
// In a real app, you might filter by conversation ID
if (message.from.id === process.env.TELEGRAM_BOT_TOKEN) {
// Ignore our own messages
return
}
// Forward to backend or trigger action
console.log('New message:', message.text)
}
function handleCallback(callbackQuery) {
const query = callbackQuery.data
// Example: /start, /add, /remove, /checkout
switch (query.action) {
case 'add':
// Extract product ID from query.text
const productId = query.text.split('[')[1].split(']')[0]
const product = await api.getProduct(productId)
cart.addToCart(product)
break
case 'checkout':
// Redirect to external payment or generate invoice
openCheckoutFlow(query.params)
break
}
}
export const useTelegram = defineStore('telegram', {
actions: {}
})
5. Checkout Flow
Two common patterns exist:
#### A. Inline Invoice via Telegram Bot
Generate a simple PDF invoice and share it via a button. The user clicks, receives the PDF, and pays externally (e.g., Stripe, PayPal).
// src/actions/checkout.js
import { inject } from 'vue'
const TELEGRAM_CLIENT = inject('telegramClient')
async function generateInvoice(total) {
// Create a minimal PDF (using a library like pdfmake or wkhtmltopdf)
const invoiceHtml = `
<h2>Order #${total}</h2>
<ul>
${this.products.map(p => `<li>${p.name} x ${p.qty} $${p.price} = $${(p.price * p.qty).toFixed(2)}</li>`).join('')}
</ul>
<p>Total: $${total}</p>
`
// Use cURL to upload to your storage or send via webhook
const response = await fetch('https://your-storage.com/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: Buffer.from(invoiceHtml, 'utf-8').toString()
})
const url = response.url
// Share via Telegram
await TELEGRAM_CLIENT.sendMessage(
process.env.TELEGRAM_BOT_TOKEN,
`Your order has been created. Here is your invoice:`
)
}
#### B. External Payment Gateway
Redirect the user to your payment page, collect the order details, then confirm the transaction.
async function startExternalCheckout() {
// POST order details to your backend
const order = await api.createOrder({
items: cart.items,
total: cart.total,
customerEmail: getCustomerEmail()
})
// Confirm with the payment provider
await paymentProvider.confirm(order.id)
// On success, mark as paid in DB and notify Telegram
await api.markPaid(order.id)
await TELEGRAM_CLIENT.sendMessage(
process.env.TELEGRAM_BOT_TOKEN,
`✅ Order #${order.id} is confirmed! Thank you for your purchase.`
)
}
6. Webhook Endpoint
The Mini App must expose a webhook endpoint that Telegram posts updates to. This is typically a route on your backend (Node, Go, Python, etc.). Below is a minimal Express example:
// server/index.js (Express)
const express = require('express')
const bodyParser = require('body-parser')
const { webhookHandler } = require('./handlers/webhook')
const app = express()
app.use(bodyParser.json())
app.use('/webhook', webhookHandler)
app.listen(3000, () => console.log('Webhook listening on port 3000'))
The handler reads the incoming JSON, extracts the update_id, and dispatches events to your frontend store:
// server/handlers/webhook.js
import { useCart } from '../store/cart'
async function webhookHandler(req, res) {
const update = req.body
const updateId = update.update_id
// Dispatch to frontend store
const cartStore = useCart()
// You would normally use a channel-based approach (e.g., Socket.io) to push
// updates to the frontend. For simplicity, we log here.
console.log(`Handled update ${updateId}:`, update)
// Persist any changes locally
cartStore.saveCart(cartStore.items)
res.status(200).send('OK')
}
7. Idempotency and Edge Cases
Idempotency: When the same update is delivered twice (due to network retries), the Mini App must not double-count items or charge the user twice. The solution is to track processed update_ids on the backend and ignore duplicates. On the frontend, you can also deduplicate by checking if the cart state changed since the last update.
Offline Mode: If the browser loses connectivity while the Mini App is open, the cart persists in localStorage. When the connection returns, the webhook handler processes pending updates. Ensure your backend stores each update's update_id and only applies it once.
Network Errors: Wrap every outbound request (PDF generation, payment confirmation) in a retry loop with exponential backoff. Display a friendly message like "Payment failed, please try again" rather than crashing.
Security: Never expose your Telegram bot token in client-side code. Keep it on the server. The initData parameter passed during /start can be used to verify the client version and detect tampering.
Rate Limits: Telegram imposes limits on webhook requests (typically 1 request per minute per bot). Batch updates if possible, and consider using long polling for periodic sync instead of relying solely on webhooks for non-critical updates.
8. Putting It All Together
Here is a simplified full-flow example:
1. User opens the Mini App and sees the product catalog. 2. They tap a product → addToCart adds it to the Pinia cart. 3. The cart is automatically saved to localStorage. 4. Later, they tap "Checkout" → the frontend calls startExternalCheckout() which creates an order and confirms with the payment provider. 5. Upon successful payment, the backend marks the order as paid and sends a notification via Telegram (/start with a custom message). 6. The Mini App reflects the updated cart (empty if all items were purchased) and shows a success toast.
9. Further Reading
For deeper dives into the Telegram Mini App lifecycle, see the official documentation: https://botservice.biz/telegram-bot-api
If you need help shipping production-grade Telegram bots and Mini Apps, check out BotCreator — studio that ships Telegram bots / Mini Apps.
---
This guide gives you a solid foundation for a Vue-based catalog Mini App with a persistent cart and flexible checkout options. Adapt the payment logic to your preferred provider, and always keep your webhook secure.
BotCreator