Appearance
Checkout Steps
The Lynx checkout is one page made of steps: Address, Shipping Method and Payment out of the box, always in that order. Each step is an ordinary component with a provider. A module can add a step of its own, and the merchant places it in PageBuilder, in one of the two slots of the Checkout component between the built-in steps.
This page explains how the container and the steps work together, the contract a step implements, and builds a Delivery Instructions step as an example.
Before you add a step, check whether something smaller fits:
| You need | Use |
|---|---|
| Input that belongs to one carrier (a pickup store, a delivery date for that carrier) | A shipping renderer under its rate |
| Input that belongs to one payment method | A payment renderer |
| Content next to the checkout (badges, notes) | A component in the Below Order Summary slot |
| A question every shopper answers before paying | A checkout step (this page) |
Claude Code
The Lynx Claude Code skills can add a checkout step for you, following this page. See Working with Claude Code.
How the checkout is put together
Checkout (default_checkout)
├── express express payment buttons
│ Address (default_checkout_step_shipping_address, rendered by the container)
├── before_shipping_method extra steps, in slot order
│ Shipping Method (default_checkout_step_shipping_method, rendered by the container)
├── before_payment extra steps, in slot order
│ Payment (default_checkout_step_payment, rendered by the container)
└── sidebar_after content below the order summaryThe container (OOCS_LynxCheckout::Components/Checkout and the checkout store) owns the flow. A step owns its card.
| The container | A step |
|---|---|
Reads the flow (steps prop: the built-in steps with the visible nodes of each slot between them) and the URL that answers for them (steps_url) | Registers itself with the store when it mounts |
| Decides which step is current, starts at the first step that takes part, moves forward and back | Draws its card: heading, form while current, a collapsed summary once done, nothing else while not reached |
| Fetches the target step's props from the server and saves the step before it | Validates its own input and calls nextStep() when the shopper presses Continue |
Keeps the shared draft (email, addresses, shipping and payment method) in localStorage, per visitor | Contributes its own data to what is posted on every step request |
| Shows the order summary, coupon, terms and the express buttons | Shows its own inline errors |
Gives the last step a place below the cards for its main action ([data-checkout-actions], where Payment puts Place Order) |
What happens when the shopper presses Continue
The step validates its input in the browser. If something is wrong, it shows the error and stops.
It calls
checkout.nextStep(). The store marks the next step as current at once (the previous card collapses to its summary) and POSTs tosteps_url(/lynx/checkout/process):json{ "form_key": "…", "step": "vendor_checkout_step_delivery_notes", "steps": ["default_checkout_step_shipping_address", "default_checkout_step_shipping_method", "vendor_checkout_step_delivery_notes", "default_checkout_step_payment"], "data": { "shippingAddress": { … }, "shippingMethod": "flatrate_flatrate", "vendorDeliveryInstructions": "…" } }stepslists only the steps that take part (a skipped step is left out).datais the merge of every taking-part step'sdata().Controller\Checkout\Processlooks up the provider ofstepin the component registry. If there is none, or itsoptional()is nottrue, it answers 404 and nothing is saved.It finds the step right before the target in
steps. If that step's provider implementsOOCS\LynxCheckout\Api\CheckoutStepInterface, it callssave($data)on it.It calls
resolve($data)on the target step's provider and answers its props as JSON. The store puts them on the page under the step's node name, and the step component receives them as props.If
save()orresolve()throws aLocalizedException, the answer is{ "error": "…" }alone. The store shows the message as an error message and moves the shopper back to the step before the target.When the saved step registered
affectsTotals, the store reads the totals again.
The same request runs whenever a step becomes current: on page load for the first step, on Edit of a finished step, and again for the current step when an Inertia visit on the page drops its props. In every case the step saved is the one right before the target, not necessarily the one the shopper was looking at. Going back to the first step saves nothing.
Payment is not saved through this request: the payment step sends the method and billing address with Magento's payment-information REST call when the order is placed.
The step contract
Server: the provider
A step's provider extends OOCS\LynxComponent\Model\AbstractComponentProvider like any other (Component Providers). Two things are specific to steps:
| Method | Required | When it runs | What it gets |
|---|---|---|---|
optional(array $props): bool | Yes, return true | On the page, to leave the step's props out; on every step request, as the check in step 3 above | The node's settings on the page, the posted draft in a step request |
resolve(array $props): array | Yes | Only in a step request, when the step becomes current | The posted draft (data), not the node's settings |
save(array $draft): void (CheckoutStepInterface) | Only if the step writes to the quote | In the request that fetches the step after it | The posted draft, the same array resolve() gets |
- Every step must be optional.
Processrefuses a step whose provider is missing or not optional, and then also skips saving the step before it. A step with no data of its own still needs a small provider that returnstruefromoptional()and itslabelsfromresolve(). $draftholds every step's data, not only yours: the shipping address undershippingAddress, the method undershippingMethod, your keys under the names yourdata()used. It comes from the browser, so check types and values as you would any request input.- Make
save()idempotent and cheap. It runs each time the step after it is fetched, including after Edit and back navigation. Compare with what the quote already holds and return early, as the built-in steps do. - Throw
LocalizedExceptionwith a translated message to reject the data. The shopper is sent back to your step and sees the message. - The quote is the checkout session's quote (
Magento\Checkout\Model\Session::getQuote()), for guests and customers alike. CheckgetId()andisVirtual()before writing.
The built-in providers are the reference: Provider\Checkout\Step\ShippingAddress (validates and assigns the shipping address, unless its signature matches the quote's), Provider\Checkout\Step\ShippingMethod (saves shipping information, unless the quote already has that method) and Provider\Checkout\Step\Payment (no save(), lists the payment methods).
Client: the component
A step gets the checkout store with useCheckoutStore() from OOCS_LynxCheckout/stores/checkoutStore and calls register() once in <script setup>. The store finds the step's node name by itself, from the surrounding node.
register(registration) takes:
| Key | Type | Meaning |
|---|---|---|
title | string | The step's name in the previous step's Continue to … button |
skip | boolean | true when the step does not apply to this cart. It is left out of the flow and of steps in requests. The shipping steps pass checkout.isVirtual |
affectsTotals | boolean | true when saving the step changes the totals, so the summary is read again once the shopper has left it |
data | () => Record<string, unknown> | What the step adds to data in every step request. Use keys that cannot clash with other modules |
It returns the step's handle. The flags are computed refs (step.isDone.value).
| Member | Meaning |
|---|---|
index | The step's position. Put it on the card's root as data-step: the store scrolls to that element and focuses its h2 when the step becomes current |
isCurrent | The shopper is on this step: show the form and the Continue button |
isReached | Current or done |
isDone | The shopper has moved past it: show the collapsed summary with an Edit button |
loading | The step's props are on their way |
continueLabel | The text for the Continue button: "Continue to next step's title", or "Continue" |
number | The step's place among the steps the cart goes through, from 1; 0 while it takes no part. Put it before the heading when checkout.appearance.stepNumbering is on |
Store members a step uses:
| Member | Meaning |
|---|---|
nextStep() / prevStep() | Move to the next or previous step that takes part |
goTo(index) | Move to a step, for the Edit button: checkout.goTo(step.index) |
labels | The container's translated labels, available from the first paint (step headings, continue, continueTo) |
isVirtual | The cart has nothing to ship |
draft | The shared draft. Its shape is fixed (CheckoutDraft); keep your own values in the component |
stepCardClass | Classes of the step's root, card or flat as the merchant set Step Style on the Checkout component |
stepButtonClass | Classes of the built-in Continue button, following Continue Button Width |
appearance | The Checkout component's step settings: stepNumbering, plus what the built-in steps read |
inputClass(invalid) | Classes of the built-in text inputs |
refreshTotals() | Read the totals again, if your step changes them outside a step request |
Errors. Show inline errors in the step and mark them with data-checkout-error, as the built-in fields do; the built-in steps scroll the first one into view with document.querySelector('[data-checkout-error]'). Errors from save() arrive as an error message from the store.
Props. On the page, the step node carries only its saved settings and _key. The provider's props arrive with the step request, so props from resolve() are undefined until the step is first reached. The heading must show from the first paint, before that request, so it cannot come from the step's own labels; the built-in steps read it from the container's checkout.labels. A module adds its heading there with a plugin on the container's provider (see the example).
Registering the component
Declare the step in an etc/components/<Template>.graphqls file of your module, like any component (Creating Components), with these arguments of @component:
| Argument | Value |
|---|---|
group | checkout_step, the only group the Before Shipping Method and Before Payment slots accept |
availableOn | ["checkout_index_index"], so the step is offered only on the checkout page |
zones | ["content"] |
provider | Your provider class |
The code is also the node name the store and Process look the step up by. Each slot holds a component once (max: 1 on the slot). Declare every prop resolve() returns as @optional, since the props are undefined until the step is first reached: Process passes the step's props through the .graphqls declaration like a page render does, so an undeclared key never reaches the component.
Example: Delivery Instructions
Vendor_DeliveryNotes adds a step between Shipping Method and Payment: a text area for instructions to the courier and a "leave the package at the door" checkbox. The values are saved on the quote and copied to the order.
app/code/Vendor/DeliveryNotes/
├── etc/
│ ├── module.xml
│ ├── db_schema.xml
│ ├── fieldset.xml
│ ├── components/DeliveryNotes.graphqls ← the step component and its props
│ └── frontend/di.xml ← the heading plugin
├── i18n/en_US.csv
├── Plugin/AddStepHeading.php
├── Provider/DeliveryNotes.php
├── registration.php
└── view/frontend/lynx/
└── vue/src/Components/Checkout/Step/DeliveryNotes.vueModule and storage
etc/module.xml loads after the checkout module. In composer.json, require oocscommerce/module-lynx-checkout.
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Vendor_DeliveryNotes">
<sequence>
<module name="OOCS_LynxCheckout"/>
</sequence>
</module>
</config>Storing the values is plain Magento and your module's job: one column per value on quote and on sales_order, etc/db_schema.xml:
xml
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="quote">
<column xsi:type="text" name="vendor_delivery_instructions" nullable="true" comment="Delivery Instructions"/>
<column xsi:type="smallint" name="vendor_leave_at_door" unsigned="true" nullable="false" default="0"
comment="Leave At Door"/>
</table>
<table name="sales_order">
<column xsi:type="text" name="vendor_delivery_instructions" nullable="true" comment="Delivery Instructions"/>
<column xsi:type="smallint" name="vendor_leave_at_door" unsigned="true" nullable="false" default="0"
comment="Leave At Door"/>
</table>
</schema>Generate db_schema_whitelist.json with bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_DeliveryNotes.
Magento copies quote fields to the order when the order is placed, for the fields listed in the sales_convert_quote fieldset. etc/fieldset.xml:
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:DataObject/etc/fieldset.xsd">
<scope id="global">
<fieldset id="sales_convert_quote">
<field name="vendor_delivery_instructions">
<aspect name="to_order"/>
</field>
<field name="vendor_leave_at_door">
<aspect name="to_order"/>
</field>
</fieldset>
</scope>
</config>Showing the values in the admin order view, emails or the REST API is also up to your module, as for any order attribute.
The provider
Provider/DeliveryNotes.php:
php
<?php
declare(strict_types=1);
namespace Vendor\DeliveryNotes\Provider;
use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Framework\Exception\LocalizedException;
use Magento\Quote\Api\CartRepositoryInterface;
use OOCS\LynxCheckout\Api\CheckoutStepInterface;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
class DeliveryNotes extends AbstractComponentProvider implements CheckoutStepInterface
{
private const MAX_LENGTH = 250;
private const FIELD_INSTRUCTIONS = 'vendor_delivery_instructions';
private const FIELD_LEAVE_AT_DOOR = 'vendor_leave_at_door';
public function __construct(
private readonly CheckoutSession $checkoutSession,
private readonly CartRepositoryInterface $quoteRepository,
) {
}
/**
* Fetched when the shopper reaches the step; every step must be optional
*
* @param array $props
* @return bool
*/
public function optional(array $props): bool
{
return true;
}
/**
* What the quote holds, so going back or reloading shows the saved values
*
* @param array $props the posted draft
* @return array
*/
public function resolve(array $props): array
{
$quote = $this->checkoutSession->getQuote();
$props['instructions'] = (string) $quote->getData(self::FIELD_INSTRUCTIONS);
$props['leave_at_door'] = (bool) $quote->getData(self::FIELD_LEAVE_AT_DOOR);
$props['max_length'] = self::MAX_LENGTH;
$props['labels'] = [
'instructions' => (string) __('Instructions for the courier'),
'leaveAtDoor' => (string) __('Leave the package at the door'),
'tooLong' => (string) __('Please enter less or equal than %1 symbols.', self::MAX_LENGTH),
'none' => (string) __('None'),
'edit' => (string) __('Edit'),
];
return $props;
}
/**
* Write the step's values to the quote, unless it already holds them
*
* @param array $draft
* @return void
* @throws LocalizedException
*/
public function save(array $draft): void
{
$instructions = $draft['vendorDeliveryInstructions'] ?? null;
$quote = $this->checkoutSession->getQuote();
if (!\is_string($instructions) || !$quote->getId() || $quote->isVirtual()) {
return;
}
$instructions = trim($instructions);
$leaveAtDoor = !empty($draft['vendorLeaveAtDoor']) ? 1 : 0;
if (mb_strlen($instructions) > self::MAX_LENGTH) {
throw new LocalizedException(__('Please enter less or equal than %1 symbols.', self::MAX_LENGTH));
}
if ((string) $quote->getData(self::FIELD_INSTRUCTIONS) === $instructions
&& (int) $quote->getData(self::FIELD_LEAVE_AT_DOOR) === $leaveAtDoor
) {
return;
}
$quote->setData(self::FIELD_INSTRUCTIONS, $instructions !== '' ? $instructions : null);
$quote->setData(self::FIELD_LEAVE_AT_DOOR, $leaveAtDoor);
$this->quoteRepository->save($quote);
}
}The phrases "Please enter less or equal than %1 symbols.", "None" and "Edit" are Magento's own; add the two new ones to i18n/en_US.csv, together with the heading:
csv
"Delivery Instructions","Delivery Instructions"
"Instructions for the courier","Instructions for the courier"
"Leave the package at the door","Leave the package at the door"The heading
The heading shows before the step is reached, so it goes into the container's labels. Plugin/AddStepHeading.php:
php
<?php
declare(strict_types=1);
namespace Vendor\DeliveryNotes\Plugin;
use OOCS\LynxCheckout\Provider\Checkout\Checkout;
class AddStepHeading
{
/**
* Add the step's heading to the container's labels
*
* @param Checkout $subject
* @param array $result
* @return array
*/
public function afterResolve(Checkout $subject, array $result): array
{
$result['labels']['deliveryInstructions'] = (string) __('Delivery Instructions');
return $result;
}
}etc/frontend/di.xml:
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="OOCS\LynxCheckout\Provider\Checkout\Checkout">
<plugin name="vendor_deliverynotes_step_heading" type="Vendor\DeliveryNotes\Plugin\AddStepHeading"/>
</type>
</config>Registering the step
etc/components/DeliveryNotes.graphqls:
graphql
"Instructions for the courier and a leave-at-door choice, saved on the order"
type VendorCheckoutStepDeliveryNotes @component(
code: "vendor_checkout_step_delivery_notes"
template: "Vendor_DeliveryNotes::Components/Checkout/Step/DeliveryNotes"
provider: "Vendor\\DeliveryNotes\\Provider\\DeliveryNotes"
label: "Delivery Instructions"
group: "checkout_step"
icon: "ph-note-pencil"
zones: ["content"]
availableOn: ["checkout_index_index"]
) {
instructions: String! @optional
leave_at_door: Boolean! @optional
max_length: Int! @optional
"Every string the step shows, translated by the provider"
labels: StringMap! @optional
}The components take their props type, Lynx.VendorCheckoutStepDeliveryNotes, from this declaration.
The component
The card follows the built-in steps: the heading always, the form while current, a summary with Edit once done. The values live in the component and are seeded from the quote each time the provider answers.
vue
<!-- DeliveryNotes.vue -->
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useCheckoutStore } from 'OOCS_LynxCheckout/stores/checkoutStore'
// From Provider\DeliveryNotes, undefined until the step is first reached
const props = withDefaults(defineProps<Lynx.VendorCheckoutStepDeliveryNotes>(), {
instructions: undefined,
leave_at_door: false,
max_length: 250,
labels: () => ({}),
})
const checkout = useCheckoutStore()
const form = reactive({ instructions: '', leaveAtDoor: false })
const error = ref('')
const step = checkout.register({
title: checkout.labels.deliveryInstructions ?? '',
skip: checkout.isVirtual,
data: () => ({ vendorDeliveryInstructions: form.instructions.trim(), vendorLeaveAtDoor: form.leaveAtDoor }),
})
// Seed from the quote whenever the provider answers
watch(
() => [props.instructions, props.leave_at_door] as const,
([instructions, leaveAtDoor]) => {
if (instructions === undefined) return
form.instructions = instructions
form.leaveAtDoor = leaveAtDoor
},
{ immediate: true }
)
function submit() {
error.value = form.instructions.trim().length > props.max_length ? (props.labels.tooLong ?? '') : ''
if (!error.value) checkout.nextStep()
}
</script>
<template>
<div
v-if="!checkout.isVirtual"
:data-step="step.index"
:class="checkout.stepCardClass"
>
<h2
class="text-lg font-semibold text-base-content"
:class="step.isReached.value ? 'pb-3 mb-4 border-b border-base-300' : ''"
>
<template v-if="checkout.appearance.stepNumbering && step.number.value">{{ step.number.value }}. </template
>{{ checkout.labels.deliveryInstructions }}
</h2>
<div
v-if="step.isDone.value"
class="flex items-start justify-between gap-3 rounded-box border-(length:--border) border-base-300 p-3"
>
<div class="text-base text-base-content/80">
<p v-if="form.leaveAtDoor" class="font-semibold text-base-content">{{ labels.leaveAtDoor }}</p>
<p v-if="form.instructions" class="whitespace-pre-line">{{ form.instructions }}</p>
<p v-if="!form.leaveAtDoor && !form.instructions">{{ labels.none }}</p>
</div>
<button
type="button"
class="-m-2 p-2 bg-transparent border-none cursor-pointer text-sm font-semibold text-link"
@click="checkout.goTo(step.index)"
>
{{ labels.edit }}
</button>
</div>
<template v-else-if="step.isCurrent.value">
<div v-if="step.loading.value" class="l-skeleton h-32 w-full" aria-busy="true"></div>
<div v-else class="grid gap-4">
<label class="grid gap-1">
<span class="text-base font-semibold text-base-content">{{ labels.instructions }}</span>
<textarea
v-model="form.instructions"
rows="3"
:maxlength="max_length"
:class="['l-textarea w-full', error ? 'l-textarea-error' : '']"
></textarea>
</label>
<p v-if="error" data-checkout-error class="text-sm text-error">{{ error }}</p>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="form.leaveAtDoor"
type="checkbox"
class="l-checkbox l-checkbox-xs l-checkbox-primary shrink-0"
/>
<span class="text-base text-base-content">{{ labels.leaveAtDoor }}</span>
</label>
</div>
<button type="button" :class="checkout.stepButtonClass" :disabled="step.loading.value" @click="submit">
{{ step.continueLabel.value }}
</button>
</template>
</div>
</template>The browser check and the maxlength attribute are for the shopper's convenience; save() enforces the limit.
To report the step to analytics, track an event of your own on the analytics bus in submit() before nextStep(), as the shipping method step does with add_shipping_info. See Forms and Analytics.
Install, build and place the step
bash
bin/magento module:enable Vendor_DeliveryNotes
bin/magento setup:upgrade
bin/magento lynx:theme:setup OOCS_LynxVue # adds the module to the build
bin/magento lynx:static:deploy # also writes the Lynx.* props types from the .graphqls
bin/magento lynx:compile # when the store uses compiled configuration
bin/magento cache:cleanThe step shows once the merchant places it. In PageBuilder, open the Checkout page, select the Checkout component, and use Add to Before Payment to add Delivery Instructions. The default design is read-only, so this is done in the merchant's own design (Designs). Put this in your installation guide; do not write the merchant's checkout layout from a data patch, it belongs to their design.
Modifying the built-in steps
The built-in steps are not layout nodes: the Checkout component renders them in their fixed order, and their declarations are hidden: true, kept only for Process to find their providers. Their look follows the Checkout component's Steps settings. You do not have to replace a built-in step to change it.
- Add or change data: an
afterResolveplugin on the step's provider (OOCS\LynxCheckout\Provider\Checkout\Step\ShippingAddress,ShippingMethodorPayment) adds props or changes labels. The component receives whateverresolve()returns. - Save more when a step is left: a
beforeSaveplugin onShippingAddressorShippingMethodreads your keys from$draftand writes them to the quote before the step's own save. In-Store Pickup'sOOCS\LynxMSI\Plugin\SavePickupLocationdoes this onShippingMethod(Saving the renderer's data).Paymenthas nosave(). - Input for one carrier or payment method: a shipping renderer under the rate (with
validate,dataandsummary) or a payment renderer. - Change the markup: override the step's
.vuefile in a theme (Overriding components). Keep theregister()call,data-stepon the root, and thedata()keys the providers read. - Address fields: configured by the merchant in Stores › Attributes › Customer Address Fields, see Address form fields.
Limits and things to know
- Pick the slot by what the step reads. In Before Shipping Method the address is already on the quote; in Before Payment the carrier is saved too and the totals are collected again. Payment is always last: it has no Continue button, and Place Order appears below the cards as soon as it is reached.
- Every step in the flow must register. The flow starts once every step listed in
stepshas calledregister(), so call it even when the step does not apply, withskip: true. A component that never registers keeps the checkout from starting. A step whose component is not in the build is left out ofstepsand skipped. - One node per step. Each slot holds a component once, and the store and
Processfind a step by itscode. A hidden node (eye icon) is not part of the flow. - Virtual carts. The container sends
is_virtual; the shipping steps register withskip: checkout.isVirtualand render nothing. Do the same if your step is about shipping, and checkisVirtual()insave()too. - Node settings do not reach the provider.
resolve()andsave()get the posted draft, and on the page the step's provider is not resolved. Settings that need a provider to resolve (_i18n,image,url) are therefore not usable on a step; plain settings reach the component as props. - The draft is the container's. Only the built-in fields are kept in
localStorage. After a reload the checkout starts again at the first step; your step shows what the quote holds once it is reached again, so values typed but not yet saved are lost. - Guests and customers. The same provider runs for both; read the quote from the checkout session. The shared draft in
localStorageis dropped when it belongs to a different visitor. - Steps load their own code. Load everything through
import. The checkout pins the hash of every chunk (Subresource integrity on the checkout), and external scripts need your module'scsp_whitelist.xmland the page nonce (checkout.cspNonce), as described in Payment Methods. - Style with design tokens and the
l-controls, smallest texttext-sm(Styling). - Editor preview. The checkout preview uses the admin browser's own cart, with real data: add a product to the cart first. Steps added in the editor appear in the preview before the layout is saved, and the step requests save to that real quote.
Testing checklist
- Guest checkout, and signed in with saved addresses.
- A virtual cart: the shipping steps and any shipping-related step of yours drop out, and the Continue to … labels follow.
- Edit on each finished step, then Continue again: the values are kept and
save()does no unnecessary writes. - A value your
save()rejects: the message shows and the shopper is back on your step. - Reload in the middle of the checkout: the draft is restored and your step shows the saved values when reached.
- The order placed carries your values (here,
sales_order.vendor_delivery_instructions). - The editor preview of the checkout page with the step added.