Shop — Product Manager
A polished e-commerce admin product manager built in vanilla JS. Browse a products data-table with thumbnails, SKUs, prices, stock, and status, then search, filter by status, and sort any column. Select rows for bulk publish, archive, or delete, edit inventory inline, and create or update items in a slide-in drawer with title, price, stock, variants, and status. Includes pagination, live summary stats, and toast feedback.
MCP
Code
:root {
--bg: #ffffff;
--panel: #f7f8fb;
--panel-2: #eef1f7;
--ink: #16181d;
--ink-2: #2a2e38;
--muted: #6b7280;
--brand: #3457ff;
--brand-d: #2742d6;
--brand-soft: #eaeeff;
--sale: #e0245e;
--sale-soft: #fdeaf0;
--ok: #1f9d55;
--ok-soft: #e6f6ec;
--warn: #b7791f;
--warn-soft: #fcf3e3;
--line: rgba(16, 18, 29, 0.1);
--line-2: rgba(16, 18, 29, 0.06);
--shadow: 0 1px 2px rgba(16, 18, 29, 0.06), 0 8px 24px rgba(16, 18, 29, 0.08);
--shadow-lg: 0 24px 60px rgba(16, 18, 29, 0.22);
--radius: 12px;
--radius-sm: 8px;
--sidebar-w: 232px;
--font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--font);
background: var(--panel);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "tnum" 1, "cv05" 1;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; border: 0;
}
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Layout ---------- */
.app {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
min-height: 100vh;
}
/* ---------- Sidebar ---------- */
.sidebar {
background: var(--bg);
border-right: 1px solid var(--line);
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 22px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px;
}
.brand-mark {
display: grid;
place-items: center;
width: 30px; height: 30px;
border-radius: 9px;
background: linear-gradient(135deg, var(--brand), #6b4bff);
color: #fff;
font-size: 15px;
}
.brand-name { font-weight: 800; letter-spacing: -0.02em; }
.nav { display: flex; flex-direction: column; gap: 3px; }
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: var(--radius-sm);
color: var(--ink-2);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: background 0.14s, color 0.14s;
}
.nav-item span { color: var(--muted); width: 18px; text-align: center; }
.nav-item:hover { background: var(--panel); }
.nav-item.is-active { background: var(--brand-soft); color: var(--brand-d); }
.nav-item.is-active span { color: var(--brand); }
.nav-foot { margin-top: auto; }
.store-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel);
}
.store-chip strong { display: block; font-size: 13px; }
.store-chip small { color: var(--muted); font-size: 11.5px; }
.store-dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 3px var(--ok-soft);
flex: none;
}
/* ---------- Main ---------- */
.main {
padding: 22px 28px 40px;
max-width: 1180px;
width: 100%;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
.topbar h1 {
margin: 0;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.025em;
}
.subtitle { margin: 2px 0 0; color: var(--muted); font-size: 13.5px; }
.topbar-actions { display: flex; gap: 10px; flex: none; }
/* ---------- Buttons ---------- */
.btn {
font-family: inherit;
font-size: 13.5px;
font-weight: 600;
border-radius: var(--radius-sm);
padding: 9px 15px;
border: 1px solid transparent;
cursor: pointer;
transition: background 0.14s, border-color 0.14s, transform 0.06s, box-shadow 0.14s;
white-space: nowrap;
line-height: 1.1;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--brand);
color: #fff;
box-shadow: 0 1px 2px rgba(52, 87, 255, 0.4);
}
.btn-primary:hover { background: var(--brand-d); }
.btn-ghost {
background: var(--bg);
color: var(--ink-2);
border-color: var(--line);
}
.btn-ghost:hover { background: var(--panel-2); }
.btn-danger {
background: var(--bg);
color: var(--sale);
border-color: rgba(224, 36, 94, 0.35);
}
.btn-danger:hover { background: var(--sale-soft); }
/* ---------- Stats ---------- */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 20px;
}
.stat {
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 14px 16px;
}
.stat-label { display: block; color: var(--muted); font-size: 12.5px; font-weight: 500; }
.stat-value { display: block; font-size: 23px; font-weight: 800; letter-spacing: -0.02em; margin-top: 3px; }
.stat-ok { color: var(--ok); }
.stat-sale { color: var(--sale); }
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.search {
position: relative;
flex: 1 1 280px;
min-width: 200px;
}
.search-ico {
position: absolute;
left: 12px; top: 50%;
transform: translateY(-50%);
color: var(--muted);
font-size: 16px;
pointer-events: none;
}
.search input {
width: 100%;
font-family: inherit;
font-size: 14px;
padding: 10px 12px 10px 36px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--ink);
}
.search input:focus-visible { outline-offset: 0; border-color: var(--brand); }
.search input::placeholder { color: var(--muted); }
.filters { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
font-family: inherit;
font-size: 13px;
font-weight: 600;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--bg);
color: var(--ink-2);
cursor: pointer;
transition: background 0.14s, color 0.14s, border-color 0.14s;
}
.chip:hover { background: var(--panel-2); }
.chip.is-active {
background: var(--ink);
color: #fff;
border-color: var(--ink);
}
/* ---------- Bulk bar ---------- */
.bulkbar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 14px;
margin-bottom: 12px;
background: var(--ink);
color: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
animation: bulkIn 0.16s ease;
}
@keyframes bulkIn { from { opacity: 0; transform: translateY(-6px); } }
.bulk-count { font-size: 13.5px; }
.bulk-count strong { font-size: 15px; }
.bulk-actions { display: flex; gap: 8px; margin-left: auto; }
.bulkbar .btn-ghost { background: rgba(255,255,255,0.1); color: #fff; border-color: rgba(255,255,255,0.22); }
.bulkbar .btn-ghost:hover { background: rgba(255,255,255,0.2); }
.bulkbar .btn-danger { background: rgba(224,36,94,0.18); color: #ffb3c8; border-color: rgba(224,36,94,0.5); }
.bulkbar .btn-danger:hover { background: var(--sale); color: #fff; }
.bulk-clear {
background: none; border: none; color: rgba(255,255,255,0.7);
font-size: 15px; cursor: pointer; padding: 4px;
}
.bulk-clear:hover { color: #fff; }
/* ---------- Table ---------- */
.table-wrap {
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.ptable {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
.ptable thead th {
text-align: left;
padding: 12px 14px;
background: var(--panel);
border-bottom: 1px solid var(--line);
color: var(--muted);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
z-index: 2;
}
.col-check { width: 44px; }
.col-price, .col-stock { width: 120px; }
.col-status { width: 130px; }
.col-act { width: 90px; }
.sort {
background: none; border: none; padding: 0; margin: 0;
font: inherit; color: inherit; cursor: pointer;
text-transform: inherit; letter-spacing: inherit;
display: inline-flex; align-items: center; gap: 5px;
}
.sort:hover { color: var(--ink); }
.sort-ind::after { content: "⇅"; opacity: 0.4; font-size: 11px; }
.sort.asc { color: var(--ink); }
.sort.asc .sort-ind::after { content: "↑"; opacity: 1; color: var(--brand); }
.sort.desc { color: var(--ink); }
.sort.desc .sort-ind::after { content: "↓"; opacity: 1; color: var(--brand); }
.ptable tbody tr {
border-bottom: 1px solid var(--line-2);
transition: background 0.12s;
}
.ptable tbody tr:last-child { border-bottom: none; }
.ptable tbody tr:hover { background: var(--panel); }
.ptable tbody tr.is-selected { background: var(--brand-soft); }
.ptable tbody tr.is-selected:hover { background: #e0e6ff; }
.ptable td { padding: 11px 14px; vertical-align: middle; }
input[type="checkbox"] {
width: 16px; height: 16px;
accent-color: var(--brand);
cursor: pointer;
}
.prod-cell { display: flex; align-items: center; gap: 12px; }
.thumb {
width: 42px; height: 42px;
border-radius: 9px;
flex: none;
display: grid;
place-items: center;
font-size: 20px;
border: 1px solid var(--line-2);
}
.prod-name { font-weight: 600; color: var(--ink); }
.prod-meta { color: var(--muted); font-size: 12px; }
.sku-mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; color: var(--muted); }
.price-cell { font-weight: 600; }
/* inline stock edit */
.stock-edit {
width: 64px;
font-family: inherit;
font-size: 13.5px;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ink);
font-weight: 600;
transition: border-color 0.12s, background 0.12s;
}
.stock-edit:hover { background: var(--panel-2); }
.stock-edit:focus-visible { background: var(--bg); border-color: var(--brand); outline: none; }
.stock-low { color: var(--warn); }
.stock-out { color: var(--sale); }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge-published { background: var(--ok-soft); color: var(--ok); }
.badge-draft { background: var(--warn-soft); color: var(--warn); }
.badge-archived { background: var(--panel-2); color: var(--muted); }
.row-act {
display: flex; gap: 4px; justify-content: flex-end;
}
.icon-btn {
width: 30px; height: 30px;
display: grid; place-items: center;
border: 1px solid var(--line);
background: var(--bg);
border-radius: 7px;
cursor: pointer;
font-size: 14px;
color: var(--ink-2);
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.icon-btn:hover { background: var(--panel-2); }
.icon-btn.danger:hover { background: var(--sale-soft); color: var(--sale); border-color: rgba(224,36,94,0.3); }
/* empty state */
.empty { text-align: center; padding: 56px 20px; color: var(--muted); }
.empty-ico { font-size: 32px; display: block; margin-bottom: 8px; opacity: 0.5; }
.empty p { margin: 0 0 14px; }
/* ---------- Pager ---------- */
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 16px;
flex-wrap: wrap;
}
.pager-info { color: var(--muted); font-size: 13px; }
.pager-ctrls { display: flex; align-items: center; gap: 6px; }
.pager-pages { display: flex; gap: 4px; }
.page-num {
min-width: 34px; height: 34px;
border: 1px solid var(--line);
background: var(--bg);
border-radius: 7px;
font: inherit; font-size: 13px; font-weight: 600;
color: var(--ink-2);
cursor: pointer;
transition: background 0.12s;
}
.page-num:hover { background: var(--panel-2); }
.page-num.is-active { background: var(--brand); color: #fff; border-color: var(--brand); }
.btn[disabled] { opacity: 0.45; cursor: not-allowed; }
/* ---------- Drawer ---------- */
.scrim {
position: fixed; inset: 0;
background: rgba(16, 18, 29, 0.42);
z-index: 40;
animation: fade 0.18s ease;
}
@keyframes fade { from { opacity: 0; } }
.drawer {
position: fixed;
top: 0; right: 0;
height: 100vh;
width: min(440px, 100vw);
background: var(--bg);
z-index: 50;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
transform: translateX(100%);
transition: transform 0.26s cubic-bezier(0.4, 0, 0.2, 1);
visibility: hidden;
}
.drawer.is-open { transform: translateX(0); visibility: visible; }
.drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--line);
}
.drawer-head h2 { margin: 0; font-size: 18px; font-weight: 700; letter-spacing: -0.02em; }
.drawer-close {
background: none; border: none; cursor: pointer;
font-size: 16px; color: var(--muted);
width: 32px; height: 32px; border-radius: 7px;
}
.drawer-close:hover { background: var(--panel-2); color: var(--ink); }
.drawer-body {
padding: 20px 22px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.d-preview {
height: 96px;
border-radius: var(--radius);
display: grid;
place-items: center;
font-size: 40px;
border: 1px solid var(--line-2);
background: linear-gradient(135deg, var(--brand-soft), #f3eaff);
transition: background 0.2s;
}
.field { display: flex; flex-direction: column; gap: 6px; border: none; padding: 0; margin: 0; min-width: 0; }
.field-label { font-size: 13px; font-weight: 600; color: var(--ink-2); }
.field input, .field select {
font-family: inherit;
font-size: 14px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--ink);
width: 100%;
}
.field input:focus-visible, .field select:focus-visible { border-color: var(--brand); outline-offset: 0; }
.field input.invalid { border-color: var(--sale); background: var(--sale-soft); }
.field-err { color: var(--sale); font-size: 12px; min-height: 0; }
.field-err:empty { display: none; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.input-prefix {
display: flex; align-items: center;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
overflow: hidden;
}
.input-prefix:focus-within { border-color: var(--brand); }
.input-prefix span { padding: 0 4px 0 12px; color: var(--muted); font-weight: 600; }
.input-prefix input { border: none; padding-left: 4px; }
.input-prefix input:focus-visible { outline: none; }
.variants { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.variants:empty { display: none; }
.variant-tag {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 6px 5px 11px;
background: var(--brand-soft);
color: var(--brand-d);
border-radius: 999px;
font-size: 12.5px;
font-weight: 600;
}
.variant-tag button {
background: none; border: none; cursor: pointer;
color: var(--brand-d); font-size: 13px; padding: 0 3px;
border-radius: 50%;
}
.variant-tag button:hover { background: rgba(52,87,255,0.2); }
.variant-add { display: flex; gap: 8px; }
.variant-add input { flex: 1; }
.drawer-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 22px;
border-top: 1px solid var(--line);
background: var(--panel);
}
/* ---------- Toasts ---------- */
.toast-host {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 80;
width: max-content;
max-width: 92vw;
}
.toast {
background: var(--ink);
color: #fff;
padding: 11px 16px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 500;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
gap: 9px;
animation: toastIn 0.2s ease;
}
.toast.out { animation: toastOut 0.25s ease forwards; }
.toast .t-ico { font-size: 14px; }
.toast.ok .t-ico { color: #6ee7a3; }
.toast.warn .t-ico { color: #ffd27a; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(10px); } }
/* ---------- Responsive ---------- */
@media (max-width: 1080px) {
.stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 860px) {
:root { --sidebar-w: 0px; }
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
.main { padding: 18px 16px 40px; }
.col-sku { display: none; }
.table-wrap { overflow-x: auto; }
.ptable { min-width: 620px; }
}
@media (max-width: 560px) {
.topbar { flex-direction: column; }
.topbar-actions { width: 100%; }
.topbar-actions .btn { flex: 1; }
.stats { grid-template-columns: 1fr 1fr; gap: 10px; }
.stat-value { font-size: 20px; }
.field-row { grid-template-columns: 1fr; }
.drawer { width: 100vw; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}(function () {
"use strict";
/* ---------- Seed data ---------- */
var GRADS = [
"linear-gradient(135deg,#fde68a,#fb923c)",
"linear-gradient(135deg,#bfdbfe,#3457ff)",
"linear-gradient(135deg,#bbf7d0,#1f9d55)",
"linear-gradient(135deg,#fbcfe8,#e0245e)",
"linear-gradient(135deg,#ddd6fe,#7c3aed)",
"linear-gradient(135deg,#a5f3fc,#0891b2)",
"linear-gradient(135deg,#fed7aa,#c2410c)",
"linear-gradient(135deg,#e2e8f0,#475569)"
];
var products = [
{ id: 1, title: "Cedar Pour-Over Kettle", sku: "KIT-0291", price: 78.0, stock: 42, status: "published", emoji: "☕", variants: ["Matte Black", "Brushed Steel"] },
{ id: 2, title: "Linen Apron — Field", sku: "APR-1180", price: 36.5, stock: 8, status: "published", emoji: "🧵", variants: ["S/M", "L/XL"] },
{ id: 3, title: "Smoked Glass Tumbler Set", sku: "GLS-0044", price: 54.0, stock: 0, status: "published", emoji: "🥃", variants: ["Set of 2", "Set of 4"] },
{ id: 4, title: "Walnut Cutting Board", sku: "BRD-7720", price: 64.0, stock: 19, status: "published", emoji: "🪵", variants: [] },
{ id: 5, title: "Beeswax Wood Balm", sku: "CAR-0310", price: 14.0, stock: 120, status: "published", emoji: "🐝", variants: ["4oz", "8oz"] },
{ id: 6, title: "Stoneware Mug — Fog", sku: "MUG-2204", price: 22.0, stock: 6, status: "draft", emoji: "🫖", variants: ["Fog", "Clay", "Moss"] },
{ id: 7, title: "Copper Espresso Spoon", sku: "SPN-0099", price: 12.0, stock: 64, status: "published", emoji: "🥄", variants: [] },
{ id: 8, title: "Indigo Tea Towel Pair", sku: "TWL-3301", price: 28.0, stock: 31, status: "published", emoji: "🧺", variants: ["Indigo", "Rust"] },
{ id: 9, title: "Cast Iron Skillet 10\"", sku: "PAN-0510", price: 89.0, stock: 14, status: "published", emoji: "🍳", variants: ["10 inch", "12 inch"] },
{ id: 10, title: "Hand-Dipped Beeswax Tapers", sku: "CDL-1402", price: 18.0, stock: 0, status: "draft", emoji: "🕯️", variants: ["Natural", "Charcoal"] },
{ id: 11, title: "Ceramic Spice Jar Trio", sku: "JAR-6612", price: 42.0, stock: 27, status: "published", emoji: "🫙", variants: [] },
{ id: 12, title: "Oak Coffee Scoop", sku: "SCP-0073", price: 16.0, stock: 9, status: "archived", emoji: "🥄", variants: [] },
{ id: 13, title: "Waxed Canvas Tote", sku: "TOT-9001", price: 96.0, stock: 22, status: "published", emoji: "👜", variants: ["Olive", "Sand", "Black"] },
{ id: 14, title: "Slate Cheese Board", sku: "BRD-4408", price: 48.0, stock: 5, status: "published", emoji: "🧀", variants: [] },
{ id: 15, title: "Recycled Wool Throw", sku: "THR-2025", price: 128.0, stock: 11, status: "published", emoji: "🧶", variants: ["Heather", "Charcoal"] },
{ id: 16, title: "Brass Bottle Opener", sku: "OPN-0150", price: 24.0, stock: 0, status: "archived", emoji: "🍾", variants: [] },
{ id: 17, title: "Maple Honey Dipper", sku: "DIP-0801", price: 9.0, stock: 88, status: "published", emoji: "🍯", variants: [] },
{ id: 18, title: "Enamel Camp Mug", sku: "MUG-7700", price: 19.0, stock: 47, status: "draft", emoji: "🏕️", variants: ["Cream", "Navy"] }
];
var nextId = 19;
/* ---------- State ---------- */
var state = {
search: "",
status: "all",
sortKey: "title",
sortDir: "asc",
page: 1,
perPage: 8,
selected: new Set(),
editingId: null,
formVariants: []
};
/* ---------- Helpers ---------- */
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var money = function (n) {
return "$" + Number(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
var esc = function (s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
};
function gradFor(id) { return GRADS[id % GRADS.length]; }
/* ---------- Toast ---------- */
var toastHost = $("#toasts");
function toast(msg, kind) {
var t = document.createElement("div");
t.className = "toast " + (kind || "ok");
var ico = kind === "warn" ? "⚠" : kind === "info" ? "ℹ" : "✓";
t.innerHTML = '<span class="t-ico">' + ico + "</span>" + esc(msg);
toastHost.appendChild(t);
setTimeout(function () {
t.classList.add("out");
setTimeout(function () { t.remove(); }, 260);
}, 2600);
}
/* ---------- Filtering / sorting ---------- */
function filtered() {
var q = state.search.trim().toLowerCase();
var rows = products.filter(function (p) {
if (state.status !== "all" && p.status !== state.status) return false;
if (q) {
return (p.title.toLowerCase().indexOf(q) !== -1) || (p.sku.toLowerCase().indexOf(q) !== -1);
}
return true;
});
var dir = state.sortDir === "asc" ? 1 : -1;
var k = state.sortKey;
rows.sort(function (a, b) {
var av = a[k], bv = b[k];
if (typeof av === "string") { av = av.toLowerCase(); bv = bv.toLowerCase(); }
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return 0;
});
return rows;
}
/* ---------- Render stats ---------- */
function renderStats() {
var total = products.length;
var pub = products.filter(function (p) { return p.status === "published"; }).length;
var oos = products.filter(function (p) { return p.stock === 0; }).length;
var val = products.reduce(function (s, p) { return s + p.price * p.stock; }, 0);
$("#stat-total").textContent = total;
$("#stat-published").textContent = pub;
$("#stat-oos").textContent = oos;
$("#stat-value").textContent = money(val);
$("#catalog-count").textContent = total + " products · " + pub + " published";
}
/* ---------- Render table ---------- */
function stockClass(s) { return s === 0 ? "stock-out" : s <= 10 ? "stock-low" : ""; }
function rowHtml(p) {
var sel = state.selected.has(p.id);
var variantsLabel = p.variants.length ? p.variants.length + " variants" : "Single variant";
return (
'<tr data-id="' + p.id + '" class="' + (sel ? "is-selected" : "") + '">' +
'<td class="col-check"><input type="checkbox" class="row-check" ' + (sel ? "checked" : "") +
' aria-label="Select ' + esc(p.title) + '" /></td>' +
'<td class="col-prod"><div class="prod-cell">' +
'<span class="thumb" style="background:' + gradFor(p.id) + '" aria-hidden="true">' + p.emoji + "</span>" +
'<div><div class="prod-name">' + esc(p.title) + "</div>" +
'<div class="prod-meta">' + esc(variantsLabel) + "</div></div></div></td>" +
'<td class="col-sku"><span class="sku-mono">' + esc(p.sku) + "</span></td>" +
'<td class="col-price price-cell">' + money(p.price) + "</td>" +
'<td class="col-stock"><input type="number" min="0" step="1" value="' + p.stock +
'" class="stock-edit ' + stockClass(p.stock) + '" data-stock="' + p.id +
'" aria-label="Stock for ' + esc(p.title) + '" /></td>' +
'<td class="col-status"><span class="badge badge-' + p.status + '">' +
p.status.charAt(0).toUpperCase() + p.status.slice(1) + "</span></td>" +
'<td class="col-act"><div class="row-act">' +
'<button type="button" class="icon-btn" data-edit="' + p.id + '" title="Edit" aria-label="Edit ' + esc(p.title) + '">✎</button>' +
'<button type="button" class="icon-btn danger" data-del="' + p.id + '" title="Delete" aria-label="Delete ' + esc(p.title) + '">🗑</button>' +
"</div></td>" +
"</tr>"
);
}
function renderTable() {
var rows = filtered();
var pages = Math.max(1, Math.ceil(rows.length / state.perPage));
if (state.page > pages) state.page = pages;
var start = (state.page - 1) * state.perPage;
var pageRows = rows.slice(start, start + state.perPage);
var tbody = $("#rows");
var empty = $("#empty");
if (rows.length === 0) {
tbody.innerHTML = "";
empty.hidden = false;
} else {
empty.hidden = true;
tbody.innerHTML = pageRows.map(rowHtml).join("");
}
// pager
$("#pagerInfo").textContent = rows.length
? "Showing " + (start + 1) + "–" + (start + pageRows.length) + " of " + rows.length
: "No results";
$("#prevPage").disabled = state.page <= 1;
$("#nextPage").disabled = state.page >= pages;
var pagesHtml = "";
for (var i = 1; i <= pages; i++) {
pagesHtml += '<button type="button" class="page-num ' + (i === state.page ? "is-active" : "") +
'" data-page="' + i + '"' + (i === state.page ? ' aria-current="page"' : "") + ">" + i + "</button>";
}
$("#pagerPages").innerHTML = pagesHtml;
// select-all reflects current page
var checkAll = $("#checkAll");
var allSel = pageRows.length > 0 && pageRows.every(function (p) { return state.selected.has(p.id); });
var someSel = pageRows.some(function (p) { return state.selected.has(p.id); });
checkAll.checked = allSel;
checkAll.indeterminate = !allSel && someSel;
// sort indicators
$$(".sort").forEach(function (b) {
b.classList.remove("asc", "desc");
b.removeAttribute("aria-sort");
if (b.dataset.sort === state.sortKey) {
b.classList.add(state.sortDir);
b.setAttribute("aria-sort", state.sortDir === "asc" ? "ascending" : "descending");
}
});
renderBulk();
}
function renderBulk() {
var bar = $("#bulkbar");
var n = state.selected.size;
if (n > 0) {
bar.hidden = false;
$("#bulk-n").textContent = n;
} else {
bar.hidden = true;
}
}
function renderAll() { renderStats(); renderTable(); }
/* ---------- Events: toolbar ---------- */
$("#search").addEventListener("input", function (e) {
state.search = e.target.value;
state.page = 1;
renderTable();
});
$$(".chip").forEach(function (chip) {
chip.addEventListener("click", function () {
$$(".chip").forEach(function (c) { c.classList.remove("is-active"); c.setAttribute("aria-selected", "false"); });
chip.classList.add("is-active");
chip.setAttribute("aria-selected", "true");
state.status = chip.dataset.status;
state.page = 1;
renderTable();
});
});
$$(".sort").forEach(function (btn) {
btn.addEventListener("click", function () {
var key = btn.dataset.sort;
if (state.sortKey === key) {
state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
} else {
state.sortKey = key;
state.sortDir = "asc";
}
renderTable();
});
});
$("#resetFilters").addEventListener("click", function () {
state.search = ""; state.status = "all"; state.page = 1;
$("#search").value = "";
$$(".chip").forEach(function (c) {
var on = c.dataset.status === "all";
c.classList.toggle("is-active", on);
c.setAttribute("aria-selected", on ? "true" : "false");
});
renderTable();
});
/* ---------- Events: table delegation ---------- */
$("#rows").addEventListener("change", function (e) {
var id;
if (e.target.classList.contains("row-check")) {
id = Number(e.target.closest("tr").dataset.id);
if (e.target.checked) state.selected.add(id); else state.selected.delete(id);
e.target.closest("tr").classList.toggle("is-selected", e.target.checked);
renderTable();
} else if (e.target.classList.contains("stock-edit")) {
id = Number(e.target.dataset.stock);
var p = products.find(function (x) { return x.id === id; });
var v = Math.max(0, parseInt(e.target.value, 10) || 0);
e.target.value = v;
p.stock = v;
toast("Stock updated for " + p.title + " → " + v, "info");
renderStats();
// refresh class on the field without full rerender to keep focus
e.target.className = "stock-edit " + stockClass(v);
}
});
$("#rows").addEventListener("click", function (e) {
var editBtn = e.target.closest("[data-edit]");
var delBtn = e.target.closest("[data-del]");
if (editBtn) { openDrawer(Number(editBtn.dataset.edit)); }
if (delBtn) {
var id = Number(delBtn.dataset.del);
var p = products.find(function (x) { return x.id === id; });
if (!p) return;
products = products.filter(function (x) { return x.id !== id; });
state.selected.delete(id);
toast("Deleted “" + p.title + "”", "warn");
renderAll();
}
});
/* ---------- Select-all ---------- */
$("#checkAll").addEventListener("change", function (e) {
var rows = filtered();
var start = (state.page - 1) * state.perPage;
var pageRows = rows.slice(start, start + state.perPage);
pageRows.forEach(function (p) {
if (e.target.checked) state.selected.add(p.id); else state.selected.delete(p.id);
});
renderTable();
});
/* ---------- Bulk actions ---------- */
$$("[data-bulk]").forEach(function (btn) {
btn.addEventListener("click", function () {
var action = btn.dataset.bulk;
var ids = Array.from(state.selected);
if (!ids.length) return;
if (action === "delete") {
products = products.filter(function (p) { return !state.selected.has(p.id); });
toast(ids.length + " product" + (ids.length > 1 ? "s" : "") + " deleted", "warn");
state.selected.clear();
} else {
var newStatus = action === "publish" ? "published" : "archived";
ids.forEach(function (id) {
var p = products.find(function (x) { return x.id === id; });
if (p) p.status = newStatus;
});
toast(ids.length + " product" + (ids.length > 1 ? "s" : "") + " " + newStatus, "ok");
}
renderAll();
});
});
$("#bulkClear").addEventListener("click", function () {
state.selected.clear();
renderTable();
});
/* ---------- Pager ---------- */
$("#prevPage").addEventListener("click", function () {
if (state.page > 1) { state.page--; renderTable(); }
});
$("#nextPage").addEventListener("click", function () {
var pages = Math.max(1, Math.ceil(filtered().length / state.perPage));
if (state.page < pages) { state.page++; renderTable(); }
});
$("#pagerPages").addEventListener("click", function (e) {
var b = e.target.closest("[data-page]");
if (b) { state.page = Number(b.dataset.page); renderTable(); }
});
/* ---------- Export (mock) ---------- */
$("#exportBtn").addEventListener("click", function () {
var rows = filtered();
toast("Exported " + rows.length + " products to CSV", "ok");
});
/* ---------- Drawer ---------- */
var drawer = $("#drawer");
var scrim = $("#scrim");
var form = $("#prodForm");
var lastFocused = null;
function renderFormVariants() {
var host = $("#variants");
host.innerHTML = state.formVariants.map(function (v, i) {
return '<span class="variant-tag">' + esc(v) +
'<button type="button" data-rmv="' + i + '" aria-label="Remove ' + esc(v) + '">✕</button></span>';
}).join("");
}
function updatePreview() {
var em = state.editingId ? (products.find(function (p) { return p.id === state.editingId; }) || {}).emoji : "📦";
$("#dPreview").textContent = em || "📦";
}
function openDrawer(id) {
lastFocused = document.activeElement;
state.editingId = id || null;
form.reset();
clearErrors();
if (id) {
var p = products.find(function (x) { return x.id === id; });
$("#drawerTitle").textContent = "Edit product";
$("#fTitle").value = p.title;
$("#fPrice").value = p.price;
$("#fStock").value = p.stock;
$("#fSku").value = p.sku;
$("#fStatus").value = p.status;
state.formVariants = p.variants.slice();
$("#dPreview").textContent = p.emoji;
$("#dPreview").style.background = gradFor(p.id);
} else {
$("#drawerTitle").textContent = "Add product";
state.formVariants = [];
$("#dPreview").textContent = "📦";
$("#dPreview").style.background = "linear-gradient(135deg,var(--brand-soft),#f3eaff)";
}
renderFormVariants();
drawer.classList.add("is-open");
drawer.setAttribute("aria-hidden", "false");
scrim.hidden = false;
setTimeout(function () { $("#fTitle").focus(); }, 60);
document.addEventListener("keydown", onEsc);
}
function closeDrawer() {
drawer.classList.remove("is-open");
drawer.setAttribute("aria-hidden", "true");
scrim.hidden = true;
document.removeEventListener("keydown", onEsc);
if (lastFocused) lastFocused.focus();
}
function onEsc(e) {
if (e.key === "Escape") closeDrawer();
// simple focus trap across drawer panel + footer Save button
if (e.key === "Tab") {
var f = $$('button, input, select', drawer).concat([$("#drawerSave")]);
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
$("#addBtn").addEventListener("click", function () { openDrawer(null); });
$("#drawerClose").addEventListener("click", closeDrawer);
$("#drawerCancel").addEventListener("click", closeDrawer);
scrim.addEventListener("click", closeDrawer);
// variants in form
$("#variantAdd").addEventListener("click", addVariant);
$("#variantInput").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); addVariant(); }
});
function addVariant() {
var input = $("#variantInput");
var v = input.value.trim();
if (!v) return;
if (state.formVariants.indexOf(v) === -1) state.formVariants.push(v);
input.value = "";
input.focus();
renderFormVariants();
}
$("#variants").addEventListener("click", function (e) {
var b = e.target.closest("[data-rmv]");
if (b) {
state.formVariants.splice(Number(b.dataset.rmv), 1);
renderFormVariants();
}
});
/* ---------- Validation + save ---------- */
function clearErrors() {
["Title", "Price", "Stock"].forEach(function (f) {
$("#err" + f).textContent = "";
$("#f" + f).classList.remove("invalid");
});
}
function err(field, msg) {
$("#err" + field).textContent = msg;
$("#f" + field).classList.add("invalid");
}
function makeSku(title) {
var prefix = (title.replace(/[^a-z]/gi, "").slice(0, 3) || "PRD").toUpperCase();
return prefix + "-" + String(Math.floor(1000 + Math.random() * 9000));
}
form.addEventListener("submit", function (e) {
e.preventDefault();
clearErrors();
var title = $("#fTitle").value.trim();
var price = parseFloat($("#fPrice").value);
var stock = parseInt($("#fStock").value, 10);
var ok = true;
if (!title) { err("Title", "Title is required."); ok = false; }
if (isNaN(price) || price < 0) { err("Price", "Enter a valid price."); ok = false; }
if (isNaN(stock) || stock < 0) { err("Stock", "Enter inventory ≥ 0."); ok = false; }
if (!ok) return;
var sku = $("#fSku").value.trim() || makeSku(title);
var status = $("#fStatus").value;
var variants = state.formVariants.slice();
if (state.editingId) {
var p = products.find(function (x) { return x.id === state.editingId; });
p.title = title; p.price = price; p.stock = stock; p.sku = sku; p.status = status; p.variants = variants;
toast("Saved changes to “" + title + "”", "ok");
} else {
products.unshift({
id: nextId++, title: title, sku: sku, price: price, stock: stock,
status: status, emoji: "📦", variants: variants
});
toast("Product “" + title + "” created", "ok");
}
closeDrawer();
renderAll();
});
/* ---------- Init ---------- */
renderAll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shop — Product Manager</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>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" aria-label="Admin navigation">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◈</span>
<span class="brand-name">Northwind</span>
</div>
<nav class="nav">
<a href="#" class="nav-item"><span aria-hidden="true">▦</span> Dashboard</a>
<a href="#" class="nav-item is-active" aria-current="page"><span aria-hidden="true">▤</span> Products</a>
<a href="#" class="nav-item"><span aria-hidden="true">▥</span> Orders</a>
<a href="#" class="nav-item"><span aria-hidden="true">▧</span> Customers</a>
<a href="#" class="nav-item"><span aria-hidden="true">▨</span> Analytics</a>
</nav>
<div class="nav-foot">
<div class="store-chip">
<span class="store-dot" aria-hidden="true"></span>
<div>
<strong>Northwind Goods</strong>
<small>store.northwind.shop</small>
</div>
</div>
</div>
</aside>
<!-- Main -->
<main class="main" aria-labelledby="page-title">
<header class="topbar">
<div>
<h1 id="page-title">Products</h1>
<p class="subtitle" id="catalog-count">Loading catalog…</p>
</div>
<div class="topbar-actions">
<button type="button" class="btn btn-ghost" id="exportBtn">Export CSV</button>
<button type="button" class="btn btn-primary" id="addBtn">+ Add product</button>
</div>
</header>
<!-- Stat cards -->
<section class="stats" aria-label="Catalog summary">
<div class="stat">
<span class="stat-label">Total products</span>
<strong class="stat-value" id="stat-total">—</strong>
</div>
<div class="stat">
<span class="stat-label">Published</span>
<strong class="stat-value stat-ok" id="stat-published">—</strong>
</div>
<div class="stat">
<span class="stat-label">Out of stock</span>
<strong class="stat-value stat-sale" id="stat-oos">—</strong>
</div>
<div class="stat">
<span class="stat-label">Inventory value</span>
<strong class="stat-value" id="stat-value">—</strong>
</div>
</section>
<!-- Toolbar -->
<section class="toolbar" aria-label="Filter products">
<div class="search">
<span class="search-ico" aria-hidden="true">⌕</span>
<input type="search" id="search" placeholder="Search by name or SKU…" aria-label="Search products" autocomplete="off" />
</div>
<div class="filters" role="tablist" aria-label="Filter by status">
<button type="button" class="chip is-active" role="tab" aria-selected="true" data-status="all">All</button>
<button type="button" class="chip" role="tab" aria-selected="false" data-status="published">Published</button>
<button type="button" class="chip" role="tab" aria-selected="false" data-status="draft">Draft</button>
<button type="button" class="chip" role="tab" aria-selected="false" data-status="archived">Archived</button>
</div>
</section>
<!-- Bulk bar -->
<div class="bulkbar" id="bulkbar" hidden role="region" aria-label="Bulk actions">
<span class="bulk-count"><strong id="bulk-n">0</strong> selected</span>
<div class="bulk-actions">
<button type="button" class="btn btn-ghost" data-bulk="publish">Publish</button>
<button type="button" class="btn btn-ghost" data-bulk="archive">Archive</button>
<button type="button" class="btn btn-danger" data-bulk="delete">Delete</button>
</div>
<button type="button" class="bulk-clear" id="bulkClear" aria-label="Clear selection">✕</button>
</div>
<!-- Table -->
<section class="table-wrap" aria-label="Products table">
<table class="ptable">
<thead>
<tr>
<th class="col-check">
<input type="checkbox" id="checkAll" aria-label="Select all rows" />
</th>
<th class="col-prod"><button type="button" class="sort" data-sort="title">Product <span class="sort-ind" aria-hidden="true"></span></button></th>
<th class="col-sku"><button type="button" class="sort" data-sort="sku">SKU <span class="sort-ind" aria-hidden="true"></span></button></th>
<th class="col-price"><button type="button" class="sort" data-sort="price">Price <span class="sort-ind" aria-hidden="true"></span></button></th>
<th class="col-stock"><button type="button" class="sort" data-sort="stock">Stock <span class="sort-ind" aria-hidden="true"></span></button></th>
<th class="col-status">Status</th>
<th class="col-act"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody id="rows"><!-- rows injected --></tbody>
</table>
<div class="empty" id="empty" hidden>
<span class="empty-ico" aria-hidden="true">⌕</span>
<p>No products match your filters.</p>
<button type="button" class="btn btn-ghost" id="resetFilters">Clear filters</button>
</div>
</section>
<!-- Pagination -->
<footer class="pager" aria-label="Pagination">
<span class="pager-info" id="pagerInfo">—</span>
<div class="pager-ctrls">
<button type="button" class="btn btn-ghost" id="prevPage">‹ Prev</button>
<span class="pager-pages" id="pagerPages"></span>
<button type="button" class="btn btn-ghost" id="nextPage">Next ›</button>
</div>
</footer>
</main>
</div>
<!-- Drawer -->
<div class="scrim" id="scrim" hidden></div>
<aside class="drawer" id="drawer" aria-hidden="true" aria-labelledby="drawerTitle" role="dialog" aria-modal="true">
<header class="drawer-head">
<h2 id="drawerTitle">Add product</h2>
<button type="button" class="drawer-close" id="drawerClose" aria-label="Close drawer">✕</button>
</header>
<form class="drawer-body" id="prodForm" novalidate>
<div class="d-preview" id="dPreview" aria-hidden="true"></div>
<label class="field">
<span class="field-label">Product title</span>
<input type="text" name="title" id="fTitle" required placeholder="e.g. Cedar Pour-Over Kettle" />
<span class="field-err" id="errTitle"></span>
</label>
<div class="field-row">
<label class="field">
<span class="field-label">Price (USD)</span>
<div class="input-prefix">
<span aria-hidden="true">$</span>
<input type="number" name="price" id="fPrice" min="0" step="0.01" required placeholder="0.00" inputmode="decimal" />
</div>
<span class="field-err" id="errPrice"></span>
</label>
<label class="field">
<span class="field-label">Inventory</span>
<input type="number" name="stock" id="fStock" min="0" step="1" required placeholder="0" inputmode="numeric" />
<span class="field-err" id="errStock"></span>
</label>
</div>
<label class="field">
<span class="field-label">SKU</span>
<input type="text" name="sku" id="fSku" placeholder="Auto-generated if blank" autocomplete="off" />
</label>
<fieldset class="field">
<legend class="field-label">Variants</legend>
<div class="variants" id="variants"></div>
<div class="variant-add">
<input type="text" id="variantInput" placeholder="Add a variant (e.g. Large)" aria-label="New variant" autocomplete="off" />
<button type="button" class="btn btn-ghost" id="variantAdd">Add</button>
</div>
</fieldset>
<label class="field">
<span class="field-label">Status</span>
<select name="status" id="fStatus">
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
</label>
</form>
<footer class="drawer-foot">
<button type="button" class="btn btn-ghost" id="drawerCancel">Cancel</button>
<button type="submit" form="prodForm" class="btn btn-primary" id="drawerSave">Save product</button>
</footer>
</aside>
<div class="toast-host" id="toasts" aria-live="polite" aria-atomic="false"></div>
<script src="script.js"></script>
</body>
</html>Product Manager
A complete storefront admin for managing a catalog. The screen pairs a fixed sidebar and a live summary strip (total products, published count, out-of-stock, and inventory value) with a sortable products data-table. Each row shows a gradient product thumbnail, title, variant count, SKU, formatted price, an inline-editable stock field, a status badge, and quick edit/delete actions. A search box matches on title or SKU, status chips filter the list, and clicking any column header toggles ascending/descending sort.
Selecting rows reveals a sticky bulk-action bar where you can publish, archive, or delete many products at once; a header checkbox selects the whole current page. Stock can be edited directly in the cell, instantly updating the summary stats. The “Add product” button and per-row edit pencil open a slide-in drawer for full CRUD — title, price, inventory, SKU (auto-generated if blank), chip-based variants, and status — with inline validation, a focus trap, and Escape to close.
Everything runs on vanilla JavaScript with no dependencies: filter, search, sort, selection, bulk operations, inline edits, pagination, and drawer create/edit all mutate a single in-memory dataset and re-render. Toasts confirm each action so the CRUD flow feels real.
Illustrative storefront UI only — fictional products, prices, and reviews. No real checkout.