Checkout Flow
1
Cart2
Shipping3
Payment4
DoneShopping Cart
Wireless Headphones
$99.99 each
$99.99
Bluetooth Speaker
$49.99 each
$99.98
Total$199.97
import { matchina, createStoreMachine, addStoreApi, withSubscribe, setup, effect,} from "matchina";import { states } from "./states";import type { CartData, ShippingForm, PaymentForm } from "./types";
interface CheckoutState { cart: CartData; shipping: ShippingForm; payment: PaymentForm; orderId: string | null; error: string | null;}
const defaultCart: CartData = { items: [ { id: "1", name: "Wireless Headphones", price: 99.99, quantity: 1 }, { id: "2", name: "Bluetooth Speaker", price: 49.99, quantity: 2 }, ], total: 199.97,};
const createCheckoutStore = (initialState: CheckoutState) => { const store = createStoreMachine<CheckoutState>(initialState, { updateCart: (cart: CartData) => (change) => ({ ...change.from, cart }), updateShipping: (shipping: ShippingForm) => (change) => ({ ...change.from, shipping, }), updatePayment: (payment: PaymentForm) => (change) => ({ ...change.from, payment, }), setOrderId: (orderId: string) => (change) => ({ ...change.from, orderId, error: null, }), setError: (error: string) => (change) => ({ ...change.from, error }), reset: () => () => initialState, }); return addStoreApi(withSubscribe(store));};
export const createCheckoutMachine = () => { const initialState: CheckoutState = { cart: defaultCart, shipping: { address: "", city: "", zipCode: "" }, payment: { cardNumber: "", expiryDate: "", cvv: "" }, orderId: null, error: null, };
const store = createCheckoutStore(initialState);
const machine = matchina( states, { Cart: { proceedToShipping: "Shipping", }, Shipping: { proceedToPayment: "Payment", backToCart: "Cart", }, Payment: { placeOrder: "Processing", backToShipping: "Shipping", }, Processing: { success: "Success", failure: "Failed", }, Success: { newOrder: "Cart", }, Failed: { retry: "Payment", backToCart: "Cart", }, }, "Cart" );
setup(machine)( effect((ev) => { if (ev.type === "newOrder") { store.api.reset(); } }) );
return Object.assign(machine, { store });};
export type CheckoutMachine = ReturnType<typeof createCheckoutMachine>;import { defineStates } from "matchina";
export const states = defineStates({ Cart: undefined, Shipping: undefined, Payment: undefined, Processing: undefined, Success: undefined, Failed: undefined,});export type CartData = { items: Array<{ id: string; name: string; price: number; quantity: number }>; total: number;};export type ShippingForm = { address: string; city: string; zipCode: string; error?: string | null;};export type PaymentForm = { cardNumber: string; expiryDate: string; cvv: string; error?: string | null;};export type ShippingData = { cart: CartData; shipping: ShippingForm;};export type PaymentData = { cart: CartData; shipping: ShippingForm; payment: PaymentForm;};export type ProcessingData = { cart: CartData; shipping: ShippingForm; payment: PaymentForm;};export type SuccessData = { cart: CartData; shipping: ShippingForm; payment: PaymentForm; orderId: string;};export type FailedData = { cart: CartData; shipping: ShippingForm; payment: PaymentForm; error: string;};import { useState } from "react";import type { CheckoutMachine } from "./machine";import type { PaymentData } from "./types";
const inputCls = "w-full bg-background border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring placeholder:text-muted-foreground";
function FieldLabel({ children }: { children: React.ReactNode }) { return ( <label className="block text-[10px] font-mono uppercase tracking-widest text-muted-foreground mb-1.5"> {children} </label> );}
function ErrorBanner({ error }: { error: string | null }) { if (!error) return null; return ( <div className="mb-4 px-3 py-2.5 bg-destructive/10 border border-destructive/30 rounded-lg text-xs text-destructive"> {error} </div> );}
export function CartForm({ machine }: { machine: CheckoutMachine }) { const [items, setItems] = useState(machine.store.getState().cart.items);
const handleQuantityChange = (id: string, quantity: number) => { setItems((prev) => prev.map((item) => (item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item)), ); };
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return ( <div> <h2 className="text-base font-semibold mb-4">Shopping Cart</h2> <div className="space-y-2 mb-5"> {items.map((item) => ( <div key={item.id} className="flex justify-between items-center bg-muted rounded-xl px-4 py-3"> <div> <p className="text-sm font-medium">{item.name}</p> <p className="text-xs text-muted-foreground">${item.price.toFixed(2)} each</p> </div> <div className="flex items-center gap-3"> <label htmlFor={item.id} className="text-xs text-muted-foreground"> Qty </label> <input id={item.id} type="number" min={0} value={item.quantity} onChange={(e) => handleQuantityChange(item.id, Number(e.target.value))} className="w-14 bg-background border border-border rounded-lg px-2 py-1 text-sm text-center focus:outline-none focus:ring-2 focus:ring-ring" /> <span className="text-sm font-semibold tabular-nums w-14 text-right"> ${(item.price * item.quantity).toFixed(2)} </span> </div> </div> ))} </div>
<div className="bg-muted rounded-xl p-4 mb-5 flex justify-between items-center"> <span className="text-sm text-muted-foreground font-mono uppercase tracking-widest text-[10px]"> Total </span> <span className="text-xl font-bold tabular-nums">${total.toFixed(2)}</span> </div>
<button type="button" onClick={() => machine.proceedToShipping()} className="btn btn-primary w-full" > Continue to Shipping </button> </div> );}
export function ShippingForm({ machine }: { machine: CheckoutMachine }) { const [address, setAddress] = useState(""); const [city, setCity] = useState(""); const [zipCode, setZipCode] = useState("");
const error = machine.store.getState().error;
return ( <div> <h2 className="text-base font-semibold mb-4">Shipping Information</h2> <ErrorBanner error={error} />
<div className="space-y-4 mb-6"> <div> <FieldLabel>Address</FieldLabel> <input type="text" value={address} onChange={(e) => setAddress(e.target.value)} className={inputCls} placeholder="123 Main Street" /> </div> <div className="grid grid-cols-2 gap-4"> <div> <FieldLabel>City</FieldLabel> <input type="text" value={city} onChange={(e) => setCity(e.target.value)} className={inputCls} placeholder="New York" /> </div> <div> <FieldLabel>ZIP Code</FieldLabel> <input type="text" value={zipCode} onChange={(e) => setZipCode(e.target.value)} className={inputCls} placeholder="10001" /> </div> </div> </div>
<div className="flex gap-3"> <button type="button" onClick={() => machine.backToCart()} className="btn btn-outline flex-1"> Back </button> <button type="button" onClick={() => machine.proceedToPayment()} className="btn btn-primary flex-1"> Continue to Payment </button> </div> </div> );}
export function PaymentForm({ machine, handleAsyncProcessing,}: { machine: CheckoutMachine; handleAsyncProcessing: (data: PaymentData) => void;}) { const cart = machine.store.getState().cart; const shipping = machine.store.getState().shipping; const [cardNumber, setCardNumber] = useState(""); const [expiryDate, setExpiryDate] = useState(""); const [cvv, setCvv] = useState("");
const error = machine.store.getState().error;
return ( <div> <h2 className="text-base font-semibold mb-4">Payment</h2> <ErrorBanner error={error} />
<div className="bg-muted rounded-xl px-4 py-3 mb-5"> <p className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground mb-1"> Shipping to </p> <p className="text-sm">{shipping.address}</p> <p className="text-sm text-muted-foreground"> {shipping.city}, {shipping.zipCode} </p> </div>
<div className="space-y-4 mb-5"> <div> <FieldLabel>Card Number</FieldLabel> <input type="text" value={cardNumber} onChange={(e) => setCardNumber(e.target.value)} className={inputCls} placeholder="1234 5678 9012 3456" /> </div> <div className="grid grid-cols-2 gap-4"> <div> <FieldLabel>Expiry</FieldLabel> <input type="text" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className={inputCls} placeholder="MM/YY" /> </div> <div> <FieldLabel>CVV</FieldLabel> <input type="text" value={cvv} onChange={(e) => setCvv(e.target.value)} className={inputCls} placeholder="123" /> </div> </div> </div>
<div className="bg-muted rounded-xl p-4 mb-5 flex justify-between items-center"> <span className="text-[10px] font-mono uppercase tracking-widest text-muted-foreground">Total</span> <span className="text-xl font-bold tabular-nums">${cart.total?.toFixed(2) ?? "0.00"}</span> </div>
<div className="flex gap-3"> <button type="button" onClick={() => machine.backToShipping()} className="btn btn-outline flex-1"> Back </button> <button type="button" onClick={() => handleAsyncProcessing({ cart, shipping, payment: { cardNumber, expiryDate, cvv } })} className="btn btn-primary flex-1" > Place Order </button> </div> </div> );}import { useMachine } from "matchina/react";import { CartForm, PaymentForm, ShippingForm } from "./forms";import type { CheckoutMachine } from "./machine";
const steps = ["Cart", "Shipping", "Payment", "Done"] as const;type Step = (typeof steps)[number];
const stateToStep: Record<string, Step> = { Cart: "Cart", Shipping: "Shipping", Payment: "Payment", Processing: "Payment", Success: "Done", Failed: "Payment",};
function StepIndicator({ currentStep }: { currentStep: Step }) { const currentIndex = steps.indexOf(currentStep); return ( <div className="flex items-center gap-0 w-full mb-6"> {steps.map((step, i) => { const done = i < currentIndex; const active = i === currentIndex; return ( <div key={step} className="flex items-center flex-1 last:flex-none"> <div className="flex flex-col items-center gap-1"> <div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-mono transition-colors ${ done ? "bg-primary/30 text-primary border border-primary/40" : active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground border border-border" }`} > {done ? "✓" : i + 1} </div> <span className={`text-[9px] font-mono uppercase tracking-widest whitespace-nowrap ${ active ? "text-foreground" : "text-muted-foreground" }`} > {step} </span> </div> {i < steps.length - 1 && ( <div className={`flex-1 h-px mx-2 mb-4 ${i < currentIndex ? "bg-primary/40" : "bg-border"}`} /> )} </div> ); })} </div> );}
export function CheckoutView({ machine }: { machine: CheckoutMachine }) { useMachine(machine); useMachine(machine.store); const currentState = machine.getState(); const currentStep = stateToStep[currentState.key] ?? "Cart";
const handlePlaceOrder = async () => { machine.placeOrder(); await new Promise((r) => setTimeout(r, 800)); if (Math.random() > 0.3) { machine.store.api.setOrderId("ORD-" + Math.random().toString(36).substring(2, 9).toUpperCase()); machine.success(); } else { machine.store.api.setError("Payment declined. Please try a different card."); machine.failure(); } };
return ( <div className="max-w-lg mx-auto bg-card border border-border rounded-2xl p-6"> <StepIndicator currentStep={currentStep} />
{currentState.match({ Cart: () => <CartForm machine={machine} />, Shipping: () => <ShippingForm machine={machine} />, Payment: () => <PaymentForm machine={machine} handleAsyncProcessing={handlePlaceOrder} />,
Processing: () => ( <div className="flex flex-col items-center gap-5 py-8 text-center"> <div className="w-10 h-10 rounded-full border-2 border-border border-t-primary animate-spin" /> <div> <h2 className="text-base font-semibold mb-1">Processing order</h2> <p className="text-sm text-muted-foreground">Please wait while we confirm your payment.</p> </div> </div> ),
Success: () => { const storeData = machine.store.getState(); return ( <div className="flex flex-col items-center gap-5 py-6 text-center"> <div className="w-12 h-12 rounded-full bg-[oklch(0.55_0.16_142)]/10 border border-[oklch(0.55_0.16_142)]/20 flex items-center justify-center"> <span className="text-xl">✓</span> </div> <div> <h2 className="text-base font-semibold text-[oklch(0.55_0.16_142)] mb-1">Order confirmed</h2> <p className="text-xs font-mono text-muted-foreground">ID: {storeData.orderId}</p> </div> <div className="w-full bg-muted rounded-xl p-4 text-sm text-muted-foreground text-left"> Your order has been placed. A confirmation will be sent to your email shortly. </div> <button type="button" onClick={() => machine.newOrder()} className="btn btn-outline btn-sm"> Place Another Order </button> </div> ); },
Failed: () => { const storeData = machine.store.getState(); return ( <div className="flex flex-col items-center gap-5 py-6 text-center"> <div className="w-12 h-12 rounded-full bg-destructive/10 border border-destructive/20 flex items-center justify-center"> <span className="text-xl">✕</span> </div> <div> <h2 className="text-base font-semibold text-destructive mb-1">Order failed</h2> <p className="text-sm text-muted-foreground">{storeData.error}</p> </div> <div className="w-full bg-muted rounded-xl border border-destructive/20 p-4 text-sm text-muted-foreground text-left"> There was a problem processing your payment. You have not been charged. </div> <div className="flex gap-3"> <button type="button" onClick={() => machine.retry()} className="btn btn-primary btn-sm"> Try Again </button> <button type="button" onClick={() => machine.backToCart()} className="btn btn-outline btn-sm"> Back to Cart </button> </div> </div> ); }, })}
</div> );}import { useMemo } from "react";import { CheckoutView } from "./CheckoutView";import { createCheckoutMachine } from "./machine";
export function CheckoutDemo() { const machine = useMemo(createCheckoutMachine, []); return <CheckoutView machine={machine} />;}