Guide 08 · Developer docs

Commerce

A Core dna store is a set of records and an API that moves a purchase from cart to order: products described in every language and priced in every currency the store sells in, a cart, a checkout that becomes an order, invoices, payments, tax and shipping. The site builds the storefront on top: the cart and checkout pages, their scripts, and the customer emails.

L2–L3
Unit 03 · Building the checkout

The single-page checkout

A checkout page is a small application. Seed its state from the server, send every call through one API module, give each concern its own component, and save what the shopper has typed so that a reload loses nothing.

Who this is for. Front-end developers building a checkout page, or restructuring the script of one that has grown. After this page you can lay out the page's script so that each part has one owner, decide what is shared with the cart page, and keep a shopper's typing through a reload.

Three rules

A checkout page holds a dozen inputs, several API calls, totals that change after each one, and a payment step from an outside provider. Three rules keep it manageable. They are the arrangement of a production multilingual store, which this page follows:

  1. Server-seeded state. The template prints the checkout's starting state into the page; the script does not fetch it on load.
  2. One API layer. One module makes every call to /cdna-api/checkout/<id>/… and hands each response to the state.
  3. A component per concern. Addresses, shipping, the discount code, metadata, the donation and payment are each a small class that reads the state and asks the API layer for changes.

Seed the state from the server

GET /checkout renders checkouts/single-page.html with the checkout record (The pages and the API). Print the values the script starts from into an inline script, once, so that they are there before the first API call:

Template code checkouts/single-page.html
<script nonce="{$_inlineScriptNonce}">
	window.checkout = {
		uuid: '{$checkout->getUuid()}',
		currency: '{$checkout->getCurrencyCode()}',
		total: {$checkout->getTotalAsMoney()->getValue()}
	};
</script>
<script nonce="{{ _inlineScriptNonce }}">
	window.checkout = {
		uuid: '{{ checkout.getUuid() }}',
		currency: '{{ checkout.getCurrencyCode() }}',
		total: {{ checkout.getTotalAsMoney().getValue() }}
	};
</script>

The inline script carries the page's nonce, which the site's content security policy requires of every inline script. The three values are machine values (a uuid, a currency code and a number), so they print directly. The currency is the checkout's one currency, copied from the cart (Its currency, and the language of its lines).

The production store seeds a little more the same way: the site id, the cart's uuid, and a flattened list of the items (id, name, amount and product code). The item names there are the names stored on the lines, in the client's default language. It prints the signed-in state and the CSRF token as globals of their own, so that window.checkout holds only the checkout's data.

From then on the state changes only through API responses.

One API layer

One module, checkout/api/checkout.js, is the only code that calls the checkout API. It has a function per call: update (the PUT that writes the person, addresses and metadata), purchase, addDiscountCode, removeDiscountCode and clearDiscountCode, getShippingRates and selectShippingRate. Each call sends the X-Csrf-Token header from the token the page received at load, and every successful update or purchase response goes straight into the state.

Nothing else calls fetch() on a checkout route. A component that needs a change asks the API layer, then reads the result from the state. That gives one place to add the CSRF header, normalise errors and build a properties selector, and one place to look when a call needs attention.

One state object

checkout/state/checkout-state.js is a closure around one state object, with three kinds of function:

  • init(), called once with the server-seeded data;
  • update(), which merges in whatever the API layer hands back after a call;
  • read-only getters for each value a component needs: getPerson(), getDeliveryAddress(), getBillingAddress(), getItems(), getDiscountTotal(), getShippingTotal(), getTaxTotal(), getTotal(), getCurrency() and so on.

Each getter returns a copy, not the live object, so no component can change shared state by accident. A component that wants something changed goes through the API layer, and the response updates the state for every component at once.

A component per concern

Each concern is a small class in checkout/components/ that reads the state and calls the API layer, and never calls a checkout route itself:

ComponentIts job
AddressesThe delivery and billing fields, and the "same as delivery" switch that keeps billing in step
ShippingDelivery or pickup, rates for the shopper's country, loading and error states
Discount codeApplies, removes and clears a code, one at a time
Metadata fieldsCollects every metadata input and writes them with one PUT
DonationAdds or removes a donation line, then starts the checkout again
Places autocompleteSuggests addresses and fills the same fields the shopper could type into
Form handlerThe submit button, the check for an existing account, and the submission
PersistenceSaves and restores what the shopper has typed
CaptchaWraps the reCAPTCHA widget
Payment methodsLists the available methods and selects one, with one adapter per provider behind a common shape

The entry point, checkout/index.js, wires them up on DOMContentLoaded in a fixed order:

  1. the state, from the seeded data;
  2. restoring saved typing;
  3. the address fields;
  4. the form groups;
  5. the captcha;
  6. the submit button;
  7. the handlers for the other concerns;
  8. the payment methods, last, because they need the resolved total and currency.

The directory structure

The production store's checkout script, as it is laid out:

