Telegram recently launched its Mini Apps integration, enabling web developers to build a wide range of applications that allow users to seamlessly transition from chatting to shopping, gaming, or booking services without ever stepping out of the Telegram ecosystem.
Telegram Mini Apps provide the same mobile app experience as native mobile apps, solving one of the major challenges developers face when launching mobile apps. Currently, developers have to deploy their Android apps to the Google Play Store, which usually takes up to five days for Google to approve the app for download. Similarly, an iOS app takes up to five days for the Apple store to approve an app for download.
Telegram Mini Apps have seen wide adoption in the Web3 ecosystem, where most new crypto startups use Mini Apps to quickly roll out their MVPs and build a community of active users. In this tutorial, we’ll explore the process of developing Telegram Mini Apps as we build and deploy a fullstack ecommerce application with Stripe integration for handling payments.
Before moving forward with this tutorial, you should have:
A super-app is a single application that provides a one-stop shop for various applications that users need. Mini apps are applications that execute within a super-app. Similarly, Telegram Mini Apps (or TMAs) are web applications that are executed within Telegram, allowing developers to build a variety of applications on the service.
Telegram Mini Apps offer a wide range of functionalities, including gaming, content sharing, productivity tools, ecommerce, and more. Included among the many benefits they offer are:
In this section, we will create an ecommerce application with React and Node.js that will run on Telegram as a Mini App. Our ecommerce application will have the following features:
Because we’re using Postgres database, which is an SQL database, we need to create tables and establish relationships between the tables.
Open psql
, create your database, and run the following command to create the required tables for the application:
CREATE TABLE users ( id SERIAL PRIMARY KEY, username VARCHAR(100) NOT NULL, email VARCHAR(100) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE products ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, description TEXT, price NUMERIC(10, 2) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id), total NUMERIC(10, 2) NOT NULL, status VARCHAR(50) DEFAULT 'Pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_items ( id SERIAL PRIMARY KEY, order_id INTEGER REFERENCES orders(id), product_id INTEGER REFERENCES products(id), quantity INTEGER NOT NULL, price NUMERIC(10, 2) NOT NULL );
This is the database schema for the ecommerce application. The SQL statements create a set of tables for managing users, products, orders, and order items in a database, with appropriate data types, constraints, and relationships between the tables.
We need a backend application that communicates with the database to store and retrieve data across the entire TMA. Let’s start by creating a Node.js project and installing dependencies with the following command:
npm install bcryptjs express jsonwebtoken pg
Create a .env.postgres
file and add the following environment variables. You will need to change the values to use the credentials to connect to your database:
PORT=5100 DB_USER=your_username DB_HOST=your_db_host DB_NAME=your_DB_name DB_PASSWORD=password DB_PORT=your_db_port JWT_SECRET=your_jwt_secret STRIPE_PUBLISHABLE_KEY=you_stripe_publishable_key STRIPE_SECRET_KEY=you_stripe_secret_key
Create a config/config.js
file in the project root directory and add the following:
import dotenv from 'dotenv'; import pkg from 'pg'; const { Pool } = pkg; dotenv.config(); const config = { port: process.env.PORT || 5000, db: { user: process.env.DB_USER, host: process.env.DB_HOST, database: process.env.DB_NAME, password: process.env.DB_PASSWORD, port: process.env.DB_PORT }, jwtSecret: process.env.JWT_SECRET || 'your_jwt_secret' }; const pool = new Pool(config.db); export {config, pool};
This config setup allows the application to securely manage our app settings and database connections using environment variables.
Next, create a middlewares/authMiddleware.js
file in the project root directory and add the following:
import jwt from 'jsonwebtoken'; import {config} from '../config/config.js'; const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.sendStatus(401); jwt.verify(token, config.jwtSecret, (err, user) => { if (err) return res.sendStatus(403); req.user = user; next(); }); }; export { authenticateToken };
The middleware function authenticateToken
is used to restrict routes to authenticated users by ensuring that incoming requests contain a valid JWT. If the token is missing, invalid, or expired, the function responds with appropriate HTTP status codes (403
). If the token is valid, the decoded user information is attached to the request object, allowing subsequent middleware or route handlers to access the authenticated user’s details.
Create a controllers/authController.js
file in the project root directory and add the following code to implement user registration:
import jwt from "jsonwebtoken"; import bcrypt from "bcryptjs"; import { config, pool } from "../config/config.js"; const register = async (req, res) => { const { username, email, password } = req.body; const hashedPassword = await bcrypt.hash(password, 10); try { const result = await pool.query( "INSERT INTO users (username, email, password) VALUES ($1, $2, $3) RETURNING *", [username, email, hashedPassword] ); const { ...user } = result.rows[0]; res.status(201).json({ ...user, password: null }); } catch (error) { res.status(400).json({ error: error.message }); } }; export { register, login, profile };
The register
function inserts the new user into the users
table with the hashed password, and retrieves the newly created user record with the RETURNING *
clause, excluding the password.
This is how we would implement user login:
const generateToken = (user) => { return jwt.sign({ id: user.id, username: user.username }, config.jwtSecret, { expiresIn: "1h", }); }; const login = async (req, res) => { const { email, password } = req.body; try { const result = await pool.query("SELECT * FROM users WHERE email = $1", [ email, ]); const user = result.rows[0]; if (user && (await bcrypt.compare(password, user.password))) { const token = generateToken(user); res.json({ token }); } else { res.status(401).json({ error: "Invalid email or password" }); } } catch (error) { res.status(400).json({ error: error.message }); } };
The generateToken
function creates a JWT token for a given user, containing their id
and username
, and signs it with a secret key, setting an expiration time of one hour.
The login
function queries the database for a user with the given email, comparing the provided password with the hashed password stored in the database. If the credentials are valid, it generates a JWT token and sends it in the response. If the credentials are invalid, it responds with a 401 Unauthorized
status.
The following code is how we would implement a user profile:
const profile = async (req, res) => { const { id } = req.params; try { const result = await pool.query("SELECT * FROM users WHERE id = $1", [req.user.id]); if (result.rows.length === 0) return res.status(404).json({ message: "User not found" }); res.status(200).json(result.rows[0]); } catch (error) { res.status(400).json({ error: error.message }); } };
The profile
function retrieves the profile information of an authenticated user from the database by the user ID.
Create a productController.js
file in the controller
directory and add the following code to create a product:
import {config, pool} from '../config/config.js'; export const createProduct = async (req, res) => { const { name, description, price } = req.body; try { const result = await pool.query( 'INSERT INTO products (name, description, price) VALUES ($1, $2, $3) RETURNING *', [name, description, price] ); res.json(result.rows[0]); } catch (error) { res.status(500).json({ error: error.message }); } };
The createProduct
function inserts the new product into the products
table, retrieves the newly created product record with the RETURNING *
clause, and returns it as a JSON response.
To get products, run the following code:
export const getProducts = async (req, res) => { try { const result = await pool.query('SELECT * FROM products'); res.json(result.rows); } catch (error) { res.status(500).json({ error: error.message }); } };
The getProducts
function queries the database for all the available products with the SELECT *
clause.
Get a product using its ID with the following code:
export const getProductById = async (req, res) => { const { id } = req.params; try { const result = await pool.query('SELECT * FROM products WHERE id = $1', [id]); if (result.rows.length === 0) return res.status(404).json({ message: 'Product not found' }); res.status(200).json(result.rows[0]); } catch (error) { res.status(400).json({ error: error.message }); } };
The getProductById
function queries the database for a product with the given ID. If the product is not found, it responds with a 404 Not found
status.
To update a product, run the following code:
export const updateProduct = async (req, res) => { const { id } = req.params; const { name, description, price } = req.body; try { const result = await pool.query( 'UPDATE products SET name = $1, description = $2, price = $3 WHERE id = $4 RETURNING *', [name, description, price, id] ); res.json(result.rows[0]); } catch (error) { res.status(500).json({ error: error.message }); } };
The updateProduct
function allows the modification of product details by their ID in the database.
The following code can be used to delete a product:
export const deleteProduct = async (req, res) => { const { id } = req.params; try { await pool.query('DELETE FROM products WHERE id = $1', [id]); res.sendStatus(204); } catch (error) { res.status(500).json({ error: error.message }); } };
The deleteProduct
function deletes a product from the database using the product ID.
Create a orderController.js
file in the controller
directory and add the following to create an order:
import {config, pool} from '../config/config.js'; export const createOrder = async (req, res) => { const { items } = req.body; try { const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); const orderResult = await pool.query( 'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING *', [req.user.id, total] ); const order = orderResult.rows[0]; const orderItemsQueries = items.map(item => { return pool.query( 'INSERT INTO order_items (order_id, product_id, quantity, price) VALUES ($1, $2, $3, $4)', [order.id, item.product_id, item.quantity, item.price] ); }); await Promise.all(orderItemsQueries); res.json(order); } catch (error) { res.status(500).json({ error: error.message }); } };
The createOrder
function creates a new order in the database by extracting the order items from the request body, calculating the total cost of the order, inserting a new order into the orders
table with the user ID and total cost, then inserting each item in the order into the order_items
table with the corresponding order_id
, product_id
, quantity
, and price
. It then sends the newly created order as a JSON response.
Run the following code to get orders:
export const getOrders = async (req, res) => { try { const result = await pool.query('SELECT * FROM orders WHERE user_id = $1', [req.user.id]); res.json(result.rows); } catch (error) { res.status(500).json({ error: error.message }); } };
The getOrders
function queries the database for all the available orders with the SELECT *
clause.
In order to get an order by ID, run this:
export const getOrderById = async (req, res) => { const { id } = req.params; try { const result = await pool.query('SELECT * FROM orders WHERE id = $1 AND user_id = $2', [id, req.user.id]); if (result.rows.length === 0) return res.status(404).json({ message: 'Contact not found' }); res.status(200).json(result.rows[0]); } catch (error) { res.status(400).json({ error: error.message }); } };
The getOrderById
function queries the database for an order with the given ID. If the order is not found, it responds with a 404 Not found
status.
Create a paymentController.js
file in the controller
directory and add the following:
import Stripe from "stripe"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2024-06-20", }); export const config = (req, res) => { res.send({ publishableKey: process.env.STRIPE_PUBLISHABLE_KEY, }); }; export const paymentIntent = async (req, res) => { const { currency, amount } = req.body; try { const paymentIntent = await stripe.paymentIntents.create({ currency: currency ? currency : "usd", amount: amount * 100, automatic_payment_methods: { enabled: true }, }); res.send({ clientSecret: paymentIntent.client_secret, }); } catch (e) { return res.status(400).send({ error: { message: e.message, }, }); } };
The above code snippet integrates with Stripe to manage payment processing. The config
function provides the Stripe publishable key to the client while the paymentIntent
function creates a payment intent with Stripe using the provided currency and amount. It then returns the client secret needed to complete the payment on the client side.
To get your STRIPE_PUBLISHABLE_KEY
and STRIPE_SECRET_KEY
, you’ll need to sign up for a Stripe developer account, turn on the test mode toggle, and click on Developers > API keys.
Next, create a routes/authRoutes.js
file in the project root directory and add the following:
import express from 'express'; import { register, login, profile } from '../controllers/authController.js'; import { authenticateToken } from '../middlewares/authMiddleware.js'; const router = express.Router(); router.post('/register', register); router.post('/login', login); router.route('/profile') .get(authenticateToken, profile); export default router;
The above code sets up an Express router to handle authentication-related routes. The /profile
route is protected using the authenticateToken
middleware, ensuring that only authenticated users can access it.
Then, create a orderRoutes.js
file in the routes
directory and add the following:
import express from 'express'; import { createOrder, getOrders, getOrderById } from '../controllers/orderController.js'; import { authenticateToken } from '../middlewares/authMiddleware.js'; const router = express.Router(); router.route('/') .post(authenticateToken, createOrder) .get(authenticateToken, getOrders); router.route('/:id') .get(authenticateToken, getOrderById) export default router;
This code sets up an Express router to handle order-related routes. All the routes are protected using the authenticateToken
middleware, ensuring that only authenticated users can access them.
The following code will set up an Express router to handle payment-related routes. Similarly, all the routes are protected using the authenticateToken
middleware.
Create a paymentRoutes.js
file in the routes
directory and add the following:
import express from 'express'; import { authenticateToken } from '../middlewares/authMiddleware.js'; import { config, paymentIntent } from '../controllers/paymentController.js'; const router = express.Router(); router.route('/create-payment-intent') .post(authenticateToken, paymentIntent) router.route('/config') .get(authenticateToken, config); export default router;
The code below sets up an Express router to handle product-related routes. Create a productRoutes.js
file in the routes
directory and add the following:
import express from 'express'; import { createProduct, getProducts, getProductById, updateProduct, deleteProduct } from '../controllers/productController.js'; import { authenticateToken } from '../middlewares/authMiddleware.js'; const router = express.Router(); router.route('/') .post(authenticateToken, createProduct) .get(getProducts); router.route('/:id') .get(getProductById) .put(authenticateToken, updateProduct) .delete(authenticateToken, deleteProduct); export default router;
Next, create a server.js
file in the project root directory and add the following:
import express from "express"; import bodyParser from "body-parser"; import authRoutes from "./routes/authRoutes.js"; import productRoutes from "./routes/productRoutes.js"; import orderRoutes from "./routes/orderRoutes.js"; import paymentRoutes from "./routes/paymentRoutes.js" import { config } from "./config/config.js"; import cors from "cors"; const corsOptions = { credentials: true, origin: ["http://localhost:3000", "https://shopeefai.netlify.app"], // Whitelist the domains you want to allow }; const app = express(); app.use(cors(corsOptions)); app.use(bodyParser.json()); app.use("/api/auth", authRoutes); app.use("/api/products", productRoutes); app.use("/api/orders", orderRoutes); app.use("/api/payments", paymentRoutes); app.listen(config.port, () => { console.log(`Server is running on port ${config.port}`); });
This code sets up an Express server with middleware and routes for handling authentication, product management, order processing, and payment features. It also includes CORS configuration to allow specific domains to access the server.
Now you can test your API with Postman to confirm that each endpoint works as expected before integrating with the frontend application.
Create a React project and install the following dependencies:
npm install react-icons react-router-dom @stripe/react-stripe-js @stripe/stripe-js axios
Next, create a .env.postgres
file and add the following environment variables. You will need to change the values to use the credentials to connect to your API server:
REACT_APP_API_URL=your_api_url
In the src
directory, create a constants/index.js
file and add the following:
export const BASE_URL = process.env.REACT_APP_API_URL;
Next, we’ll set up Tailwind for styling. Create tailwind.config.js
file in the project root directory and add the following:
module.exports = { content: [ "./src/**/*.{js,jsx,ts,tsx}", ], theme: { extend: {}, }, plugins: [], }
In the src
directory, create a tailwind.css
file and add the following:
@tailwind base; @tailwind components; @tailwind utilities;
Then, still in the src
directory, create a components/Spinner.js
file and add the following:
import { ImSpinner6 } from "react-icons/im"; const Spinner = ({ className }) => { return ( <ImSpinner6 className={`animate-spin text-[#F95D44] ${className} `} /> ); }; export default Spinner;
To set up the context for global state management, create a context/AuthContext.js
file in the src
directory and add the following:
import React, { createContext, useState, useEffect } from 'react'; import axios from 'axios'; import { BASE_URL } from '../constants'; const AuthContext = createContext(null); const AuthProvider = ({ children }) => { const [user, setUser] = useState({}); useEffect(() => { const token = localStorage.getItem('token'); if (token) { axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; axios.get(`${BASE_URL}/auth/profile`) .then(response => setUser(response.data)) .catch(() => setUser(null)); } }, []); const login = async (email, password) => { const response = await axios.post(`${BASE_URL}/auth/login`, { email, password }); const token = response.data.token; localStorage.setItem('token', token); axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; const user = await axios.get(`${BASE_URL}/auth/profile`); setUser(user.data); }; const logout = () => { localStorage.removeItem('token'); setUser(null); }; return ( <AuthContext.Provider value={{ user, login, logout }}> {children} </AuthContext.Provider> ); }; export { AuthContext, AuthProvider };
The AuthContext.js
sets up an authentication context and provider to manage user state, login, and logout functionality across the application.
The login
function sends a POST request to the login endpoint with the provided email and password, saves the received JWT token to localStorage
, sets the authorization header for subsequent Axios requests, fetches the user’s profile data, and updates the user
state.
The logout
function handles user logout by removing the JWT token from localStorage
and setting the user
state to null
.
The register
function handles user registration by sending user details (username, email, password) to the /auth/register
endpoint of the API.
In the context
directory, create a CartContext.js
file and add the following:
import React, { createContext, useState } from "react"; const CartContext = createContext(); const CartProvider = ({ children }) => { const [cart, setCart] = useState([]); const [cartTotal, setCartTotal] = useState(0); const addToCart = (product) => { const item = cart.find((i) => i.id === product.id); if (item) { setCart(cart.map((item) => item.id === product.id ? { ...item, quantity: Number(item.quantity) + 1, price: Number(item.price) + Number(product.price) } : item)); } else { setCart([...cart, {...product, quantity: 1}]); } }; const removeFromCart = (product) => { const item = cart.find((i) => i.id === product.id); if (item) { setCart( cart.filter((item) => item.id !== product.id )); } }; const clearCart = () => { setCart([]); }; return ( <CartContext.Provider value={{ cart, addToCart, removeFromCart, clearCart, setCartTotal, cartTotal }} > {children} </CartContext.Provider> ); }; export { CartContext, CartProvider };
The CartContext.js
sets up a React context and provider to manage the shopping cart functionality across the application.
Next, we’ll implement a register. In the components
directory, create a Register.js
file and add the following:
import React, { useState, useContext } from "react"; import { AuthContext } from "../context/AuthContext"; import { Link, useNavigate } from "react-router-dom"; const Register = () => { const { register } = useContext(AuthContext); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [username, setUsername] = useState(""); const [isLoading, setIsLoading] = useState(false); const navigate = useNavigate(); const handleSubmit = async (e) => { e.preventDefault(); setIsLoading(true) try { await register(username, email, password); navigate("/login"); setIsLoading(false) } catch (error) { console.error("Failed to register", error); setIsLoading(false) } }; return ( <div className="flex items-center justify-center min-h-screen bg-gray-100"> <div className="w-full max-w-md p-8 space-y-8 bg-white rounded shadow-md"> <h2 className="text-2xl font-bold text-center">Register</h2> <form onSubmit={handleSubmit} className="space-y-6"> <div> <label htmlFor="username" className="block text-sm font-medium text-gray-700" > Username </label> <input type="text" id="username" value={username} onChange={(e) => setUsername(e.target.value)} className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300" /> </div> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700" > Email </label> <input type="email" id="email" value={email} onChange={(e) => setEmail(e.target.value)} className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300" /> </div> <div> <label htmlFor="password" className="block text-sm font-medium text-gray-700" > Password </label> <input type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300" /> </div> <p><Link className="text-blue-900" to={'/login'}>Login</Link> to an existing account</p> <div> <button type="submit" disabled={isLoading} className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700 focus:outline-none focus:ring" > {isLoading ? "Processing..." : "Login"} </button> </div> </form> </div> </div> ); }; export default Register;
The Register
component handles user registration using React Hooks and Context to manage and submit the registration form.
Now, to implement a login functionality, we’ll create an Auth.js
file in the components
directory and add the following:
import React, { useState, useContext } from "react"; import { AuthContext } from "../context/AuthContext"; import { useNavigate } from "react-router-dom"; const Auth = () => { const { login } = useContext(AuthContext); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const navigate = useNavigate(); const handleSubmit = async (e) => { e.preventDefault(); setIsLoading(true) try { await login(email, password); navigate("/"); setIsLoading(false) } catch (error) { console.error("Failed to login", error); setIsLoading(false) } }; return ( <div className="flex items-center justify-center min-h-screen bg-gray-100"> <div className="w-full max-w-md p-8 space-y-8 bg-white rounded shadow-md"> <h2 className="text-2xl font-bold text-center">Login</h2> <form onSubmit={handleSubmit} className="space-y-6"> <div> <label htmlFor="email" className="block text-sm font-medium text-gray-700" > Email </label> <input type="email" id="email" value={email} onChange={(e) => setEmail(e.target.value)} className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300" /> </div> <div> <label htmlFor="password" className="block text-sm font-medium text-gray-700" > Password </label> <input type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300" /> </div> <div> <button type="submit" disabled={isLoading} className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700 focus:outline-none focus:ring" > {isLoading ? "Processing..." : "Login"} </button> </div> </form> </div> </div> ); }; export default Auth;
The Auth
component handles user login using React Hooks and Context to manage and submit the login form. It uses AuthContext
to access the login
function for authentication.
Next, we’ll implement a product list by creating a ProductList.js
file in the components
directory, and adding the following code:
import React, { useEffect, useState, useContext } from 'react'; import axios from 'axios'; import { CartContext } from '../context/CartContext'; import { BASE_URL } from '../constants'; import Spinner from './Spinner'; const ProductList = () => { const [products, setProducts] = useState([]); const [isLoading, setIsLoading] = useState(false); const { addToCart } = useContext(CartContext); useEffect(() => { fetchProducts(); }, []); const fetchProducts = async () => { setIsLoading(true) try { const response = await axios.get(`${BASE_URL}/products`); setProducts(response.data); setIsLoading(false) } catch (error) { console.error('Failed to fetch products', error); setIsLoading(false) } }; return ( <div className="container mx-auto p-4"> <h2 className="text-2xl font-bold mb-4">Products</h2> <ul className="space-y-4"> {isLoading ? <div className='flex justify-center items-center w-full'> <Spinner className={"text-2xl w-10 h-10"}/> </div> : products.length > 0 ? products.map((product) => ( <li key={product.id} className="p-4 bg-white rounded shadow-md"> <div className="flex items-center justify-between"> <div> <h3 className="text-xl font-bold">{product.name}</h3> <p className="text-gray-700">${product.price}</p> </div> <div className="space-x-2"> <button onClick={() => addToCart(product)} className="px-4 py-2 font-bold text-white bg-green-500 rounded hover:bg-green-700" > Add to Cart </button> </div> </div> </li> )) : <p className='text-center'>No Product Available</p>} </ul> </div> ); }; export default ProductList;
The ProductList
component fetches and displays a list of products from the API. Users can view these products and add them to their shopping cart using CartContext
.
Next, we’ll implement a product form. In the components
directory, create ProductForm.js
file and add the following:
import React, { useState, useContext } from 'react'; import axios from 'axios'; import { AuthContext } from '../context/AuthContext'; import { BASE_URL } from '../constants'; const ProductForm = () => { const { user } = useContext(AuthContext); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [price, setPrice] = useState(''); const handleSubmit = async (e) => { e.preventDefault(); if (!user) return; try { await axios.post(`${BASE_URL}/products`, { name, description, price }); setName(''); setDescription(''); setPrice(''); } catch (error) { console.error('Failed to create product', error); } }; return ( <form onSubmit={handleSubmit} className="space-y-6"> <div> <label htmlFor="name" className="block text-sm font-medium text-gray-700">Name</label> <input type="text" id="name" value={name} onChange={(e) => setName(e.target.value)} className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300" /> </div> <div> <label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label> <input type="text" id="description" value={description} onChange={(e) => setDescription(e.target.value)} className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300" /> </div> <div> <label htmlFor="price" className="block text-sm font-medium text-gray-700">Price</label> <input type="number" id="price" value={price} onChange={(e) => setPrice(e.target.value)} className="block w-full px-3 py-2 mt-1 text-gray-700 bg-gray-100 border border-gray-300 rounded focus:outline-none focus:ring focus:border-blue-300" /> </div> <div> <button type="submit" className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700 focus:outline-none focus:ring">Add Product</button> </div> </form> ); }; export default ProductForm;
The ProductForm
component allows authenticated users to add new products to a product list by submitting a form.
Now, we’ll implement a cart where users can view their items, remove them, and proceed to the checkout page. The Cart
component will help us achieve this functionality. In the components
directory, create a Cart.js
file and add the following:
import React, { useContext, useEffect } from "react"; import { CartContext } from "../context/CartContext"; import { useNavigate } from "react-router-dom"; const Cart = () => { const { cart, removeFromCart, setCartTotal } = useContext(CartContext); const navigate = useNavigate(); const cartTotal = cart.reduce((sum, item) => { return sum + parseFloat(item.price) * item.quantity; }, 0); useEffect(() => { setCartTotal(cartTotal); }, []); return ( <div> <h2 className="text-2xl font-bold mb-4">Cart</h2> <ul className="space-y-4"> {cart.map((product) => ( <li key={product.id} className="p-4 bg-white rounded shadow-md"> <div className="flex items-center justify-between"> <div> <h3 className="text-xl font-bold">{product.name}</h3> <p className="text-gray-700">${product.price}</p> <p className="text-gray-700">Quantity: {product.quantity}</p> </div> <button onClick={() => removeFromCart(product)} className="px-4 py-2 font-bold text-white bg-red-500 rounded hover:bg-red-700" > Remove </button> </div> </li> ))} </ul> <p className="text-xl font-bold mt-6">Total: ${cartTotal}</p> <button onClick={() => navigate("/checkout")} className="w-full px-4 py-2 mt-4 font-bold text-white bg-blue-500 rounded hover:bg-blue-700" > Checkout </button> </div> ); }; export default Cart;
In the next step, we’ll implement an order list using the OrderList
component, which displays a list of orders for the authenticated user. In the components
directory, create an OrderList.js
file and add the following:
import React, { useEffect, useState, useContext } from 'react'; import axios from 'axios'; import { AuthContext } from '../context/AuthContext'; import { BASE_URL } from '../constants'; import Spinner from './Spinner'; const OrderList = () => { const [orders, setOrders] = useState([]); const { user } = useContext(AuthContext); const [isLoading, setIsLoading] = useState(false); useEffect(() => { fetchOrders(); }, []); const fetchOrders = async () => { setIsLoading(true) try { const response = await axios.get(`${BASE_URL}/orders`); setOrders(response.data); setIsLoading(false) } catch (error) { console.error('Failed to fetch orders', error); setIsLoading(false) } }; return ( <div> <h2 className="text-2xl font-bold mb-4">Orders</h2> <ul className="space-y-4"> {isLoading ? <div className='flex justify-center items-center w-full'> <Spinner className={"text-2xl w-10 h-10"}/> </div> : orders.length > 0 ? orders.map((order) => ( <li key={order.id} className="p-4 bg-white rounded shadow-md"> <div className="flex items-center justify-between"> <div> <p className="text-gray-700">Order ID: {order.id}</p> <p className="text-gray-700">Total: ${order.total}</p> <p className="text-gray-700">Status: <span className="bg-yellow-600 text-white py-1 px-3 rounded-md">{order.status}</span></p> </div> </div> </li> )) : <p className='text-center'>No Order Available</p>} </ul> </div> ); }; export default OrderList;
Next, we’ll implement a Stripe payment process in our user’s cart using the Payment
component. This interacts with the Stripe API to create a payment intent and configure Stripe elements.
In the components
directory, create a Payment.js
file and add the following:
import { useContext, useEffect, useState } from "react"; import axios from 'axios'; import { Elements } from "@stripe/react-stripe-js"; import CheckoutForm from "./CheckoutForm"; import { loadStripe } from "@stripe/stripe-js"; import { CartContext } from "../context/CartContext"; import { BASE_URL } from "../constants"; function Payment() { const { cartTotal } = useContext(CartContext); const [stripePromise, setStripePromise] = useState(null); const [clientSecret, setClientSecret] = useState(""); const fetchConfig = async () => { try { const response = await axios.get(`${BASE_URL}/payments/config`) setStripePromise(loadStripe(response.data.publishableKey)); } catch (error) { console.log(error) } } useEffect( () => { fetchConfig() }, []); const paymentIntent = async () => { try { const response = await axios.post(`${BASE_URL}/payments/create-payment-intent`, {currency: 'usd', amount: cartTotal}) setClientSecret(response.data.clientSecret); } catch (error) { console.log(error) } } useEffect( () => { paymentIntent() }, []); return ( <> <h1>Your Total is: ${cartTotal}</h1> {clientSecret && stripePromise && ( <Elements stripe={stripePromise} options={{ clientSecret }}> <CheckoutForm /> </Elements> )} </> ); } export default Payment;
One of our last steps will be to implement a completion page using the Completion
component. This handles the post-payment process by creating an order based on the items in the user’s cart. In the components
directory, create a Completion.js
file and add the following:
import axios from 'axios'; import React, { useContext, useEffect } from 'react' import { Link } from 'react-router-dom' import { CartContext } from '../context/CartContext'; import { BASE_URL } from '../constants'; const Completion = () => { const { cart } = useContext(CartContext); const createOrder = async () => { try { await axios.post(`${BASE_URL}/orders`, {items: cart}); } catch (error) { console.error('Failed to create product', error); } }; useEffect(() => { createOrder() }, []) return ( <div className='text-center'> <p className='text-xl mb-6'>Payment Successful</p> <Link to={"/orders"} className='bg-green-700 p-2 rounded text-base text-white'>Track Order</Link> </div> ) } export default Completion
We’re almost at the end! Now, we’ll add a product checkout form using the CheckoutForm
component. This component handles the Stripe payment process, configures payment using Stripe’s API, and displays the appropriate message based on the payment status.
In the components
directory, create a CheckoutForm.js
file and add the following code. We will also use the PaymentElement
component to provide a form to collect payment details:
import { useState } from "react"; import { useStripe, useElements, PaymentElement } from "@stripe/react-stripe-js"; import { BASE_URL } from "../constants"; import { useNavigate } from "react-router-dom"; export default function CheckoutForm() { const stripe = useStripe(); const elements = useElements(); const navigate = useNavigate() const [message, setMessage] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); if (!stripe || !elements) { return; } setIsProcessing(true); const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `/completion`, }, redirect: 'if_required' }); if (error.type === "card_error" || error.type === "validation_error") { setMessage(error.message); } else { setMessage("An unexpected error occurred."); } navigate("/completion") setIsProcessing(false); }; return ( <form id="payment-form" onSubmit={handleSubmit}> <PaymentElement id="payment-element" /> <button disabled={isProcessing || !stripe || !elements} id="submit"> <span id="button-text"> {isProcessing ? "Processing ... " : "Pay now"} </span> </button> {message && <div id="payment-message">{message}</div>} </form> ); }
Finally, we’ll finish things off by implementing the App
component, which sets up the main structure and routing of the application using AuthContext
to manage and check the user’s authentication status and redirect to the login page if necessary.
Update the App.js
file with the following:
import React, { useContext } from "react"; import { BrowserRouter as Router, Route, Routes, Navigate, Link, } from "react-router-dom"; import { AuthContext } from "./context/AuthContext"; import Auth from "./components/Auth"; import Cart from "./components/Cart"; import OrderList from "./components/OrderList"; import ProductForm from "./components/ProductForm"; import Payment from "./components/Payment"; import Completion from "./components/Completion"; import ProductList from "./components/ProductList"; import Register from "./components/Register"; const App = () => { const { user, logout } = useContext(AuthContext); return ( <Router> <nav className="flex justify-between flex-wrap shadow-lg px-4 mb-6 items-center h-20"> <div className="flex space-x-2 items-center"> <Link to={"/"} className="font-bold text-xl">Shopee</Link> <Link to={"/cart"}>Cart</Link> <Link to={"/orders"}>Orders</Link> </div> {user ? ( <div className="flex items-center space-x-2"> <h1 className="text-base font-bold">Welcome, {user.username}</h1> <button onClick={logout} className="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700" > Logout </button> </div> ) : <Link to={"login"} className="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700" > Login </Link>} </nav> <Routes> <Route path="/login" element={<Auth />} /> <Route path="/register" element={<Register />} /> <Route path="/orders" element={user ? <OrderList /> : <Navigate to="/login" />} /> <Route path="/" element={<ProductList />} /> <Route path="/cart" element={<Cart />} /> <Route path="/add-product" element={user ? <ProductForm /> : <Navigate to="/login" />} /> <Route path="/checkout" element={user ? <Payment /> : <Navigate to="/login" />} /> <Route path="/completion" element={user ? <Completion /> : <Navigate to="/login" />}/> </Routes> </Router> ); }; export default App;
In this section, we’ll deploy our API server and React application to a remote server.
Use the following steps to deploy your React application to Netlify:
Use the following steps to deploy your API server to Render:
After successful deployment, you’ll see the URL to access the API remotely.
Because we’re using the Postgres database for our ecommerce application, we must create a Postgres database on Render:
After the PostgreSQL database is successfully created, head over to the deployed API server and update your environment variables with the newly created database information so the API server can access the database.
In this section, we will set up and configure a Telegram bot to transition our fullstack ecommerce application to a Mini App running on Telegram.
Open your Telegram application and search for BotFather
. Join the BotFather
bot, then type /start
to access the Telegram Bot API.
Type /newbot
to create a new bot, and then you will be prompted with Please choose a name for your bot
,” followed by choose a username for your bot
. After responding to the prompt with the required information, your bot will be created.
Next, type /newapp
to create a new web app then type @your_bot_name
. This will create a new web app for your bot and prompt you with the following:
1. Please enter a title for the web app. 2. Please enter a short description of the web app. 3. Please upload a photo, 640x360 pixels. 4. Now upload a demo GIF or send /empty to skip this step. 5. Now please send me the Web App URL that will be opened when users follow a web app direct link. 6. Now please choose a short name for your web app
After responding to the prompt with the required information, your web app will be transformed into a Mini App that runs on Telegram. Your Mini App URL will be in the format t.me/bot_name/web_app_name
.
If you followed along correctly, here is what your Telegram Mini App should look like:
You can interact with the final version of the Mini App demo at t.me/shopzify_bot/shopzify.
Here are the GitHub repositories for the full-stack ecommerce application:
In this tutorial, we looked at the process of creating a full-stack, ecommerce Telegram Mini App using React, Node.js, Express, and Postgres. This demo should give you an idea of how to use familiar technologies to create mini apps in a super-app ecosystem.
There are so many ways this process can be improved. What types of mini app would you build using Telegram’s platform? Or how have you already implemented Telegram Mini Apps in your projects? Let us know in the comments below!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowHandle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
Design React Native UIs that look great on any device by using adaptive layouts, responsive scaling, and platform-specific tools.
Angular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.