Cart and Checkout

3 views Markdown

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.

StepViewWhat the visitor does
1checkout/configureCycle, domain, requirements, add-ons
1checkout/configure-addonAn add-on for an owned service
1checkout/configure-domainEdits a domain line in the cart
2checkout/cartReviews lines, applies a coupon, removes items
3checkout/checkoutAccount, billing and payment in one page
3checkout/payAn invoice, outside the cart
4checkout/order-completeOrder confirmation
4checkout/invoice-completeDirectly paid invoice confirmation
4checkout/pay-resultReturn from a redirect gateway

The remaining five carry no layout.

section-account, section-billing, section-payment Included by checkout/checkout.tpl, one per card.
section-rail-items The summary's line items, separate because the summary appears twice and must match.
checkout/pay-choices Not included, returned as AJAX HTML by three operations.

Step by Step

Wire the Funnel Shell

  1. Extend layouts/checkout.tpl: step indicator, thin footer, shell script, no nav.
  2. Do not compute the step in the view; the controller sets it.
  3. Put page scripts in {block name=scripts}: the core bundle loads before it, the shell after.
  4. 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.

  1. The aside must be a direct child of the split container and a sibling of the main column.
  2. Give the form an id and attach outside submit buttons with the form attribute. The stack action bar and the portalled aside both land outside.
  3. Build the summary from the shape the cart operations return; two shapes, two totals.
  1. Declare checkout_sidebar as a select: rail, card, stack.
  2. Stamp the choice on the root element from the layout. Rail is the default, with no class.
  3. The shell script reads that class and skips the portal in card and stack mode.

Host the Payment Pane

  1. Give the payment section an empty pane the operation fills with returned HTML.
  2. A gateway may return nothing embeddable: the response reports a fallback and your footer button takes the visitor to the pay page.
  3. 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.

$checkout_step On all four: 1 configure, 2 cart, 3 checkout or pay, 4 complete. Read by the header partial.
$checkout_legal On configure, cart and checkout: purchase time contracts, flagged per page. Not the footer links.
Cart pageCheckout pageHolds
$cart_items$checkout_itemsThe lines, priced and formatted
$cart_summary$checkout_summaryDiscount groups, stacked taxes and savings
$cart_subtotal, $cart_totalread from the summaryFigures printed outside the summary
$cart_count$cart_countRecomputed from the lines; overrides the badge
$coupon_enabled, $cart_has_unconfigurednot setCart only: coupons on, and unconfigured lines
not set$payment_methods, $payment_default, $payment_lockedCheckout only: gateways, preselection, no-choice flag
not set$is_member, $billing_profiles, $countriesCheckout only: account and billing cards
$domain_section Configure only. Always carries 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

what the template functions call underneath
// {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

ModeWhat the gateway returnedWhat reaches your pane
htmlIts own markup, a card form or hosted fieldsThat markup, embedded as is
choicesOptions, say instalments or bank accountsThe shared pay-choices partial
redirectA single destination addressThe same partial with one choice: one button
noneNothing embeddable, a legacy full page formNothing; 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.

the payload, and the pay page's own copy of it
$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

views/checkout/checkout.tpl, the split skeleton
{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}
the manifest entry that switches all three funnel pages
// 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

A misplaced aside is invisible in rail mode

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.

A delegated handler will not see a modal button

Modals sit outside the page root your script scopes to, so a handler checking that container silently misses them. Allow the closest modal too.

Use the theme's collapse, not the framework's

The framework collapse snaps in this shell, so the shipped themes use their own everywhere, configure's domain and nameserver panels included.

Collecting a field is not persisting it

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.

The domain card reuses the public search endpoint

Configure has no availability endpoint: it posts to the domain controller's check operation with the public search's token form key.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.