Features & Build Notes · · 3 min read
E-commerce feature: product cards & a cart that don't leak conversion
Core e-commerce website features, taken apart: Rupiah formatting with Intl.NumberFormat, the anatomy of a product card that sells, and a localStorage cart with a badge — with a live preview.
Arabika
Kopi Gayo Premium 250g
Rp 85.000
Arabika
Kopi Toraja Sapan 250g
Rp 95.000
Robusta
Robusta Temanggung 500g
Rp 62.000
Product cards and the cart are the first two features I build on every client e-commerce project. They look simple, but this is where conversion leaks when done wrong: confusing prices, unclear buttons, and carts that “forget” their contents. Click View preview below to see the finished result running.
1. Price formatting: solve it once, correct forever
The most common mistake in local online stores: Rp 85000 (no thousands separator) or formatting that shifts between pages. One helper, used everywhere:
export const rupiah = (n: number) =>
new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0, // no ,00 — nobody prices in cents
}).format(n);
rupiah(85000); // "Rp 85.000"
Intl.NumberFormat ships with browsers & servers — no library, consistent everywhere.
2. A product card that sells
The proven card anatomy: fixed-ratio image, name capped at two lines, prominent price, and one primary action:
<article class="group border border-line bg-base">
<div class="aspect-square overflow-hidden bg-surface">
<img src="/img/gayo-coffee.webp" alt="Gayo Coffee 250g" loading="lazy"
class="h-full w-full object-cover transition-transform group-hover:scale-105" />
</div>
<div class="p-4">
<p class="font-mono text-[10px] uppercase tracking-[0.18em] text-muted">Arabica</p>
<h3 class="mt-1 line-clamp-2 font-display text-[15px] font-semibold">Premium Gayo Coffee 250g</h3>
<p class="mt-2 font-display text-lg font-semibold">Rp 85.000</p>
<button class="btn-primary mt-3 w-full" data-add-to-cart data-id="gayo-coffee-250">
+ Add to cart
</button>
</div>
</article>
Details people forget: loading="lazy" (catalog pages load dozens of images), aspect-square (the grid doesn’t “jump” as images load), and line-clamp-2 (long names can’t break card heights).
3. A cart that doesn’t forget: localStorage + a badge
Before touching the backend, the cart can live in the browser — surviving page navigation and closed tabs:
const CART_KEY = "cart";
const readCart = () => JSON.parse(localStorage.getItem(CART_KEY) ?? "[]");
function addToCart(id) {
const cart = readCart();
const row = cart.find((r) => r.id === id);
row ? row.qty++ : cart.push({ id, qty: 1 });
localStorage.setItem(CART_KEY, JSON.stringify(cart));
renderBadge();
}
function renderBadge() {
const total = readCart().reduce((s, r) => s + r.qty, 0);
const badge = document.querySelector("[data-cart-badge]");
badge.textContent = total;
badge.hidden = total === 0; // a "0" badge is worse than no badge
}
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-add-to-cart]");
if (btn) addToCart(btn.dataset.id);
});
renderBadge(); // sync on page load
One listener on document (event delegation) handles every button — including cards that arrive later via infinite scroll/HTMX.
4. When to move the cart to the server?
localStorage is enough until you need: a cross-device cart (logged-in users), stock validation on add, or prices that can change (promos). The pattern stays identical — addToCart switches from writing localStorage to POST /cart, and the badge renders server-side. Start simple; upgrade when the data demands it.
Lessons
- Price is the most important UI in a store — its formatting must not wobble a single pixel between pages.
- A stable product grid (locked aspect ratios) feels “expensive”; a jumpy one feels cheap.
- An empty cart after refresh = a lost sale. localStorage is a 15-minute fix.
Want something like this built for your business?