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
35 changes: 31 additions & 4 deletions deploy/seed-demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,43 @@
}
echo " menu: " . count($catIds) . " categories, " . count($menu) . " items\n";

// --- 3b) Site settings: brand + render the menu at the root ----------------
// --- 3b) The homepage (a `single`-kind collection: one editable entry) ------
// The public root ("/") renders this as a restaurant front page (theme
// `entry-home.php`); featured dishes come live from the menu via the plugin's
// view-data contributor. Modeled as content so the copy is editable in the CMS.
$collections->create('home', 'Home', '#', '', ['kind' => 'single', 'permissions' => []], [
['handle' => 'hero_kicker', 'label' => 'Hero kicker', 'type' => 'text', 'required' => false, 'options' => []],
['handle' => 'hero_title', 'label' => 'Hero title', 'type' => 'text', 'required' => false, 'options' => []],
['handle' => 'hero_tagline', 'label' => 'Hero tagline', 'type' => 'textarea', 'required' => false, 'options' => []],
['handle' => 'about_title', 'label' => 'About title', 'type' => 'text', 'required' => false, 'options' => []],
['handle' => 'about_body', 'label' => 'About body', 'type' => 'textarea', 'required' => false, 'options' => []],
['handle' => 'hours', 'label' => 'Hours', 'type' => 'textarea', 'required' => false, 'options' => []],
['handle' => 'address', 'label' => 'Address', 'type' => 'textarea', 'required' => false, 'options' => []],
['handle' => 'phone', 'label' => 'Phone', 'type' => 'text', 'required' => false, 'options' => []],
]);
$homeCol = $repo->findByHandle('home');
$entries->save($homeCol, new EntryInput('The Copper Table', 'home', 'published', [
'hero_kicker' => 'Est. 2014 · Modern American',
'hero_title' => 'The Copper Table',
'hero_tagline' => 'A neighbourhood kitchen for lunch and dinner — seasonal plates, an easy room, and a short list done well.',
'about_title' => 'About the table',
'about_body' => "We opened on a corner in 2014 with a wood-topped bar and a small menu that changes with the season. Everything is cooked to order; nothing leaves the pass we wouldn't eat ourselves.\n\nToday the room runs on the Restaurant Automation System — a live NimbusCMS demo.",
'hours' => "Mon–Thu · 11:00–22:00\nFri–Sat · 11:00–23:00\nSunday · 10:00–21:00",
'address' => "18 Copper Lane\nOld Town\nEC1 4RS",
'phone' => '020 7946 0142',
], '2024-01-01 00:00:00'), null, null);
echo " homepage: 1 single collection + entry\n";

// --- 3c) Site settings: brand + render the homepage at the root ------------
// These are DB settings (nb_settings), which shadow config/site.php at runtime —
// so the guest-facing root ("/") shows the branded menu, not the bare Nimbus
// so the guest-facing root ("/") shows the branded homepage, not the bare Nimbus
// placeholder. Seeded here so a from-scratch rebuild matches the golden restore.
(new SettingsRepository($db))->setMany([
'site.title' => 'The Copper Table',
'site.description' => 'A restaurant running on the Restaurant Automation System — a live NimbusCMS demo.',
'site.home' => 'menu_items',
'site.home' => 'home',
]);
echo " settings: home -> menu_items, brand -> The Copper Table\n";
echo " settings: home -> home, brand -> The Copper Table\n";

// --- 4) Live floor / orders / reservations ---------------------------------
$storage = static fn (): PluginStorage => new PluginStorage($db);
Expand Down
140 changes: 140 additions & 0 deletions docs/design/slice-b-homepage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Slice B — Public homepage + a denser guest site

**Status:** design (pre-build) · **Branch:** `nimbus-rebuild` · **Depends on:** the RAS
theme (Slice 8b), the menu collections, the ADR-0027 view-data hinge (core, already
shipped).

## Why

The 2014 original had **no public website** — it was a staff dashboard (RAS). The
guest-facing site is therefore new work, and today it is a single page: the menu at
`/`. Dan's ask: *"make the public-facing site more dense with a homepage direction
and all the normal stuff"*, keeping the uplifted RAS identity (dark `#1a1c20` +
gold `#d4a017`, serif display, printed-menu feel).

So: a real restaurant homepage — hero, a short about, a few **featured dishes
pulled live from the menu**, hours & location, and a reservations call-to-action —
with the full menu remaining its own page.

