Tujuan
Di proyek ini kamu membangun landing page responsif untuk sebuah toko online fiktif, lengkap dengan katalog produk dinamis. Semua yang kamu pelajari di Fase 2 dipakai bersama: HTML semantik, SCSS, layout Flexbox dan Grid yang responsif, manipulasi DOM, event delegation, validasi dengan regex, fetch ke API, dan keranjang belanja yang tersimpan di localStorage.
Proyek ini sengaja dibuat mirip pekerjaan front-end sungguhan. Klien jarang meminta "tolong buatkan satu tombol". Yang diminta biasanya "tolong buatkan halaman toko yang enak dibuka di HP, produknya bisa dicari, dan keranjangnya tidak hilang kalau browser ditutup". Kamu harus memecah permintaan itu menjadi bagian kecil, mengerjakannya satu per satu, lalu memastikan semuanya bekerja bersama. Hasil akhirnya layak dipajang di portofolio GitHub kamu.
Data produk diambil dari DummyJSON (https://dummyjson.com/products), API publik gratis tanpa key yang berisi 194 produk dengan judul, harga (dalam dolar AS), kategori, rating, stok, dan gambar. API ini mengirim header CORS untuk semua origin, jadi bisa dipanggil langsung dari browser.
Alat yang dipakai: Vite 8 (dibuat dengan create-vite 9.3.1) sebagai dev server dan bundler, serta Sass 1.106 untuk SCSS dengan sistem modul modern @use. Versi ini dicek dari registry npm saat materi ditulis. Kalau versimu lebih baru, langkahnya tetap sama.
Fitur wajib
- Landing page semantik:
headerdengan logo dan navigasi,sectionhero berisi judul, tagline, dan tombol ajakan (CTA),sectionkatalog,asidekeranjang, danfooter. Satuh1per halaman, heading berurutan, gambar punyaalt. - Responsif mobile-first: grid produk 1 kolom di HP, 2 kolom di tablet, 3-4 kolom di desktop. Navigasi berubah menjadi tombol menu di layar kecil. Tidak ada scroll horizontal di lebar 360px.
- Data dari
fetch: produk dimuat dari DummyJSON lengkap dengan state loading (skeleton), error (pesan + tombol "Coba lagi"), dan empty ("Produk tidak ditemukan"). - Search: kolom pencarian menyaring produk berdasarkan judul atau brand, tidak peduli huruf besar/kecil, dengan debounce.
- Filter dan urutkan: dropdown kategori (dibangun otomatis dari data), filter harga maksimum, dan urutan (relevan, harga termurah, harga termahal, rating tertinggi).
- Filter tersimpan di URL: memakai
URLSearchParamsdanhistory.replaceState, sehingga link hasil filter bisa dibagikan dan bertahan saat reload. - Keranjang di
localStorage: tambah produk, ubah jumlah (+/−), hapus, hitung subtotal dan total item, tetap ada setelah reload. Badge jumlah item tampil di header. - Form newsletter dengan validasi email memakai regex dan pesan error per kolom.
- Aman dari XSS: semua data dari API dirender lewat
textContentatau atribut, bukan disisipkan mentah keinnerHTML.
Langkah bertahap
Kerjakan berurutan dan commit ke Git setiap selesai satu langkah. Jangan lompat ke JavaScript sebelum HTML dan CSS-nya rapi. Halaman yang strukturnya benar jauh lebih mudah diberi interaksi.
Langkah 1, Siapkan proyek
Buat proyek Vite template vanilla, pasang Sass, lalu susun folder:
npm create vite@latest katalog-toko -- --template vanilla
cd katalog-toko
npm install
npm install -D sass # Vite otomatis memproses file .scss kalau sass terpasang
npm run dev # buka alamat yang muncul, biasanya http://localhost:5173
# Struktur akhir yang dituju
katalog-toko/
├── index.html
├── package.json
└── src/
├── main.js # titik masuk: inisialisasi & event
├── api.js # fetchJSON + ambil produk
├── cart.js # state keranjang + localStorage
├── render.js # semua fungsi yang membangun DOM
└── styles/
├── _tokens.scss # warna, spasi, breakpoint
├── _mixins.scss # mixin responsif
└── main.scss # gaya utama
Hapus isi bawaan template (counter.js, isi style.css, dan logo) supaya kamu mulai dari kanvas bersih.
Langkah 2, HTML semantik
Tulis seluruh kerangka di index.html tanpa CSS dulu. Buka di browser dan pastikan halaman masih masuk akal walau polos. Itu tanda strukturnya benar. Cek urutan heading dengan ekstensi seperti HeadingsMap, atau lewat tab Accessibility di DevTools.
Langkah 3, SCSS dan layout responsif
Mulai dari tampilan HP (mobile-first), lalu tambahkan media query min-width untuk layar lebih besar. Pakai Flexbox untuk komponen satu dimensi (header, baris filter, isi kartu) dan Grid untuk katalog. Trik grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)) membuat jumlah kolom menyesuaikan lebar layar tanpa banyak media query. Simpan nilai yang berulang (warna, radius, breakpoint) di _tokens.scss, lalu muat dengan @use.
Langkah 4, Ambil data dan render
Buat fetchJSON (dari modul 4) di api.js. Ambil semua produk sekali dengan ?limit=0&select=... supaya respons ringan, lalu simpan di variabel state. Filter dan pencarian dilakukan di sisi browser terhadap array itu, jadi tidak perlu request baru setiap kali pengguna mengetik. Render kartu dengan createElement dan textContent.
Langkah 5, Search, filter, sort, URL
Tulis satu fungsi murni terapkanFilter(produk, filter) yang menerima array dan object filter, lalu mengembalikan array baru hasil filter dan urut. Fungsi murni artinya tidak menyentuh DOM, sehingga mudah diuji di console. Setiap kali input berubah, perbarui object filter, tulis ke URL dengan history.replaceState, lalu render ulang.
Di sini dipakai replaceState, bukan pushState. Kalau setiap ketikan menambah entri riwayat, pengguna harus menekan back puluhan kali untuk keluar dari halaman.
Langkah 6, Keranjang
Simpan keranjang sebagai array { id, title, price, thumbnail, qty } di localStorage. Semua perubahan lewat fungsi di cart.js (tambah, ubahQty, hapus), dan setiap fungsi menyimpan lalu memanggil render ulang. Tombol "Tambah" di kartu dan tombol +/− di keranjang ditangani dengan event delegation memakai data-aksi dan data-id.
Langkah 7, Newsletter, aksesibilitas, poles
Tambahkan form newsletter dengan validasi regex. Beri aria-live pada area status supaya pembaca layar mengumumkan "12 produk ditemukan". Pastikan semua tombol bisa dioperasikan dengan keyboard (Tab, Enter, Escape untuk menutup keranjang). Uji di DevTools Device Mode pada lebar 360px, 768px, dan 1280px.
Langkah 8, Build dan deploy
Jalankan npm run build. Vite menghasilkan folder dist/ berisi HTML, CSS, dan JS yang sudah dioptimasi. Cek hasilnya dengan npm run preview, lalu deploy folder dist/ ke hosting statis gratis seperti Netlify, Vercel, Cloudflare Pages, atau GitHub Pages. Kamu belajar CI/CD lebih dalam di fase berikutnya; untuk sekarang cukup pastikan link publiknya bisa dibuka dari HP.
Kode kerangka
Kerangka berikut sudah berjalan, tapi sengaja menyisakan beberapa bagian untuk kamu lengkapi (ditandai TODO). Baca dan pahami setiap baris; jangan hanya menyalin.
index.html
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Toko Serba Ada: belanja produk pilihan dengan mudah.">
<title>Toko Serba Ada, Katalog Produk</title>
<link rel="stylesheet" href="/src/styles/main.scss">
</head>
<body>
<header class="site-header">
<a href="#" class="logo">Serba<span>Ada</span></a>
<button class="nav-toggle" aria-expanded="false" aria-controls="nav-utama">☰ Menu</button>
<nav id="nav-utama" class="nav">
<a href="#katalog">Katalog</a>
<a href="#newsletter">Promo</a>
</nav>
<button class="cart-toggle" aria-controls="keranjang" aria-expanded="false">
🛒 <span class="badge" id="badge-cart">0</span>
<span class="sr-only">item di keranjang</span>
</button>
</header>
<main>
<section class="hero">
<h1>Belanja hemat, barang terpercaya</h1>
<p>Ratusan produk pilihan, dikirim cepat ke seluruh Indonesia.</p>
<a href="#katalog" class="btn btn--primary">Lihat katalog</a>
</section>
<section id="katalog" class="katalog" aria-labelledby="judul-katalog">
<h2 id="judul-katalog">Katalog Produk</h2>
<form class="filter" id="form-filter" role="search">
<label class="filter__item">
<span>Cari</span>
<input type="search" name="q" placeholder="Cari produk atau brand..." autocomplete="off">
</label>
<label class="filter__item">
<span>Kategori</span>
<select name="kategori"><option value="">Semua kategori</option></select>
</label>
<label class="filter__item">
<span>Harga maks ($)</span>
<input type="number" name="maks" min="0" step="1" placeholder="Bebas">
</label>
<label class="filter__item">
<span>Urutkan</span>
<select name="urut">
<option value="">Relevan</option>
<option value="harga-naik">Harga termurah</option>
<option value="harga-turun">Harga termahal</option>
<option value="rating">Rating tertinggi</option>
</select>
</label>
</form>
<p id="status-katalog" class="status" role="status" aria-live="polite"></p>
<div id="grid-produk" class="grid"></div>
</section>
<section id="newsletter" class="newsletter">
<h2>Dapatkan promo mingguan</h2>
<form id="form-newsletter" novalidate>
<label for="email-news">Email</label>
<input id="email-news" name="email" type="email" placeholder="kamu@contoh.com">
<button class="btn btn--primary">Daftar</button>
<small class="error" id="error-email" aria-live="polite"></small>
</form>
</section>
</main>
<aside id="keranjang" class="cart" aria-label="Keranjang belanja" hidden>
<div class="cart__head">
<h2>Keranjang</h2>
<button class="cart__close" data-aksi="tutup-cart" aria-label="Tutup keranjang">✕</button>
</div>
<ul id="isi-cart" class="cart__list"></ul>
<p class="cart__total">Total: <strong id="total-cart">$0.00</strong></p>
<button class="btn btn--primary" id="checkout">Checkout</button>
</aside>
<footer class="site-footer">
<p>© 2026 Toko Serba Ada. Data produk dari DummyJSON (contoh, bukan toko sungguhan).</p>
</footer>
<script type="module" src="/src/main.js"></script>
</body>
</html>
src/styles/_tokens.scss dan _mixins.scss
// _tokens.scss, satu sumber kebenaran untuk nilai desain
$warna-utama: #e2572b;
$warna-teks: #1f2328;
$warna-latar: #fafafa;
$warna-kartu: #ffffff;
$warna-garis: #e5e7eb;
$radius: 12px;
$gap: 1rem;
$breakpoints: (
tablet: 640px,
desktop: 1024px,
);
// _mixins.scss
@use "sass:map";
@use "tokens" as t;
// Mobile-first: gaya di dalam mixin berlaku mulai lebar tertentu ke atas
@mixin naik($nama) {
@media (min-width: map.get(t.$breakpoints, $nama)) {
@content;
}
}
src/styles/main.scss
@use "tokens" as t;
@use "mixins" as m;
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
color: t.$warna-teks;
background: t.$warna-latar;
line-height: 1.5;
}
img { max-width: 100%; display: block; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.btn {
display: inline-block; padding: .7rem 1.2rem; border: 0; border-radius: t.$radius;
font: inherit; cursor: pointer; text-decoration: none;
&--primary { background: t.$warna-utama; color: #fff; }
&:disabled { opacity: .5; cursor: not-allowed; }
}
// Header: flex, menu tersembunyi di HP
.site-header {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between; gap: t.$gap;
padding: .8rem 1rem; background: t.$warna-kartu; border-bottom: 1px solid t.$warna-garis;
.logo { font-weight: 800; font-size: 1.25rem; color: inherit; text-decoration: none;
span { color: t.$warna-utama; } }
.nav { display: none; gap: t.$gap;
&.terbuka { display: flex; flex-direction: column; position: absolute; top: 100%; left: 0; right: 0;
padding: 1rem; background: t.$warna-kartu; } }
@include m.naik(tablet) {
.nav-toggle { display: none; }
.nav { display: flex; }
}
}
.badge { background: t.$warna-utama; color: #fff; border-radius: 999px; padding: 0 .5em; font-size: .8rem; }
.hero {
padding: 3rem 1rem; text-align: center;
background: linear-gradient(135deg, lighten(t.$warna-utama, 35%), t.$warna-latar);
h1 { font-size: clamp(1.8rem, 5vw, 3rem); margin: 0 0 .5rem; }
}
.katalog { padding: 2rem 1rem; max-width: 1200px; margin: 0 auto; }
// Baris filter: menumpuk di HP, sejajar di layar lebar
.filter {
display: grid; gap: .75rem;
@include m.naik(tablet) { grid-template-columns: 2fr 1fr 1fr 1fr; }
&__item { display: flex; flex-direction: column; font-size: .9rem; }
input, select { padding: .6rem; border: 1px solid t.$warna-garis; border-radius: 8px; font: inherit; }
}
// Grid katalog: kolom menyesuaikan lebar otomatis
.grid {
display: grid; gap: t.$gap;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.kartu {
display: flex; flex-direction: column;
background: t.$warna-kartu; border: 1px solid t.$warna-garis; border-radius: t.$radius; overflow: hidden;
img { aspect-ratio: 1; object-fit: cover; background: t.$warna-garis; }
&__isi { display: flex; flex-direction: column; gap: .3rem; padding: .8rem; flex: 1; }
&__harga { font-weight: 700; color: t.$warna-utama; }
.btn { margin-top: auto; }
}
.skeleton {
min-height: 320px; border-radius: t.$radius;
background: linear-gradient(90deg, #eee 25%, #f6f6f6 50%, #eee 75%);
background-size: 200% 100%; animation: kilau 1.2s infinite;
}
@keyframes kilau { to { background-position: -200% 0; } }
// Keranjang: panel geser dari kanan
.cart {
position: fixed; inset: 0 0 0 auto; z-index: 20; width: min(380px, 100%);
display: flex; flex-direction: column; gap: t.$gap; padding: 1rem;
background: t.$warna-kartu; box-shadow: -4px 0 24px rgb(0 0 0 / .15); overflow-y: auto;
&__head { display: flex; justify-content: space-between; align-items: center; }
&__list { list-style: none; margin: 0; padding: 0; display: grid; gap: .75rem; }
&__item { display: grid; grid-template-columns: 56px 1fr auto; gap: .6rem; align-items: center; }
}
.error { color: #b42318; }
.newsletter { padding: 2rem 1rem; text-align: center; }
.site-footer { padding: 1.5rem 1rem; text-align: center; font-size: .85rem; border-top: 1px solid t.$warna-garis; }
Catatan: fungsi global lighten() masih jalan di Sass 1.x tapi sudah deprecated dan memunculkan peringatan. Versi modernnya adalah color.adjust(t.$warna-utama, $lightness: 35%) setelah @use "sass:color";. Mengganti ini adalah bagian dari tugasmu.
src/api.js
const BASE = "https://dummyjson.com";
export async function fetchJSON(url, { timeout = 8000, ...opsi } = {}) {
let res;
try {
res = await fetch(url, { ...opsi, signal: AbortSignal.timeout(timeout) });
} catch (err) {
throw new Error(err.name === "TimeoutError"
? "Server terlalu lama merespons."
: "Tidak bisa terhubung. Periksa koneksi internet kamu.");
}
if (!res.ok) throw new Error(`Gagal memuat data (HTTP ${res.status}).`);
return res.json();
}
// Ambil semua produk sekali, hanya kolom yang dipakai supaya respons ringan
export async function ambilProduk() {
const kolom = "title,price,category,thumbnail,rating,stock,brand";
const data = await fetchJSON(`${BASE}/products?limit=0&select=${kolom}`);
return data.products; // → array 194 object { id, title, price, category, ... }
}
src/cart.js
const KUNCI = "serbaada-cart-v1";
function baca() {
try {
const data = JSON.parse(localStorage.getItem(KUNCI));
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
let items = baca();
const pendengar = new Set();
function simpanDanKabari() {
localStorage.setItem(KUNCI, JSON.stringify(items));
pendengar.forEach((fn) => fn(items));
}
export const cart = {
get items() { return items; },
onChange(fn) { pendengar.add(fn); fn(items); },
tambah(produk) {
const ada = items.find((it) => it.id === produk.id);
if (ada) {
items = items.map((it) => (it.id === produk.id ? { ...it, qty: it.qty + 1 } : it));
} else {
const { id, title, price, thumbnail } = produk;
items = [...items, { id, title, price, thumbnail, qty: 1 }];
}
simpanDanKabari();
},
ubahQty(id, delta) {
items = items
.map((it) => (it.id === id ? { ...it, qty: it.qty + delta } : it))
.filter((it) => it.qty > 0); // qty 0 → otomatis terhapus
simpanDanKabari();
},
hapus(id) {
items = items.filter((it) => it.id !== id);
simpanDanKabari();
},
totalItem: () => items.reduce((n, it) => n + it.qty, 0),
totalHarga: () => items.reduce((n, it) => n + it.qty * it.price, 0),
};
src/render.js
export const formatUSD = (n) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
// formatUSD(1299.5) → '$1,299.50'
export function renderSkeleton(grid, jumlah = 8) {
grid.replaceChildren(...Array.from({ length: jumlah }, () => {
const div = document.createElement("div");
div.className = "skeleton";
return div;
}));
}
export function buatKartu(p) {
const kartu = document.createElement("article");
kartu.className = "kartu";
const img = document.createElement("img");
img.src = p.thumbnail;
img.alt = p.title;
img.loading = "lazy"; // gambar di bawah layar dimuat belakangan
img.width = 300;
img.height = 300;
const isi = document.createElement("div");
isi.className = "kartu__isi";
const judul = document.createElement("h3");
judul.textContent = p.title; // data API → textContent (anti-XSS)
const meta = document.createElement("p");
meta.textContent = `${p.brand ?? "Tanpa brand"} · ⭐ ${p.rating.toFixed(1)}`;
const harga = document.createElement("p");
harga.className = "kartu__harga";
harga.textContent = formatUSD(p.price);
const tombol = document.createElement("button");
tombol.className = "btn btn--primary";
tombol.dataset.aksi = "tambah";
tombol.dataset.id = p.id;
tombol.textContent = p.stock > 0 ? "Tambah ke keranjang" : "Stok habis";
tombol.disabled = p.stock === 0;
isi.append(judul, meta, harga, tombol);
kartu.append(img, isi);
return kartu;
}
export function renderProduk(grid, status, daftar) {
if (daftar.length === 0) {
grid.replaceChildren();
status.textContent = "Produk tidak ditemukan. Coba kata kunci atau filter lain.";
return;
}
status.textContent = `${daftar.length} produk ditemukan`;
grid.replaceChildren(...daftar.map(buatKartu));
}
export function renderCart(list, totalEl, badge, items, totalHarga, totalItem) {
badge.textContent = totalItem;
totalEl.textContent = formatUSD(totalHarga);
if (items.length === 0) {
const li = document.createElement("li");
li.textContent = "Keranjang masih kosong.";
list.replaceChildren(li);
return;
}
list.replaceChildren(...items.map((it) => {
const li = document.createElement("li");
li.className = "cart__item";
li.dataset.id = it.id;
const img = document.createElement("img");
img.src = it.thumbnail;
img.alt = "";
const info = document.createElement("div");
const nama = document.createElement("strong");
nama.textContent = it.title;
const sub = document.createElement("small");
sub.textContent = `${it.qty} × ${formatUSD(it.price)}`;
info.append(nama, document.createElement("br"), sub);
const aksi = document.createElement("div");
for (const [label, aksiNama, judul] of [["−", "kurang", "Kurangi"], ["+", "tambah-qty", "Tambah"], ["🗑", "hapus", "Hapus"]]) {
const b = document.createElement("button");
b.textContent = label;
b.dataset.aksi = aksiNama;
b.setAttribute("aria-label", `${judul} ${it.title}`);
aksi.append(b);
}
li.append(img, info, aksi);
return li;
}));
}
replaceChildren(...) mengganti seluruh isi elemen sekaligus. Hasilnya sama dengan innerHTML = "" lalu append, tapi dalam satu langkah.
src/main.js
import { ambilProduk } from "./api.js";
import { cart } from "./cart.js";
import { renderSkeleton, renderProduk, renderCart } from "./render.js";
const $ = (sel) => document.querySelector(sel);
const grid = $("#grid-produk");
const status = $("#status-katalog");
const formFilter = $("#form-filter");
const panelCart = $("#keranjang");
let semuaProduk = [];
// ---------- Filter: fungsi murni, mudah diuji di console ----------
export function terapkanFilter(produk, { q = "", kategori = "", maks = "", urut = "" }) {
const kata = q.trim().toLowerCase();
const batas = maks === "" ? Infinity : Number(maks);
const hasil = produk.filter((p) =>
(!kata || p.title.toLowerCase().includes(kata) || (p.brand ?? "").toLowerCase().includes(kata)) &&
(!kategori || p.category === kategori) &&
p.price <= batas
);
const pembanding = {
"harga-naik": (a, b) => a.price - b.price,
"harga-turun": (a, b) => b.price - a.price,
rating: (a, b) => b.rating - a.rating,
}[urut];
return pembanding ? hasil.toSorted(pembanding) : hasil; // toSorted tidak mengubah array asli
}
// ---------- Sinkron form <-> URL ----------
function bacaFilterDariForm() {
return Object.fromEntries(new FormData(formFilter));
}
function tulisKeURL(filter) {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(filter)) if (v) params.set(k, v);
const qs = params.toString();
history.replaceState(null, "", qs ? `?${qs}` : location.pathname);
}
function isiFormDariURL() {
const params = new URLSearchParams(location.search);
for (const nama of ["q", "kategori", "maks", "urut"]) {
if (params.has(nama)) formFilter.elements[nama].value = params.get(nama);
}
}
function perbarui() {
const filter = bacaFilterDariForm();
tulisKeURL(filter);
renderProduk(grid, status, terapkanFilter(semuaProduk, filter));
}
function debounce(fn, jeda = 300) {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), jeda); };
}
// ---------- Memuat data ----------
function isiKategori(produk) {
const select = formFilter.elements.kategori;
const unik = [...new Set(produk.map((p) => p.category))].sort();
for (const k of unik) select.append(new Option(k, k));
}
async function muat() {
renderSkeleton(grid);
status.textContent = "Memuat produk...";
try {
semuaProduk = await ambilProduk();
isiKategori(semuaProduk);
isiFormDariURL(); // setelah opsi kategori ada, baru isi dari URL
perbarui();
} catch (err) {
grid.replaceChildren();
status.textContent = err.message;
const ulang = document.createElement("button");
ulang.className = "btn btn--primary";
ulang.textContent = "Coba lagi";
ulang.addEventListener("click", muat, { once: true });
status.append(" ", ulang);
}
}
// ---------- Event ----------
formFilter.addEventListener("input", debounce(perbarui));
formFilter.addEventListener("submit", (e) => e.preventDefault()); // Enter tidak reload
// Delegation: semua tombol "Tambah ke keranjang"
grid.addEventListener("click", (e) => {
const tombol = e.target.closest('[data-aksi="tambah"]');
if (!tombol) return;
const produk = semuaProduk.find((p) => p.id === Number(tombol.dataset.id));
cart.tambah(produk);
tombol.textContent = "✓ Ditambahkan";
setTimeout(() => (tombol.textContent = "Tambah ke keranjang"), 1200);
});
// Delegation: tombol di dalam keranjang
panelCart.addEventListener("click", (e) => {
const tombol = e.target.closest("[data-aksi]");
if (!tombol) return;
const aksi = tombol.dataset.aksi;
if (aksi === "tutup-cart") return bukaCart(false);
const id = Number(tombol.closest("[data-id]")?.dataset.id);
if (aksi === "kurang") cart.ubahQty(id, -1);
if (aksi === "tambah-qty") cart.ubahQty(id, +1);
if (aksi === "hapus") cart.hapus(id);
});
function bukaCart(buka) {
panelCart.hidden = !buka;
$(".cart-toggle").setAttribute("aria-expanded", String(buka));
if (buka) panelCart.querySelector(".cart__close").focus();
}
$(".cart-toggle").addEventListener("click", () => bukaCart(panelCart.hidden));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !panelCart.hidden) bukaCart(false);
});
// Menu mobile
$(".nav-toggle").addEventListener("click", (e) => {
const terbuka = $("#nav-utama").classList.toggle("terbuka");
e.currentTarget.setAttribute("aria-expanded", String(terbuka));
});
// Keranjang: render ulang setiap kali isinya berubah
cart.onChange((items) =>
renderCart($("#isi-cart"), $("#total-cart"), $("#badge-cart"), items, cart.totalHarga(), cart.totalItem())
);
// Newsletter
$("#form-newsletter").addEventListener("submit", (e) => {
e.preventDefault();
const email = e.currentTarget.elements.email.value.trim();
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email);
$("#error-email").textContent = valid ? "Terima kasih! Cek inbox kamu." : "Format email belum benar.";
if (valid) e.currentTarget.reset();
});
// TODO 1: tombol #checkout → tampilkan ringkasan (jumlah item & total) lalu kosongkan keranjang.
// TODO 2: tutup panel keranjang saat pengguna mengklik di luar panel.
// TODO 3: ganti lighten() di main.scss dengan color.adjust().
muat();
Karena main.js adalah module, fungsinya tidak otomatis tersedia di Console. Untuk menguji terapkanFilter tanpa menyentuh UI, salin fungsinya ke Console (atau ke file tes terpisah yang meng-import-nya), lalu jalankan:
const contoh = [
{ title: "Kopi Arabika", brand: "Senja", category: "groceries", price: 12, rating: 4.5 },
{ title: "Meja Lipat", brand: "Kayu", category: "furniture", price: 80, rating: 4.1 },
{ title: "Kopi Robusta", brand: "Pagi", category: "groceries", price: 8, rating: 3.9 },
];
console.log(terapkanFilter(contoh, { q: "kopi", urut: "harga-naik" }).map((p) => p.title));
// → [ 'Kopi Robusta', 'Kopi Arabika' ]
console.log(terapkanFilter(contoh, { kategori: "furniture" }).length); // → 1
console.log(terapkanFilter(contoh, { maks: "10" }).map((p) => p.title)); // → [ 'Kopi Robusta' ]
Kriteria lulus
- [ ] HTML lolos validator W3C (validator.w3.org) tanpa error. Ada tepat satu
h1, heading berurutan, dan semua gambar punyaalt. - [ ] SCSS memakai
@use(bukan@import), memiliki minimal satu file token dan satu mixin, serta tidak memunculkan peringatan deprecation saatnpm run build. - [ ] Layout bekerja di lebar 360px, 768px, dan 1280px tanpa scroll horizontal. Grid produk berubah jumlah kolomnya, dan navigasi berubah menjadi tombol menu di HP.
- [ ] Produk dimuat dari DummyJSON, skeleton tampil saat loading, dan pesan error dengan tombol "Coba lagi" muncul saat offline (uji dengan DevTools → Network → Offline).
- [ ] Search, filter kategori, harga maksimum, dan urutan bisa dikombinasikan; status menampilkan jumlah hasil, dan pesan khusus muncul saat hasilnya kosong.
- [ ] Filter tersimpan di URL: menyalin URL ke tab baru menghasilkan tampilan yang sama.
- [ ] Keranjang bisa tambah, kurang, dan hapus item; total dan badge benar; isinya tetap ada setelah reload.
- [ ] Tidak ada data API yang disisipkan ke
innerHTML. Cari dengan Ctrl+FinnerHTMLdi foldersrc/dan pastikan tidak ada yang berisi data luar. - [ ] Semua fitur bisa dipakai dengan keyboard saja, dan Escape menutup keranjang.
- [ ] Tidak ada error merah di Console, dan situs ter-deploy dengan link publik yang bisa dibuka dari HP.
- [ ] Ketiga
TODOdimain.jsselesai.
Pengembangan lanjutan
Setelah semua kriteria lulus, pilih satu atau dua tantangan ini untuk memperkuat portofolio:
- Pagination atau infinite scroll: tampilkan 12 produk dulu, lalu muat berikutnya dengan tombol "Muat lagi" atau
IntersectionObserversaat pengguna mendekati bawah halaman. - Halaman detail produk: saat kartu diklik, tampilkan modal berisi detail dari
https://dummyjson.com/products/{id}, dan simpan id di URL (?produk=12) supaya bisa dibagikan. - Konversi mata uang: tampilkan harga dalam Rupiah memakai kurs dari API publik, dan beri catatan sumber serta waktu kursnya. Jangan menulis kurs tetap di kode lalu menyebutnya kurs terkini.
- Wishlist: daftar favorit terpisah dari keranjang, juga di
localStorage, dengan ikon hati di setiap kartu. - Checkout via WhatsApp: susun isi keranjang menjadi teks lalu buka
https://wa.me/62xxxx?text=...denganencodeURIComponent. Validasi nomor penjual dengan regex nomor HP dari modul 1. - Lighthouse 90+: audit dengan Lighthouse di DevTools, lalu perbaiki skor Performance, Accessibility, dan SEO. Catat skor sebelum dan sesudah di README proyekmu.