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
168 changes: 137 additions & 31 deletions src/CommerceAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,42 @@
use Nimbus\Plugin\PluginStorage;

/**
* The Commerce admin page — a read-only list of orders with their status, total
* and lines. Registered as a GET-only plugin admin page; orders are placed and
* moved through the MCP tools (or an agent), so this is a window, not an editor.
* The Commerce admin page — the orders list with their status, total and lines,
* a quick place-order form, and per-row lifecycle buttons (pay, fulfil, cancel).
* The same lifecycle an agent drives over MCP; this is the human hand on it.
*
* Customer emails and SKU codes can originate from callers, so every value is
* escaped before it reaches the page.
* escaped before it reaches the page. Status "pills" use the admin theme's
* semantic tokens (which redefine per theme) rather than hard-coded colours, so
* they stay legible in dark and every selectable theme.
*/
final class CommerceAdmin
{
/** status => [background token, text token] — all theme-defined, so dark-safe. */
private const STATUS_TONE = [
'pending' => '#9a6a12',
'paid' => '#0f766e',
'fulfilled' => '#5751d6',
'cancelled' => '#a5386b',
'pending' => ['--nb-warn-bg', '--nb-warn-text'],
'paid' => ['--nb-brand-tint', '--nb-link-color'],
'fulfilled' => ['--nb-ok-bg', '--nb-ok-text'],
'cancelled' => ['--nb-surface-2', '--nb-muted'],
];

/** A tiny symbol map — no ext-intl dependency; unknown codes render as "12.50 XYZ". */
private const SYMBOL = [
'USD' => '$', 'EUR' => '€', 'GBP' => '£', 'JPY' => '¥',
'AUD' => 'A$', 'CAD' => 'C$', 'NZD' => 'NZ$', 'INR' => '₹',
];

private const NOTICES = [
'placed' => ['ok', 'Order placed and stock reserved.'],
'paid' => ['ok', 'Order marked paid.'],
'fulfilled' => ['ok', 'Order fulfilled — stock shipped.'],
'cancelled' => ['ok', 'Order cancelled — stock released.'],
'short' => ['err', 'Not enough stock available to place that order.'],
'invalid' => ['err', 'Check the SKU and quantity and try again.'],
'placed' => ['ok', 'Order placed and stock reserved.'],
'paid' => ['ok', 'Order marked paid.'],
'fulfilled' => ['ok', 'Order fulfilled — stock shipped.'],
'cancelled' => ['ok', 'Order cancelled — stock released.'],
'short' => ['err', 'Not enough stock available to place that order.'],
'badqty' => ['err', 'Enter a valid quantity and unit price.'],
'noinventory' => ['err', 'Install the Inventory plugin — an order reserves stock against it.'],
'notfound' => ['err', 'No order with that reference.'],
'badstate' => ['err', 'That order can’t move to that state.'],
'invalid' => ['err', 'Check the SKU and quantity and try again.'],
];

/** @param \Closure():PluginStorage $storage */
Expand All @@ -40,11 +53,23 @@ public function __construct(private \Closure $storage)
/**
* @param string $csrf CSRF token for the forms (passed by core to the page handler)
* @param ?string $notice a fixed notice code (from the ?ok=/?err= redirect)
* @param ?string $status a status filter (from ?status=), allow-listed to the known statuses
*/
public function render(string $csrf = '', ?string $notice = null): string
public function render(string $csrf = '', ?string $notice = null, ?string $status = null): string
{
$s = ($this->storage)();
$orders = $s->select('SELECT id, reference, status, customer_email, total, placed_at FROM ' . Schema::ORDER . ' ORDER BY id DESC LIMIT 50');
$s = ($this->storage)();

// Allow-list the filter: an unknown value is ignored (never reaches SQL).
$status = ($status !== null && isset(self::STATUS_TONE[$status])) ? $status : null;
$where = $status === null ? '' : ' WHERE status = :status';
$params = $status === null ? [] : ['status' => $status];
$orders = $s->select('SELECT id, reference, status, customer_email, currency, total, placed_at FROM ' . Schema::ORDER . $where . ' ORDER BY id DESC LIMIT 50', $params);

/** @var list<string> $skus SKUs sold before — the place-form suggestions (own table; boundary-safe) */
$skus = array_map(
static fn (array $r): string => (string) $r['sku_code'],
$s->select('SELECT DISTINCT sku_code FROM ' . Schema::LINE . ' ORDER BY sku_code'),
);

$banner = '';
if ($notice !== null && isset(self::NOTICES[$notice])) {
Expand All @@ -59,49 +84,130 @@ public function render(string $csrf = '', ?string $notice = null): string

$html = '<div class="nb-page-head"><h1>Commerce</h1></div>' . $banner
. '<p class="nb-muted" style="margin:-8px 0 20px">Orders reserve stock against Inventory. '
. 'Place a quick order below, or drive the full lifecycle (pay, fulfil, cancel) over MCP.</p>'
. 'Place an order below and advance it with the buttons, or drive the lifecycle over MCP.</p>'
. $this->datalist($skus)
. $this->placeForm($csrf);

$html .= $this->statusFilter($status);

if ($orders === []) {
$html .= '<p class="nb-muted">No orders yet. Place one with the <code>shop_place_order</code> tool.</p>';
$html .= $status === null
? '<p class="nb-muted">No orders yet. Place one above, or with the <code>shop_place_order</code> tool.</p>'
: '<p class="nb-muted">No ' . $this->e($status) . ' orders.</p>';
return $html;
}

$html .= '<div class="nb-table-wrap nb-stack"><table class="nb-table"><thead><tr>'
. '<th>Order</th><th>Status</th><th>Customer</th><th>Items</th>'
. '<th style="text-align:right">Total</th><th>Placed</th></tr></thead><tbody>';
. '<th style="text-align:right">Total</th><th>Placed</th><th>Actions</th></tr></thead><tbody>';

foreach ($orders as $o) {
$status = (string) $o['status'];
$tone = self::STATUS_TONE[$status] ?? '#565d6d';
$items = [];
$items = [];
foreach ($lines[(int) $o['id']] ?? [] as $ln) {
$items[] = $this->e((string) $ln['qty']) . ' × <code>' . $this->e((string) $ln['sku_code']) . '</code>';
}
$html .= '<tr><td data-label="Order"><code>' . $this->e((string) $o['reference']) . '</code></td>'
. '<td data-label="Status"><span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:.8rem;color:#fff;background:' . $tone . '">' . $this->e($status) . '</span></td>'
. '<td data-label="Status">' . $this->pill((string) $o['status']) . '</td>'
. '<td data-label="Customer">' . $this->e((string) ($o['customer_email'] ?? '—')) . '</td>'
. '<td data-label="Items" class="nb-muted">' . implode(', ', $items) . '</td>'
. '<td data-label="Total" style="text-align:right">$' . $this->e((string) $o['total']) . '</td>'
. '<td data-label="Placed" class="nb-muted">' . $this->e((string) $o['placed_at']) . '</td></tr>';
. '<td data-label="Total" style="text-align:right">' . $this->money((string) $o['total'], (string) $o['currency']) . '</td>'
. '<td data-label="Placed" class="nb-muted">' . $this->e((string) $o['placed_at']) . '</td>'
. '<td data-label="Actions">' . $this->actions((string) $o['reference'], (string) $o['status'], $csrf) . '</td></tr>';
}

$html .= '</tbody></table></div>';
return $html;
}

/** A coloured status pill using theme tokens (dark-safe). */
private function pill(string $status): string
{
[$bg, $fg] = self::STATUS_TONE[$status] ?? ['--nb-surface-2', '--nb-muted'];

return '<span style="display:inline-block;padding:2px 8px;border-radius:var(--nb-radius-pill,999px);font-size:.8rem;'
. 'background:var(' . $bg . ');color:var(' . $fg . ')">' . $this->e($status) . '</span>';
}

/** The lifecycle buttons valid for this order's status; each is a CSRF-protected POST. */
private function actions(string $reference, string $status, string $csrf): string
{
$verbs = match ($status) {
'pending' => ['pay' => 'Pay', 'cancel' => 'Cancel'],
'paid' => ['fulfil' => 'Fulfil', 'cancel' => 'Cancel'],
default => [],
};
if ($verbs === []) {
return '<span class="nb-muted">—</span>';
}

$out = '<div style="display:flex;gap:.4rem;flex-wrap:wrap">';
foreach ($verbs as $action => $label) {
$primary = $action === 'cancel' ? '' : ' nb-btn-primary';
$out .= '<form method="post" action="/admin/commerce/' . $this->e($action) . '" style="margin:0">'
. '<input type="hidden" name="_token" value="' . $this->e($csrf) . '">'
. '<input type="hidden" name="reference" value="' . $this->e($reference) . '">'
. '<button type="submit" class="nb-btn' . $primary . '" style="padding:2px 10px;font-size:.8rem">' . $this->e($label) . '</button>'
. '</form>';
}
return $out . '</div>';
}

/** Status filter chips (GET links, allow-listed). */
private function statusFilter(?string $active): string
{
$chip = function (string $label, ?string $status) use ($active): string {
$on = $status === $active;
$href = $status === null ? '/admin/commerce' : '/admin/commerce?status=' . rawurlencode($status);
$cls = 'nb-btn' . ($on ? ' nb-btn-primary' : '');
return '<a class="' . $cls . '" href="' . $this->e($href) . '">' . $this->e($label) . '</a>';
};

$out = '<div class="nb-stack" style="display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:.75rem">'
. $chip('All', null);
foreach (array_keys(self::STATUS_TONE) as $status) {
$out .= $chip(ucfirst($status), $status);
}
return $out . '</div>';
}

/**
* Known-SKU suggestions for the place form, from this plugin's own order lines.
*
* @param list<string> $skus
*/
private function datalist(array $skus): string
{
$out = '<datalist id="ord-skus">';
foreach ($skus as $sku) {
$out .= '<option value="' . $this->e((string) $sku) . '"></option>';
}
return $out . '</datalist>';
}

private function money(string $amount, string $currency): string
{
$currency = strtoupper(trim($currency)) ?: 'USD';
$symbol = self::SYMBOL[$currency] ?? '';

return $symbol !== ''
? $this->e($symbol . $amount)
: $this->e($amount . ' ' . $currency);
}

/** A quick single-line place-order form (posts to the plugin admin action with CSRF). */
private function placeForm(string $csrf): string
{
$f = static fn (string $label, string $name, string $ph): string =>
'<div class="nb-field" style="flex:1 1 130px"><label for="ord-' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($label, ENT_QUOTES) . '</label>'
. '<input id="ord-' . htmlspecialchars($name, ENT_QUOTES) . '" name="' . htmlspecialchars($name, ENT_QUOTES) . '" placeholder="' . htmlspecialchars($ph, ENT_QUOTES) . '"></div>';
$f = function (string $label, string $name, string $ph, bool $suggest = false): string {
$list = $suggest ? ' list="ord-skus"' : '';
return '<div class="nb-field" style="flex:1 1 130px"><label for="ord-' . $this->e($name) . '">' . $this->e($label) . '</label>'
. '<input id="ord-' . $this->e($name) . '" name="' . $this->e($name) . '"' . $list . ' placeholder="' . $this->e($ph) . '"></div>';
};

return '<form class="nb-form-card" method="post" action="/admin/commerce/place" style="margin-bottom:1.5rem">'
. '<h2>Place an order</h2>'
. '<input type="hidden" name="_token" value="' . $this->e($csrf) . '">'
. '<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-end">'
. $f('SKU', 'sku', 'house-blend')
. $f('SKU', 'sku', 'house-blend', true)
. $f('Location', 'location', 'main')
. $f('Qty', 'qty', '2')
. $f('Unit price', 'unit_price', '12.50')
Expand All @@ -110,7 +216,7 @@ private function placeForm(string $csrf): string
. '</div></form>';
}

private function e(string $v): string
public function e(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
}
Expand Down
50 changes: 45 additions & 5 deletions src/CommercePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,36 +42,76 @@ public function register(PluginContext $context): void

$context->mcp()->register(new CommerceToolset($orders));

// Admin page: an orders overview + a quick place-order form (H3).
// Admin page: an orders overview + place form + per-row lifecycle buttons
// (H3). Gated on this plugin's own wildcard-immune capability (ADR 0020) —
// advancing an order in the UI needs `nimbuscms.commerce:write`, exactly
// like the MCP tools, so a content-only editor can't.
$context->adminPages()->register(
'commerce',
'Commerce',
'🧾',
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CommerceAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err')),
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CommerceAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('status')),
self::ID . ':write',
);
$context->adminPages()->action('commerce', 'place', static function (Request $r) use ($orders): Response {
$sku = trim((string) ($r->input('sku') ?? ''));
$qty = trim((string) ($r->input('qty') ?? ''));
$sku = trim((string) ($r->input('sku') ?? ''));
$qty = trim((string) ($r->input('qty') ?? ''));
$price = trim((string) ($r->input('unit_price') ?? '')) ?: '0';
if ($sku === '' || $qty === '') {
return Response::redirect('/admin/commerce?err=invalid');
}
// Validate the numbers at the boundary so a non-numeric qty/price is an
// honest "badqty" notice, not a database error surfacing as something else.
if (preg_match('/^\d+(\.\d{1,4})?$/', $qty) !== 1 || preg_match('/^\d+(\.\d{1,2})?$/', $price) !== 1) {
return Response::redirect('/admin/commerce?err=badqty');
}
$line = [
'sku' => $sku,
'location' => trim((string) ($r->input('location') ?? '')) ?: 'main',
'qty' => $qty,
'unit_price' => trim((string) ($r->input('unit_price') ?? '')) ?: '0',
'unit_price' => $price,
];
$email = trim((string) ($r->input('customer_email') ?? '')) ?: null;
try {
$orders->place([$line], $email, date('Y-m-d H:i:s'));
return Response::redirect('/admin/commerce?ok=placed');
} catch (\NimbusCMS\Inventory\InsufficientStock) {
return Response::redirect('/admin/commerce?err=short');
} catch (NoInventory) {
return Response::redirect('/admin/commerce?err=noinventory');
} catch (\InvalidArgumentException) {
return Response::redirect('/admin/commerce?err=badqty');
} catch (\Throwable) {
return Response::redirect('/admin/commerce?err=invalid');
}
});

// The lifecycle actions — the UI catching up to the MCP tools. Each reads
// the order reference, advances it, and maps a typed failure to an honest
// notice (unknown order vs illegal transition).
foreach ([
'pay' => static fn (OrderBook $o, string $ref): array => $o->pay($ref, date('Y-m-d H:i:s')),
'fulfil' => static fn (OrderBook $o, string $ref): array => $o->fulfil($ref, 'admin-ui', date('Y-m-d H:i:s')),
'cancel' => static fn (OrderBook $o, string $ref): array => $o->cancel($ref, date('Y-m-d H:i:s')),
] as $action => $run) {
$context->adminPages()->action('commerce', $action, static function (Request $r) use ($orders, $run, $action): Response {
$ref = trim((string) ($r->input('reference') ?? ''));
if ($ref === '') {
return Response::redirect('/admin/commerce?err=invalid');
}
try {
$run($orders, $ref);
return Response::redirect('/admin/commerce?ok=' . ($action === 'pay' ? 'paid' : ($action === 'fulfil' ? 'fulfilled' : 'cancelled')));
} catch (OrderNotFound) {
return Response::redirect('/admin/commerce?err=notfound');
} catch (IllegalTransition) {
return Response::redirect('/admin/commerce?err=badstate');
} catch (\Throwable) {
return Response::redirect('/admin/commerce?err=invalid');
}
});
}

$context->skills()->register('Commerce', Guide::text());
}
}
19 changes: 19 additions & 0 deletions src/IllegalTransition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Commerce;

/**
* An order was asked to move to a status it cannot reach from its current one
* (e.g. fulfilling an unpaid order, cancelling a fulfilled one). A subclass of
* \RuntimeException — the distinct type lets the admin actions map a bad
* transition to an honest notice, separate from an unknown-order error.
*/
final class IllegalTransition extends \RuntimeException
{
public function __construct(public readonly string $from, public readonly string $to)
{
parent::__construct("An order cannot move to \"{$to}\" from \"{$from}\".");
}
}
16 changes: 16 additions & 0 deletions src/NoInventory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Commerce;

/**
* No inventory plugin is installed, so stock cannot be reserved and an order
* cannot be placed (ADR 0019 soft dependency). A subclass of \RuntimeException so
* existing callers that catch that keep working; the distinct type lets the admin
* map it to an honest "install Inventory" notice without swallowing unrelated
* runtime errors (e.g. a database fault) under the same message.
*/
final class NoInventory extends \RuntimeException
{
}
Loading
Loading