Blocks / Auth and flows
Checkout
A one-page checkout with a live cart, order summary with VAT included, address, delivery and card payment.
LarderShop
Check out your shelf kit
Everything pairs with Larder on first switch-on. Thirty days to send any of it back.
Your order
- Bluetooth barcode scanner
£89
Line total £89.00
- Thermal label printer
£149
Line total £149.00
- Shelf-edge labels
£18 each
Prints use-by dates and allergens
Line total £72.00
- Walk-in fridge temperature probe
£39 each
Line total £78.00
Show order summaryHide order summary£372.95
- Subtotal (8 items)
- £388.00
- Code OPENKITCHEN
- Discount, minus £20.00
- Shipping
- £4.95
- Total
- £372.95
- Includes 20% VAT
- £62.16
Install
Writes the block to components/blocks/checkout.tsx and its .css, and every part it uses to components/equip/, with the parts those are built from.
npx https://equip.studio311.app/equip.tgz add block:checkoutBuilt from 12 parts
Notes
- Cart lines and the delivery choice live in the block's state, so the summary and the Pay button always show the same total.
- Prices include 20% VAT, so the VAT row is one sixth of the total and is stated under it rather than added.
- The cart's own subtotal row is left out with showSubtotal={false} because the order summary repeats it with discount, delivery and VAT.
- Card details should go to your payment provider's hosted fields or SDK; the Form action here only simulates the charge.
Source
components/blocks/checkout.tsx273 lines
"use client";
import { useId, useState } from "react";
import { AddressForm } from "@/components/equip/address-form";
import { Button } from "@/components/equip/button";
import { CardField } from "@/components/equip/card-field";
import { Cart, type CartItem } from "@/components/equip/cart";
import { Checkbox } from "@/components/equip/checkbox";
import { Field } from "@/components/equip/field";
import { Form, FormActions, FormSection } from "@/components/equip/form";
import { Heading } from "@/components/equip/heading";
import { Icon } from "@/components/equip/icon";
import { OrderSummary } from "@/components/equip/order-summary";
import { PlanPicker } from "@/components/equip/plan-picker";
import { Text } from "@/components/equip/text";
import "./checkout.css";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const money = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
/* Line drawings of the kit, one accent each from the chart palette. Decorative: the title names the line. */
const draw = {
viewBox: "0 0 48 48",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
function Scanner() {
return (
<svg {...draw}>
<path d="M14 12h16a4 4 0 0 1 4 4v3a4 4 0 0 1-4 4h-5l-3 13a2.5 2.5 0 0 1-5-1l2-12h-5a3 3 0 0 1-3-3v-5a3 3 0 0 1 3-3z" />
<path d="M34 15.5h4M34 19.5h4" stroke="var(--eq-chart-1)" />
</svg>
);
}
function Printer() {
return (
<svg {...draw}>
<rect x="10" y="16" width="28" height="16" rx="3" />
<path d="M16 32v6h16v-6" />
<path d="M17 16v-6h14v6" />
<path d="M15 22h6" stroke="var(--eq-chart-2)" />
</svg>
);
}
function Labels() {
return (
<svg {...draw}>
<circle cx="18" cy="22" r="9" />
<circle cx="18" cy="22" r="3" />
<path d="M18 31h18v6H18" />
<path d="M24 34h4M31 34h2" stroke="var(--eq-chart-3)" />
</svg>
);
}
function Probe() {
return (
<svg {...draw}>
<rect x="9" y="12" width="16" height="22" rx="3" />
<path d="M13 18h8" stroke="var(--eq-chart-4)" />
<path d="M17 25v3" />
<path d="M25 26c6 0 7 4 10 4h4" />
</svg>
);
}
const kit: CartItem[] = [
{ id: "scanner", title: "Bluetooth barcode scanner", options: "Pairs with phones and tablets", unitPrice: 89, quantity: 1, max: 6, media: <Scanner /> },
{ id: "printer", title: "Thermal label printer", options: "USB-C and Bluetooth", unitPrice: 149, quantity: 1, max: 3, media: <Printer /> },
{ id: "labels", title: "Shelf-edge labels", options: "Roll of 500", unitPrice: 18, quantity: 4, max: 20, media: <Labels />, note: "Prints use-by dates and allergens" },
{ id: "probe", title: "Walk-in fridge temperature probe", options: "Logs every 5 minutes", unitPrice: 39, quantity: 2, max: 8, media: <Probe /> },
];
type Delivery = "standard" | "next" | "collect";
const deliveries: { value: Delivery; label: string; description: string; price: number }[] = [
{ value: "standard", label: "Standard", description: "3 to 5 working days, tracked", price: 4.95 },
{ value: "next", label: "Next working day", description: "Order before 14:00, delivered before noon", price: 9.95 },
{ value: "collect", label: "Collect in Hackney Wick", description: "Ready from tomorrow at 10:00", price: 0 },
];
const DISCOUNT = 20;
const UK_POSTCODE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
/*
* Buying the Larder shelf kit. The cart and delivery choice live here, at the top, so the
* summary and the pay button always show the same total as the lines.
*/
export default function Checkout() {
const id = useId();
const titleId = `${id}-title`;
const orderId = `${id}-order`;
const [items, setItems] = useState(kit);
const [removed, setRemoved] = useState<Record<string, { item: CartItem; index: number }>>({});
const [delivery, setDelivery] = useState<Delivery>("standard");
const [sameBilling, setSameBilling] = useState(true);
const subtotal = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const count = items.reduce((sum, item) => sum + item.quantity, 0);
const empty = items.length === 0;
const discount = empty ? 0 : Math.min(DISCOUNT, subtotal);
const rate = deliveries.find((d) => d.value === delivery) ?? deliveries[0];
const shipping = empty ? 0 : rate.price;
const total = Math.max(0, subtotal - discount + shipping);
/* Prices include VAT at 20%, so the VAT inside a total is one sixth of it. */
const vat = Math.round((total / 6) * 100) / 100;
return (
<section className="blk-checkout" aria-labelledby={titleId}>
<div className="blk-checkout-inner">
<header className="blk-checkout-head">
<div className="blk-checkout-brand">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10" /><path d="M8 4v2.5a3 3 0 0 1-1.5 2.6A3 3 0 0 0 5 11.7V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6.3a3 3 0 0 0-1.5-2.6A3 3 0 0 1 16 6.5V4" /><path d="M5 13h14" /></svg>
<span>Larder</span>
<span className="blk-checkout-shop">Shop</span>
</div>
<Heading level={1} id={titleId} className="blk-checkout-title">
Check out your shelf kit
</Heading>
<Text tone="quiet">Everything pairs with Larder on first switch-on. Thirty days to send any of it back.</Text>
</header>
<section className="blk-checkout-side" aria-labelledby={orderId}>
<Heading level={2} size="s" id={orderId}>
Your order
</Heading>
<Cart
className="blk-checkout-cart"
items={items}
currency="GBP"
locale="en-GB"
showSubtotal={false}
onQuantityChange={(lineId, quantity) => setItems((all) => all.map((i) => (i.id === lineId ? { ...i, quantity } : i)))}
onRemove={(lineId) => {
const index = items.findIndex((i) => i.id === lineId);
if (index >= 0) setRemoved((r) => ({ ...r, [lineId]: { item: items[index], index } }));
setItems((all) => all.filter((i) => i.id !== lineId));
}}
onUndo={(lineId) => {
const entry = removed[lineId];
if (!entry) return;
setItems((all) => [...all.slice(0, entry.index), entry.item, ...all.slice(entry.index)]);
}}
emptyAction={
<Button variant="outline" onClick={() => setItems(kit)}>
Put the kit back
</Button>
}
/>
{!empty && (
<OrderSummary
className="blk-checkout-summary"
currency="GBP"
locale="en-GB"
subtotal={subtotal}
itemCount={count}
discounts={[{ label: "Code OPENKITCHEN", amount: discount }]}
shipping={shipping === 0 ? "free" : shipping}
tax={vat}
taxMode="included"
taxLabel="Includes 20% VAT"
collapsible="auto"
/>
)}
</section>
<Form
className="blk-checkout-form"
aria-label="Payment and delivery"
summaryTitle={(n) => (n === 1 ? "Fix 1 field to pay" : `Fix ${n} fields to pay`)}
validate={(values) => {
const errors: Record<string, string> = {};
for (const prefix of ["shipping-", "billing-"]) {
const postal = values[`${prefix}postal`];
if (values[`${prefix}country`] === "GB" && typeof postal === "string" && postal.trim() && !UK_POSTCODE.test(postal.trim())) {
errors[`${prefix}postal`] = "Enter a full postcode, such as E8 1BG.";
}
}
return errors;
}}
action={async () => {
await wait(900);
return {
message:
delivery === "collect"
? "Paid. Your kit will be ready to collect in Hackney Wick from 10:00 tomorrow."
: "Paid. A receipt and tracking link are on their way to your inbox.",
};
}}
>
{({ errors, pending }) => (
<>
<FormSection title="Contact">
<Field
id={`${id}-email`}
name="email"
type="email"
label="Email for the receipt"
defaultValue="priya@marrow.co.uk"
autoComplete="email"
required
error={errors.email}
/>
</FormSection>
<AddressForm
legend="Shipping address"
section="shipping"
namePrefix="shipping-"
defaultCountry="GB"
fields={["country", "name", "organization", "line1", "line2", "city", "region", "postal"]}
defaultValues={{ name: "Priya Raman", organization: "Marrow", line1: "41 Wilton Way", city: "London", postal: "E8 1BG" }}
errors={errors}
/>
<PlanPicker
label="Delivery"
className="blk-checkout-delivery"
name="delivery"
currency="GBP"
locale="en-GB"
value={delivery}
onValueChange={(value) => setDelivery(deliveries.find((d) => d.value === value)?.value ?? "standard")}
options={deliveries.map((d) => ({
value: d.value,
title: d.label,
description: d.description,
price: d.price === 0 ? ("free" as const) : d.price,
}))}
/>
<FormSection title="Payment" description="Charged once, when the kit leaves us.">
<CardField label="Card" required />
<Checkbox
name="billing-same"
label="Billing address same as shipping"
checked={sameBilling}
onChange={(event) => setSameBilling(event.currentTarget.checked)}
/>
</FormSection>
{!sameBilling && (
<AddressForm
legend="Billing address"
section="billing"
namePrefix="billing-"
defaultCountry="GB"
errors={errors}
/>
)}
<FormActions className="blk-checkout-actions">
<Button type="submit" variant="accent" size="l" loading={pending} disabled={empty} lead={<Icon name="lock" />}>
{empty ? "Add something to pay" : `Pay ${money.format(total)}`}
</Button>
</FormActions>
<Text size="s" tone="faint" className="blk-checkout-fine">
Card details go straight to the payment provider; Larder never sees the full number.
</Text>
</>
)}
</Form>
</div>
</section>
);
}components/blocks/checkout.css111 lines
@layer blocks {
.blk-checkout {
container: blk-checkout / inline-size;
background: var(--eq-bg);
color: var(--eq-ink);
}
.blk-checkout-inner {
display: grid;
grid-template-areas:
"head"
"side"
"form";
gap: var(--eq-space-6);
max-width: 76rem;
margin-inline: auto;
padding: calc(var(--eq-space-6) * 1.25) var(--eq-space-4);
}
.blk-checkout-head {
grid-area: head;
display: grid;
justify-items: start;
gap: var(--eq-space-2);
}
.blk-checkout-head > * {
margin: 0;
}
.blk-checkout-brand {
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
margin-bottom: var(--eq-space-3);
font-weight: var(--eq-weight-strong);
}
.blk-checkout-shop {
padding-inline-start: var(--eq-space-2);
border-inline-start: 1px solid var(--eq-line-strong);
color: var(--eq-ink-2);
font-weight: var(--eq-weight);
}
.blk-checkout-title {
font-size: clamp(1.75rem, 5cqi, 2.75rem);
line-height: 1.06;
letter-spacing: -0.025em;
text-wrap: balance;
}
/* ---- the order: a receipt-like panel ---- */
.blk-checkout-side {
grid-area: side;
display: grid;
align-content: start;
gap: var(--eq-space-4);
min-width: 0;
padding: var(--eq-space-4);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-surface);
}
.blk-checkout-side > h2 {
margin: 0;
}
.blk-checkout-summary {
padding-top: var(--eq-space-4);
border-top: 1px dashed var(--eq-line-strong);
}
/* ---- the form ---- */
.blk-checkout-form {
grid-area: form;
min-width: 0;
max-width: 36rem;
}
.blk-checkout-actions > * {
flex: 1 1 100%;
}
.blk-checkout-fine {
margin: 0;
}
@container blk-checkout (width >= 64rem) {
.blk-checkout-inner {
grid-template-columns: minmax(0, 1fr) 34rem;
grid-template-areas:
"head side"
"form side";
grid-template-rows: auto 1fr;
column-gap: calc(var(--eq-space-6) * 1.5);
padding-block: calc(var(--eq-space-6) * 2);
}
/* The order stays in view while the form scrolls past it. */
.blk-checkout-side {
position: sticky;
top: var(--eq-space-4);
align-self: start;
padding: var(--eq-space-5);
}
}
}