Catalog and Product Pages

2 views Markdown

Three views carry the whole public catalog. The hard part is printing a price the JavaScript will not immediately change.

Overview

The catalog is the only part of a theme where the same number is produced twice: the server prints it for the first paint, the browser again on every billing toggle. One cent of disagreement and the page flickers.

Amounts arrive converted and promotion resolved, so no pricing logic belongs in a view.

Structure

Three views, one price mechanism. The category page carries two arrangements and picks between them at runtime. Until a category has priced products, every branch below is skipped.

ViewAnswersMain data
views/products/categoryEvery category address, hub and leaf$mode, $tabs or $cards plus $plans, $billing_cycles, $price_mode
views/products/softwareThe store root and each store category$products, $filters, $page_size, $total_count
views/products/detailOne software product's own page$product, $prices, $gallery, $included, $versions, $faq
components/plan-grid.tpl Vertical cards. Expects $plans and reads $billing_cycles from the parent, so pass both.
components/plan-rows.tpl Horizontal rows. Expects specs and chips on each plan instead of features.
Products::catalog_plans() Builds both shapes; the third argument picks one.

Step by Step

Handle Both Category Modes

Branch on $mode first thing inside the content block.

  1. tabs: every child is a leaf, so the page shows sub family tabs and a grid per pane. Each $tabs entry carries its own title, meta, hero background and plans.
  2. drilldown: the category has sub categories and its own plans — $cards and $plans.
  3. Print the billing toggle only when there is something to toggle: {if $billing_cycles|@count > 1}.

Print the Plan, Not the Price Logic

  1. Give the card the class plan-card, or plan-row in the horizontal layout.
  2. Add the data-* attributes from the contract below.
  3. Use $plan.in_stock to swap the call to action for a sold out state; do not hide the card.
  4. Use $plan.link as it comes.

Print the Price Twice, Identically

The server prints so the page is never blank, the script overwrites on load, and both must produce the same string.

  1. Print the server value into <span data-role="price-now">{$plan.price_now}</span>.
  2. Print the per month suffix beside it, hidden on a period total: {if $plan.is_period}d-none{/if}.
  3. Leave the derived figures empty — they are the script's.
  4. Put the page context on the wrapper.

Respect the Category's Layout Choice

A category can ask for horizontal rows instead of the grid; the choice arrives as $layout.

  1. Branch once, at the include: rows or grid, never a half converted card.
  2. A grid plan has features; a rows plan has specs and chips and no features.
  3. Feature text with no value|label pairs turns every line into a chip and leaves the specification columns empty. That is data, not a bug.

Reference

The Shape of One Plan

signature
public static function catalog_plans(int $categoryId, string $type, string $layout = 'grid'): array;

// $categoryId  the category whose products are wanted
// $type        the product kind, the same segment the configure address uses
// $layout      'grid' or 'rows'; it changes the SHAPE of every returned plan
one element of the returned list
$plan = [
    'id'          => 18,                 // product id
    'title'       => 'Starter',
    'tagline'     => 'For a first site',
    'popular'     => true,               // from the product's own options
    'in_stock'    => true,               // '' stock means unlimited, 0 means sold out
    'prices'      => [                   // ONLY the cycles that actually carry a price
        'monthly' => 4.90,
        'annual'  => 49.00,
    ],
    'currency'    => 'USD',              // display code, for the data-currency attribute
    'currency_id' => 2,                  // display currency id, used by the formatter
    'link'        => '/configure/hosting/18',
    'features'    => ['10 GB disk', '1 domain'],
];

// With $layout = 'rows' the last key is replaced by two:
//   'specs' => [['value' => '10 GB', 'label' => 'Disk'], ...]   the comparable columns
//   'chips' => ['Free migration', ...]                          the plain lines

How the First-Paint Price Is Produced

the seeding rule, from Products
public static function seed_plan_prices(array &$plans, string $cycle, bool $periodMode): void;

// $cycle       the default billing cycle for this page
// $periodMode  true prints the full period total, false prints the monthly equivalent
//
// Adds two keys to every plan:
//   price_now  the formatted string the template prints
//   is_period  whether that string is a period total (drives the "/mo" suffix)
//
// A plan with no price for $cycle gets price_now = '' rather than a zero.
// A plan with no monthly price is forced into period mode even when $periodMode is false.

public static function plan_cycles(array $tabs): array;
// [['key' => 'monthly', 'label' => 'Monthly'], ['key' => 'annual', 'label' => 'Annually']]
// Only cycles that at least one plan on the page actually prices. Fixed display order.

public static function price_monthly_equivalent(): bool;
// The GLOBAL setting behind $periodMode. It lives in the installation's theme
// configuration, NOT in your theme's own settings schema, so a theme cannot force it.

The data-* Contract

Rename one and the price silently stops updating. The script takes each data-billing element as a group, then each .plan-card and .plan-row inside it as a card.

