diff --git a/composer.json b/composer.json index 89b8a99..d4d71b2 100644 --- a/composer.json +++ b/composer.json @@ -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", @@ -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", @@ -36,6 +37,10 @@ { "type": "vcs", "url": "https://github.com/NimbusCMS/plugin-inventory" + }, + { + "type": "vcs", + "url": "https://github.com/NimbusCMS/plugin-commerce" } ], "autoload": { diff --git a/src/StorefrontCart.php b/src/StorefrontCart.php new file mode 100644 index 0000000..4cfc9c3 --- /dev/null +++ b/src/StorefrontCart.php @@ -0,0 +1,123 @@ +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; + } +} diff --git a/src/StorefrontPlugin.php b/src/StorefrontPlugin.php index 71b222c..607d055 100644 --- a/src/StorefrontPlugin.php +++ b/src/StorefrontPlugin.php @@ -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; /** @@ -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()); diff --git a/src/StorefrontResolver.php b/src/StorefrontResolver.php index 3125962..0be5599 100644 --- a/src/StorefrontResolver.php +++ b/src/StorefrontResolver.php @@ -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). */ @@ -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']); } diff --git a/templates/shop-cart.php b/templates/shop-cart.php new file mode 100644 index 0000000..46c28a6 --- /dev/null +++ b/templates/shop-cart.php @@ -0,0 +1,47 @@ +>,total:string,count:int} $cart + * @var string $csrf + * @var bool $available + */ +?> +
The cart is unavailable right now.
+ +Your cart is empty. Browse the shop.
+ +Total = $e($cart['total']) ?>
+ Checkout += $e($item['price']) ?>= $item['unit'] !== null ? ' / ' . $e($item['unit']) . '' : '' ?>
= $e($labels[$item['availability']] ?? $item['availability']) ?>
+ + += $e($item['category']) ?>
diff --git a/tests/StorefrontCartTest.php b/tests/StorefrontCartTest.php new file mode 100644 index 0000000..ad4d977 --- /dev/null +++ b/tests/StorefrontCartTest.php @@ -0,0 +1,148 @@ +port = new FakeCartPort(); + $port = $this->port; + $this->cart = new StorefrontCart(static fn (): CartPort => $port); + } + + /** @param array