## Non-goals (this slice)

- Online **ordering / payment** — that is Slice C2 (its own design + security pass).
- Online **reservations** — booking stays staff-side (the Reservations terminal).
The homepage CTA is *"call to reserve"* (a real `tel:` link), not a public form.
(A public booking form is a candidate future slice; it is a public write surface
and would need the same security treatment as ordering.)
- No new **core** capability. Everything here rides existing hinges.

## Content model — the homepage is editable CMS content

Model the homepage as a **`single`-kind collection `home`** (one entry, no index),
so the copy is editable in the admin and the slice also demonstrates Nimbus as a
CMS rather than hard-coding strings in a template.

`home` fields (all optional; the template degrades gracefully when blank):

| handle | type | purpose |
|--------|------|---------|
| `hero_kicker` | text | eyebrow, e.g. "Est. 2014 · Modern American" |
| `hero_title` | text | large display line, e.g. "The Copper Table" |
| `hero_tagline` | textarea | one or two sentences under the title |
| `about_title` | text | section heading |
| `about_body` | textarea | a short paragraph |
| `hours` | textarea | one "Day · time" per line |
| `address` | textarea | postal address, one line per row |
| `phone` | text | used for the `tel:` reservations CTA |

Wire it with the existing home mechanism: set the **`site.home` setting** to
`home` (DB setting — see the deploy gotcha; seeded via `SettingsRepository`). The
router then renders the single entry through `entry-home.php`
(`specialize('entry','home')`), falling back to `entry.php` if the theme lacked it.

> The menu stays at `/menu_items`. `site.home` moves from `menu_items` → `home`.

## Live "featured dishes" — the plugin feeds the theme (ADR 0027)

A restaurant homepage should show a few real dishes, and they must stay live as the
menu changes. The platform-honest way (no core change, no restaurant logic leaking
into core) is the **view-data hinge**:

- The restaurant plugin registers a `ViewDataContributor` via
`PluginContext::viewData()`.
- Its `data(PageContext $page)` returns `[]` unless `$page->kind === 'home'`; on the
home page it returns `['featured' => [ {title, price, category, blurb}, … ]]` — a
**handful** of published menu items read through the plugin's existing
`ContentReader` (`$context->content()`), which is **published-only** and
**visitor-independent** (safe to bake into the by-path page cache).
- The result reaches the theme namespaced as
`contrib['danmat.restaurant']['featured']`. The theme **escapes every value on
render** (it is data, not HTML).

Selection rule for "featured": the first *N* (=3) published menu items that have a
non-empty description (`body`), falling back to the first *N* items — deterministic,
cache-stable, no per-visitor state.

Why not just query the menu in the template? Themes get only the current page's
view-model; cross-collection reads are a plugin concern. The hinge is exactly this
seam, and using it keeps the theme dumb and the data live.

## Theme changes

- **`templates/entry-home.php`** (new) — the homepage, sections in order:
1. **Hero** — kicker, title, tagline; full-width dark band, gold rule, a "View
the menu" button (→ `/menu_items`) and a "Call to reserve" button (→ `tel:`).
2. **About** — `about_title` + `about_body`, measure-width column.
3. **Featured** — 2–3 cards from `contrib['danmat.restaurant']['featured']`
(name · price · one-line blurb), a "See the full menu →" link.
4. **Visit** — a two-column block: **Hours** (from `hours`) and **Find us**
(`address` + a `tel:` phone), on one column at ≤ 640px.
5. **Reserve** — a closing CTA band (call to reserve).
- **`templates/header.php`** — nav becomes **Home · Menu** (and later Order). The
brand already links to `/`.
- **`assets/app.css`** — sections styled in the existing token system (no new
colours beyond the current palette); hero, cards grid, the two-column Visit block,
responsive at `40rem`. Reuse `.wrap`, `.eyebrow`, serif headings, dotted leaders.
- **`theme.json`** — document the new template + `nav`.

## Seed (`deploy/seed-demo.php`)

- Create the `home` single collection + its fields.
- Save the one home entry with real copy for "The Copper Table".
- Set `site.home` → `home` (was `menu_items`); keep title/description.
- Golden re-dump on the box so the hourly reset serves it.

## Three-hat review (proportionate — no core change)

**🧑‍💼 Product** — Real problem (the demo needs a credible restaurant front page),
for the demo's guests; keeps the menu live. Not over-built (no booking/ordering
here). ✅

