Store
Store demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.
App.tsx
import './App.css';
import { Component } from '@expressive/mvc';
import { Link, Route, Router } from '@expressive/router';
import { CartPage } from './Cart';
import { ProductPage } from './Product';
import { Cart } from './Store';
import { Storefront } from './Storefront';
export default () => (
<div className="container shop">
<h1>Storefront</h1>
<p>
The cart is a <code>map</code> keyed by product id, spawning a{' '}
<code>Line</code> per entry. Adding a product again finds its line by key
and bumps it; a line stepped to zero destroys itself, which evicts it from
the map.
</p>
<Component for={Cart}>
<Router>
<Route as={Layout}>
<Route as={Storefront} />
<Route to="category/:cat" as={Storefront} />
<Route to="product/:id" as={ProductPage} />
<Route to="cart" as={CartPage} />
<Route none as={NotFound} />
</Route>
</Router>
</Component>
<small>
Each line renders its own row, so the cart page drops the map straight
into a <code><ul></code>. The badge reads only <code>count</code>,
and the product page's quantity stepper reads the page through context.
</small>
</div>
);
const Layout = ({ children }: { children?: Component.Node }) => (
<div className="store">
<header className="store-head">
<Link to="/" className="brand">
๐๏ธ Emoji Store
</Link>
<CartButton />
</header>
{children}
</div>
);
const CartButton = () => {
const { count } = Cart.get();
return (
<Link to="/cart" className="cart-btn" aria-label="Cart">
๐
{count > 0 && <span className="badge">{count}</span>}
</Link>
);
};
const NotFound = () => (
<div className="notice">
<span className="big-emoji">๐งญ</span>
<h2>Nothing here</h2>
<Link to="/">Back to the store</Link>
</div>
);
App.css
.container.shop {
max-width: 52rem;
}
.store {
display: flex;
flex-direction: column;
gap: var(--s5);
width: 100%;
text-align: left;
}
.store-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s4);
padding-bottom: var(--s4);
border-bottom: 1px solid var(--border);
}
.brand {
font-size: var(--t-xl);
font-weight: 700;
color: var(--fg);
letter-spacing: -0.02em;
}
.brand:hover {
text-decoration: none;
color: var(--accent);
}
.cart-btn {
position: relative;
display: inline-flex;
align-items: center;
font-size: var(--t-xl);
line-height: 1;
padding: var(--s2);
border-radius: var(--r);
border: 1px solid var(--border-2);
}
.cart-btn:hover {
text-decoration: none;
border-color: var(--accent);
}
.badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: grid;
place-items: center;
background: var(--accent);
color: var(--accent-fg);
border-radius: 999px;
font-size: var(--t-xs);
font-weight: 600;
font-family: var(--font-sans);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
margin-bottom: var(--s5);
}
.chip {
padding: var(--s1) var(--s3);
border: 1px solid var(--border-2);
border-radius: 999px;
color: var(--fg-soft);
font-size: var(--t-sm);
font-weight: 500;
}
.chip:hover {
text-decoration: none;
border-color: var(--accent);
}
.chip.on {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: var(--s3);
}
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s1);
padding: var(--s4) var(--s3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
color: var(--fg);
transition:
border-color 0.15s,
transform 0.15s;
}
.card:hover {
text-decoration: none;
border-color: var(--accent);
transform: translateY(-2px);
}
.card-emoji {
font-size: 2.75rem;
line-height: 1.1;
}
.card-name {
font-weight: 600;
}
.card-price {
color: var(--muted);
font-size: var(--t-sm);
font-family: var(--font-mono);
}
.crumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s2);
font-size: var(--t-sm);
color: var(--muted);
margin-bottom: var(--s5);
}
.crumbs > * + *::before {
display: inline-block;
content: 'โบ';
margin-right: var(--s2);
color: var(--border-2);
}
.crumbs [aria-current='page'] {
color: var(--fg);
font-weight: 600;
}
.hero {
display: flex;
flex-wrap: wrap;
gap: var(--s6);
align-items: center;
}
.hero-emoji {
font-size: 8rem;
line-height: 1;
flex: 0 0 auto;
}
.hero-info {
display: flex;
flex-direction: column;
gap: var(--s3);
min-width: 12rem;
flex: 1;
}
.hero-price {
margin: 0;
font-size: var(--t-xl);
font-family: var(--font-mono);
color: var(--fg-soft);
}
.qty {
display: inline-flex;
align-items: center;
gap: var(--s2);
}
.qty button {
width: 2rem;
height: 2rem;
padding: 0;
display: grid;
place-items: center;
font-size: var(--t-lg);
}
.qty-val {
min-width: 1.5rem;
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
font-weight: 600;
align-self: flex-start;
}
.primary:hover {
border-color: var(--accent);
filter: brightness(1.08);
}
.cart {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.lines {
gap: var(--s2);
}
.line {
display: flex;
align-items: center;
gap: var(--s4);
padding: var(--s3);
border: 1px solid var(--border);
border-radius: var(--r);
cursor: default;
}
.line:hover {
background: none;
}
.line-emoji {
font-size: 2rem;
line-height: 1;
}
.line-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 6rem;
}
.line-info a {
color: var(--fg);
font-weight: 600;
}
.line-sub {
font-family: var(--font-mono);
font-weight: 600;
min-width: 5rem;
text-align: right;
}
.remove {
padding: var(--s1) var(--s2);
color: var(--muted);
border-color: transparent;
}
.remove:hover {
color: var(--fg);
border-color: var(--border-2);
}
.summary {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-top: var(--s3);
border-top: 1px solid var(--border);
font-weight: 600;
}
.summary .total {
font-size: var(--t-xl);
font-family: var(--font-mono);
}
.checkout {
align-self: stretch;
text-align: center;
padding: var(--s3);
font-size: var(--t-lg);
}
.notice {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
text-align: center;
padding: var(--s6) var(--s4);
}
.big-emoji {
font-size: 4rem;
line-height: 1;
}
Cart.tsx
import { Link } from '@expressive/router';
import { usd } from './catalog';
import { Cart } from './Store';
export const CartPage = () => {
const {
receipt,
count
} = Cart.get();
if (receipt) return <Receipt />;
if (count)
return (
<div className="cart">
<h2>Your Cart</h2>
<Lines />
<Checkout />
</div>
);
return <Empty />;
};
const Receipt = () => {
const {
reset,
receipt: {
count,
total
}
} = Cart.get(true);
return (
<div className="notice">
<span className="big-emoji">โ
</span>
<h2>Order placed!</h2>
<p>
{count} {count === 1 ? 'item' : 'items'} ยท {usd(total)}
</p>
<Link to="/" onClick={reset}>
Continue shopping
</Link>
</div>
);
};
const Empty = () => (
<div className="notice">
<span className="big-emoji">๐</span>
<h2>Your cart is empty</h2>
<Link to="/">Browse the store</Link>
</div>
);
const Lines = () => {
const { items } = Cart.get();
return <ul className="lines">{items}</ul>;
};
const Checkout = () => {
const {
total,
checkout
} = Cart.get();
return (
<>
<div className="summary">
<span>Total</span>
<span className="total">{usd(total)}</span>
</div>
<button className="primary checkout" onClick={checkout}>
Checkout ยท {usd(total)}
</button>
</>
);
};
catalog.ts
export interface Product {
id: string;
emoji: string;
name: string;
category: string;
price: number;
}
export interface Category {
slug: string;
label: string;
}
const INVENTORY: Record<string, Record<string, [string, number]>> = {
Food: {
Pizza: ['๐', 12.5],
Burger: ['๐', 9.75],
Taco: ['๐ฎ', 4.25],
Sushi: ['๐ฃ', 18],
Donut: ['๐ฉ', 2.5],
IceCream: ['๐ฆ', 3.75]
},
Animals: {
Dog: ['๐ถ', 45],
Cat: ['๐ฑ', 42],
Fox: ['๐ฆ', 38.5],
Panda: ['๐ผ', 49.99],
Lion: ['๐ฆ', 47],
Penguin: ['๐ง', 29.5]
},
Faces: {
Grinning: ['๐', 5],
Cool: ['๐', 7.5],
Robot: ['๐ค', 14.25],
Ghost: ['๐ป', 6.66],
Cowboy: ['๐ค ', 11],
Party: ['๐ฅณ', 8.5]
},
Nature: {
Cactus: ['๐ต', 16],
Wave: ['๐', 22.5],
Fire: ['๐ฅ', 13.75],
Rainbow: ['๐', 24],
Star: ['โญ', 19.99],
Moon: ['๐', 21]
}
};
const label = (key: string) => key.replace(/([a-z])([A-Z])/g, '$1 $2');
const slug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
export const categories: Category[] = Object.keys(INVENTORY).map((key) => ({
slug: slug(key),
label: label(key)
}));
export const products: Product[] = Object.entries(INVENTORY).flatMap(
([category, items]) =>
Object.entries(items).map(([key, [emoji, price]]) => {
const name = label(key);
return {
id: slug(name),
emoji,
name,
category: slug(category),
price
};
})
);
const byId = new Map(products.map((p) => [p.id, p]));
export const getProduct = (id: string) => byId.get(id);
export const inCategory = (cat: string) =>
products.filter((p) => p.category === cat);
export const categoryLabel = (cat: string) =>
categories.find((c) => c.slug === cat)?.label ?? cat;
export const usd = (n: number) =>
n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
Line.tsx
import { Component } from '@expressive/mvc';
import { Link } from '@expressive/router';
import { getProduct, usd } from './catalog';
export class Line extends Component {
qty = 0;
constructor(readonly sku: string) {
super();
}
get product() {
return getProduct(this.sku)!;
}
get subtotal() {
return this.product.price * this.qty;
}
step(by: number) {
const qty = this.qty + by;
if (qty > 0) this.qty = qty;
else this.set(null);
}
remove() {
this.set(null);
}
render() {
const {
qty,
subtotal,
step,
remove,
product: {
id,
emoji,
name,
price
}
} = this;
const to = `/product/${id}`;
return (
<li className="line">
<Link to={to} className="line-emoji">
{emoji}
</Link>
<div className="line-info">
<Link to={to}>{name}</Link>
<small>{usd(price)} each</small>
</div>
<div className="qty">
<button onClick={() => step(-1)} aria-label="Decrease quantity">
โ
</button>
<span className="qty-val">{qty}</span>
<button onClick={() => step(1)} aria-label="Increase quantity">
+
</button>
</div>
<span className="line-sub">{usd(subtotal)}</span>
<button className="remove" onClick={remove} aria-label={`Remove ${name}`}>
โ
</button>
</li>
);
}
}
Product.tsx
import { Component, get } from '@expressive/mvc';
import { Link, Route } from '@expressive/router';
import { categoryLabel, getProduct, usd } from './catalog';
import { Cart } from './Store';
export class ProductPage extends Component {
route = get(Route);
cart = get(Cart);
qty = 1;
get product() {
const id = this.route.match?.id;
return id ? getProduct(id) : undefined;
}
get subtotal() {
return this.product ? this.product.price * this.qty : 0;
}
quantity(by: number) {
this.qty = Math.max(1, this.qty + by);
}
addToCart() {
const { product, qty } = this;
if (!product) return;
this.cart.add(product.id, qty);
this.qty = 1;
}
render() {
const {
product,
qty,
subtotal,
addToCart
} = this;
if (product)
return (
<div className="product">
<nav className="crumbs" aria-label="Breadcrumb">
<Link to="/">Store</Link>
<Link to={`/category/${product.category}`}>
{categoryLabel(product.category)}
</Link>
<span aria-current="page">{product.name}</span>
</nav>
<div className="hero">
<span className="hero-emoji">{product.emoji}</span>
<div className="hero-info">
<h2>{product.name}</h2>
<p className="hero-price">{usd(product.price)}</p>
<Stepper />
<button className="primary" onClick={addToCart}>
Add {qty} to cart ยท {usd(subtotal)}
</button>
</div>
</div>
</div>
);
return <NotFound />;
}
}
const Stepper = () => {
const {
qty,
quantity
} = ProductPage.get();
return (
<div className="qty">
<button onClick={() => quantity(-1)} aria-label="Decrease quantity">
โ
</button>
<span className="qty-val">{qty}</span>
<button onClick={() => quantity(1)} aria-label="Increase quantity">
+
</button>
</div>
);
};
const NotFound = () => (
<div className="notice">
<span className="big-emoji">๐ซฅ</span>
<h2>Product not found</h2>
<Link to="/">Back to the store</Link>
</div>
);
Store.ts
import { map, State } from '@expressive/mvc';
import { Line } from './Line';
export class Cart extends State {
items = map((id: string) => new Line(id));
receipt?: { count: number; total: number };
get count() {
return sum(this.items.values((line) => line.qty));
}
get total() {
return sum(this.items.values((line) => line.subtotal));
}
add(id: string, qty = 1) {
const line = this.items.get(id) ?? this.items.set(id).get(id)!;
line.qty += qty;
}
checkout() {
if (!this.count) return;
this.receipt = { count: this.count, total: this.total };
this.items.clear();
}
reset() {
this.receipt = undefined;
}
}
const sum = (values: Iterable<number>) => {
let total = 0;
for (const value of values) total += value;
return total;
};
Storefront.tsx
import { Link, Route } from '@expressive/router';
import { categories, inCategory, products, usd } from './catalog';
export const Storefront = () => {
const {
match: {
cat
} = {}
} = Route.get();
const shown = cat ? inCategory(cat) : products;
return (
<div className="storefront">
<nav className="chips">
<Link to="/" className={cat ? 'chip' : 'chip on'}>
All
</Link>
{categories.map((c) => (
<Link
key={c.slug}
to={`/category/${c.slug}`}
className={cat === c.slug ? 'chip on' : 'chip'}>
{c.label}
</Link>
))}
</nav>
<div className="grid">
{shown.map((p) => (
<Link key={p.id} to={`/product/${p.id}`} className="card">
<span className="card-emoji">{p.emoji}</span>
<span className="card-name">{p.name}</span>
<span className="card-price">{usd(p.price)}</span>
</Link>
))}
</div>
</div>
);
};