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
57 changes: 53 additions & 4 deletions src/StorefrontCart.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Nimbus\Http\Response;
use Nimbus\Site\PageView;
use NimbusCMS\Commerce\CartPort;
use NimbusCMS\Commerce\OrderReadPort;
use NimbusCMS\Inventory\CatalogReadPort;

/**
* The storefront's public cart + checkout face (ADR 0026). It owns the themed
Expand Down Expand Up @@ -37,9 +39,16 @@ final class StorefrontCart
/** The only notice codes a template will render — anything else is ignored (no reflection). */
private const NOTICES = ['unavailable', 'expired', 'empty', 'stock'];

/** @param \Closure():?CartPort $cart resolved per request; null when Commerce is absent */
public function __construct(private \Closure $cart)
{
/**
* @param \Closure():?CartPort $cart resolved per request; null when Commerce is absent
* @param ?\Closure():?OrderReadPort $orderRead public-safe order read for the confirmation (ADR 0026); null → ref-only view
* @param ?\Closure():?CatalogReadPort $catalog resolves a line's SKU to a display name; null → the SKU itself
*/
public function __construct(
private \Closure $cart,
private ?\Closure $orderRead = null,
private ?\Closure $catalog = null,
) {
}

// --- render (GET sections) ------------------------------------------
Expand Down Expand Up @@ -86,7 +95,47 @@ public function orderSection(Request $request): ?PageView
if ($ref === null || $request->cookie(self::ORDER_COOKIE) !== $ref) {
return null;
}
return new PageView('shop-order', ['ref' => $ref], ['title' => 'Order received'], 200, true);
// The itemised receipt, or null → the theme falls back to the ref-only view.
return new PageView('shop-order', [
'ref' => $ref,
'order' => $this->orderSummary($ref),
], ['title' => 'Order received'], 200, true);
}

/**
* The visitor's just-placed order, projected public-safe (no PII) by Commerce's
* {@see OrderReadPort} and enriched with each line's display name via the catalog.
* Null when Commerce is absent or the order can't be read — the confirmation then
* shows the reference alone. Only reached after the ORDER_COOKIE gate above.
*
* @return array{status:string,total:string,lines:list<array{name:string,sku_code:string,qty:int,unit_price:string,line_total:string}>}|null
*/
private function orderSummary(string $ref): ?array
{
$port = $this->orderRead !== null ? ($this->orderRead)() : null;
if ($port === null) {
return null;
}
$order = $port->get($ref);
if ($order === null) {
return null;
}
$catalog = $this->catalog !== null ? ($this->catalog)() : null;

$lines = [];
foreach ($order['lines'] as $line) {
// Active item → its name; an inactive/deleted SKU → the SKU itself (never blank/500).
$item = $catalog?->get($line['sku_code']);
$name = is_array($item) ? $item['name'] : $line['sku_code'];
$lines[] = [
'name' => $name,
'sku_code' => $line['sku_code'],
'qty' => $line['qty'],
'unit_price' => $line['unit_price'],
'line_total' => $line['line_total'],
];
}
return ['status' => $order['status'], 'total' => $order['total'], 'lines' => $lines];
}

// --- actions (POST /ext) --------------------------------------------
Expand Down
9 changes: 7 additions & 2 deletions src/StorefrontPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Nimbus\Plugin\Plugin;
use Nimbus\Plugin\PluginContext;
use NimbusCMS\Commerce\CartPort;
use NimbusCMS\Commerce\OrderReadPort;
use NimbusCMS\Inventory\CatalogReadPort;

