दिन 29: Major Project 1: Online Shop Catalogue
✅ आज आप सीखेंगे
- Professional की तरह असली multi-file front-end project plan करना
- Live API से products load करना – loading, error और retry states के साथ
- सारा data एक store में रखकर हर बदलाव पर re-render
- Debounced search, category chips, sort, price और stock filters
- Filters को URL में save करना ताकि links share हो सकें
- Accessible <dialog> में product details दिखाना
🏢 असली दुनिया में
Sharma Sweets, Johari Bazaar, Jaipur के पास HTML और CSS courses में बनी website पहले से है। अब मालिक चाहते हैं कि Diwali से पहले ग्राहक online order करें: मिठाइयाँ देखें, search करें, filter करें, cart में डालें और checkout करें – Shopify का ख़र्च किए बिना। अगले दो दिन आप यह shop ठीक वैसे बनाएँगे जैसे नौकरी में junior front-end developer बनाता है: अलग files, live API, central state store और असली error handling। आज: catalogue। कल: cart, checkout और launch।
पहले तैयार shop चलाकर देखें
यह वह पूरा app है जो Day 30 के बाद आपके पास होगा। यह असली CodingEasily practice API से बात करता है। “kaju” search करें, category पर click करें, sort बदलें, product खोलें, cart में डालें और coupon SWEET10 के साथ test order भी लगाएँ।
इस lesson के अंदर app sandboxed frame में चलता है, इसलिए reload के बाद cart याद नहीं रहता और URL नहीं बदलता। Course kit download करके index.html खोलें – वहाँ सब पूरी तरह चलता है।
Step 1: client की requirements
| # | Requirement | हम कैसे हल करेंगे |
|---|---|---|
| 1 | हर मिठाई photo, price, rating और stock के साथ | API से GET → cards render |
| 2 | Type करते समय तेज़ search | Debounced input (Day 24) |
| 3 | Category, price, stock से filter; sort | Pure filter + sort functions (Day 8) |
| 4 | Filtered link WhatsApp पर भेजना | URL params में filters (Day 22) |
| 5 | धीमे internet पर भी blank page नहीं | Loading skeleton, error message, retry |
| 6 | Refresh के बाद भी cart बचा रहे | Safe wrapper से localStorage (Day 15) |
| 7 | Prices नकली न हो सकें | Server हर order दोबारा गिनता है (Day 26) |
| 8 | Phones और dark mode में चले | CSS course की techniques |
Step 2: folder structure – हर 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) double-click से खुली file (file://) से load नहीं होते – browser रोक देता है। defer वाली classic scripts हर जगह चलती हैं, HTML parse होने के बाद क्रम से चलती हैं और top-level functions share करती हैं। Vite या React वाली नौकरी में आप import/export इस्तेमाल करेंगे – files का बँटवारा वही रहेगा।
Step 3: HTML ढाँचा
Page में search और cart button वाला sticky header, filter controls, JavaScript से भरने वाला ख़ाली product grid, और तीन <dialog> elements हैं: product details, cart drawer और 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>- हर dialog में <form method="dialog"> के अंदर close button है – browser बिना JavaScript के बंद कर देता है।
- Category chips aria-pressed इस्तेमाल करते हैं ताकि screen readers “pressed” बोलें।
- Results line में aria-live="polite" है – filter के बाद “3 of 8 sweets” पढ़ा जाता है।
- Checkout form में novalidate है क्योंकि हम अपने ज़्यादा दोस्ताना 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 | उदाहरण | नतीजा |
|---|---|---|
| rupees() | rupees(1325) | ₹1,325 (भारतीय grouping) |
| formatQty() | formatQty(0.75, "kg") | 750 g |
| debounce() | debounce(search, 250) | Typing रुकने के 250 ms बाद चलता है |
| storage.get() | storage.get("cart", []) | Saved value या [] – कभी crash नहीं |
Step 5: api.js – 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 सिर्फ़ network समस्या पर fail होता है। 500 response भी “सफल” fetch है – इसीलिए fetchJSON ख़ुद res.ok जाँचकर throw करता है। इसे भूलना interviews और असली code में सबसे आम fetch bug है।
Step 6: store.js – छोटा 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);
},
};
})();Cart [{ id, qty }] के रूप में save होता है – prices कभी नहीं। कल shop price बदले तो cart अपने-आप नई price दिखाएगा, और कोई localStorage बदलकर ₹1 में Kaju Katli नहीं ख़रीद सकता।
Step 7: तीन states के साथ products load करना
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
- Offline हो तो चुपचाप fail
- User बार-बार refresh करता है
Job-ready version
- status: loading → ready | error
- Loading के दौरान shimmer skeleton
- साफ़ message + “Try again” button
- Screen readers के लिए aria-busy
Step 8: filtering और 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() state लेकर नया array लौटाता है – state.products कभी नहीं बदलता। इससे इसे test (Day 27) करना आसान और हर render पर बुलाना सुरक्षित है। sort() से पहले [...list] से copy करते हैं क्योंकि sort उसी array को बदल देता है।
// 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: product cards सुरक्षित render करना
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 textContent से जाता है, innerHTML से कभी नहीं – server का data भरोसेमंद नहीं (Day 26)।
- Image एक <button> है, ताकि keyboard users Enter से details खोल सकें।
- 10 से कम stock पर “Only 8 left” – असली shops इससे जल्दी करवाती हैं।
- replaceChildren(...cards) पूरा grid एक DOM update में बदल देता है।
Step 10: 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);
}“Milk sweets” चुनें और price से sort करें, तो address index.html?category=milk&sort=price-asc बन जाता है। मालिक वही link WhatsApp पर भेज सकते हैं। filtersFromUrl() हर value को allow-list से जाँचता है, इसलिए ?max=-5 जैसा हाथ से बदला URL page नहीं तोड़ सकता।
replaceState history entries जोड़े बिना address बदलता है। pushState से हर slider हरकत Back-button का एक step जोड़ देती – बहुत परेशान करने वाला।
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() से मुफ़्त focus trap, Esc से बंद होना और ::backdrop मिलता है। Quantity options unit के साथ बदलते हैं: kg वाली मिठाई के लिए 250 g steps, gift boxes के लिए पूरी संख्या।
Step 12: app.js – 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 कहाँ | क्या बुलाता है |
|---|---|---|
| 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 | “/” search पर focus |
| storage | window | Tabs के बीच cart sync |
Step 13: हमारा test run
| Test | नतीजा |
|---|---|
| index.html?category=milk खोलें | ✅ “3 of 8 sweets”, Milk chip pressed |
| Search में “kaju” लिखें | ✅ “2 of 8 sweets”, URL ?q=kaju |
| धीमा API (?delay=3000) | ✅ shimmer skeleton दिखा |
| API बंद (?fail=1) | ✅ दोस्ताना error + Try again |
| Product खोलें, 1 kg चुनें, Add | ✅ badge बदला, toast दिखा |
| 375 px phone width | ✅ कोई horizontal scroll नहीं |
| Console errors | ✅ कोई नहीं |
/* 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
Practice tasks
🏋️ Practice – ख़ुद करें
Quick quiz
🧠 Quick Quiz – ख़ुद को जाँचें
Interview corner
💼 Interview Corner
Framework के बिना vanilla JavaScript app कैसे structure करेंगे?
Concerns को files में बाँटें: helpers, API layer, update/subscribe वाला एक store, state पढ़ने वाले render functions, और events जोड़ने वाली एक file। Data एक दिशा में बहता है – events state बदलते हैं, state बदलाव render चलाता है। यही React + Redux का mental model है, इसलिए सीधे काम आता है।
API call के loading और error states कैसे संभालेंगे?
State में status field (loading, ready, error) रखें, loading पर skeleton, error पर retry वाला message, AbortController से timeout, technical errors को दोस्ताना text में बदलें, और loading के दौरान region पर aria-busy लगाएँ।
Filtering client पर हो या server पर?
छोटे catalogue (दसियों items) में एक बार fetch करके client पर filter – तुरंत और आसान। हज़ारों products में filters query params में भेजें और server को filter व paginate करने दें, वरना बहुत ज़्यादा data download होगा।
सारांश
- एक file, एक काम: utils, api, store, catalogue, cart, checkout, app।
- fetchJSON timeouts जोड़ता है, res.ok जाँचता है और दोस्ताना errors देता है।
- एक store + subscribe से one-way data flow।
- Pure filter/sort functions, सुरक्षित DOM rendering, URL में saved filters।
Day 30 – Major project भाग 2: cart, server validation वाला checkout, order receipt, testing और shop को live करना।
