Free tutorials & notes in Hindi & English · Clean code examples 🧰 Free PDF & Image Tools →
🚀 Days 28–30 – Projects  ·  Day 29

Day 29: Major Project 1: Online Shop Catalogue

⏱ 4 hours📶 Major project🎯 100% self-study
Day 29 / 30

✅ 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.

▶  Sharma Sweets online shop – live preview
📝 Preview limits

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

#RequirementHow we solve it
1Show all sweets with photo, price, rating and stockGET the API → render cards
2Fast search while typingDebounced input (Day 24)
3Filter by category, price and stock; sortPure filter + sort functions (Day 8)
4Send a filtered link on WhatsAppFilters in URL params (Day 22)
5Never a blank page on slow internetLoading skeleton, error message, retry
6Cart survives a refreshlocalStorage via a safe wrapper (Day 15)
7Prices cannot be fakedServer recalculates every order (Day 26)
8Works on phones and in dark modeCSS course techniques

Step 2: folder structure – one job per file

Project folder
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)
📦
utils.jsTiny reusable helpers. No knowledge of the shop.
🌐
api.jsThe only file that knows the server URL.
🧠
store.jsThe single source of truth.
🖼️
catalogue.js / cart.jsRead state, draw the screen.
🔌
app.jsConnects clicks to functions.
Five kinds of files, five responsibilities
💡 Why classic scripts with defer, not modules?

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.

HTML – index.html
<!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

JavaScript – js/utils.js
// =============================================================
//  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);
}
HelperExampleResult
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

JavaScript – js/api.js
// =============================================================
//  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() with an AbortController timeoutRead JSON (even for errors)res.ok false → throw Error with status + field errorsAbortError → “taking too long”TypeError → “you seem to be offline”
fetchJSON turns every failure into a friendly message
⚠️ fetch does not reject on 404 or 500

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

JavaScript – js/store.js
// =============================================================
//  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);
    },
  };
})();
User clicks / typesA function calls store.update(change)A NEW state object is createdThe cart is saved to localStorageEvery subscriber re-renders the screen
One-way data flow (the idea behind Redux and React)
💡 Store only ids in the cart

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

JavaScript – catalogue.js: loadProducts()
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

JavaScript – catalogue.js: visibleProducts()
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.

JavaScript
// 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 ' } }));
All: [ 'Soan Papdi ₹380', 'Kalakand ₹600', 'Malai Ghewar ₹650', 'Kaju Katli ₹900' ] Milk: [ 'Kalakand ₹600', 'Malai Ghewar ₹650' ] Milk, in stock: [ 'Malai Ghewar ₹650' ] Under ₹700: [ 'Soan Papdi ₹380', 'Kalakand ₹600', 'Malai Ghewar ₹650' ] Search "kaju": [ 'Kaju Katli ₹900' ]

Step 9: rendering product cards safely

JavaScript – catalogue.js: productCard() + renderCatalogue()
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

JavaScript – catalogue.js: URL ↔ filters
// ---- 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, not pushState

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

JavaScript – catalogue.js: openDetails()
// ---- 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

JavaScript – js/app.js
// =============================================================
//  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();
EventListener onCalls
input (debounced)#searchsetFilter({ q })
click (delegated)#category-chipssetFilter({ category })
change / input#sort, #max-price, #in-stocksetFilter(...)
click (delegated)#product-gridaddToCart / openDetails / loadProducts
keydowndocument“/” focuses search
storagewindowsyncs the cart between tabs

Step 13: our test run

TestResult
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
CSS – css/style.css (from the CSS course)
/* 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

✔ Split a front-end app into files with one responsibility each✔ Wrap fetch with timeouts and friendly errors✔ Build a central store with one-way data flow✔ Filter, sort and render with pure functions✔ Keep UI state in the URL safely

Practice tasks

🏋️ Practice – do it yourself

Task 1: Add a “Veg only” or “Rating 4.5+” filter chip and store it in the URL as ?rating=4.5.
Task 2: Highlight the search word inside product names using <mark> – build it with createTextNode, not innerHTML.
Task 3: Add a “Recently viewed” row that remembers the last 4 opened products in localStorage.
Task 4: Change getProducts() to call the API with ?delay=2000 and check the skeleton and the aria-busy attribute in DevTools.

Quick quiz

🧠 Quick Quiz – test yourself

1. Why does api.js check res.ok?
Only network failures reject.
2. What does the cart store in localStorage?
Prices always come from fresh data.
3. Why [...list].sort() instead of list.sort()?
Keep functions pure.
4. Which method updates the URL without adding Back-button steps?
History length stays the same.
5. Why are scripts loaded with defer and not as modules here?
Works when double-clicked.

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.
➡️ Next

Day 30 – Major project part 2: cart, checkout with server validation, order receipt, testing and putting the shop live.

Frequently Asked Questions

Can I use my own products?
Yes. Replace API_URL in api.js with your own JSON file or API that returns objects with id, name, price, unit, category, stock, rating, image and description.
Why not use React for this?
You can – and after this project React will feel easy, because you already think in state, render and events. Many small business sites still use plain JavaScript because it needs no build step.
← JavaScript (30 Days)
🔗

Share this topic with a friend

यह topic किसी दोस्त को भेजें

Found it useful? Send it to a classmate learning the same thing.

अच्छा लगा? जो दोस्त यही सीख रहा है, उसे भेज दीजिए।

Next topicDay 30: Major Project 2: Cart, Checkout & Launch Your progress: 29 / 30 (97%) · Lesson 30 / 30 Continue →

How was this lesson?

Your feedback helps us make the content better.

Write a review or suggestion (optional)

After approval, your name and city may appear publicly with your review. Please do not write your email or phone number.

▶ CodingEasily on YouTube

Learn with video lessons in easy Hindi

Featured video: RDBMS Part 3 – Class XI Commerce IT

▶ Subscribe – it’s free