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>&lt;ul&gt;</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>
  );
};