Day 29: Major Project 1: Online Shop Catalogue
✅ Today you will learn
- Plan a real multi-file front-end project like a professional
- Load products from a live API with loading, error and retry states
- Keep all data in one store and re-render on every change
- Debounced search, category chips, sort, price and stock filters
- Save filters in the URL so links can be shared
- Show product details in an accessible <dialog>
🏢 In the real world
Sharma Sweets, Johari Bazaar, Jaipur already has the website you built in the HTML and CSS courses. Now the owner wants customers to order online before Diwali: browse sweets, search, filter, add to a cart and checkout – without paying for Shopify. Over the next two days you build this shop exactly as a junior front-end developer would in a real job: separate files, a live API, a central state store, and real error handling. Today: the catalogue. Tomorrow: cart, checkout and launch.
Try the finished shop first
This is the complete app you will have after Day 30. It talks to the real CodingEasily practice API. Search “kaju”, click a category, change the sort, open a product, add to the cart and even place a test order with coupon SWEET10.
Inside this lesson the app runs in a sandboxed frame, so the cart is not remembered after a reload and the URL does not change. Download the course kit and open index.html – everything works fully there.
Step 1: the client’s requirements
| # | Requirement | How we solve it |
|---|---|---|
| 1 | Show all sweets with photo, price, rating and stock | GET the API → render cards |
| 2 | Fast search while typing | Debounced input (Day 24) |
| 3 | Filter by category, price and stock; sort | Pure filter + sort functions (Day 8) |
| 4 | Send a filtered link on WhatsApp | Filters in URL params (Day 22) |
| 5 | Never a blank page on slow internet | Loading skeleton, error message, retry |
| 6 | Cart survives a refresh | localStorage via a safe wrapper (Day 15) |
| 7 | Prices cannot be faked | Server recalculates every order (Day 26) |
| 8 | Works on phones and in dark mode | CSS course techniques |
Step 2: folder structure – one job per file
sharma-shop/
├── index.html ← page structure, dialogs, script tags
├── css/
│ └── style.css ← design tokens, grid, dialogs, dark mode
└── js/
├── utils.js ← $, $$, rupees(), formatQty(), debounce(), storage, toast()
├── api.js ← fetchJSON(), getProducts(), placeOrder()
├── store.js ← the single state object + update/subscribe
├── catalogue.js ← load, filter, sort, render cards, URL, details dialog
├── cart.js ← add / change / remove, totals, cart drawer (Day 30)
├── checkout.js ← validation, POST order, receipt (Day 30)
└── app.js ← wires events and starts the app (runs last)ES modules (Day 21) do not load from a file opened by double-click (file://) – the browser blocks them. Classic scripts with defer work everywhere, still run in order after the HTML is parsed, and share top-level functions. In a Vite or React job you would use import/export instead – the file split stays the same.
Step 3: the HTML skeleton
The page has a sticky header with search and cart button, filter controls, an empty product grid that JavaScript fills, and three <dialog> elements: product details, cart drawer and checkout.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sharma Sweets Online – Order Fresh Mithai in Jaipur</title>
<meta name="description" content="Order fresh kaju katli, ghewar, rasmalai, ladoo and Diwali gift boxes online from Sharma Sweets, Johari Bazaar, Jaipur. Search, filter, add to cart and checkout in seconds.">
<meta name="theme-color" content="#c2410c">
<link rel="stylesheet" href="css/style.css">
<!-- classic scripts, in order: helpers first, app.js last -->
<script src="js/utils.js" defer></script>
<script src="js/api.js" defer></script>
<script src="js/store.js" defer></script>
<script src="js/catalogue.js" defer></script>
<script src="js/cart.js" defer></script>
<script src="js/checkout.js" defer></script>
<script src="js/app.js" defer></script>
</head>
<body>
<a class="skip" href="#main">Skip to products</a>
<!-- ========== HEADER ========== -->
<header class="site-header">
<div class="bar">
<a class="brand" href="./">🍬 <span>Sharma Sweets</span></a>
<label class="search">
<span class="sr-only">Search sweets</span>
<input id="search" type="search" placeholder="Search kaju, ghewar, gift box…" autocomplete="off">
</label>
<div class="header-actions">
<button class="icon-btn" id="theme-btn" type="button" aria-pressed="false" aria-label="Toggle dark mode">🌓</button>
<button class="cart-btn" id="cart-btn" type="button" aria-haspopup="dialog">
🛒 <span class="sr-only">Cart,</span><span class="badge" id="cart-count">0</span><span class="sr-only">items</span>
</button>
</div>
</div>
</header>
<main id="main">
<!-- ========== HERO ========== -->
<section class="hero">
<h1>Fresh mithai, delivered in 2 hours 🛵</h1>
<p>Made every morning in pure desi ghee at Johari Bazaar, Jaipur. Free delivery above ₹1,500 · use <b>SWEET10</b> for 10% off.</p>
</section>
<!-- ========== FILTERS ========== -->
<section class="filters" aria-label="Filter products">
<div class="chips" id="category-chips" role="group" aria-label="Category">
<button type="button" class="chip" data-category="" aria-pressed="true">All</button>
<button type="button" class="chip" data-category="milk" aria-pressed="false">Milk sweets</button>
<button type="button" class="chip" data-category="dry" aria-pressed="false">Dry sweets</button>
<button type="button" class="chip" data-category="box" aria-pressed="false">Gift boxes</button>
</div>
<div class="filter-row">
<label>Sort
<select id="sort">
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
<option value="name">Name A–Z</option>
</select>
</label>
<label for="max-price">Max price ₹<span id="max-price-out">1000</span></label>
<input id="max-price" type="range" min="300" max="1000" step="50" value="1000" aria-describedby="max-price-out">
<label class="check"><input id="in-stock" type="checkbox"> In stock only</label>
</div>
</section>
<!-- ========== PRODUCTS ========== -->
<p class="results" id="results-text" aria-live="polite"></p>
<section class="grid" id="product-grid" aria-label="Products"></section>
</main>
<!-- ========== PRODUCT DETAIL ========== -->
<dialog class="modal" id="product-dialog" aria-labelledby="pd-name">
<form method="dialog"><button class="close" aria-label="Close">✕</button></form>
<img id="pd-image" src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" alt="" width="640" height="360">
<div class="modal__body">
<h2 id="pd-name"></h2>
<p class="muted" id="pd-meta"></p>
<p id="pd-desc"></p>
<div class="qty-row">
<label for="pd-qty">Quantity</label>
<select id="pd-qty"></select>
<strong id="pd-price"></strong>
</div>
<button class="btn" id="pd-add" type="button">Add to cart</button>
</div>
</dialog>
<!-- ========== CART DRAWER ========== -->
<dialog class="drawer" id="cart-dialog" aria-labelledby="cart-title">
<header class="drawer__head">
<h2 id="cart-title">Your cart</h2>
<form method="dialog"><button class="close" aria-label="Close cart">✕</button></form>
</header>
<ul class="cart-list" id="cart-list"></ul>
<p class="empty" id="cart-empty">Your cart is empty. Add something sweet! 🍬</p>
<div class="totals" id="cart-totals">
<div><span>Subtotal</span><span id="t-subtotal"></span></div>
<div><span>Delivery</span><span id="t-delivery"></span></div>
<p class="hint" id="t-hint"></p>
<div class="grand"><span>Estimated total</span><span id="t-total"></span></div>
<button class="btn btn--block" id="checkout-btn" type="button">Checkout →</button>
</div>
</dialog>
<!-- ========== CHECKOUT ========== -->
<dialog class="modal" id="checkout-dialog" aria-labelledby="co-title">
<form method="dialog"><button class="close" aria-label="Close checkout">✕</button></form>
<div class="modal__body" id="checkout-body">
<h2 id="co-title">Checkout</h2>
<form id="checkout-form" novalidate>
<div class="field"><label for="co-name">Full name</label><input id="co-name" name="name" autocomplete="name" aria-describedby="co-name-err"><p class="err" id="co-name-err"></p></div>
<div class="field"><label for="co-phone">Mobile</label><input id="co-phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" aria-describedby="co-phone-err"><p class="err" id="co-phone-err"></p></div>
<fieldset class="field">
<legend>Delivery or pick-up</legend>
<label class="check"><input type="radio" name="mode" value="delivery" checked> Home delivery</label>
<label class="check"><input type="radio" name="mode" value="pickup"> Pick up from Johari Bazaar</label>
</fieldset>
<div class="field" id="address-field"><label for="co-address">Address (with PIN)</label><textarea id="co-address" name="address" rows="2" autocomplete="street-address" aria-describedby="co-address-err"></textarea><p class="err" id="co-address-err"></p></div>
<div class="field"><label for="co-coupon">Coupon (optional)</label><input id="co-coupon" name="coupon" placeholder="SWEET10" aria-describedby="co-coupon-err"><p class="err" id="co-coupon-err"></p></div>
<p class="err" id="co-form-err" role="alert"></p>
<button class="btn btn--block" id="place-order" type="submit">Place order</button>
</form>
</div>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<footer class="site-footer">
<p>Sharma Sweets · Shop 14, Johari Bazaar, Jaipur · <a href="tel:+919829012345">+91 98290 12345</a></p>
<p class="muted">JavaScript course project – orders go to the CodingEasily practice API and are not stored.</p>
</footer>
</body>
</html>- Every dialog has a close button inside <form method="dialog"> – the browser closes it without any JavaScript.
- Category chips use aria-pressed so screen readers announce “pressed”.
- The results line has aria-live="polite" – “3 of 8 sweets” is read aloud after filtering.
- The checkout form has novalidate because we show our own friendlier messages.
Step 4: utils.js – helpers
// =============================================================
// utils.js – small helpers used everywhere (Days 5, 9, 15, 24)
// =============================================================
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const inr = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 });
const rupees = (amount) => inr.format(Math.round(amount));
// 0.5 kg → "500 g", 1.5 kg → "1.5 kg", boxes → "2 boxes"
function formatQty(qty, unit) {
if (unit === 'box') return qty === 1 ? '1 box' : `${qty} boxes`;
return qty < 1 ? `${Math.round(qty * 1000)} g` : `${qty} kg`;
}
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// localStorage that never crashes (private mode, sandboxed previews, full quota)
const storage = {
get(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch (err) {
return fallback;
}
},
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (err) {
return false;
}
},
};
let toastTimer;
function toast(message) {
const box = $('#toast');
box.textContent = message;
box.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => box.classList.remove('show'), 2600);
}| Helper | Example | Result |
|---|---|---|
| rupees() | rupees(1325) | ₹1,325 (Indian grouping) |
| formatQty() | formatQty(0.75, "kg") | 750 g |
| debounce() | debounce(search, 250) | runs 250 ms after typing stops |
| storage.get() | storage.get("cart", []) | saved value or [] – never crashes |
Step 5: api.js – one place for the server
// =============================================================
// api.js – everything that talks to the server (Day 20)
// The CodingEasily practice API sends CORS headers, so this
// works from any page – even a file opened from your computer.
// =============================================================
const API_URL = 'https://codingeasily.in/api/sweets/';
async function fetchJSON(url, { timeout = 8000, ...options } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { ...options, signal: controller.signal });
const data = await res.json().catch(() => null);
if (!res.ok) {
const error = new Error(data?.error || `Server error (HTTP ${res.status})`);
error.status = res.status;
error.fields = data?.fields || {};
throw error;
}
return data;
} catch (err) {
if (err.name === 'AbortError') throw new Error('The server is taking too long. Please try again.');
if (err instanceof TypeError) throw new Error('You seem to be offline. Check your internet and retry.');
throw err;
} finally {
clearTimeout(timer);
}
}
// GET all products once – filtering and sorting happen in the browser (only 8 items)
function getProducts() {
return fetchJSON(API_URL);
}
// POST an order – only ids and quantities are sent; the SERVER calculates prices (Day 26)
function placeOrder({ name, phone, mode, address, coupon, items }) {
return fetchJSON(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, phone, mode, address, coupon, items }),
});
}fetch only fails on network problems. A 500 response is still a “successful” fetch – that is why fetchJSON checks res.ok and throws itself. Forgetting this is the most common fetch bug in interviews and real code.
Step 6: store.js – a tiny state manager
// =============================================================
// store.js – one state object + subscribe/update (Days 12, 25)
// =============================================================
const CART_KEY = 'ss-shop-cart-v1';
const THEME_KEY = 'ss-shop-theme';
const store = (() => {
let state = {
products: [], // from the API
status: 'loading', // 'loading' | 'ready' | 'error'
error: '',
filters: { q: '', category: '', sort: 'popular', maxPrice: 1000, inStock: false },
cart: storage.get(CART_KEY, []), // [{ id, qty }] – only ids, prices come from products
};
const listeners = [];
return {
get: () => state,
// every change goes through update(): new object, save the cart, notify the UI
update(change) {
state = { ...state, ...(typeof change === 'function' ? change(state) : change) };
storage.set(CART_KEY, state.cart);
listeners.forEach((fn) => fn(state));
},
subscribe(fn) {
listeners.push(fn);
},
};
})();The cart is saved as [{ id, qty }] – never prices. If the shop changes a price tomorrow, the cart automatically shows the new price, and nobody can edit localStorage to buy Kaju Katli for ₹1.
Step 7: loading products with three states
const POPULARITY = { 1: 1, 2: 2, 7: 3, 3: 4, 4: 5, 8: 6, 6: 7, 5: 8 }; // best sellers first
async function loadProducts() {
store.update({ status: 'loading', error: '' });
try {
const products = await getProducts();
store.update({ products, status: 'ready' });
} catch (err) {
store.update({ status: 'error', error: err.message });
}
}Beginner version
- fetch → render
- Blank page while waiting
- Silent failure if offline
- User refreshes again and again
Job-ready version
- status: loading → ready | error
- Shimmer skeleton while loading
- Clear message + “Try again” button
- aria-busy for screen readers
Step 8: filtering and sorting – pure functions
function visibleProducts({ products, filters }) {
const q = filters.q.trim().toLowerCase();
const list = products
.filter((p) => !q || p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q))
.filter((p) => !filters.category || p.category === filters.category)
.filter((p) => p.price <= filters.maxPrice)
.filter((p) => !filters.inStock || p.stock > 0);
const sorters = {
popular: (a, b) => POPULARITY[a.id] - POPULARITY[b.id],
'price-asc': (a, b) => a.price - b.price,
'price-desc': (a, b) => b.price - a.price,
name: (a, b) => a.name.localeCompare(b.name),
};
return [...list].sort(sorters[filters.sort] || sorters.popular);
}visibleProducts() takes state and returns a new array – it never changes state.products. That makes it easy to test (Day 27) and safe to call on every render. We copy with [...list] before sort() because sort changes the array it is called on.
// the same pure function, tested in Node with sample data
const products = [
{ id: 1, name: 'Kaju Katli', description: 'Cashew fudge', category: 'dry', price: 900, stock: 25 },
{ id: 2, name: 'Malai Ghewar', description: 'Honeycomb disc', category: 'milk', price: 650, stock: 8 },
{ id: 5, name: 'Kalakand', description: 'Milk cake', category: 'milk', price: 600, stock: 0 },
{ id: 6, name: 'Soan Papdi', description: 'Flaky layers', category: 'dry', price: 380, stock: 60 },
];
function visible({ products, filters }) {
const q = filters.q.trim().toLowerCase();
return products
.filter((p) => !q || p.name.toLowerCase().includes(q))
.filter((p) => !filters.category || p.category === filters.category)
.filter((p) => p.price <= filters.maxPrice)
.filter((p) => !filters.inStock || p.stock > 0)
.sort((a, b) => a.price - b.price)
.map((p) => `${p.name} ₹${p.price}`);
}
const base = { q: '', category: '', maxPrice: 1000, inStock: false };
console.log('All:', visible({ products, filters: base }));
console.log('Milk:', visible({ products, filters: { ...base, category: 'milk' } }));
console.log('Milk, in stock:', visible({ products, filters: { ...base, category: 'milk', inStock: true } }));
console.log('Under ₹700:', visible({ products, filters: { ...base, maxPrice: 700 } }));
console.log('Search "kaju":', visible({ products, filters: { ...base, q: 'KAJU ' } }));Step 9: rendering product cards safely
function productCard(p) {
const card = document.createElement('article');
card.className = 'card' + (p.stock === 0 ? ' card--out' : '');
const media = document.createElement('button'); // image opens the details dialog
media.type = 'button';
media.className = 'card__media';
media.dataset.action = 'details';
media.dataset.id = p.id;
media.setAttribute('aria-label', `View details of ${p.name}`);
const img = document.createElement('img');
img.src = p.image;
img.alt = '';
img.loading = 'lazy';
media.append(img);
if (p.stock === 0 || p.stock < 10) {
const tag = document.createElement('span');
tag.className = 'card__tag' + (p.stock === 0 ? '' : ' card__tag--low');
tag.textContent = p.stock === 0 ? 'Sold out' : `Only ${p.stock} left`;
media.append(tag);
}
const body = document.createElement('div');
body.className = 'card__body';
const title = document.createElement('h3');
title.className = 'card__title';
title.textContent = p.name;
const desc = document.createElement('p');
desc.className = 'muted';
desc.textContent = p.description;
const price = document.createElement('span');
price.className = 'card__price';
price.textContent = `${rupees(p.price)} / ${p.unit} · ★ ${p.rating}`;
const add = document.createElement('button');
add.type = 'button';
add.className = 'btn';
add.dataset.action = 'add';
add.dataset.id = p.id;
add.disabled = p.stock === 0;
add.textContent = p.stock === 0 ? 'Sold out' : `Add ${p.unit === 'box' ? '1 box' : '½ kg'}`;
body.append(title, desc, price, add);
card.append(media, body);
return card;
}
function renderCatalogue(state) {
const grid = $('#product-grid');
const results = $('#results-text');
if (state.status === 'loading') {
results.textContent = 'Loading fresh sweets…';
grid.innerHTML = '<div class="skeleton"></div>'.repeat(4);
grid.setAttribute('aria-busy', 'true');
return;
}
grid.removeAttribute('aria-busy');
if (state.status === 'error') {
results.textContent = '';
const box = document.createElement('div');
box.className = 'state';
box.textContent = '⚠️ ' + state.error + ' ';
const retry = document.createElement('button');
retry.className = 'btn';
retry.dataset.action = 'retry';
retry.textContent = 'Try again';
box.append(retry);
grid.replaceChildren(box);
return;
}
const list = visibleProducts(state);
results.textContent = `${list.length} of ${state.products.length} sweets`;
if (list.length === 0) {
grid.innerHTML = '<div class="state">No sweets match these filters. <button class="btn btn--ghost" data-action="reset">Clear filters</button></div>';
return;
}
grid.replaceChildren(...list.map(productCard));
}- Product text goes in with textContent, never innerHTML – data from a server is untrusted (Day 26).
- The image is a <button>, so keyboard users can open details with Enter.
- Stock below 10 shows “Only 8 left” – real shops use this to create urgency.
- replaceChildren(...cards) swaps the whole grid in one DOM update.
Step 10: filters in the URL
// ---- filter controls ↔ state ↔ URL ----
function filtersToUrl(filters) {
const params = new URLSearchParams();
if (filters.q) params.set('q', filters.q);
if (filters.category) params.set('category', filters.category);
if (filters.sort !== 'popular') params.set('sort', filters.sort);
if (filters.maxPrice !== 1000) params.set('max', filters.maxPrice);
if (filters.inStock) params.set('stock', '1');
const url = location.pathname + (params.toString() ? '?' + params : '');
try { history.replaceState(null, '', url); } catch (err) { /* sandboxed preview */ }
}
function filtersFromUrl() {
const p = new URLSearchParams(location.search);
return {
q: p.get('q') || '',
category: ['milk', 'dry', 'box'].includes(p.get('category')) ? p.get('category') : '',
sort: ['popular', 'price-asc', 'price-desc', 'name'].includes(p.get('sort')) ? p.get('sort') : 'popular',
maxPrice: Math.min(1000, Math.max(300, Number(p.get('max')) || 1000)),
inStock: p.get('stock') === '1',
};
}
function syncFilterControls({ filters }) {
$('#search').value = filters.q;
$('#sort').value = filters.sort;
$('#max-price').value = filters.maxPrice;
$('#max-price-out').textContent = filters.maxPrice;
$('#in-stock').checked = filters.inStock;
$$('#category-chips .chip').forEach((chip) => chip.setAttribute('aria-pressed', String(chip.dataset.category === filters.category)));
}
function setFilter(change) {
store.update((s) => ({ filters: { ...s.filters, ...change } }));
filtersToUrl(store.get().filters);
}Choose “Milk sweets” and sort by price, and the address becomes index.html?category=milk&sort=price-asc. The owner can send that exact link on WhatsApp. filtersFromUrl() checks every value against an allow-list, so a hand-edited URL like ?max=-5 cannot break the page.
replaceState updates the address without adding history entries. With pushState, every slider movement would add a Back-button step – very annoying.
Step 11: product details dialog
// ---- product details dialog ----
function openDetails(id) {
const p = store.get().products.find((x) => x.id === id);
if (!p) return;
const dialog = $('#product-dialog');
$('#pd-image').src = p.image.replace('-400.webp', '.webp');
$('#pd-image').alt = p.name;
$('#pd-name').textContent = p.name;
$('#pd-meta').textContent = `★ ${p.rating} · ${p.category === 'box' ? 'Gift box' : p.category === 'milk' ? 'Milk sweet – eat within 2 days' : 'Dry sweet – stays fresh 15 days'}`;
$('#pd-desc').textContent = p.description;
const qty = $('#pd-qty');
const options = p.unit === 'box' ? [1, 2, 3, 5, 10] : [0.25, 0.5, 1, 1.5, 2, 5];
qty.replaceChildren(...options.map((q) => new Option(formatQty(q, p.unit), q)));
qty.value = p.unit === 'box' ? 1 : 0.5;
const updatePrice = () => { $('#pd-price').textContent = rupees(p.price * Number(qty.value)); };
qty.onchange = updatePrice;
updatePrice();
const add = $('#pd-add');
add.disabled = p.stock === 0;
add.textContent = p.stock === 0 ? 'Sold out' : 'Add to cart';
add.onclick = () => { addToCart(p.id, Number(qty.value)); dialog.close(); };
dialog.showModal();
}showModal() gives us a free focus trap, Esc-to-close and a ::backdrop. Quantity options change with the unit: 250 g steps for sweets sold by kg, whole numbers for gift boxes.
Step 12: app.js – wiring the catalogue
// =============================================================
// app.js – wires everything together (runs last)
// 1. Theme 2. Render on every state change 3. Events
// 4. Start
// =============================================================
// ---------- 1. THEME (Day 15 storage + CSS variables) ----------
function applyTheme(theme) {
document.documentElement.classList.toggle('dark', theme === 'dark');
$('#theme-btn').setAttribute('aria-pressed', String(theme === 'dark'));
}
const savedTheme = storage.get(THEME_KEY, matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
applyTheme(savedTheme);
// ---------- 2. RENDER ON EVERY STATE CHANGE ----------
store.subscribe((state) => {
renderCatalogue(state);
renderCart(state);
});
// ---------- 3. EVENTS ----------
// search (debounced, Day 24)
$('#search').addEventListener('input', debounce((e) => setFilter({ q: e.target.value }), 250));
// category chips (delegation, Day 11)
$('#category-chips').addEventListener('click', (e) => {
const chip = e.target.closest('.chip');
if (chip) setFilter({ category: chip.dataset.category });
syncFilterControls(store.get());
});
$('#sort').addEventListener('change', (e) => setFilter({ sort: e.target.value }));
$('#max-price').addEventListener('input', (e) => {
$('#max-price-out').textContent = e.target.value;
setFilter({ maxPrice: Number(e.target.value) });
});
$('#in-stock').addEventListener('change', (e) => setFilter({ inStock: e.target.checked }));
// product grid: add / details / retry / reset (one delegated listener)
$('#product-grid').addEventListener('click', (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
const id = Number(target.dataset.id);
if (target.dataset.action === 'add') addToCart(id);
if (target.dataset.action === 'details') openDetails(id);
if (target.dataset.action === 'retry') loadProducts();
if (target.dataset.action === 'reset') {
store.update({ filters: { q: '', category: '', sort: 'popular', maxPrice: 1000, inStock: false } });
filtersToUrl(store.get().filters);
syncFilterControls(store.get());
}
});
// cart drawer
$('#cart-btn').addEventListener('click', () => $('#cart-dialog').showModal());
$('#cart-list').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const id = Number(btn.closest('.cart-item').dataset.id);
if (btn.dataset.action === 'inc') changeQty(id, +1);
if (btn.dataset.action === 'dec') changeQty(id, -1);
if (btn.dataset.action === 'remove') removeFromCart(id);
});
$('#checkout-btn').addEventListener('click', openCheckout);
// checkout (the form element is kept so it can be restored after a receipt)
checkoutFormNodes = [...$('#checkout-body').childNodes];
$('#checkout-form').addEventListener('submit', submitCheckout);
$('#checkout-form').addEventListener('change', (e) => { if (e.target.name === 'mode') updateAddressVisibility(); });
$('#checkout-form').addEventListener('input', (e) => {
if (e.target.getAttribute('aria-invalid') === 'true') {
const rule = checkoutRules[e.target.name];
const result = rule ? rule(e.target.value, e.currentTarget) : true;
setFieldError(e.currentTarget, e.target.name, result === true ? '' : result);
}
});
// clicking the dark backdrop closes any dialog
$$('dialog').forEach((dialog) => dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); }));
// theme toggle
$('#theme-btn').addEventListener('click', () => {
const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
applyTheme(next);
storage.set(THEME_KEY, next);
});
// keep the cart in sync between open tabs (Day 15)
window.addEventListener('storage', (e) => {
if (e.key === CART_KEY) store.update({ cart: storage.get(CART_KEY, []) });
});
// "/" focuses search
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
e.preventDefault();
$('#search').focus();
}
});
// ---------- 4. START ----------
store.update({ filters: filtersFromUrl() });
syncFilterControls(store.get());
loadProducts();| Event | Listener on | Calls |
|---|---|---|
| input (debounced) | #search | setFilter({ q }) |
| click (delegated) | #category-chips | setFilter({ category }) |
| change / input | #sort, #max-price, #in-stock | setFilter(...) |
| click (delegated) | #product-grid | addToCart / openDetails / loadProducts |
| keydown | document | “/” focuses search |
| storage | window | syncs the cart between tabs |
Step 13: our test run
| Test | Result |
|---|---|
| Open index.html?category=milk | ✅ “3 of 8 sweets”, Milk chip pressed |
| Type “kaju” in search | ✅ “2 of 8 sweets”, URL ?q=kaju |
| Slow API (?delay=3000) | ✅ shimmer skeleton shown |
| API down (?fail=1) | ✅ friendly error + Try again |
| Open a product, choose 1 kg, Add | ✅ badge updates, toast shown |
| 375 px phone width | ✅ no horizontal scroll |
| Console errors | ✅ none |
/* Sharma Sweets online shop – styles (CSS course techniques) */
:root {
--brand: #c2410c; --brand-600: #9a3412; --gold: #f59e0b; --gold-soft: #fef3c7; --on-brand: #fff;
--ink: #2b1a12; --text: #44342b; --muted: #7c6a5e; --line: #f0e2d4; --bg: #fffaf3; --surface: #fff;
--ok: #15803d; --bad: #b91c1c; --radius: 16px; --shadow: 0 10px 30px rgb(87 46 18 / 10%);
}
:root.dark {
--ink: #fdf2e6; --text: #e9dccf; --muted: #b9a797; --line: #3a2a20; --bg: #1a120d; --surface: #241912;
--gold-soft: #3d2a12; --brand: #f97316; --brand-600: #fb923c; --on-brand: #1a120d; --shadow: 0 10px 30px rgb(0 0 0 / 45%);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* the one allowed !important: display:grid must never beat the hidden attribute */
body { margin: 0; font-family: system-ui, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.55; }
img { display: block; max-width: 100%; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, h3 { color: var(--ink); line-height: 1.2; }
a { color: var(--brand); }
:focus-visible { outline: 3px solid var(--gold); outline-offset: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.skip { position: absolute; left: -999px; } .skip:focus { left: 8px; top: 8px; z-index: 100; background: var(--surface); padding: 8px; }
.muted { color: var(--muted); }
/* header */
.site-header { position: sticky; top: 0; z-index: 20; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--line); }
.bar { width: min(1120px, 100% - 24px); margin: auto; display: flex; align-items: center; gap: 12px; min-height: 64px; flex-wrap: wrap; padding-block: 8px; }
.brand { font-weight: 800; font-size: 1.2rem; text-decoration: none; color: var(--ink); white-space: nowrap; }
.search { flex: 1 1 220px; }
.search input { width: 100%; padding: 10px 14px; border-radius: 99px; border: 1.5px solid var(--line); background: var(--surface); }
.header-actions { display: flex; gap: 8px; margin-left: auto; }
@media (max-width: 560px) { .search { order: 3; flex-basis: 100%; } }
.icon-btn, .cart-btn { border: 1.5px solid var(--line); background: var(--surface); border-radius: 99px; padding: 8px 14px; }
.cart-btn { background: var(--brand); color: var(--on-brand); border-color: var(--brand); font-weight: 700; display: flex; gap: 6px; align-items: center; }
.badge { background: var(--on-brand); color: var(--brand); border-radius: 99px; min-width: 24px; padding: 0 6px; text-align: center; font-size: .85rem; }
.badge.bump { animation: bump .4s; }
@keyframes bump { 50% { transform: scale(1.4); } }
main { width: min(1120px, 100% - 24px); margin: auto; padding-bottom: 40px; }
.hero { padding: 28px 0 8px; }
.hero h1 { margin: 0 0 6px; font-size: clamp(1.5rem, 1.1rem + 2vw, 2.4rem); }
.hero p { margin: 0; max-width: 65ch; }
/* filters */
.filters { display: grid; gap: 10px; margin: 18px 0 8px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { border: 1.5px solid var(--line); background: var(--surface); border-radius: 99px; padding: 6px 14px; font-weight: 600; }
.chip[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.filter-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
.filter-row label { display: flex; gap: 8px; align-items: center; font-weight: 600; }
.filter-row select { padding: 6px 10px; border-radius: 8px; border: 1.5px solid var(--line); background: var(--surface); }
.filter-row input[type="range"] { accent-color: var(--brand); margin-left: -8px; }
.check { display: inline-flex; gap: 8px; align-items: center; font-weight: 500; }
.check input { accent-color: var(--brand); width: 18px; height: 18px; }
.results { color: var(--muted); margin: 8px 0 12px; }
/* product grid */
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); }
.card { display: flex; flex-direction: column; background: var(--surface); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); transition: transform .25s, box-shadow .25s; }
.card:hover { transform: translateY(-4px); }
.card__media { position: relative; border: 0; padding: 0; background: none; }
.card__media img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; background: var(--gold-soft); }
.card__tag { position: absolute; top: 10px; left: 10px; background: var(--bad); color: #fff; font-size: .75rem; font-weight: 700; padding: 3px 8px; border-radius: 6px; }
.card__tag--low { background: var(--gold); color: #1a120d; }
.card__body { padding: 12px 14px 16px; display: flex; flex-direction: column; gap: 4px; flex: 1; }
.card__title { margin: 0; font-size: 1.05rem; }
.card__price { font-weight: 800; color: var(--brand); }
.card__body .btn { margin-top: auto; }
.card--out { opacity: .6; }
.skeleton { height: 290px; border-radius: var(--radius); background: linear-gradient(90deg, var(--line) 25%, var(--surface) 50%, var(--line) 75%); background-size: 200% 100%; animation: shimmer 1.2s linear infinite; }
@keyframes shimmer { to { background-position: -200% 0; } }
.state { grid-column: 1 / -1; text-align: center; padding: 30px; background: var(--surface); border-radius: var(--radius); }
/* buttons */
.btn { display: inline-block; text-align: center; text-decoration: none; background: var(--brand); color: var(--on-brand); border: 0; border-radius: 10px; padding: 10px 16px; font-weight: 700; transition: background-color .2s, transform .1s; }
.btn:hover { background: var(--brand-600); }
.btn:active { transform: scale(.98); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
.btn--ghost { background: transparent; color: var(--brand); border: 1.5px solid var(--brand); }
.btn--block { width: 100%; }
/* dialogs */
dialog { border: 0; padding: 0; color: var(--text); background: var(--surface); box-shadow: 0 30px 60px rgb(0 0 0 / 35%); }
dialog::backdrop { background: rgb(26 18 13 / 55%); backdrop-filter: blur(2px); }
.modal { width: min(92vw, 520px); border-radius: 20px; }
.modal img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; background: var(--gold-soft); }
.modal__body { padding: 18px 22px 22px; }
.modal__body h2 { margin: 0 0 4px; }
.close { position: absolute; top: 10px; right: 10px; z-index: 2; width: 36px; height: 36px; border-radius: 50%; border: 0; background: var(--surface); box-shadow: var(--shadow); }
.qty-row { display: flex; gap: 12px; align-items: center; margin: 12px 0; }
.qty-row select { padding: 6px 10px; border-radius: 8px; border: 1.5px solid var(--line); background: var(--surface); }
.drawer { margin: 0 0 0 auto; height: 100dvh; max-height: 100dvh; width: min(420px, 100vw); display: flex; flex-direction: column; }
.drawer:not([open]) { display: none; }
.drawer[open] { animation: slide .25s ease-out; }
@keyframes slide { from { transform: translateX(100%); } }
.drawer__head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--line); }
.drawer__head h2 { margin: 0; }
.drawer__head .close { position: static; box-shadow: none; }
.cart-list { list-style: none; margin: 0; padding: 0 18px; overflow: auto; flex: 1; }
.cart-item { display: grid; grid-template-columns: 56px 1fr auto; gap: 10px; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--line); }
.cart-item img { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; }
.cart-item__name { font-weight: 700; color: var(--ink); }
.stepper { display: inline-flex; align-items: center; border: 1.5px solid var(--line); border-radius: 99px; }
.stepper button { border: 0; background: none; width: 30px; height: 30px; font-size: 1.1rem; }
.stepper span { min-width: 54px; text-align: center; font-variant-numeric: tabular-nums; }
.remove { border: 0; background: none; color: var(--bad); font-size: .85rem; text-decoration: underline; }
.empty { text-align: center; padding: 30px 18px; flex: 1; }
.totals { padding: 14px 18px 18px; border-top: 1px solid var(--line); display: grid; gap: 6px; }
.totals div { display: flex; justify-content: space-between; }
.totals .grand { font-weight: 800; font-size: 1.15rem; color: var(--ink); }
.hint { margin: 0; font-size: .85rem; color: var(--ok); }
/* checkout form */
.field { display: grid; gap: 4px; margin-bottom: 10px; border: 0; padding: 0; }
.field > label, legend { font-weight: 700; color: var(--ink); }
.field input:not([type="radio"]), .field textarea { padding: 10px 12px; border: 1.5px solid var(--line); border-radius: 10px; background: var(--bg); }
.field [aria-invalid="true"] { border-color: var(--bad); }
.err { color: var(--bad); font-size: .85rem; margin: 0; min-height: 1em; }
.success { text-align: center; padding: 10px 0; }
.success .big { font-size: 3rem; margin: 0; }
.success table { width: 100%; border-collapse: collapse; margin: 12px 0; text-align: left; }
.success td { padding: 4px 0; border-bottom: 1px solid var(--line); }
.success td:last-child { text-align: right; }
/* toast + footer */
.toast { position: fixed; left: 50%; bottom: 20px; transform: translate(-50%, 160%); background: var(--ink); color: var(--bg); padding: 12px 18px; border-radius: 12px; transition: transform .3s ease; z-index: 50; max-width: calc(100vw - 24px); }
.toast.show { transform: translate(-50%, 0); }
.site-footer { text-align: center; padding: 24px 12px; border-top: 1px solid var(--line); }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }🏅 Job skills unlocked today
Practice tasks
🏋️ Practice – do it yourself
Quick quiz
🧠 Quick Quiz – test yourself
Interview corner
💼 Interview Corner
How do you structure a vanilla JavaScript app without a framework?
Separate concerns into files: helpers, API layer, a single store with update/subscribe, render functions that read state, and one file that wires events. Data flows one way – events change state, state change triggers render. This is the same mental model as React + Redux, so it transfers directly.
How do you handle loading and error states for an API call?
Keep a status field (loading, ready, error) in state, render a skeleton for loading, a message with a retry action for error, add a timeout with AbortController, translate technical errors to user-friendly text, and mark the region aria-busy while loading.
Should filtering happen on the client or the server?
For a small catalogue (tens of items) fetch once and filter on the client – instant and simple. For thousands of products send filters as query params and let the server filter and paginate, otherwise you download too much data.
Summary
- One file, one job: utils, api, store, catalogue, cart, checkout, app.
- fetchJSON adds timeouts, checks res.ok and returns friendly errors.
- A single store + subscribe gives one-way data flow.
- Pure filter/sort functions, safe DOM rendering, filters saved in the URL.
Day 30 – Major project part 2: cart, checkout with server validation, order receipt, testing and putting the shop live.
