Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "nimbuscms/storefront",
"description": "A public, themed storefront for NimbusCMS: renders the Inventory item master as a shoppable catalog (categories, filter, sort, search, availability). An official plugin.",
"description": "A public, themed storefront for NimbusCMS: renders the Inventory item master as a shoppable catalog (categories, filter, sort, search, availability), with a cart + checkout via Commerce. An official plugin.",
"type": "nimbuscms-plugin",
"keywords": [
"nimbuscms",
Expand All @@ -21,7 +21,8 @@
"php": ">=8.2",
"ext-json": "*",
"nimbuscms/nimbus": "dev-main",
"nimbuscms/inventory": "dev-main"
"nimbuscms/inventory": "dev-main",
"nimbuscms/commerce": "dev-main"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.95",
Expand All @@ -36,6 +37,10 @@
{
"type": "vcs",
"url": "https://github.com/NimbusCMS/plugin-inventory"
},
{
"type": "vcs",
"url": "https://github.com/NimbusCMS/plugin-commerce"
}
],
"autoload": {
Expand Down
123 changes: 123 additions & 0 deletions src/StorefrontCart.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Storefront;

use Nimbus\Http\Request;
use Nimbus\Http\Response;
use Nimbus\Site\PageView;
use NimbusCMS\Commerce\CartPort;

/**
* The storefront's public cart + checkout face (ADR 0026). It owns the themed
* `/cart`·`/checkout`·`/order` **sections** (GET) and the `/ext/shop/*` POST
* **actions** (ADR 0017), driving Commerce's {@see CartPort} — never its tables.
*
* Security posture (ADR 0026): the cart is authorised by an opaque, server-random
* cookie token (`HttpOnly`+`SameSite=Lax`+`Secure`); price is resolved server-side
* by the port; the money action (checkout) is CSRF-verified with the per-cart
* secret, and cart mutations are CSRF-verified whenever a real cart exists (a
* first "bootstrap" add mints the cart and is covered by `SameSite=Lax`). Viewing
* a page never mints a cart. Cart/checkout/order pages are marked **private** so a
* CDN never serves one visitor's cart to another.
*/
final class StorefrontCart
{
public const COOKIE = 'nb_cart';
private const COOKIE_TTL = 14 * 86400;

/** @param \Closure():?CartPort $cart resolved per request; null when Commerce is absent */
public function __construct(private \Closure $cart)
{
}

// --- render (GET sections) ------------------------------------------

/** The `/cart` page — the cart priced live, with update/remove forms. Private. */
public function cartSection(Request $request): PageView
{
$port = ($this->cart)();
$meta = $this->existing($request, $port);
$contents = ($port !== null && $meta !== null) ? $port->contents($meta['token']) : ['lines' => [], 'total' => '0.00', 'count' => 0];

return new PageView('shop-cart', [
'cart' => $contents,
'csrf' => $meta['csrf'] ?? '',
'available' => $port !== null,
], ['title' => 'Your cart'], 200, true);
}

// --- actions (POST /ext) --------------------------------------------

/** POST add: {sku, qty}. Mints the cart on first add (sets the cookie). */
public function add(Request $request): Response
{
return $this->mutating($request, function (CartPort $port, string $token) use ($request): void {
$port->add($token, (string) ($request->input('sku') ?? ''), (string) ($request->input('qty') ?? '1'));
});
}

/** POST update: {sku, qty} (qty 0 removes). */
public function update(Request $request): Response
{
return $this->mutating($request, function (CartPort $port, string $token) use ($request): void {
$port->setQty($token, (string) ($request->input('sku') ?? ''), (string) ($request->input('qty') ?? '0'));
});
}

// --- helpers ---------------------------------------------------------

/**
* Run a cart mutation with the cart-token cookie + CSRF discipline, then
* redirect to /cart. If the request already carried a real cart, its CSRF
* token is required; a first add (no/stale cookie → a freshly minted cart) is
* allowed and covered by SameSite=Lax. The (possibly new) token is always
* re-set as the cookie.
*
* @param \Closure(CartPort,string):void $do
*/
private function mutating(Request $request, \Closure $do): Response
{
$port = ($this->cart)();
if ($port === null) {
return Response::redirect('/shop');
}
$incoming = $request->cookie(self::COOKIE);
$meta = $port->getOrCreate($incoming);
$token = $meta['token'];

// A pre-existing cart must present its CSRF token; a bootstrap mint need not.
$preExisting = $incoming !== null && $incoming === $token;
if ($preExisting && !$port->csrfOk($token, $request->input('_cart_csrf'))) {
return Response::redirect('/cart');
}

try {
$do($port, $token);
} catch (\InvalidArgumentException) {
// A bad qty / unavailable item: fall through to the cart, which shows
// the current state — the storefront never 500s on user input.
}

return Response::redirect('/cart')->withCookie(self::COOKIE, $token, self::COOKIE_TTL);
}

/**
* The visitor's existing cart meta, or null — WITHOUT minting one (viewing a
* page must never create a cart row). Returns null when there's no cookie.
*
* @return array{token:string,csrf:string}|null
*/
public function existing(Request $request, ?CartPort $port): ?array
{
$token = $request->cookie(self::COOKIE);
if ($token === null || $token === '' || $port === null) {
return null;
}
// getOrCreate returns the existing cart for a valid token (no INSERT); a
// stale token mints a fresh empty one, which is harmless and rare.
$meta = $port->getOrCreate($token);
return $meta['token'] === $token ? $meta : null;
}
}
23 changes: 20 additions & 3 deletions src/StorefrontPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

namespace NimbusCMS\Storefront;

use Nimbus\Http\Request;
use Nimbus\Http\Response;
use Nimbus\Plugin\Plugin;
use Nimbus\Plugin\PluginContext;
use NimbusCMS\Commerce\CartPort;
use NimbusCMS\Inventory\CatalogReadPort;

/**
Expand All @@ -31,9 +34,23 @@ public function register(PluginContext $context): void
// installed, so the resolver degrades gracefully.
$port = static fn (): ?CatalogReadPort => $context->services()->get(CatalogReadPort::class);

// A themed public section at /shop, with this plugin's default templates as
// the theme-overridable fallback.
$context->pages()->register('shop', new StorefrontResolver($port), dirname(__DIR__) . '/templates');
// The cart, driven through Commerce's CartPort (ADR 0026) — null when
// Commerce is absent, so a browse-only storefront still works.
$cartPort = static fn (): ?CartPort => $context->services()->get(CartPort::class);
$cart = new StorefrontCart($cartPort);
$templates = dirname(__DIR__) . '/templates';

// The current cart's CSRF token, for add-to-cart forms on the shop pages.
$cartCsrf = static fn (Request $r): string => $cart->existing($r, $cartPort())['csrf'] ?? '';

// The themed public sections (ADR 0023): the catalog at /shop, and the cart.
$context->pages()->register('shop', new StorefrontResolver($port, $cartCsrf), $templates);
$context->pages()->register('cart', $cart->cartSection(...), $templates);

// The cart mutations — public POST actions (ADR 0017), CSRF-guarded, that
// redirect back to /cart.
$context->routes()->post('shop', '/cart/add', static fn (Request $r, array $p): Response => $cart->add($r));
$context->routes()->post('shop', '/cart/update', static fn (Request $r, array $p): Response => $cart->update($r));

// Teach an agent what the storefront is (ADR 0013).
$context->skills()->register('Storefront', Guide::text());
Expand Down
21 changes: 16 additions & 5 deletions src/StorefrontResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,24 @@ final class StorefrontResolver
/** The handle this section is mounted at, and its URL prefix. */
private const HANDLE = 'shop';

/** @param \Closure():?CatalogReadPort $port resolved per request; null when Inventory is absent */
public function __construct(private \Closure $port)
/**
* @param \Closure():?CatalogReadPort $port resolved per request; null when Inventory is absent
* @param ?\Closure(Request):string $cartCsrf the current cart's CSRF token (or '') for add-to-cart forms
*/
public function __construct(private \Closure $port, private ?\Closure $cartCsrf = null)
{
}

public function __invoke(Request $request): ?PageView
{
$sku = $this->skuFromPath($request->path);
return $sku === null ? $this->listing($request) : $this->product($sku);
return $sku === null ? $this->listing($request) : $this->product($request, $sku);
}

/** The current cart's CSRF token for add-to-cart forms (empty when no cart yet). */
private function cartCsrf(Request $request): string
{
return $this->cartCsrf !== null ? ($this->cartCsrf)($request) : '';
}

/** The listing at `/shop` — filters from the query, always a page (never 404). */
Expand Down Expand Up @@ -64,18 +73,20 @@ private function listing(Request $request): PageView
'sort' => is_string($filters['sort']) ? $filters['sort'] : '',
],
'available' => $port !== null,
'cart_csrf' => $this->cartCsrf($request),
], ['title' => 'Shop', 'description' => 'Browse our range.']);
}

