openvibers/openvibe.coupons Commits

The store page: the shop's head, the best code first, the codes as rows (#158)

OP OpenVibers committed 13 days ago · 40022d1840 · 13 files, +596 −63

The store page (/m/<slug>) is rebuilt around what people reported:

- the head carries the shop's mark and name, its domain and one muted line
  ("5 codes - 45 reports this week");
- the best code comes first, in a dashed box with a Copy button, its rate
  ("87% worked (23 reports)"), when it was last reported, its expiry with the
  precision it was stated with, and the two report buttons ("It worked" and
  "It didn't work") as plain forms to the report route;
- the other codes follow as compact rows with the same facts and the same
  buttons, and the codes that ended fold under "Expired";
- the tabs read "Codes (n) - Product deals (n) - About"; a shop without codes
  says so and offers the watch instead of a "Codes 0" tab;
- a slim bar with the best code and a Copy button appears once the best code
  leaves the screen (an IntersectionObserver; without JavaScript it is not
  there);
- the About tab carries the quick facts we back: a code's origin with its
  evidence URL, its restrictions, its stated expiry, and a neighbour's latest
  price observation with its date.

A code's status comes only from people's reports: under five reports the page
says "Validity unknown (2 reports)" and never a confidence number, and the
page never says "verified".

Checked with the suite (npm test), the new test/store-page.test.js (the best
code, the wording at 0, 4, 5 and 23 reports, the 30-day rule, the empty shop,
the report form posting, no "verified") and the size budget (cssRawKB 78,
cssBrotliKB 16, with the measured sizes and today's date).

Changes

ddiff --git a/public/css/coupons.css b/public/css/coupons.css
iindex 99b5173..2452073 100644
---- a/public/css/coupons.css
++++ b/public/css/coupons.css
@@@ -151,6 +151,54 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
.cpn-lede { padding: 0; margin: 0 0 8px; }
.ovk-icon-btn--on { color: var(--accent-light, #60a5fa); }
++/* The store page (the owner's rebuild, 2026-10-31): the shop's mark and name in the head, the best code in a
++ dashed box, the other codes as compact rows, an empty state that says so, and a slim bar that keeps the best
++ code one tap away while the page is scrolled. Theme tokens only. */
++.cpn-shop-title { display: inline-flex; align-items: center; gap: 10px; }
++.cpn-shop-name { overflow-wrap: anywhere; }
++.cpn-muted { color: var(--text-muted, #7386a3); }
++.cpn-counts { display: block; }
++main:has(.cpn-shop-title) .ovk-sub { white-space: normal; }
++.cpn-best-card { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 14px; margin: 12px 0; background: var(--bg-card, #131c2e); }
++.cpn-best-label { margin: 0; font-size: .76rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--success-text, var(--success, #22c55e)); }
++.cpn-best-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; margin: 2px 0 10px; }
++.cpn-best-head .cpn-off { font-size: 1.4rem; font-weight: 800; letter-spacing: -.01em; }
++.cpn-best-title { color: var(--text-secondary, #96a7c2); font-size: .95rem; }
++.cpn-codebox { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; border: 2px dashed var(--accent, #3b82f6); border-radius: 12px; background: var(--bg-secondary, #0f1729); }
++.cpn-codetext { font: 700 1.6rem/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .06em; word-break: break-all; user-select: all; -webkit-user-select: all; }
++.cpn-status-line { margin: 10px 0 0; font-weight: 600; }
++.cpn-meta { margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: .88rem; }
++.cpn-limits { color: var(--text-muted, #7386a3); }
++.cpn-votes { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
++.cpn-vote-form { display: inline-flex; }
++.cpn-vote { padding: 6px 14px; border-radius: 999px; min-height: 36px; font-size: .9rem; }
++.cpn-list .ovk-row { align-items: flex-start; }
++.cpn-list .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; }
++.cpn-list .ovk-row-top strong { white-space: normal; overflow: visible; }
++.cpn-row-code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .04em; }
++.cpn-row-off { color: var(--text-secondary, #96a7c2); font-weight: 400; }
++.cpn-row-facts, .cpn-row-limits { display: block; }
++.cpn-row-limits { color: var(--text-muted, #7386a3); font-size: .84rem; }
++.cpn-submit { margin: 14px 0; }
++.cpn-empty { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 18px; margin: 12px 0; background: var(--bg-card, #131c2e); }
++.cpn-empty-line { font-size: 1.2rem; font-weight: 700; margin: 0 0 6px; }
++.cpn-fact { border-top: 1px solid var(--border, #1f2d47); padding-top: 10px; margin-top: 10px; }
++.cpn-fact h3 { margin: 0 0 4px; font-size: .98rem; }
++.cpn-fact-list { margin: 0; padding-left: 1.1rem; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
++.cpn-sticky { position: fixed; left: 0; right: 0; bottom: var(--ovk-bottom, 0px); z-index: 40; display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-top: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); box-shadow: 0 -6px 18px color-mix(in srgb, var(--border, #1f2d47) 70%, transparent); }
++.cpn-sticky[hidden] { display: none; }
++.cpn-sticky-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted, #7386a3); }
++.cpn-sticky-code { font: 700 1.05rem/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; margin-right: auto; overflow-wrap: anywhere; }
++@media (max-width: 600px) {
++ .cpn-codebox { padding: 10px 12px; }
++ .cpn-codetext { font-size: 1.35rem; }
++ /* A code's row: the code and its facts on their own lines, the buttons under them. */
++ .cpn-list .ovk-row--static { flex-wrap: wrap; align-items: flex-start; }
++ .cpn-list .ovk-row--static .ovk-row-main { flex: 1 1 100%; }
++ .cpn-list .ovk-row--static .ovk-row-trail { width: 100%; margin-left: 0; justify-content: flex-end; }
++ .cpn-vote { min-height: 40px; }
++}
++
/* Success over time (render/pages.js reportTrend, merchantTrend): the shared kit draws the chart; these rules place
it and the one-line people count. Theme tokens only. */
.cpn-trend { margin: 22px 0 8px; }
ddiff --git a/server/app.js b/server/app.js
iindex b219923..88894e3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -30,6 +30,7 @@ const { createCoupons } = require('./domain/coupons');
const { createReports } = require('./domain/reports');
const { createInstalls } = require('./domain/installs');
const { createWatches } = require('./domain/watches');
++const { createFacts } = require('./domain/facts');
const { createModeration } = require('./domain/moderation');
const { createSourcesImporter } = require('./clients/sources');
const { createApi } = require('./http/api');
@@@ -84,6 +85,8 @@ async function createApp(opts = {}) {
const reports = createReports({ store, config, coupons, merchants, publication });
const installs = createInstalls({ store, config });
const watches = createWatches({ store });
++ // A neighbour's facts about a shop (the latest price we observed for one of its products).
++ const facts = createFacts({ config, log });
const moderation = createModeration({ store, merchants, coupons, outbox });
const importer = createSourcesImporter({ store, config, merchants, coupons, fetchImpl, log });
// Offline session verification and the service-token key share the SDK's process-wide JWKS client.
@@@ -107,7 +110,7 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, jwks, config, installs });
const worker = createWorker({ config, coupons, importer, outbox, log });
-- const ctx = { config, store, accountData, accountSend, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow };
++ const ctx = { config, store, accountData, accountSend, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow, facts };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/config.js b/server/config.js
iindex f7f4fa7..f7ab7df 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -108,6 +108,11 @@ function load(env = process.env) {
intervalMs: int(env.COUPONS_SWEEP_INTERVAL_MS, 60 * 1000),
},
++ // Our neighbours' public facts about a shop (OpenVibe.Deals: the latest price we observed for
++ // one of its products). The store page shows such a fact with the date it was observed; a neighbour
++ // that is down or slow never delays the page.
++ dealsUrl: trim(env.COUPONS_DEALS_API_URL || 'https://openvibe.deals'),
++
// IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment it
// appears, changes or leaves the index. Unset: IndexNow is off — no key file, no requests.
indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
ddiff --git a/server/domain/coupons.js b/server/domain/coupons.js
iindex c998f8f..d1f51b3 100644
---- a/server/domain/coupons.js
++++ b/server/domain/coupons.js
@@@ -199,6 +199,10 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
COUNT(*) FILTER (WHERE c.status = 'reported_working') AS working
FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL}`),
reportsSince: db.prepare('SELECT COUNT(*) AS n FROM coupon_validation_reports WHERE updated_at > ?'),
++ // A shop's reports since a moment: one per person and code, the same unit the code's own counts use
++ // (each person's most recent report on a code counts once).
++ merchantReports: db.prepare(`SELECT COUNT(*) AS n FROM (SELECT DISTINCT r.coupon_id, r.reporter_key FROM coupon_validation_reports r
++ JOIN coupons c ON c.id = r.coupon_id WHERE c.merchant_id = @merchant AND r.updated_at > @since) x`),
allActive: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL} ORDER BY c.created_at DESC LIMIT 50000`),
activePage: db.prepare(`SELECT c.* FROM coupons c JOIN coupon_merchants m ON m.id = c.merchant_id WHERE ${ACTIVE_SQL} ORDER BY c.id LIMIT @limit OFFSET @offset`),
allPage: db.prepare('SELECT c.* FROM coupons c ORDER BY c.id LIMIT @limit OFFSET @offset'),
@@@ -633,6 +637,8 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
return out;
},
merchantHints: async (merchantId) => await q.merchantHints.all(merchantId),
++ /** How many reports a shop's codes collected since a moment (one per person and code). */
++ merchantReportsSince: async (merchantId, sinceMs) => Number((await q.merchantReports.get({ merchant: merchantId, since: sinceMs })).n) || 0,
};
}
ddiff --git a/server/domain/facts.js b/server/domain/facts.js
nnew file mode 100644
iindex 0000000..9a98f75
---- /dev/null
++++ b/server/domain/facts.js
@@@ -0,0 +1,60 @@
++'use strict';
++
++/**
++ * A fact about a shop that lives in a neighbour of ours: the latest price we observed for one of its
++ * products (OpenVibe.Deals). Every value is one the neighbour reported, with the date it was observed, and
++ * a neighbour that is slow or down never delays the page: the fact is skipped and the page says nothing
++ * about it. Nothing here is counted or estimated.
++ */
++const TTL_MS = 15 * 60 * 1000; // how long a fact is reused
++const TIMEOUT_MS = 1200; // a neighbour that takes longer than this is treated as down
++
++function createFacts({ config, log }) {
++ const cache = new Map();
++
++ /** The newest price observation a neighbour holds for this shop, or null when it holds none. */
++ async function latestPrice(host) {
++ const key = String(host || '').trim().toLowerCase();
++ if (!key || !config.dealsUrl || config.nodeEnv === 'test') return null;
++ const hit = cache.get(key);
++ if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
++ const value = await fetchLatestPrice(key);
++ cache.set(key, { at: Date.now(), value });
++ return value;
++ }
++
++ async function fetchLatestPrice(host) {
++ try {
++ const res = await fetch(`${config.dealsUrl}/api/v1/stores/${encodeURIComponent(host)}`, {
++ headers: { accept: 'application/json' },
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ if (!res.ok) return null;
++ const body = await res.json();
++ const offers = Array.isArray(body && body.offers) ? body.offers : [];
++ let best = null;
++ for (const offer of offers) {
++ const obs = offer && offer.latest_observation;
++ if (!obs || obs.price == null || !obs.observed_at) continue;
++ const at = Date.parse(obs.observed_at);
++ if (!Number.isFinite(at)) continue;
++ if (!best || at > best.at) {
++ best = {
++ at,
++ price: String(obs.price),
++ currency: obs.currency || null,
++ name: (offer.product && offer.product.name) || offer.title || null,
++ };
++ }
++ }
++ return best ? { price: best.price, currency: best.currency, name: best.name, observed_at: new Date(best.at).toISOString() } : null;
++ } catch (err) {
++ if (log && log.warn) log.warn({ err }, 'the shop facts lookup failed; the page carries no price');
++ return null;
++ }
++ }
++
++ return { latestPrice };
++}
++
++module.exports = { createFacts };
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 0638e0d..f9c3da1 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -27,6 +27,7 @@ const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
const { renderPage, oneGraph } = require('../render/layout');
const pages = require('../render/pages');
++const shop = require('../render/store');
const offer = require('../render/offer');
const home = require('../render/home');
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
@@@ -264,23 +265,34 @@ function createPublicRoutes(ctx) {
router.get('/m/:slug', async (req, res) => {
const m = await visibleMerchant(req, res);
if (!m) return;
++ const now = store.now();
const active = await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));
-- // One tab at a time (?tab=codes|deals|about): the recently ended codes are only read for Codes, where they fold.
++ // The codes for the whole shop and the codes for one product are two tabs, and one tab opens at a time
++ // (?tab=codes|deals|about). The recently ended codes are only read for Codes, where they fold.
++ const codes = active.filter((v) => !pages.isProductDeal(v));
++ const deals = active.filter(pages.isProductDeal);
const tab = pages.shopTab(req.query, active);
const ended = tab === 'codes' ? await Promise.all((await coupons.recentlyEnded(m.id)).map(async (c) => await coupons.view(c, { merchant: m }))) : [];
const decision = publication.merchantDecision(m, active.length);
const flashMsg = FLASH[req.query.done] || null;
-- // The success chart summarises every code, so it sits on the Codes and Product deals tabs (not About).
++ // The success chart summarises every code of the shop: it sits under the codes and the product deals.
const success = tab === 'about' ? null : await coupons.merchantWeeks(m.id);
++ const domains = await merchants.domains(m);
++ // What people reported about this shop's codes this week (one report per person and code).
++ const reportsThisWeek = await coupons.merchantReportsSince(m.id, now - 7 * DAY);
++ // A neighbour's fact about the shop (the latest price we observed for one of its products), when it has one.
++ const price = tab === 'about' ? await ctx.facts.latestPrice(domains[0] && domains[0].host) : null;
send(req, res, 200, {
title: `${m.name} coupon codes`,
description: active.length ? `${active.length} active code${active.length === 1 ? '' : 's'} for ${m.name}, with restrictions, expiry and people's reports.` : `No active codes for ${m.name} right now.`,
decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),
feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],
chart: true,
-- body: pages.merchant({
-- m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended, success,
-- viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}${tab === 'codes' ? '' : `?tab=${tab}`}`, flashMsg, tab,
++ scripts: tab === 'codes' ? [shop.STICKY_SCRIPT] : [],
++ body: shop.storePage({
++ m, domains, hints: await coupons.merchantHints(m.id), codes, deals, ended, reportsThisWeek, price,
++ now, windowDays: confidence.WINDOW_DAYS,
++ viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}${tab === 'codes' ? '' : `?tab=${tab}`}`, flashMsg, tab, success,
watching: await watches.isWatching(req.viewer.subject, m),
}),
}, { cacheable: !flashMsg });
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex b71ada0..c77c8f9 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -106,7 +106,7 @@ function renderPage(o) {
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
summary: o.summary,
styles: [...(o.styles || []), 'kit.css', ...(o.chart ? ['kit-data.css'] : [])], // shared style names; kit-data.css is the chart's look
-- scripts: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`, ...(o.chart ? [`<script src="${ovServe.url('kit-data-web.js')}" defer></script>`] : [])], // the kit's behaviour; the page works without it
++ scripts: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`, ...(o.chart ? [`<script src="${ovServe.url('kit-data-web.js')}" defer></script>`] : []), ...(o.scripts || [])], // the kit's behaviour; the page works without it
facts: o.facts,
updated: o.updated,
url: o.url,
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex a63f9c8..ac3d7b9 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -245,55 +245,6 @@ function shopTab(query, active) {
return asked || (codes || !active.length ? 'codes' : 'deals');
}
--/**
-- * A shop (GET /m/:slug): the head (the shop, its codes, Watch, Submit and more as icons), the best code right now, and
-- * one tab at a time. ended is only read for the Codes tab (the route passes [] otherwise).
-- */
--function merchant({ m, domains, hints, active, ended, success = null, viewer, csrf, watching, back, flashMsg, tab = 'codes' }) {
-- const codes = active.filter((v) => !isProductDeal(v));
-- const deals = active.filter(isProductDeal);
-- const shopUrl = m.homepage_url || null;
-- const base = `/m/${m.slug}`;
-- const tabs = kit.tabs({ base, active: tab, label: `${m.name} sections`, items: [
-- { key: 'codes', label: 'Codes', count: codes.length },
-- ...(deals.length ? [{ key: 'deals', label: 'Product deals', count: deals.length }] : []),
-- { key: 'about', label: 'About' },
-- ] });
-- const watchForm = viewer && viewer.kind === 'user'
-- ? String(h`<form method="post" action="/m/${m.slug}/watch" class="ovk-inline">${hidden(csrf)}<input type="hidden" name="action" value="${watching ? 'unwatch' : 'watch'}">${k(kit.iconButton({ icon: 'bell', label: watching ? 'Stop watching this shop (while you watch, new codes reach your OpenVibe notifications)' : 'Watch this shop (private to you; new codes reach your OpenVibe notifications)', kind: watching ? 'on' : '' }))}</form>`)
-- : '';
-- const head = kit.head({
-- title: `${m.name} coupon codes`,
-- subHtml: String(h`${shopUrl ? h`<a href="${shopUrl}" rel="nofollow noopener">${hostOf(shopUrl)}</a> · ` : ''}${String(active.length)} active code${active.length === 1 ? '' : 's'}`),
-- back: { href: '/', label: 'All shops' },
-- actions: [watchForm, kit.iconButton({ href: `/submit?merchant=${encodeURIComponent(m.slug)}`, icon: 'plus', label: `Submit a code for ${m.name}` }),
-- kit.more({ label: 'More', items: [{ href: '/about', label: 'How codes are labelled', icon: 'help' }, { href: `${base}/feed.xml`, label: 'RSS feed', icon: 'link' }, { href: `${base}.json`, label: 'As JSON', icon: 'code' }] })],
-- tabs,
-- });
-- const best = codes.filter((v) => v.status === 'reported_working' && v.confidence != null).sort((a, b) => b.confidence - a.confidence)[0];
-- const card = (v) => couponCard(v, { viewer, csrf, back, link: true, shopUrl });
-- let body;
-- if (tab === 'about') {
-- body = h`<section class="ovk-section"><h2>The shop</h2><p class="ovk-note">${shopUrl ? h`<a href="${shopUrl}" rel="nofollow noopener">${hostOf(shopUrl)}</a>. ` : ''}Covers ${domains.map((d, i) => h`${i ? '; ' : ''}${d.host}${d.path_prefix || ''}${d.include_subdomains ? ' and its subdomains' : ''}`)}.</p>
--${m.description ? h`<p class="ovk-note">${m.description}</p>` : ''}
--${hints.length ? h`<ul class="hints ovk-note">${hints.map((x) => h`<li>How to apply codes here: ${x.text}</li>`)}</ul>` : ''}</section>
--<section class="ovk-section"><h2>How codes are labelled</h2><p class="ovk-note">A code is only called working when people reported recently that it worked; nobody else can set that, not the submitter, staff, a source or an AI model. An expiry is shown only when someone stated it, and restrictions only when they were stated. <a href="/about">The whole method</a>.</p></section>
--<p class="meta">Follow: <a href="${base}/feed.xml">RSS</a> · <a href="${base}.json">JSON</a></p>`;
-- } else if (tab === 'deals') {
-- body = h`<p class="ovk-note cpn-lede">Codes that take money off one product, as the source posted them.</p>
--${deals.length ? deals.map(card) : h`<p class="empty">No product deals for ${m.name} right now.</p>`}`;
-- } else {
-- body = h`${best ? h`<a class="cpn-best" href="#${best.id}"><span>Best code now</span><strong>${headline(best)}</strong><code>${best.code}</code><span class="meta">${`Confidence ${Math.round(best.confidence * 100)}%`}</span></a>` : ''}
--${codes.length ? codes.map(card) : active.length ? h`<p class="empty">No code for the whole shop right now; ${String(deals.length)} product deal${deals.length === 1 ? '' : 's'} <a href="${base}?tab=deals">in Product deals</a>.</p>` : h`<p class="empty">No active codes for ${m.name} right now.</p>`}
--<p>${k(kit.button({ href: `/submit?merchant=${encodeURIComponent(m.slug)}`, label: `Submit a code for ${m.name}`, icon: 'plus', kind: 'quiet', small: true }))}</p>
--${ended.length ? k(kit.fold({ title: 'Recently ended (not in active results)', meta: String(ended.length), body: String(h`<ul class="ended">${ended.map((v) => h`<li><code>${v.code}</code> — ${v.title} · ${v.status === 'expired' ? 'expired' : 'past its expiry'} ${v.expired_at ? v.expired_at.slice(0, 10) : v.expiry.expires_at ? v.expiry.expires_at.slice(0, 10) : ''}</li>`)}</ul>`) })) : ''}`;
-- }
-- return h`${k(head)}
--${flash(flashMsg)}
--${tab === 'about' ? '' : merchantTrend(success, m.name)}
--${body}`;
--}
--
/** A week's Monday, for the buckets the API leaves null when fewer than 3 people reported. */
const DAY = 86400000;
const weekAt = (trend, i) => new Date(Date.parse(`${trend.from}T00:00:00Z`) + i * 7 * DAY).toISOString().slice(0, 10);
@@@ -520,4 +471,5 @@ function message({ heading, text, action }) {
}
module.exports = {
-- shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
++ shopTab, isProductDeal, headline, moneyOf, symbolOf, currencyOf, home, shopsSection, merchantTrend, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY,
++ regionName, BASIS_LABEL };
ddiff --git a/server/render/store.js b/server/render/store.js
nnew file mode 100644
iindex 0000000..9cb2582
---- /dev/null
++++ b/server/render/store.js
@@@ -0,0 +1,293 @@
++'use strict';
++
++/**
++ * The store page (/m/<slug>): the shop's head (its mark, its name, its domain, one muted line), the best code
++ * first, the other codes as compact rows, the tabs, the slim sticky bar and the About tab's quick facts.
++ *
++ * The rules this page keeps (the research outcomes of 2026-10-30):
++ * - a code's status comes only from people's reports: "87% worked (23 reports)", and under five reports the
++ * page says only "Validity unknown (2 reports)";
++ * - a code whose last report is older than the window says "No reports in the last 30 days" and sorts last;
++ * - the page never says "verified" and never shows a share of people that no report produced;
++ * - a shop with no codes says so; a tab never reads "Codes 0".
++ *
++ * Everything is in the HTML: the codes are selectable text, the report buttons are plain forms to the report
++ * route, and the page works without JavaScript (the sticky bar is then simply not there).
++ */
++const ssr = require('openvibe-publishing/ssr');
++const kit = require('openvibe-shared/kit');
++
++const { html: h, raw } = ssr;
++const k = (markup) => raw(String(markup));
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++const { isProductDeal, headline, shopTab, merchantTrend, couponCard, regionName, BASIS_LABEL } = require('./pages');
++
++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
++/** Below this many reports a code's rate is not shown at all: the page says only that validity is unknown. */
++const MIN_REPORTS = 5;
++const DAY = 86400000;
++
++const ORIGIN = {
++ member: 'submitted by a member',
++ staff: 'set by staff',
++ source: 'imported from a source',
++ ai: 'found by an AI workflow and reviewed by staff',
++};
++
++/** Who said what about a code in the last 30 days: how many said it worked, how many said it did not. */
++function tally(v) {
++ const worked = Number(v.reports && v.reports.worked) || 0;
++ const failed = Number(v.reports && v.reports.failed) || 0;
++ return { worked, failed, total: worked + failed };
++}
++
++/** The share of people whose report said it worked — only with enough reports for the share to mean something. */
++function workedShare(v) {
++ const t = tally(v);
++ return t.total >= MIN_REPORTS ? t.worked / t.total : null;
++}
++
++/** The status line: "87% worked (23 reports)", or, under five reports, only that the validity is unknown. */
++function statusText(v) {
++ const t = tally(v);
++ if (t.total >= MIN_REPORTS) return `${Math.round((t.worked / t.total) * 100)}% worked (${t.total} report${t.total === 1 ? '' : 's'})`;
++ if (t.total > 0) return `Validity unknown (${t.total} report${t.total === 1 ? '' : 's'})`;
++ return 'Validity unknown';
++}
++
++/** When the last report came, in this site's own words: "Last report 30 Oct around 14:00 UTC". */
++function lastReport(v, now, windowDays) {
++ const at = v.reports && v.reports.last_report_at;
++ const t = at ? Date.parse(at) : NaN;
++ if (!Number.isFinite(t)) return { text: `No reports in the last ${windowDays} days`, iso: null, recent: false, ever: false };
++ if (now - t > windowDays * DAY) return { text: `No reports in the last ${windowDays} days`, iso: at, recent: false, ever: true };
++ const d = new Date(t);
++ const year = d.getUTCFullYear() === new Date(now).getUTCFullYear() ? '' : ` ${d.getUTCFullYear()}`;
++ const hh = String(d.getUTCHours()).padStart(2, '0');
++ return { text: `Last report ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}${year} around ${hh}:00 UTC`, iso: at, recent: true, ever: true };
++}
++
++/** The expiry as it was stated: a day (end of that day in UTC) or a moment. */
++function expiryOf(v) {
++ const e = v.expiry || {};
++ if (!e.known || !e.expires_at) return null;
++ const iso = String(e.expires_at);
++ const d = new Date(iso);
++ if (!Number.isFinite(d.getTime())) return null;
++ const instant = e.precision === 'instant';
++ const day = `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
++ return {
++ instant,
++ short: instant ? `Expires ${day} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')} UTC` : `Expires ${day} (end of day, UTC)`,
++ full: instant ? `${iso.slice(0, 16).replace('T', ' ')} UTC` : `${iso.slice(0, 10)} (end of that day in UTC; the merchant's time zone is not stated)`,
++ basis: e.basis || null,
++ said: BASIS_LABEL[e.basis] || null,
++ };
++}
++
++/** The stated restrictions as words (none stated is none stated, never "no restrictions"). */
++function restrictionText(list) {
++ return (list || []).map((r) => {
++ if (r.kind === 'min_spend') return `Minimum spend ${r.amount}`;
++ if (r.kind === 'category') return `Category: ${r.value}`;
++ if (r.kind === 'new_customers_only') return 'New customers only';
++ if (r.kind === 'region') return `Region: ${regionName(r.value)}`;
++ return r.value;
++ });
++}
++
++function recency(v) {
++ const t = v.reports && v.reports.last_report_at ? Date.parse(v.reports.last_report_at) : NaN;
++ return Number.isFinite(t) ? t : 0;
++}
++
++/**
++ * The order the codes are read in: the one with the most evidence of working first. A code needs five
++ * reports before its rate counts; then the higher share of people who said it worked wins, and then the more
++ * recent report. A code nobody reported recently can never come first.
++ */
++function byEvidence(a, b) {
++ const ta = tally(a), tb = tally(b);
++ const sa = workedShare(a), sb = workedShare(b);
++ if (sa != null && sb != null) return sa === sb ? recency(b) - recency(a) : sb - sa;
++ if (sa != null) return -1;
++ if (sb != null) return 1;
++ if (ta.total !== tb.total) return tb.total - ta.total;
++ return recency(b) - recency(a);
++}
++
++function hostOf(u) { try { return new URL(u).hostname; } catch { return u; } }
++function hidden(csrf) { return csrf ? h`<input type="hidden" name="csrf" value="${csrf}">` : ''; }
++
++/** The Copy button: it works only where the clipboard works (the kit shows it then), and copies the code. */
++function copyButton(id, { small = false, kind = '' } = {}) {
++ return kit.button({ type: 'button', label: 'Copy', icon: 'copy', small, kind, attrs: { 'data-ovk-copy': `#${id}`, hidden: true } });
++}
++
++/** The two report buttons: plain forms to the report route, so they work without JavaScript. */
++function reportForms(v, { csrf, back }) {
++ const token = csrf ? `<input type="hidden" name="csrf" value="${esc(csrf)}">` : '';
++ const one = (outcome, label) => `<form method="post" action="/c/${v.id}/report" class="ovk-inline cpn-vote-form">${token}<input type="hidden" name="outcome" value="${outcome}"><input type="hidden" name="back" value="${esc(back)}"><button type="submit" class="cpn-vote">${label}</button></form>`;
++ return raw(one('worked', 'It worked') + one('failed', 'It didn\'t work'));
++}
++
++/** The best code, first on the page: the code in a dashed box, its rate, when it was last reported and its expiry. */
++function bestCard(v, { now, windowDays, csrf, back }) {
++ const lr = lastReport(v, now, windowDays);
++ const ex = expiryOf(v);
++ const limits = restrictionText(v.restrictions);
++ const codeId = v.id;
++ return h`<section class="cpn-best-card" id="cpn-best-card" aria-labelledby="cpn-best-h">
++<h2 class="cpn-best-label" id="cpn-best-h">Best code right now</h2>
++<p class="cpn-best-head"><strong class="cpn-off">${headline(v)}</strong> <a class="cpn-best-title" href="/c/${v.id}">${v.title}</a></p>
++<div class="cpn-codebox"><code class="cpn-codetext" id="${codeId}">${v.code}</code>${k(copyButton(codeId))}</div>
++<p class="cpn-status-line">${statusText(v)}</p>
++<p class="cpn-meta">${lr.iso ? h`<time datetime="${lr.iso}">${lr.text}</time>` : lr.text}</p>
++<p class="cpn-meta">${ex ? h`${ex.short}${ex.said ? h` (${ex.said})` : ''}` : h`<span class="unknown">Expiry unknown</span>`}</p>
++${limits.length ? h`<p class="cpn-meta cpn-limits">${limits.join(' · ')}</p>` : h`<p class="cpn-meta cpn-limits">No restrictions stated. That does not mean there are none.</p>`}
++<div class="cpn-votes">${reportForms(v, { csrf, back })}</div>
++</section>`;
++}
++
++/** One code as a compact row: the code, the same facts and the same buttons. */
++function codeRow(v, { now, windowDays, csrf, back }) {
++ const lr = lastReport(v, now, windowDays);
++ const ex = expiryOf(v);
++ const facts = [statusText(v), lr.text, ex ? (ex.said ? `${ex.short} (${ex.said})` : ex.short) : 'Expiry unknown'];
++ const limits = restrictionText(v.restrictions);
++ const codeId = v.id;
++ return kit.row({
++ titleHtml: `<a class="cpn-row-code" href="/c/${v.id}"><code>${esc(v.code)}</code></a> <span class="cpn-row-off">${esc(headline(v))}</span>`,
++ subHtml: `<span class="cpn-row-facts">${esc(facts.join(' · '))}</span>${limits.length ? `<span class="cpn-row-limits">${esc(limits.join(' · '))}</span>` : ''}`,
++ trail: `${copyButton(codeId, { small: true, kind: 'quiet' })}${reportForms(v, { csrf, back })}`,
++ });
++}
++
++/** A code that ended: what it was and when it ended. */
++function endedRow(v) {
++ const ex = expiryOf(v);
++ const when = v.expired_at ? String(v.expired_at).slice(0, 10) : (ex ? ex.full.split(' ')[0] : '');
++ return kit.row({
++ titleHtml: `<code>${esc(v.code)}</code> <span class="cpn-row-off">${esc(v.title)}</span>`,
++ subHtml: `<span class="cpn-row-facts">${esc([when ? `Ended ${when}` : 'Ended', ex ? ex.short : 'Expiry unknown'].join(' · '))}</span>`,
++ });
++}
++
++/** The About tab: the quick facts this site backs — where a code came from, what it is restricted to, when it ends. */
++function aboutTab({ m, domains, hints, codes, price, now, windowDays }) {
++ const facts = codes.map((v) => {
++ const ex = expiryOf(v);
++ const lr = lastReport(v, now, windowDays);
++ const origin = ORIGIN[v.origin] || 'submitted by a member';
++ const evidence = (v.evidence || []).filter((e) => e.url);
++ const limits = restrictionText(v.restrictions);
++ return h`<div class="cpn-fact">
++<h3><code>${v.code}</code> — ${v.title}</h3>
++<ul class="cpn-fact-list">
++<li>Origin: ${origin}${evidence.length ? h` — <a href="${evidence[0].url}" rel="nofollow ugc noopener">${hostOf(evidence[0].url)}</a>${evidence[0].retrieved_at ? ` (retrieved ${String(evidence[0].retrieved_at).slice(0, 10)})` : ''}` : ' (no link recorded)'}</li>
++${limits.length ? h`<li>Restrictions: ${limits.join('; ')}</li>` : h`<li>Restrictions: no restrictions stated. That does not mean there are none.</li>`}
++<li>Expiry: ${ex ? h`${ex.full}${ex.said ? `, ${ex.said}` : ''}` : h`<span class="unknown">unknown</span> (nobody stated one)`}</li>
++<li>${lr.ever ? lr.text : 'No report yet'}</li>
++</ul>
++</div>`;
++ });
++ return h`<section class="ovk-section">
++<h2>The shop</h2>
++<p class="ovk-note">${m.homepage_url ? h`<a href="${m.homepage_url}" rel="nofollow noopener">${hostOf(m.homepage_url)}</a>. ` : ''}Covers ${domains.map((d, i) => h`${i ? '; ' : ''}${d.host}${d.path_prefix || ''}${d.include_subdomains ? ' and its subdomains' : ''}`)}.</p>
++${m.description ? h`<p class="ovk-note">${m.description}</p>` : ''}
++${hints.length ? h`<ul class="hints ovk-note">${hints.map((x) => h`<li>How to apply codes here: ${x.text}</li>`)}</ul>` : ''}
++${price ? h`<p class="ovk-note">Latest price we observed at this shop: ${price.price}${price.currency ? ` ${price.currency}` : ''}${price.name ? ` for ${price.name}` : ''} (observed ${String(price.observed_at).slice(0, 10)}, OpenVibe.Deals).</p>` : ''}
++</section>
++${facts.length ? h`<section class="ovk-section"><h2>Quick facts</h2>
++<p class="ovk-note">What we can back for each code: where it came from, what it is restricted to and when it ends. A code's validity comes only from people's reports.</p>
++${facts}</section>` : h`<section class="ovk-section"><h2>Quick facts</h2><p class="ovk-note">This shop has no codes right now, so there is nothing to show here yet.</p></section>`}
++<section class="ovk-section"><h2>How codes are labelled</h2><p class="ovk-note">A code is only called working when people reported recently that it worked; nobody else can set that, not the submitter, not staff, not a source, not an AI model. An expiry is shown only when someone stated it, and restrictions only when they were stated. <a href="/about">The whole method</a>.</p></section>
++<p class="meta">Follow: <a href="/m/${m.slug}/feed.xml">RSS</a> · <a href="/m/${m.slug}.json">JSON</a></p>`;
++}
++
++/**
++ * The store page. codes: the shop's active codes (a code for the whole shop), deals: the codes for one product,
++ * ended: the codes that ended, reportsThisWeek: how many reports the shop's codes collected this week.
++ */
++function storePage({ m, domains, hints, codes, deals, ended, reportsThisWeek, price, now, windowDays, viewer, csrf, watching, back, flashMsg, tab = 'codes', success = null }) {
++ const base = `/m/${m.slug}`;
++ const host = (domains[0] && domains[0].host) || (m.homepage_url ? hostOf(m.homepage_url) : m.name);
++ const all = [...codes, ...deals];
++ const best = all.slice().sort(byEvidence)[0] || null;
++ const others = codes.filter((v) => !best || v.id !== best.id).sort(byEvidence);
++ const signedIn = Boolean(viewer && viewer.kind === 'user');
++
++ // The tabs: a count only when there is something to count, so no tab ever reads "Codes 0".
++ const tabs = kit.tabs({ base, active: tab, label: `${m.name} sections`, items: [
++ { key: 'codes', label: 'Codes', count: codes.length ? String(codes.length) : null },
++ { key: 'deals', label: 'Product deals', count: deals.length ? String(deals.length) : null },
++ { key: 'about', label: 'About' },
++ ] });
++
++ const watchForm = signedIn
++ ? String(h`<form method="post" action="${base}/watch" class="ovk-inline">${hidden(csrf)}<input type="hidden" name="action" value="${watching ? 'unwatch' : 'watch'}">${k(kit.iconButton({ icon: 'bell', label: watching ? `Stop watching ${m.name} (while you watch, new codes reach your OpenVibe notifications)` : `Watch ${m.name} (private to you; new codes reach your OpenVibe notifications)`, kind: watching ? 'on' : '' }))}</form>`)
++ : '';
++ const head = kit.head({
++ titleHtml: `<span class="cpn-shop-title">${k(kit.mark({ name: m.name, square: true }))}<span class="cpn-shop-name">${esc(m.name)}</span></span>`,
++ subHtml: `${m.homepage_url ? `<a href="${esc(m.homepage_url)}" rel="nofollow noopener">${esc(host)}</a>` : `<span class="cpn-shop-name">${esc(host)}</span>`}<span class="cpn-muted cpn-counts">${codes.length + deals.length ? `${String(codes.length + deals.length)} code${codes.length + deals.length === 1 ? '' : 's'}${reportsThisWeek ? ` · ${String(reportsThisWeek)} report${reportsThisWeek === 1 ? '' : 's'} this week` : ''}` : 'no codes yet'}</span>`,
++ back: { href: '/', label: 'All shops' },
++ actions: [watchForm, kit.iconButton({ href: `/submit?merchant=${encodeURIComponent(m.slug)}`, icon: 'plus', label: `Submit a code for ${m.name}` }),
++ kit.more({ label: 'More', items: [{ href: '/about', label: 'How codes are labelled', icon: 'help' }, { href: `${base}/feed.xml`, label: 'RSS feed', icon: 'link' }, { href: `${base}.json`, label: 'As JSON', icon: 'code' }] })],
++ tabs,
++ });
++
++ const flashMsgHtml = flashMsg ? h`<p class="notice" role="status">${flashMsg}</p>` : '';
++
++ let body;
++ if (tab === 'about') {
++ body = h`${aboutTab({ m, domains, hints, codes: all, price, now, windowDays })}`;
++ } else if (tab === 'deals') {
++ body = h`<p class="ovk-note cpn-lede">Codes that take money off one product, as the source posted them.</p>
++${deals.length ? h`${deals.map((v) => couponCard(v, { viewer, csrf, back, link: true, shopUrl: m.homepage_url }))}` : h`<p class="empty">No product deals for ${m.name} right now.</p>`}
++${merchantTrend(success, m.name)}`;
++ } else if (!codes.length) {
++ // A shop with no codes says so, and offers the watch that tells us when one appears.
++ body = h`<section class="cpn-empty">
++<p class="cpn-empty-line">No codes for ${host} right now</p>
++<p class="ovk-note">When someone reports a code that works here, it appears on this page — and, if you watch this shop, in your OpenVibe notifications.</p>
++${watchLink({ m, csrf, signedIn, watching, base })}
++${deals.length ? h`<p class="ovk-note">${String(deals.length)} product deal${deals.length === 1 ? '' : 's'} <a href="${base}?tab=deals">in Product deals</a>.</p>` : ''}
++</section>
++${ended.length ? k(kit.fold({ title: 'Expired', meta: String(ended.length), body: String(kit.list(ended.map(endedRow))) })) : ''}`;
++ } else {
++ body = h`${best ? bestCard(best, { now, windowDays, csrf, back }) : ''}
++${others.length ? k(`<div class="cpn-list">${kit.list(others.map((v) => codeRow(v, { now, windowDays, csrf, back })), { title: 'Other codes' })}</div>`) : ''}
++${merchantTrend(success, m.name)}
++<p class="cpn-submit">${k(kit.button({ href: `/submit?merchant=${encodeURIComponent(m.slug)}`, label: `Submit a code for ${m.name}`, icon: 'plus', kind: 'quiet', small: true }))}</p>
++${ended.length ? k(kit.fold({ title: 'Expired', meta: String(ended.length), body: String(kit.list(ended.map(endedRow))) })) : ''}`;
++ }
++
++ // The slim bar: the best code stays one tap away while the page is scrolled. It is there only with
++ // JavaScript (the kit's script shows it when the best code leaves the screen).
++ const sticky = best ? h`<div class="cpn-sticky" id="cpn-sticky" hidden>
++<span class="cpn-sticky-label">Best code</span>
++<code class="cpn-sticky-code">${best.code}</code>
++${k(copyButton(best.id, { small: true }))}
++</div>` : '';
++
++ return h`${k(head)}
++${flashMsgHtml}
++${body}
++${sticky}`;
++}
++
++/**
++ * The few lines that show the slim bar once the best code has left the screen. Without JavaScript the bar
++ * stays hidden: the page itself never depends on it.
++ */
++const STICKY_SCRIPT = '<script>document.addEventListener(' + '"DOMContentLoaded"' + ',function(){var bar=document.getElementById(' + '"cpn-sticky"' + '),card=document.getElementById(' + '"cpn-best-card"' + ');if(!bar||!card||!(' + '"IntersectionObserver" in window))return;new IntersectionObserver(function(entries){for(var i=0;i<entries.length;i++){bar.hidden=entries[i].isIntersecting;}},{threshold:0}).observe(card);});</script>';
++
++/** The watch, as a link or a button: "Tell me when one appears". */
++function watchLink({ m, csrf, signedIn, watching, base }) {
++ if (signedIn && watching) return h`<p class="ovk-note">You are watching this shop: a new code reaches your OpenVibe notifications.</p>`;
++ if (signedIn) return h`<form method="post" action="${base}/watch" class="ovk-inline">${hidden(csrf)}<input type="hidden" name="action" value="watch"><button type="submit" class="ovk-btn ovk-btn--primary">Tell me when one appears</button></form>`;
++ return h`<p class="ovk-note"><a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(base)}">Tell me when one appears</a></p>`;
++}
++
++module.exports = { storePage, STICKY_SCRIPT, statusText, lastReport, expiryOf, byEvidence, workedShare, tally, MIN_REPORTS };
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
iindex d22bc14..7f80bde 100644
---- a/test/lifecycle.test.js
++++ b/test/lifecycle.test.js
@@@ -137,7 +137,7 @@ const DAY = 24 * HOUR;
const html = (await t.get(`/m/${shop.slug}`)).text;
const feed = (await t.get('/feed.xml')).text;
const map = (await t.get('/sitemaps/coupons.xml')).text;
-- return { api: api.includes('FALL20'), page: /id="cpn_[^"]+"[\s\S]*?FALL20/.test(html.split('Recently ended')[0]), feed: feed.includes('FALL20'), sitemap: map.includes(dated.id) };
++ return { api: api.includes('FALL20'), page: /id="cpn_[^"]+"[\s\S]*?FALL20/.test(html.split('Expired')[0]), feed: feed.includes('FALL20'), sitemap: map.includes(dated.id) };
};
t.clock.set(expiresAt - 1);
assert.deepStrictEqual(await listed(), { api: true, page: true, feed: true, sitemap: true });
@@@ -147,7 +147,7 @@ const DAY = 24 * HOUR;
assert.strictEqual(cpage.status, 200, 'still readable');
assert.match(cpage.text, /<meta name="robots" content="noindex, follow">/);
assert.match(cpage.headers.get('x-robots-tag'), /noindex/);
-- assert.match((await t.get(`/m/${shop.slug}`)).text, /Recently ended[\s\S]*FALL20/);
++ assert.match((await t.get(`/m/${shop.slug}`)).text, /Expired[\s\S]*FALL20/);
const rep = await t.get(`/api/v1/coupons/${dated.id}/report`, { as: t.network.userToken(bob), json: { outcome: 'worked' } });
assert.strictEqual(rep.status, 409, 'no reports on a code out of active results');
});
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 47feaee..451b151 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -54,8 +54,9 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);
const after = await t.get(`/m/${shop.slug}?done=reported`, { as: bob });
assert.match(after.text, /your report was recorded/);
-- assert.match(after.text, /Reported working/);
-- assert.match(after.text, /Confidence 67%/);
++ // The store page rates a code by what people reported, never by a confidence number.
++ assert.match(after.text, /Validity unknown \(1 report\)/);
++ assert.doesNotMatch(after.text, /Confidence \d+%/, 'no confidence number on the store page');
const offsite = await t.get(`/c/${codeId}/report`, { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: 'https://evil.example-shop.com/' } });
assert.strictEqual(offsite.headers.get('location'), `/c/${codeId}?done=report_same`, 'no open redirect');
const w = await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'watch' } });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex b1aa838..7991b75 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 334, // 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 327.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 319.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 312.4 openvibe-shared 4.29.2 (2026-10-28): Notifications 2.0's toast layer (held while a menu is open, grouping, priority, System cards) and the menu's results view; 307.6 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 300.1 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 297.3 Shared 4.20.1: the kit's kit-web.js (copy buttons, menus, confirms; 7 KB) and 4.18-4.19's menu and footer; 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 263.4 with shared 4.11.0's seasonal navbar; 254.1 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before the shell's web-runtime.js) // raised 316 -> 325 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
jsBrotliKB: 83, // 82.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 80.9 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 79.3 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 76.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.5 Shared 4.20.1 (kit-web.js); 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 61.7 with shared 4.11.0's seasonal navbar; 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js) // raised 78 -> 80 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
cssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)
-- cssRawKB: 74, // 73.2 with the short home (2026-10-30: the pick block's and the fold's own rules); 70.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 68.6 the home's card row keeps its absolute labels inside (2026-10-29); 68.4 touch targets on phones (2026-10-29); 67.9 the home's phone rows and fold (2026-10-29); 62.7 openvibe-shared 4.26.1 (hero visual); 58.4 with a code's reports over time (2026-10-28: weekly columns, legend, phone labels); 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css) // raised 67 -> 85 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
-- cssBrotliKB: 15.4, // 15.1 with the short home (2026-10-30: the pick block's and the fold's own rules); 14.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 14.1 touch targets on phones (2026-10-29); 12.9 openvibe-shared 4.26.1 (hero visual); 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css) // raised 14 -> 17 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
++ cssRawKB: 78, // 77.4 with the store page's own rules (2026-10-31: the shop's head, the best-code box, the compact code rows, the empty state and the slim bar); 73.2 with the short home (2026-10-30: the pick block's and the fold's own rules); 70.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 68.6 the home's card row keeps its absolute labels inside (2026-10-29); 68.4 touch targets on phones (2026-10-29); 67.9 the home's phone rows and fold (2026-10-29); 62.7 openvibe-shared 4.26.1 (hero visual); 58.4 with a code's reports over time (2026-10-28: weekly columns, legend, phone labels); 55.6 with the OpenVibe Kit's kit.css (Shared 4.20, 2026-10-28: the shop page's head, tabs, folds and menus); 28.2 with Shared 4.13.0 (2026-10-26: account links); 21.8 with the front page's numbers, tickets and questions (2026-10-22); 18.1 before (4.4 before showcase.css) // raised 67 -> 85 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
++ cssBrotliKB: 16, // 15.8 with the store page's own rules (2026-10-31); 15.1 with the short home (2026-10-30: the pick block's and the fold's own rules); 14.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 14.1 touch targets on phones (2026-10-29); 12.9 openvibe-shared 4.26.1 (hero visual); 11.5 with kit.css (Shared 4.20); 6.2 with Shared 4.13.0 (2026-10-26: account links); 5.0 with the front page (2026-10-22); 3.6 before (1.2 before showcase.css) // raised 14 -> 17 for the openvibe-shared Kit 2 pin (kit.css grew) // back to the pre-Kit 2 value: Kit 2 has its own files (kit-data.css) since openvibe-shared v4.37.0, so kit.css weighs what it did
externalFiles: 1, // 0
};
ddiff --git a/test/store-page.test.js b/test/store-page.test.js
nnew file mode 100644
iindex 0000000..7ea8f78
---- /dev/null
++++ b/test/store-page.test.js
@@@ -0,0 +1,153 @@
++'use strict';
++/**
++ * The store page (server/render/store.js): the best code and how it is chosen, the status wording at 0, 4, 5 and
++ * 23 reports, the 30-day rule, a shop with no codes, the report forms, and the words the page never uses.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++/** A code of a shop, submitted the way a member submits one. */
++async function submitCode(t, user, slug, code, title, extra) {
++ const form = Object.assign({ csrf: t.csrf(user), merchant: slug, code, title, expires: '' }, extra || {});
++ const r = await t.get('/submit', { as: user, form });
++ const err = /<div class="errors"[\s\S]*?<\/div>/.exec(r.text);
++ assert.strictEqual(r.status, 201, 'submit ' + code + ': ' + r.status + ' ' + (err ? err[0] : r.text.slice(0, 300)));
++ return (await t.ctx.store.db.prepare('SELECT id FROM coupons WHERE code = ?').get(code)).id;
++}
++
++/** People report on a code: one report each, so each counts as one person. */
++async function report(t, codeId, outcome, times) {
++ for (let i = 0; i < times; i++) {
++ const who = t.network.addUser('reporter-' + i + '-' + Math.random().toString(36).slice(2, 7));
++ const r = await t.get('/c/' + codeId + '/report', { as: who, form: { csrf: t.csrf(who), outcome, back: '/' } });
++ assert.strictEqual(r.status, 303, 'report on ' + codeId + ': ' + r.status + ' ' + r.text.slice(0, 200));
++ }
++}
++
++/** The code the page shows first: the one inside the best-code card. */
++function bestCardCode(html) {
++ const card = html.slice(html.indexOf('id="cpn-best-card"'));
++ const m = /<code class="cpn-codetext" id="[^"]+">([^<]+)</.exec(card);
++ return m ? m[1] : null;
++}
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++
++ await check('the best code is the one with the most evidence of working', async () => {
++ const shop = await t.merchant({ name: 'Every Plate', host: 'everyplate.com' });
++ const best = await submitCode(t, alice, shop.slug, 'SAVE20', '20% off');
++ const runnerUp = await submitCode(t, alice, shop.slug, 'HALFOFF', '15% off');
++ const five = await submitCode(t, alice, shop.slug, 'TENOFF', '10% off');
++ const four = await submitCode(t, alice, shop.slug, 'FOUR', '5% off');
++ const stale = await submitCode(t, alice, shop.slug, 'STALE', 'Old code');
++ await submitCode(t, alice, shop.slug, 'NONE', 'Free shipping');
++ // 20 of 23 people said SAVE20 worked (87%); 8 of 10 said HALFOFF worked (80%).
++ await report(t, best, 'worked', 20);
++ await report(t, best, 'failed', 3);
++ await report(t, runnerUp, 'worked', 8);
++ await report(t, runnerUp, 'failed', 2);
++ await report(t, five, 'worked', 3);
++ await report(t, five, 'failed', 2);
++ await report(t, four, 'worked', 4);
++ await report(t, stale, 'worked', 1);
++
++ const page = await t.get('/m/' + shop.slug);
++ assert.strictEqual(page.status, 200);
++ const html = page.text;
++ assert.strictEqual(bestCardCode(html), 'SAVE20', 'the code with the highest share of people who said it worked');
++ assert.match(html, /87% worked \(23 reports\)/, 'the rate and its sample size');
++ assert.match(html, /80% worked \(10 reports\)/);
++ assert.match(html, /60% worked \(5 reports\)/, 'five reports are enough for a rate');
++ assert.match(html, /Validity unknown \(4 reports\)/, 'under five reports: only that validity is unknown');
++ assert.doesNotMatch(html, /Validity unknown \(0 reports\)/, 'no count is invented');
++
++ // The other codes follow the same order: the most evidence first, the code nobody reported on last.
++ const order = ['HALFOFF', 'TENOFF', 'FOUR', 'STALE', 'NONE'].map((c) => html.indexOf('>' + c + '<'));
++ assert.ok(order.every((i) => i > 0), 'every code is on the page');
++ assert.deepStrictEqual(order.slice().sort((a, b) => a - b), order, 'the codes are ordered by their evidence');
++
++ // The head: the shop, its domain and one muted line.
++ assert.match(html, /Every Plate/);
++ assert.match(html, /everyplate\.com/);
++ assert.match(html, /6 codes · 43 reports this week/, 'the counts of the shop');
++
++ // The report buttons, and the report forms post to the report route.
++ assert.match(html, /It worked/);
++ assert.match(html, /It didn't work/);
++ assert.ok(html.indexOf('<form method="post" action="/c/' + best + '/report"') > 0, 'the report forms post to the report route');
++
++ // Nothing on the page is called verified.
++ assert.doesNotMatch(html, /verified/i, 'the page never says verified');
++ });
++
++ await check('the last report is dated, and a code nobody reported on lately says so and sorts last', async () => {
++ const shop = await t.merchant({ name: 'Late Shop', host: 'late-shop.com' });
++ const stale = await submitCode(t, alice, shop.slug, 'OLDC', 'Old code');
++ await report(t, stale, 'worked', 2);
++ const before = (await t.get('/m/' + shop.slug)).text;
++ assert.match(before, /Last report 22 Sep around 12:00 UTC/, 'the last report, to the hour');
++ assert.doesNotMatch(before, /No reports in the last 30 days/);
++
++ // 31 days later the report is outside the window: the code says so and sorts last.
++ t.clock.advance(31 * 24 * 60 * 60 * 1000);
++ const fresh = await submitCode(t, alice, shop.slug, 'NEWC', 'New code');
++ await report(t, fresh, 'worked', 2);
++ const after = (await t.get('/m/' + shop.slug)).text;
++ assert.match(after, /No reports in the last 30 days/, 'the stale code says it has no recent reports');
++ assert.ok(after.indexOf('>NEWC<') < after.indexOf('>OLDC<'), 'the stale code sorts last');
++ });
++
++ await check('a shop with no codes says so, and never reads "Codes 0"', async () => {
++ const shop = await t.merchant({ name: 'Empty Shop', host: 'empty-shop.com' });
++ const page = await t.get('/m/' + shop.slug);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /No codes for empty-shop\.com right now/);
++ assert.match(page.text, /Tell me when one appears/, 'the watch');
++ assert.doesNotMatch(page.text, /Codes 0/);
++ assert.doesNotMatch(page.text, /Codes \(0\)/);
++ assert.doesNotMatch(page.text, /ovk-tab-n">0</, 'no tab counts nothing');
++ // The watch is the existing coupon_watches flow: a signed-out visitor is sent to sign in first.
++ assert.match(page.text, /\/auth\/login\?next=/, 'the watch asks a visitor to sign in');
++ const member = t.network.addUser('watcher');
++ const signed = await t.get('/m/' + shop.slug, { as: member });
++ assert.ok(signed.text.indexOf('<form method="post" action="/m/' + shop.slug + '/watch"') > 0, 'the watch is a plain form to the watch route: slug=' + shop.slug + ' idx=' + signed.text.indexOf('/watch') + ' ctx=' + signed.text.slice(Math.max(0, signed.text.indexOf('/watch') - 200), signed.text.indexOf('/watch') + 200));
++ assert.doesNotMatch(page.text, /verified/i);
++ });
++
++ await check('the report forms post and the page shows the report that followed', async () => {
++ const shop = await t.merchant({ name: 'Reporting Shop', host: 'reporting-shop.com' });
++ const code = await submitCode(t, alice, shop.slug, 'REPORT1', '10% off');
++ const bob = t.network.addUser('bob');
++ const r = await t.get('/c/' + code + '/report', { as: bob, form: { csrf: t.csrf(bob), outcome: 'worked', back: '/m/' + shop.slug } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(r.headers.get('location'), '/m/' + shop.slug + '?done=reported');
++ const after = await t.get('/m/' + shop.slug + '?done=reported', { as: bob });
++ assert.match(after.text, /your report was recorded/);
++ assert.match(after.text, /Validity unknown \(1 report\)/, 'one report is not a rate yet');
++ // A signed-out visitor cannot report: the form sends them to sign in.
++ const anon = await t.get('/c/' + code + '/report', { form: { outcome: 'worked', back: '/m/' + shop.slug } });
++ assert.strictEqual(anon.status, 303);
++ assert.match(anon.headers.get('location'), /\/auth\/login/);
++ });
++
++ await check('the sticky bar and the tabs are there, and the About tab carries the quick facts', async () => {
++ const shop = await t.merchant({ name: 'Facts Shop', host: 'facts-shop.com' });
++ await submitCode(t, alice, shop.slug, 'FACTS20', '20% off', { expires: '2026-12-31', evidence_url: 'https://everyplate.com/offers' });
++ const page = await t.get('/m/' + shop.slug);
++ assert.match(page.text, /id="cpn-sticky"/, 'the slim bar');
++ assert.match(page.text, /hidden/, 'the bar is hidden until the best code leaves the screen');
++ assert.match(page.text, /IntersectionObserver/, 'the few lines that show it');
++ assert.match(page.text, /Expires 31 Dec 2026 \(end of day, UTC\)/, 'the expiry with its precision');
++ const about = await t.get('/m/' + shop.slug + '?tab=about');
++ assert.strictEqual(about.status, 200);
++ assert.match(about.text, /Quick facts/);
++ assert.match(about.text, /everyplate\.com/, 'the evidence URL');
++ assert.match(about.text, /2026-12-31 \(end of that day in UTC/, 'the expiry in full');
++ assert.doesNotMatch(about.text, /verified/i);
++ });
++
++ await t.close();
++ done();
++})();