**🏗️ Architect** — Classification: **theme + plugin + seed** in the app repo; zero
core change. The one design choice — homepage as a `single` collection + featured
via the view-data hinge — reuses shipped seams (ADR 0027, single-kind home) exactly
as intended, and is the smallest thing that keeps featured dishes live. No new
capability, no API surface frozen. A reusable *pattern* (a plugin lighting up a
theme homepage) but implemented entirely with existing hooks. ✅

**👷 Principal engineer** — Correctness: template degrades when any field/contrib is
empty; featured selection is deterministic (cache-stable). Perf: featured is a
handful, read once per cached page; no N+1. Testability: the contributor is a pure
`PageContext → array` unit (test: returns `[]` off-home, returns ≤3 published items
on-home, never a draft). Mobile: verify at 375px (hero, cards, two-column Visit
collapse). ✅

**🔒 Security lens (light — public read surface only):** all output is
escape-on-render (data, not HTML, per the hinge contract); the contributor is
**visitor-independent** (no `$_COOKIE`/`$_SESSION`/user) so nothing per-visitor is
baked into the shared page cache; ContentReader is published-only (no draft leak);
`tel:` uses an admin-entered phone, escaped. No write surface, no new route. Nothing
to block. (The real security work lands in Slice C2, which *does* add a public
write.)

## Definition of done

- `entry-home.php` renders all five sections; blanks degrade gracefully.
- Featured dishes come **live** from the menu via the contributor; editing a menu
item is reflected on the homepage after the page-cache TTL.
- Header nav = Home · Menu; menu still at `/menu_items`.
- Verified at desktop **and 375px** (no horizontal scroll; Visit collapses).
- Plugin CI green (contributor unit test); theme CI green (`php -l`).
- Seed creates `home` + sets `site.home`; box golden re-dumped.
36 changes: 36 additions & 0 deletions plugin/src/HomeViewData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace DanMat\Restaurant;

use Nimbus\Site\PageContext;
use Nimbus\Site\ViewDataContributor;

/**
* Feeds the public homepage a handful of live "featured dishes" from the menu
* collection, via the view-data hinge (ADR 0027). Registered by the plugin through
* PluginContext::viewData(); the theme renders `contrib['danmat.restaurant']
* ['featured']`, escaping every value (this returns DATA, not HTML).
*
* Only the home page gets data — every other page kind returns []. The payload is
* a small, deterministic, visitor-independent list (see {@see MenuSource::featured}),
* so it is safe to bake into the by-path page cache: it depends on no cookie,
* session, or current user.
*/
final class HomeViewData implements ViewDataContributor
{
public function __construct(private MenuSource $menu, private int $limit = 3)
{
}

/** @return array<string,mixed> */
public function data(PageContext $page): array
{
if ($page->kind !== 'home') {
return [];
}
$featured = $this->menu->featured($this->limit);
return $featured === [] ? [] : ['featured' => $featured];
}
}
31 changes: 31 additions & 0 deletions plugin/src/Menu.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,37 @@ public function items(): array
return $out;
}

/**
* A handful of items for the public homepage: name, price, category and a
* short blurb (the item's `body`). Items that HAVE a blurb come first (a
* homepage reads better with descriptions), then the rest, capped at $limit.
* Order within each group follows the collection read order, so the result is
* deterministic and visitor-independent — safe to bake into the page cache
* (ADR 0027).
*
* @return list<array{name:string,price:string,category:?string,blurb:string}>
*/
public function featured(int $limit = 3): array
{
$blurbed = [];
$plain = [];
foreach (($this->reader)()->entries(self::COLLECTION, 500) as $entry) {
$fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : [];
$row = [
'name' => (string) ($entry['title'] ?? ''),
'price' => $this->price($entry),
'category' => $this->category($entry),
'blurb' => trim((string) ($fields['body'] ?? '')),
];
if ($row['blurb'] !== '') {
$blurbed[] = $row;
} else {
$plain[] = $row;
}
}
return array_slice(array_merge($blurbed, $plain), 0, max(0, $limit));
}

