Skip to content

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

Open the page on its own

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>

What it uses