data-billing On the wrapper: the opening cycle from $default_cycle, and the group selector; the toggle rewrites it.
.plan-card and .plan-row The card selector: grid uses the first, rows the second. Add classes, do not replace these.
data-price-mode On the wrapper: monthly or period from $price_mode. A page value, not a theme setting.
data-currency On the wrapper and each card; the card wins, so mixed currencies format correctly.
data-monthly, data-annual, and so on On the card: raw decimals, one per cycle, with |default:'' so an unpriced cycle stays empty rather than zero.
data-role The nodes the script writes into: price-now, price-suffix, price-was, savings, cycle-total, cycle-mo, currency-label.
data-save-text On the wrapper: the savings badge label, from the locale because the script has no translator.

The Billing Toggle Contract

The button carries its cycle in data-cycle, never data-billing: the handler walks up to the nearest data-billing ancestor, so a button carrying it becomes its own group.

data-billing-toggle On the button group. Moves the active class between its buttons, so several toggles can coexist.
data-action="set-billing" On each button. The theme's delegated click handler dispatches on it; no listener of your own.
data-cycle On each button: monthly, quarterly, semiannually, annual, biennial or triennial. Print it from $c.key, the label from $c.label.

Example

the controller side
// controllers/website/products.php, the drilldown branch

$plans = Products::catalog_plans($categoryId, $kind, $layout);

// Global setting, inverted: monthly-equivalent ON means period mode OFF.
$period_mode = !Theme::price_monthly_equivalent();

// Writes price_now + is_period onto every plan, in the page's default cycle.
Products::seed_plan_prices($plans, $default_cycle, $period_mode);

$this->addData("mode",           "drilldown");
$this->addData("plans",          $plans);
$this->addData("cards",          $cards);
$this->addData("layout",         $layout);
$this->addData("billing_cycles", Products::plan_cycles([['plans' => $plans]]));
$this->addData("default_cycle",  $default_cycle);
$this->addData("price_mode",     $period_mode ? "period" : "monthly");
views/products/category.tpl plus components/plan-grid.tpl
{* The section wrapper carries the page-level context the price script reads. *}
<section data-billing="{$default_cycle}" data-currency="{$selected_currency_code}" data-price-mode="{$price_mode}">

    {if $layout == 'rows'}
        {include file='components/plan-rows.tpl' plans=$plans billing_cycles=$billing_cycles}
    {else}
        {include file='components/plan-grid.tpl' plans=$plans billing_cycles=$billing_cycles}
    {/if}

</section>

{* components/plan-grid.tpl, the price band of one card *}
<div class="card plan-card{if !$plan.in_stock} plan-soldout{/if}"
     data-currency="{$plan.currency}"
     data-monthly="{$plan.prices.monthly|default:''}"
     data-annual="{$plan.prices.annual|default:''}"
     data-biennial="{$plan.prices.biennial|default:''}">

  <div class="plan-price price-band">
    {* Filled by the script. Empty on the server on purpose. *}
    <del class="price-was num-tabular d-none" data-role="price-was"></del>
    <span class="badge d-none" data-role="savings"></span>

    {* Printed by the server so the first paint is never blank, then rewritten
       with the SAME string by the script on load. *}
    <span class="price-now num-tabular" data-role="price-now">{$plan.price_now|default:''}</span>
    <span class="{if $plan.is_period}d-none{/if}" data-role="price-suffix">{lang key='website/products/category-per-month'}</span>

    {* One row per cycle; the totals are the script's job. *}
    {foreach $billing_cycles as $c}
      <tr data-cycle="{$c.key}">
        <td>{$c.label}</td>
        <td data-role="cycle-total"></td>
        <td data-role="cycle-mo"></td>
      </tr>
    {/foreach}
  </div>
</div>

Pitfalls

One cent of disagreement is a visible flicker

toFixed(2) rounds the stored double uncorrected, the server formatter rounds half to even, and PHP's round corrects floating point first. All three disagree on values ending in five: 237.905 becomes two strings and the page jumps. The seeding helper pre rounds with sprintf('%.2f'); do the same.

The horizontal layout has two keys behind it

The panel writes the choice into list_template (2 means flat); the page reads layout and expects rows. Nothing maps between them, so a flat category still comes out as a grid; the homepage rack reads both.

Monthly equivalent is not your theme's setting

An installation wide option, deliberately kept out of the theme settings schema. Read it through $price_mode and add no lookalike switch of your own.

Pasted embeds can delay every derived figure

A category's rich text body goes out raw, so a synchronous third party tag in it blocks parsing. The main price survives; the derived figures wait.

Stock is live, not cached with the plan

Plan lists are cached; the in stock flag is refreshed after. Treat $plan.in_stock as current and the rest as up to an hour old, and never cache a fragment mixing the two.

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.