Blocks / Auth and flows

Checkout

A one-page checkout with a live cart, order summary with VAT included, address, delivery and card payment.

Width
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

    Pairs with phones and tablets

    £89

    Line total £89.00

  • Thermal label printer

    USB-C and Bluetooth

    £149

    Line total £149.00

  • Shelf-edge labels

    Roll of 500

    £18 each

    Prints use-by dates and allergens

    Line total £72.00

  • Walk-in fridge temperature probe

    Logs every 5 minutes

    £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
Contact
Shipping address
Delivery
Payment

Charged once, when the kit leaves us.

Card

Card details go straight to the payment provider; Larder never sees the full number.

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:checkout

Built 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);
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index