Shop — Discounts / Promo Codes
A back-office discounts manager for a storefront. A table lists each promo code with its type chip (percent, fixed amount or free shipping), value, a live usage progress bar against its limit, schedule window and a derived status of Active, Scheduled, Paused or Expired. Status filter tabs and live search narrow the view. Every row offers an activate toggle, copy-to-clipboard, edit and delete, and a slide-out editor with a code generator, segmented type control, validation and a live preview creates or updates codes in place.
MCP
Code
:root {
--bg: #f5f6f9;
--surface: #ffffff;
--ink: #16181d;
--muted: #6b7280;
--faint: #9aa1ad;
--brand: #3457ff;
--brand-d: #2742d6;
--brand-soft: #eaeefe;
--sale: #e0245e;
--ok: #1f9d55;
--ok-soft: #e4f6ec;
--warn: #b7791f;
--warn-soft: #fbf2e0;
--line: rgba(16, 18, 29, .1);
--line-2: rgba(16, 18, 29, .06);
--shadow: 0 1px 2px rgba(16, 18, 29, .05), 0 10px 30px -12px rgba(16, 18, 29, .18);
--radius: 14px;
--radius-sm: 10px;
}
* { 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;
}
h1, h2, h3 { margin: 0; line-height: 1.25; letter-spacing: -.01em; }
button { font: inherit; cursor: pointer; }
input { font: inherit; color: inherit; }
.vh {
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: 2.5px solid var(--brand);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Layout shell ---------- */
.app {
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
}
.sidebar {
background: var(--surface);
border-right: 1px solid var(--line);
padding: 22px 16px;
display: flex;
flex-direction: column;
gap: 8px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex; align-items: center; gap: 10px;
padding: 4px 8px 18px;
font-weight: 800; font-size: 1.05rem;
}
.brand-mark {
display: grid; place-items: center;
width: 30px; height: 30px;
background: linear-gradient(135deg, var(--brand), #6d8bff);
color: #fff; border-radius: 9px; font-size: .95rem;
}
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex; align-items: center; gap: 11px;
padding: 9px 12px;
border-radius: var(--radius-sm);
color: var(--muted);
text-decoration: none;
font-weight: 500; font-size: .92rem;
transition: background .15s, color .15s;
}
.nav-item span { width: 18px; text-align: center; opacity: .8; }
.nav-item:hover { background: var(--line-2); color: var(--ink); }
.nav-item.is-active { background: var(--brand-soft); color: var(--brand-d); font-weight: 600; }
.nav-item.is-active span { opacity: 1; }
.side-foot {
margin-top: auto;
display: flex; align-items: center; gap: 11px;
padding: 12px 10px;
border-top: 1px solid var(--line);
}
.avatar {
display: grid; place-items: center;
width: 36px; height: 36px; border-radius: 50%;
background: linear-gradient(135deg, #ffd27a, #ff8e8e);
color: #5b3a00; font-weight: 700; font-size: .8rem;
}
.side-foot-meta { display: flex; flex-direction: column; line-height: 1.3; }
.side-foot-meta strong { font-size: .88rem; }
.side-foot-meta span { font-size: .76rem; color: var(--faint); }
.main { padding: 26px clamp(16px, 4vw, 38px) 56px; min-width: 0; }
.topbar {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap;
margin-bottom: 22px;
}
.topbar h1 { font-size: clamp(1.35rem, 3vw, 1.7rem); font-weight: 800; }
.sub { margin: 4px 0 0; color: var(--muted); font-size: .92rem; }
/* ---------- Buttons ---------- */
.btn {
display: inline-flex; align-items: center; gap: 7px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 10px 16px;
font-weight: 600; font-size: .9rem;
transition: background .15s, border-color .15s, transform .05s, box-shadow .15s;
white-space: nowrap;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--brand); color: #fff;
box-shadow: 0 1px 0 rgba(16,18,29,.04), 0 8px 18px -10px var(--brand);
}
.btn-primary:hover { background: var(--brand-d); }
.btn-ghost { background: var(--surface); color: var(--ink); border-color: var(--line); }
.btn-ghost:hover { background: var(--line-2); }
.link {
background: none; border: none; color: var(--brand-d);
font-weight: 600; padding: 0; text-decoration: underline;
}
/* ---------- Stats ---------- */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 22px;
}
.stat {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 18px;
display: flex; flex-direction: column; gap: 4px;
box-shadow: var(--shadow);
}
.stat-label { color: var(--muted); font-size: .8rem; font-weight: 500; }
.stat-value { font-size: 1.55rem; font-weight: 800; letter-spacing: -.02em; }
.stat-trend { font-size: .76rem; color: var(--faint); }
.stat-trend.up { color: var(--ok); font-weight: 600; }
/* ---------- Toolbar ---------- */
.toolbar {
display: flex; align-items: center; justify-content: space-between;
gap: 14px; flex-wrap: wrap;
margin-bottom: 14px;
}
.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
.tab {
display: inline-flex; align-items: center; gap: 7px;
background: none; border: 1px solid transparent;
border-radius: 999px; padding: 7px 14px;
color: var(--muted); font-weight: 600; font-size: .86rem;
transition: background .15s, color .15s;
}
.tab:hover { background: var(--line-2); color: var(--ink); }
.tab.is-active { background: var(--ink); color: #fff; }
.tab-count {
font-size: .72rem; font-weight: 700;
background: rgba(16,18,29,.08); color: inherit;
padding: 1px 7px; border-radius: 999px; line-height: 1.5;
}
.tab.is-active .tab-count { background: rgba(255,255,255,.22); }
.search { position: relative; }
.search-ico {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--faint); font-size: 1.05rem; pointer-events: none;
}
.search input {
width: 260px; max-width: 60vw;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius-sm);
padding: 9px 12px 9px 34px;
}
.search input:focus { border-color: var(--brand); }
/* ---------- Card / Table ---------- */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.table-card { overflow-x: auto; }
.codes { width: 100%; border-collapse: collapse; min-width: 760px; }
.codes thead th {
text-align: left;
font-size: .74rem; font-weight: 700; letter-spacing: .04em;
text-transform: uppercase; color: var(--faint);
padding: 13px 16px;
border-bottom: 1px solid var(--line);
background: #fafbfd;
position: sticky; top: 0; z-index: 1;
}
.codes th.num { text-align: right; }
.usage-col { width: 200px; }
.actions-col { width: 132px; }
.codes tbody td {
padding: 14px 16px;
border-bottom: 1px solid var(--line-2);
vertical-align: middle;
font-size: .9rem;
}
.codes tbody tr:last-child td { border-bottom: none; }
.codes tbody tr { transition: background .12s; }
.codes tbody tr:hover { background: #fafbff; }
.row-dim td { opacity: .62; }
.code-cell { display: flex; align-items: center; gap: 9px; }
.code-chip {
font-family: "Inter", monospace;
font-weight: 700; letter-spacing: .03em;
background: var(--brand-soft); color: var(--brand-d);
padding: 4px 10px; border-radius: 7px; font-size: .84rem;
}
.copy-btn {
border: none; background: none; color: var(--faint);
font-size: 1rem; line-height: 1; padding: 4px; border-radius: 6px;
transition: color .15s, background .15s;
}
.copy-btn:hover { color: var(--brand-d); background: var(--brand-soft); }
.type-cell { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); }
.type-ico {
display: grid; place-items: center; width: 22px; height: 22px;
border-radius: 6px; font-size: .78rem; font-weight: 800;
background: var(--line-2); color: var(--ink);
}
.type-cell.t-percent .type-ico { background: #e9eefe; color: var(--brand-d); }
.type-cell.t-fixed .type-ico { background: var(--ok-soft); color: var(--ok); }
.type-cell.t-shipping .type-ico { background: var(--warn-soft); color: var(--warn); }
.val { font-weight: 700; }
.val-sub { display: block; font-size: .74rem; color: var(--faint); font-weight: 500; }
td.num { text-align: right; }
.usage { display: flex; flex-direction: column; gap: 5px; min-width: 150px; }
.usage-meta { display: flex; justify-content: space-between; font-size: .76rem; color: var(--muted); }
.usage-meta b { color: var(--ink); font-weight: 700; }
.bar { height: 6px; border-radius: 999px; background: var(--line-2); overflow: hidden; }
.bar > span {
display: block; height: 100%;
background: linear-gradient(90deg, var(--brand), #6d8bff);
border-radius: 999px; transition: width .4s ease;
}
.bar.is-high > span { background: linear-gradient(90deg, var(--sale), #ff6b94); }
.usage.unlimited .bar > span { background: repeating-linear-gradient(45deg, var(--line), var(--line) 4px, transparent 4px, transparent 8px); width: 100% !important; }
.sched { font-size: .82rem; color: var(--muted); line-height: 1.4; }
.sched b { color: var(--ink); font-weight: 600; }
.status {
display: inline-flex; align-items: center; gap: 6px;
font-size: .78rem; font-weight: 700;
padding: 4px 10px; border-radius: 999px;
}
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.status.s-active { background: var(--ok-soft); color: var(--ok); }
.status.s-paused { background: var(--line-2); color: var(--muted); }
.status.s-scheduled { background: var(--brand-soft); color: var(--brand-d); }
.status.s-expired { background: #fdeaef; color: var(--sale); }
.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; }
/* Toggle switch (in row + form) */
.switch { position: relative; display: inline-flex; align-items: center; }
.switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
.switch .track {
width: 38px; height: 22px; border-radius: 999px;
background: var(--line); transition: background .18s;
display: inline-flex; align-items: center; padding: 2px;
}
.switch .thumb {
width: 18px; height: 18px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3);
transition: transform .18s;
}
.switch input:checked + .track { background: var(--ok); }
.switch input:checked + .track .thumb { transform: translateX(16px); }
.switch input:focus-visible + .track { outline: 2.5px solid var(--brand); outline-offset: 2px; }
.switch input:disabled + .track { opacity: .5; }
.icon-btn {
border: 1px solid var(--line); background: var(--surface);
border-radius: 8px; width: 32px; height: 32px;
display: grid; place-items: center; color: var(--muted);
transition: background .15s, color .15s, border-color .15s;
}
.icon-btn:hover { background: var(--line-2); color: var(--ink); }
.icon-btn.danger:hover { background: #fdeaef; color: var(--sale); border-color: #f7c6d4; }
.empty { padding: 32px; text-align: center; color: var(--muted); font-size: .92rem; }
/* ---------- Drawer ---------- */
.drawer-scrim {
position: fixed; inset: 0;
background: rgba(16,18,29,.42);
backdrop-filter: blur(2px);
z-index: 40; opacity: 0; animation: fade .2s forwards;
}
@keyframes fade { to { opacity: 1; } }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(440px, 100%);
background: var(--surface);
z-index: 50;
display: flex; flex-direction: column;
box-shadow: -18px 0 50px -20px rgba(16,18,29,.4);
transform: translateX(100%);
transition: transform .26s cubic-bezier(.4,.0,.2,1);
}
.drawer.is-open { transform: translateX(0); }
.drawer[aria-hidden="true"] { pointer-events: none; }
.drawer.is-open { pointer-events: auto; }
.drawer-head {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 22px; border-bottom: 1px solid var(--line);
}
.drawer-head h2 { font-size: 1.15rem; font-weight: 700; }
.drawer-body { padding: 22px; overflow-y: auto; display: flex; flex-direction: column; gap: 18px; flex: 1; }
.field { display: flex; flex-direction: column; gap: 6px; border: 0; margin: 0; padding: 0; min-width: 0; }
.field > label, .field > legend { font-size: .82rem; font-weight: 600; color: var(--ink); padding: 0; }
.hint { font-size: .76rem; color: var(--faint); margin: 0; }
.err { font-size: .78rem; color: var(--sale); font-weight: 600; margin: 0; }
.field input[type="text"],
.field input[type="number"],
.field input[type="date"],
.field input[type="search"] {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 10px 12px;
background: var(--surface);
width: 100%;
}
.field input:focus { border-color: var(--brand); }
.field input[aria-invalid="true"] { border-color: var(--sale); background: #fff6f8; }
.code-row { display: flex; gap: 8px; }
.code-row input { flex: 1; text-transform: uppercase; font-weight: 700; letter-spacing: .04em; }
.code-row .btn { flex-shrink: 0; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.seg {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
background: var(--line-2); padding: 4px; border-radius: var(--radius-sm);
}
.seg-opt { position: relative; }
.seg-opt input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.seg-opt span {
display: block; text-align: center;
padding: 8px 6px; border-radius: 7px;
font-size: .85rem; font-weight: 600; color: var(--muted);
transition: background .15s, color .15s, box-shadow .15s;
}
.seg-opt input:checked + span { background: var(--surface); color: var(--brand-d); box-shadow: var(--shadow); }
.seg-opt input:focus-visible + span { outline: 2.5px solid var(--brand); outline-offset: 2px; }
.prefixed { position: relative; }
.prefixed::before {
content: attr(data-prefix);
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: var(--faint); font-weight: 600; pointer-events: none;
}
.prefixed input { padding-left: 26px !important; }
.switch-field { gap: 10px; }
.switch-label { font-size: .82rem; font-weight: 600; }
.preview {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
background: linear-gradient(135deg, #f3f6ff, #fdfdff);
border: 1px dashed var(--brand); border-radius: var(--radius-sm);
padding: 14px 16px;
}
.preview-tag {
font-size: .64rem; font-weight: 800; letter-spacing: .08em;
color: var(--brand-d); background: #fff; border: 1px solid var(--line);
padding: 2px 7px; border-radius: 6px;
}
.preview-code { font-weight: 800; letter-spacing: .04em; font-size: .95rem; color: var(--ink); }
.preview-desc { color: var(--muted); font-size: .86rem; }
.drawer-foot {
display: flex; justify-content: flex-end; gap: 10px;
padding-top: 6px; margin-top: auto;
}
.drawer-foot .btn { flex: 1; justify-content: center; }
/* ---------- Toast ---------- */
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translate(-50%, 18px);
background: var(--ink); color: #fff;
padding: 12px 18px; border-radius: 999px;
font-size: .88rem; font-weight: 600;
box-shadow: 0 14px 36px -10px rgba(16,18,29,.5);
z-index: 60; opacity: 0; pointer-events: none;
transition: opacity .22s, transform .22s;
display: flex; align-items: center; gap: 8px;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.toast .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
/* ---------- Responsive ---------- */
@media (max-width: 1040px) {
.stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 880px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: static; height: auto; flex-direction: row; align-items: center;
overflow-x: auto; gap: 14px; padding: 12px 16px;
}
.brand { padding: 0; }
.nav { flex-direction: row; }
.side-foot { display: none; }
}
@media (max-width: 560px) {
.stats { grid-template-columns: 1fr; }
.toolbar { align-items: stretch; }
.search input { width: 100%; max-width: none; }
.grid-2 { grid-template-columns: 1fr; }
.topbar .btn-primary { width: 100%; justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(() => {
"use strict";
/* ---------- Seed data ---------- */
let codes = [
{ id: "c1", code: "SUMMER20", type: "percent", value: 20, min: 40, used: 312, limit: 500, start: "2026-06-01", end: "2026-08-31", paused: false },
{ id: "c2", code: "FREESHIP", type: "shipping", value: 0, min: 35, used: 1840, limit: 0, start: "2026-01-15", end: "", paused: false },
{ id: "c3", code: "WELCOME10", type: "fixed", value: 10, min: 0, used: 96, limit: 1000, start: "2026-05-10", end: "2026-12-31", paused: false },
{ id: "c4", code: "FLASH50", type: "percent", value: 50, min: 80, used: 200, limit: 200, start: "2026-06-20", end: "2026-06-22", paused: false },
{ id: "c5", code: "SPRING15", type: "percent", value: 15, min: 0, used: 540, limit: 600, start: "2026-03-01", end: "2026-05-31", paused: false },
{ id: "c6", code: "VIPNIGHT", type: "fixed", value: 25, min: 120, used: 0, limit: 300, start: "2026-09-01", end: "2026-09-02", paused: true }
];
const TODAY = new Date("2026-06-14");
let activeFilter = "all";
let query = "";
/* ---------- DOM refs ---------- */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const rowsEl = $("#rows");
const emptyEl = $("#empty");
const searchEl = $("#search");
const drawer = $("#drawer");
const scrim = $("#scrim");
const form = $("#codeForm");
const toastEl = $("#toast");
/* ---------- Helpers ---------- */
const money = (n) => "$" + Number(n).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
function fmtDate(d) {
if (!d) return null;
const dt = new Date(d + "T00:00:00");
return dt.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
// Derived lifecycle status from schedule + paused flag.
function statusOf(c) {
if (c.paused) return "paused";
const start = c.start ? new Date(c.start + "T00:00:00") : null;
const end = c.end ? new Date(c.end + "T23:59:59") : null;
if (end && end < TODAY) return "expired";
if (start && start > TODAY) return "scheduled";
if (c.limit > 0 && c.used >= c.limit) return "expired";
return "active";
}
const STATUS_LABEL = { active: "Active", paused: "Paused", scheduled: "Scheduled", expired: "Expired" };
const TYPE_META = {
percent: { ico: "%", label: "Percent off" },
fixed: { ico: "$", label: "Fixed amount" },
shipping: { ico: "⛟", label: "Free shipping" }
};
function valueText(c) {
if (c.type === "percent") return { main: c.value + "% off", sub: c.min ? "min " + money(c.min) : "no minimum" };
if (c.type === "fixed") return { main: money(c.value) + " off", sub: c.min ? "min " + money(c.min) : "no minimum" };
return { main: "Free shipping", sub: c.min ? "min " + money(c.min) : "no minimum" };
}
function describe(c) {
if (c.type === "percent") return `${c.value}% off orders`;
if (c.type === "fixed") return `${money(c.value)} off orders`;
return "Free shipping on orders";
}
function toast(msg, ok = true) {
toastEl.innerHTML = `<span class="dot" style="background:${ok ? "var(--ok)" : "var(--sale)"}"></span>${esc(msg)}`;
toastEl.hidden = false;
requestAnimationFrame(() => toastEl.classList.add("show"));
clearTimeout(toast._t);
toast._t = setTimeout(() => {
toastEl.classList.remove("show");
setTimeout(() => { toastEl.hidden = true; }, 250);
}, 2400);
}
/* ---------- Rendering ---------- */
function counts() {
const c = { all: codes.length, active: 0, scheduled: 0, expired: 0, paused: 0 };
codes.forEach((x) => { c[statusOf(x)]++; });
return c;
}
function updateStats() {
const c = counts();
const totalUsed = codes.reduce((s, x) => s + x.used, 0);
// Rough est. discount: percent ~ value% of an avg $60 order; fixed ~ value; shipping ~ $6.
const spend = codes.reduce((s, x) => {
if (x.type === "percent") return s + x.used * (x.value / 100) * 60;
if (x.type === "fixed") return s + x.used * x.value;
return s + x.used * 6;
}, 0);
const limited = codes.filter((x) => x.limit > 0);
const rate = limited.length
? Math.round(limited.reduce((s, x) => s + Math.min(1, x.used / x.limit), 0) / limited.length * 100)
: 0;
$("#statActive").textContent = c.active;
$("#statRedeem").textContent = totalUsed.toLocaleString("en-US");
$("#statSpend").textContent = money(Math.round(spend));
$("#statRate").textContent = rate + "%";
Object.entries(c).forEach(([k, v]) => {
const el = $(`.tab-count[data-count="${k}"]`);
if (el) el.textContent = v;
});
}
function rowHTML(c) {
const st = statusOf(c);
const tm = TYPE_META[c.type];
const vt = valueText(c);
const unlimited = c.limit <= 0;
const pct = unlimited ? 100 : Math.min(100, Math.round((c.used / c.limit) * 100));
const high = !unlimited && pct >= 85;
const schedStart = fmtDate(c.start) || "—";
const schedEnd = c.end ? fmtDate(c.end) : "No end date";
const usageHTML = unlimited
? `<div class="usage unlimited">
<div class="usage-meta"><span><b>${c.used.toLocaleString("en-US")}</b> used</span><span>Unlimited</span></div>
<div class="bar"><span></span></div>
</div>`
: `<div class="usage">
<div class="usage-meta"><span><b>${c.used.toLocaleString("en-US")}</b> / ${c.limit.toLocaleString("en-US")}</span><span>${pct}%</span></div>
<div class="bar${high ? " is-high" : ""}"><span style="width:${pct}%"></span></div>
</div>`;
// Active toggle only meaningful while not expired.
const canToggle = st !== "expired";
const isOn = !c.paused;
return `<tr data-id="${c.id}" class="${st === "expired" || st === "paused" ? "row-dim" : ""}">
<td>
<div class="code-cell">
<span class="code-chip">${esc(c.code)}</span>
<button class="copy-btn" type="button" data-act="copy" aria-label="Copy code ${esc(c.code)}" title="Copy">⧉</button>
</div>
</td>
<td>
<span class="type-cell t-${c.type}"><span class="type-ico" aria-hidden="true">${tm.ico}</span>${tm.label}</span>
</td>
<td class="num"><span class="val">${vt.main}</span><span class="val-sub">${vt.sub}</span></td>
<td>${usageHTML}</td>
<td><div class="sched"><b>${schedStart}</b><br>→ ${schedEnd}</div></td>
<td><span class="status s-${st}">${STATUS_LABEL[st]}</span></td>
<td>
<div class="row-actions">
<label class="switch" title="${canToggle ? (isOn ? "Deactivate" : "Activate") : "Expired"}">
<input type="checkbox" data-act="toggle" ${isOn ? "checked" : ""} ${canToggle ? "" : "disabled"} aria-label="Activate ${esc(c.code)}">
<span class="track" aria-hidden="true"><span class="thumb"></span></span>
</label>
<button class="icon-btn" type="button" data-act="edit" aria-label="Edit ${esc(c.code)}" title="Edit">✎</button>
<button class="icon-btn danger" type="button" data-act="delete" aria-label="Delete ${esc(c.code)}" title="Delete">🗑</button>
</div>
</td>
</tr>`;
}
function render() {
const q = query.trim().toLowerCase();
const list = codes.filter((c) => {
const matchFilter = activeFilter === "all" || statusOf(c) === activeFilter;
const matchQuery = !q || c.code.toLowerCase().includes(q);
return matchFilter && matchQuery;
});
rowsEl.innerHTML = list.map(rowHTML).join("");
emptyEl.hidden = list.length > 0;
updateStats();
}
/* ---------- Row interactions (delegated) ---------- */
rowsEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const tr = btn.closest("tr");
const id = tr && tr.dataset.id;
const c = codes.find((x) => x.id === id);
if (!c) return;
const act = btn.dataset.act;
if (act === "copy") {
copyText(c.code);
toast(`Copied “${c.code}”`);
} else if (act === "edit") {
openDrawer(c);
} else if (act === "delete") {
codes = codes.filter((x) => x.id !== id);
render();
toast(`Deleted “${c.code}”`, false);
}
});
rowsEl.addEventListener("change", (e) => {
const input = e.target.closest('input[data-act="toggle"]');
if (!input) return;
const tr = input.closest("tr");
const c = codes.find((x) => x.id === tr.dataset.id);
if (!c) return;
c.paused = !input.checked;
render();
toast(c.paused ? `“${c.code}” paused` : `“${c.code}” is now live`, !c.paused);
});
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } catch (_) {}
document.body.removeChild(ta);
}
/* ---------- Filters + search ---------- */
$$(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
$$(".tab").forEach((t) => { t.classList.remove("is-active"); t.setAttribute("aria-selected", "false"); });
tab.classList.add("is-active"); tab.setAttribute("aria-selected", "true");
activeFilter = tab.dataset.filter;
render();
});
});
searchEl.addEventListener("input", () => { query = searchEl.value; render(); });
$("#clearFilters").addEventListener("click", () => {
query = ""; searchEl.value = "";
activeFilter = "all";
$$(".tab").forEach((t) => { t.classList.remove("is-active"); t.setAttribute("aria-selected", "false"); });
const allTab = $('.tab[data-filter="all"]');
allTab.classList.add("is-active"); allTab.setAttribute("aria-selected", "true");
render();
});
/* ---------- Drawer / form ---------- */
let lastFocused = null;
function openDrawer(c) {
lastFocused = document.activeElement;
form.reset();
clearErrors();
const isEdit = !!c;
$("#editId").value = isEdit ? c.id : "";
$("#drawer-title").textContent = isEdit ? "Edit promo code" : "New promo code";
$("#saveBtn").textContent = isEdit ? "Save changes" : "Create code";
if (isEdit) {
$("#fCode").value = c.code;
form.querySelector(`input[name="type"][value="${c.type}"]`).checked = true;
$("#fValue").value = c.value;
$("#fMin").value = c.min;
$("#fLimit").value = c.limit;
$("#fStart").value = c.start || "";
$("#fEnd").value = c.end || "";
$("#fActive").checked = !c.paused;
} else {
$("#fStart").value = "2026-06-14";
}
syncTypeUI();
updatePreview();
scrim.hidden = false;
drawer.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => drawer.classList.add("is-open"));
setTimeout(() => $("#fCode").focus(), 60);
document.addEventListener("keydown", onKeydown);
}
function closeDrawer() {
drawer.classList.remove("is-open");
drawer.setAttribute("aria-hidden", "true");
document.removeEventListener("keydown", onKeydown);
setTimeout(() => { scrim.hidden = true; }, 260);
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
function onKeydown(e) {
if (e.key === "Escape") { closeDrawer(); return; }
if (e.key === "Tab") trapFocus(e);
}
function trapFocus(e) {
const focusables = $$('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', drawer)
.filter((el) => !el.disabled && el.offsetParent !== null);
if (!focusables.length) return;
const first = focusables[0], last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
$("#newCodeBtn").addEventListener("click", () => openDrawer(null));
$("#closeDrawer").addEventListener("click", closeDrawer);
$("#cancelBtn").addEventListener("click", closeDrawer);
scrim.addEventListener("click", closeDrawer);
/* ---------- Code generator ---------- */
const WORDS = ["SAVE", "DEAL", "SHOP", "MEGA", "VIP", "BONUS", "EXTRA", "TREAT", "GIFT", "JOY"];
$("#genCode").addEventListener("click", () => {
const w = WORDS[Math.floor(Math.random() * WORDS.length)];
const n = Math.floor(Math.random() * 90 + 10);
$("#fCode").value = w + n;
clearError("fCode", "codeErr");
updatePreview();
});
/* ---------- Type-dependent UI ---------- */
function currentType() {
const r = form.querySelector('input[name="type"]:checked');
return r ? r.value : "percent";
}
function syncTypeUI() {
const t = currentType();
const valueField = $("#valueField");
const valueInput = $("#fValue");
const valueLabel = $("#valueLabel");
const wrap = valueInput.closest(".prefixed");
if (t === "shipping") {
valueField.style.display = "none";
valueInput.removeAttribute("required");
} else {
valueField.style.display = "";
if (t === "percent") {
valueLabel.textContent = "Percentage off";
wrap.dataset.prefix = "%";
valueInput.max = "100";
if (Number(valueInput.value) > 100) valueInput.value = "100";
} else {
valueLabel.textContent = "Amount off";
wrap.dataset.prefix = "$";
valueInput.removeAttribute("max");
}
}
}
form.querySelectorAll('input[name="type"]').forEach((r) =>
r.addEventListener("change", () => { syncTypeUI(); updatePreview(); })
);
["#fCode", "#fValue"].forEach((sel) =>
$(sel).addEventListener("input", updatePreview)
);
function updatePreview() {
const code = ($("#fCode").value || "PROMO").toUpperCase();
const t = currentType();
const v = Number($("#fValue").value) || 0;
let desc;
if (t === "percent") desc = `${v}% off orders`;
else if (t === "fixed") desc = `${money(v)} off orders`;
else desc = "Free shipping on orders";
$("#previewCode").textContent = code;
$("#previewDesc").textContent = desc;
}
/* ---------- Validation ---------- */
function setError(inputId, errId, msg) {
const input = $("#" + inputId);
const err = $("#" + errId);
if (input) input.setAttribute("aria-invalid", "true");
if (err) { err.textContent = msg; err.hidden = false; }
}
function clearError(inputId, errId) {
const input = $("#" + inputId);
const err = $("#" + errId);
if (input) input.removeAttribute("aria-invalid");
if (err) { err.textContent = ""; err.hidden = true; }
}
function clearErrors() {
["fCode:codeErr", "fValue:valueErr", "fEnd:endErr"].forEach((p) => {
const [i, e] = p.split(":"); clearError(i, e);
});
}
form.addEventListener("submit", (e) => {
e.preventDefault();
clearErrors();
let ok = true;
const id = $("#editId").value;
const codeRaw = $("#fCode").value.trim().toUpperCase();
const type = currentType();
const value = Number($("#fValue").value);
const min = Math.max(0, Number($("#fMin").value) || 0);
const limit = Math.max(0, Number($("#fLimit").value) || 0);
const start = $("#fStart").value;
const end = $("#fEnd").value;
// Code: required, alphanumeric, unique.
if (!codeRaw) { setError("fCode", "codeErr", "Enter a code."); ok = false; }
else if (!/^[A-Z0-9]+$/.test(codeRaw)) { setError("fCode", "codeErr", "Use letters and numbers only."); ok = false; }
else if (codes.some((c) => c.code === codeRaw && c.id !== id)) { setError("fCode", "codeErr", "That code already exists."); ok = false; }
// Value: required + ranged unless free shipping.
if (type !== "shipping") {
if (!Number.isFinite(value) || value <= 0) { setError("fValue", "valueErr", "Enter a value above 0."); ok = false; }
else if (type === "percent" && value > 100) { setError("fValue", "valueErr", "Percentage can’t exceed 100."); ok = false; }
}
// Dates: end must be after start.
if (start && end && new Date(end) < new Date(start)) {
setError("fEnd", "endErr", "End date is before the start date."); ok = false;
}
if (!ok) return;
const record = {
code: codeRaw,
type,
value: type === "shipping" ? 0 : value,
min,
limit,
start,
end,
paused: !$("#fActive").checked
};
if (id) {
const c = codes.find((x) => x.id === id);
Object.assign(c, record);
render();
toast(`Saved “${codeRaw}”`);
} else {
codes.unshift(Object.assign({ id: "c" + Date.now(), used: 0 }, record));
// Show the new code regardless of current filter.
activeFilter = "all"; query = ""; searchEl.value = "";
$$(".tab").forEach((t) => { t.classList.remove("is-active"); t.setAttribute("aria-selected", "false"); });
const allTab = $('.tab[data-filter="all"]');
allTab.classList.add("is-active"); allTab.setAttribute("aria-selected", "true");
render();
toast(`Created “${codeRaw}”`);
}
closeDrawer();
});
/* ---------- Init ---------- */
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shop — Discounts / Promo Codes</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" aria-label="Sections">
<a href="#" class="nav-item"><span aria-hidden="true">▦</span> Dashboard</a>
<a href="#" class="nav-item"><span aria-hidden="true">▤</span> Products</a>
<a href="#" class="nav-item"><span aria-hidden="true">▥</span> Orders</a>
<a href="#" class="nav-item is-active" aria-current="page"><span aria-hidden="true">⛯</span> Discounts</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="side-foot">
<div class="avatar" aria-hidden="true">RK</div>
<div class="side-foot-meta">
<strong>Rosa Kline</strong>
<span>Store manager</span>
</div>
</div>
</aside>
<!-- Main -->
<main class="main" aria-labelledby="page-title">
<header class="topbar">
<div>
<h1 id="page-title">Discounts & promo codes</h1>
<p class="sub">Create, schedule and track storefront promotions.</p>
</div>
<button class="btn btn-primary" id="newCodeBtn" type="button">
<span aria-hidden="true">+</span> New code
</button>
</header>
<!-- Stat strip -->
<section class="stats" aria-label="Promotion summary">
<div class="stat">
<span class="stat-label">Active codes</span>
<strong class="stat-value" id="statActive">0</strong>
<span class="stat-trend up">running now</span>
</div>
<div class="stat">
<span class="stat-label">Total redemptions</span>
<strong class="stat-value" id="statRedeem">0</strong>
<span class="stat-trend">across all codes</span>
</div>
<div class="stat">
<span class="stat-label">Est. discount given</span>
<strong class="stat-value" id="statSpend">$0</strong>
<span class="stat-trend">this period</span>
</div>
<div class="stat">
<span class="stat-label">Avg. usage rate</span>
<strong class="stat-value" id="statRate">0%</strong>
<span class="stat-trend">of set limits</span>
</div>
</section>
<!-- Toolbar -->
<section class="toolbar" aria-label="Filter codes">
<div class="tabs" role="tablist" aria-label="Status filter">
<button class="tab is-active" role="tab" aria-selected="true" data-filter="all" type="button">All <span class="tab-count" data-count="all">0</span></button>
<button class="tab" role="tab" aria-selected="false" data-filter="active" type="button">Active <span class="tab-count" data-count="active">0</span></button>
<button class="tab" role="tab" aria-selected="false" data-filter="scheduled" type="button">Scheduled <span class="tab-count" data-count="scheduled">0</span></button>
<button class="tab" role="tab" aria-selected="false" data-filter="expired" type="button">Expired <span class="tab-count" data-count="expired">0</span></button>
<button class="tab" role="tab" aria-selected="false" data-filter="paused" type="button">Paused <span class="tab-count" data-count="paused">0</span></button>
</div>
<div class="search">
<span class="search-ico" aria-hidden="true">⌕</span>
<input id="search" type="search" placeholder="Search codes…" aria-label="Search promo codes" autocomplete="off" />
</div>
</section>
<!-- Table -->
<section class="card table-card" aria-label="Promo codes">
<table class="codes">
<thead>
<tr>
<th scope="col">Code</th>
<th scope="col">Type</th>
<th scope="col" class="num">Value</th>
<th scope="col" class="usage-col">Usage</th>
<th scope="col">Schedule</th>
<th scope="col">Status</th>
<th scope="col" class="actions-col"><span class="vh">Actions</span></th>
</tr>
</thead>
<tbody id="rows"><!-- rows injected by script.js --></tbody>
</table>
<p class="empty" id="empty" hidden>No codes match this view. <button class="link" id="clearFilters" type="button">Reset filters</button></p>
</section>
</main>
</div>
<!-- Editor drawer -->
<div class="drawer-scrim" id="scrim" hidden></div>
<aside class="drawer" id="drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="drawer-title" tabindex="-1">
<header class="drawer-head">
<h2 id="drawer-title">New promo code</h2>
<button class="icon-btn" id="closeDrawer" type="button" aria-label="Close editor">✕</button>
</header>
<form class="drawer-body" id="codeForm" novalidate>
<input type="hidden" id="editId" value="" />
<div class="field">
<label for="fCode">Code</label>
<div class="code-row">
<input id="fCode" name="code" type="text" inputmode="latin" autocomplete="off"
placeholder="e.g. SUMMER20" maxlength="24" aria-describedby="codeErr" />
<button class="btn btn-ghost" id="genCode" type="button">Generate</button>
</div>
<p class="hint">Letters and numbers only — shown to shoppers at checkout.</p>
<p class="err" id="codeErr" role="alert" hidden></p>
</div>
<fieldset class="field">
<legend>Discount type</legend>
<div class="seg" role="radiogroup" aria-label="Discount type">
<label class="seg-opt">
<input type="radio" name="type" value="percent" checked />
<span>% off</span>
</label>
<label class="seg-opt">
<input type="radio" name="type" value="fixed" />
<span>$ off</span>
</label>
<label class="seg-opt">
<input type="radio" name="type" value="shipping" />
<span>Free ship</span>
</label>
</div>
</fieldset>
<div class="grid-2">
<div class="field" id="valueField">
<label for="fValue" id="valueLabel">Percentage off</label>
<div class="prefixed" data-prefix="%">
<input id="fValue" name="value" type="number" min="0" max="100" step="1" value="20" aria-describedby="valueErr" />
</div>
<p class="err" id="valueErr" role="alert" hidden></p>
</div>
<div class="field">
<label for="fMin">Minimum spend</label>
<div class="prefixed money" data-prefix="$">
<input id="fMin" name="min" type="number" min="0" step="1" value="0" />
</div>
<p class="hint">0 = no minimum.</p>
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="fLimit">Usage limit</label>
<input id="fLimit" name="limit" type="number" min="0" step="1" value="500" aria-describedby="limitHint" />
<p class="hint" id="limitHint">Total redemptions allowed. 0 = unlimited.</p>
</div>
<div class="field">
<label for="fStart">Starts</label>
<input id="fStart" name="start" type="date" />
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="fEnd">Ends</label>
<input id="fEnd" name="end" type="date" aria-describedby="endErr" />
<p class="err" id="endErr" role="alert" hidden></p>
</div>
<div class="field switch-field">
<span class="switch-label">Active on save</span>
<label class="switch">
<input type="checkbox" id="fActive" checked />
<span class="track" aria-hidden="true"><span class="thumb"></span></span>
<span class="vh">Activate this code</span>
</label>
</div>
</div>
<div class="preview" aria-live="polite">
<span class="preview-tag" aria-hidden="true">PREVIEW</span>
<code class="preview-code" id="previewCode">SUMMER20</code>
<span class="preview-desc" id="previewDesc">20% off orders</span>
</div>
<footer class="drawer-foot">
<button class="btn btn-ghost" type="button" id="cancelBtn">Cancel</button>
<button class="btn btn-primary" type="submit" id="saveBtn">Create code</button>
</footer>
</form>
</aside>
<!-- Toast -->
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script src="script.js"></script>
</body>
</html>Discounts / Promo Codes
A promo-code console for the fictional Northwind storefront. The main table lists each code with a copyable code chip, a type cell (percentage off, fixed amount or free shipping), the value with its minimum-spend note, a usage progress bar against the redemption limit, the schedule window and a status pill. Status is derived from the schedule and the active toggle, so codes resolve to Active, Scheduled, Paused or Expired automatically. A summary strip totals active codes, redemptions, estimated discount given and average usage rate, and updates as you edit.
Filter tabs across the top — All, Active, Scheduled, Expired and Paused — narrow the table, each carrying a live count, while the search box matches code names as you type. Every row has an activate/deactivate switch, a one-tap copy button, plus edit and delete actions, and all of them update the table and summary in place with a confirming toast.
The New code button and per-row edit open a slide-out drawer with a focus trap. It includes a random code generator, a segmented percent / fixed / free-ship control that reshapes the value field, minimum spend, usage limit, start and end dates, an active-on-save switch and a live preview. Inline validation guards empty or duplicate codes, out-of-range values and end-before-start dates before a record is created or saved. Everything reads from one in-memory model and the layout collapses the sidebar and drawer gracefully down to 360px.
Illustrative storefront UI only — fictional products, prices, and reviews. No real checkout.