Prejsť na obsah

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.

Title a JSON-LD

Živé stránky berú document title, meta description, canonical URL a JSON-LD z Libraries/Mark.php.

  • Dokumentový <title> začína Example: a končí DotApp PHP Framework 2.0 documentation demo.
  • JSON-LD je TechArticle + LearningResource (educationalUse: demonstration). Žiadne typy Product, Offer ani Store.
  • 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
Routymodule.init.phpVerejné URL + /desk/*. Zápisové routy majú ->before('#Nova:WriteGate@write!').
Verejný obchodControllers/Site.phpÚvod, katalóg, produkt, košík, checkout, zamknutý účet.
DeskControllers/Desk.phpLogin (tu vždy zlyhá), prehľad, objednávky, produkty, platby, zákazníci, nastavenia.
BránaMiddleware/WriteGate.phpDemo: odmietni každý zápis. Produkcia: Auth::isLogged() + Auth::can().
FloorLibraries/Floor.phpDemo katalóg v PHP + Cache::use('Nova'). Produkcia: DB::module('RAW') na nova_*.
KošíkLibraries/Cart.phpLen DSM::use('Nova'). Nikdy $_SESSION. Na tejto ukážke žiadna tabuľka košíka.
SEOLibraries/Mark.phpExample title, canonical, TechArticle JSON-LD.
SchémaInstallation.phpVýukové tabuľky: products, orders, payments, customers, settings. Na verejnej ukážke sa neinštalujú.
Šablónyviews/*.view.phpCelé HTML dokumenty + fragmenty. Žiadny Blade, Twig, include.
JSassets/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)
/catalogAJAX katalóg, hľadanie od 3 znakov
/documentation/examples/run/nova/product/{slug}Stránka produktu
/cartKošík
/checkoutCheckout
/accountPrihlásenie zákazníka
/deskPrihlásenie do desk-u
/desk/homePrehľad desk-u
/desk/ordersZoznam 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.

Otvoriť NOVA shop Otvoriť obchodný 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 + featured sample products
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.cssCSSAero-modern shop + desk: Syne/Outfit, glass, 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. 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).

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.

Otvoriť NOVA shop Otvoriť obchodný 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.

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

  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.