/** A product page at `/shop/{sku}`, or null (→ themed 404) when not found/active. */
private function product(string $sku): ?PageView
private function product(Request $request, string $sku): ?PageView
{
$item = (($this->port)())?->get($sku);
if ($item === null) {
return null;
}
return new PageView('shop-product', [
'item' => $item,
'item' => $item,
'cart_csrf' => $this->cartCsrf($request),
], ['title' => $item['name'], 'description' => $item['description'] ?? '', 'og_type' => 'product']);
}

Expand Down
47 changes: 47 additions & 0 deletions templates/shop-cart.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php
/**
* Default cart page (ADR 0026). Private (no-store). Every value escaped; the
* update/remove forms carry the per-cart CSRF token.
*
* @var callable(?string):string $e
* @var array{lines:list<array<string,mixed>>,total:string,count:int} $cart
* @var string $csrf
* @var bool $available
*/
?>
<div class="sf-wrap">
<h1>Your cart</h1>

<?php if (!$available): ?>
<p class="sf-empty">The cart is unavailable right now.</p>
<?php elseif ($cart['count'] === 0): ?>
<p class="sf-empty">Your cart is empty. <a href="/shop">Browse the shop</a>.</p>
<?php else: ?>
<div class="sf-cart">
<?php foreach ($cart['lines'] as $line): ?>
<div class="sf-cart-row">
<div class="sf-cart-name"><?= $e($line['name']) ?><?php if ($line['unit'] !== null): ?> <span class="sf-muted">/ <?= $e($line['unit']) ?></span><?php endif; ?></div>
<form class="sf-cart-qty" method="post" action="/ext/shop/cart/update">
<input type="hidden" name="_cart_csrf" value="<?= $e($csrf) ?>">
<input type="hidden" name="sku" value="<?= $e($line['sku_code']) ?>">
<label class="sf-sr">Quantity of <?= $e($line['name']) ?></label>
<input type="number" name="qty" min="0" max="999" value="<?= $e((string) $line['qty']) ?>" inputmode="numeric">
<button type="submit" class="sf-btn sf-btn-sm">Update</button>
</form>
<div class="sf-cart-price"><?= $e($line['line_total']) ?></div>
<form class="sf-cart-remove" method="post" action="/ext/shop/cart/update">
<input type="hidden" name="_cart_csrf" value="<?= $e($csrf) ?>">
<input type="hidden" name="sku" value="<?= $e($line['sku_code']) ?>">
<input type="hidden" name="qty" value="0">
<button type="submit" class="sf-link-danger">Remove</button>
</form>
</div>
<?php endforeach; ?>
</div>

