Checkout wizard
Each Continue posts the whole order to one endpoint together with the current step. The server validates just that step and answers with either an errors object or the next step number; on the last step it renders a receipt and removes the card number from the store. The step lives in the query string with history entries, so the browser’s back button goes back a step, and the items and address survive a reload.
Live
- 1Basket
- 2Delivery
- 3Payment
- ✓Done
---const items = [ { key: 'kettle', name: 'Stovetop kettle', price: 34.5, emoji: '🫖' }, { key: 'teapot', name: 'Ceramic teapot', price: 22, emoji: '🍵' }, { key: 'cups', name: 'Cups, set of four', price: 18, emoji: '☕' },];---<div class="ex wizard" data-signals="{ errors: {}, submitting: false }" data-signals__ifmissing="{ order: { qty: { kettle: 1, teapot: 0, cups: 0 }, email: '', address: '', card: '' } }" data-persist:checkout-demo="{ include: /^order\.(qty|email|address)/ }" data-query-string:step__history="1" data-computed:total="$order.qty.kettle * 34.5 + $order.qty.teapot * 22 + $order.qty.cups * 18" data-computed:count="$order.qty.kettle + $order.qty.teapot + $order.qty.cups"> <ol class="stepper"> <li data-class:active="$step === 1" data-class:done="$step > 1"><i>1</i>Basket</li> <li data-class:active="$step === 2" data-class:done="$step > 2"><i>2</i>Delivery</li> <li data-class:active="$step === 3" data-class:done="$step > 3"><i>3</i>Payment</li> <li data-class:active="$step === 4"><i>✓</i>Done</li> </ol> <form class="layout" data-on:submit="$submitting = true; @post('/api/checkout')" data-indicator:submitting> <section class="card pane"> <fieldset data-show="$step === 1"> <h4>Your basket</h4> {items.map((it) => ( <div class="item"> <span class="thumb" aria-hidden="true">{it.emoji}</span> <div><b>{it.name}</b><small class="muted num">{it.price.toFixed(2)} EUR each</small></div> <div class="stepper-ctl"> <button class="btn ghost round" type="button" data-on:click={`$order.qty.${it.key} = Math.max(0, $order.qty.${it.key} - 1)`}>−</button> <b class="num" data-text={`$order.qty.${it.key}`}></b> <button class="btn ghost round" type="button" data-on:click={`$order.qty.${it.key}++`}>+</button> </div> </div> ))} <p class="err" data-show="$errors?.qty" data-text="$errors?.qty"></p> </fieldset> <fieldset data-show="$step === 2" style="display:none"> <h4>Where should it go?</h4> <label>Email <input class="field" type="email" placeholder="you@example.com" data-bind:order.email data-class:invalid="$errors?.email"></label> <label>Address <input class="field" placeholder="Street, town, postcode" data-bind:order.address data-class:invalid="$errors?.address"></label> <p class="err" data-show="$errors?.email || $errors?.address" data-text="[$errors?.email, $errors?.address].filter(Boolean).join(' ')"></p> </fieldset> <fieldset data-show="$step === 3" style="display:none"> <h4>Payment</h4> <label>Card number <input class="field num" inputmode="numeric" placeholder="1234 5678 9012 3456" data-bind:order.card data-custom-validity="$step !== 3 || $order.card.replace(/\s/g, '').length === 16 ? '' : 'Sixteen digits, please.'"></label> <p class="muted" style="font-size:.8rem">Any sixteen digits will do; the number is removed from the store as soon as the order confirms.</p> <p class="err" data-show="$errors?.card" data-text="$errors?.card"></p> </fieldset> <div class="receipt" data-show="$step === 4" style="display:none"><div id="receipt"></div><button class="btn ghost" type="button" data-on:click="$step = 1; $order.card = ''">Start again</button></div> <div class="actions" data-show="$step < 4"> <button class="btn ghost" type="button" data-show="$step > 1" data-on:click="$errors = {}; $step--">Back</button> <button class="btn" data-attr:disabled="$submitting || ($step === 1 && !$count)" data-text="$submitting ? 'Please wait…' : $step === 3 ? 'Pay ' + @intl('number', $total, { style: 'currency', currency: 'EUR' }) : 'Continue'">Continue</button> </div> </section> <aside class="card summary"> <h4>Order summary</h4> {items.map((it) => ( <div class="line" data-show={`$order.qty.${it.key} > 0`}> <span data-text={`$order.qty.${it.key} + ' × ${it.name}'`}></span> <span class="num" data-text={`@intl('number', $order.qty.${it.key} * ${it.price}, { style: 'currency', currency: 'EUR' })`}></span> </div> ))} <p class="muted" data-show="!$count">Nothing in the basket yet.</p> <div class="line total"><span>Total</span><span class="num" data-text="@intl('number', $total, { style: 'currency', currency: 'EUR' })"></span></div> <small class="muted">Step <span data-text="$step"></span> is in the URL; items and address are saved locally.</small> </aside> </form></div><style is:global> .wizard { padding: 1rem; display: grid; gap: .9rem; } .wizard .stepper { display: flex; list-style: none; margin: 0; padding: 0; gap: 0; counter-reset: s; } .wizard .stepper li { flex: 1; min-width: 0; display: flex; align-items: center; gap: .5rem; font-size: .8rem; white-space: nowrap; color: var(--ex-muted); position: relative; } .wizard .stepper li::after { content: ''; flex: 1; height: 2px; background: var(--ex-line); margin: 0 .6rem; } .wizard .stepper li:last-child::after { display: none; } .wizard .stepper i { display: grid; place-items: center; width: 1.6rem; height: 1.6rem; border-radius: 50%; font-style: normal; font-weight: 700; font-size: .75rem; background: var(--ex-surface-2); border: 1px solid var(--ex-line); } .wizard .stepper li.active { color: var(--ex-ink); font-weight: 600; } .wizard .stepper li.active i { background: var(--ex-accent); color: #fff; border-color: transparent; } .wizard .stepper li.done i { background: var(--ex-good); color: #fff; border-color: transparent; } .wizard .stepper li.done::after { background: var(--ex-good); } .wizard .layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(12rem, 16rem); gap: .9rem; } @media (max-width: 40rem) { .wizard .layout { grid-template-columns: 1fr; } } @media (max-width: 30rem) { .wizard .stepper li { font-size: .72rem; gap: .3rem; } .wizard .stepper li::after { margin: 0 .3rem; } .wizard .stepper i { width: 1.35rem; height: 1.35rem; } } .wizard .pane { padding: 1rem; } .wizard fieldset { border: 0; padding: 0; margin: 0 0 .8rem; display: grid; gap: .6rem; min-width: 0; } .wizard h4 { margin: 0 0 .4rem; font-size: 1rem; } .wizard label { display: grid; gap: .25rem; font-size: .85rem; } .wizard .item { display: grid; grid-template-columns: auto 1fr auto; gap: .7rem; align-items: center; padding: .5rem 0; border-bottom: 1px dashed var(--ex-line); } .wizard .item:last-of-type { border-bottom: 0; } .wizard .thumb { display: grid; place-items: center; width: 2.6rem; height: 2.6rem; border-radius: 12px; background: var(--ex-accent-low); font-size: 1.3rem; } .wizard .item b { display: block; font-size: .92rem; } .wizard .item small { display: block; font-size: .75rem; } .wizard .stepper-ctl { display: flex; align-items: center; gap: .5rem; } .wizard .stepper-ctl .btn { width: 2rem; height: 2rem; } .wizard .actions { display: flex; justify-content: flex-end; gap: .5rem; } .wizard .summary { padding: 1rem; display: grid; gap: .4rem; align-content: start; } .wizard .line { display: flex; justify-content: space-between; font-size: .9rem; } .wizard .line.total { border-top: 1px solid var(--ex-line); margin-top: .3rem; padding-top: .5rem; font-weight: 700; font-size: 1.05rem; } .wizard .receipt { display: grid; gap: .8rem; justify-items: start; } .wizard #receipt { width: 100%; padding: .9rem 1rem; border: 2px dashed var(--ex-good); border-radius: 12px; background: color-mix(in srgb, var(--ex-good) 8%, transparent); } .wizard #receipt h4 { color: var(--ex-good); }</style>What it uses
Section titled “What it uses”data-signals__ifmissingfor defaults that must not overwrite restored state, plusdata-persistanddata-query-stringdata-computed:totaland@intlcurrency formatting in the button labeldata-showper step,data-classfor the progress indicator and invalid fieldsdata-custom-validityfor immediate feedback before the server is askedjson()responses merged as signals, then ansse()response combining a rendered receipt with a signal patch that setscardtonulldata-indicatordisabling the button while the request runs
Server side
Section titled “Server side”src/pages/api/checkout.ts
Section titled “src/pages/api/checkout.ts”import { json, patchElements, patchSignals, readSignals, sse } from '@sigmx/astro/server';import type { APIRoute } from 'astro';
export const prerender = false;export const catalogue = { kettle: 34.5, teapot: 22, cups: 18 };
const validate = (step: number, o: any): Record<string, string> => { const e: Record<string, string> = {}; if (step === 1 && !Object.values(o.qty ?? {}).some((n: any) => Number(n) > 0)) e.qty = 'Pick at least one item.'; if (step === 2) { if (!/^\S+@\S+\.\S+$/.test(o.email ?? '')) e.email = 'That email does not look right.'; if (String(o.address ?? '').trim().length < 6) e.address = 'Please enter a full address.'; } if (step === 3 && !/^\d{16}$/.test(String(o.card ?? '').replace(/\s/g, ''))) e.card = 'A card number has 16 digits (any digits will do here).'; return e;};
export const POST: APIRoute = async ({ request }) => { const { step = 1, order = {} } = await readSignals(request); const errors = validate(Number(step), order); if (Object.keys(errors).length) return json({ errors, submitting: false }); if (Number(step) < 3) return json({ errors: {}, step: Number(step) + 1, submitting: false }); const lines = Object.entries(order.qty ?? {}) .filter(([, q]) => Number(q) > 0) .map(([k, q]) => `<li>${q} × ${k}</li>`) .join(''); const total = Object.entries(order.qty ?? {}).reduce((s, [k, q]) => s + Number(q) * (catalogue as any)[k], 0); return sse( patchElements( `<div id="receipt"><h4>Order confirmed</h4><ul>${lines}</ul><p>Total <b>${total.toFixed(2)} EUR</b>, receipt sent to ${order.email}.</p></div>`, ), patchSignals({ step: 4, submitting: false, order: { card: null } }), // never keep the card number around );};