/**
* The name + unit price to snapshot onto an order line, for one menu item id,
* or null if there is no such published item.
Expand Down
19 changes: 14 additions & 5 deletions plugin/src/MenuSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@
namespace DanMat\Restaurant;

/**
* The menu as the order surfaces need it — a pickable list. A seam over {@see Menu}
* (which reads the menu collection through the core content-read capability), so the
* admin and MCP surfaces depend on this, not on core content, and a test can supply
* a canned menu without a collection.
* The menu as the app's surfaces need it. A seam over {@see Menu} (which reads the
* menu collection through the core content-read capability), so the admin, MCP and
* public surfaces depend on this, not on core content, and a test can supply a
* canned menu without a collection.
*/
interface MenuSource
{
/**
* The menu items available to order.
* The menu items available to order — a pickable list.
*
* @return list<array{id:int,name:string,price:string,category:?string}>
*/
public function items(): array;

/**
* A handful of items to feature on the public homepage — items with a
* description first, then others, capped at $limit. Deterministic and
* visitor-independent (safe for the page cache; see ADR 0027).
*
* @return list<array{name:string,price:string,category:?string,blurb:string}>
*/
public function featured(int $limit = 3): array;
}
5 changes: 5 additions & 0 deletions plugin/src/RestaurantPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ public function register(PluginContext $context): void
// The agent surface — every tool gates on danmat.restaurant:read|write (ADR 0016).
$context->mcp()->register(new RestaurantToolset($tables, $orders, $menu, $reservations, $reports));

// The public homepage's live "featured dishes" — the plugin feeds the theme
// a handful of menu items via the view-data hinge (ADR 0027). Data only,
// home page only, visitor-independent (cache-safe); the theme escapes it.
$context->viewData()->register(new HomeViewData($menu));

// The floor board. A staff terminal is a capability-gated ADMIN PAGE, never a
// public plugin route (routes carry no auth/CSRF). Gated on :write; the handler
// gets the CSP nonce (2nd arg) and a CSRF token (3rd arg).
Expand Down
82 changes: 82 additions & 0 deletions plugin/tests/HomeViewDataTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace DanMat\Restaurant\Tests;

use DanMat\Restaurant\HomeViewData;
use DanMat\Restaurant\MenuSource;
use Nimbus\Site\PageContext;
use PHPUnit\Framework\TestCase;

/**
* The homepage view-data contributor (ADR 0027): it feeds featured dishes to the
* theme ONLY on the home page, and returns data (not HTML) that the theme escapes.
* A fake {@see MenuSource} supplies the menu, so this needs no collection or DB.
*/
final class HomeViewDataTest extends TestCase
{
/** @param list<array{name:string,price:string,category:?string,blurb:string}> $featured */
private function menu(array $featured): MenuSource
{
return new class ($featured) implements MenuSource {
/** @param list<array{name:string,price:string,category:?string,blurb:string}> $featured */
public function __construct(private array $featured)
{
}

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

public function featured(int $limit = 3): array
{
return array_slice($this->featured, 0, $limit);
}
};
}

private function context(string $kind): PageContext
{
return new PageContext($kind, 'https://x.test/', 'T', 'Site', 'nonce');
}

public function test_it_contributes_featured_dishes_on_the_home_page(): void
{
$menu = $this->menu([
['name' => 'Grilled Salmon', 'price' => '9.99', 'category' => 'Main Course', 'blurb' => 'Seasonal greens.'],
]);
$data = (new HomeViewData($menu))->data($this->context('home'));

self::assertArrayHasKey('featured', $data);
self::assertSame('Grilled Salmon', $data['featured'][0]['name']);
}

public function test_it_contributes_nothing_off_the_home_page(): void
{
$menu = $this->menu([
['name' => 'Grilled Salmon', 'price' => '9.99', 'category' => 'Main Course', 'blurb' => 'x'],
]);
$contributor = new HomeViewData($menu);

self::assertSame([], $contributor->data($this->context('collection')), 'no data on a collection page');
self::assertSame([], $contributor->data($this->context('entry')), 'no data on an entry page');
}

public function test_an_empty_menu_contributes_nothing_even_on_home(): void
{
$data = (new HomeViewData($this->menu([])))->data($this->context('home'));
self::assertSame([], $data, 'no featured key when there is nothing to feature');
}

public function test_the_limit_is_honoured(): void
{
$items = [];
foreach (['A', 'B', 'C', 'D', 'E'] as $n) {
$items[] = ['name' => $n, 'price' => '1.00', 'category' => null, 'blurb' => ''];
}
$data = (new HomeViewData($this->menu($items), 3))->data($this->context('home'));
self::assertCount(3, $data['featured']);
}
}
Loading
Loading