openvibers/openvibe.coupons Commits
The store page: the shop's head, the best code first, the codes as rows (#158)
Changes
@@@ -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 placeit and the one-line people count. Theme tokens only. */.cpn-trend { margin: 22px 0 8px; }@@@ -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');@@@ -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(),@@@ -199,6 +199,10 @@ function createCoupons({ store, merchants, publication, outbox = null }) {COUNT(*) FILTER (WHERE c.status = 'reported_working') AS workingFROM 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,};}nnew file mode 100644@@@ -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 };@@@ -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 });@@@ -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 itfacts: o.facts,updated: o.updated,url: o.url,@@@ -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 };nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };@@@ -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');});@@@ -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' } });@@@ -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 didjsBrotliKB: 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 didcssFiles: 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 didexternalFiles: 1, // 0};nnew file mode 100644@@@ -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();++})();
@@ -151,6 +151,54 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
@@ -151,6 +151,54 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
.cpn-lede { padding: 0; margin: 0 0 8px; }
.cpn-lede { padding: 0; margin: 0 0 8px; }
.ovk-icon-btn--on { color: var(--accent-light, #60a5fa); }
.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
/* 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. */
it and the one-line people count. Theme tokens only. */
.cpn-trend { margin: 22px 0 8px; }
.cpn-trend { margin: 22px 0 8px; }
@@ -30,6 +30,7 @@ const { createCoupons } = require('./domain/coupons');
@@ -30,6 +30,7 @@ const { createCoupons } = require('./domain/coupons');
const { createReports } = require('./domain/reports');
const { createReports } = require('./domain/reports');
const { createInstalls } = require('./domain/installs');
const { createInstalls } = require('./domain/installs');
const { createWatches } = require('./domain/watches');
const { createWatches } = require('./domain/watches');
+const { createFacts } = require('./domain/facts');
const { createModeration } = require('./domain/moderation');
const { createModeration } = require('./domain/moderation');
const { createSourcesImporter } = require('./clients/sources');
const { createSourcesImporter } = require('./clients/sources');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
@@ -84,6 +85,8 @@ async function createApp(opts = {}) {
@@ -84,6 +85,8 @@ async function createApp(opts = {}) {
const reports = createReports({ store, config, coupons, merchants, publication });
const reports = createReports({ store, config, coupons, merchants, publication });
const installs = createInstalls({ store, config });
const installs = createInstalls({ store, config });
const watches = createWatches({ store });
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 moderation = createModeration({ store, merchants, coupons, outbox });
const importer = createSourcesImporter({ store, config, merchants, coupons, fetchImpl, log });
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.
// Offline session verification and the service-token key share the SDK's process-wide JWKS client.
@@ -107,7 +110,7 @@ async function createApp(opts = {}) {
@@ -107,7 +110,7 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, jwks, config, installs });
const viewers = createViewerResolver({ auth, jwks, config, installs });
const worker = createWorker({ config, coupons, importer, outbox, log });
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -108,6 +108,11 @@ function load(env = process.env) {
@@ -108,6 +108,11 @@ function load(env = process.env) {
intervalMs: int(env.COUPONS_SWEEP_INTERVAL_MS, 60 * 1000),
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
// 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.
// appears, changes or leaves the index. Unset: IndexNow is off — no key file, no requests.
indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
indexnowKey: String(env.INDEXNOW_KEY || '').trim(),
@@ -199,6 +199,10 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
@@ -199,6 +199,10 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
COUNT(*) FILTER (WHERE c.status = 'reported_working') AS working
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}`),
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 > ?'),
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`),
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`),
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'),
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 }) {
@@ -633,6 +637,8 @@ function createCoupons({ store, merchants, publication, outbox = null }) {
return out;
return out;
},
},
merchantHints: async (merchantId) => await q.merchantHints.all(merchantId),
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,
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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 };
@@ -27,6 +27,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -27,6 +27,7 @@ const cache = require('openvibe-shared/cache-policy');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const { renderPage, oneGraph } = require('../render/layout');
const { renderPage, oneGraph } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
+const shop = require('../render/store');
const offer = require('../render/offer');
const offer = require('../render/offer');
const home = require('../render/home');
const home = require('../render/home');
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
@@ -264,23 +265,34 @@ function createPublicRoutes(ctx) {
@@ -264,23 +265,34 @@ function createPublicRoutes(ctx) {
router.get('/m/:slug', async (req, res) => {
router.get('/m/:slug', async (req, res) => {
const m = await visibleMerchant(req, res);
const m = await visibleMerchant(req, res);
if (!m) return;
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 })));
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 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 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 decision = publication.merchantDecision(m, active.length);
const flashMsg = FLASH[req.query.done] || null;
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 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, {
send(req, res, 200, {
title: `${m.name} coupon codes`,
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.`,
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),
decision, canonical: publication.merchantUrl(m), jsonLd: jsonLd(m, null),
feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],
feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],
chart: true,
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),
watching: await watches.isWatching(req.viewer.subject, m),
}),
}),
}, { cacheable: !flashMsg });
}, { cacheable: !flashMsg });
@@ -106,7 +106,7 @@ function renderPage(o) {
@@ -106,7 +106,7 @@ function renderPage(o) {
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
// and a WebPage JSON-LD tag from these real fields; with none it emits neither.
summary: o.summary,
summary: o.summary,
styles: [...(o.styles || []), 'kit.css', ...(o.chart ? ['kit-data.css'] : [])], // shared style names; kit-data.css is the chart's look
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,
facts: o.facts,
updated: o.updated,
updated: o.updated,
url: o.url,
url: o.url,
@@ -245,55 +245,6 @@ function shopTab(query, active) {
@@ -245,55 +245,6 @@ function shopTab(query, active) {
return asked || (codes || !active.length ? 'codes' : 'deals');
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. */
/** A week's Monday, for the buckets the API leaves null when fewer than 3 people reported. */
const DAY = 86400000;
const DAY = 86400000;
const weekAt = (trend, i) => new Date(Date.parse(`${trend.from}T00:00:00Z`) + i * 7 * DAY).toISOString().slice(0, 10);
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 }) {
@@ -520,4 +471,5 @@ function message({ heading, text, action }) {
}
}
module.exports = {
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,293 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
@@ -137,7 +137,7 @@ const DAY = 24 * HOUR;
@@ -137,7 +137,7 @@ const DAY = 24 * HOUR;
const html = (await t.get(`/m/${shop.slug}`)).text;
const html = (await t.get(`/m/${shop.slug}`)).text;
const feed = (await t.get('/feed.xml')).text;
const feed = (await t.get('/feed.xml')).text;
const map = (await t.get('/sitemaps/coupons.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);
t.clock.set(expiresAt - 1);
assert.deepStrictEqual(await listed(), { api: true, page: true, feed: true, sitemap: true });
assert.deepStrictEqual(await listed(), { api: true, page: true, feed: true, sitemap: true });
@@ -147,7 +147,7 @@ const DAY = 24 * HOUR;
@@ -147,7 +147,7 @@ const DAY = 24 * HOUR;
assert.strictEqual(cpage.status, 200, 'still readable');
assert.strictEqual(cpage.status, 200, 'still readable');
assert.match(cpage.text, /<meta name="robots" content="noindex, follow">/);
assert.match(cpage.text, /<meta name="robots" content="noindex, follow">/);
assert.match(cpage.headers.get('x-robots-tag'), /noindex/);
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' } });
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');
assert.strictEqual(rep.status, 409, 'no reports on a code out of active results');
});
});
@@ -54,8 +54,9 @@ const { boot, check, done } = require('./helpers/boot');
@@ -54,8 +54,9 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);
assert.strictEqual(r.headers.get('location'), `/m/${shop.slug}?done=reported`);
const after = await t.get(`/m/${shop.slug}?done=reported`, { as: bob });
const after = await t.get(`/m/${shop.slug}?done=reported`, { as: bob });
assert.match(after.text, /your report was recorded/);
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/' } });
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');
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' } });
const w = await t.get(`/m/${shop.slug}/watch`, { as: alice, form: { csrf: t.csrf(alice), action: 'watch' } });
@@ -18,8 +18,8 @@ const BUDGETS = {
@@ -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
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
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)
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
externalFiles: 1, // 0
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -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();
+})();