NOVA obchod
Kompletný e-shop modul: katalóg, cache, košík, checkout, obchodný desk, šablóny a dotapp.js.
Živý obchod: /documentation/examples/run/nova.
Zamknutý desk: /documentation/examples/run/nova/desk.
Čo staviate
E-shop nie je mriežka produktov. Sú to dva produkty v jednom module: verejný obchod, ktorý ľudia prehliadajú, a desk pre objednávky, katalóg, platby, zákazníkov a nastavenia.
Tento návod stavia oboje v app/modules/Nova. Živá ukážka je tichý fashion shop NOVA — úvod so sliderom, katalóg, produkt, košík, checkout plus desk len na čítanie.
Mini Storefront ostáva základný príklad (jedna myšlienka: DSM košík + AJAX hľadanie). NOVA je Pro príklad: cache, checkout, zoznamy objednávok a obchodný shell.
Táto verejná ukážka nevie predávať, prihlásiť ani zapisovať databázu
Zákaznícke aj obchodné prihlásenie sú skutočné fo-rm odoslania. PHP ich overí a vždy ich odmietne. Auth::login() sa nikdy nevolá.
Checkout overí a payload zahodí — žiadna platobná brána, žiadna karta, žiadny INSERT objednávky.
Každé uloženie na desku zastaví WriteGate@write. Katalóg je v PHP. Nikto tu nemôže zmeniť riadok v databáze.
Túto stránku použite ako recept na ostrý obchod. Vymeňte katalóg v pamäti za tabuľky nova_*, desk dajte za Auth::isLogged(), zapojte platobného poskytovateľa, ktorého ovládate, a šablóny nechajte.
- Storefront — miniobchod (Basic)
- CMS Studio — Pro IT-studio CMS
- Users module — ostré prihlásenie, registrácia, 2FA
- AJAX zoznamy —
paginate(), šifrované id, hľadanie - DotApper CLI — scaffold modulu, kostru nevytvárajte ručne
Title a JSON-LD
Živé stránky berú document title, meta description, canonical URL a JSON-LD z Libraries/Mark.php.
- Dokumentový
<title>začínaExample:a končíDotApp PHP Framework 2.0 documentation demo. - JSON-LD je
TechArticle+LearningResource(educationalUse: demonstration). Žiadne typyProduct,OfferaniStore. googlebot-news: noindex. Login stránky majúnoindex,nofollow.
Mapa modulu
Jeden modul vlastní celý obchod. Nerozdeľujte „storefront app“ a „admin app“, kým to nie sú skutočne oddelené produkty.
| Časť | Cesta | Úloha |
|---|---|---|
| Routy | module.init.php | Verejné URL + /desk/*. Zápisové routy majú ->before('#Nova:WriteGate@write!'). |
| Verejný obchod | Controllers/Site.php | Úvod, katalóg, produkt, košík, checkout, zamknutý účet. |
| Desk | Controllers/Desk.php | Login (tu vždy zlyhá), prehľad, objednávky, produkty, platby, zákazníci, nastavenia. |
| Brána | Middleware/WriteGate.php | Demo: odmietni každý zápis. Produkcia: Auth::isLogged() + Auth::can(). |
| Floor | Libraries/Floor.php | Demo katalóg v PHP + Cache::use('Nova'). Produkcia: DB::module('RAW') na nova_*. |
| Košík | Libraries/Cart.php | Len DSM::use('Nova'). Nikdy $_SESSION. Na tejto ukážke žiadna tabuľka košíka. |
| SEO | Libraries/Mark.php | Example title, canonical, TechArticle JSON-LD. |
| Schéma | Installation.php | Výukové tabuľky: products, orders, payments, customers, settings. Na verejnej ukážke sa neinštalujú. |
| Šablóny | views/*.view.php | Celé HTML dokumenty + fragmenty. Žiadny Blade, Twig, include. |
| JS | assets/js/nova.js, desk.js | $dotapp().form a $dotapp().load. Nie jQuery. |
Živé URL
| URL | Čo uvidíte |
|---|---|
| /documentation/examples/run/nova | Úvod obchodu (štítok cache) |
| /catalog | AJAX katalóg, hľadanie od 3 znakov |
/documentation/examples/run/nova/product/{slug} | Stránka produktu |
| /cart | Košík |
| /checkout | Checkout |
| /account | Prihlásenie zákazníka |
| /desk | Prihlásenie do desk-u |
| /desk/home | Prehľad desk-u |
| /desk/orders | Zoznam objednávok (hľadajte „NV-”) |
Prefix je Config::module('Nova', 'prefix'), predvolene /documentation/examples/run/nova. Na vašom webe dajte obchod na / a desk na /desk.
Súbory na disku
Po DotApperi doplňte tieto cesty. Živá ukážka číta produkty, objednávky a platby z Libraries/Floor.php.
Košík je Libraries/Cart.php (DSM). Installation.php je celý nižšie — na verejnej ukážke sa nespúšťa.
app/modules/Nova/
module.init.php routy (celý súbor nižšie)
Installation.php tabuľky nova_* (celý súbor nižšie)
Libraries/View.php Renderer helper
Libraries/Floor.php vzorový katalóg + cache
Libraries/Cart.php DSM košík (celý súbor nižšie)
Libraries/Mark.php Example SEO
Controllers/Site.php verejný obchod
Controllers/Desk.php obchodný desk
Middleware/WriteGate.php zámok zápisu (celý súbor nižšie)
views/shop.view.php verejný chrome
views/shop-*.view.php verejné fragmenty
views/desk.view.php desk chrome
views/desk-*.view.php desk fragmenty
assets/css/nova.css aero-modern shop + desk
assets/js/nova.js
assets/js/desk.js
assets/img/*.jpg fotky produktov + hero
Otvoriť živý obchod
Preskočte do bežiaceho modulu. Nemusíte sa vracať na začiatok stránky.
Scaffold with DotApper
Never hand-create the module skeleton. Generate it, then fill in routes and classes.
php dotapper.php --create-module=Nova
php dotapper.php --module=Nova --create-controller=Site
php dotapper.php --module=Nova --create-controller=Desk
php dotapper.php --module=Nova --create-middleware=WriteGate
--module= must appear before --create-controller / --create-middleware.
That creates app/modules/Nova/ with Controllers/, Middleware/, Libraries/, views/, assets/, and module.init.php.
Routes
Static controllers: 'Nova:Site@home!'. Trailing ! is required. Pair each path with and without a trailing slash.
Dynamic product slugs are not STATIC_ROUTE. Register exact desk paths before any slug route so /desk/orders is not swallowed.
File: app/modules/Nova/module.init.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Nova;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Router;
class Module extends \Dotsystems\App\Parts\Module
{
public function initialize($dotApp)
{
Config::module('Nova', 'prefix') ?? Config::module('Nova', 'prefix', '/documentation/examples/run/nova');
$p = rtrim((string) Config::module('Nova', 'prefix'), '/');
$pair = function (string $path): array {
$path = rtrim($path, '/');
return [$path, $path . '/'];
};
Router::get($pair($p), 'Nova:Site@home!', Router::STATIC_ROUTE);
Router::get($pair($p . '/catalog'), 'Nova:Site@catalog!', Router::STATIC_ROUTE);
Router::post($pair($p . '/catalog/list'), 'Nova:Site@catalogList!', Router::STATIC_ROUTE);
Router::get($p . '/product/{slug:s}', 'Nova:Site@product!');
Router::get($pair($p . '/cart'), 'Nova:Site@cart!', Router::STATIC_ROUTE);
Router::post($pair($p . '/cart/add'), 'Nova:Site@add!', Router::STATIC_ROUTE);
Router::post($pair($p . '/cart/remove'), 'Nova:Site@remove!', Router::STATIC_ROUTE);
Router::get($pair($p . '/checkout'), 'Nova:Site@checkout!', Router::STATIC_ROUTE);
Router::post($pair($p . '/checkout'), 'Nova:Site@checkoutSave!', Router::STATIC_ROUTE);
Router::get($pair($p . '/account'), 'Nova:Site@account!', Router::STATIC_ROUTE);
Router::post($pair($p . '/account'), 'Nova:Site@accountSave!', Router::STATIC_ROUTE);
Router::get($pair($p . '/desk'), 'Nova:Desk@login!', Router::STATIC_ROUTE);
Router::post($pair($p . '/desk'), 'Nova:Desk@loginSave!', Router::STATIC_ROUTE);
Router::get($pair($p . '/desk/home'), 'Nova:Desk@home!', Router::STATIC_ROUTE);
Router::get($pair($p . '/desk/orders'), 'Nova:Desk@orders!', Router::STATIC_ROUTE);
Router::post($pair($p . '/desk/orders/list'), 'Nova:Desk@ordersList!', Router::STATIC_ROUTE);
Router::get($pair($p . '/desk/products'), 'Nova:Desk@products!', Router::STATIC_ROUTE);
Router::post($pair($p . '/desk/products/list'), 'Nova:Desk@productsList!', Router::STATIC_ROUTE);
Router::post($pair($p . '/desk/products/save'), 'Nova:Desk@lockedWrite!', Router::STATIC_ROUTE)
->before('#Nova:WriteGate@write!');
Router::get($pair($p . '/desk/payments'), 'Nova:Desk@payments!', Router::STATIC_ROUTE);
Router::post($pair($p . '/desk/payments/list'), 'Nova:Desk@paymentsList!', Router::STATIC_ROUTE);
Router::get($pair($p . '/desk/customers'), 'Nova:Desk@customers!', Router::STATIC_ROUTE);
Router::post($pair($p . '/desk/settings'), 'Nova:Desk@lockedWrite!', Router::STATIC_ROUTE)
->before('#Nova:WriteGate@write!');
Router::get($pair($p . '/desk/settings'), 'Nova:Desk@settings!', Router::STATIC_ROUTE);
}
public function initializeRoutes()
{
return ['/documentation/examples/run/nova', '/documentation/examples/run/nova/*'];
}
public function initializeCondition($routeMatch)
{
return $routeMatch;
}
}
new Module($dotApp);
initializeRoutes() must return the prefixes the autoloader uses:
['/documentation/examples/run/nova', '/documentation/examples/run/nova/*'].
There is no Laravel Route::group(). Concatenate the prefix yourself.
Read slugs with $request->matchData()['slug']. Missing product → new Response(404, 'Sample product not found').
Renderer helper
Call Renderer::new()->module('Nova')->setView($name) before setViewVar.
Views that fail to render return "" — check that and log, then return HTTP 500.
Shared chrome is one document view; inner pages are fragments swapped into .
$r = Renderer::new()->module('Nova')->setView($name, 'clean');
foreach ($vars as $key => $value) {
$r->setViewVar($key, $value);
}
$html = $r->renderView();
if ($html === '') {
Logger::use()->error('Nova view empty', ['view' => $name]);
return new Response(500, 'Template error');
}
Catalog cache
The live catalog uses Cache::use('Nova') so the walkthrough can show a module cache, not only a query.
Context is the search string plus category. Lifetime is 120 seconds. The UI prints cache hit / miss so you can see it work.
$cache = Cache::use('Nova');
$key = 'floor.list';
$ctx = ['q' => $q, 'cat' => $cat];
$hit = $cache->load($key, $ctx);
if (is_array($hit)) {
$hit['cache'] = 'hit';
return $hit;
}
$pack = [/* filtered sample rows */];
$cache->save($key, $pack, 120, $ctx);
On a real shop cache the query result the same way. Still paginate lists that can grow. Encryption of SKUs is not a cache key — encrypt after the cache load.
Schema: the complete Installation.php
An e-shop is not “one products table”. Catalog, orders, payments, customers, and settings each need a table the module owns.
Every table is nova_*. Never unprefixed names, never dotapp_* for shop data.
There is no working DB::migrate(). The cart on this demo is DSM::use('Nova'), not a table — so a visitor cannot persist orders.
The public demo does not run this installer. Live pages read Libraries/Floor.php.
On your project copy this file, then call Installation::module('Nova')->install() from initialize() when a database is configured.
markDone('1.0.0') runs only after every CREATE TABLE succeeds.
| Table | Who fills it | Who reads it |
|---|---|---|
nova_products | Desk → Products | Catalog, product URL, cart add |
nova_orders | Checkout (production) | Desk → Orders |
nova_payments | Your gateway callback (production) | Desk → Payments |
nova_customers | Account create (production) | Desk → Customers |
nova_settings | Desk → Settings | Shop name in chrome |
nova_installations | ensureTable() / markDone() | Idempotency |
File: app/modules/Nova/Installation.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Nova;
use Dotsystems\App\Parts\DB;
use Dotsystems\App\Parts\Installer;
use Dotsystems\App\Parts\Logger;
class Installation extends Installer
{
public static function installer()
{
return [
'1.0.0' => function () {
if (self::alreadyDone('1.0.0')) {
return;
}
$sql = [];
$sql[] = "CREATE TABLE IF NOT EXISTS `nova_products` (
`id` INT NOT NULL AUTO_INCREMENT,
`sku` VARCHAR(40) NOT NULL,
`slug` VARCHAR(160) NOT NULL,
`title` VARCHAR(200) NOT NULL,
`category` VARCHAR(40) NOT NULL,
`price` INT NOT NULL DEFAULT 0,
`blurb` VARCHAR(255) NOT NULL DEFAULT '',
`status` VARCHAR(40) NOT NULL DEFAULT 'draft',
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `sku` (`sku`),
UNIQUE KEY `slug` (`slug`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `nova_orders` (
`id` INT NOT NULL AUTO_INCREMENT,
`code` VARCHAR(40) NOT NULL,
`email` VARCHAR(190) NOT NULL,
`total` INT NOT NULL DEFAULT 0,
`status` VARCHAR(40) NOT NULL DEFAULT 'open',
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `nova_payments` (
`id` INT NOT NULL AUTO_INCREMENT,
`order_id` INT NOT NULL DEFAULT 0,
`method` VARCHAR(40) NOT NULL,
`amount` INT NOT NULL DEFAULT 0,
`state` VARCHAR(40) NOT NULL DEFAULT 'none',
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `nova_customers` (
`id` INT NOT NULL AUTO_INCREMENT,
`email` VARCHAR(190) NOT NULL,
`name` VARCHAR(120) NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$sql[] = "CREATE TABLE IF NOT EXISTS `nova_settings` (
`id` INT NOT NULL AUTO_INCREMENT,
`setting_key` VARCHAR(80) NOT NULL,
`setting_value` TEXT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `setting_key` (`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$ok = true;
foreach ($sql as $chunk) {
DB::module('RAW')->q(function ($qb) use ($chunk) {
$qb->raw($chunk, []);
})->execute(
function () {},
function ($error) use (&$ok) {
$ok = false;
Logger::use()->error('Nova 1.0.0 failed', $error);
}
);
if (!$ok) {
return;
}
}
self::markDone('1.0.0');
},
];
}
public static function uninstaller()
{
return [
'1.0.0' => function () {
foreach (['nova_settings', 'nova_customers', 'nova_payments', 'nova_orders', 'nova_products', 'nova_installations'] as $table) {
DB::module('RAW')->q(fn($qb) => $qb->raw('DROP TABLE IF EXISTS `' . $table . '`', []))
->execute(null, function ($e) use ($table) {
Logger::use()->error($table . ' drop failed', $e);
});
}
},
];
}
private static function ensureTable(): void
{
DB::module('RAW')->q(function ($qb) {
$qb->raw(
"CREATE TABLE IF NOT EXISTS `nova_installations` (
`id` INT NOT NULL AUTO_INCREMENT,
`installation_id` VARCHAR(100) NOT NULL,
`installed_at` DATETIME NOT NULL,
`status` TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `ver` (`installation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
[]
);
})->execute(null, function ($e) {
Logger::use()->error('nova_installations table', $e);
});
}
private static function alreadyDone(string $version): bool
{
self::ensureTable();
$rows = DB::module('RAW')->q(function ($qb) use ($version) {
$qb->raw(
'SELECT 1 AS ok FROM `nova_installations` WHERE `installation_id` = :v AND `status` = 1 LIMIT 1',
['v' => $version]
);
})->all();
return !empty($rows);
}
private static function markDone(string $version): void
{
DB::module('RAW')->q(function ($qb) use ($version) {
$qb->insert('nova_installations', [
'installation_id' => $version,
'installed_at' => date('Y-m-d H:i:s'),
'status' => 1,
]);
})->execute(null, function ($e) {
Logger::use()->error('Nova markDone', $e);
});
}
}
Hook on a real site (not this public demo):
$dbs = Config::get('databases');
if (is_array($dbs) && $dbs !== []) {
try {
Installation::module('Nova')->install();
} catch (\Throwable $e) {
Logger::use()->error('Nova install skipped', ['msg' => $e->getMessage()]);
}
}
Public shop: templates, cart, checkout
Same two-layer pattern as CMS Studio. shop.view.php is the HTML document (ribbon, nav, cart count, scripts).
Inner views are fragments injected as {{ var: $bodyHtml }}. CSS: /assets/modules/Nova/css/nova.css.
Catalog data on the demo comes from Libraries/Floor.php, not nova_products.
Every shop view file
| File | Kind | What it paints |
|---|---|---|
views/shop.view.php | Document | EXAMPLE ribbon, sticky header, mobile drawer (#nvMenuBtn), #storeWrap, confirm modal, nova.js |
views/shop-home.view.php | Fragment | Hero + featured sample products |
views/shop-catalog.view.php | Fragment | Search, category chips, list wrap |
views/shop-grid.view.php | AJAX fragment | Product cards HTML in reply.html |
views/shop-product.view.php | Fragment | One sample product (no Offer schema) |
views/shop-cart.view.php | Fragment | Cart page around #cartInner |
views/shop-cart-inner.view.php | AJAX fragment | Lines + totals |
views/shop-checkout.view.php | Fragment | fo-rm checkoutForm |
views/shop-account.view.php | Document | Customer login that always fails |
Libraries/Cart.php | PHP | DSM cart — full file below |
assets/js/nova.js | JS | Mobile drawer (no $dotapp wait), add/remove, catalog search, checkout form |
assets/css/nova.css | CSS | Aero-modern shop + desk: Syne/Outfit, glass, product photos |
assets/img/*.jpg | Photos | Hero + one image per product slug |
Look: CSS and product photos
Public CSS is /assets/modules/Nova/css/nova.css (source app/modules/Nova/assets/css/nova.css).
Product photos live in app/modules/Nova/assets/img/ and are served as /assets/modules/Nova/img/{slug}.jpg.
Libraries/Floor.php sets $item['image'] from the slug. The hero uses nova-hero.jpg.
Copy the whole CSS file from disk — it is long on purpose (sticky glass header, featured grid, product story, cart lines with thumbs, merchant desk).
| File | Used on |
|---|---|
slide-summer.jpg, slide-linen.jpg, slide-home.jpg | Home slider |
linen-shirt.jpg … oak-tray.jpg | Cards, product page, cart, desk product table |
'image' => '/assets/modules/Nova/img/' . $slug . '.jpg',
File: app/modules/Nova/views/shop.view.php — copy this whole file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ var: $title }}</title>
<meta name="description" content="{{ var: $metaDescription }}" />
<meta name="robots" content="{{ var: $robots }}" />
<meta name="googlebot" content="{{ var: $robots }}" />
<meta name="googlebot-news" content="noindex" />
<meta name="author" content="Dotsystems s.r.o." />
<link rel="canonical" href="{{ var: $canonical }}" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ var: $title }}" />
<meta property="og:description" content="{{ var: $metaDescription }}" />
<meta property="og:url" content="{{ var: $canonical }}" />
<meta property="og:site_name" content="DotApp PHP Framework 2.0" />
<meta name="twitter:card" content="summary_large_image" />
<script type="application/ld+json">{{ var: $jsonLd }}</script>
<link rel="stylesheet" href="/assets/modules/Nova/css/nova.css?v={{ var: $assetV }}" />
</head>
<body class="nv-body">
<div class="nv-ribbon" role="note"><strong>EXAMPLE</strong> — DotApp PHP Framework 2.0 documentation demo.</div>
<div class="nv-util">
<span>50% off orders above €100</span>
<div class="nv-util-right">
<span>Language: English</span>
<span>Currency: EUR</span>
<a href="{{ var: $accountUrl }}">My Account</a>
<a href="{{ var: $accountUrl }}">Wishlist</a>
<a href="{{ var: $accountUrl }}">Register or Sign in</a>
</div>
</div>
<header class="nv-top">
<a class="nv-logo" href="{{ var: $homeUrl }}">NOVA<i>.</i></a>
<button type="button" class="nv-burger" id="nvMenuBtn" aria-controls="nvNav" aria-expanded="false" aria-label="Open menu"><span></span><span></span><span></span></button>
<div class="nv-scrim" id="nvScrim" hidden="hidden"></div>
<nav class="nv-nav" id="nvNav" aria-label="Shop">
<div class="nv-nav-head">
<span>Menu</span>
<button type="button" class="nv-nav-close" id="nvMenuClose" aria-label="Close menu">Close</button>
</div>
<a href="{{ var: $homeUrl }}" class="{{ if $nav === "home" }}is-active{{ /if }}">Home</a>
<a href="{{ var: $catalogUrl }}" class="{{ if $nav === "catalog" }}is-active{{ /if }}">Categories</a>
<a href="{{ var: $featuredUrl }}">Featured Products</a>
<a href="{{ var: $latestUrl }}">Latest Product</a>
<a href="{{ var: $aboutUrl }}" class="{{ if $nav === "about" }}is-active{{ /if }}">About Us</a>
<a href="{{ var: $contactUrl }}" class="{{ if $nav === "contact" }}is-active{{ /if }}">Contact Us</a>
<div class="nv-nav-extra">
<a href="{{ var: $accountUrl }}">My Account</a>
<a href="{{ var: $accountUrl }}">Wishlist</a>
<a href="{{ var: $accountUrl }}">Register or Sign in</a>
<a href="{{ var: $deskUrl }}">Desk</a>
</div>
</nav>
<form class="nv-find" method="get" action="{{ var: $catalogUrl }}">
<label class="nv-sr" for="nvFindCat">Category</label>
<select id="nvFindCat" name="cat">
<option value="">All Category</option>
{{ foreach $categories as $cat }}
<option value="{{ var: $cat['slug'] }}">{{ var: $cat['title'] }}</option>
{{ /foreach }}
</select>
<label class="nv-sr" for="nvFindQ">Find product</label>
<input type="search" id="nvFindQ" name="q" placeholder="Find Product Here" autocomplete="off" />
<button type="submit" aria-label="Search">⌕</button>
</form>
<a class="nv-cart-pill" href="{{ var: $cartUrl }}" aria-label="Cart">🛍<strong id="cartCount">{{ var: $cartCount }}</strong></a>
</header>
<main class="nv-main {{ var: $shellClass }}" id="storeWrap" data-add="{{ var: $prefix }}/cart/add" data-remove="{{ var: $prefix }}/cart/remove">
<div id="error-message" class="nv-error" hide="hide"></div>
<div id="status" class="nv-status" hide="hide"></div>
{{ var: $bodyHtml }}
</main>
<footer class="nv-foot">
<div>
<a class="nv-logo" href="{{ var: $homeUrl }}">NOVA<i>.</i></a>
<p>Quiet clothes and objects for everyday rooms. Live e-shop for the DotApp NOVA walkthrough.</p>
</div>
<div>
<strong>Shop</strong>
<p><a href="{{ var: $catalogUrl }}">Categories</a><br /><a href="{{ var: $cartUrl }}">Cart</a><br /><a href="{{ var: $accountUrl }}">Account</a></p>
</div>
<div>
<strong>Company</strong>
<p><a href="{{ var: $aboutUrl }}">About</a><br /><a href="{{ var: $contactUrl }}">Contact</a></p>
</div>
<div>
<strong>Desk</strong>
<p><a href="{{ var: $deskUrl }}">Sign in</a><br /><a href="{{ var: $docsUrl }}">Walkthrough</a></p>
</div>
</footer>
<div id="nvConfirm" class="nv-modal" hidden="hidden">
<div class="nv-modal-card">
<h2 id="nvConfirmTitle">Remove this item?</h2>
<p id="nvConfirmText">Remove this line from the cart.</p>
<button type="button" class="nv-btn js-nv-ok">Remove</button>
<button type="button" class="nv-btn nv-btn-ghost js-nv-cancel">Cancel</button>
</div>
</div>
<script src="{{ var: $dotappJs }}"></script>
<script src="/assets/modules/Nova/js/nova.js?v={{ var: $assetV }}"></script>
</body>
</html>
File: app/modules/Nova/views/shop-home.view.php — copy this whole file
<section class="nv-hero">
<div>
<p class="nv-kicker">Orbital supply</p>
<h1>NOVA</h1>
<p class="nv-lead">Habitat gear, visors, and transit kits — cut for dock light and long nights.</p>
<p class="nv-hero-actions">
<a class="nv-btn" href="{{ var: $prefix }}/catalog">Open catalog</a>
<a class="nv-btn nv-btn-ghost" href="{{ var: $prefix }}/product/pulse-visor">Pulse visor</a>
</p>
<p class="nv-cache">{{ var: $cacheLabel }}</p>
</div>
</section>
<section class="nv-cats">
{{ foreach $categories as $cat }}
<a class="nv-chip" href="{{ var: $prefix }}/catalog">{{ var: $cat['title'] }}</a>
{{ /foreach }}
</section>
<div class="nv-section-head">
<h2>Featured</h2>
<a href="{{ var: $prefix }}/catalog">All products</a>
</div>
<section class="nv-grid nv-grid-featured">
{{ foreach $featured as $item }}
<article class="nv-card">
<a class="nv-card-media" href="{{ var: $prefix }}/product/{{ var: $item['slug'] }}"><img src="{{ var: $item['image'] }}" alt="{{ var: $item['name'] }}" /></a>
<div class="nv-card-body">
<p class="nv-meta">{{ var: $item['category_title'] }} · {{ var: $item['status'] }}</p>
<h2><a href="{{ var: $prefix }}/product/{{ var: $item['slug'] }}">{{ var: $item['name'] }}</a></h2>
<p class="nv-blurb">{{ var: $item['blurb'] }}</p>
<p class="nv-price">{{ var: $item['price_label'] }}</p>
<button type="button" class="nv-btn js-nv-add" data-item="{{ var: $item['enc'] }}">Add to cart</button>
</div>
</article>
{{ /foreach }}
</section>
<div class="nv-section-head">
<h2>Collection</h2>
</div>
<section class="nv-grid">
{{ foreach $products as $item }}
<article class="nv-card">
<a class="nv-card-media" href="{{ var: $prefix }}/product/{{ var: $item['slug'] }}"><img src="{{ var: $item['image'] }}" alt="{{ var: $item['name'] }}" /></a>
<div class="nv-card-body">
<p class="nv-meta">{{ var: $item['category_title'] }}</p>
<h2><a href="{{ var: $prefix }}/product/{{ var: $item['slug'] }}">{{ var: $item['name'] }}</a></h2>
<p class="nv-blurb">{{ var: $item['blurb'] }}</p>
<p class="nv-price">{{ var: $item['price_label'] }}</p>
<button type="button" class="nv-btn js-nv-add" data-item="{{ var: $item['enc'] }}">Add to cart</button>
</div>
</article>
{{ /foreach }}
</section>
File: app/modules/Nova/views/shop-product.view.php — copy this whole file
<article class="nv-story">
<div class="nv-story-media"><img src="{{ var: $item['image'] }}" alt="{{ var: $item['name'] }}" /></div>
<div class="nv-story-copy">
<p class="nv-kicker">{{ var: $item['category_title'] }}</p>
<h1>{{ var: $item['name'] }}</h1>
<p class="nv-meta">SKU {{ var: $item['sku'] }} · {{ var: $item['status'] }}</p>
<p class="nv-lead">{{ var: $item['blurb'] }}</p>
<p class="nv-price">{{ var: $item['price_label'] }}</p>
<button type="button" class="nv-btn js-nv-add" data-item="{{ var: $enc }}" data-add="{{ var: $addUrl }}">Add to cart</button>
</div>
</article>
File: app/modules/Nova/views/shop-grid.view.php — AJAX catalog cards, copy this whole file
{{ if $total }}
<div class="nv-grid">
{{ foreach $rows as $item }}
<article class="nv-card" data-item="{{ var: $item['enc'] }}">
<a class="nv-card-media" href="{{ var: $item['href'] }}"><img src="{{ var: $item['image'] }}" alt="" /></a>
<div class="nv-card-body">
<p class="nv-meta">{{ var: $item['cat'] }} · {{ var: $item['status'] }}</p>
<h2><a href="{{ var: $item['href'] }}">{{ var: $item['name'] }}</a></h2>
<p class="nv-blurb">{{ var: $item['blurb'] }}</p>
<p class="nv-price">{{ var: $item['price'] }}</p>
<button type="button" class="nv-btn js-nv-add" data-item="{{ var: $item['enc'] }}">Add to cart</button>
</div>
</article>
{{ /foreach }}
</div>
{{ else }}
{{ if $searching }}
<p class="nv-empty">No products match that search.</p>
{{ else }}
<p class="nv-empty">No products in this slice.</p>
{{ /if }}
{{ /if }}
Cart: the complete Cart.php (DSM, not a table)
Never $_SESSION. Remove uses a graphical confirm (never alert / confirm) then $dotapp().load().
File: app/modules/Nova/Libraries/Cart.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Nova\Libraries;
use Dotsystems\App\Parts\DSM;
class Cart
{
public static function lines(): array
{
$cart = DSM::use('Nova')->get('cart');
if (!is_array($cart)) {
return [];
}
return $cart;
}
public static function save(array $cart): void
{
DSM::use('Nova')->set('cart', $cart);
}
public static function count(): int
{
$n = 0;
foreach (self::lines() as $line) {
$n += (int) ($line['qty'] ?? 0);
}
return $n;
}
public static function total(): int
{
$n = 0;
foreach (self::lines() as $line) {
$n += ((int) ($line['price'] ?? 0)) * ((int) ($line['qty'] ?? 0));
}
return $n;
}
public static function add(array $product): void
{
$cart = self::lines();
$found = false;
foreach ($cart as &$line) {
if (($line['sku'] ?? '') === $product['sku']) {
$line['qty'] = (int) $line['qty'] + 1;
$found = true;
break;
}
}
unset($line);
if (!$found) {
$cart[] = [
'sku' => $product['sku'],
'name' => $product['name'],
'price' => $product['price'],
'image' => $product['image'] ?? '',
'qty' => 1,
];
}
self::save($cart);
}
public static function remove(string $sku): void
{
$next = [];
foreach (self::lines() as $line) {
if (($line['sku'] ?? '') !== $sku) {
$next[] = $line;
}
}
self::save($next);
}
}
Catalog
Search fires from three characters. Category chips call the same list endpoint. PHP returns HTML for #gridInner.
Product ids in the DOM are Crypto::encrypt($sku, 'Nova.product.id'). Decrypt on add/remove; false means invalid.
Catalog slices are cached with Cache::use('Nova') for 120 seconds (see Catalog cache above).
Checkout
Named fo-rm checkoutForm. PHP checks name, email, payment method, and a non-empty cart, then clears the DSM cart.
On a real shop this is where you INSERT nova_orders and redirect to a provider you operate. Do not copy a live gateway into this public demo.
return ['code' => 200, 'body' => [
'status' => 1,
'message' => 'Thank you. Your order is complete.',
'count' => 0,
]];
Customer account
File views/shop-account.view.php. accountForm always returns status 0. Auth::login is not called. Robots meta is noindex,nofollow.
Production: follow the Users module walkthrough.
Merchant desk
The desk is the same module, a different document: sidebar, lock banner, fragments for overview / orders / products / payments / customers / settings.
On a phone the sidebar is an off-canvas drawer (#nvDeskMenuBtn in desk.view.php).
Login is desk-login.view.php — a fo-rm named loginForm.
Locked login (this demo)
The handler still uses crcCheck + $request->form(..., 'loginForm', ...). It never calls Auth::login().
Any email/password pair returns status 0. The read-only desk is a separate GET so you can still see the UI.
return ['code' => 200, 'body' => [
'status' => 0,
'locked' => 1,
'message' => 'Unable to sign in.',
]];
Production login
Copy the Users module pattern, then protect GET desk routes. Do not ship that gate on this public docs site — visitors would need accounts, and the point of the demo is that they cannot get a writable session.
Router::get($pair($p . '/desk/home'), 'Nova:Desk@home!', Router::STATIC_ROUTE)
->before('#Nova:AuthGate@check!');
public static function check($request)
{
if (!Auth::isLogged()) {
return Response::redirect($prefix . '/desk', 302);
}
if (!Auth::can(['Nova.desk'])) {
return new Response(403, 'Forbidden');
}
}
Write lock: the complete WriteGate.php
Every mutating desk POST is registered with ->before('#Nova:WriteGate@write!').
Returning a Response from a before-hook stops the controller. The live gate always answers:
File: app/modules/Nova/Middleware/WriteGate.php — copy this whole file
<?php
namespace Dotsystems\App\Modules\Nova\Middleware;
use Dotsystems\App\DotApp;
use Dotsystems\App\Parts\Response;
class WriteGate extends \Dotsystems\App\Parts\ModuleMiddleware
{
public static function write($request)
{
$body = DotApp::DotApp()->ajaxReply([
'status' => 0,
'locked' => 1,
'message' => 'EXAMPLE lock: this public demo never writes the database. No product, order, customer, or payment row can change.',
], 200);
return new Response(200, $body);
}
}
The product editor is still a real fo-rm (saveProduct) so loaders and error banners work.
Save posts, WriteGate rejects, the row does not change. Orders / payments lists are paginated AJAX with search from three characters, sticky header, and <mark>.
Encrypted ids
$enc = Crypto::encrypt($sku, 'Nova.product.id');
$id = Crypto::decrypt((string) ($data['id'] ?? ''), 'Nova.product.id');
if ($id === false) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Invalid sample product.'], 200);
}
Different extra keys: Nova.product.id, Nova.order.id, Nova.pay.id, Nova.customer.id.
Encryption is not authorization. On a real desk still call Auth::can().
dotapp.js
Load /assets/dotapp/dotapp.js first. Page logic listens for the dotapp event.
$dotapp is not jQuery. $dotapp().live() calls handler(element, event) — the first argument is the matched node.
$dotapp().live("click", ".js-nv-add", function (el, ev) {
var btn = (el && el.nodeType === 1) ? el : ev.currentTarget;
var id = btn.getAttribute("data-item");
$dotapp().load(addUrl(btn), "POST", { id: id }, function (raw) {
var reply = $dotapp().parseReply(raw);
if (reply && typeof reply.count !== "undefined") {
document.getElementById("cartCount").textContent = reply.count;
}
});
});
Catalog and desk lists: debounce input, fire search from three characters, overlay .lp_busy while in flight, patch the inner HTML with reply.html.
Pager buttons are type="button" with data-page.
Production checklist
- Scaffold with DotApper. Tables are
nova_*only. - Public shop: slugs, cache, DSM cart,
fo-rmcheckout, module CSS,Renderer+ fragments. - Desk:
Auth::isLogged()+Auth::can('Nova.desk')on every GET that is not the login page. - Writes:
crcCheck, unique extra keys,Auth::canagain, then INSERT/UPDATE. - Payments: a provider you operate. Never a live gateway on a public documentation demo.
- Growing lists:
paginate()+ AJAX pager + search from 3 characters + overlay + empty state + sticky header + highlight. - SEO: if the page is a real store, then Product schema belongs there. If the page is a docs example, keep TechArticle / LearningResource and the EXAMPLE ribbon.
- Do not invent Blade/Eloquent/jQuery APIs. If it is not in AIRULES, open
app/partsread-only.
Vyskúšajte živú ukážku
Otvorte bežiaci modul. Nemusíte sa vracať na začiatok tejto stránky.
Otvoriť NOVA obchod Otvoriť desk len na čítanie
- Open the shop: /documentation/examples/run/nova
- Search the catalog (try “lamp”), add a sample product, open the cart, run checkout — it will not charge anything.
- Open customer login and submit any password — it fails.
- Open the desk preview, search orders, click Save product / Save settings — every write is rejected.
Source: app/modules/Nova/. Walkthrough source: these layout files under Docs/views/layouts/pages/examples/nova*.layout.php.