How to build a simple E-commerce web app using Alby's Lightning tools
Let your users seamlessly scan to pay for your products
TL;DR
This project tutorial is open-source on Github and you can easily fork, customize and test as much as you like. I would appreciate a star on the Github project if you found it helpful.
Live Demo: https://bitcoin-shop.netlify.app
Frontend Repo: https://github.com/Toheeb-Ojuolape/bitcoin-shop
Backend Repo: https://github.com/Toheeb-Ojuolape/bitcommerce-api
Introduction
In this tutorial article, I will be sharing a step-by-step guide on how to build a simple e-commerce website that uses Alby's lightning tools to enable customers to make payments and sends the payment to the merchant's Alby wallet immediately.
Here's a demo of what we will be building 👇🏾:
The tech stack:
The frontend of this project is built entirely with React using Vite and the API is built with Nodejs (Typescript).
Our app will have a single view on the Home page which will hold the logic for the web app and the ui for the different products. We will also be using a simple state management dependency called Zustand to persist our state across different components, especially when adding a product to the cart.
Now, let's start hacking ✌🏼!
Backend
The backend is where we would implement the integration with Alby's js-lightning tools to generate an invoice and listen for invoice payments.
First, we will set up a Nodejs project in a folder you can call bitcoin-shop-api (or anything you like, really), then run:
npm init -y
Next, we install all the dependencies needed for the project:
npm i @getalby/lightning-tools cors dotenv express socket.io ts-node
And then dev dependencies for Typescript:
npm i @types/express @types/node typescript
Here's what each dependency does:
@getalby/lightning-tools: to manage our lightning integrations and enable us to generate and listen to invoice payments.
cors: to allow Cross-Origin Resource Sharing for our API.
dotenv: to enable us to use environment variables in our project
express: to setup an express server.
socket.io: to setup real-time connection from the backend to listen to when payment has been settled and update the frontend
ts-node/typescript: to run Typescript
For this tutorial, our API will have only two endpoints:
GET '/products' - to fetch all the products for our project. For simplicity, we would store these products in a JSON file rather than fetching them from a database.
POST '/invoice' - to generate an invoice for the purchase transaction based on the amount.
We would also be creating a helper function called listenInvoice
which would enable us setup a socket connection to the frontend to push an update when the invoice has been settled.
In your App.ts, enter the code below:
require("dotenv").config()
const express = require("express");
import { Request,Response } from "express";
import router from "./src/routes/routes";
const cors = require("cors");
const port = process.env.PORT || 5001;
const app = express();
app.use(
cors({
origin: process.env.PROJECT_URL,
})
);
app.use(express.json());
const server = app.listen(port, () => {
console.log(`App listening on PORT: ${port}`);
});
app.get("/",(req:Request,res:Response)=>{
res.send(`<h2>Bitcommerce API</h2>`)
})
const io = require("socket.io")(server, {
cors: {
origin: [process.env.PROJECT_URL],
},
});
app.use(router);
export { io };
Explanation: This code helps us setup an Express server with CORS handled and routes for our different API endpoints. We also setup socket here and export it for use in our listenInvoice.ts file
/src/routes/routes.ts
import { Router } from "express";
import { fetchProducts } from "../controllers/fetchProducts";
import { generateInvoice } from "../controllers/generateInvoice";
const router = Router()
router.get("/products",fetchProducts)
router.post("/invoice",generateInvoice)
export default router
Explanation: This sets up our two API endpoints as discussed earlier and links them to their respective controllers.
For the fetchProducts
controller, we have these simple lines of code that fetches and serves the data in our JSON file (you can view the sample JSON file in the codebase here) :
/src/controllers/fetchProducts.ts
import { Request, Response } from "express";
import { Product } from "../interfaces/ProductInterface";
const data:Array<Product> = require("../../data.json");
export const fetchProducts = (req: Request, res: Response) => {
try {
res.status(200).json({
data: data,
});
} catch (error) {
res.status(400).json({
error:"Error encountered while fetching product data"
})
}
};
/src/interfaces/ProductInterfaces.tsx
export interface Product {
id: number;
title: string;
description: string;
price: number;
discountPercentage: number;
rating: number;
stock: number;
brand: string;
category: string;
thumbnail: string;
images: Array<string>
}
The generateInvoice
controller is where we use Alby's js-lightning tools to generate an invoice on behalf of the merchant using the merchant's lightning address (e.g. mine is toheeb@getalby.com) which must be defined as an environment variable.
The buyer's name, email, address and id of the selected products would be sent as a request from the frontend. We would be storing the user data and product order information passed by the user as a comment and payerdata in our invoice:
The user's purchase request would have an interface as below:
/src/interfaces/PurchaseRequest.tsx
export interface PurchaseRequest{
name:string,
email:string,
address:string,
products:Array<number>
}
We would be calculating the total amount to be paid by the user using a sumAmount function which checks our product lists using the id of each product and sums the price as below:
/src/utils/sumAmount.tsx
import { Product } from "../interfaces/ProductInterface";
const data: Array<Product> = require("../../data.json");
export const sumAmount = (products: Array<number>): number => {
let sum = 0;
products.forEach((product) => {
sum += data.filter((item) => item.id == product)[0].price;
});
return sum;
};
We then fetch the names of each product as well and convert it into a string of names using the same logic:
/src/utils/productNames.tsx
import { Product } from "../interfaces/ProductInterface";
const data: Array<Product> = require("../../data.json");
export const getProductNames = (products: Array<number>): string => {
let productNames = "";
products.forEach((product) => {
productNames += data.filter((item) => (item.id == product))[0].title + ", ";
});
return productNames;
};
We also write a function that would help generate the ideal comment we would like to add to our invoice:
import { PurchaseRequest } from "../interfaces/PurchaseRequest";
import { getProductNames } from "./productName";
export const getInvoiceComment = (payload: PurchaseRequest) => {
return `"Purchase of ${getProductNames(payload.products)} by ${
payload.name
}, Email: ${payload.email}, Delivery address: ${payload.address}"`;
};
Then we can write our generateInvoice
API to put it all together
import { Request, Response } from "express";
import { LightningAddress, Invoice } from "@getalby/lightning-tools";
import { listenInvoice } from "../helpers/listenInvoice";
import { sumAmount } from "../utils/sumAmount";
import { PurchaseRequest } from "../interfaces/PurchaseRequest";
import { getInvoiceComment } from "../utils/getInvoiceComment";
if (!process.env.MERCHANT_LN_ADDRESS) {
throw new Error(
"Merchant's Lightning address environment variable is not defined"
);
}
const ln = new LightningAddress(process.env.MERCHANT_LN_ADDRESS);
export const generateInvoice = async (req: Request, res: Response) => {
const { name, email, products } = req.body as PurchaseRequest;
try {
if (products.length == 0) {
return res.status(400).json({
error: "Please select products to purchase",
});
}
await ln.fetch();
// get the LNURL-pay data:
const invoice: Invoice = await ln.requestInvoice({
satoshi: sumAmount(products),
comment: getInvoiceComment(req.body),
payerdata: {
name: name,
email: email,
},
});
listenInvoice(req, invoice, res);
return res.status(200).json({
invoice: invoice,
});
} catch (error) {
res.status(400).json({
error: "Error encountered while generating invoice",
});
}
};
Next, we define our listenInvoice
helper function which takes in the invoice and response. The listenInvoice
function also uses Alby's js-lightning tools to check if the Lightning invoice has been settled or not and then sends a real-time response to the frontend (using socket) if the invoice has been settled. We also send an email notification to both the sender and the recipient once the invoice has been settled
/src/helpers/listenInvoice.ts
import { Invoice } from "@getalby/lightning-tools";
import { Request,Response } from "express";
import { io } from "../app";
import sendNotification from "./email";
import { PurchaseRequest } from "../interfaces/PurchaseRequest";
export const listenInvoice = async (
req:Request,
invoice: Invoice,
res: Response
) => {
try {
const intervalId = setInterval(async () => {
const paid = await invoice.isPaid();
if (paid) {
clearInterval(intervalId);
io.emit("payment-verified", {
message: "Payment verified successfully",
});
sendNotification(req.body as PurchaseRequest,res)
}
}, 3000);
} catch (error) {
res.status(400).json({
error: "Error occured while verifying payment",
});
}
};
/src/helpers/email.ts
import { Response } from "express";
import { sumAmount } from "../utils/sumAmount";
import { getProductNames } from "../utils/productName";
import { PurchaseRequest } from "../interfaces/PurchaseRequest";
var nodemailer = require("nodemailer");
const Handlebars = require("handlebars");
const path = require("path");
const fs = require("fs");
const templatePath = path.join(__dirname, "../templates/email.hbs");
const template = fs.readFileSync(templatePath, "utf-8");
const compiledTemplate = Handlebars.compile(template);
if (!process.env.EMAIL_ADDRESS || !process.env.EMAIL_PASSWORD) {
throw new Error("Please set your email credentials");
}
async function sendNotification(payload: PurchaseRequest, res: Response) {
var mail = nodemailer.createTransport({
service: "gmail",
port: 465,
secure: true,
auth: {
user: process.env.EMAIL_ADDRESS,
pass: process.env.EMAIL_PASSWORD,
},
});
const html = compiledTemplate({
name: payload.name,
email: payload.email,
amount: sumAmount(payload.products),
address: payload.address,
products: getProductNames(payload.products),
support: "mailto:" + process.env.EMAIL_ADDRESS,
});
var mailOptions = {
from: `"Bitcoin⚡Shop <${process.env.EMAIL_ADDRESS}>"`,
to: `${payload.email},${process.env.EMAIL_ADDRESS}`,
replyTo: process.env.EMAIL_ADDRESS,
subject: "Your order has been received, " + payload.name,
html: html,
};
mail.sendMail(mailOptions, function (error: any, info: any) {
if (error) {
return;
} else {
res.status(200).json({
message: "Email sent successfully",
});
return;
}
});
}
export default sendNotification;
And that's it! Our backend logic is done. You can find all the code for the backend here
Now let's move to the frontend implementation of the project
Frontend
Let's set up our React project using Vite. To do this, run:
npm create vite@latest my-bitcoin-shop --template react
Then we install the required dependencies
npm i @webbtc/webln-types axios react-hot-toast react-qr-code react-router-dom socket.io-client webln zustand
Here's what each dependency does:
axios: to handle API requests
react-hot-toast: shows beautiful toast messages when we copy our lightning invoice or add a new product to the cart.
react-qr-code: displays the lightning invoice in qrcode form
react-router-dom: to add routing to the project (for later updates)
socket.io-client: to setup socket connection from the frontend client to listen to when payment has been settled and update the frontend
@webbtc/webln-types: to integrate WebLN and prompt a user's web wallet to pay an invoice
zustand: for state management
To manage multiple routes in this project, your App.jsx should look like this:
import { Route,BrowserRouter, Routes } from 'react-router-dom'
import Home from './views/Home/Home'
import "./App.css"
function App() {
return (
<>
<BrowserRouter>
<Routes>
<Route path='/' element={<Home />}/>
</Routes>
</BrowserRouter>
</>
)
}
export default App
For state management using zustand, we setup a simple store that enables us to add products to the cart, remove products and fetch all the products in our cart:
/src/store/store.jsx
import { create } from "zustand";
const useProductStore = create((set) => ({
products: [],
addProducts: (newProducts) =>
set((state) => ({ products: [...state.products, newProducts] })),
removeAllProducts: () => set({ products: [] }),
removeItem: (products) => set({ products: products }),
}));
export default useProductStore;
To separate our API logic from our UI logic, let's create an Ajax file where we would perform all our API calls to the backend
/src/ajax/ajax.jsx
import axios from "axios";
import handleError from "../utils/handleError";
export default {
async fetchProducts() {
try {
const response = await axios({
method: "GET",
url: import.meta.env.VITE_API_URL + "/products",
headers: {
"Content-Type": "application/json",
},
});
return response.data;
} catch (error) {
handleError(error.response.data.error)
return error
}
},
async generateInvoice(buyer, products) {
try {
const response = await axios({
method: "POST",
url: import.meta.env.VITE_API_URL + "/invoice",
headers: {
"Content-Type": "application/json",
},
data: {
name: buyer.name,
email: buyer.email,
address: buyer.address,
products: products,
},
});
return response.data;
} catch (error) {
handleError(error.response.data.error)
}
},
};
/src/helpers/handleError.js
import { toast } from "react-hot-toast";
export default function handleError(message) {
toast.error(message);
}
Now let's define our Home.jsx view.
Create a views folder, create a Home folder and then a Home.jsx file with the following lines of code:
/src/views/Home/Home.jsx
import React, { useState, useEffect } from "react";
import Products from "../../components/Products/Products";
import ajax from "../../ajax/ajax";
import Navbar from "../../components/NavBar/Navbar";
import useProductStore from "../../store/store";
import Checkout from "../../components/Checkout/Checkout";
import { toast } from "react-hot-toast";
import Cart from "../../components/Elements/Cart/Cart";
import handleError from "../../utils/handleError";
function Home() {
const [products, setProducts] = useState([]);
const [checkout, setShowCheckout] = useState(false);
const [productsincart, setProductsInCart] = useState(
useProductStore.getState().products
);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAllProducts = async () => {
try {
const fetchProducts = await ajax.fetchProducts();
setProducts(fetchProducts.data);
setLoading(false);
} catch (error) {
handleError(error.response.data.error);
setLoading(false);
}
};
fetchAllProducts();
}, [setLoading]);
const handleAddProduct = (product) => {
const id = toast.loading("loading");
useProductStore.getState().addProducts(product);
setProductsInCart(useProductStore.getState().products);
toast.dismiss(id);
toast.success("Product added to cart");
};
const removeItem = (index) => {
const updatedProducts = productsincart.slice();
updatedProducts.splice(index, 1);
useProductStore.getState().removeItem(updatedProducts);
setProductsInCart(updatedProducts);
};
const removeAllProducts = () => {
useProductStore.getState().removeAllProducts();
setProductsInCart([]);
};
const showCheckout = () => {
setShowCheckout(!checkout);
window.scroll(0, 0);
};
return (
<div className="relative">
<Navbar products={productsincart} showCheckout={showCheckout} />
<Products
products={products}
addProduct={handleAddProduct}
loading={loading}
/>
<div
className={
productsincart.length >= 1
? "bounce-animation floating-cart"
: "floating-cart"
}
>
<Cart products={productsincart} showCheckout={showCheckout} />
</div>
{checkout && (
<Checkout
products={productsincart}
closeCheckout={() => setShowCheckout(!checkout)}
removeItem={removeItem}
removeAllProducts={removeAllProducts}
/>
)}
</div>
);
}
export default Home;
The Home screen contains our Navbar component, Products component and Checkout component.
/src/components/Navbar/Navbar.jsx
import React from "react";
import "./Navbar.css";
import Cart from "../Elements/Cart/Cart";
import logo from "../../assets/bitcoinlogo.png"
function Navbar({ showCheckout,products }) {
return (
<div className="navbar">
<h1>My ฿itcoin Shop <img width="30px" src={logo}/></h1>
<Cart products={products} showCheckout={showCheckout} />
</div>
);
}
export default Navbar;
/src/components/Products/Products.jsx
import React from "react";
import ProductCategory from "./ProductCategory";
function Products({ products, addProduct }) {
return (
<div>
<ProductCategory
products={products}
title={"All Products"}
addProduct={addProduct}
/>
{/* Devices products */}
<ProductCategory
products={products.filter((product)=>product.category==='device')}
title={"Devices"}
addProduct={addProduct}
/>
<ProductCategory
products={products.filter((product)=>product.category==='accessories')}
title={"Accessories"}
addProduct={addProduct}
/>
</div>
);
}
export default Products;
/src/components/Products/ProductCategory.jsx
import React from "react";
import Product from "./Product";
function ProductCategory({ title, addProduct, products }) {
return (
<div className="product-category">
<h2>{title}</h2>
<div className="products">
{products &&
products.map((product, i) => (
<Product product={product} key={i} addProduct={addProduct} />
))}
</div>
</div>
);
}
export default ProductCategory;
/src/components/Products/Product.jsx
import React from "react";
import { PrimaryButton } from "../Elements/Buttons/Buttons";
import "./Products.css";
import { standardAmountFormat } from "../../utils/amountFormatter";
function Product({ product, addProduct }) {
return (
<div className="product">
<img width={"250px"} src={product.thumbnail} />
<div>
<div className="product-title">{product.title}</div>
<p>{standardAmountFormat(product.price)}</p>
</div>
<PrimaryButton onClick={() => addProduct(product)}>
Add to cart
</PrimaryButton>
</div>
);
}
export default Product;
The standardAmount function is used to format the price of our products properly:
src/utils/amountFormatter.js
export function standardAmountFormat(amount){
return parseFloat(parseFloat(amount && amount).toFixed(2)).toLocaleString('en') + " sats"
}
Next, let's implement our Checkout components to manage the products added to our cart.
The main Checkout component houses multiple components and also holds the socket connection to our backend. At the point of payment, we generate an invoice and check if the user has a WebLN-supported wallet in their browser. If they do, the WebLN sendPayment() function is called to initiate payment. If the user does not have WebLN installed, then an Invoice QR code and string is displayed.
/src/utils/checkWebln.js
export const checkWebln = async () => {
if (window.webln) {
await window.webln.enable()
return true
} else {
return false;
}
};
Once an invoice has been settled, the socket connection changes the paymentStatus in the component from false to true, clears the products in the cart and then displays the SuccessComponent to the user.
/src/components/Checkout/Checkout.jsx
import React, { useState, useEffect } from "react";
import "./Checkout.css";
import CheckoutItem from "./CheckoutItem";
import closeicon from "../../assets/icons/closeicon.svg";
import { sumAmount } from "../../utils/sumAmount";
import { standardAmountFormat } from "../../utils/amountFormatter";
import { OutlinedButton } from "../Elements/Buttons/Buttons";
import ajax from "../../ajax/ajax";
import Invoice from "../Invoice/invoice";
import DetailsForm from "../Elements/Forms/DetailsForm";
import SuccessComponent from "./SuccessComponent";
import io from "socket.io-client";
import { productList } from "../../utils/productList";
import handleError from "../../utils/handleError";
import { checkWebln } from "../../utils/checkWebln";
function Checkout({ products, closeCheckout, removeItem, removeAllProducts }) {
const [loading, setLoading] = useState(false);
const [invoice, setInvoice] = useState("");
const [ispayment, setPayment] = useState(false);
const [disabled, setDisabled] = useState(true);
const [paymentStatus, setPaymentStatus] = useState(false);
const [buyer, setBuyer] = useState({});
const socket = io(import.meta.env.VITE_API_URL);
useEffect(() => {
socket.on("payment-verified", () => {
setPaymentStatus(true);
removeAllProducts();
});
}, []);
const generateInvoice = async () => {
try {
setLoading(true);
const response = await ajax.generateInvoice(buyer, productList(products));
const weblnStatus = await checkWebln()
if (weblnStatus) {
setLoading(false);
await window.webln.sendPayment(response.invoice.paymentRequest);
} else {
setLoading(false);
setInvoice(response.invoice);
setPayment(true);
}
} catch (error) {
handleError(error.message);
setLoading(false);
setPayment(false);
}
};
const setBuyerInfo = (payload) => {
setBuyer(payload);
};
const goBack = () => {
setPayment(false);
};
return (
<div className="checkout">
<div className="flex justify-between">
<h3>Checkout</h3>
<div onClick={closeCheckout}>
<img width={"25px"} src={closeicon} alt="closeicon" />
</div>
</div>
{products.map((product, index) => (
<div key={index}>
<CheckoutItem
product={product}
index={index}
removeItem={removeItem}
/>
</div>
))}
{products.length != 0 && (
<div>
<div className="total-checkout">
<div>Total:</div>{" "}
<div>
<strong>{standardAmountFormat(sumAmount(products))}</strong>
</div>
</div>
{!ispayment && (
<div>
<DetailsForm
setDisabled={setDisabled}
setBuyerInfo={setBuyerInfo}
/>
{!disabled && (
<OutlinedButton
width={"100%"}
loading={loading}
onClick={() => generateInvoice()}
>
Pay Now
</OutlinedButton>
)}
</div>
)}
{ispayment && !paymentStatus && invoice && (
<Invoice goBack={goBack} invoice={invoice} />
)}
</div>
)}
{paymentStatus && <SuccessComponent />}
</div>
);
}
export default Checkout;
Now let's sum the price of each product to display to the user on the frontend :
/src/utils/sumAmount.js
export const sumAmount = (products)=>{
return products.reduce((arr,cur) => arr+cur.price,0)
}
/src/components/Checkout/CheckoutItem.jsx
import React from "react";
import { standardAmountFormat } from "../../utils/amountFormatter";
import closeicon from "../../assets/icons/closeicon.svg";
function CheckoutItem({ product, index,removeItem }) {
return (
<div className="checkout-item">
<div className="flex">
<img
className="checkout-image"
src={product.thumbnail}
alt="checkout-image"
/>
<div className="checkout-item-title">{product.title}</div>
</div>
<div className="flex justify-between">
<div className="checkout-product-price">
{standardAmountFormat(product.price)}
</div>
<img
onClick={() => removeItem(index)}
width={"21px"}
src={closeicon}
alt="closeicon"
/>
</div>
</div>
);
}
export default CheckoutItem;
/src/Components/Forms/DetailsForms.jsx
import React, { useState, useEffect } from "react";
import Input from "./Input";
function DetailsForm({ setDisabled,setBuyerInfo }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [address, setAddress] = useState("");
useEffect(() => {
if (name && email && address) {
setDisabled(false);
setBuyerInfo({
name:name,
email:email,
address:address
})
} else {
setDisabled(true);
}
}, [name, email, address]);
return (
<form className="details-form">
<div className="form-row">
<div className="form-colum">
<Input
setValue={setName}
label={"Name"}
placeholder={"First & last name"}
type="text"
/>
</div>
<div className="form-colum">
<Input
setValue={setEmail}
label={"Email"}
placeholder={"Email address"}
type="email"
value={name}
/>
</div>
</div>
<div className="form-row">
<Input
setValue={setAddress}
width={"95%"}
label={"Address"}
placeholder={"Delivery Address"}
type="text"
/>
</div>
</form>
);
}
export default DetailsForm;
/src/components/Checkout/SuccessComponent.jsx
import React from "react";
import success from "../../assets/icons/success.gif";
function SuccessComponent() {
return (
<div className="success-icon">
<div>
<img width="200px" src={success} alt="" />
<h2>Payment made successfully</h2>
<p>
Your payment has been confirmed successfully <br/>and your products are on
the way
</p>
</div>
</div>
);
}
export default SuccessComponent;
Now, let's write the code for the Invoice component to display a Lightning invoice for payment:
/src/components/Invoice/Invoice.jsx
import React from "react";
import QRCode from "react-qr-code";
import "./invoice.css";
import InvoiceInput from "../Elements/Forms/InvoiceInput";
import PaymentLoading from "./PaymentLoading";
import backIcon from "../../assets/icons/back-icon.svg";
import handleError from "../../utils/handleError";
import { checkWebln } from "../../utils/checkWebln";
function invoice({ invoice, goBack }) {
const payInvoice = async () => {
const weblnStatus = await checkWebln();
if (weblnStatus) {
await window.webln.sendPayment(invoice.paymentRequest);
} else {
handleError("WebLN is not available");
}
};
return (
<div>
<img onClick={goBack} src={backIcon} width={"20px"}></img>
<div onClick={payInvoice} className="invoice">
<div>
<QRCode
size={100}
style={{ height: "auto", maxWidth: "150px", width: "150px" }}
value={invoice.paymentRequest}
viewBox={`0 0 256 256`}
/>
</div>
</div>
<p className="invoice-text">Click the invoice to pay</p>
<div>
<InvoiceInput invoice={invoice.paymentRequest} />
</div>
<PaymentLoading invoice={invoice} />
</div>
);
}
export default invoice;
NOTE: With the payInvoice function, we use the WebLN dependency to prompt the user's Lightning wallet to pay for the products. This wallet could be a Chrome extension like the Alby Browser Extension that works in the browser.
This is a holistic overview of the frontend of the Bitcoin-shop project. You can visit the repo here to checkout the code I used to add styling and other custom elements I created for the frontend.
Conclusion
In this tutorial, we successfully created an e-commerce shop that uses the Lightning Network as its payment provider to seamlessly enable users to pay for products. We've barely scratched the surface of what is possible with these sophisticated tools provided by Alby. To learn more about all the other amazing things you can do with the Alby JS lightning tools, make sure to check out its documentation here: https://github.com/getAlby/js-lightning-tools
Happy hacking ✌🏼
Reference Documentations:
Alby's js-lightning tools: https://github.com/getAlby/js-lightning-tools
WebLN Guide: https://www.webln.guide/