/**
Expand Down Expand Up @@ -36,8 +37,12 @@ public function register(PluginContext $context): void

// 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);
$cartPort = static fn (): ?CartPort => $context->services()->get(CartPort::class);
// The public-safe order read (ADR 0026) for the itemised confirmation, and
// the catalog port to resolve each line's SKU to a display name. Both
// resolved lazily; null-safe when their plugin is absent.
$orderRead = static fn (): ?OrderReadPort => $context->services()->get(OrderReadPort::class);
$cart = new StorefrontCart($cartPort, $orderRead, $port);
$templates = dirname(__DIR__) . '/templates';

// The current cart's CSRF token, for add-to-cart forms on the shop pages.
Expand Down
18 changes: 18 additions & 0 deletions templates/shop-order.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,29 @@
*
* @var callable(?string):string $e
* @var string $ref
* @var array{status:string,total:string,lines:list<array{name:string,sku_code:string,qty:int,unit_price:string,line_total:string}>}|null $order
*/
$order = $order ?? null;
?>
<div class="sf-wrap sf-order">
<h1>Order received</h1>
<p>Thank you — your order <strong><?= $e($ref) ?></strong> has been placed.</p>
<?php if ($order !== null && $order['lines'] !== []): ?>
<table class="sf-receipt">
<tbody>
<?php foreach ($order['lines'] as $line): ?>
<tr>
<td><?= $e($line['name']) ?> &times; <?= $e((string) $line['qty']) ?></td>
<td class="sf-receipt-amt"><?= $e($line['line_total']) ?></td>
</tr>
<?php endforeach; ?>
<tr class="sf-receipt-total">
<td>Total</td>
<td class="sf-receipt-amt"><strong><?= $e($order['total']) ?></strong></td>
</tr>
</tbody>
</table>
<?php endif; ?>
<p class="sf-muted">We'll be in touch to arrange payment and delivery.</p>
<p><a class="sf-btn" href="/shop">Continue shopping</a></p>
</div>
60 changes: 60 additions & 0 deletions tests/StorefrontCartTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Nimbus\Http\Request;
use Nimbus\Site\PageView;
use NimbusCMS\Commerce\CartPort;
use NimbusCMS\Commerce\OrderReadPort;
use NimbusCMS\Inventory\CatalogReadPort;
use NimbusCMS\Storefront\StorefrontCart;
use PHPUnit\Framework\TestCase;

Expand Down Expand Up @@ -204,6 +206,64 @@ public function test_the_order_confirmation_is_gated_to_the_order_cookie(): void
self::assertNull($this->cart->orderSection(new Request('GET', '/order/ORD-TEST', [], [], [], [], null, '', [])));
self::assertNull($this->cart->orderSection(new Request('GET', '/order/OTHER', [], [], [], [], null, '', [StorefrontCart::ORDER_COOKIE => 'ORD-TEST'])));
}

public function test_the_confirmation_is_itemised_with_resolved_names_and_a_sku_fallback(): void
{
$orderPort = new class () implements OrderReadPort {
public function get(string $ref): ?array
{
return $ref !== 'ORD-TEST' ? null : [
'reference' => 'ORD-TEST', 'status' => 'placed', 'total' => '3.80', 'placed_at' => '2026-01-01 09:00:00',
'lines' => [
['sku_code' => 'avocado', 'qty' => 2, 'unit_price' => '0.90', 'line_total' => '1.80'],
['sku_code' => 'discontinued', 'qty' => 1, 'unit_price' => '2.00', 'line_total' => '2.00'],
],
];
}
};
$catalog = new class () implements CatalogReadPort {
public function list(array $filters): array
{
return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 0, 'pages' => 0];
}

public function get(string $sku): ?array
{
// 'avocado' is active → a name; 'discontinued' is gone → null.
return $sku !== 'avocado' ? null : [
'sku_code' => 'avocado', 'name' => 'Avocado', 'price' => '0.90', 'unit' => 'each',
'description' => null, 'image_media_id' => null, 'category_id' => null,
'category' => null, 'featured' => false, 'availability' => 'in_stock',
];
}

public function categories(): array
{
return [];
}
};

$port = $this->port;
$cart = new StorefrontCart(static fn (): CartPort => $port, static fn (): OrderReadPort => $orderPort, static fn (): CatalogReadPort => $catalog);

$view = $cart->orderSection(new Request('GET', '/order/ORD-TEST', [], [], [], [], null, '', [StorefrontCart::ORDER_COOKIE => 'ORD-TEST']));
self::assertInstanceOf(PageView::class, $view);
$order = $view->data['order'];
self::assertSame('3.80', $order['total']);
self::assertSame('placed', $order['status']);
self::assertSame('Avocado', $order['lines'][0]['name'], 'an active SKU resolves to its name');
self::assertSame('discontinued', $order['lines'][1]['name'], 'a gone SKU falls back to the SKU itself');
self::assertSame('1.80', $order['lines'][0]['line_total']);
}

public function test_the_confirmation_falls_back_to_ref_only_without_commerce(): void
{
// No order-read port wired → order is null, the page still renders.
$view = $this->cart->orderSection(new Request('GET', '/order/ORD-TEST', [], [], [], [], null, '', [StorefrontCart::ORDER_COOKIE => 'ORD-TEST']));
self::assertInstanceOf(PageView::class, $view);
self::assertSame('ORD-TEST', $view->data['ref']);
self::assertNull($view->data['order']);
}
}

/** A minimal CartPort double that records what the storefront asked it to do. */
Expand Down
Loading