Checkout
Checkout 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, has } from '@expressive/mvc';
import { Steps } from '@expressive/steps';
const CART = [
{ name: 'Desk lamp', price: 48 },
{ name: 'Notebook', price: 12 }
];
const TOTAL = CART.reduce((sum, item) => sum + item.price, 0);
const APPROVAL = 15000;
export default () => (
<div className="container">
<h1>Checkout</h1>
<p>
A <code>Steps</code> model, its methods steps: <code>run</code> reserves the
cart, <code>reserve</code> schedules <code>pay</code>, and <code>pay</code>{' '}
parks on <code>this.then(this.approve, this.decline, this.timeout())</code> - <code>timeout</code> races the answer, declining once its duration passes. While
that wait is open, <code>checkout.approve</code> and{' '}
<code>checkout.decline</code> read as callable steps, so their buttons
appear and disappear with it. Approving ships and mails the receipt in
parallel, then ends.
</p>
<Storefront />
<small>
A decline throws <code>Declined</code>, caught where <code>reserve</code>{' '}
called <code>this.pay(card).catch(this.declined)</code>: the unwind runs
the undo <code>pay</code> registered with{' '}
<code>{'this.then.on({ reset: async () => { await bank.cancel(authorization); } })'}</code>
- and restores{' '}
<code>pay</code>'s fields; the hold stays. <code>declined</code> then waits on{' '}
<code>this.then(this.pay)</code> for another card. Stop aborts - halts and
unwinds everything. The journal below is <code>checkout.log</code>, filled by the{' '}
<code>after</code> hook with each step's entry.
</small>
</div>
);
class Storefront extends Component {
checkout = new Checkout();
card = '4242 4242 4242 4242';
typed(event: { currentTarget: { value: string } }) {
this.card = event.currentTarget.value;
}
place() {
this.checkout.run(this.card);
}
render() {
const { checkout, card } = this;
const { status, active } = checkout.then;
const { hold, authorization, tracking, receipt, notice, log } = checkout;
const working = status == 'running' || status == 'waiting';
const ready = !working;
return (
<div className="checkout">
<ul className="cart">
{CART.map((item) => (
<li key={item.name}>
{item.name} <span>${item.price}</span>
</li>
))}
</ul>
<label>
Card <input value={card} onInput={this.typed} />
</label>
<div className="controls">
{ready && <button onClick={this.place}>Place order</button>}
{checkout.approve && <button onClick={() => checkout.approve()}>Approve</button>}
{checkout.decline && <button onClick={() => checkout.decline()}>Decline</button>}
{checkout.pay && <button onClick={() => checkout.pay(card)}>Try another card</button>}
{working && <button onClick={() => checkout.then.abort('Order cancelled.')}>Stop</button>}
</div>
<p className="status">
Status: {status}
{active && <> · {active}</>}
</p>
{notice && <p className="notice">{notice}</p>}
<dl className="fields">
<dt>Hold</dt>
<dd>{hold || '-'}</dd>
<dt>Authorization</dt>
<dd>{authorization || '-'}</dd>
<dt>Tracking</dt>
<dd>{tracking || '-'}</dd>
<dt>Receipt</dt>
<dd>{receipt || '-'}</dd>
</dl>
<ol className="record">
{log.map((entry, index) => (
<li key={index}>
<code>{entry.id}</code>
{entry.wrote.length > 0 && <span> wrote {entry.wrote.join(', ')}</span>}
{entry.unwound && <span className="undone"> undone</span>}
</li>
))}
</ol>
</div>
);
}
}
class Checkout extends Steps {
hold = '';
authorization = '';
tracking = '';
receipt = '';
notice = '';
log = has<Steps.Entry>();
async run(card: string) {
this.tracking = '';
this.receipt = '';
this.notice = '';
this.reserve(card);
try {
await this;
} catch (error) {
this.notice = (error as Error).message;
throw error;
}
return { tracking: this.tracking, receipt: this.receipt };
}
protected async reserve(card: string) {
const hold = await inventory.hold(this.then.signal);
this.hold = hold;
this.then.on({ reset: async () => { await inventory.release(hold); } });
this.pay(card).catch(this.declined);
}
async pay(card: string) {
const authorization = await bank.authorize(card, TOTAL, this.then.signal);
this.authorization = authorization;
this.notice = '';
this.then.on({ reset: async () => { await bank.cancel(authorization); } });
this.then(this.approve, this.decline, this.timeout());
}
protected async timeout() {
this.then({ ms: APPROVAL });
this.decline('timed out');
}
async approve() {
this.then(this.ship(), this.mail());
this.then.end();
}
async decline(reason = 'declined') {
throw new Declined(`Card ${reason}.`);
}
protected async declined(error: unknown) {
if (!(error instanceof Declined)) throw error;
this.notice = error.message;
this.another().catch(this.declined);
}
protected async another() {
this.then(this.pay);
}
protected async ship() {
this.tracking = await courier.book(this.hold, this.then.signal);
}
protected async mail() {
this.receipt = await mailer.send(this.authorization, this.then.signal);
}
protected before(step: string) {
if (step == 'run') this.log.clear();
}
protected after(step: string, entry: Steps.Entry) {
this.log.push(entry);
}
}
class Declined extends Error {}
let holds = 0;
let parcels = 0;
const inventory = {
async hold(signal: AbortSignal) {
await delay(400, signal);
return `H-${++holds}`;
},
async release(hold: string) {
void hold;
}
};
const bank = {
async authorize(card: string, amount: number, signal: AbortSignal) {
await delay(600, signal);
return `A-${card.replace(/\D/g, '').slice(-4)}-$${amount}`;
},
async cancel(authorization: string) {
void authorization;
}
};
const courier = {
async book(hold: string, signal: AbortSignal) {
await delay(800, signal);
return `T-${hold}-${++parcels}`;
}
};
const mailer = {
async send(authorization: string, signal: AbortSignal) {
await delay(300, signal);
return `R-${authorization}`;
}
};
function delay(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
}
App.css
.checkout {
display: grid;
gap: var(--s3);
max-width: 28rem;
}
.checkout .cart {
margin: 0;
padding: 0;
list-style: none;
}
.checkout .cart li {
display: flex;
justify-content: space-between;
padding: var(--s1) 0;
border-bottom: 1px solid var(--border);
}
.checkout label {
display: grid;
gap: var(--s1);
color: var(--muted);
font-size: var(--t-sm);
}
.checkout input {
padding: var(--s2) var(--s3);
font: inherit;
}
.checkout .controls {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
min-height: 2.25rem;
}
.checkout .status {
margin: 0;
font-family: var(--font-mono);
font-size: var(--t-sm);
}
.checkout .notice {
margin: 0;
color: var(--accent);
}
.checkout .fields {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--s1) var(--s4);
margin: 0;
font-size: var(--t-sm);
}
.checkout .fields dt {
color: var(--muted);
}
.checkout .fields dd {
margin: 0;
font-family: var(--font-mono);
}
.checkout .record {
margin: 0;
padding: var(--s3) var(--s3) var(--s3) var(--s6);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
font-size: var(--t-sm);
min-height: 6rem;
}
.checkout .undone {
color: var(--muted);
text-decoration: line-through;
}