# Page Surfaces

https://dev.wisecp.com/es/page-surfaces

Every public and client page resolves to one view file inside your theme. This is the complete map from address to file.

## Overview

A controller never names a template file. It names a *view*, a slash-separated path with no extension. The engine turns that into a file inside your theme: `render("account/services")` becomes `views/account/services.tpl` for a Smarty theme and `views/account/services.twig` for a Twig one. The theme decides the extension through its manifest; the controller never learns which engine is running.

That single rule is what makes a theme portable. Ship the 69 view files a full theme needs and every surface answers. Ship fewer and the controller answers 404 for the ones you left out, because almost every website controller checks the view first.

## Structure

### The Views Tree

Views are grouped by area, never dumped flat into one folder. The counts below are the shipped themes measured file by file. **Basic** and **WStyle** carry 69 views each.

| Folder | Basic / WStyle | What lives there |
| --- | --- | --- |
| `views/` (root) | 6 | Standalone surfaces with no natural family: home, domain search, the SMS landing page, licence verification, 404, maintenance |
| `views/products/` | 3 | Catalog: category page, software detail page, software store |
| `views/checkout/` | 14 | Configure, cart, checkout, payment and their shared fragments |
| `views/account/` | 25 | The signed-in client area, from the dashboard to sub accounts |
| `views/auth/` | 6 | Login, register, password reset, activation, invitation |
| `views/content/` | 14 | Editorial surfaces: blog, news, references, knowledge base, CMS pages, contact |
| `views/page/` | 1 | File-based pages: a view here publishes a URL with no controller and no route |

### Layouts, Partials and Components

Views hold page bodies only. The shell around them lives in three sibling folders, referenced from the theme root, never relative to the view.