<div class="sf-cart-foot">
<p class="sf-cart-total">Total <strong><?= $e($cart['total']) ?></strong></p>
<a class="sf-btn sf-btn-primary" href="/checkout">Checkout</a>
</div>
<?php endif; ?>
</div>
9 changes: 9 additions & 0 deletions templates/shop-index.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* @var int $pages
* @var int $total
* @var bool $available
* @var string $cart_csrf
*/
$labels = ['in_stock' => 'In stock', 'low' => 'Low stock', 'out' => 'Out of stock'];
$sorts = ['featured' => 'Featured', 'name' => 'Name', 'price_asc' => 'Price: low to high', 'price_desc' => 'Price: high to low'];
Expand Down Expand Up @@ -94,6 +95,14 @@
<a class="sf-name" href="/shop/<?= $e(rawurlencode($it['sku_code'])) ?>"><?= $e($it['name']) ?></a>
<span class="sf-price"><?= $e($it['price']) ?><?= $it['unit'] !== null ? ' <span>/ ' . $e($it['unit']) . '</span>' : '' ?></span>
<span class="sf-avail <?= $e($it['availability']) ?>"><?= $e($labels[$it['availability']] ?? $it['availability']) ?></span>
<?php if ($it['availability'] !== 'out'): ?>
<form method="post" action="/ext/shop/cart/add" class="sf-add">
<input type="hidden" name="_cart_csrf" value="<?= $e($cart_csrf ?? '') ?>">
<input type="hidden" name="sku" value="<?= $e($it['sku_code']) ?>">
<input type="hidden" name="qty" value="1">
<button type="submit" class="sf-btn">Add to cart</button>
</form>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
Expand Down
9 changes: 9 additions & 0 deletions templates/shop-product.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* @var callable(?int):?array{url:string,alt:?string} $media
* @var string $cspNonce
* @var array<string,mixed> $item
* @var string $cart_csrf
*/
$labels = ['in_stock' => 'In stock', 'low' => 'Low stock', 'out' => 'Out of stock'];
$img = $media($item['image_media_id']);
Expand All @@ -33,6 +34,14 @@
<h1><?= $e($item['name']) ?></h1>
<p class="sf-p-price"><?= $e($item['price']) ?><?= $item['unit'] !== null ? ' <span>/ ' . $e($item['unit']) . '</span>' : '' ?></p>
<p class="sf-p-avail <?= $e($item['availability']) ?>"><?= $e($labels[$item['availability']] ?? $item['availability']) ?></p>
<?php if ($item['availability'] !== 'out'): ?>
<form method="post" action="/ext/shop/cart/add" class="sf-add">
<input type="hidden" name="_cart_csrf" value="<?= $e($cart_csrf ?? '') ?>">
<input type="hidden" name="sku" value="<?= $e($item['sku_code']) ?>">
<input type="number" name="qty" value="1" min="1" max="999" inputmode="numeric" aria-label="Quantity">
<button type="submit" class="sf-btn sf-btn-primary">Add to cart</button>
</form>
<?php endif; ?>
<?php if ($item['category'] !== null): ?>
<p class="sf-p-cat"><?= $e($item['category']) ?></p>
<?php endif; ?>
Expand Down
Loading
Loading