Catalog and Product Pages
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.
| View | Answers | Main data |
|---|---|---|
views/products/category | Every category address, hub and leaf | $mode, $tabs or $cards plus $plans, $billing_cycles, $price_mode |
views/products/software | The store root and each store category | $products, $filters, $page_size, $total_count |
views/products/detail | One software product's own page | $product, $prices, $gallery, $included, $versions, $faq |
$plans and reads $billing_cycles from the parent, so pass both.
specs and chips on each plan instead of features.
Step by Step
Handle Both Category Modes
Branch on $mode first thing inside the content block.
- tabs: every child is a leaf, so the page shows sub family tabs and a grid per pane. Each
$tabsentry carries its own title, meta, hero background and plans. - drilldown: the category has sub categories and its own plans —
$cardsand$plans. - Print the billing toggle only when there is something to toggle:
{if $billing_cycles|@count > 1}.
Print the Plan, Not the Price Logic
- Give the card the class
plan-card, orplan-rowin the horizontal layout. - Add the
data-*attributes from the contract below. - Use
$plan.in_stockto swap the call to action for a sold out state; do not hide the card. - Use
$plan.linkas 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.
- Print the server value into
<span data-role="price-now">{$plan.price_now}</span>. - Print the per month suffix beside it, hidden on a period total:
{if $plan.is_period}d-none{/if}. - Leave the derived figures empty — they are the script's.
- 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.
- Branch once, at the include: rows or grid, never a half converted card.
- A grid plan has
features; a rows plan hasspecsandchipsand nofeatures. - Feature text with no
value|labelpairs turns every line into a chip and leaves the specification columns empty. That is data, not a bug.
Reference
The Shape of One Plan
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
$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
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.
$default_cycle, and the group selector; the toggle rewrites it.
monthly or period from $price_mode. A page value, not a theme setting.
|default:'' so an unpriced cycle stays empty rather than zero.
price-now, price-suffix, price-was, savings, cycle-total, cycle-mo, currency-label.
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.
monthly, quarterly, semiannually, annual, biennial or triennial. Print it from $c.key, the label from $c.label.
Example
// 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");
{* 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
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 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.
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.
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.
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.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.