- **layouts/**: Full documents a view extends. WStyle ships four: `default` (public and client shell), `auth` (split stage and form), `checkout` (focused funnel shell), `invoice` (print-friendly).
- **partials/**: Chrome the layout includes, in three sets. Public: `header`, `topbar`, `drawer`, `footer`, `announcements`, `cookie-notice`. Client area: `client-topbar`, `client-sidebar`, `client-footer`. Funnel: `checkout-header`, `checkout-footer`.
- **components/**: Reusable blocks a view includes with arguments: plan grids, dashboard panels, payment method lists, configure sub cards.
- **tables/**: Plain PHP table presets for the client lists: services, domains, invoices, tickets. The list engine reads them, not a template.

## Reference

### The Resolution API

Three methods decide whether a surface exists and how it is produced. All three are on the theme instance returned by `Theme::active()`.

```php
public static function active(): self;

// True when views/<view>.<ext> is a real file. $view is slash separated, no extension.
// '' and any path containing '..' return false before the disk is touched.
public function viewExists(string $view): bool;

// Render one view to a string, independent of request context (cron, mail, PDF).
public function render(string $view, array $data = []): string;

// Read one theme setting: config.php value, or the schema default from theme.php.
public function setting(string $key): mixed;

// The manifest's meta block, including the theme flags the core reads.
public function meta(): array;

// Absolute URL under the theme's assets/ folder. Backs the {asset} template function.
public function assetUrl(string $path = ''): string;
```

- **Theme::viewExists()**: The extension comes from the manifest engine: `twig` gives `.twig`, `php` gives `.php`, anything else gives `.tpl`. Website controllers alone call it 41 times, and every one of those guards a page.
- **Theme::render()**: The context free entry point. Use it from cron, mail and PDF code; `View::chose("website")` silently skips the theme when `CRON` or `ADMINISTRATOR` is defined.
- **View::render()**: The in-request path used by every controller. It adds the address variables, runs `filter:template.variables`, loads the view's content scope and finally filters the finished markup.
- **TemplateEngine::render_file()**: The engine dispatcher underneath both. It builds a fresh Smarty or Twig instance per call and prefixes the view with `views/`.

### Root Views

| View | Produced by | Address |
| --- | --- | --- |
| `home` | `controllers/website/index.php` | route `home`, the site root |
| `domain` | `controllers/website/domain.php` | route `domain`, `/domain` |
| `sms-introduction` | `controllers/website/sms.php` | route `international-sms`, `/international-sms` |
| `license-verification` | `controllers/website/license.php` | route `license`, `/license-verify` |
| `404` | `Controllers::page_404` | no route: any unmatched address, and every failed view guard |
| `maintenance` | `controllers/system/maintenance.php` | no route: every address while maintenance mode is on |

### Catalog Views

| View | Produced by | Address |
| --- | --- | --- |
| `products/category` | `controllers/website/products.php` | routes `products` and `products-2`, `/{category}` and `/{kind}/{category}` |
| `products/software` | `controllers/website/softwares.php` | routes `softwares` and `softwares_cat`, `/softwares` and `/softwares/{category}` |
| `products/detail` | `controllers/website/product-detail.php` | route `software_detail`, `/software/{slug}` |

### Checkout Views

Four of the fourteen are fragments. They carry no layout, and another template pulls them in or an AJAX response returns them.

| View | Produced by | Address |
| --- | --- | --- |
| `checkout/configure` | `controllers/website/configure.php` | routes `configure`, `configure-p`, `/configure/{type}/{id}` |
| `checkout/configure-addon` | `configure.php`, `page_addon()` | the addon branch of the same configure address |
| `checkout/configure-domain` | `configure.php`, `page_edit_item()` | route `configure-edit`, `/configure/edit/{key}` |
| `checkout/cart` | `controllers/website/cart.php` | routes `cart` and `basket`, `/cart` |
| `checkout/checkout` | `controllers/website/checkout.php` | route `checkout`, `/checkout` |
| `checkout/order-complete` | `checkout.php`, `page_complete()` | route `order-complete`, `/checkout/complete/{id}` |
| `checkout/invoice-complete` | `invoices.php`, `page_complete()` | route `invoice-complete`, `/invoices/complete/{id}` |
| `checkout/pay` | `controllers/website/pay.php` | route `pay`, `/pay/{id}` |
| `checkout/pay-result` | `controllers/website/payment.php` | routes `pay-successful` and `pay-failed` |
| `checkout/pay-choices` | operations `ClientCheckout`, `ClientInvoicePay`, `BalanceOps` | fragment: AJAX response, three callers share it |
| `checkout/section-account` | `checkout/checkout.tpl` | fragment: included by the checkout body |
| `checkout/section-billing` | `checkout/checkout.tpl` | fragment: included by the checkout body |
| `checkout/section-payment` | `checkout/checkout.tpl` | fragment: included by the checkout body |
| `checkout/section-rail-items` | `checkout/checkout.tpl` | fragment: included by the order summary rail |

### Client Area Views

| View | Produced by | Address |
| --- | --- | --- |
| `account/dashboard-hero` | `controllers/website/dashboard.php` | route `my-account`, `/dashboard`, hero layout variant |
| `account/dashboard-standard` | `dashboard.php` | same address, standard layout variant |
| `account/settings` | `controllers/website/account.php` | route `info`, `/account/info` |
| `account/services` | `services.php`, `page_list()` | route `services`, `/services` |
| `account/service-detail` | `services.php`, `page_detail()` | route `service-detail`, `/services/detail/{id}` |
| `account/service-transfer-approve` | `services.php`, `page_transfer_approve()` | route `service-transfer-approve`, token in the path |
| `account/domains` | `domains.php`, `page_list()` | route `domains`, `/domains` |
| `account/domain-detail` | `domains.php`, `page_detail()` | route `domain-detail`, `/domain-detail/{id}` |
| `account/invoices` | `invoices.php`, `page_list()` | route `invoices`, `/invoices` |
| `account/invoice-detail` | `invoices.php`, `page_detail()` | route `invoice-detail`, `/invoices/detail/{id}` |
| `account/subscriptions` | `invoices.php`, `page_subscriptions()` | route `invoice-subscriptions`, `/invoices/subscriptions` |
| `account/bulk-pay` | `invoices.php`, `page_bulk_pay()` | route `bulk-pay`, `/invoices/bulk-pay` |
| `account/balance` | `controllers/website/balance.php` | route `balance`, `/balance` |
| `account/tickets` | `tickets.php`, `page_list()` | route `tickets`, `/tickets` |
| `account/ticket-detail` | `tickets.php`, `page_detail()` and `page_guest_view()` | routes `ticket-detail` and `ticket-guest` |
| `account/ticket-create` | `tickets.php`, `page_create()` | route `ticket-create`, `/tickets/create` |
| `account/ticket-msg` | `tickets.php` | fragment: one reply, returned to the AJAX poller |
| `account/sms` | `sms.php`, `page_panel()` | route `sms`, `/sms` |
| `account/sub-accounts` | `controllers/website/sub-accounts.php` | route `sub-accounts`, `/sub-accounts` |
| `account/api-credentials` | `controllers/website/api.php` | route `api`, `/api-credentials` |
| `account/affiliate` | `controllers/website/affiliate.php` | route `affiliate`, `/affiliate` |
| `account/reseller-program` | `reseller.php`, `page_program()` | route `reseller`, guest and non reseller view |
| `account/reseller` | `reseller.php`, `page_dashboard()` | route `reseller`, the reseller's own panel |
| `account/license-transfer-verify` | `verify-license-transfer.php` | route `verify-license-transfer`, token in the path |
| `account/access-denied` | `Controllers`, the permission guard | no route: any client page a sub account may not open |

### Auth Views

All six go through one private helper on the sign controller. That is why they share a guard, a canonical link and a page title convention.

| View | Produced by | Address |
| --- | --- | --- |
| `auth/login` | `sign.php`, `page_in()` | route `sign-in`, `/login` |
| `auth/register` | `sign.php`, `page_up()` | route `sign-up`, `/register` |
| `auth/forget-password` | `sign.php`, `page_forget()` | route `sign-forget`, `/forget-password` |
| `auth/reset-password` | `sign.php`, `page_reset()` | route `sign-reset`, `/reset-password` |
| `auth/activate` | `sign.php`, `page_activate()` | route `account-activation`, `/account-activation` |
| `auth/accept-invite` | `sign.php`, `page_invite()` | route `accept-invite`, `/invitation/{token}` |

### Content Views

| View | Produced by | Address |
| --- | --- | --- |
| `content/blog` | `controllers/website/articles.php` | route `articles`, `/articles` |
| `content/blog-detail` | `page-detail.php` | route `articles_detail`, a bare slug |
| `content/blog-comment-list` | operation `ClientBlogComments` | fragment: the comment list, reloaded over AJAX |
| `content/news` | `controllers/website/news.php` | route `news`, `/news` |
| `content/news-detail` | `page-detail.php` | route `news_detail`, a bare slug |
| `content/references` | `controllers/website/references.php` | route `references`, `/references` |
| `content/references-detail` | `page-detail.php` | route `references_detail`, a bare slug |
| `content/page-detail` | `page-detail.php` | routes `normal_detail` and `contract_detail`, every CMS page and contract |
| `content/knowledgebase` | `knowledgebase.php` | route `kbase`, `/knowledgebase` |
| `content/knowledgebase-category` | `knowledgebase.php` | route `kbase_category`, `/knowledgebase/category/{slug}` |
| `content/knowledgebase-article` | `knowledgebase.php` and `knowledgebase_detail.php` | route `kbase_detail`, `/knowledgebase/article/{slug}` |
| `content/contact` | `controllers/website/contact.php` | route `contact`, `/contact` |
| `content/newsletter-unsubscribe` | `controllers/website/newsletter.php` | no route key: the first URL segment resolves to the controller |
| `content/addon` | `controllers/website/addon.php` | no route key: `/addon/{ModuleName}`, an addon module's own page |

### File-Based Pages

A view under `views/page/` publishes an address on its own. No controller, no route entry, no core edit. A routing filter runs last, after every registered route has failed to match, and hands the request to a generic controller when the file is there.

- **views/page/about.tpl**: Published at `/about`. The slug is restricted to `a-z 0-9 / _ -` and any `..` is rejected, checked twice: once in the routing filter, once in the controller.
- **filter:routing.match**: The router's last fallback. It never shadows a registered route, so adding a file-based page can never break an existing address.
- **locale scope**: The `page/` prefix does not reach the locale: `views/page/about.tpl` reads `locale/{lang}/about.php`, because the scope mirrors the public URL, not the folder.

### What Happens When a View Is Missing

Three different things, depending on who asked. Knowing which one you are looking at saves an hour of hunting for a template error that was never raised.

| Caller | Behaviour | What you see |
| --- | --- | --- |
| A controller that guards with `viewExists()` | Returns the 404 page instead | HTTP 404 with your theme's `404` view, no error at all |
| A view with no guard | The engine throws, the dispatcher catches it and returns an empty string | A blank page. A warning goes to the log, and in development the message appears inside a preformatted block |
| An optional feature view | The feature turns itself off | No error, a different code path runs (see the PDF pitfall below) |

## Example

One surface end to end: the controller line that names the view, and the view file that answers it. The two halves are shown together because the view path is the only contract between them.

```php
// controllers/website/services.php, page_list()

// Guard first: a theme that ships no services list answers 404 rather than a blank page.
if (!Theme::active()->viewExists("account/services")) return $this->page_404("website");

$this->addData("page_title", Language::gc("website/services/meta/title"));
$this->addData("canonical_link", LinkGenerator::client("services"));
$this->addData("meta_robots", "noindex, nofollow");

// Turns on the client shell (sidebar, subnav, notification bell) and marks the active tab.
$this->addData("show_client_subnav", true);
$this->addData("subnav_active", "services");

// Logo, company name, contract links, currency list, the client chrome data.
$this->set_predefined_data("client");

// No extension, no engine: "account/services" is resolved against the ACTIVE theme.
return $this->view->chose("website")->render("account/services", $this->data, true);
```

```smarty
{extends file='layouts/default.tpl'}

{* Stylesheets and libraries the core JS itself consumes go in head. *}
{block name=head}
    <script src="{asset path='js/list.js'}" defer></script>
{/block}

{* Page scripts go in scripts, AFTER the core bundle, or window.WStyle is not ready yet. *}
{block name=scripts}
    <script src="{asset path='js/services.js'}" defer></script>
{/block}

{block name=content}
<section class="pt-4 pb-4">
    <div class="container list-page" data-services-page>
        {csrf form='services'}

        <nav aria-label="{lang key='website/services/breadcrumb-aria'}">
            <ol class="breadcrumb mb-1">
                <li class="breadcrumb-item"><a href="{link route='my-account'}">{lang key='website/index/subnav-dashboard'}</a></li>
                <li class="breadcrumb-item active" aria-current="page">{lang key='website/services/title'}</li>
            </ol>
        </nav>
    </div>
</section>
{/block}
```

## Pitfalls

> **A missing view is a blank page, not an exception**
> 
> The engine dispatcher catches every throwable and returns an empty string in production. If a surface comes out as nothing at all, look for a view file before you look at your data. In development the caught message appears on the page instead, which is the fastest way to confirm it.

> **Background output must not go through the request path**
> 
> `View::chose("website")` only resolves the theme when neither `CRON` nor `ADMINISTRATOR` is defined. From a scheduled task it silently falls back to plain PHP templates. It then fails to find your template file and returns an empty string. Use `Theme::active()->render()` for cron, mail attachments and PDF generation.

> **The dashboard is one view or two, and the theme decides**
> 
> The controller first asks for `account/dashboard`. If that file exists the theme has one dashboard and the layout setting is ignored entirely. Only when it is absent does the controller fall back to `account/dashboard-hero` or `account/dashboard-standard`, picked by the theme setting. Basic and WStyle ship the pair; a theme may ship the single view instead.

> **An optional view can switch a whole engine on**
> 
> Invoice PDF generation picks headless Chrome only when a Chrome binary resolves *and* the theme ships `account/invoice-pdf`. None of the three bundled themes ships it, so the built in generator is what actually runs today. Adding that one file changes the output engine for every invoice.

> **The maintenance view is a whole document, not a page body**
> 
> It is the one view that does not extend a layout. The site is closed, so no navigation, cart or account chrome may link back into it. It opens with its own doctype, its own head and its own first-paint guard. A theme that ships no maintenance view falls back to the legacy system template, which is not skinned.

## Related Articles

- [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy)
- [Template Variables](https://dev.wisecp.com/en/template-variables)
- [Catalog and Product Pages](https://dev.wisecp.com/en/catalog-and-product-pages)
- [Cart and Checkout](https://dev.wisecp.com/en/cart-and-checkout)
- [The Client Area](https://dev.wisecp.com/en/the-client-area)
- [Login and Registration](https://dev.wisecp.com/en/login-and-registration)
- [System Pages](https://dev.wisecp.com/en/system-pages)
