# Catalog and Product Pages

https://dev.wisecp.com/es/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` |

- **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

```php
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
```

```php
$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

```php
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

```php
// 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");
```

```smarty
{* 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.

## Related Articles

- [Page Surfaces](https://dev.wisecp.com/en/page-surfaces)
- [Cart and Checkout](https://dev.wisecp.com/en/cart-and-checkout)
- [Template Variables](https://dev.wisecp.com/en/template-variables)
- [Theme Performance and Caching](https://dev.wisecp.com/en/theme-performance-and-caching)
- [Theme Settings](https://dev.wisecp.com/en/theme-settings)
