# Checkout wizard

Source: https://signy.dev/examples/checkout-wizard/

Four steps, one state machine.

The current step is one signal, and a transition table says which events each step accepts. Next is allowed only when the step is valid, and the steps indicator follows the state.

## Preview

[Open the page on its own](https://signy.dev/examples/checkout-wizard/demo/index.html.md)

## 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**

```html
<!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>Checkout wizard - 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, Signal, Computed, application, html, Switch, Match, Show } = Signy;

      // Each state lists the events it accepts and where they lead. Anything else is ignored.
      const MACHINE = {
        cart: { next: "shipping" },
        shipping: { next: "payment", back: "cart" },
        payment: { next: "review", back: "shipping" },
        review: { confirm: "placing", back: "payment" },
        placing: { placed: "done" },
        done: { restart: "cart" },
      };

      const STEPS = [
        ["cart", "Cart"],
        ["shipping", "Shipping"],
        ["payment", "Payment"],
        ["review", "Review"],
      ];

      const PRICE = 18;

      class Checkout extends Component {
        state = new Signal("cart");
        quantity = new Signal(1);
        name = new Signal("");
        address = new Signal("");
        delivery = new Signal("standard");
        card = new Signal("");

        shippingCost = new Computed(() => (this.delivery.value === "express" ? 9 : 0));
        total = new Computed(() => this.quantity.value * PRICE + this.shippingCost.value);

        // Next is allowed only when the current step is complete
        valid = new Computed(() => {
          switch (this.state.value) {
            case "cart":
              return this.quantity.value > 0;
            case "shipping":
              return this.name.value.trim() !== "" && this.address.value.trim() !== "";
            case "payment":
              return this.card.value.replace(/\s/g, "").length === 16;
            default:
              return true;
          }
        });

        send(event) {
          const target = MACHINE[this.state.value][event];
          if (!target) return;
          if ((event === "next" || event === "confirm") && !this.valid.value) return;
          this.state.value = target;
          if (target === "placing") setTimeout(() => this.send("placed"), 800);
        }

        text(label, signal, attributes = {}) {
          return html`
            <label>
              ${label}
              <input value=${signal.peek()} onInput=${(e) => (signal.value = e.currentTarget.value)} ...${attributes} />
            </label>
          `;
        }

        steps() {
          return html`
            <ol class="steps">
              ${STEPS.map(([state, label]) => html`
                <li aria-current=${() => (this.state.value === state ? "step" : null)}>${label}</li>
              `)}
            </ol>
          `;
        }

        step(state, body) {
          return Match({ when: () => this.state.value === state, children: body });
        }

        render() {
          return html`
            <article class="card">
              <header>
                <h1>Checkout</h1>
                ${Show({
                  when: () => STEPS.some(([state]) => state === this.state.value),
                  children: this.steps(),
                })}
              </header>

              ${Switch({
                children: [
                  this.step("cart", () => html`
                    <h2>Cart</h2>
                    <label>
                      Signy mugs, ${PRICE} EUR each
                      <input type="number" min="0" max="9" value=${this.quantity.peek()}
                        onInput=${(e) => (this.quantity.value = Number(e.currentTarget.value) || 0)} />
                    </label>
                  `),
                  this.step("shipping", () => html`
                    <h2>Shipping</h2>
                    ${this.text("Name", this.name, { autocomplete: "name" })}
                    ${this.text("Address", this.address, { autocomplete: "street-address" })}
                    <fieldset>
                      <legend>Delivery</legend>
                      <label><input type="radio" name="delivery" checked=${this.delivery.peek() === "standard"}
                        onChange=${() => (this.delivery.value = "standard")} /> Standard, free</label>
                      <label><input type="radio" name="delivery" checked=${this.delivery.peek() === "express"}
                        onChange=${() => (this.delivery.value = "express")} /> Express, 9 EUR</label>
                    </fieldset>
                  `),
                  this.step("payment", () => html`
                    <h2>Payment</h2>
                    ${this.text("Card number", this.card, { inputmode: "numeric", autocomplete: "cc-number", placeholder: "4242 4242 4242 4242" })}
                    <p><small>Any 16 digits work. Nothing is charged.</small></p>
                  `),
                  this.step("review", () => html`
                    <h2>Review</h2>
                    <table>
                      <tbody>
                        <tr><th scope="row">Mugs</th><td>${() => this.quantity.value} × ${PRICE} EUR</td></tr>
                        <tr><th scope="row">Ship to</th><td>${() => this.name.value}, ${() => this.address.value}</td></tr>
                        <tr><th scope="row">Delivery</th><td>${() => this.delivery.value}</td></tr>
                        <tr><th scope="row">Card</th><td>ending in ${() => this.card.value.replace(/\s/g, "").slice(-4)}</td></tr>
                        <tr><th scope="row">Total</th><td><strong>${() => this.total.value} EUR</strong></td></tr>
                      </tbody>
                    </table>
                  `),
                  this.step("placing", () => html`<p aria-busy="true">Placing your order</p>`),
                  this.step("done", () => html`
                    <p class="alert success" role="status">Thank you! Your order is on its way.</p>
                    <button onClick=${() => this.send("restart")}>Start again</button>
                  `),
                ],
              })}

              ${Show({
                when: () => STEPS.some(([state]) => state === this.state.value),
                children: html`
                  <footer class="cluster">
                    <button class="secondary outlined" disabled=${() => !MACHINE[this.state.value].back}
                      onClick=${() => this.send("back")}>Back</button>
                    <button class="primary" disabled=${() => !this.valid.value}
                      onClick=${() => this.send(this.state.value === "review" ? "confirm" : "next")}>
                      ${() => (this.state.value === "review" ? `Pay ${this.total.value} EUR` : "Next")}
                    </button>
                  </footer>
                `,
              })}
            </article>
          `;
        }
      }

      application(Checkout).mount("#app");
    </script>
  </body>
</html>

```

## What it uses

- [Signals](https://signy.dev/documentation/1/reactivity/signals/index.html.md) and [Computed](https://signy.dev/documentation/1/reactivity/computed/index.html.md) guards
- [Switch and Match](https://signy.dev/documentation/1/templates/switch-and-match/index.html.md) for the steps
- The Iris CSS `.steps` component
