Rework storefront into modules and tokenised styles

Split the 100-line script into catalog, cart, format, dom and main modules.
Money is integer cents, the bag is capped and sanitised and syncs across
tabs, and the view (category, sort) lives in the URL. Templates escape every
interpolation.

Rewrite the minified stylesheet as cascade layers on design tokens. Fixes
heading tracking that collapsed word gaps, a 2.6:1 focus ring on the hero,
1.3:1 control borders, misaligned cart quantities, uneven product cards and
undersized touch targets. Adds a favicon and a resolvable dialog label.
This commit is contained in:
2026-11-07 21:18:38 -07:00
parent ddad4af7fd
commit bd89201e25
9 changed files with 1071 additions and 116 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#20392d"/><path d="M17 47V17h27v7H25v5h16v7H25v11z" fill="#fbfaf0"/><circle cx="46" cy="43" r="5" fill="#e2f596"/></svg>

After

Width:  |  Height:  |  Size: 224 B

+71 -14
View File
@@ -1,27 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="Fieldwork Supply, a fictional outdoor goods storefront and portfolio demo.">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fieldwork Supply — Equipment for getting out</title>
<link rel="stylesheet" href="style.css"><script defer src="shop.js"></script>
<meta name="description" content="Fieldwork Supply, a fictional outdoor goods storefront and portfolio demo.">
<meta name="theme-color" content="#20392d">
<meta property="og:type" content="website">
<meta property="og:title" content="Fieldwork Supply — Equipment for getting out">
<meta property="og:description" content="A fictional outdoor goods storefront and portfolio demo.">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
<script type="module" src="js/main.js"></script>
</head>
<body>
<a class="skip-link" href="#shop">Skip to the shop</a>
<div class="notice">FIELDWORK SUPPLY &nbsp; / &nbsp; A FICTIONAL STOREFRONT</div>
<header class="top"><a class="wordmark" href="#">Fieldwork<span>.</span></a><nav aria-label="Main"><a href="#shop">Shop</a><a href="#story">Our story</a></nav><button id="bag" class="bag" type="button">Bag <span id="count">0</span></button></header>
<header class="top">
<a class="wordmark" href="./" aria-label="Fieldwork, home">Fieldwork<span>.</span></a>
<nav aria-label="Main"><a href="#shop">Shop</a><a href="#story">Our story</a></nav>
<button id="bag" class="bag" type="button" data-action="open-bag" aria-label="Open bag, 0 items">Bag <span id="count" aria-hidden="true">0</span></button>
</header>
<main>
<section class="hero">
<div class="hero-copy"><p class="eyebrow"><i></i> THE TRAIL COLLECTION / 01</p><h1>Better<br>outside<span>.</span></h1><p>Thoughtfully designed gear for slow mornings, long trails, and weekends out of range.</p><a class="primary" href="#shop">Explore the collection <span aria-hidden="true">↗</span></a></div>
<div class="hero-art"><img src="landscape.svg" alt="Illustrated mountain range and pine forest at sunset"><span class="image-label">FIG. 01 &nbsp; / &nbsp; ABOVE THE TREELINE</span></div>
<section class="hero" aria-labelledby="hero-title">
<div class="hero-copy">
<p class="eyebrow"><i></i> THE TRAIL COLLECTION / 01</p>
<h1 id="hero-title">Better<br>outside<span>.</span></h1>
<p>Thoughtfully designed gear for slow mornings, long trails, and weekends out of range.</p>
<a class="primary" href="#shop">Explore the collection <span aria-hidden="true">↗</span></a>
</div>
<div class="hero-art">
<img src="landscape.svg" alt="Illustrated mountain range and pine forest at sunset" width="900" height="690" fetchpriority="high">
<span class="image-label" aria-hidden="true">FIG. 01 &nbsp; / &nbsp; ABOVE THE TREELINE</span>
</div>
</section>
<section id="shop" class="shop" aria-labelledby="shop-title">
<div class="shop-heading">
<div><p class="eyebrow">01 / THE SHOP</p><h2 id="shop-title">Trail essentials</h2></div>
<p>Pack light. Make it count.</p>
</div>
<div class="controls">
<div id="filters" role="group" aria-label="Filter products"></div>
<label>Sort by <select id="sort" name="sort"></select></label>
</div>
<p class="sr-only" id="results" aria-live="polite"></p>
<div id="products" class="grid"></div>
<noscript><p class="notice-js">The shop needs JavaScript to list products and keep your bag. Please enable it and reload.</p></noscript>
</section>
<section class="story" id="story" aria-labelledby="story-title">
<p class="eyebrow">02 / THE FIELD NOTES</p>
<h2 id="story-title">Good gear leaves room for the good stuff.</h2>
<p>Fieldwork is an imagined gear shop built around a simple packing list. Six essentials, each with a clear job, for getting out the door and into the day.</p>
<a href="#shop">Back to the collection ↑</a>
</section>
<section id="shop" class="shop"><div class="shop-heading"><div><p class="eyebrow">01 / THE SHOP</p><h2>Trail essentials</h2></div><p>Pack light. Make it count.</p></div>
<div class="controls"><div id="filters" role="group" aria-label="Filter products"></div><label>Sort by <select id="sort"><option value="featured">Featured</option><option value="low">Price: low to high</option><option value="high">Price: high to low</option></select></label></div>
<p class="sr-only" id="results" aria-live="polite"></p><div id="products" class="grid"></div></section>
<section class="story" id="story"><p class="eyebrow">02 / THE FIELD NOTES</p><h2>Good gear leaves room for the good stuff.</h2><p>Fieldwork is an imagined gear shop built around a simple packing list. Six essentials, each with a clear job, for getting out the door and into the day.</p><a href="#shop">Back to the collection ↑</a></section>
</main>
<footer><a class="wordmark" href="#">Fieldwork<span>.</span></a><p>Fictional brand. Portfolio demo.</p><a href="#shop">Shop the collection ↑</a></footer>
<dialog id="product-dialog" aria-labelledby="product-title"><div class="dialog-head"><p class="eyebrow">FIELDWORK / EQUIPMENT</p><form method="dialog"><button class="close" aria-label="Close product details">×</button></form></div><div id="product-detail"></div></dialog>
<dialog id="cart" aria-labelledby="cart-title"><div class="dialog-head"><h2 id="cart-title">Your bag</h2><form method="dialog"><button class="close" aria-label="Close bag">×</button></form></div><div id="cart-items"></div><div id="cart-summary" hidden><p class="subtotal"><strong>Subtotal</strong><strong id="subtotal"></strong></p><button class="primary" id="checkout">Place demo order <span aria-hidden="true">↗</span></button><p class="fine">Demo checkout. No payment or shipping details required.</p></div><p id="order" role="status" hidden></p></dialog>
<footer>
<a class="wordmark" href="./" aria-label="Fieldwork, home">Fieldwork<span>.</span></a>
<p>Fictional brand. Portfolio demo.</p>
<a href="#shop">Shop the collection ↑</a>
</footer>
<dialog id="product-dialog">
<div class="dialog-head"><p class="eyebrow">FIELDWORK / EQUIPMENT</p><form method="dialog"><button class="close" aria-label="Close product details">×</button></form></div>
<div id="product-detail"></div>
</dialog>
<dialog id="cart" aria-labelledby="cart-title">
<div class="dialog-head"><h2 id="cart-title">Your bag</h2><form method="dialog"><button class="close" aria-label="Close bag">×</button></form></div>
<div id="cart-items"></div>
<div id="cart-summary" hidden>
<p class="subtotal"><strong>Subtotal</strong><strong id="subtotal"></strong></p>
<button class="primary" id="checkout" type="button" data-action="checkout">Place demo order <span aria-hidden="true">↗</span></button>
<p class="fine">Demo checkout. No payment or shipping details required.</p>
</div>
<p id="order" role="status" tabindex="-1" hidden></p>
<p class="sr-only" id="cart-status" role="status"></p>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
/**
* Shopping bag logic. A cart is a plain `{ [productId]: quantity }` object.
*
* Everything here is pure (no DOM, no globals) except load/save, which take the storage object
* as an argument so they can be tested with a fake, including one that throws.
*/
export const MAX_QTY = 9;
export const STORAGE_KEY = "fieldwork-bag-v1"; // format unchanged, so existing bags carry over
const clamp = (n) => Math.min(MAX_QTY, Math.max(0, n));
/** Keep only known products with whole-number quantities, clamped to 1..MAX_QTY. */
export function sanitizeCart(raw, validIds) {
const clean = {};
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return clean;
for (const id of validIds) {
const qty = raw[id];
if (Number.isSafeInteger(qty) && qty > 0) clean[id] = clamp(qty);
}
return clean;
}
/** Return a new cart with `delta` added to one line. Lines at 0 are removed. */
export function changeQty(cart, id, delta) {
const next = { ...cart };
const qty = clamp((cart[id] || 0) + delta);
if (qty === 0) delete next[id];
else next[id] = qty;
return next;
}
export const cartCount = (cart) => Object.values(cart).reduce((sum, qty) => sum + qty, 0);
/** Lines in catalogue order: [{ product, qty, total }] with totals in cents. */
export function cartLines(cart, products) {
return products
.filter((product) => cart[product.id])
.map((product) => ({
product,
qty: cart[product.id],
total: product.price * cart[product.id],
}));
}
export const cartSubtotal = (cart, products) =>
cartLines(cart, products).reduce((sum, line) => sum + line.total, 0);
export function loadCart(storage, validIds) {
try {
return sanitizeCart(JSON.parse(storage.getItem(STORAGE_KEY)), validIds);
} catch {
return {}; // storage blocked, empty, or corrupted JSON
}
}
export function saveCart(storage, cart) {
try {
if (cartCount(cart) === 0) storage.removeItem(STORAGE_KEY);
else storage.setItem(STORAGE_KEY, JSON.stringify(cart));
} catch {
/* private mode or quota exceeded: the bag still works for this visit */
}
}
+94
View File
@@ -0,0 +1,94 @@
/** Product data and the pure functions that filter, sort and (de)serialise the shop view. */
const freeze = Object.freeze;
export const PRODUCTS = freeze(
[
{
id: "pack",
name: "Ridgeline Pack",
category: "Carry",
price: 8600,
alt: "Illustration of an olive roll-top daypack with a front pocket",
detail: "A compact daypack with a roll-top closure and an easy-access front pocket.",
},
{
id: "mug",
name: "Camp Enamel Mug",
category: "Camp",
price: 2400,
alt: "Illustration of a rust-red enamel mug with a dark rim",
detail: "An enamel-coated camp mug with a generous handle and a dark rolled rim.",
},
{
id: "cap",
name: "Switchback Cap",
category: "Wear",
price: 3200,
alt: "Illustration of an olive five-panel cap",
detail: "A soft five-panel cap with an adjustable back for sunny walks and early starts.",
},
{
id: "bottle",
name: "Alpine Flask",
category: "Carry",
price: 3800,
alt: "Illustration of a slate-green insulated flask with a carry loop",
detail: "A narrow insulated flask with a screw-top lid and a carry loop.",
},
{
id: "lantern",
name: "Afterglow Lantern",
category: "Camp",
price: 5400,
alt: "Illustration of a lantern with a warm glowing diffuser",
detail: "A compact lantern with a warm diffuser and a folding carry handle.",
},
{
id: "blanket",
name: "Meadow Blanket",
category: "Camp",
price: 6800,
alt: "Illustration of a striped camp blanket rolled up",
detail: "A striped camp blanket that rolls down neatly for a picnic or a cool evening.",
},
].map(freeze),
);
export const PRODUCTS_BY_ID = new Map(PRODUCTS.map((p) => [p.id, p]));
export const CATEGORIES = freeze(["All", ...new Set(PRODUCTS.map((p) => p.category))]);
export const SORTS = freeze({
featured: "Featured",
low: "Price: low to high",
high: "Price: high to low",
});
export const DEFAULT_VIEW = freeze({ category: "All", sort: "featured" });
/** Filter by category and sort by price. Never mutates its input; ties keep catalogue order. */
export function queryProducts(products, { category = "All", sort = "featured" } = {}) {
const list = category === "All" ? [...products] : products.filter((p) => p.category === category);
if (sort === "low") list.sort((a, b) => a.price - b.price);
else if (sort === "high") list.sort((a, b) => b.price - a.price);
return list;
}
/** Parse `?category=Camp&sort=low`. Anything unrecognised falls back to the default. */
export function readView(search) {
const params = new URLSearchParams(search);
const category = params.get("category");
const sort = params.get("sort");
return {
category: CATEGORIES.includes(category) ? category : DEFAULT_VIEW.category,
sort: Object.keys(SORTS).includes(sort) ? sort : DEFAULT_VIEW.sort,
};
}
/** Inverse of readView. Defaults are omitted so the plain shop URL stays clean. */
export function viewToSearch(view) {
const params = new URLSearchParams();
if (view.category !== DEFAULT_VIEW.category) params.set("category", view.category);
if (view.sort !== DEFAULT_VIEW.sort) params.set("sort", view.sort);
const query = params.toString();
return query ? `?${query}` : "";
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Tiny HTML templating with automatic escaping.
*
* Every interpolated value is escaped unless it is itself the result of `html` (or `raw`),
* so product names, ids and any future user-supplied text can never inject markup.
*
* html`<h3>${name}</h3>` // name is escaped
* html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>` // nested templates are not double-escaped
*/
class SafeHtml {
constructor(value) {
this.value = value;
}
toString() {
return this.value;
}
}
const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
export function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (ch) => ESCAPES[ch]);
}
function toHtml(value) {
if (value instanceof SafeHtml) return value.value;
if (Array.isArray(value)) return value.map(toHtml).join("");
// null/undefined render as nothing. `false` deliberately renders as "false" so boolean
// attributes such as aria-pressed="${isActive}" work; use `cond ? x : ""` for optional chunks.
if (value === null || value === undefined) return "";
return escapeHtml(value);
}
/** Mark a trusted string (e.g. a static attribute) as already-safe markup. */
export const raw = (value) => new SafeHtml(String(value));
export function html(strings, ...values) {
let out = strings[0];
for (let i = 0; i < values.length; i++) out += toHtml(values[i]) + strings[i + 1];
return new SafeHtml(out);
}
/** Replace an element's contents. Only accepts `html` results, never plain strings. */
export function mount(element, template) {
if (!(template instanceof SafeHtml)) {
throw new TypeError("mount() needs an html`...` template; plain strings are refused");
}
element.innerHTML = template.value;
}
+20
View File
@@ -0,0 +1,20 @@
/** Formatting helpers. Money is always handled as integer cents to avoid float drift. */
// Intl formatters are comparatively expensive to build, so create each one once.
const whole = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const exact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
/** 8600 -> "$86", 8650 -> "$86.50" */
export function money(cents) {
return (cents % 100 === 0 ? whole : exact).format(cents / 100);
}
/** plural(1, "item") -> "1 item", plural(3, "item") -> "3 items" */
export function plural(count, noun, pluralNoun = `${noun}s`) {
return `${count} ${count === 1 ? noun : pluralNoun}`;
}
+273
View File
@@ -0,0 +1,273 @@
/**
* Fieldwork storefront: wires the pure modules (catalog, cart) to the page.
*
* Structure: state at the top, view functions that render from state, then one delegated click
* handler that dispatches `data-action` attributes to the `actions` table below.
*/
import {
CATEGORIES,
PRODUCTS,
PRODUCTS_BY_ID,
SORTS,
queryProducts,
readView,
viewToSearch,
} from "./catalog.js";
import {
MAX_QTY,
STORAGE_KEY,
cartCount,
cartLines,
cartSubtotal,
changeQty,
loadCart,
saveCart,
} from "./cart.js";
import { html, mount, raw } from "./dom.js";
import { money, plural } from "./format.js";
const $ = (id) => document.getElementById(id);
/* ------------------------------------------------------------------ state */
const storage = (() => {
try {
return window.localStorage;
} catch {
return null; // blocked by browser settings; the bag then lasts for this visit only
}
})();
const validIds = [...PRODUCTS_BY_ID.keys()];
let view = readView(location.search); // { category, sort }, mirrored in the URL
let cart = loadCart(storage, validIds); // { [productId]: quantity }
let toastTimer;
/* ------------------------------------------------------------------ views */
const productCard = (product, index) => html`
<article class="product">
<button class="product-image" type="button" data-action="open" data-id="${product.id}"
aria-label="View ${product.name}">
<img src="${product.id}.svg" alt="" width="480" height="340"
${index > 2 ? raw('loading="lazy"') : ""}>
</button>
<div class="product-info">
<div>
<p class="eyebrow">${product.category}</p>
<h3>${product.name}</h3>
<p class="price">${money(product.price)}</p>
</div>
<button class="add" type="button" data-action="add" data-id="${product.id}"
aria-label="Add ${product.name} to bag">+</button>
</div>
</article>`;
const cartRow = ({ product, qty, total }) => html`
<div class="cart-row">
<img src="${product.id}.svg" alt="" width="480" height="340">
<div>
<strong>${product.name}</strong>
<p class="price">${money(total)}</p>
</div>
<div class="qty" role="group" aria-label="${product.name} quantity">
<button type="button" data-action="adjust" data-id="${product.id}" data-step="-1"
aria-label="Decrease ${product.name} quantity">−</button>
<span>${qty}</span>
<button type="button" data-action="adjust" data-id="${product.id}" data-step="1"
aria-label="Increase ${product.name} quantity"
${qty >= MAX_QTY ? raw("disabled") : ""}>+</button>
</div>
</div>`;
const emptyBag = html`
<p class="empty">Your bag is empty.</p>
<button class="primary" type="button" data-action="close-cart">Keep shopping</button>`;
function renderControls() {
mount(
$("filters"),
html`${CATEGORIES.map(
(name) => html`<button type="button" data-action="filter" data-value="${name}"
aria-pressed="${name === view.category}">${name}</button>`,
)}`,
);
mount(
$("sort"),
html`${Object.entries(SORTS).map(
([value, label]) => html`<option value="${value}">${label}</option>`,
)}`,
);
syncControls();
}
/** Update pressed state in place so keyboard focus is never lost on a filter click. */
function syncControls() {
for (const button of $("filters").children) {
button.setAttribute("aria-pressed", String(button.dataset.value === view.category));
}
$("sort").value = view.sort;
}
function renderProducts() {
const items = queryProducts(PRODUCTS, view);
mount($("products"), html`${items.map(productCard)}`);
$("results").textContent = `${plural(items.length, "product")} shown`;
}
function renderCart() {
const lines = cartLines(cart, PRODUCTS);
const count = cartCount(cart);
$("count").textContent = count;
$("bag").setAttribute("aria-label", `Open bag, ${plural(count, "item")}`);
mount($("cart-items"), lines.length ? html`${lines.map(cartRow)}` : emptyBag);
$("subtotal").textContent = money(cartSubtotal(cart, PRODUCTS));
$("cart-summary").hidden = lines.length === 0;
}
/* ------------------------------------------------------------------ helpers */
function setCart(next) {
cart = next;
saveCart(storage, cart);
renderCart();
}
function commitView() {
try {
history.replaceState(null, "", location.pathname + viewToSearch(view) + location.hash);
} catch {
/* e.g. sandboxed frame: the shop still works, the URL just is not updated */
}
syncControls();
renderProducts();
}
function notify(message) {
$("toast").textContent = message;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($("toast").textContent = ""), 2600);
}
function openBag() {
$("order").hidden = true;
$("cart-items").hidden = false;
$("cart-status").textContent = "";
renderCart();
$("cart").showModal();
}
const orderRef = () =>
`FW-${String(crypto.getRandomValues(new Uint32Array(1))[0] % 100000).padStart(5, "0")}`;
/* ------------------------------------------------------------------ actions */
const actions = {
filter({ value }) {
view = { ...view, category: value };
commitView();
},
open({ id }) {
const product = PRODUCTS_BY_ID.get(id);
if (!product) return;
mount(
$("product-detail"),
html`
<img src="${product.id}.svg" alt="${product.alt}" width="480" height="340">
<h2 id="product-title">${product.name}</h2>
<p>${product.detail}</p>
<button class="primary" type="button" data-action="add" data-id="${product.id}">
Add to bag <span>${money(product.price)}</span>
</button>`,
);
// The title is rendered above, so the label reference is only valid from this point on.
$("product-dialog").setAttribute("aria-labelledby", "product-title");
$("product-dialog").showModal();
},
add({ id }) {
const product = PRODUCTS_BY_ID.get(id);
if (!product) return;
if ((cart[id] || 0) >= MAX_QTY) {
notify(`Limit of ${MAX_QTY} per item`);
return;
}
setCart(changeQty(cart, id, 1));
if ($("product-dialog").open) $("product-dialog").close();
notify(`${product.name} added · ${plural(cartCount(cart), "item")} in bag`);
},
adjust({ id, step }) {
setCart(changeQty(cart, id, Number(step)));
const name = PRODUCTS_BY_ID.get(id).name;
$("cart-status").textContent = cart[id] ? `${name}: ${cart[id]} in bag` : `${name} removed`;
// The row was re-rendered: put focus back on the same control (or its sibling if that one
// is now disabled at the quantity limit, or the close button if the row is gone).
const find = (s) =>
$("cart-items").querySelector(
`[data-action="adjust"][data-id="${CSS.escape(id)}"][data-step="${s}"]`,
);
const target = [step, step === "1" ? "-1" : "1"].map(find).find((b) => b && !b.disabled);
(target || $("cart").querySelector(".close")).focus();
},
"open-bag": openBag,
"close-cart"() {
$("cart").close();
},
checkout() {
const count = cartCount(cart);
if (!count) return;
const total = cartSubtotal(cart, PRODUCTS);
setCart({});
$("cart-items").hidden = true;
$("order").hidden = false;
$("order").textContent =
`Demo order ${orderRef()} placed: ${plural(count, "item")}, ${money(total)}. ` +
"Thanks for exploring Fieldwork.";
$("order").focus();
},
};
/* ------------------------------------------------------------------ events */
document.addEventListener("click", (event) => {
const control = event.target.closest("[data-action]");
if (control && Object.hasOwn(actions, control.dataset.action)) {
actions[control.dataset.action](control.dataset, control);
return;
}
// Clicking the backdrop (outside the dialog's own box) closes a modal dialog.
const dialog = event.target;
if (dialog instanceof HTMLDialogElement && dialog.open) {
const box = dialog.getBoundingClientRect();
const inside =
event.clientX >= box.left &&
event.clientX <= box.right &&
event.clientY >= box.top &&
event.clientY <= box.bottom;
if (!inside) dialog.close();
}
});
$("sort").addEventListener("change", (event) => {
view = { ...view, sort: event.target.value };
commitView();
});
// Keep the bag in sync when it changes in another tab or window.
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY || event.key === null) {
cart = loadCart(storage, validIds);
renderCart();
}
});
/* ------------------------------------------------------------------ start */
renderControls();
renderProducts();
renderCart();
-100
View File
@@ -1,100 +0,0 @@
"use strict";
const catalog = [
{id:"pack",name:"Ridgeline Pack",category:"Carry",price:86,detail:"A compact daypack with a roll-top closure and an easy-access front pocket."},
{id:"mug",name:"Camp Enamel Mug",category:"Camp",price:24,detail:"An enamel-coated camp mug with a generous handle and a dark rolled rim."},
{id:"cap",name:"Switchback Cap",category:"Wear",price:32,detail:"A soft five-panel cap with an adjustable back for sunny walks and early starts."},
{id:"bottle",name:"Alpine Flask",category:"Carry",price:38,detail:"A narrow insulated flask with a screw-top lid and a carry loop."},
{id:"lantern",name:"Afterglow Lantern",category:"Camp",price:54,detail:"A compact lantern with a warm diffuser and a folding carry handle."},
{id:"blanket",name:"Meadow Blanket",category:"Camp",price:68,detail:"A striped camp blanket that rolls down neatly for a picnic or a cool evening."}
];
const byId = id => document.getElementById(id);
const money = value => new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:0}).format(value);
const product = id => catalog.find(item => item.id === id);
const art = item => '<img src="' + item.id + '.svg" alt="' + item.name + ' product illustration">';
const storageKey = "fieldwork-bag-v1";
let bag = {};
try {
const stored = JSON.parse(localStorage.getItem(storageKey)) || {};
for (const {id} of catalog) {
if (Number.isSafeInteger(stored[id]) && stored[id] > 0) bag[id] = stored[id];
}
} catch {}
let filter = "All";
let toastTimer;
function renderProducts() {
const items = catalog.filter(item => filter === "All" || item.category === filter);
const order = byId("sort").value;
if (order !== "featured") items.sort((a,b) => order === "low" ? a.price-b.price : b.price-a.price);
byId("products").innerHTML = items.map(item => `<article class="product">
<button class="product-image" data-detail="${item.id}" aria-label="View ${item.name}">${art(item)}</button>
<div class="product-info"><div><p class="eyebrow">${item.category}</p><h3>${item.name}</h3><p class="price">${money(item.price)}</p></div>
<button class="add" data-add="${item.id}" aria-label="Add ${item.name} to bag">+</button></div></article>`).join("");
byId("results").textContent = `${items.length} products shown`;
}
function renderCart() {
const items = catalog.filter(item => bag[item.id]);
byId("count").textContent = items.reduce((sum,item) => sum + bag[item.id],0);
byId("cart-items").innerHTML = items.map(item => `<div class="cart-row">${art(item)}
<div><strong>${item.name}</strong><p class="price">${money(item.price * bag[item.id])}</p></div>
<div class="qty"><button data-adjust="${item.id}" data-step="-1" aria-label="Decrease ${item.name} quantity">−</button>
<span>${bag[item.id]}</span><button data-adjust="${item.id}" data-step="1" aria-label="Increase ${item.name} quantity">+</button></div></div>`).join("") || "<p>Your bag is empty.</p>";
byId("subtotal").textContent = money(items.reduce((sum,item) => sum + item.price * bag[item.id],0));
byId("cart-summary").hidden = !items.length;
}
function updateBag(id, amount) {
bag[id] = (bag[id] || 0) + amount;
if (bag[id] <= 0) delete bag[id];
try {localStorage.setItem(storageKey,JSON.stringify(bag));} catch {}
renderCart();
}
function notify(message) {
byId("toast").textContent = message;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => byId("toast").textContent = "",2400);
}
byId("filters").innerHTML = ["All",...new Set(catalog.map(item => item.category))]
.map(category => `<button data-category="${category}" aria-pressed="${category === filter}">${category}</button>`).join("");
byId("filters").addEventListener("click",event => {
const button = event.target.closest("[data-category]");
if (!button) return;
filter = button.dataset.category;
for (const option of byId("filters").children) option.setAttribute("aria-pressed", option === button);
renderProducts();
});
byId("sort").addEventListener("change",renderProducts);
byId("bag").addEventListener("click",() => {
byId("order").hidden = true;
byId("cart-items").hidden = false;
byId("cart").showModal();
});
document.addEventListener("click",event => {
const button = event.target.closest("button");
if (!button) return;
const {add,detail,adjust,step} = button.dataset;
if (add) {
updateBag(add,1);
if (byId("product-dialog").open) byId("product-dialog").close();
notify(`${product(add).name} added to your bag`);
}
if (detail) {
const item = product(detail);
byId("product-detail").innerHTML = `${art(item)}<h2 id="product-title">${item.name}</h2><p>${item.detail}</p>
<button class="primary" data-add="${item.id}">Add to bag <span>${money(item.price)}</span></button>`;
byId("product-dialog").showModal();
}
if (adjust) {
updateBag(adjust,Number(step));
(byId("cart-items").querySelector(`[data-adjust="${adjust}"][data-step="${step}"]`) || byId("cart").querySelector(".close")).focus();
}
});
byId("checkout").addEventListener("click",() => {
for (const id of Object.keys(bag)) delete bag[id];
try {localStorage.removeItem(storageKey);} catch {}
renderCart();
byId("cart-items").hidden = true;
byId("order").textContent = "Demo order placed. Thanks for exploring Fieldwork.";
byId("order").hidden = false;
byId("cart").querySelector(".close").focus();
});
renderProducts();
renderCart();
+497 -1
View File
File diff suppressed because one or more lines are too long