Cart and Checkout
Fourteen views make up the purchase funnel and share one focused shell. The order summary is portalled out of the document at wide sizes.
Overview
Configure, cart and checkout drop the public header and footer for a stripped shell with a step indicator. A visitor mid purchase should not be offered the way out.
Two things behave unlike anything else in a theme. The order summary is moved out of its container at desktop widths. The sidebar arrangement is a theme setting, not per page.
Structure
Four addresses, numbered by $checkout_step, plus what follows an order.
| Step | View | What the visitor does |
|---|---|---|
| 1 | checkout/configure | Cycle, domain, requirements, add-ons |
| 1 | checkout/configure-addon | An add-on for an owned service |
| 1 | checkout/configure-domain | Edits a domain line in the cart |
| 2 | checkout/cart | Reviews lines, applies a coupon, removes items |
| 3 | checkout/checkout | Account, billing and payment in one page |
| 3 | checkout/pay | An invoice, outside the cart |
| 4 | checkout/order-complete | Order confirmation |
| 4 | checkout/invoice-complete | Directly paid invoice confirmation |
| 4 | checkout/pay-result | Return from a redirect gateway |
The remaining five carry no layout.
checkout/checkout.tpl, one per card.
Step by Step
Wire the Funnel Shell
- Extend
layouts/checkout.tpl: step indicator, thin footer, shell script, no nav. - Do not compute the step in the view; the controller sets it.
- Put page scripts in
{block name=scripts}: the core bundle loads before it, the shell after. - Put modals in
{block name=body_end}: inside<main>a stacking context breaks a fixed overlay.
Build the Order Summary Correctly
Count the nesting here; indentation in a design file misleads.
- The aside must be a direct child of the split container and a sibling of the main column.
- Give the form an
idand attach outside submit buttons with theformattribute. The stack action bar and the portalled aside both land outside. - Build the summary from the shape the cart operations return; two shapes, two totals.
Offer the Sidebar Variants
- Declare
checkout_sidebaras a select: rail, card, stack. - Stamp the choice on the root element from the layout. Rail is the default, with no class.
- The shell script reads that class and skips the portal in card and stack mode.
Host the Payment Pane
- Give the payment section an empty pane the operation fills with returned HTML.
- A gateway may return nothing embeddable: the response reports a fallback and your footer button takes the visitor to the pay page.
- Never build your own gateway markup — the shared partial turns a redirect into one button.
Reference
Funnel Variables
Only two names are shared by the funnel. $cart_items in the checkout body prints and reports nothing.
| Cart page | Checkout page | Holds |
|---|---|---|
$cart_items | $checkout_items | The lines, priced and formatted |
$cart_summary | $checkout_summary | Discount groups, stacked taxes and savings |
$cart_subtotal, $cart_total | read from the summary | Figures printed outside the summary |
$cart_count | $cart_count | Recomputed from the lines; overrides the badge |
$coupon_enabled, $cart_has_unconfigured | not set | Cart only: coupons on, and unconfigured lines |
| not set | $payment_methods, $payment_default, $payment_locked | Checkout only: gateways, preselection, no-choice flag |
| not set | $is_member, $billing_profiles, $countries | Checkout only: account and billing cards |
visible and mode. Branch on visible first: a suppressed card arrives as ["visible" => false, "mode" => ""], not absent. license mode adds need_domain, need_ip, can_change; chooser adds tabs, tab_count, default_tab, subdomains, nameservers, check_url, free_json.
Functions the Funnel Needs
// {csrf form='<key>'} prints a hidden token input, scoped by key.
// $input = false returns the bare token instead of an input element.
public static function get_csrf_token($form_index = '', $input = true);
// {money amount=$x currency=$cid} formats one amount.
// $currency defaults to the visitor's selected currency when omitted.
public static function formatter_symbol($amount = 0, $currency = 0, $exchange = false, $info = false): string;
// {link route='cart'} and {link route='configure' p1=$type p2=$id}
// p1..p5 are COLLECTED in numeric order into one list, not indexed into it:
// skipping p1 does not leave a hole, it moves p2 into the first slot, so a link
// built with p2 alone silently addresses the wrong segment.
public static function client($route = '', $params = [], $lang = '');
// {captcha area='<area>' tray='<id>' class='mb-3' force=true} renders the active
// provider, or '' when the operator has not enabled captcha for that form area.
// $opts accepts exactly three keys, and the template function passes only these:
// tray id of a collapse wrapper; the slot is rendered closed inside it
// class wrapper classes; defaults to 'mt-2' with a tray and 'mb-3' without
// force render unconditionally and visibly, ignoring the per-area toggle
public static function widget(string $area = '', array $opts = []): string;
Gateway Pane Modes
| Mode | What the gateway returned | What reaches your pane |
|---|---|---|
html | Its own markup, a card form or hosted fields | That markup, embedded as is |
choices | Options, say instalments or bank accounts | The shared pay-choices partial |
redirect | A single destination address | The same partial with one choice: one button |
none | Nothing embeddable, a legacy full page form | Nothing; a fallback is reported, the footer button takes over |
Your script sees two of these. The first three arrive as mode: "html" with an html string, the fourth as mode: "fallback" with no markup.
What pay-choices Receives
Built from an explicit two key payload: layout, cart and account variables are out of scope.
$this->view->chose("website")->render("checkout/pay-choices", [
// One entry per button. A redirect gateway arrives as a list of exactly one.
'pay_choices' => [
[
'url' => 'https://gateway.example.com/session/abc',
'label' => 'Pay now', // the module's own button label
'image' => '', // when set, print the image INSTEAD of the label
],
],
// Optional heading above the buttons. Print nothing when it is empty.
'pay_choices_note' => '',
], true);
// The pay page (views/checkout/pay) sets the same two names itself, plus
// pay_mode, pay_html, pay_total_fmt, pay_stored_cards, pay_can_store,
// pay_can_autopay, pay_has_installments, pay_capture_url and pay_error.
Example
{extends file='layouts/checkout.tpl'}
{block name=scripts}
<script src="{asset path='js/checkout.js'}" defer></script>
{/block}
{block name=content}
<section>
<div class="container">
{* The form WRAPS the split, aside included. Give it an id even so: the
action bar below sits outside it and needs the explicit association. *}
<form method="post" novalidate data-checkout id="checkout-form">
{* Depth matters: the aside is a CHILD of checkout-split and a SIBLING of
checkout-split-main. One level deeper it collapses under the content in
card and stack mode, where the script does not portal it away. *}
<div class="checkout-split">
<div class="checkout-split-main">
<div class="checkout-split-main-inner">
{csrf form='checkout'}
{include file='views/checkout/section-account.tpl'}
{include file='views/checkout/section-billing.tpl'}
{include file='views/checkout/section-payment.tpl'}
</div>
</div>
{* On >=lg the shell script MOVES this node to <body>. Anything inside it
that must submit needs form="checkout-form" from that moment on. *}
<aside class="checkout-split-aside">
<div class="checkout-split-aside-inner">
{include file='views/checkout/section-rail-items.tpl'}
</div>
</aside>
</div>
</form>
{* Stack-mode action bar: OUTSIDE the form on purpose, so form="" is the only
thing that makes it submit. Same rule the portalled aside falls under. *}
<div class="checkout-actionbar{if $payment_locked} d-none{/if}" data-checkout-actionbar>
<span class="checkout-actionbar-value num-tabular" data-role="actionbar-total">{$checkout_summary.total_fmt}</span>
<button type="submit" form="checkout-form" class="btn btn-primary">
{lang key='website/checkout/place-order'}
</button>
</div>
</div>
</section>
{/block}
{* Modals live OUTSIDE main: the content block is wrapped in a stacking context
that breaks a fixed-position backdrop. *}
{block name=body_end}
<div class="modal" id="billingProfileModal" tabindex="-1"></div>
{/block}
// templates/website/{Theme}/theme.php returns the whole manifest.
return [
'meta' => ['name' => 'Acme', 'version' => '1.0.0', 'author' => 'Acme'],
'engine' => 'smarty',
'status' => 'ready',
'settings' => [
'groups' => [
'checkout' => ['label' => 'grp_checkout', 'icon' => 'bi-cart3'],
],
'fields' => [
// Read by layouts/checkout.tpl, which stamps a class on the root element.
// It is a THEME setting, not a per-page one: configure, cart and checkout
// all change together, which is what keeps the funnel visually coherent.
'checkout_sidebar' => [
'type' => 'select',
'group' => 'checkout',
'label' => 'set_checkout_sidebar',
'desc' => 'set_checkout_sidebar_desc',
'options' => [
'rail' => 'opt_checkout_rail', // portalled full-height rail (default)
'card' => 'opt_checkout_card', // plain card beside the content
'stack' => 'opt_checkout_stack', // stacked under the content
],
'default' => 'rail',
],
],
],
];
Pitfalls
The script moves the summary to the body, so its position never shows. In card or stack the same markup drops it under the content.
Modals sit outside the page root your script scopes to, so a handler checking that container silently misses them. Allow the closest modal too.
The framework collapse snaps in this shell, so the shipped themes use their own everywhere, configure's domain and nameserver panels included.
Configure validates field groups the collect chain reads, not the template. An input with a plausible name arrives nowhere; follow each new field to its operation.
Configure has no availability endpoint: it posts to the domain controller's check operation with the public search's token form key.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.