The Client Area
Twenty five views share one shell that a single controller flag turns on. Every one can appear for an account other than the person logged in.
Overview
This is the only family where the same page answers two questions: what is on it, and whose it is. A member can act on another account, so a list may belong to someone else. A tab may be missing because a permission was withheld, not because the data is empty.
The views extend the public layout, and a flag turns it into the client shell.
Structure
The twenty five views group into six jobs.
| Group | Views | Notes |
|---|---|---|
| Dashboard | dashboard-hero, dashboard-standard | Two variants of one address, or a single dashboard view |
| Assets | services, service-detail, domains, domain-detail, service-transfer-approve, license-transfer-verify | Lists use a preset; detail pages are multi tab and deep linkable |
| Money | invoices, invoice-detail, subscriptions, bulk-pay, balance | Two extend the invoice layout |
| Support | tickets, ticket-detail, ticket-create, ticket-msg | ticket-msg is a fragment: one reply for the poller |
| Account | settings, sub-accounts, api-credentials, sms | Avatar menu pages; they mark no tab |
| Programs | affiliate, reseller, reseller-program, access-denied | One reseller address, two views |
Step by Step
Turn On the Client Shell
- Compute the flag once at the top of the layout: signed in and sub navigation requested.
- Use it to choose the chrome: client topbar, sidebar and footer, no marketing bands.
- Mark the active tab from
$subnav_active. Controllers fix its values:dashboard,services,domains,billing,support,sms. Avatar menu pages carry an empty string. - Do not invent tab names: an unrecognised value shows no active tab, silently.
Build for the Right Account
- Never print the login identity as the owner: one is who signed in, the other is whose data is on screen.
- Expect a panel to be absent rather than empty; a withheld permission means the query never ran.
- Keep the account switcher visible; a member acting for another needs a way back.
Build a List Surface
The four big lists print rows twice: on the server for the first page, through the table engine after.
- Put the row markup in the theme's table preset, under
tables/. - Have the view's loop print the same markup, so first paint and an AJAX page are byte identical.
- Read page values from the table options, not globals: the preset sees no controller data.
Make Detail Tabs Linkable
- Give each tab button a stable key attribute, separate from the pane id, so the page does not jump.
- On load, open the tab named in the query string; it must override the remembered tab.
- When a tab is shown, write its key back and drop parameters from the tab you left.
- For a lazily loaded tab, check on load and again on the next tick: the remembered tab is restored at two moments.
Reference
Shell Variables
$notification_count_text. Core chrome.
name, surname, full_name, email, avatar, initials, balance (formatted), support_pin, two_factor, is_reseller, dealership, last_login, last_login_date, last_login_ip, last_login_country, last_login_city. Fall back avatar → initials → icon.
The Account Context API
// Null when there is no member session. Never mutates the login identity.
public static function activeAccount(): ?array;
// Own account is always allowed; a switched account only when the permission was granted.
public static function accountCan(string $permission): bool;
// Only a still-valid membership (or self) is accepted; a client-supplied id is never trusted.
public static function switchAccount(int $ownerId): bool;
// The bell count for the signed-in member. 0 means "no member" as well as "nothing unread".
public static function notification_count(int $user_id = 0): int;
$ctx = [
'login_id' => 41, // who authenticated; NEVER changes while switched
'owner_id' => 88, // whose data this page shows
'is_self' => false, // true when the two ids are the same
'permissions' => ['view_services', 'view_invoices'], // re-read every request
];
// The switched account is re-validated against the membership table on EVERY request,
// so a revoked membership loses access immediately rather than at the next login.
Dashboard Data
| Variable | Holds | Behaviour when unpermitted |
|---|---|---|
$dash_services, $dash_domains, $dash_invoices, $dash_tickets | The panel lists | Empty; the query never runs |
$dash_stats | The figure strip | Built from permitted reads |
$dash_alerts | Attention items, through a filter hook modules extend | Fewer entries, never missing |
$dash_health, $dash_feed, $dash_balance | Health line, feed, balance | Present and empty — "not yours to see", not zero |
$dash_greeting, $dash_first_name, $dash_today | The welcome line; names the signed-in member | Always present |
$dash_l10n | Strings the dashboard scripts label with | Always present |
$show_activity | Whether the activity feed may appear | False when switched: activity is personal |
Client Area Theme Flags
hero or standard, selecting views/account/dashboard-{value}. Ignored when a single account/dashboard ships.
Example
{* Computed ONCE, at the very top, before the doctype. Both halves are required:
a signed-in visitor on a marketing page must still get the public chrome. *}
{$is_client_area = $is_logged_in && !empty($show_client_subnav)}<!DOCTYPE html>
<html lang="{$ui_lang|default:'en'}" dir="{$ui_dir|default:'ltr'}">
<head>
{* Shell-only stylesheet: a public page never pays for it. *}
{if $is_client_area}<link rel="stylesheet" href="{asset path='css/client-nav.css'}">{/if}
{block name=head}{/block}
{hook name='ui:client.head.css'}
</head>
<body class="{block name=body_class}{/block}"{if $is_client_area} data-client-nav="sidebar"{/if}>
{hook name='ui:client.body.begin'}
{if $is_client_area}
{include file='partials/client-topbar.tpl'}
{include file='partials/client-sidebar.tpl'}
{else}
{include file='partials/header.tpl'}
{/if}
<main class="client-main">
{block name=content}{/block}
</main>
{* Marketing bands belong to the public site only. *}
{if !$is_client_area}{block name=bands}{/block}{/if}
{if $is_client_area}
{include file='partials/client-footer.tpl'}
{else}
{include file='partials/footer.tpl'}
{/if}
{block name=body_end}{/block}
{hook name='ui:client.body.end'}
// The identity is never the owner. Resolve both, then ask about permissions.
$ctx = UserManager::activeAccount();
$uid = (int) $ctx["owner_id"];
$self = (bool) $ctx["is_self"];
// Own account bypasses the gate entirely; a switched account is checked per area.
$can = fn (string $p): bool => $self || in_array($p, $ctx["permissions"], true);
if (!Theme::active()->viewExists("account/services")) return $this->page_404("website");
// MUST come before set_predefined_data("client"): the notification count is read
// behind this flag, and a controller that sets it afterwards gets a zero badge.
$this->addData("show_client_subnav", true);
$this->addData("subnav_active", "services");
// An unpermitted area contributes nothing rather than an empty-looking panel.
$this->addData("service_list", $can("view_services") ? $this->model->services($uid) : []);
$this->set_predefined_data("client");
return $this->view->chose("website")->render("account/services", $this->data, true);
Pitfalls
The notification count is computed behind show_client_subnav. Set the flag afterwards and the badge is always zero.
Omitting a tab for an unpermitted area is presentation. The boundary is the guard behind the access denied view, which runs regardless.
Declare its helpers as variables holding closures: a named function fatals on the second include, which an AJAX page request returning a summary produces.
A counter the shell reads is always assigned, even at zero. A variable set inside an if is undefined wherever the branch is skipped.
The confirmation dialog defaults its cancel label to English, so passing only the action label leaves a stray English word in other languages.
Related Articles
Vielen Dank für Ihre Rückmeldung!
Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.