Skip to content

NOVA shop

A complete e-shop module: catalog, cache, cart, checkout, merchant desk, templates, and dotapp.js. Live shop: /documentation/examples/run/nova. Locked desk: /documentation/examples/run/nova/desk.

What you are building

An e-shop is not a product grid. It is two products in one module: a public store people browse, and a merchant desk for orders, catalog, payments, customers, and settings. This walkthrough builds both inside app/modules/Nova. The live example is a quiet fashion shop called NOVA — home slider, catalog, product, cart, checkout, plus a read-only desk.

The mini Storefront stays the Basic example (one idea: DSM cart + AJAX search). NOVA is the Pro example: cache, checkout, order lists, and a merchant shell.

This public demo cannot sell, sign in, or write the database

Customer login and merchant login are real fo-rm posts. PHP validates them, then always rejects them. Auth::login() is never called. Checkout validates, then discards the payload — no payment gateway, no captured card, no order INSERT. Every desk save is stopped by WriteGate@write. The catalog lives in PHP. Nobody can change a database row here.

Use this page as the recipe for a real shop on your own project. Swap the in-memory floor for nova_* tables, put the desk behind Auth::isLogged(), plug in a payment provider you control, and keep the same templates.

Titles and JSON-LD

Live pages use Libraries/Mark.php for the document title, meta description, canonical URL, and JSON-LD.

  • Document <title> starts with Example: and ends with DotApp PHP Framework 2.0 documentation demo.
  • JSON-LD is TechArticle + LearningResource (educationalUse: demonstration). There is no Product, Offer, or Store type.
  • googlebot-news: noindex. Login pages use noindex,nofollow.

Module map

One module owns the whole shop. Do not split “storefront app” and “admin app” unless they are genuinely separate products.

PiecePathRole
Routesmodule.init.phpPublic URLs + /desk/*. Write routes use ->before('#Nova:WriteGate@write!').
Public shopControllers/Site.phpHome, catalog, product, cart, checkout, locked account.
DeskControllers/Desk.phpLogin (always fails here), overview, orders, products, payments, customers, settings.
GateMiddleware/WriteGate.phpDemo: reject every write. Production: require Auth::isLogged() + Auth::can().
FloorLibraries/Floor.phpDemo catalog in PHP + Cache::use('Nova'). Production: DB::module('RAW') on nova_*.
CartLibraries/Cart.phpDSM::use('Nova') only. Never $_SESSION. Never a cart table on this demo.
SEOLibraries/Mark.phpExample titles, canonicals, TechArticle JSON-LD.
SchemaInstallation.phpTeaching tables: products, orders, payments, customers, settings. Not installed on the public demo.
Templatesviews/*.view.phpFull HTML documents + fragments. No Blade, no Twig, no include.
JSassets/js/nova.js, desk.js$dotapp().form and $dotapp().load. Not jQuery.

Live URLs

URLWhat you see
/documentation/examples/run/novaPublic shop home (cache label)
/catalogAJAX catalog, search from 3 characters
/documentation/examples/run/nova/product/{slug}Product page
/cartCart
/checkoutCheckout
/accountCustomer sign-in
/deskMerchant sign-in
/desk/homeDesk overview
/desk/ordersOrders list (search “NV-”)

Prefix is Config::module('Nova', 'prefix'), default /documentation/examples/run/nova. On your site use / for the shop and /desk for the merchant UI.

Files on disk

After DotApper, fill these paths. The live demo reads products, orders, and payments from Libraries/Floor.php. The cart is Libraries/Cart.php (DSM). Installation.php is listed in full later — not executed on the public demo.

app/modules/Nova/
  module.init.php          routes (full file below)
  Installation.php         nova_* tables (full file below)
  Libraries/View.php       Renderer helper
  Libraries/Floor.php      sample catalog + cache
  Libraries/Cart.php       DSM cart (full file below)
  Libraries/Mark.php       Example SEO
  Controllers/Site.php     public shop
  Controllers/Desk.php     merchant desk
  Middleware/WriteGate.php write lock (full file below)
  views/shop.view.php      public chrome
  views/shop-*.view.php    public fragments
  views/desk.view.php      desk chrome
  views/desk-*.view.php    desk fragments
  assets/css/nova.css      aero-modern shop + desk
  assets/js/nova.js
  assets/js/desk.js
  assets/img/*.jpg         product photos + hero

Open the live shop

Jump into the running module. You do not need to scroll back to the top of this page.

Open NOVA shop Open merchant desk

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.

TableWho fills itWho reads it
nova_productsDesk → ProductsCatalog, product URL, cart add
nova_ordersCheckout (production)Desk → Orders
nova_paymentsYour gateway callback (production)Desk → Payments
nova_customersAccount create (production)Desk → Customers
nova_settingsDesk → SettingsShop name in chrome
nova_installationsensureTable() / 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

FileKindWhat it paints
views/shop.view.phpDocumentEXAMPLE ribbon, sticky header, mobile drawer (#nvMenuBtn), #storeWrap, confirm modal, nova.js
views/shop-home.view.phpFragmentHero slider, trust bar, featured + latest grids
views/shop-catalog.view.phpFragmentSearch, category chips, list wrap
views/shop-grid.view.phpAJAX fragmentProduct cards HTML in reply.html
views/shop-product.view.phpFragmentOne sample product (no Offer schema)
views/shop-cart.view.phpFragmentCart page around #cartInner
views/shop-cart-inner.view.phpAJAX fragmentLines + totals
views/shop-checkout.view.phpFragmentfo-rm checkoutForm
views/shop-account.view.phpDocumentCustomer login that always fails
Libraries/Cart.phpPHPDSM cart — full file below
assets/js/nova.jsJSMobile drawer (no $dotapp wait), add/remove, catalog search, checkout form
assets/css/nova.cssCSSLight shop: teal accent, slider, utility bar, product photos
assets/img/*.jpgPhotosHero + 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. Home slides use slide-summer.jpg, slide-linen.jpg, slide-home.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).

FileUsed on
slide-summer.jpg, slide-linen.jpg, slide-home.jpgHome slider
linen-shirt.jpgoak-tray.jpgCards, 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.

Open NOVA shop Open merchant desk

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-rm checkout, 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::can again, 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/parts read-only.

Try the live demo

Open the running module. You do not need to scroll back to the top of this page.

Open NOVA shop Open read-only desk

  1. Open the shop: /documentation/examples/run/nova
  2. Search the catalog (try “lamp”), add a sample product, open the cart, run checkout — it will not charge anything.
  3. Open customer login and submit any password — it fails.
  4. 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.