Shopping cart
Products, a cart and totals that follow every change.
The cart lives in a store. Changing a quantity writes one path, such as
cart.set('lines', 0, 'qty', 2), and only the cells that
read it update. Computed values add up the subtotal, the discount and
the total.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Shopping cart - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<main class="container" id="app"></main>
<script>
const { Component, Computed, application, html, store, For, Show } = Signy;
const PRODUCTS = [
{ id: "tea", name: "Green tea", price: 6.5 },
{ id: "honey", name: "Wildflower honey", price: 9 },
{ id: "biscuits", name: "Butter biscuits", price: 4.25 },
];
const COUPONS = { SPRING: 0.1 };
const euro = (value) => `${value.toFixed(2)} EUR`;
class Shop extends Component {
cart = store({ lines: [], coupon: "" });
subtotal = new Computed(() =>
this.cart.state.lines.reduce((sum, line) => sum + line.price * line.qty, 0),
);
discount = new Computed(() => this.subtotal.value * (COUPONS[this.cart.state.coupon] ?? 0));
total = new Computed(() => this.subtotal.value - this.discount.value);
add(product) {
const index = this.cart.state.lines.findIndex((line) => line.id === product.id);
if (index === -1) this.cart.set("lines", (lines) => [...lines, { ...product, qty: 1 }]);
else this.cart.set("lines", index, "qty", (qty) => qty + 1);
}
change(id, delta) {
const index = this.cart.state.lines.findIndex((line) => line.id === id);
const qty = this.cart.state.lines[index].qty + delta;
if (qty > 0) this.cart.set("lines", index, "qty", qty);
else this.cart.set("lines", (lines) => lines.filter((line) => line.id !== id));
}
// A line is a plain object from the store, so read its qty through the store to keep it live
qtyOf(id) {
return this.cart.state.lines.find((line) => line.id === id)?.qty ?? 0;
}
render() {
return html`
<h1>Corner shop</h1>
<div class="grid">
${For({
each: PRODUCTS,
keyBy: "id",
children: (product) => html`
<article class="card">
<h2>${product.name}</h2>
<p>${euro(product.price)}</p>
<button class="primary" onClick=${() => this.add(product)}>Add to cart</button>
</article>
`,
})}
</div>
<h2>Cart</h2>
${Show({
when: () => this.cart.state.lines.length,
fallback: html`<p>Your cart is empty.</p>`,
children: html`
<table>
<thead>
<tr><th scope="col">Product</th><th scope="col">Quantity</th><th scope="col">Price</th></tr>
</thead>
<tbody>
${For({
each: () => this.cart.state.lines,
keyBy: "id",
children: (line) => html`
<tr>
<td>${line.name}</td>
<td>
<div class="cluster">
<button class="small outlined" aria-label=${`One less ${line.name}`} onClick=${() => this.change(line.id, -1)}>−</button>
<span>${() => this.qtyOf(line.id)}</span>
<button class="small outlined" aria-label=${`One more ${line.name}`} onClick=${() => this.change(line.id, 1)}>+</button>
</div>
</td>
<td>${() => euro(line.price * this.qtyOf(line.id))}</td>
</tr>
`,
})}
</tbody>
</table>
<form class="group" onSubmit=${(e) => {
e.preventDefault();
this.cart.set("coupon", e.currentTarget.elements.coupon.value.trim().toUpperCase());
}}>
<input name="coupon" placeholder="Coupon, try SPRING" aria-label="Coupon" />
<button class="secondary">Apply</button>
</form>
<p>Subtotal: ${() => euro(this.subtotal.value)}</p>
<p>Discount: ${() => euro(this.discount.value)}</p>
<p><strong>Total: ${() => euro(this.total.value)}</strong></p>
`,
})}
`;
}
}
application(Shop).mount("#app");
</script>
</body>
</html>