Shop — Wishlist / Saved Items
A polished e-commerce wishlist component rendering saved products as a responsive grid of cards, each with a gradient product tile, inline-SVG silhouette, brand, title, star rating with review count, prominent price, and a colour-coded stock chip. Per-item heart-remove and move-to-cart buttons, a select-all plus bulk move and remove bar, undo toasts, price-drop badges, a live cart counter, and a built-in empty state. Selections and saved items persist to localStorage.
MCP
Code
:root {
--bg: #ffffff;
--surface: #ffffff;
--tint: #f6f7f9;
--ink: #16181d;
--muted: #6b7280;
--brand: #3457ff;
--brand-d: #2742d6;
--sale: #e0245e;
--ok: #1f9d55;
--warn: #b7791f;
--line: rgba(16, 18, 29, .1);
--line-2: rgba(16, 18, 29, .06);
--shadow: 0 1px 2px rgba(16, 18, 29, .05), 0 8px 24px rgba(16, 18, 29, .07);
--shadow-hover: 0 2px 6px rgba(16, 18, 29, .07), 0 16px 38px rgba(16, 18, 29, .12);
--radius: 16px;
--radius-sm: 10px;
--max: 1120px;
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img, svg { display: block; }
h1, h2, h3, p, ul, dl { margin: 0; }
ul { list-style: none; padding: 0; }
a { color: inherit; }
button { font: inherit; cursor: pointer; }
:focus-visible {
outline: 3px solid color-mix(in srgb, var(--brand) 55%, white);
outline-offset: 2px;
border-radius: 6px;
}
.skip {
position: absolute;
left: -999px;
top: 8px;
z-index: 50;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
}
.skip:focus { left: 12px; }
/* ── Top bar ─────────────────────────────── */
.topbar {
position: sticky;
top: 0;
z-index: 30;
background: rgba(255, 255, 255, .88);
backdrop-filter: saturate(1.4) blur(10px);
border-bottom: 1px solid var(--line);
}
.topbar__in {
max-width: var(--max);
margin: 0 auto;
padding: 14px 20px;
display: flex;
align-items: center;
gap: 22px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 9px;
font-weight: 800;
letter-spacing: -.02em;
text-decoration: none;
color: var(--ink);
}
.brand svg { color: var(--brand); }
.topnav {
display: flex;
gap: 22px;
margin-left: 6px;
}
.topnav a {
text-decoration: none;
color: var(--muted);
font-weight: 600;
font-size: 14px;
transition: color .15s;
}
.topnav a:hover { color: var(--ink); }
.topbar__actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.topbar__icon, .topbar__cart {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 12px;
color: var(--ink);
transition: background .15s;
}
.topbar__icon:hover, .topbar__cart:hover { background: var(--tint); }
.topbar__icon svg { color: var(--sale); }
.topbar__badge, .topbar__count {
position: absolute;
top: 2px;
right: 2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #fff;
border-radius: 999px;
border: 2px solid var(--bg);
}
.topbar__badge { background: var(--sale); }
.topbar__count { background: var(--brand); }
/* ── Layout ──────────────────────────────── */
.wrap {
max-width: var(--max);
margin: 0 auto;
padding: 28px 20px 80px;
}
.head { margin-bottom: 18px; }
.head h1 {
font-size: clamp(24px, 4vw, 32px);
font-weight: 800;
letter-spacing: -.025em;
}
.head__sub {
margin-top: 4px;
color: var(--muted);
font-size: 14px;
}
.head__sub a {
color: var(--brand);
font-weight: 600;
text-decoration: none;
}
.head__sub a:hover { text-decoration: underline; }
/* ── Bulk bar ────────────────────────────── */
.bulk {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
background: var(--tint);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 12px 16px;
margin-bottom: 18px;
}
.bulk__select {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.bulk__select input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.bulk__selbox {
width: 20px;
height: 20px;
border-radius: 6px;
border: 1.8px solid var(--line);
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background .12s, border-color .12s;
}
.bulk__selbox svg { opacity: 0; transition: opacity .12s; }
.bulk__select input:checked + .bulk__selbox,
.bulk__select input:indeterminate + .bulk__selbox {
background: var(--brand);
border-color: var(--brand);
}
.bulk__select input:checked + .bulk__selbox svg { opacity: 1; }
.bulk__select input:indeterminate + .bulk__selbox::after {
content: "";
width: 10px;
height: 2.4px;
border-radius: 2px;
background: #fff;
}
.bulk__select input:indeterminate + .bulk__selbox svg { opacity: 0; }
.bulk__select input:focus-visible + .bulk__selbox {
outline: 3px solid color-mix(in srgb, var(--brand) 55%, white);
outline-offset: 2px;
}
.bulk__actions {
margin-left: auto;
display: flex;
gap: 8px;
}
/* ── Buttons ─────────────────────────────── */
.btn {
appearance: none;
border: 1px solid transparent;
border-radius: 11px;
padding: 10px 16px;
font-weight: 600;
font-size: 14px;
color: var(--ink);
background: #fff;
transition: transform .08s, background .15s, border-color .15s, box-shadow .15s, color .15s;
}
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn--ghost {
border-color: var(--line);
background: #fff;
}
.btn--ghost:not(:disabled):hover {
border-color: var(--ink);
background: var(--tint);
}
.btn--brand {
background: var(--brand);
color: #fff;
}
.btn--brand:not(:disabled):hover { background: var(--brand-d); }
.btn--danger {
border-color: var(--line);
color: var(--sale);
background: #fff;
}
.btn--danger:not(:disabled):hover {
border-color: var(--sale);
background: color-mix(in srgb, var(--sale) 8%, white);
}
/* ── Grid ────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 18px;
}
.card {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
overflow: hidden;
transition: box-shadow .18s, transform .18s, border-color .18s, opacity .25s;
}
.card:hover {
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--ink) 14%, white);
}
.card.is-selected {
border-color: var(--brand);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand) 35%, white), var(--shadow);
}
.card.is-leaving {
opacity: 0;
transform: scale(.94);
pointer-events: none;
}
/* product tile */
.card__media {
position: relative;
aspect-ratio: 4 / 3;
display: grid;
place-items: center;
border-bottom: 1px solid var(--line-2);
}
.card__media svg.silhouette {
width: 58%;
height: 58%;
filter: drop-shadow(0 10px 18px rgba(16, 18, 29, .16));
}
/* select + heart overlays */
.card__check {
position: absolute;
top: 12px;
left: 12px;
z-index: 2;
}
.card__check input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.card__checkbox {
width: 24px;
height: 24px;
border-radius: 7px;
border: 1.8px solid var(--line);
background: rgba(255, 255, 255, .92);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 1px 3px rgba(16, 18, 29, .14);
transition: background .12s, border-color .12s;
}
.card__checkbox svg { opacity: 0; transition: opacity .12s; }
.card__check input:checked + .card__checkbox {
background: var(--brand);
border-color: var(--brand);
}
.card__check input:checked + .card__checkbox svg { opacity: 1; }
.card__check input:focus-visible + .card__checkbox {
outline: 3px solid color-mix(in srgb, var(--brand) 55%, white);
outline-offset: 2px;
}
.card__heart {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, .92);
box-shadow: 0 1px 4px rgba(16, 18, 29, .16);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--sale);
transition: transform .12s, background .15s;
}
.card__heart:hover { transform: scale(1.08); background: #fff; }
.card__heart svg { width: 19px; height: 19px; }
.card__heart.pop { animation: heartpop .32s ease; }
@keyframes heartpop {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
/* badges */
.badges {
position: absolute;
bottom: 12px;
left: 12px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tag {
font-size: 11px;
font-weight: 700;
letter-spacing: .02em;
padding: 4px 8px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(16, 18, 29, .12);
}
.tag--drop { background: var(--sale); color: #fff; }
.tag--new { background: var(--brand); color: #fff; }
/* body */
.card__body {
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.card__brand {
font-size: 11px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.card__title {
font-size: 15px;
font-weight: 600;
letter-spacing: -.01em;
line-height: 1.35;
}
.rating {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--muted);
}
.rating__stars {
position: relative;
display: inline-block;
font-size: 13px;
line-height: 1;
color: var(--line);
letter-spacing: 1px;
}
.rating__stars::before { content: "★★★★★"; }
.rating__fill {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
white-space: nowrap;
color: #f5a623;
}
.rating__fill::before { content: "★★★★★"; }
.price {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 2px;
}
.price__now { font-size: 18px; font-weight: 800; letter-spacing: -.02em; }
.price__now.is-sale { color: var(--sale); }
.price__was {
font-size: 13px;
color: var(--muted);
text-decoration: line-through;
}
.stock {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
}
.stock::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.stock--in { color: var(--ok); }
.stock--low { color: var(--warn); }
.stock--out { color: var(--muted); }
.card__foot {
margin-top: auto;
padding-top: 4px;
}
.card__cta {
width: 100%;
background: var(--brand);
color: #fff;
border: none;
border-radius: 11px;
padding: 11px 14px;
font-weight: 700;
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background .15s, transform .08s;
}
.card__cta svg { width: 17px; height: 17px; }
.card__cta:hover:not(:disabled) { background: var(--brand-d); }
.card__cta:active { transform: translateY(1px); }
.card__cta:disabled {
background: var(--tint);
color: var(--muted);
cursor: not-allowed;
}
/* ── Empty state ─────────────────────────── */
.empty {
text-align: center;
padding: 70px 20px 60px;
border: 1px dashed var(--line);
border-radius: var(--radius);
background: var(--tint);
}
.empty__art { display: inline-flex; color: var(--sale); margin-bottom: 14px; }
.empty h2 { font-size: 20px; font-weight: 800; }
.empty p { color: var(--muted); margin: 6px 0 18px; }
/* ── Toast ───────────────────────────────── */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 14px);
z-index: 60;
display: flex;
align-items: center;
gap: 14px;
background: var(--ink);
color: #fff;
padding: 12px 16px;
border-radius: 12px;
box-shadow: 0 12px 36px rgba(16, 18, 29, .3);
opacity: 0;
pointer-events: none;
transition: opacity .2s, transform .2s;
max-width: calc(100vw - 32px);
}
.toast.is-on {
opacity: 1;
transform: translate(-50%, 0);
pointer-events: auto;
}
.toast__msg { font-size: 14px; font-weight: 500; }
.toast__btn {
border: none;
background: color-mix(in srgb, var(--brand) 80%, white);
color: #fff;
font-weight: 700;
font-size: 13px;
padding: 7px 13px;
border-radius: 9px;
}
.toast__btn:hover { background: #fff; color: var(--ink); }
/* ── Responsive ──────────────────────────── */
@media (max-width: 760px) {
.topnav { display: none; }
.bulk__actions { margin-left: 0; width: 100%; }
.bulk__actions .btn { flex: 1; }
}
@media (max-width: 460px) {
.grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.card__body { padding: 12px 12px 14px; }
.card__cta span.label { display: none; }
.toast { left: 16px; right: 16px; transform: translateY(14px); }
.toast.is-on { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; }
}(function () {
"use strict";
var STORE_KEY = "nw_wishlist_v1";
var CART_KEY = "nw_cart_count_v1";
// ── Seed catalogue (fictional) ───────────────────────────
var SEED = [
{
id: "p1", brand: "Aer", title: "Loft Over-Ear Headphones",
price: 189, was: 249, rating: 4.7, reviews: 412, stock: "in",
drop: true, isnew: false, tint: ["#eef2ff", "#dbe4ff"], hue: "#3457ff", shape: "headphones"
},
{
id: "p2", brand: "Norden", title: "Maple Standing Desk 48\"",
price: 429, was: null, rating: 4.9, reviews: 198, stock: "low",
drop: false, isnew: true, tint: ["#fff7ed", "#ffe8cc"], hue: "#d97706", shape: "desk"
},
{
id: "p3", brand: "Lumen", title: "Arc Task Lamp",
price: 79, was: 99, rating: 4.5, reviews: 327, stock: "in",
drop: true, isnew: false, tint: ["#ecfdf5", "#d1fae5"], hue: "#1f9d55", shape: "lamp"
},
{
id: "p4", brand: "Pace", title: "Trail Runner GT Sneakers",
price: 134, was: null, rating: 4.6, reviews: 904, stock: "in",
drop: false, isnew: false, tint: ["#fdf2f8", "#fce7f3"], hue: "#e0245e", shape: "shoe"
},
{
id: "p5", brand: "Kettle", title: "Pour-Over Coffee Set",
price: 58, was: null, rating: 4.4, reviews: 156, stock: "out",
drop: false, isnew: false, tint: ["#f5f3ff", "#ede9fe"], hue: "#7c3aed", shape: "kettle"
},
{
id: "p6", brand: "Field", title: "Canvas Weekender Bag",
price: 112, was: 140, rating: 4.8, reviews: 271, stock: "low",
drop: false, isnew: false, tint: ["#f0fdfa", "#ccfbf1"], hue: "#0d9488", shape: "bag"
}
];
// ── Inline SVG product silhouettes ───────────────────────
function silhouette(shape, hue) {
var c = hue;
var map = {
headphones:
'<path d="M14 40v-6a18 18 0 0 1 36 0v6" fill="none" stroke="' + c + '" stroke-width="3.4" stroke-linecap="round"/>' +
'<rect x="9" y="38" width="9" height="16" rx="4" fill="' + c + '"/>' +
'<rect x="46" y="38" width="9" height="16" rx="4" fill="' + c + '"/>',
desk:
'<rect x="8" y="22" width="48" height="6" rx="2" fill="' + c + '"/>' +
'<rect x="13" y="28" width="5" height="26" rx="2" fill="' + c + '"/>' +
'<rect x="46" y="28" width="5" height="26" rx="2" fill="' + c + '"/>' +
'<rect x="22" y="14" width="20" height="13" rx="2" fill="' + c + '" opacity=".55"/>',
lamp:
'<rect x="22" y="52" width="20" height="4" rx="2" fill="' + c + '"/>' +
'<path d="M32 52V30" stroke="' + c + '" stroke-width="3.4" stroke-linecap="round"/>' +
'<path d="M32 30l14-14" stroke="' + c + '" stroke-width="3.4" stroke-linecap="round"/>' +
'<path d="M40 8l12 12-7 6-11-11 6-7z" fill="' + c + '"/>',
shoe:
'<path d="M8 42c4-2 10-4 16-10 3 4 8 6 16 6 6 0 14 1 16 6v4H8v-12z" fill="' + c + '"/>' +
'<path d="M24 32c2 3 6 5 12 5" fill="none" stroke="#fff" stroke-width="2" opacity=".6"/>',
kettle:
'<path d="M20 26h24l-3 24a4 4 0 0 1-4 4H27a4 4 0 0 1-4-4l-3-24z" fill="' + c + '"/>' +
'<path d="M44 32l8-6" stroke="' + c + '" stroke-width="3.4" stroke-linecap="round"/>' +
'<path d="M24 26c0-6 16-6 16 0" fill="none" stroke="' + c + '" stroke-width="3.4"/>',
bag:
'<rect x="12" y="26" width="40" height="28" rx="6" fill="' + c + '"/>' +
'<path d="M24 26v-4a8 8 0 0 1 16 0v4" fill="none" stroke="' + c + '" stroke-width="3.4"/>' +
'<rect x="28" y="38" width="8" height="9" rx="2" fill="#fff" opacity=".55"/>'
};
return '<svg class="silhouette" viewBox="0 0 64 64" aria-hidden="true">' + (map[shape] || "") + "</svg>";
}
// ── Money ────────────────────────────────────────────────
function money(n) {
return "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// ── State / persistence ──────────────────────────────────
function load() {
try {
var raw = localStorage.getItem(STORE_KEY);
if (raw) {
var ids = JSON.parse(raw);
if (Array.isArray(ids)) {
var byId = {};
SEED.forEach(function (p) { byId[p.id] = p; });
var list = ids.map(function (id) { return byId[id]; }).filter(Boolean);
if (list.length) return list;
}
}
} catch (e) { /* fall through to seed */ }
return SEED.slice();
}
function persist() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(items.map(function (p) { return p.id; }))); } catch (e) {}
}
function loadCart() {
var n = parseInt(localStorage.getItem(CART_KEY) || "0", 10);
return isNaN(n) ? 0 : n;
}
function bumpCart(by) {
cartCount += by;
try { localStorage.setItem(CART_KEY, String(cartCount)); } catch (e) {}
els.cartCount.textContent = cartCount;
}
var items = load();
var selected = {}; // id -> true
var cartCount = loadCart();
// ── DOM refs ─────────────────────────────────────────────
var els = {
grid: document.getElementById("grid"),
empty: document.getElementById("empty"),
bulk: document.getElementById("bulk"),
selectAll: document.getElementById("selectAll"),
selText: document.getElementById("selText"),
bulkMove: document.getElementById("bulkMove"),
bulkRemove: document.getElementById("bulkRemove"),
headCount: document.getElementById("headCount"),
savedBadge: document.getElementById("savedBadge"),
cartCount: document.getElementById("cartCount"),
browse: document.getElementById("browse"),
toast: document.getElementById("toast"),
toastMsg: document.getElementById("toastMsg"),
toastBtn: document.getElementById("toastBtn")
};
// ── Toast w/ optional undo ───────────────────────────────
var toastTimer = null;
function toast(msg, undoFn) {
clearTimeout(toastTimer);
els.toastMsg.textContent = msg;
if (undoFn) {
els.toastBtn.hidden = false;
els.toastBtn.onclick = function () {
clearTimeout(toastTimer);
hideToast();
undoFn();
};
} else {
els.toastBtn.hidden = true;
els.toastBtn.onclick = null;
}
els.toast.hidden = false;
requestAnimationFrame(function () { els.toast.classList.add("is-on"); });
toastTimer = setTimeout(hideToast, undoFn ? 5000 : 2600);
}
function hideToast() {
els.toast.classList.remove("is-on");
setTimeout(function () { els.toast.hidden = true; }, 220);
}
// ── Render ───────────────────────────────────────────────
var STOCK_LABEL = { in: "In stock", low: "Only a few left", out: "Out of stock" };
function cardHTML(p) {
var stars = Math.round((p.rating / 5) * 100);
var badges = "";
if (p.drop) badges += '<span class="tag tag--drop">Price drop</span>';
if (p.isnew) badges += '<span class="tag tag--new">New</span>';
var priceBlock = p.was
? '<span class="price__now is-sale">' + money(p.price) + '</span><span class="price__was">' + money(p.was) + '</span>'
: '<span class="price__now">' + money(p.price) + '</span>';
var outOfStock = p.stock === "out";
return '' +
'<article class="card' + (selected[p.id] ? " is-selected" : "") + '" data-id="' + p.id + '">' +
'<div class="card__media" style="background:linear-gradient(135deg,' + p.tint[0] + ',' + p.tint[1] + ');">' +
'<label class="card__check">' +
'<input type="checkbox" data-sel="' + p.id + '" aria-label="Select ' + p.title + '"' + (selected[p.id] ? " checked" : "") + '/>' +
'<span class="card__checkbox" aria-hidden="true"><svg viewBox="0 0 16 16" width="11" height="11"><path d="M3 8.2l3.2 3.2L13 4" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>' +
'</label>' +
'<button class="card__heart" type="button" data-remove="' + p.id + '" aria-label="Remove ' + p.title + ' from wishlist" title="Remove from wishlist">' +
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20s-7-4.35-9.5-8.5C1 8.5 2.5 5 6 5c2 0 3.2 1.2 4 2.4C10.8 6.2 12 5 14 5c3.5 0 5 3.5 3.5 6.5C19 15.65 12 20 12 20z" fill="currentColor"/></svg>' +
'</button>' +
silhouette(p.shape, p.hue) +
(badges ? '<div class="badges">' + badges + '</div>' : "") +
'</div>' +
'<div class="card__body">' +
'<span class="card__brand">' + p.brand + '</span>' +
'<h3 class="card__title">' + p.title + '</h3>' +
'<span class="rating"><span class="rating__stars" aria-hidden="true"><span class="rating__fill" style="width:' + stars + '%"></span></span>' +
'<span>' + p.rating.toFixed(1) + ' (' + p.reviews.toLocaleString("en-US") + ')</span></span>' +
'<div class="price">' + priceBlock + '</div>' +
'<span class="stock stock--' + p.stock + '">' + STOCK_LABEL[p.stock] + '</span>' +
'<div class="card__foot">' +
'<button class="card__cta" type="button" data-move="' + p.id + '"' + (outOfStock ? " disabled" : "") + '>' +
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h15l-1.5 9h-12L6 6z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M6 6L5 3H2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="9" cy="20" r="1.3" fill="currentColor"/><circle cx="18" cy="20" r="1.3" fill="currentColor"/></svg>' +
'<span class="label">' + (outOfStock ? "Out of stock" : "Move to cart") + '</span>' +
'</button>' +
'</div>' +
'</div>' +
'</article>';
}
function render() {
var hasItems = items.length > 0;
els.empty.hidden = hasItems;
els.bulk.style.display = hasItems ? "" : "none";
els.grid.style.display = hasItems ? "" : "none";
els.grid.innerHTML = items.map(cardHTML).join("");
var n = items.length;
els.headCount.textContent = n + (n === 1 ? " item" : " items");
els.savedBadge.textContent = n;
persist();
syncBulk();
}
// ── Bulk-bar state ───────────────────────────────────────
function selectedIds() {
return items.filter(function (p) { return selected[p.id]; }).map(function (p) { return p.id; });
}
function syncBulk() {
var sel = selectedIds();
var count = sel.length;
var total = items.length;
els.selectAll.checked = count > 0 && count === total;
els.selectAll.indeterminate = count > 0 && count < total;
els.selText.textContent = count ? count + " selected" : "Select all";
els.bulkMove.disabled = count === 0;
els.bulkRemove.disabled = count === 0;
}
// ── Actions ──────────────────────────────────────────────
function moveToCart(id) {
var idx = items.findIndex(function (p) { return p.id === id; });
if (idx === -1) return;
var p = items[idx];
if (p.stock === "out") { toast("That item is out of stock."); return; }
items.splice(idx, 1);
delete selected[id];
bumpCart(1);
var snapshot = { item: p, index: idx };
render();
toast("Moved “" + p.title + "” to cart", function () {
items.splice(snapshot.index, 0, snapshot.item);
bumpCart(-1);
render();
});
}
function removeItem(id) {
var idx = items.findIndex(function (p) { return p.id === id; });
if (idx === -1) return;
var p = items[idx];
var card = els.grid.querySelector('.card[data-id="' + id + '"]');
var snapshot = { item: p, index: idx, wasSelected: !!selected[id] };
delete selected[id];
var commit = function () {
items.splice(idx, 1);
render();
toast("Removed “" + p.title + "”", function () {
items.splice(snapshot.index, 0, snapshot.item);
if (snapshot.wasSelected) selected[snapshot.item.id] = true;
render();
});
};
if (card) {
card.classList.add("is-leaving");
setTimeout(commit, 220);
} else {
commit();
}
}
function bulkMove() {
var movable = items.filter(function (p) { return selected[p.id] && p.stock !== "out"; });
if (!movable.length) { toast("Selected items are out of stock."); return; }
var snapshot = items.map(function (p) { return p; });
var moved = movable.map(function (p) { return p.id; });
items = items.filter(function (p) { return moved.indexOf(p.id) === -1; });
moved.forEach(function (id) { delete selected[id]; });
bumpCart(moved.length);
render();
toast(moved.length + (moved.length === 1 ? " item" : " items") + " moved to cart", function () {
items = snapshot;
bumpCart(-moved.length);
render();
});
}
function bulkRemove() {
var ids = selectedIds();
if (!ids.length) return;
var snapshot = { list: items.slice(), sel: Object.assign({}, selected) };
items = items.filter(function (p) { return ids.indexOf(p.id) === -1; });
ids.forEach(function (id) { delete selected[id]; });
render();
toast(ids.length + (ids.length === 1 ? " item" : " items") + " removed", function () {
items = snapshot.list;
selected = snapshot.sel;
render();
});
}
// ── Events (delegated) ───────────────────────────────────
els.grid.addEventListener("click", function (e) {
var moveBtn = e.target.closest("[data-move]");
if (moveBtn) { moveToCart(moveBtn.getAttribute("data-move")); return; }
var heart = e.target.closest("[data-remove]");
if (heart) {
heart.classList.add("pop");
removeItem(heart.getAttribute("data-remove"));
}
});
els.grid.addEventListener("change", function (e) {
var box = e.target.closest("[data-sel]");
if (!box) return;
var id = box.getAttribute("data-sel");
if (box.checked) selected[id] = true; else delete selected[id];
var card = els.grid.querySelector('.card[data-id="' + id + '"]');
if (card) card.classList.toggle("is-selected", box.checked);
syncBulk();
});
els.selectAll.addEventListener("change", function () {
if (els.selectAll.checked) {
items.forEach(function (p) { selected[p.id] = true; });
} else {
selected = {};
}
render();
});
els.bulkMove.addEventListener("click", bulkMove);
els.bulkRemove.addEventListener("click", bulkRemove);
els.browse.addEventListener("click", function () {
items = SEED.slice();
selected = {};
render();
toast("Wishlist restored with sample items");
});
// ── Boot ─────────────────────────────────────────────────
els.cartCount.textContent = cartCount;
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind — Your Wishlist</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip" href="#saved">Skip to saved items</a>
<header class="topbar" role="banner">
<div class="topbar__in">
<a class="brand" href="#" aria-label="Northwind home">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 7l9-4 9 4-9 4-9-4z" fill="currentColor" opacity=".9"/>
<path d="M3 7v10l9 4 9-4V7l-9 4-9-4z" fill="currentColor" opacity=".45"/>
</svg>
<span>Northwind</span>
</a>
<nav class="topnav" aria-label="Primary">
<a href="#">New</a>
<a href="#">Audio</a>
<a href="#">Desk</a>
<a href="#">Sale</a>
</nav>
<div class="topbar__actions">
<div class="topbar__icon" title="Saved items">
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 20s-7-4.35-9.5-8.5C1 8.5 2.5 5 6 5c2 0 3.2 1.2 4 2.4C10.8 6.2 12 5 14 5c3.5 0 5 3.5 3.5 6.5C19 15.65 12 20 12 20z" fill="currentColor" opacity=".9"/>
</svg>
<span class="topbar__badge" id="savedBadge" aria-label="saved items">0</span>
</div>
<div class="topbar__cart" aria-live="polite">
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6 6h15l-1.5 9h-12L6 6z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/>
<path d="M6 6L5 3H2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="9" cy="20" r="1.4" fill="currentColor"/>
<circle cx="18" cy="20" r="1.4" fill="currentColor"/>
</svg>
<span class="topbar__count" id="cartCount">0</span>
</div>
</div>
</div>
</header>
<main id="saved" class="wrap" role="main">
<div class="head">
<h1>Your wishlist</h1>
<p class="head__sub"><span id="headCount">0 items</span> · <a href="#">Continue shopping</a></p>
</div>
<!-- BULK BAR -->
<div class="bulk" id="bulk" role="region" aria-label="Bulk actions">
<label class="bulk__select">
<input type="checkbox" id="selectAll" aria-label="Select all items" />
<span class="bulk__selbox" aria-hidden="true">
<svg viewBox="0 0 16 16" width="12" height="12"><path d="M3 8.2l3.2 3.2L13 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="bulk__seltext" id="selText">Select all</span>
</label>
<div class="bulk__actions">
<button class="btn btn--ghost" type="button" id="bulkMove" disabled>Move to cart</button>
<button class="btn btn--danger" type="button" id="bulkRemove" disabled>Remove</button>
</div>
</div>
<!-- GRID -->
<section class="grid" id="grid" aria-label="Saved products"><!-- rendered by JS --></section>
<!-- EMPTY STATE -->
<div class="empty" id="empty" hidden>
<div class="empty__art" aria-hidden="true">
<svg viewBox="0 0 96 96" width="96" height="96">
<circle cx="48" cy="48" r="44" fill="none" stroke="currentColor" stroke-width="2" opacity=".18"/>
<path d="M48 66s-18-11-23-21C22 39 25 30 34 30c5 0 8 3 9.6 5.6C45.2 33 48.2 30 53.4 30c9 0 12 9 9 15-5 10-14.4 21-14.4 21z" fill="currentColor" opacity=".22"/>
</svg>
</div>
<h2>No saved items yet</h2>
<p>Tap the heart on any product to keep it here for later.</p>
<button class="btn btn--brand" type="button" id="browse">Start shopping</button>
</div>
</main>
<div class="toast" id="toast" role="status" aria-live="polite" hidden>
<span class="toast__msg" id="toastMsg"></span>
<button class="toast__btn" type="button" id="toastBtn" hidden>Undo</button>
</div>
<script src="script.js"></script>
</body>
</html>Wishlist / Saved Items
A clean white, ink, and single-accent saved-items grid. Each card pairs a soft tinted gradient tile and an inline-SVG product silhouette with the brand, title, a partial-fill star rating and review count, a prominent price (with strike-through original on discounted items), and a colour-coded stock chip — in stock, only a few left, or out of stock. A floating select checkbox and heart sit over the tile, with optional price-drop and new badges in the corner.
A bulk-actions bar above the grid offers a tri-state select-all checkbox that reflects partial and full selection, a live “N selected” label, and bulk move-to-cart and remove buttons that enable only when something is checked. Every card also has its own move-to-cart CTA and a heart that removes the item with a quick pop animation.
All interactions are real and self-contained in vanilla JS. Moving an item to cart bumps the
header cart counter and offers an undo toast; removing surfaces undo too; out-of-stock items are
skipped on move. Saved items and the cart count persist to localStorage, so the list survives a
reload, and clearing everything reveals a dedicated empty state with a one-tap restore. Money is
formatted as $1,299.00.
Illustrative storefront UI only — fictional products, prices, and reviews. No real checkout.