checkout/
  index.js                       entry point: DOMContentLoaded wiring, in call order
  api/checkout.js                the only file that calls /cdna-api/checkout/*
  state/checkout-state.js        one state object; init() once, update() after every API call
  components/
    addresses-handler.js         same-as-delivery toggle, billing and delivery sync
    checkout-fields.js           typed getters over the address and person form fields
    discount-code-handler.js     apply, remove, clear a code
    donation-handler.js          add or remove a donation line, recreate the checkout, reload
    form-data-persistence.js     debounced save and restore of address and email fields
    form-handler.js              submit button, existing-account check, submission flow
    google-places-handler.js     address suggestions; fills the same named inputs
    metadata-fields-handler.js   serialises metadata inputs, PUTs them
    payment-method-handler.js    renders and selects the available payment methods
    shipping-handler.js          shipping method choice for this page
  payment-methods/
    mock-pay.js, stripe.js       one adapter per provider, common shape
  types/
    payment-method.ts, stripe-payment-method.ts
shared/
  api/
    api-client.js                fetch wrapper, errors normalised
    checkout.js                  thinner checkout helper used elsewhere
    payment-methods.js           GET the active payment methods
  components/
    express-payment-handler.js   Apple Pay, Google Pay and other wallet buttons
    payment-response-handler.js  turns a purchase response into redirect, reload or error
    poller.js                    polls payment-processing until it resolves
    recaptcha-handler.js
    utm-metadata.js
    shipping-handler.js          the cart page's own shipping widget
  express-checkout/
    stripe.js
utils/
  alert-modal.js, css-loader.js, custom-form-settings.js, global-event-listeners.js,
  globals.js, phone-input.js, utilities.js, utm-capture.js

Shared code or checkout code

Code the cart page also needs lives outside checkout/: the generic API client, the call that lists payment methods, the express payment (wallet) buttons, the handler that turns a purchase response into a redirect, reload or error, the payment-processing poller, phone input, UTM capture and the alert helper. The rule for each folder:

  • A file in checkout/components/ may read the checkout state and call the checkout API layer.
  • A file in shared/ must not assume it runs on the checkout page.
  • A file in utils/ knows nothing about any page.

The two shipping handlers show where the line falls. The cart page and the checkout page call the same API but show shipping differently, so each has its own handler: shared/components/shipping-handler.js for the cart's widget, checkout/components/shipping-handler.js for the checkout.

Keeping what the shopper typed

Everything already sent to the API stays on the checkout: the person, addresses, metadata values, discount codes, fulfilments and rates. It comes back on the next GET /checkout, found through the checkout cookie (Secure, HttpOnly, SameSite=Strict, kept for Checkouts.lifetime). Only what the shopper has typed and the page has not yet sent needs saving in the browser. That part matters: checkout reloads happen (a donation added, a delivery option changed), and a form that comes back empty loses the sale.

The production store's persistence component:

  • saves only the delivery and billing address fields (names, both address lines, suburb, state, postcode, country and phone) and person.email, to localStorage under checkout_form_data, 500 ms after typing stops; phone country and number pairs go under a second key, checkout_phone_data, restored separately;
  • saves again on the "same address" checkbox, and immediately before any action that reloads the page, such as adding a donation or changing shipping;
  • restores on load, after a short delay, and only into fields that are empty, so it never overwrites a value the server seeded or the shopper has typed since.
JavaScript checkout/components/form-data-persistence.js
const STORAGE_KEY = 'checkout_form_data';
const ADDRESS_FIELDS = ['firstName', 'lastName', 'addressLine1', 'addressLine2',
	'suburb', 'stateName', 'postcode', 'countryName', 'phone'];
// an explicit list: nothing payment-related, ever
const FIELDS = [
	...ADDRESS_FIELDS.map((field) => `deliveryAddress.${field}`),
	...ADDRESS_FIELDS.map((field) => `billingAddress.${field}`),
	'person.email',
];

export function saveFormData() {
	const data = {};
	for (const name of FIELDS) {
		const input = document.querySelector(`[name="${name}"]`);
		if (input && input.value) data[name] = input.value;
	}
	try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) {}
}

export function restoreFormData() {
	let data = {};
	try { data = JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } catch (e) {}
	for (const [name, value] of Object.entries(data)) {
		const input = document.querySelector(`[name="${name}"]`);
		if (input && !input.value) input.value = value; // fill empty fields only
	}
}

let saveTimeout;
document.addEventListener('input', (event) => {
	if (!FIELDS.includes(event.target.name)) return;
	clearTimeout(saveTimeout);
	saveTimeout = setTimeout(saveFormData, 500);
});
Never save payment details

The saved list names its fields one by one, and none of them is about payment: no payment method, no card field. Card fields belong to the provider's own elements, which never expose card data to the page's script. Keep the list explicit, so that a field added to the form later is not saved by accident, and remove the saved entry once the order is complete.

Go deeper

Was this page helpful?

Your answer goes to the docs team.