openvibers/openvibe.food Commits

Food boxes: every meal kit, ready meal and food box side by side (38 in 8 kinds), each fact from the service's own page with its date, what a plan comes to, codes from Coupons and scores from Reviews, side by side, and the API; Shared 4.20.1, the phone app bar and the auto footer (#110)

OP OpenVibers committed 16 days ago · 545e14ee7e · 14 files, +679 −13

Changes

ddiff --git a/README.md b/README.md
iindex 54aee6d..89b0ed1 100644
---- a/README.md
++++ b/README.md
@@@ -28,6 +28,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it. Checked recipes are OpenVibe.Search documents (owner `food`, type `recipe`) through Search's owner API ([server/food/search-index.js](server/food/search-index.js), `FOOD_SEARCH_URL`, `search.document.write`) |
| Food recalls | [/recalls](server/http/recall-pages.js), [server/food/recalls.js](server/food/recalls.js) | The FDA's food enforcement reports, read from OpenVibe.Sources (source `openfda-food-enforcement`, category `food`, `sources.item.read`): Food follows Sources' change feed hourly from its cursor (`openvibe-publishing/ingest`) and keeps each recall as the report states it (product, reason, company, class, status, where it was sold, dates). `/recalls` filters by words, class and the company's state; `/recalls/:number` is one recall (noindex, as the source's registry entry asks). Every page carries openFDA's note: unvalidated, not for medical decisions, no FDA endorsement. Food never calls the FDA; `OV_SOURCES_INTERNAL_URL` (default `http://127.0.0.1:4720`, empty turns it off) |
++| Food boxes | [/boxes, /boxes/:slug, /boxes/compare](server/http/box-pages.js), [server/food/boxes.js](server/food/boxes.js), [server/food/box-links.js](server/food/box-links.js) | Every meal kit, ready-meal service and food box (produce, meat and fish, snacks, coffee, baby, pets), weekly or monthly, side by side: 38 of them in 8 kinds, a tab per kind, diets as chips, a search row, a box's plans, what its stated price comes to for your plan size, and boxes side by side. Each fact was read on the service's own public pages by a person and carries that page and the day (2026-10-28); nothing is fetched from the services (the 2026-09-28 rule), and a fact nobody checked says so. A box shows its codes from OpenVibe.Coupons and its score from OpenVibe.Reviews (read from their public APIs on the loopback, 10 minutes, 1.5 s at most; off unless `FOOD_COUPONS_URL`/`FOOD_REVIEWS_URL` are set outside production), and links to OpenVibe.Watch for its prices. The same data is `GET /api/v1/boxes`, `/boxes/:slug` and `/boxes/:slug/cost`. |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| Household chat | [/plan/p/:id](server/http/pages.js), [server/food/household-chat.js](server/food/household-chat.js) | The owner of a shared plan starts a private room on OpenVibe.Chat for the household, made through Chat's room API as the owner, with every member added by account subject (and anyone who joins later, when the owner opens the plan) and **Pantry**, an AI member linked to the plan. Writing `@pantry` there asks it: Chat reads the household from `GET /internal/households/:id/chat-context` (`food.household.read`, Chat's service token) — the plan, what is still to buy and every member's cupboard — and OpenVibe.AI answers. The plan's page links the room for everyone |
ddiff --git a/package-lock.json b/package-lock.json
iindex f3a1fe3..1e3bfad 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -934,9 +934,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.19.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
-- "integrity": "sha512-OksbPienuKJ88R5HrXYh3Dgh/TWZGwUL4FUQQmGhQe6wkQpbAxaknfAouU+HaI1xwbbGhQ5f9bVIKJv9+LrNQQ==",
++ "version": "4.20.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1",
++ "integrity": "sha512-W8is+/ug91LtACo2NlalSWsDb2bgNBZrhTtDnY6kv8twjRSgJv7i2a7f6m4CMHDa/BKmzuEnPCQk5/p6qqHrMw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 49fc28f..0c517c7 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/boxes.css b/public/css/boxes.css
nnew file mode 100644
iindex 0000000..89a4205
---- /dev/null
++++ b/public/css/boxes.css
@@@ -0,0 +1,30 @@
++/* Food boxes (server/http/box-pages.js) on the OpenVibe Kit (kit.css): the diets as a strip of chips, a box's stated
++ price as its headline, its facts as rows, what it comes to, its links across OpenVibe, and boxes side by side. */
++.bx-diets { display: flex; gap: 6px; margin: 0 -16px 12px; padding: 0 16px; overflow-x: auto; scrollbar-width: none; }
++.bx-diets::-webkit-scrollbar { display: none; }
++.bx-diets a { flex: none; display: inline-flex; align-items: center; min-height: 34px; padding: 5px 13px; border-radius: 999px; border: 1px solid var(--border, rgba(255, 255, 255, .12)); color: var(--text-secondary, #b6c2d4); font-size: .88rem; font-weight: 600; text-decoration: none; white-space: nowrap; }
++.bx-diets a:hover { border-color: var(--accent, #3b82f6); }
++.bx-diets a[aria-current="page"] { background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); color: var(--text-primary, #e8ecf3); }
++.bx-how { margin: 14px 0 0; }
++.bx-what { margin: 0 0 6px; color: var(--text-secondary, #b6c2d4); }
++.bx-price { margin: 0 0 12px; font-size: 1.05rem; }
++.bx-price strong { font-size: 1.45rem; font-weight: 800; letter-spacing: -.01em; }
++.bx-fact { cursor: default; }
++.bx-val { flex: 0 1 58%; text-align: right; font-size: .94rem; }
++.bx-notes { margin: 0; padding: 4px 14px 8px 30px; }
++.bx-link { color: inherit; text-decoration: none; }
++.bx-link:hover { background: var(--bg-hover, rgba(255, 255, 255, .04)); }
++.bx-link .ovk-i { color: var(--text-muted, #93a0b4); }
++.bx-calc { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px; padding: 4px 14px 10px; }
++.bx-calc label { display: grid; gap: 4px; color: var(--text-muted, #93a0b4); font-size: .86rem; }
++.bx-calc select { min-height: 38px; padding: 6px 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255, 255, 255, .12)); background: var(--bg-input, #0f1522); color: var(--text-primary, #e8ecf3); font: inherit; }
++.bx-cost { margin: 0; padding: 0 14px 6px; }
++.bx-cost strong { font-size: 1.3rem; }
++.bx-pick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 12px; }
++.bx-pick select { flex: 1 1 220px; min-height: 40px; padding: 6px 10px; border-radius: 10px; border: 1px solid var(--border, rgba(255, 255, 255, .12)); background: var(--bg-input, #0f1522); color: var(--text-primary, #e8ecf3); font: inherit; }
++.bx-compare { overflow-x: auto; margin: 0 -16px 10px; padding: 0 16px; }
++.bx-compare table { border-collapse: separate; border-spacing: 0; min-width: 100%; font-size: .9rem; }
++.bx-compare th, .bx-compare td { padding: 9px 12px; border-bottom: 1px solid var(--border, rgba(255, 255, 255, .1)); text-align: left; vertical-align: top; min-width: 150px; }
++.bx-compare thead th { position: sticky; top: 0; background: var(--bg-card, #121826); font-size: 1rem; }
++.bx-compare tbody th { color: var(--text-muted, #93a0b4); font-weight: 600; min-width: 110px; position: sticky; left: 0; background: var(--bg-primary, #0b0f17); }
++.bx-x { margin-left: 6px; color: var(--text-muted, #93a0b4); text-decoration: none; }
ddiff --git a/server/app.js b/server/app.js
iindex 58f8fe9..feec759 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -57,7 +57,9 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
-- const ctx = { config, s, keys, sso, principal, log };
++ // A food box's codes and score from OpenVibe.Coupons and OpenVibe.Reviews (server/food/box-links.js; off when unset).
++ const boxLinks = (config.boxLinks.couponsUrl || config.boxLinks.reviewsUrl) ? require('./food/box-links').createBoxLinks(config.boxLinks) : null;
++ const ctx = { config, s, keys, sso, principal, log, boxLinks };
// The only outbound client this service has (Nominatim + Overpass, paced and cached); the pages and the API get
// it from here, so every test points it at a stand-in through FOOD_NOMINATIM_URL / FOOD_OVERPASS_URL.
// Its own wall clock, not the store's: the pace between two calls must not depend on an injected test clock.
ddiff --git a/server/config.js b/server/config.js
iindex fe3a016..323c928 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -104,6 +104,13 @@ function load(env = process.env) {
// (server/food/recipes.js).
aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
++ // OpenVibe.Coupons and OpenVibe.Reviews (server/food/box-links.js): a food box's codes and score, read from their
++ // public APIs when a box is shown. Production reads them on the loopback; elsewhere they are off unless set.
++ boxLinks: {
++ couponsUrl: trim(env.FOOD_COUPONS_URL == null ? (isProduction ? 'http://127.0.0.1:4850' : '') : env.FOOD_COUPONS_URL),
++ reviewsUrl: trim(env.FOOD_REVIEWS_URL == null ? (isProduction ? 'http://127.0.0.1:4830' : '') : env.FOOD_REVIEWS_URL),
++ },
++
// OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated
// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
ddiff --git a/server/food/box-links.js b/server/food/box-links.js
nnew file mode 100644
iindex 0000000..319081a
---- /dev/null
++++ b/server/food/box-links.js
@@@ -0,0 +1,70 @@
++'use strict';
++
++/**
++ * What OpenVibe.Coupons and OpenVibe.Reviews know about a food box, read from their public APIs when a box is shown
++ * (./boxes.js): the shop's active codes and its best one, and the service's score with how many it rests on. Each
++ * answer is kept ten minutes (a miss too, so a box nobody lists asks once), and a slow or absent neighbour never holds
++ * the page: after 1.5 s the box shows without it. Off (null, no requests) when the service's address is not set, as in
++ * tests and development.
++ *
++ * createBoxLinks({ couponsUrl, reviewsUrl, fetch }) → { forBox(box) → { coupons, reviews }, clear() }
++ * coupons { url, active, best: { code, title } | null } | null (null: no shop for the box's site, or off)
++ * reviews { url, name, percent, total, line } | null (null: no entry for it yet, or off)
++ */
++
++const TTL_MS = 10 * 60 * 1000;
++const TIMEOUT_MS = 1500;
++
++function createBoxLinks({ couponsUrl = '', reviewsUrl = '', fetch: fetchImpl = globalThis.fetch } = {}) {
++ const cache = new Map(); // key → { at, value }
++ const getJson = async (url, init = {}) => {
++ const res = await fetchImpl(url, { ...init, headers: { accept: 'application/json', ...(init.headers || {}) }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ if (res.status === 404 || res.status === 422) return null;
++ if (!res.ok) throw new Error(`${res.status}`);
++ return await res.json();
++ };
++ const cached = async (key, fn) => {
++ const hit = cache.get(key);
++ if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
++ let value = null;
++ try { value = await fn(); } catch { value = hit ? hit.value : null; } // a failed read keeps the last answer
++ cache.set(key, { at: Date.now(), value });
++ return value;
++ };
++
++ async function coupons(box) {
++ if (!couponsUrl) return null;
++ return await cached(`c:${box.host}`, async () => {
++ const found = await getJson(`${couponsUrl}/api/v1/merchants/resolve?host=${encodeURIComponent(box.host)}`);
++ if (!found || !found.merchant) return null;
++ const list = await getJson(`${couponsUrl}/api/v1/merchants/${encodeURIComponent(found.merchant.id)}/coupons`);
++ const codes = (list && list.coupons) || [];
++ const working = codes.filter((c) => c.status === 'reported_working').sort((a, b) => (b.confidence || 0) - (a.confidence || 0))[0];
++ return { url: found.merchant.url, active: codes.length, best: working ? { code: String(working.code).slice(0, 40), title: String(working.title || '').slice(0, 120) } : null };
++ });
++ }
++
++ async function reviews(box) {
++ if (!reviewsUrl) return null;
++ return await cached(`r:${box.slug}`, async () => {
++ const found = await getJson(`${reviewsUrl}/api/v1/resolve`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: box.name, url: box.url }) });
++ const entity = found && (found.entity || (found.candidates && found.candidates.length === 1 ? found.candidates[0].entity : null));
++ if (!entity) return null;
++ const agg = await getJson(`${reviewsUrl}/api/v1/entities/${encodeURIComponent(entity.slug || entity.id)}/aggregate`);
++ const rec = agg && agg.aggregate && agg.aggregate.result && agg.aggregate.result.components && agg.aggregate.result.components.recommendation;
++ const percent = rec && Number.isFinite(Number(rec.percent)) ? Number(rec.percent) : null;
++ const total = rec && Number.isFinite(Number(rec.total)) ? Number(rec.total) : null;
++ return { url: entity.url, name: entity.name, percent, total, line: percent != null ? `${percent}% recommend (${total})` : null };
++ });
++ }
++
++ return {
++ async forBox(box) {
++ const [c, r] = await Promise.all([coupons(box), reviews(box)]);
++ return { coupons: c, reviews: r };
++ },
++ clear: () => cache.clear(),
++ };
++}
++
++module.exports = { createBoxLinks };
ddiff --git a/server/food/boxes.js b/server/food/boxes.js
nnew file mode 100644
iindex 0000000..c0e68fe
---- /dev/null
++++ b/server/food/boxes.js
@@@ -0,0 +1,212 @@
++'use strict';
++
++/**
++ * Food boxes: every meal kit, ready-meal service and food box (produce, meat and fish, snacks, coffee, baby, pets),
++ * weekly or monthly, side by side (the owner, 2026-10-28: "an aggregated list of all the food, weekly, monthly food
++ * boxes… like EveryPlate… connected with the promotions and the review sites… and an API and SDK").
++ *
++ * How it is filled, the permitted way (2026-09-28, Q8: official links, a person's own session, licensed data; never
++ * scraping): each fact below was read on the service's own public page, by a person, and carries that page and the
++ * day it was read. Nothing here is fetched from the services. A fact nobody checked is null and the page says "not
++ * checked yet" with the link to their site; a price that changes is the service's to show, and OpenVibe.Watch can
++ * tell you when it does. Codes come from OpenVibe.Coupons and scores from OpenVibe.Reviews at request time
++ * (./box-links.js); a box never claims either.
++ *
++ * KINDS the kinds, in order, with their names
++ * BOXES the boxes (see the shape below)
++ * list({ kind, diet, q }) → boxes, in the list's order (kind first, then as listed)
++ * get(slug) → one box or null
++ * cost(box, { people, meals, weeks }) → what the stated price per serving or meal comes to, or null when the box
++ * has no stated per-serving price (or the plan size is not one it states)
++ * toWire(box) → the API's shape (`food.box@1`-like: every fact with its source)
++ *
++ * A box: { slug, name, kind, host, url, what, cadence[], people[], meals[], diets[], price, shipping, ships_to,
++ * cancel, notes[], sources[], checked }
++ * price { from, to, unit: 'serving'|'meal'|'box'|'month'|'delivery'|'day'|'membership', text } or null
++ * shipping { amount, to, text } or null (amount 0: free)
++ */
++
++const CHECKED = '2026-10-28';
++
++const KINDS = [
++ { kind: 'cook', name: 'Meal kits', short: 'Kit', what: 'Recipes and the ingredients to cook them' },
++ { kind: 'ready', name: 'Ready meals', short: 'Ready', what: 'Cooked meals to heat up' },
++ { kind: 'produce', name: 'Produce and groceries', short: 'Produce', what: 'Fruit, vegetables and pantry food' },
++ { kind: 'meat', name: 'Meat and fish', short: 'Meat', what: 'Boxes of meat and seafood' },
++ { kind: 'snacks', name: 'Snack boxes', short: 'Snacks', what: 'A box of snacks each month' },
++ { kind: 'coffee', name: 'Coffee', short: 'Coffee', what: 'Coffee from roasters, delivered' },
++ { kind: 'baby', name: 'Baby and kids', short: 'Baby', what: 'Food for babies and children' },
++ { kind: 'pets', name: 'Pets', short: 'Pets', what: 'Fresh food for dogs' },
++];
++const KIND = Object.fromEntries(KINDS.map((k) => [k.kind, k]));
++
++const DIETS = {
++ vegetarian: 'Vegetarian', vegan: 'Vegan', pescatarian: 'Pescatarian', keto: 'Keto', paleo: 'Paleo', 'gluten-free': 'Gluten-free',
++ 'calorie-smart': 'Calorie-smart', 'high-protein': 'High-protein', family: 'Family', quick: 'Quick', 'plant-based': 'Plant-based',
++};
++
++const box = (o) => ({ cadence: [], people: [], meals: [], diets: [], price: null, shipping: null, ships_to: null, cancel: null, notes: [], sources: [], checked: CHECKED, ...o });
++
++const BOXES = [
++ // ── Meal kits ──
++ box({ slug: 'everyplate', name: 'EveryPlate', kind: 'cook', host: 'everyplate.com', url: 'https://www.everyplate.com/', what: 'Simple recipes with fewer ingredients; among the cheapest kits.',
++ cadence: ['weekly'], people: [2, 4, 6], meals: [3, 4, 5], diets: ['vegetarian', 'quick', 'calorie-smart', 'family'],
++ price: { from: 5.99, to: null, unit: 'serving', text: 'Plans from $5.99 a serving' }, shipping: { amount: 10.99, text: '$10.99 a box' },
++ ships_to: 'Most of the continental US', cancel: 'Skip or cancel online',
++ sources: ['https://www.everyplate.com/', 'https://www.everyplate.com/about/menus-and-plans'] }),
++ box({ slug: 'hellofresh', name: 'HelloFresh', kind: 'cook', host: 'hellofresh.com', url: 'https://www.hellofresh.com/', what: 'The biggest menu, with family, quick and calorie-smart recipes.',
++ cadence: ['weekly'], people: [2, 4], meals: [3, 4, 5], diets: ['vegetarian', 'pescatarian', 'calorie-smart', 'gluten-free', 'keto', 'family'],
++ ships_to: 'The whole US except Alaska and Hawaii', cancel: 'No commitment: pause or cancel by 11:59 pm PT five days before the next delivery',
++ notes: ['5 recipes a week are for the two-person plan only.', 'Gluten-free and keto are options within the plans, not plans of their own.'],
++ sources: ['https://www.hellofresh.com/', 'https://www.hellofresh.com/eat/kid-friendly-meal-delivery', 'https://www.hellofresh.com/eat/healthy-meal-delivery'] }),
++ box({ slug: 'home-chef', name: 'Home Chef', kind: 'cook', host: 'homechef.com', url: 'https://www.homechef.com/', what: 'Kits plus oven-ready and fast-prep meals; meals can be customized.',
++ cadence: ['weekly'], price: { from: 6.99, to: null, unit: 'serving', text: 'Meals from $6.99 a serving' },
++ sources: ['https://cook.homechef.com/fresh-start'] }),
++ box({ slug: 'green-chef', name: 'Green Chef', kind: 'cook', host: 'greenchef.com', url: 'https://www.greenchef.com/', what: 'Organic produce and premium proteins; keto and plant-powered plans.',
++ cadence: ['weekly'], people: [2, 4, 6], meals: [3, 4], diets: ['keto', 'gluten-free', 'vegan', 'vegetarian'],
++ cancel: 'Skip or cancel before the weekly cutoff',
++ notes: ['Every meal is made in a kitchen that also handles milk, eggs, wheat, soy and nuts.'],
++ sources: ['https://www.greenchef.com/', 'https://www.greenchef.com/eat/meal-plan-delivery-service'] }),
++ box({ slug: 'marley-spoon', name: 'Marley Spoon', kind: 'cook', host: 'marleyspoon.com', url: 'https://marleyspoon.com/', what: 'Chef-designed recipes and a large weekly menu.',
++ cadence: ['weekly'], price: { from: 12.99, to: 13.49, unit: 'serving', text: 'Its plans page: $13.49 a serving for 3 meals for 2 people; its help centre: $12.99 a serving' },
++ shipping: { amount: 11.99, to: 12.99, text: '$12.99 on its plans page, $11.99 in its help centre' },
++ notes: ['Its own pages disagree on the price; check the plans page for yours.'],
++ sources: ['https://marleyspoon.com/select-plan', 'https://support.marleyspoon.com/hc/en-us/articles/360001958975-What-boxes-and-plans-do-you-offer'] }),
++ box({ slug: 'dinnerly', name: 'Dinnerly', kind: 'cook', host: 'dinnerly.com', url: 'https://dinnerly.com/', what: 'Short recipes and a low price per serving, especially on bigger boxes.' }),
++ box({ slug: 'blue-apron', name: 'Blue Apron', kind: 'cook', host: 'blueapron.com', url: 'https://www.blueapron.com/', what: 'Kits and ready-to-eat meals, bought per meal without a subscription.' }),
++ box({ slug: 'hungryroot', name: 'Hungryroot', kind: 'cook', host: 'hungryroot.com', url: 'https://www.hungryroot.com/', what: 'Groceries and simple recipes picked for you each week.' }),
++ box({ slug: 'purple-carrot', name: 'Purple Carrot', kind: 'cook', host: 'purplecarrot.com', url: 'https://www.purplecarrot.com/', what: 'Plant-based kits and prepared meals.' }),
++ box({ slug: 'gobble', name: 'Gobble', kind: 'cook', host: 'gobble.com', url: 'https://www.gobble.com/', what: 'Dinner kits with the ingredients already prepped, for quick cooking.' }),
++ // ── Ready meals ──
++ box({ slug: 'factor', name: 'Factor', kind: 'ready', host: 'factor75.com', url: 'https://www.factor75.com/', what: 'Fully cooked meals to heat up, priced per meal.' }),
++ box({ slug: 'cookunity', name: 'CookUnity', kind: 'ready', host: 'cookunity.com', url: 'https://www.cookunity.com/', what: 'Meals made by chefs, from many cuisines.',
++ cadence: ['weekly'], meals: [4, 6, 8, 10, 12, 16], price: { from: 10.39, to: 12.69, unit: 'meal', text: 'About $10.39 to $12.69 a meal, by plan size' },
++ shipping: { amount: 9.99, to: 11.99, text: '$9.99 or $11.99 by ZIP code; waived with its UnityPass membership' }, cancel: 'Skip, pause or cancel',
++ sources: ['https://www.cookunity.com/lp/cookunity-cost', 'https://www.cookunity.com/'] }),
++ box({ slug: 'tovala', name: 'Tovala', kind: 'ready', host: 'tovala.com', url: 'https://www.tovala.com/', what: 'Meals that take about a minute of prep.',
++ cadence: ['weekly'], meals: [4, 6, 8, 10, 12, 16], diets: ['high-protein', 'vegetarian'],
++ price: { from: 8.99, to: 13.99, unit: 'serving', text: 'Single meals from $13.99; two-packs and family meals about $8.99 a serving' },
++ shipping: { amount: 10.99, text: '$10.99 a delivery' }, cancel: 'Skip a week, pause or cancel',
++ sources: ['https://www.tovala.com/'] }),
++ box({ slug: 'trifecta', name: 'Trifecta', kind: 'ready', host: 'trifectanutrition.com', url: 'https://www.trifectanutrition.com/', what: 'Diet plans of cooked meals: keto, paleo, vegan, vegetarian.',
++ cadence: ['weekly'], diets: ['keto', 'paleo', 'vegan', 'vegetarian'], price: { from: 112, to: null, unit: 'delivery', text: 'Meal plans from $112 a delivery; build your own from $99' },
++ shipping: { amount: 12.99, text: '$12.99 a delivery (not Alaska or Hawaii)' },
++ sources: ['https://www.trifectanutrition.com/ask-us-anything/how-much-does-trifecta-cost', 'https://www.trifectanutrition.com/meal-plan-delivery'] }),
++ box({ slug: 'daily-harvest', name: 'Daily Harvest', kind: 'ready', host: 'daily-harvest.com', url: 'https://daily-harvest.com/', what: 'Frozen smoothies, bowls and soups.' }),
++ box({ slug: 'mosaic-foods', name: 'Mosaic Foods', kind: 'ready', host: 'mosaicfoods.com', url: 'https://mosaicfoods.com/', what: 'Frozen meals, mostly plant-based.' }),
++ // ── Produce and groceries ──
++ box({ slug: 'misfits-market', name: 'Misfits Market', kind: 'produce', host: 'misfitsmarket.com', url: 'https://www.misfitsmarket.com/', what: 'Organic and odd-looking produce plus groceries.',
++ cadence: ['weekly', 'monthly', 'once'], price: { from: 30, to: null, unit: 'box', text: 'A $30 order minimum (it can depend on where you live); membership is free' },
++ shipping: { amount: 6.99, text: 'A flat rate by ZIP code from $6.99; free over a threshold in some areas' }, cancel: 'Skip or cancel for free, anytime',
++ notes: ['Each week you get a shopping window of 3 to 6 days.'],
++ sources: ['https://www.misfitsmarket.com/how-it-works'] }),
++ box({ slug: 'farm-fresh-to-you', name: 'Farm Fresh To You', kind: 'produce', host: 'farmfreshtoyou.com', url: 'https://www.farmfreshtoyou.com/', what: 'Organic produce boxes from farms, with over 90 things to pick from each week.',
++ cadence: ['weekly'], price: { from: 29, to: null, unit: 'box', text: 'Boxes from $29, no membership fee' }, shipping: { amount: 0, text: 'Delivery included' }, cancel: 'Manage your schedule online',
++ sources: ['https://www.farmfreshtoyou.com/offers', 'https://www.farmfreshtoyou.com/signup'] }),
++ box({ slug: 'thrive-market', name: 'Thrive Market', kind: 'produce', host: 'thrivemarket.com', url: 'https://thrivemarket.com/', what: 'An online pantry store: organic and healthy groceries for members.',
++ price: { from: 12, to: null, unit: 'month', text: 'Membership: $12 a month, or $60 a year' }, sources: ['https://thrivemarket.com/'] }),
++ box({ slug: 'weee', name: 'Weee!', kind: 'produce', host: 'sayweee.com', url: 'https://www.sayweee.com/', what: 'Asian and Hispanic groceries delivered.' }),
++ // ── Meat and fish ──
++ box({ slug: 'butcherbox', name: 'ButcherBox', kind: 'meat', host: 'butcherbox.com', url: 'https://www.butcherbox.com/', what: 'Grass-fed beef, chicken, pork and seafood.',
++ cadence: ['every 2 weeks', 'every 4 weeks', 'every 6 weeks', 'every 8 weeks'],
++ price: { from: 179, to: 319, unit: 'box', text: 'Medium $179 (6 proteins), Large $249 (9), Extra-Large $319 (12)' }, shipping: { amount: 0, text: 'Free' },
++ cancel: 'Pause, change or cancel anytime', notes: ['Its pricing page has shown other figures; check the current price.'],
++ sources: ['https://www.butcherbox.com/pages/faqs', 'https://www.butcherbox.com/pages/get-started'] }),
++ box({ slug: 'porter-road', name: 'Porter Road', kind: 'meat', host: 'porterroad.com', url: 'https://porterroad.com/', what: 'Pasture-raised meat from a butcher shop.',
++ cadence: ['every 2 weeks', 'every 4 weeks', 'every 8 weeks'], shipping: { amount: 0, text: 'Free' }, sources: ['https://porterroad.com/pages/subscriptions'] }),
++ box({ slug: 'wild-alaskan', name: 'Wild Alaskan Company', kind: 'meat', host: 'wildalaskancompany.com', url: 'https://wildalaskancompany.com/', what: 'Wild-caught Alaskan seafood, frozen.',
++ cadence: ['monthly'], cancel: 'Cancel in your account; an email comes before each charge',
++ sources: ['https://wildalaskancompany.com/how-it-works', 'https://wildalaskancompany.com/pricing'] }),
++ box({ slug: 'crowd-cow', name: 'Crowd Cow', kind: 'meat', host: 'crowdcow.com', url: 'https://www.crowdcow.com/', what: 'Meat and seafood from named farms.' }),
++ box({ slug: 'good-chop', name: 'Good Chop', kind: 'meat', host: 'goodchop.com', url: 'https://www.goodchop.com/', what: 'Boxes of meat and seafood.' }),
++ // ── Snack boxes ──
++ box({ slug: 'bokksu', name: 'Bokksu', kind: 'snacks', host: 'bokksu.com', url: 'https://bokksu.com/', what: '20 to 22 Japanese snacks and a tea, around a theme each month.',
++ cadence: ['monthly'], price: { from: 32.99, to: 39.99, unit: 'month', text: '$39.99 a month, down to $32.99 on the 12-month plan' }, shipping: { amount: null, text: 'Added at checkout' },
++ notes: ['Longer plans are paid up front.'], sources: ['https://bokksu.com/products/classic-bokksu'] }),
++ box({ slug: 'snackcrate', name: 'SnackCrate', kind: 'snacks', host: 'snackcrate.com', url: 'https://www.snackcrate.com/', what: '10 to 20 snacks from a different country each month.',
++ cadence: ['monthly'], price: { from: 20, to: 50, unit: 'month', text: '$20 to $50 a month' }, cancel: 'Cancel anytime in your account',
++ notes: ['The page these figures come from may be old.'], sources: ['https://www.snackcrate.com/'] }),
++ box({ slug: 'universal-yums', name: 'Universal Yums', kind: 'snacks', host: 'universalyums.com', url: 'https://www.universalyums.com/', what: 'Snacks from a different country each month.' }),
++ box({ slug: 'sakuraco', name: 'Sakuraco', kind: 'snacks', host: 'sakuraco.com', url: 'https://sakuraco.com/', what: 'Traditional Japanese snacks and tea, monthly.' }),
++ // ── Coffee ──
++ box({ slug: 'bean-box', name: 'Bean Box', kind: 'coffee', host: 'beanbox.com', url: 'https://beanbox.com/', what: 'Coffee from Seattle-area and other independent roasters.',
++ price: { from: 19.5, to: 40, unit: 'delivery', text: 'One 12 oz bag $19.50 to $21.50 a delivery; two bags $36 to $40' },
++ cancel: 'Pause, skip or cancel by 1 pm PT the Wednesday before a shipment', sources: ['https://beanbox.com/'] }),
++ box({ slug: 'trade', name: 'Trade Coffee', kind: 'coffee', host: 'drinktrade.com', url: 'https://www.drinktrade.com/', what: 'Coffee from independent roasters, matched to your taste.' }),
++ box({ slug: 'atlas-coffee-club', name: 'Atlas Coffee Club', kind: 'coffee', host: 'atlascoffeeclub.com', url: 'https://atlascoffeeclub.com/', what: 'Single-origin coffee from a different country each box.' }),
++ // ── Baby and kids ──
++ box({ slug: 'little-spoon', name: 'Little Spoon', kind: 'baby', host: 'littlespoon.com', url: 'https://www.littlespoon.com/', what: 'Baby food, toddler and kids\' meals, delivered across the US.',
++ cadence: ['every 2 weeks'], price: { from: null, to: 5, unit: 'meal', text: 'Kids\' plates under $5 a meal' }, ships_to: 'Across the US',
++ cancel: 'Change the schedule in your account by Saturday 5 pm ET; the first order can be moved up to 4 weeks but not cancelled',
++ sources: ['https://www.littlespoon.com/'] }),
++ box({ slug: 'yumi', name: 'Yumi', kind: 'baby', host: 'helloyumi.com', url: 'https://helloyumi.com/', what: 'Baby and toddler food and snacks.' }),
++ box({ slug: 'cerebelly', name: 'Cerebelly', kind: 'baby', host: 'cerebelly.com', url: 'https://cerebelly.com/', what: 'Baby food pouches and bars.' }),
++ // ── Pets ──
++ box({ slug: 'the-farmers-dog', name: 'The Farmer\'s Dog', kind: 'pets', host: 'thefarmersdog.com', url: 'https://www.thefarmersdog.com/', what: 'Fresh dog food portioned for your dog.',
++ cadence: ['every 1 week to 3 months'], price: { from: 2, to: null, unit: 'day', text: 'From about $2 a day; your dog sets the price' }, shipping: { amount: 0, text: 'Free' },
++ cancel: 'Pause, cancel or come back anytime', sources: ['https://www.thefarmersdog.com/faq'] }),
++ box({ slug: 'nom-nom', name: 'Nom Nom', kind: 'pets', host: 'nomnomnow.com', url: 'https://www.nomnomnow.com/', what: 'Fresh dog food in portions, on a schedule you set.',
++ price: { from: 1.6, to: 4.6, unit: 'meal', text: '$1.60 to $2.60 a meal for small dogs, to $4.60 for large ones' }, cancel: 'Cancel before your next delivery',
++ sources: ['https://www.nomnomnow.com/how-it-works/', 'https://www.nomnomnow.com/faq'] }),
++ box({ slug: 'ollie', name: 'Ollie', kind: 'pets', host: 'myollie.com', url: 'https://www.myollie.com/', what: 'Fresh and baked dog food made for your dog.' }),
++];
++
++const BY_SLUG = new Map(BOXES.map((b) => [b.slug, b]));
++const UNIT = { serving: 'a serving', meal: 'a meal', box: 'a box', month: 'a month', delivery: 'a delivery', day: 'a day', membership: '' };
++
++function list({ kind = null, diet = null, q = null } = {}) {
++ const needle = q ? String(q).toLowerCase().slice(0, 60) : null;
++ return BOXES.filter((b) => (!kind || b.kind === kind) && (!diet || b.diets.includes(diet))
++ && (!needle || b.name.toLowerCase().includes(needle) || b.host.includes(needle) || b.what.toLowerCase().includes(needle)));
++}
++const get = (slug) => BY_SLUG.get(String(slug || '')) || null;
++const kindOf = (k) => KIND[k] || null;
++
++/** A price in a line: "from $5.99 a serving", "$10.39–$12.69 a meal", "under $5 a meal". */
++function priceLine(p) {
++ if (!p) return null;
++ const $ = (n) => `$${Number(n).toFixed(Number.isInteger(n) ? 0 : 2)}`;
++ const unit = UNIT[p.unit] ? ` ${UNIT[p.unit]}` : '';
++ if (p.from != null && p.to != null && p.to !== p.from) return `${$(p.from)}–${$(p.to)}${unit}`;
++ if (p.from != null) return `from ${$(p.from)}${unit}`;
++ if (p.to != null) return `under ${$(p.to)}${unit}`;
++ return null;
++}
++
++/**
++ * What a box's stated price comes to for a plan: people × meals a week (servings) at its stated price per serving or
++ * meal, for some weeks, plus its stated shipping a week. Only for a per-serving or per-meal price, and only for a plan
++ * size the box states (or any when it states none). Never a price of its own: a range stays a range.
++ */
++function cost(b, { people = 2, meals = 3, weeks = 1 } = {}) {
++ if (!b || !b.price || !['serving', 'meal'].includes(b.price.unit)) return null;
++ const p = Math.round(Number(people)), m = Math.round(Number(meals)), w = Math.round(Number(weeks));
++ if (!(p >= 1 && p <= 12 && m >= 1 && m <= 21 && w >= 1 && w <= 52)) return null;
++ if (b.people.length && !b.people.includes(p)) return null;
++ if (b.meals.length && !b.meals.includes(m)) return null;
++ const servings = b.price.unit === 'meal' && b.kind === 'ready' ? m : p * m;
++ const lo = b.price.from != null ? b.price.from : b.price.to;
++ const hi = b.price.to != null ? b.price.to : b.price.from;
++ const ship = b.shipping && b.shipping.amount != null ? b.shipping.amount : null;
++ const shipHi = b.shipping && b.shipping.to != null ? b.shipping.to : ship;
++ const r2 = (n) => Math.round(n * 100) / 100;
++ return {
++ servings, weeks: w,
++ food: { from: r2(lo * servings * w), to: r2(hi * servings * w) },
++ shipping: ship == null ? null : { from: r2(ship * w), to: r2(shipHi * w) },
++ total: ship == null ? null : { from: r2((lo * servings + ship) * w), to: r2((hi * servings + shipHi) * w) },
++ basis: `${b.price.text} (${b.name}'s own page, read ${b.checked})`,
++ };
++}
++
++function toWire(b) {
++ return {
++ slug: b.slug, name: b.name, kind: b.kind, kind_name: KIND[b.kind].name, url: b.url, host: b.host, what: b.what,
++ cadence: b.cadence, people: b.people, meals_per_week: b.meals, diets: b.diets,
++ price: b.price ? { ...b.price, line: priceLine(b.price) } : null, shipping: b.shipping, ships_to: b.ships_to, cancel: b.cancel, notes: b.notes,
++ sources: b.sources, checked: b.sources.length ? b.checked : null,
++ links: { page: `https://openvibe.food/boxes/${b.slug}`, coupons: `https://openvibe.coupons/?q=${encodeURIComponent(b.host)}`, reviews: `https://openvibe.reviews/search?q=${encodeURIComponent(b.name)}` },
++ };
++}
++
++module.exports = { KINDS, KIND, DIETS, BOXES, list, get, kindOf, cost, priceLine, toWire, CHECKED };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 4ccff7b..01fadc8 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -6,6 +6,9 @@
* GET /places?q=&kind=foodbank|grocery|both[&lat=&lon=&radius=&limit=] public food near a place (OpenStreetMap)
* GET /foods[?group=&q=] public the food list
* GET /foods/:slug public one food
++ * GET /boxes[?kind=&diet=&q=] public every food box (server/food/boxes.js)
++ * GET /boxes/:slug public one box, with its codes and score
++ * GET /boxes/:slug/cost?people=&meals=&weeks= public what its stated price comes to
* POST /plans signed in plan a shop and save it
* GET /plans signed in the plans you saved
* GET /plans/:id signed in one of yours
@@@ -21,6 +24,7 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const foods = require('../food/foods');
++const boxes = require('../food/boxes');
const { planMeals } = require('../food/planner');
const { searchPlaces } = require('../food/places');
const { matchPantry } = require('../food/pantry-match');
@@@ -83,6 +87,36 @@ function createApi(ctx) {
});
});
++ // ── Food boxes: every fact with the service's own page and the day it was read; codes and scores live ──
++ r.get('/boxes', limits.reads('food.food.read'), (req, res) => {
++ const kind = typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : null;
++ if (kind && !boxes.KIND[kind]) return fail(req, res, 422, 'food.box.invalid', `kind: one of ${boxes.KINDS.map((x) => x.kind).join(', ')}`);
++ const diet = typeof req.query.diet === 'string' && req.query.diet ? req.query.diet : null;
++ if (diet && !boxes.DIETS[diet]) return fail(req, res, 422, 'food.box.invalid', `diet: one of ${Object.keys(boxes.DIETS).join(', ')}`);
++ const q = typeof req.query.q === 'string' && req.query.q ? req.query.q.slice(0, 60) : null;
++ const list = boxes.list({ kind, diet, q });
++ res.set('Cache-Control', 'public, max-age=3600');
++ res.json({ boxes: list.map(boxes.toWire), count: list.length, kinds: boxes.KINDS, diets: boxes.DIETS,
++ method: 'Each fact was read on the service\'s own public pages by a person (sources, checked); nothing is fetched from the services. A null fact was not checked.' });
++ });
++
++ r.get('/boxes/:slug', limits.reads('food.food.read'), async (req, res) => {
++ const b = boxes.get(req.params.slug);
++ if (!b) return fail(req, res, 404, 'food.box.not_found', `no box "${String(req.params.slug).slice(0, 64)}"; GET /api/v1/boxes lists them`);
++ const links = ctx.boxLinks ? await ctx.boxLinks.forBox(b) : { coupons: null, reviews: null };
++ res.set('Cache-Control', 'public, max-age=300');
++ res.json({ box: boxes.toWire(b), coupons: links.coupons, reviews: links.reviews });
++ });
++
++ r.get('/boxes/:slug/cost', limits.reads('food.food.read'), (req, res) => {
++ const b = boxes.get(req.params.slug);
++ if (!b) return fail(req, res, 404, 'food.box.not_found', `no box "${String(req.params.slug).slice(0, 64)}"`);
++ const c = boxes.cost(b, { people: req.query.people || 2, meals: req.query.meals || 3, weeks: req.query.weeks || 1 });
++ if (!c) return fail(req, res, 422, 'food.box.no_cost', b.price && ['serving', 'meal'].includes(b.price.unit) ? `a plan size ${b.name} states: people ${b.people.join('/') || 'any'}, meals ${b.meals.join('/') || 'any'}` : `${b.name} has no stated price per serving or meal`);
++ res.set('Cache-Control', 'public, max-age=3600');
++ res.json({ box: b.slug, cost: c });
++ });
++
r.get('/foods/:slug', limits.reads('food.food.read'), (req, res) => {
const food = foods.getFood(req.params.slug);
if (!food) return fail(req, res, 404, 'food.food.not_found', `no food "${String(req.params.slug).slice(0, 64)}" in the list; GET /api/v1/foods lists them`);
ddiff --git a/server/http/box-pages.js b/server/http/box-pages.js
nnew file mode 100644
iindex 0000000..788d427
---- /dev/null
++++ b/server/http/box-pages.js
@@@ -0,0 +1,153 @@
++'use strict';
++
++/**
++ * Food boxes on the site (server/food/boxes.js), built with the OpenVibe Kit (openvibe-shared/kit, Shared 4.20):
++ *
++ * GET /boxes[?kind=&diet=&q=] every box, a tab per kind, diets as chips, a search row; rows that say the
++ * stated price, the plan sizes and the shipping at a glance
++ * GET /boxes/:slug[?people=&meals=] one box: what its own pages said and when, what that comes to for your plan,
++ * its codes on OpenVibe.Coupons and its score on OpenVibe.Reviews, Watch, its site
++ * GET /boxes/compare?b=a,b,c up to four side by side
++ *
++ * Mounted by pages.js with its page() and helpers. Everything is server-rendered and complete without JavaScript.
++ */
++const kit = require('openvibe-shared/kit');
++const cache = require('openvibe-shared/cache-policy');
++const boxes = require('../food/boxes');
++const { html, raw } = require('../render/html');
++
++const k = (markup) => raw(String(markup));
++const $ = (n) => `$${Number(n).toFixed(2)}`;
++const range = (r) => (!r ? null : r.from === r.to ? $(r.from) : `${$(r.from)}–${$(r.to)}`);
++const WATCH_NEW = 'https://openvibe.watch/watches/new';
++const watchHref = (b) => `${WATCH_NEW}?${new URLSearchParams({ template: 'page', url: b.url, name: `Food: ${b.name} prices`.slice(0, 80) })}`;
++const reviewHref = (b) => `https://openvibe.reviews/review?${new URLSearchParams({ kind: 'service', name: b.name })}`;
++const couponsHref = (b) => `https://openvibe.coupons/?${new URLSearchParams({ q: b.host })}`;
++const span = (list) => (!list.length ? null : list.length === 1 ? String(list[0]) : `${list[0]}–${list[list.length - 1]}`);
++
++/** The facts a row shows: the stated price, the plan size, free shipping; or what the box is when nothing was checked. */
++function factsLine(b) {
++ const parts = [boxes.priceLine(b.price), b.people.length ? `${span(b.people)} people` : null, b.meals.length ? `${span(b.meals)} meals a week` : null,
++ b.shipping && b.shipping.amount === 0 ? 'free shipping' : null].filter(Boolean);
++ return parts.length ? parts.join(' · ') : b.what;
++}
++
++function mountBoxPages(r, { page, signedIn, PUBLIC_CACHE, boxLinks }) {
++ // ── The directory ──
++ r.get('/boxes', (req, res) => {
++ const kinds = ['all', ...boxes.KINDS.map((x) => x.kind)];
++ const tab = kit.tabOf(req.query, kinds, 'kind');
++ const diet = boxes.DIETS[req.query.diet] ? String(req.query.diet) : null;
++ const q = typeof req.query.q === 'string' && req.query.q.trim() ? req.query.q.trim().slice(0, 60) : null;
++ const list = boxes.list({ kind: tab === 'all' ? null : tab, diet, q });
++ const tabs = kit.tabs({ base: diet ? `/boxes?diet=${diet}` : '/boxes', param: 'kind', active: tab, label: 'Kinds of box',
++ items: [{ key: 'all', label: 'All', count: boxes.list({ diet }).length }, ...boxes.KINDS.map((x) => ({ key: x.kind, label: x.name, count: boxes.list({ kind: x.kind, diet }).length }))] });
++ const head = kit.head({ title: 'Food boxes', sub: 'Meal kits, ready meals and food boxes, side by side', actions: [kit.iconButton({ href: '/boxes/compare', icon: 'list', label: 'Compare boxes' })], tabs });
++ const inKind = boxes.list({ kind: tab === 'all' ? null : tab });
++ const diets = Object.keys(boxes.DIETS).filter((d) => inKind.some((b) => b.diets.includes(d)));
++ const kindParam = tab === 'all' ? '' : `kind=${tab}`;
++ const dietHref = (d) => `/boxes?${[kindParam, d ? `diet=${d}` : ''].filter(Boolean).join('&')}`;
++ const chips = diets.length ? html`<nav class="bx-diets" aria-label="Diets"><a href="${dietHref(null)}"${diet ? '' : raw(' aria-current="page"')}>Any diet</a>${diets.map((d) => html`<a href="${dietHref(d)}"${d === diet ? raw(' aria-current="page"') : ''}>${boxes.DIETS[d]}</a>`)}</nav>` : '';
++ const row = (b) => kit.row({ href: `/boxes/${b.slug}`, mark: kit.mark({ name: b.name, square: true }), title: b.name,
++ badge: tab === 'all' ? kit.badge(boxes.KIND[b.kind].short) : '', sub: factsLine(b) });
++ page(req, res, {
++ title: tab === 'all' ? 'Food boxes, compared' : `${boxes.KIND[tab].name}, compared`,
++ description: 'Every meal kit, ready-meal service and food box (produce, meat and fish, snacks, coffee, baby food, dog food) side by side: prices as their own pages state them, plan sizes, diets, shipping and how to cancel, with codes and reviews.',
++ index: !q && !diet, cache: signedIn(req) ? null : PUBLIC_CACHE, crumbs: [], styles: ['kit.css'], localStyles: ['css/boxes.css'],
++ body: html`${k(head)}
++${k(kit.find({ action: '/boxes', placeholder: 'A box, a diet or a kind: keto, coffee, HelloFresh', hidden: tab === 'all' ? {} : { kind: tab }, value: q || '' }))}
++${chips}
++${list.length ? k(kit.list(list.map(row))) : k(kit.empty({ icon: 'search', title: 'No box matches', text: 'Try another word, another diet or All.', action: { href: '/boxes', label: 'Every box', kind: 'quiet' } }))}
++<p class="muted small bx-how">Every fact is read on the service's own pages by a person, with the page and the day beside it; prices change, so each box links to its site, and OpenVibe.Watch can tell you when they change. Codes come from <a href="https://openvibe.coupons/">OpenVibe.Coupons</a> and scores from <a href="https://openvibe.reviews/">OpenVibe.Reviews</a>. The same list is <a href="/api/v1/boxes">an API</a>.</p>`,
++ });
++ });
++
++ // ── Side by side ──
++ r.get('/boxes/compare', (req, res) => {
++ const picked = String(req.query.b || '').split(',').map((s) => boxes.get(s.trim())).filter(Boolean).slice(0, 4);
++ const head = kit.head({ title: 'Compare food boxes', sub: picked.length ? picked.map((b) => b.name).join(' · ') : 'Pick up to four', back: { href: '/boxes', label: 'All boxes' } });
++ const options = boxes.KINDS.map((kd) => html`<optgroup label="${kd.name}">${boxes.list({ kind: kd.kind }).map((b) => html`<option value="${b.slug}">${b.name}</option>`)}</optgroup>`);
++ const add = html`<form class="bx-pick" method="get" action="/boxes/compare"><label class="ovk-sr" for="bx-add">Add a box</label>
++<input type="hidden" name="keep" value="${picked.map((b) => b.slug).join(',')}"><select id="bx-add" name="add">${options}</select><button type="submit" class="ovk-btn ovk-btn--quiet ovk-btn--small">Add</button></form>`;
++ if (req.query.add) {
++ const next = [...String(req.query.keep || '').split(',').filter(Boolean), String(req.query.add)].map((s) => boxes.get(s)).filter(Boolean).map((b) => b.slug);
++ return res.redirect(303, `/boxes/compare?b=${[...new Set(next)].slice(-4).join(',')}`);
++ }
++ const rows = [
++ ['Kind', (b) => boxes.KIND[b.kind].name],
++ ['Price, as stated', (b) => (b.price ? b.price.text : 'Not checked yet')],
++ ['Shipping', (b) => (b.shipping ? b.shipping.text : 'Not checked yet')],
++ ['People', (b) => span(b.people) || '—'],
++ ['Meals a week', (b) => span(b.meals) || '—'],
++ ['How often', (b) => b.cadence.join(', ') || '—'],
++ ['Diets', (b) => b.diets.map((d) => boxes.DIETS[d]).join(', ') || '—'],
++ ['Delivers to', (b) => b.ships_to || 'Not checked yet'],
++ ['Skip and cancel', (b) => b.cancel || 'Not checked yet'],
++ ];
++ page(req, res, {
++ title: picked.length ? `${picked.map((b) => b.name).join(' vs ')}` : 'Compare food boxes',
++ description: 'Food boxes side by side: prices as their own pages state them, plan sizes, diets, shipping and how to cancel.',
++ index: false, cache: signedIn(req) ? null : PUBLIC_CACHE, crumbs: [], styles: ['kit.css'], localStyles: ['css/boxes.css'],
++ body: html`${k(head)}
++${add}
++${picked.length ? html`<div class="bx-compare" role="region" aria-label="The boxes side by side" tabindex="0"><table><thead><tr><th scope="col"><span class="ovk-sr">Fact</span></th>${picked.map((b) => html`<th scope="col"><a href="/boxes/${b.slug}">${b.name}</a> <a class="bx-x" href="/boxes/compare?b=${picked.filter((x) => x !== b).map((x) => x.slug).join(',')}" aria-label="Take ${b.name} out">×</a></th>`)}</tr></thead>
++<tbody>${rows.map(([label, f]) => html`<tr><th scope="row">${label}</th>${picked.map((b) => html`<td>${f(b)}</td>`)}</tr>`)}</tbody></table></div>
++<p class="muted small">As each service's own pages said on the day shown on its page. Prices change: open a box for its site and to watch it.</p>` : k(kit.empty({ icon: 'list', title: 'Nothing to compare yet', text: 'Add a box above, or start from the list.', action: { href: '/boxes', label: 'All boxes', kind: 'quiet' } }))}`,
++ });
++ });
++
++ // ── One box ──
++ r.get('/boxes/:slug', async (req, res, next) => {
++ const b = boxes.get(req.params.slug);
++ if (!b) return next();
++ const kind = boxes.KIND[b.kind];
++ const people = b.people.length ? (b.people.includes(Number(req.query.people)) ? Number(req.query.people) : b.people[0]) : Math.min(12, Math.max(1, Number(req.query.people) || 2));
++ const meals = b.meals.length ? (b.meals.includes(Number(req.query.meals)) ? Number(req.query.meals) : b.meals[0]) : Math.min(21, Math.max(1, Number(req.query.meals) || 3));
++ const c = boxes.cost(b, { people, meals });
++ const links = boxLinks ? await boxLinks.forBox(b) : { coupons: null, reviews: null };
++ const head = kit.head({ title: b.name, subHtml: String(html`${kind.name} · <a href="${b.url}" rel="noopener nofollow">${b.host}</a>`), back: { href: `/boxes?kind=${b.kind}`, label: kind.name },
++ actions: [kit.iconButton({ href: watchHref(b), icon: 'bell', label: `Watch ${b.name}'s prices on OpenVibe.Watch` }), kit.iconButton({ href: b.url, icon: 'open', label: `${b.name}'s site`, attrs: { rel: 'noopener nofollow' } })] });
++ const fact = (label, value) => html`<div class="ovk-set-row bx-fact"><span class="ovk-set-text">${label}</span><span class="bx-val">${value || html`<span class="muted">Not checked yet</span>`}</span></div>`;
++ const opt = (list, cur) => list.map((n) => html`<option value="${n}"${n === cur ? raw(' selected') : ''}>${n}</option>`);
++ const sizes = (b.people.length ? b.people : [1, 2, 3, 4, 5, 6]);
++ const weekly = (b.meals.length ? b.meals : [2, 3, 4, 5, 6, 7]);
++ const calc = b.price && ['serving', 'meal'].includes(b.price.unit) ? html`<section class="ovk-section" aria-labelledby="bx-calc-h"><h2 id="bx-calc-h">What it comes to</h2>
++<form class="bx-calc" method="get" action="/boxes/${b.slug}"><label>${b.kind === 'ready' ? 'Meals a week' : 'People'} <select name="${b.kind === 'ready' ? 'meals' : 'people'}">${b.kind === 'ready' ? opt(weekly, meals) : opt(sizes, people)}</select></label>
++${b.kind === 'ready' ? '' : html`<label>Meals a week <select name="meals">${opt(weekly, meals)}</select></label>`}<button type="submit" class="ovk-btn ovk-btn--quiet ovk-btn--small">Work it out</button></form>
++${c ? html`<p class="bx-cost"><strong>${range(c.total) || range(c.food)}</strong> a week${c.total ? ` (${range(c.food)} of food + ${range(c.shipping)} shipping)` : ''}, for ${c.servings} ${b.price.unit === 'meal' ? 'meals' : 'servings'}.</p>
++<p class="ovk-note">From ${c.basis}. The first box is often cheaper and later ones cost more: check theirs.</p>` : html`<p class="ovk-note">That plan size is not one ${b.name} states; pick one of theirs.</p>`}</section>` : '';
++ const codes = links.coupons
++ ? html`<a class="ovk-set-row bx-link" href="${links.coupons.url}"><span class="ovk-set-text">Codes on OpenVibe.Coupons<small>${links.coupons.best ? html`Best now: ${links.coupons.best.code} · ${links.coupons.best.title}` : `${links.coupons.active} active`}</small></span>${k(kit.icon('chevron'))}</a>`
++ : html`<a class="ovk-set-row bx-link" href="${couponsHref(b)}"><span class="ovk-set-text">Codes on OpenVibe.Coupons<small>None listed for ${b.host} yet; submit one you have</small></span>${k(kit.icon('chevron'))}</a>`;
++ const score = links.reviews
++ ? html`<a class="ovk-set-row bx-link" href="${links.reviews.url}"><span class="ovk-set-text">Reviews on OpenVibe.Reviews<small>${links.reviews.line || 'Read what people say'}</small></span>${k(kit.icon('chevron'))}</a>`
++ : html`<a class="ovk-set-row bx-link" href="${reviewHref(b)}"><span class="ovk-set-text">Reviews on OpenVibe.Reviews<small>No reviews yet: write the first</small></span>${k(kit.icon('chevron'))}</a>`;
++ const others = boxes.list({ kind: b.kind }).filter((x) => x !== b).slice(0, 4);
++ page(req, res, {
++ title: `${b.name}: ${kind.name.toLowerCase()} compared`,
++ description: `${b.name}: ${b.what} ${b.price ? `${b.price.text}. ` : ''}Plan sizes, shipping, diets and how to cancel, from its own pages, with codes and reviews.`.slice(0, 300),
++ index: !req.query.people && !req.query.meals, cache: signedIn(req) ? null : cache.htmlHeaders({ maxAge: 300 }), crumbs: [], styles: ['kit.css'], localStyles: ['css/boxes.css'],
++ body: html`${k(head)}
++<p class="bx-what">${b.what}</p>
++${b.price ? html`<p class="bx-price"><strong>${boxes.priceLine(b.price)}</strong>${b.shipping && b.shipping.amount === 0 ? ' · free shipping' : ''}</p>` : ''}
++<section class="ovk-section" aria-labelledby="bx-facts-h"><h2 id="bx-facts-h">The plans</h2>
++${fact('Price', b.price ? b.price.text : null)}
++${fact('Shipping', b.shipping ? b.shipping.text : null)}
++${fact('People', span(b.people))}
++${fact('Meals a week', span(b.meals))}
++${fact('How often', b.cadence.join(', '))}
++${fact('Diets', b.diets.length ? b.diets.map((d) => boxes.DIETS[d]).join(', ') : null)}
++${fact('Delivers to', b.ships_to)}
++${fact('Skip and cancel', b.cancel)}
++${b.notes.length ? html`<ul class="ovk-note bx-notes">${b.notes.map((n) => html`<li>${n}</li>`)}</ul>` : ''}
++<p class="ovk-note">${b.sources.length ? html`From ${b.name}'s own pages, read ${b.checked}: ${b.sources.map((s, i) => html`${i ? ', ' : ''}<a href="${s}" rel="noopener nofollow">${s.replace(/^https?:\/\/(www\.)?/, '').slice(0, 60)}</a>`)}.` : html`Nothing checked yet beyond what it is; its plans are on <a href="${b.url}" rel="noopener nofollow">its site</a>.`}</p></section>
++${calc}
++<section class="ovk-section" aria-labelledby="bx-more-h"><h2 id="bx-more-h">Across OpenVibe</h2>${codes}${score}
++<a class="ovk-set-row bx-link" href="${watchHref(b)}"><span class="ovk-set-text">Watch its prices<small>OpenVibe.Watch tells you when ${b.host}'s page changes</small></span>${k(kit.icon('chevron'))}</a>
++<a class="ovk-set-row bx-link" href="/boxes/compare?b=${[b, ...others.slice(0, 1)].map((x) => x.slug).join(',')}"><span class="ovk-set-text">Compare it<small>Side by side with ${others[0] ? others[0].name : 'others'}</small></span>${k(kit.icon('chevron'))}</a></section>
++${others.length ? k(kit.list(others.map((x) => kit.row({ href: `/boxes/${x.slug}`, mark: kit.mark({ name: x.name, square: true }), title: x.name, sub: factsLine(x) })), { title: `More ${kind.name.toLowerCase()}`, more: { href: `/boxes?kind=${b.kind}`, label: 'All' } })) : ''}`,
++ });
++ });
++}
++
++module.exports = { mountBoxPages, factsLine };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 7ae859e..8ef7947 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -35,6 +35,7 @@ const { matchPantry } = require('../food/pantry-match');
const { suggestRecipes } = require('../food/recipes');
const store = require('../food/store');
const delivery = require('../food/delivery');
++const { mountBoxPages } = require('./box-pages');
const householdChat = require('../food/household-chat');
const { date: recallDate } = require('./recall-pages');
const seo = require('openvibe-shared/seo');
@@@ -815,6 +816,9 @@ ${row.recipes.length ? html`<div class="recipe-grid">${row.recipes.map(recipeBlo
});
}
++ // ── Food boxes: every meal kit and food box side by side (./box-pages.js, server/food/boxes.js) ──
++ mountBoxPages(r, { page, signedIn, PUBLIC_CACHE, boxLinks: ctx.boxLinks || null });
++
// ── Delivery and meal kits, compared (server/food/delivery.js): every app's own search, never their data ──
r.get('/delivery', (req, res) => {
const q = delivery.cleanQuery(req.query.q);
@@@ -836,7 +840,7 @@ ${row.recipes.length ? html`<div class="recipe-grid">${row.recipes.map(recipeBlo
${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-app"><a class="dv-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="dv-name">${l.name}</span><span class="dv-host">${hostOf(l.url)} ↗</span></a><p class="dv-best muted small">${l.best}</p></li>`)}</ul>
<p class="muted small">Compare the totals each app shows you, delivery and service fees included. For coupon codes on an app's own page, the <a href="https://openvibe.coupons/companion">OpenVibe Companion</a> browser extension shows what OpenVibe.Coupons knows for it.</p>` : ''}
<section aria-labelledby="h-kits"><h2 id="h-kits">Meal kits and prepared meals</h2>
--<p class="muted">Boxes of recipes and ingredients, or meals ready to heat. Prices change with the box size and the week, so each links to its own plans page; OpenVibe.Watch can tell you when a price changes.</p>
++<p class="muted">Boxes of recipes and ingredients, or meals ready to heat. <a href="/boxes"><strong>Every food box, compared</strong></a>: meal kits, ready meals, produce, meat and fish, snacks, coffee, baby and dog food, with prices as their own pages state them, plan sizes, diets, codes and reviews. Prices change with the box size and the week, so each links to its own plans page; OpenVibe.Watch can tell you when a price changes.</p>
<ul class="dv-kits">${delivery.KITS.map(kitRow)}</ul>
<p class="muted small">Budget kits usually cost the least per serving; premium ones cost more for organic or special-diet food, and prepared meals cost more again. The first box is often heavily discounted, so compare the price from the second week.</p></section>
<p class="muted small">Cooking from what you have is cheaper still: <a href="/pantry">your cupboard</a>, <a href="/recipes">recipe ideas</a> and <a href="/plan">a meal plan</a> with its shopping list.</p>`,
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 4696ae6..d292c51 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -24,11 +24,12 @@ const TAGLINE = 'What should we eat?';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own navigation: one entry per public page it serves.
const NAV = [
-- { label: 'Food near you', href: '/near' },
-- { label: 'Meal plan', href: '/plan' },
++ { label: 'Food near you', href: '/near', icon: 'fa-location-dot' },
++ { label: 'Meal plan', href: '/plan', icon: 'fa-list-check' },
++ { label: 'Food boxes', href: '/boxes', icon: 'fa-utensils' },
++ { label: 'Cupboard', href: '/pantry', icon: 'fa-house' },
++ { label: 'Recipes', href: '/recipes', icon: 'fa-book' },
{ label: 'Food list', href: '/foods' },
-- { label: 'Cupboard', href: '/pantry' },
-- { label: 'Recipes', href: '/recipes' },
{ label: 'Delivery', href: '/delivery' },
{ label: 'What shipped', href: '/updates' },
];
@@@ -61,7 +62,8 @@ function renderPage(o) {
const nav = {
service: 'food',
apiBase: NETWORK_URL,
-- links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
++ links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
++ appBar: true, // phones: the five places with icons as the bottom tab bar, More for the rest (Shared 4.20)
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
@@@ -69,7 +71,7 @@ function renderPage(o) {
logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
};
-- const footer = { service: 'food', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
++ const footer = { service: 'food', variant: 'auto', path: pathNow.split('?')[0], mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }; // full on the front page only (Shared 4.19)
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
: html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
ddiff --git a/test/boxes-pages.test.js b/test/boxes-pages.test.js
nnew file mode 100644
iindex 0000000..7567baf
---- /dev/null
++++ b/test/boxes-pages.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++// The food boxes on the site and in the API (server/http/box-pages.js, /api/v1/boxes): the directory with its tabs,
++// diets and search, a box with its facts, sources, cost and links, boxes side by side, and the API's answers.
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ // ── The directory ──
++ let r = await t.get('/boxes');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<h1>Food boxes<\/h1>/);
++ assert.match(r.text, /href="\/boxes\?kind=cook"/, 'a tab per kind');
++ assert.match(r.text, /href="\/boxes\/everyplate"/);
++ assert.match(r.text, /from \$5\.99 a serving · 2–6 people · 3–5 meals a week/, 'a row says the stated price and plan sizes');
++ assert.match(r.text, /\/shared\/kit\.css\?v=/, 'the OpenVibe Kit');
++ r = await t.get('/boxes?kind=meat');
++ assert.match(r.text, /ButcherBox/);
++ assert.doesNotMatch(r.text, /href="\/boxes\/everyplate"/, 'one kind at a time');
++ assert.match(r.text, /aria-current="page"[^>]*>(<[^>]+>)*Meat and fish/);
++ r = await t.get('/boxes?diet=keto');
++ assert.match(r.text, /Green Chef/);
++ assert.doesNotMatch(r.text, /href="\/boxes\/butcherbox"/, 'a diet filters');
++ r = await t.get('/boxes?q=coffee');
++ assert.match(r.text, /Bean Box/);
++ r = await t.get('/boxes?q=zzzz');
++ assert.match(r.text, /No box matches/);
++
++ // ── One box ──
++ r = await t.get('/boxes/everyplate?people=4&meals=3');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<h1>EveryPlate<\/h1>/);
++ assert.match(r.text, /Plans from \$5\.99 a serving/);
++ assert.match(r.text, /<strong>\$82\.87<\/strong> a week \(\$71\.88 of food \+ \$10\.99 shipping\), for 12 servings/, '4 people × 3 meals at $5.99 + $10.99');
++ assert.match(r.text, /read 2026-10-28/, 'the day it was read');
++ assert.match(r.text, /href="https:\/\/www\.everyplate\.com\/about\/menus-and-plans"/, 'the page it was read on');
++ assert.match(r.text, /openvibe\.watch\/watches\/new\?template=page/, 'Watch');
++ assert.match(r.text, /openvibe\.reviews\/review\?kind=service&amp;name=EveryPlate/, 'no Reviews entry: write the first');
++ assert.match(r.text, /openvibe\.coupons\/\?q=everyplate\.com/, 'no Coupons shop: submit a code');
++ r = await t.get('/boxes/dinnerly');
++ assert.match(r.text, /Not checked yet/, 'nothing stated: said so');
++ assert.doesNotMatch(r.text, /What it comes to/, 'and nothing worked out');
++ assert.strictEqual((await t.get('/boxes/nope')).status, 404);
++
++ // ── Side by side ──
++ r = await t.get('/boxes/compare?b=everyplate,hellofresh,nope');
++ assert.match(r.text, /<a href="\/boxes\/everyplate">EveryPlate<\/a>/);
++ assert.match(r.text, /<a href="\/boxes\/hellofresh">HelloFresh<\/a>/);
++ assert.match(r.text, /The whole US except Alaska and Hawaii/);
++ r = await t.get('/boxes/compare?keep=everyplate&add=factor');
++ assert.deepStrictEqual([r.status, r.headers.get('location')], [303, '/boxes/compare?b=everyplate,factor']);
++
++ // ── The API ──
++ r = await t.get('/api/v1/boxes?kind=pets');
++ assert.strictEqual(r.status, 200);
++ const pets = r.json();
++ assert.ok(pets.boxes.length >= 3 && pets.boxes.every((b) => b.kind === 'pets'));
++ assert.ok(pets.method.includes('read on the service\'s own public pages'));
++ assert.strictEqual((await t.get('/api/v1/boxes?kind=cars')).status, 422);
++ r = await t.get('/api/v1/boxes/cookunity');
++ assert.deepStrictEqual([r.json().box.price.line, r.json().coupons, r.json().reviews], ['$10.39–$12.69 a meal', null, null]);
++ r = await t.get('/api/v1/boxes/everyplate/cost?people=2&meals=3&weeks=2');
++ assert.deepStrictEqual(r.json().cost.total, { from: 93.86, to: 93.86 });
++ r = await t.get('/api/v1/boxes/butcherbox/cost');
++ assert.deepStrictEqual([r.status, r.json().code], [422, 'food.box.no_cost']);
++ process.stdout.write('boxes pages and API: directory, tabs, diets, search, a box, compare and the API ok\n');
++ } finally {
++ if (t.close) await t.close();
++ }
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/boxes.test.js b/test/boxes.test.js
nnew file mode 100644
iindex 0000000..26747d7
---- /dev/null
++++ b/test/boxes.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++// Food boxes (server/food/boxes.js, server/http/box-pages.js, the /api/v1/boxes routes): every box well-formed with
++// its facts sourced, the cost honest (only from a stated per-serving or per-meal price, a range stays a range), the
++// pages and the API answering, and the Coupons and Reviews joins read when set and never when not.
++const assert = require('assert');
++const boxes = require('../server/food/boxes');
++const { createBoxLinks } = require('../server/food/box-links');
++
++// ── The data: every box complete, every stated fact sourced ──
++const slugs = new Set();
++boxes.BOXES.forEach((b) => {
++ assert.ok(/^[a-z0-9-]+$/.test(b.slug) && !slugs.has(b.slug), `slug ${b.slug}`);
++ slugs.add(b.slug);
++ assert.ok(boxes.KIND[b.kind], `${b.slug}: kind`);
++ assert.ok(/^https:\/\//.test(b.url) && b.url.includes(b.host), `${b.slug}: its url is on its host`);
++ assert.ok(b.what && b.what.length < 140, `${b.slug}: what`);
++ b.diets.forEach((d) => assert.ok(boxes.DIETS[d], `${b.slug}: diet ${d}`));
++ const stated = b.price || b.shipping || b.ships_to || b.cancel || b.people.length || b.meals.length || b.cadence.length || b.diets.length || b.notes.length;
++ if (stated) assert.ok(b.sources.length, `${b.slug}: a stated fact has its source page`);
++ b.sources.forEach((s) => assert.ok(/^https:\/\//.test(s), `${b.slug}: source ${s}`));
++ if (b.price) assert.ok(b.price.text && ['serving', 'meal', 'box', 'month', 'delivery', 'day', 'membership'].includes(b.price.unit), `${b.slug}: price`);
++});
++assert.ok(boxes.BOXES.length >= 35, 'every kind has its boxes');
++boxes.KINDS.forEach((k) => assert.ok(boxes.list({ kind: k.kind }).length >= 3, `${k.kind}: at least three`));
++
++// ── Listing and search ──
++assert.ok(boxes.list({ diet: 'keto' }).every((b) => b.diets.includes('keto')));
++assert.deepStrictEqual(boxes.list({ q: 'hellofresh' }).map((b) => b.slug), ['hellofresh']);
++assert.strictEqual(boxes.get('nope'), null);
++assert.strictEqual(boxes.priceLine({ from: 5.99, to: null, unit: 'serving' }), 'from $5.99 a serving');
++assert.strictEqual(boxes.priceLine({ from: 10.39, to: 12.69, unit: 'meal' }), '$10.39–$12.69 a meal');
++assert.strictEqual(boxes.priceLine({ from: null, to: 5, unit: 'meal' }), 'under $5 a meal');
++
++// ── Cost: only from what the box states ──
++const ep = boxes.get('everyplate');
++const c = boxes.cost(ep, { people: 2, meals: 3 });
++assert.deepStrictEqual([c.servings, c.food, c.shipping, c.total], [6, { from: 35.94, to: 35.94 }, { from: 10.99, to: 10.99 }, { from: 46.93, to: 46.93 }]);
++assert.ok(c.basis.includes('Plans from $5.99 a serving') && c.basis.includes(boxes.CHECKED), 'it says what it rests on');
++assert.strictEqual(boxes.cost(ep, { people: 3, meals: 3 }), null, 'a plan size EveryPlate does not state');
++const cu = boxes.cost(boxes.get('cookunity'), { meals: 6 });
++assert.deepStrictEqual([cu.servings, cu.food, cu.shipping], [6, { from: 62.34, to: 76.14 }, { from: 9.99, to: 11.99 }], 'a ready meal counts meals; a range stays a range');
++assert.strictEqual(boxes.cost(boxes.get('butcherbox'), {}), null, 'a price per box is not per serving');
++assert.strictEqual(boxes.cost(boxes.get('dinnerly'), {}), null, 'nothing stated, nothing worked out');
++assert.strictEqual(boxes.cost(ep, { people: 2, meals: 3, weeks: 99 }), null);
++
++// ── The wire shape ──
++const w = boxes.toWire(ep);
++assert.strictEqual(w.price.line, 'from $5.99 a serving');
++assert.strictEqual(w.checked, boxes.CHECKED);
++assert.strictEqual(boxes.toWire(boxes.get('dinnerly')).checked, null, 'nothing checked: no date');
++assert.ok(w.links.coupons.includes('everyplate.com') && w.links.reviews.includes('EveryPlate'));
++
++(async () => {
++ // ── The joins: off without addresses; read, cached and given up on cleanly when set ──
++ const off = createBoxLinks({});
++ assert.deepStrictEqual(await off.forBox(ep), { coupons: null, reviews: null });
++ const calls = [];
++ const fake = async (url, init = {}) => {
++ calls.push(url);
++ const ok = (body) => ({ ok: true, status: 200, json: async () => body });
++ if (url.includes('/merchants/resolve')) return ok({ merchant: { id: 'mer_1', url: 'https://openvibe.coupons/m/everyplate' } });
++ if (url.includes('/merchants/mer_1/coupons')) return ok({ coupons: [{ code: 'A1', title: '10% off', status: 'unknown' }, { code: 'PLATE70', title: '70% off your first box', status: 'reported_working', confidence: 0.8 }] });
++ if (url.endsWith('/api/v1/resolve')) { assert.strictEqual(init.method, 'POST'); return ok({ entity: { slug: 'everyplate', name: 'EveryPlate', url: 'https://openvibe.reviews/e/everyplate' } }); }
++ if (url.includes('/entities/everyplate/aggregate')) return ok({ aggregate: { result: { components: { recommendation: { percent: 81.2, total: 48 } } } } });
++ return { ok: false, status: 404, json: async () => ({}) };
++ };
++ const links = createBoxLinks({ couponsUrl: 'http://coupons.test', reviewsUrl: 'http://reviews.test', fetch: fake });
++ const got = await links.forBox(ep);
++ assert.deepStrictEqual(got.coupons, { url: 'https://openvibe.coupons/m/everyplate', active: 2, best: { code: 'PLATE70', title: '70% off your first box' } });
++ assert.deepStrictEqual(got.reviews, { url: 'https://openvibe.reviews/e/everyplate', name: 'EveryPlate', percent: 81.2, total: 48, line: '81.2% recommend (48)' });
++ const n = calls.length;
++ await links.forBox(ep);
++ assert.strictEqual(calls.length, n, 'kept ten minutes');
++ const down = createBoxLinks({ couponsUrl: 'http://c', reviewsUrl: 'http://r', fetch: async () => { throw new Error('down'); } });
++ assert.deepStrictEqual(await down.forBox(ep), { coupons: null, reviews: null }, 'a neighbour that is down: the box shows without it');
++ const none = createBoxLinks({ couponsUrl: 'http://c', reviewsUrl: 'http://r', fetch: async () => ({ ok: false, status: 404, json: async () => ({}) }) });
++ assert.deepStrictEqual(await none.forBox(ep), { coupons: null, reviews: null }, 'not listed: null, not an error');
++ console.log('boxes: data, listing, cost, wire shape and the Coupons/Reviews joins ok');
++})().catch((e) => { console.error(e); process.exit(1); });