openvibers/openvibe.coupons Commits
The shop page on the OpenVibe Kit: tabs (Codes, Product deals, About), the best code now, cards that lead with the discount, the code with Copy and the shop, one status line in the site's own words and Details folded; Shared 4.20.1 and the phone app bar (#114)
Changes
@@@ -18,7 +18,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1","pg": "^8.23.0"},"devDependencies": {@@@ -935,9 +935,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.19.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",-- "integrity": "sha512-OksbPienuKJ88R5HrXYh3Dgh/TWZGwUL4FUQQmGhQe6wkQpbAxaknfAouU+HaI1xwbbGhQ5f9bVIKJv9+LrNQQ==",++ "version": "4.20.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1",++ "integrity": "sha512-W8is+/ug91LtACo2NlalSWsDb2bgNBZrhTtDnY6kv8twjRSgJv7i2a7f6m4CMHDa/BKmzuEnPCQk5/p6qqHrMw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -29,7 +29,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1","pg": "^8.23.0"},"devDependencies": {@@@ -105,3 +105,30 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--@media (max-width: 600px) { .cp-search { flex-wrap: wrap; } .cp-search button { width: 100%; } .cp-stats b { font-size: 1.3rem; } .cp-stats a { padding: 12px; } .cp-cards > li:nth-child(n+5) { display: none; } }@media (prefers-reduced-motion: reduce) { .cp-stats a { transition: none; } }.home .page .sc-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }++++/* The shop page on the OpenVibe Kit (Shared 4.20): a code card answers at a glance — the discount, the code with Copy++ and the shop, one status line, the stated restrictions as chips — and folds the rest under Details. */++.cpn { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 12px 14px; margin: 10px 0; background: var(--bg-card, #131c2e); }++.cpn-top { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; }++.cpn-off { font-size: 1.45rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.15; }++.cpn-scope { font-size: .72rem; font-weight: 700; letter-spacing: .02em; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }++.cpn-title { margin: 4px 0 8px; font-size: 1rem; font-weight: 600; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.cpn-title a { color: inherit; text-decoration: none; }++.cpn-title a:hover { text-decoration: underline; }++.cpn-code { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }++.cpn-code .code { font-size: 1.05rem; }++.cpn-code .ovk-btn { margin-left: auto; }++.cpn-status { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 8px 0 0; color: var(--text-muted, #7386a3); font-size: .86rem; }++.cpn-chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; }++.cpn-chips li { padding: 2px 9px; border-radius: 999px; background: var(--bg-tertiary, #172238); color: var(--text-secondary, #96a7c2); font-size: .8rem; }++.cpn-report { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; color: var(--text-muted, #7386a3); font-size: .86rem; }++.cpn-vote { padding: 4px 12px; border-radius: 999px; font-size: .84rem; min-height: 32px; }++.cpn-more { margin-top: 8px; padding-top: 4px; border-top: 1px solid var(--border, #1f2d47); }++.cpn-more > summary { padding: 6px 0; color: var(--text-secondary, #96a7c2); font-size: .86rem; cursor: pointer; }++.cpn-more[open] > summary { margin-bottom: 4px; }++.cpn-best { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 4px 0 10px; padding: 10px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--success, #22c55e) 45%, transparent); background: color-mix(in srgb, var(--success, #22c55e) 8%, transparent); color: inherit; text-decoration: none; }++.cpn-best > span:first-child { color: var(--success-text, var(--success, #22c55e)); font-size: .8rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }++.cpn-best strong { font-size: 1.1rem; }++.cpn-best code { font-weight: 700; }++.cpn-lede { padding: 0; margin: 0 0 8px; }++.ovk-icon-btn--on { color: var(--accent-light, #60a5fa); }@@@ -254,7 +254,9 @@ function createPublicRoutes(ctx) {const m = await visibleMerchant(req, res);if (!m) return;const active = await Promise.all((await coupons.active(m.id)).map(async (c) => await coupons.view(c, { merchant: m })));-- const ended = await Promise.all((await coupons.recentlyEnded(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.++ 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;send(req, res, 200, {@@@ -264,7 +266,7 @@ function createPublicRoutes(ctx) {feeds: [{ type: 'rss', href: `/m/${m.slug}/feed.xml`, title: `${m.name} codes (RSS)` }],body: pages.merchant({m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended,-- viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}`, flashMsg,++ viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}${tab === 'codes' ? '' : `?tab=${tab}`}`, flashMsg, tab,watching: await watches.isWatching(req.viewer.subject, m),}),}, { cacheable: !flashMsg });@@@ -15,15 +15,22 @@ const fs = require('fs');const path = require('path');const layout = require('openvibe-publishing/layout');const frame = require('openvibe-shared/frame');++const ovServe = require('openvibe-shared/serve');const NETWORK_URL = 'https://openvibe.network';const SITE_NAME = 'OpenVibe.Coupons';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const NAV = [-- { label: 'Merchants', href: '/' },-- { label: 'Submit a code', href: '/submit' },++ { label: 'Shops', href: '/', icon: 'fa-ticket' },++ { label: 'Submit a code', href: '/submit', icon: 'fa-plus' },{ label: 'Browser extension', href: '/companion' },];++// The phone app bar (Shared 4.20): the shops, submitting a code, what you watch, how codes are labelled.++const APP_BAR = (signedIn) => [++ { label: 'Shops', href: '/', icon: 'fa-ticket' },++ { label: 'Submit', href: '/submit', icon: 'fa-plus' },++ signedIn ? { label: 'Watching', href: '/watching', icon: 'fa-bell' } : { label: 'How it works', href: '/about', icon: 'fa-circle-info' },++];const hashes = new Map();function assetVersion(rel) {@@@ -52,7 +59,8 @@ function renderPage(o) {const nav = {service: 'coupons',apiBase: NETWORK_URL,-- links: NAV.map((l) => ({ label: l.label, href: l.href })),++ links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}) })),++ appBar: APP_BAR(signedIn),history: { type: 'page', title: o.title || SITE_NAME },silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -78,7 +86,8 @@ function renderPage(o) {// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta// and a WebPage JSON-LD tag from these real fields; with none it emits neither.summary: o.summary,-- styles: o.styles, // openvibe-shared stylesheet names (the home's showcase.css)++ styles: [...(o.styles || []), 'kit.css'], // openvibe-shared stylesheet names (the home's showcase.css; the OpenVibe Kit)++ scripts: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`], // the kit's behaviour: copy buttons, menus, confirms (the page works without it)facts: o.facts,updated: o.updated,url: o.url,@@@ -9,8 +9,10 @@* "No reports in the last 30 days"), never as a friendly default.*/const ssr = require('openvibe-publishing/ssr');++const kit = require('openvibe-shared/kit');const { html: h, raw } = ssr;++const k = (markup) => raw(String(markup)); // a kit builder's markup (already escaped) inside an h`` templateconst STATUS_LABEL = {unknown: 'Validity unknown',@@@ -88,12 +90,64 @@ function reportForms(v, { viewer, csrf, back }) {</div>`;}--/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page } (page: its own page, where the title is the h1) */++// ── A code, as people scan it (the owner, 2026-10-28, showing SimplyCodes' store page: "so much more organized") ──++// The discount first, then the code with Copy and the shop, one status line in this site's own words (an unknown is++// still called unknown), the stated restrictions as chips; everything else (each unknown in full, the evidence, how to++// apply it, a report with its reason) folds under Details, open on the code's own page.++++/** DealNews' "Air Monitor for $35 + free shipping": a code for one product, not for the shop. */++const PRODUCT_DEAL_RE = /\bfor \$\s?\d[\d,]*(?:\.\d\d)?\b/i;++const isProductDeal = (v) => PRODUCT_DEAL_RE.test(v.title || '');++++/** The headline a person scans for, from the code's own title (then its description): 40% off, $16 off, Free shipping, $24. */++function headline(v) {++ const title = v.title || '';++ const both = `${title} ${v.description || ''}`;++ let m = /(\d{1,2}(?:\.\d)?)\s*%\s*off/i.exec(title) || /\((\d{1,2})%\)\s*off/i.exec(both) || /(\d{1,2})%\s*off/i.exec(both);++ if (m) return `${m[1]}% off`;++ m = /\$\s?(\d[\d,]*(?:\.\d\d)?)\s*off/i.exec(title);++ if (m) return `$${m[1]} off`;++ if (/free shipping/i.test(title) && !PRODUCT_DEAL_RE.test(title)) return 'Free shipping';++ m = PRODUCT_DEAL_RE.exec(title);++ if (m) return m[0].replace(/^for\s*/i, '').replace(/\s+/g, '');++ m = /\$\s?(\d[\d,]*(?:\.\d\d)?)\s*off/i.exec(both);++ if (m) return `$${m[1]} off`;++ return 'Code';++}++++/** The stated restrictions as short chips (none stated: no chips; Details says so in full). */++function restrictionChips(list) {++ if (!list.length) return '';++ return h`<ul class="cpn-chips">${list.map((r) => h`<li>${r.kind === 'min_spend' ? `Minimum spend ${r.amount}` : r.kind === 'category' ? `Category: ${r.value}` : r.kind === 'new_customers_only' ? 'New customers only' : r.kind === 'region' ? regionName(r.value) : r.value}</li>`)}</ul>`;++}++++/** One status line: the status, what the reports say (or that there are none), when the last one came, the expiry. */++function statusLine(v) {++ const parts = [];++ parts.push(v.confidence == null ? (v.reports.last_report_at ? `No reports in the last ${v.reports.window_days} days` : 'No reports yet') : `Confidence ${Math.round(v.confidence * 100)}%`);++ if (v.reports.last_report_at) parts.push(`last report ${kit.when(v.reports.last_report_at).text}`);++ parts.push(v.expiry.known ? `Expires ${kit.when(v.expiry.expires_at).text}` : 'Expiry unknown');++ return h`<p class="cpn-status">${statusBadge(v.status)} <span>${parts.join(' · ')}</span></p>`;++}++++/** Signed in: Worked / Didn't work in one tap (the reason is in Details); signed out, Details says how to report. */++function quickReport(v, { viewer, csrf, back }) {++ if (!v.active || !viewer || viewer.kind !== 'user') return '';++ const one = (outcome, label) => h`<form method="post" action="/c/${v.id}/report" class="inline">${hidden(csrf)}<input type="hidden" name="outcome" value="${outcome}"><input type="hidden" name="back" value="${back}"><button type="submit" class="cpn-vote">${label}</button></form>`;++ return h`<div class="cpn-report"><span>Did it work?</span>${one('worked', 'It worked')}${one('failed', "It didn't")}</div>`;++}++++/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page, shopUrl } (page: its own page, where the title is the h1) */function couponCard(v, opts = {}) {const title = opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title;-- return h`<article class="coupon status-${v.status}" id="${v.id}">--${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3>${title}</h3>`}--<p class="code-line"><span class="label">Code</span> <code class="code">${v.code}</code> ${statusBadge(v.status)}</p>++ return h`<article class="coupon cpn cpn--${v.status}" id="${v.id}">++<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}</div>++${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3 class="cpn-title">${title}</h3>`}++<div class="cpn-code"><code class="code" id="code-${v.id}">${v.code}</code>${k(kit.iconButton({ type: 'button', icon: 'copy', label: `Copy ${v.code}`, attrs: { 'data-ovk-copy': `#code-${v.id}`, hidden: true } }))}${opts.shopUrl ? k(kit.button({ href: opts.shopUrl, label: 'Shop', icon: 'open', kind: 'quiet', small: true, attrs: { rel: 'nofollow noopener', target: '_blank' } })) : ''}</div>++${statusLine(v)}++${restrictionChips(v.restrictions)}++${quickReport(v, opts)}++<details class="cpn-more"${opts.page ? ' open' : ''}><summary>Details</summary>${v.description ? h`<p>${v.description}</p>` : ''}<p class="meta">${confidenceText(v)}</p><p class="meta">${v.reports.last_report_at ? h`Last report ${v.reports.last_report_at.slice(0, 10)} around ${v.reports.last_report_at.slice(11, 13)}:00 UTC` : 'No reports yet'}</p>@@@ -104,6 +158,7 @@ ${evidenceList(v.evidence)}${v.attribution ? h`<p class="meta">Code via <a href="${v.attribution.url}" rel="nofollow noopener">${v.attribution.text}</a></p>` : ''}${v.origin === 'ai' ? h`<p class="disclosure">Extracted by an AI workflow from the evidence page and reviewed by staff before listing. Its validity comes only from people's reports.</p>` : ''}${reportForms(v, opts)}++</details></article>`;}@@@ -145,19 +200,60 @@ ${live.length ? h`<ul class="chips">${live.map(chip)}</ul>` : ''}${quiet.length ? h`<details class="quiet"><summary>${live.length ? 'Shops with no active code right now' : 'All shops'} (${String(quiet.length)})</summary><ul class="chips">${quiet.map(chip)}</ul></details>` : ''}</section>`;}--function merchant({ m, domains, hints, active, ended, viewer, csrf, watching, back, flashMsg }) {-- return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name }]))}++const SHOP_TABS = ['codes', 'deals', 'about'];++/** The shop's tabs: Codes (for the shop or a category), Product deals (a code for one product), About; the first that has something opens. */++function shopTab(query, active) {++ const codes = active.filter((v) => !isProductDeal(v)).length;++ const asked = query && SHOP_TABS.includes(String(query.tab)) ? String(query.tab) : null;++ 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, 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)}--<h1>${m.name} coupon codes</h1>--<p class="meta">${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>${m.description}</p>` : ''}--${hints.length ? h`<ul class="hints">${hints.map((x) => h`<li>How to apply codes here: ${x.text}</li>`)}</ul>` : ''}--<h2>Active codes (${active.length})</h2>--${active.length ? active.map((v) => couponCard(v, { viewer, csrf, back, link: true })) : h`<p class="empty">No active codes for ${m.name} right now.</p>`}--<p><a href="/submit?merchant=${m.slug}">Submit a code for ${m.name}</a></p>--${viewer && viewer.kind === 'user' ? h`<form method="post" action="/m/${m.slug}/watch">${hidden(csrf)}<input type="hidden" name="action" value="${watching ? 'unwatch' : 'watch'}"><button type="submit">${watching ? 'Stop watching' : 'Watch this shop'}</button> <span class="meta">(private to you; new codes reach your OpenVibe notifications)</span></form>` : ''}--${ended.length ? h`<h2>Recently ended (not in active results)</h2><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>` : ''}--<p class="meta">Follow: <a href="/m/${m.slug}/feed.xml">RSS</a> · <a href="/m/${m.slug}.json">JSON</a></p>`;++${body}`;}function couponPage({ v, m, history, viewer, csrf, back, flashMsg }) {@@@ -325,4 +421,5 @@ function message({ heading, text, action }) {return h`<h1>${heading}</h1><p>${text}</p>${action ? h`<p><a href="${action.href}">${action.label}</a></p>` : ''}`;}--module.exports = { home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };++module.exports = {++ shopTab, isProductDeal, headline, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };@@@ -14,12 +14,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {htmlRawKB: 42, // 37.3 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 33.7 with the curated front page (numbers, code tickets, the Companion, six questions and their JSON-LD; 2026-10-22); 24.1 with the showcase (17.4 before it; fresh database)htmlBrotliKB: 11, // 9.1 Shared 4.15.1 footer; 7.9 with the curated front page; 6.2 (4.5 before the showcase)-- jsFiles: 5, // 5 (openvibe-shared 2.9 shell: web-runtime.js)-- jsRawKB: 292, // 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)-- jsBrotliKB: 72, // 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)-- cssFiles: 2, // 2 (coupons.css + showcase.css, home only)-- cssRawKB: 33, // 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)-- cssBrotliKB: 8, // 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)++ jsFiles: 6, // 6 with the OpenVibe Kit's kit-web.js (Shared 4.20.1, 2026-10-28); 5 (openvibe-shared 2.9 shell: web-runtime.js)++ jsRawKB: 299, // 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)++ jsBrotliKB: 75, // 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)++ cssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)++ cssRawKB: 57, // 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)++ cssBrotliKB: 12, // 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)externalFiles: 1, // 0};
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -935,9 +935,9 @@
@@ -935,9 +935,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.19.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
- "integrity": "sha512-OksbPienuKJ88R5HrXYh3Dgh/TWZGwUL4FUQQmGhQe6wkQpbAxaknfAouU+HaI1xwbbGhQ5f9bVIKJv9+LrNQQ==",
+ "version": "4.20.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1",
+ "integrity": "sha512-W8is+/ug91LtACo2NlalSWsDb2bgNBZrhTtDnY6kv8twjRSgJv7i2a7f6m4CMHDa/BKmzuEnPCQk5/p6qqHrMw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.20.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -105,3 +105,30 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
@@ -105,3 +105,30 @@ button.primary { background: var(--accent-strong, #2563eb); border-color: var(--
@media (max-width: 600px) { .cp-search { flex-wrap: wrap; } .cp-search button { width: 100%; } .cp-stats b { font-size: 1.3rem; } .cp-stats a { padding: 12px; } .cp-cards > li:nth-child(n+5) { display: none; } }
@media (max-width: 600px) { .cp-search { flex-wrap: wrap; } .cp-search button { width: 100%; } .cp-stats b { font-size: 1.3rem; } .cp-stats a { padding: 12px; } .cp-cards > li:nth-child(n+5) { display: none; } }
@media (prefers-reduced-motion: reduce) { .cp-stats a { transition: none; } }
@media (prefers-reduced-motion: reduce) { .cp-stats a { transition: none; } }
.home .page .sc-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }
.home .page .sc-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }
+
+/* The shop page on the OpenVibe Kit (Shared 4.20): a code card answers at a glance — the discount, the code with Copy
+ and the shop, one status line, the stated restrictions as chips — and folds the rest under Details. */
+.cpn { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 12px 14px; margin: 10px 0; background: var(--bg-card, #131c2e); }
+.cpn-top { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; }
+.cpn-off { font-size: 1.45rem; font-weight: 800; letter-spacing: -.01em; line-height: 1.15; }
+.cpn-scope { font-size: .72rem; font-weight: 700; letter-spacing: .02em; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }
+.cpn-title { margin: 4px 0 8px; font-size: 1rem; font-weight: 600; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.cpn-title a { color: inherit; text-decoration: none; }
+.cpn-title a:hover { text-decoration: underline; }
+.cpn-code { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
+.cpn-code .code { font-size: 1.05rem; }
+.cpn-code .ovk-btn { margin-left: auto; }
+.cpn-status { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 8px 0 0; color: var(--text-muted, #7386a3); font-size: .86rem; }
+.cpn-chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; }
+.cpn-chips li { padding: 2px 9px; border-radius: 999px; background: var(--bg-tertiary, #172238); color: var(--text-secondary, #96a7c2); font-size: .8rem; }
+.cpn-report { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 10px; color: var(--text-muted, #7386a3); font-size: .86rem; }
+.cpn-vote { padding: 4px 12px; border-radius: 999px; font-size: .84rem; min-height: 32px; }
+.cpn-more { margin-top: 8px; padding-top: 4px; border-top: 1px solid var(--border, #1f2d47); }
+.cpn-more > summary { padding: 6px 0; color: var(--text-secondary, #96a7c2); font-size: .86rem; cursor: pointer; }
+.cpn-more[open] > summary { margin-bottom: 4px; }
+.cpn-best { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 4px 0 10px; padding: 10px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--success, #22c55e) 45%, transparent); background: color-mix(in srgb, var(--success, #22c55e) 8%, transparent); color: inherit; text-decoration: none; }
+.cpn-best > span:first-child { color: var(--success-text, var(--success, #22c55e)); font-size: .8rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
+.cpn-best strong { font-size: 1.1rem; }
+.cpn-best code { font-weight: 700; }
+.cpn-lede { padding: 0; margin: 0 0 8px; }
+.ovk-icon-btn--on { color: var(--accent-light, #60a5fa); }
@@ -254,7 +254,9 @@ function createPublicRoutes(ctx) {
@@ -254,7 +254,9 @@ function createPublicRoutes(ctx) {
const m = await visibleMerchant(req, res);
const m = await visibleMerchant(req, res);
if (!m) return;
if (!m) return;
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 })));
- const ended = await Promise.all((await coupons.recentlyEnded(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.
+ 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 decision = publication.merchantDecision(m, active.length);
const flashMsg = FLASH[req.query.done] || null;
const flashMsg = FLASH[req.query.done] || null;
send(req, res, 200, {
send(req, res, 200, {
@@ -264,7 +266,7 @@ function createPublicRoutes(ctx) {
@@ -264,7 +266,7 @@ function createPublicRoutes(ctx) {
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)` }],
body: pages.merchant({
body: pages.merchant({
m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended,
m, domains: await merchants.domains(m), hints: await coupons.merchantHints(m.id), active, ended,
- viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}`, flashMsg,
+ viewer: req.viewer, csrf: csrf(req), back: `/m/${m.slug}${tab === 'codes' ? '' : `?tab=${tab}`}`, flashMsg, tab,
watching: await watches.isWatching(req.viewer.subject, m),
watching: await watches.isWatching(req.viewer.subject, m),
}),
}),
}, { cacheable: !flashMsg });
}, { cacheable: !flashMsg });
@@ -15,15 +15,22 @@ const fs = require('fs');
@@ -15,15 +15,22 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const layout = require('openvibe-publishing/layout');
const layout = require('openvibe-publishing/layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const ovServe = require('openvibe-shared/serve');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Coupons';
const SITE_NAME = 'OpenVibe.Coupons';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
const NAV = [
- { label: 'Merchants', href: '/' },
- { label: 'Submit a code', href: '/submit' },
+ { label: 'Shops', href: '/', icon: 'fa-ticket' },
+ { label: 'Submit a code', href: '/submit', icon: 'fa-plus' },
{ label: 'Browser extension', href: '/companion' },
{ label: 'Browser extension', href: '/companion' },
];
];
+// The phone app bar (Shared 4.20): the shops, submitting a code, what you watch, how codes are labelled.
+const APP_BAR = (signedIn) => [
+ { label: 'Shops', href: '/', icon: 'fa-ticket' },
+ { label: 'Submit', href: '/submit', icon: 'fa-plus' },
+ signedIn ? { label: 'Watching', href: '/watching', icon: 'fa-bell' } : { label: 'How it works', href: '/about', icon: 'fa-circle-info' },
+];
const hashes = new Map();
const hashes = new Map();
function assetVersion(rel) {
function assetVersion(rel) {
@@ -52,7 +59,8 @@ function renderPage(o) {
@@ -52,7 +59,8 @@ function renderPage(o) {
const nav = {
const nav = {
service: 'coupons',
service: 'coupons',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: NAV.map((l) => ({ label: l.label, href: l.href })),
+ links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}) })),
+ appBar: APP_BAR(signedIn),
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -78,7 +86,8 @@ function renderPage(o) {
@@ -78,7 +86,8 @@ function renderPage(o) {
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// The AI-readable summary (publishing v1.3.0): given one, the shell emits the ai-summary meta
// 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, // openvibe-shared stylesheet names (the home's showcase.css)
+ styles: [...(o.styles || []), 'kit.css'], // openvibe-shared stylesheet names (the home's showcase.css; the OpenVibe Kit)
+ scripts: [`<script src="${ovServe.url('kit-web.js')}" defer></script>`], // the kit's behaviour: copy buttons, menus, confirms (the page works without it)
facts: o.facts,
facts: o.facts,
updated: o.updated,
updated: o.updated,
url: o.url,
url: o.url,
@@ -9,8 +9,10 @@
@@ -9,8 +9,10 @@
* "No reports in the last 30 days"), never as a friendly default.
* "No reports in the last 30 days"), never as a friendly default.
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
+const kit = require('openvibe-shared/kit');
const { html: h, raw } = ssr;
const { html: h, raw } = ssr;
+const k = (markup) => raw(String(markup)); // a kit builder's markup (already escaped) inside an h`` template
const STATUS_LABEL = {
const STATUS_LABEL = {
unknown: 'Validity unknown',
unknown: 'Validity unknown',
@@ -88,12 +90,64 @@ function reportForms(v, { viewer, csrf, back }) {
@@ -88,12 +90,64 @@ function reportForms(v, { viewer, csrf, back }) {
</div>`;
</div>`;
}
}
-/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page } (page: its own page, where the title is the h1) */
+// ── A code, as people scan it (the owner, 2026-10-28, showing SimplyCodes' store page: "so much more organized") ──
+// The discount first, then the code with Copy and the shop, one status line in this site's own words (an unknown is
+// still called unknown), the stated restrictions as chips; everything else (each unknown in full, the evidence, how to
+// apply it, a report with its reason) folds under Details, open on the code's own page.
+
+/** DealNews' "Air Monitor for $35 + free shipping": a code for one product, not for the shop. */
+const PRODUCT_DEAL_RE = /\bfor \$\s?\d[\d,]*(?:\.\d\d)?\b/i;
+const isProductDeal = (v) => PRODUCT_DEAL_RE.test(v.title || '');
+
+/** The headline a person scans for, from the code's own title (then its description): 40% off, $16 off, Free shipping, $24. */
+function headline(v) {
+ const title = v.title || '';
+ const both = `${title} ${v.description || ''}`;
+ let m = /(\d{1,2}(?:\.\d)?)\s*%\s*off/i.exec(title) || /\((\d{1,2})%\)\s*off/i.exec(both) || /(\d{1,2})%\s*off/i.exec(both);
+ if (m) return `${m[1]}% off`;
+ m = /\$\s?(\d[\d,]*(?:\.\d\d)?)\s*off/i.exec(title);
+ if (m) return `$${m[1]} off`;
+ if (/free shipping/i.test(title) && !PRODUCT_DEAL_RE.test(title)) return 'Free shipping';
+ m = PRODUCT_DEAL_RE.exec(title);
+ if (m) return m[0].replace(/^for\s*/i, '').replace(/\s+/g, '');
+ m = /\$\s?(\d[\d,]*(?:\.\d\d)?)\s*off/i.exec(both);
+ if (m) return `$${m[1]} off`;
+ return 'Code';
+}
+
+/** The stated restrictions as short chips (none stated: no chips; Details says so in full). */
+function restrictionChips(list) {
+ if (!list.length) return '';
+ return h`<ul class="cpn-chips">${list.map((r) => h`<li>${r.kind === 'min_spend' ? `Minimum spend ${r.amount}` : r.kind === 'category' ? `Category: ${r.value}` : r.kind === 'new_customers_only' ? 'New customers only' : r.kind === 'region' ? regionName(r.value) : r.value}</li>`)}</ul>`;
+}
+
+/** One status line: the status, what the reports say (or that there are none), when the last one came, the expiry. */
+function statusLine(v) {
+ const parts = [];
+ parts.push(v.confidence == null ? (v.reports.last_report_at ? `No reports in the last ${v.reports.window_days} days` : 'No reports yet') : `Confidence ${Math.round(v.confidence * 100)}%`);
+ if (v.reports.last_report_at) parts.push(`last report ${kit.when(v.reports.last_report_at).text}`);
+ parts.push(v.expiry.known ? `Expires ${kit.when(v.expiry.expires_at).text}` : 'Expiry unknown');
+ return h`<p class="cpn-status">${statusBadge(v.status)} <span>${parts.join(' · ')}</span></p>`;
+}
+
+/** Signed in: Worked / Didn't work in one tap (the reason is in Details); signed out, Details says how to report. */
+function quickReport(v, { viewer, csrf, back }) {
+ if (!v.active || !viewer || viewer.kind !== 'user') return '';
+ const one = (outcome, label) => h`<form method="post" action="/c/${v.id}/report" class="inline">${hidden(csrf)}<input type="hidden" name="outcome" value="${outcome}"><input type="hidden" name="back" value="${back}"><button type="submit" class="cpn-vote">${label}</button></form>`;
+ return h`<div class="cpn-report"><span>Did it work?</span>${one('worked', 'It worked')}${one('failed', "It didn't")}</div>`;
+}
+
+/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page, shopUrl } (page: its own page, where the title is the h1) */
function couponCard(v, opts = {}) {
function couponCard(v, opts = {}) {
const title = opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title;
const title = opts.link ? h`<a href="/c/${v.id}">${v.title}</a>` : v.title;
- return h`<article class="coupon status-${v.status}" id="${v.id}">
-${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3>${title}</h3>`}
-<p class="code-line"><span class="label">Code</span> <code class="code">${v.code}</code> ${statusBadge(v.status)}</p>
+ return h`<article class="coupon cpn cpn--${v.status}" id="${v.id}">
+<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}</div>
+${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3 class="cpn-title">${title}</h3>`}
+<div class="cpn-code"><code class="code" id="code-${v.id}">${v.code}</code>${k(kit.iconButton({ type: 'button', icon: 'copy', label: `Copy ${v.code}`, attrs: { 'data-ovk-copy': `#code-${v.id}`, hidden: true } }))}${opts.shopUrl ? k(kit.button({ href: opts.shopUrl, label: 'Shop', icon: 'open', kind: 'quiet', small: true, attrs: { rel: 'nofollow noopener', target: '_blank' } })) : ''}</div>
+${statusLine(v)}
+${restrictionChips(v.restrictions)}
+${quickReport(v, opts)}
+<details class="cpn-more"${opts.page ? ' open' : ''}><summary>Details</summary>
${v.description ? h`<p>${v.description}</p>` : ''}
${v.description ? h`<p>${v.description}</p>` : ''}
<p class="meta">${confidenceText(v)}</p>
<p class="meta">${confidenceText(v)}</p>
<p class="meta">${v.reports.last_report_at ? h`Last report ${v.reports.last_report_at.slice(0, 10)} around ${v.reports.last_report_at.slice(11, 13)}:00 UTC` : 'No reports yet'}</p>
<p class="meta">${v.reports.last_report_at ? h`Last report ${v.reports.last_report_at.slice(0, 10)} around ${v.reports.last_report_at.slice(11, 13)}:00 UTC` : 'No reports yet'}</p>
@@ -104,6 +158,7 @@ ${evidenceList(v.evidence)}
@@ -104,6 +158,7 @@ ${evidenceList(v.evidence)}
${v.attribution ? h`<p class="meta">Code via <a href="${v.attribution.url}" rel="nofollow noopener">${v.attribution.text}</a></p>` : ''}
${v.attribution ? h`<p class="meta">Code via <a href="${v.attribution.url}" rel="nofollow noopener">${v.attribution.text}</a></p>` : ''}
${v.origin === 'ai' ? h`<p class="disclosure">Extracted by an AI workflow from the evidence page and reviewed by staff before listing. Its validity comes only from people's reports.</p>` : ''}
${v.origin === 'ai' ? h`<p class="disclosure">Extracted by an AI workflow from the evidence page and reviewed by staff before listing. Its validity comes only from people's reports.</p>` : ''}
${reportForms(v, opts)}
${reportForms(v, opts)}
+</details>
</article>`;
</article>`;
}
}
@@ -145,19 +200,60 @@ ${live.length ? h`<ul class="chips">${live.map(chip)}</ul>` : ''}
@@ -145,19 +200,60 @@ ${live.length ? h`<ul class="chips">${live.map(chip)}</ul>` : ''}
${quiet.length ? h`<details class="quiet"><summary>${live.length ? 'Shops with no active code right now' : 'All shops'} (${String(quiet.length)})</summary><ul class="chips">${quiet.map(chip)}</ul></details>` : ''}</section>`;
${quiet.length ? h`<details class="quiet"><summary>${live.length ? 'Shops with no active code right now' : 'All shops'} (${String(quiet.length)})</summary><ul class="chips">${quiet.map(chip)}</ul></details>` : ''}</section>`;
}
}
-function merchant({ m, domains, hints, active, ended, viewer, csrf, watching, back, flashMsg }) {
- return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name }]))}
+const SHOP_TABS = ['codes', 'deals', 'about'];
+/** The shop's tabs: Codes (for the shop or a category), Product deals (a code for one product), About; the first that has something opens. */
+function shopTab(query, active) {
+ const codes = active.filter((v) => !isProductDeal(v)).length;
+ const asked = query && SHOP_TABS.includes(String(query.tab)) ? String(query.tab) : null;
+ 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, 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)}
${flash(flashMsg)}
-<h1>${m.name} coupon codes</h1>
-<p class="meta">${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>${m.description}</p>` : ''}
-${hints.length ? h`<ul class="hints">${hints.map((x) => h`<li>How to apply codes here: ${x.text}</li>`)}</ul>` : ''}
-<h2>Active codes (${active.length})</h2>
-${active.length ? active.map((v) => couponCard(v, { viewer, csrf, back, link: true })) : h`<p class="empty">No active codes for ${m.name} right now.</p>`}
-<p><a href="/submit?merchant=${m.slug}">Submit a code for ${m.name}</a></p>
-${viewer && viewer.kind === 'user' ? h`<form method="post" action="/m/${m.slug}/watch">${hidden(csrf)}<input type="hidden" name="action" value="${watching ? 'unwatch' : 'watch'}"><button type="submit">${watching ? 'Stop watching' : 'Watch this shop'}</button> <span class="meta">(private to you; new codes reach your OpenVibe notifications)</span></form>` : ''}
-${ended.length ? h`<h2>Recently ended (not in active results)</h2><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>` : ''}
-<p class="meta">Follow: <a href="/m/${m.slug}/feed.xml">RSS</a> · <a href="/m/${m.slug}.json">JSON</a></p>`;
+${body}`;
}
}
function couponPage({ v, m, history, viewer, csrf, back, flashMsg }) {
function couponPage({ v, m, history, viewer, csrf, back, flashMsg }) {
@@ -325,4 +421,5 @@ function message({ heading, text, action }) {
@@ -325,4 +421,5 @@ function message({ heading, text, action }) {
return h`<h1>${heading}</h1><p>${text}</p>${action ? h`<p><a href="${action.href}">${action.label}</a></p>` : ''}`;
return h`<h1>${heading}</h1><p>${text}</p>${action ? h`<p><a href="${action.href}">${action.label}</a></p>` : ''}`;
}
}
-module.exports = { home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
+module.exports = {
+ shopTab, isProductDeal, headline, home, shopsSection, merchant, couponPage, couponCard, about, submitForm, submitted, connect, companion, COMPANION_RELEASE, watching, staff, message, STATUS_LABEL, SITE_SUMMARY };
@@ -14,12 +14,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,12 +14,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 42, // 37.3 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 33.7 with the curated front page (numbers, code tickets, the Companion, six questions and their JSON-LD; 2026-10-22); 24.1 with the showcase (17.4 before it; fresh database)
htmlRawKB: 42, // 37.3 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 33.7 with the curated front page (numbers, code tickets, the Companion, six questions and their JSON-LD; 2026-10-22); 24.1 with the showcase (17.4 before it; fresh database)
htmlBrotliKB: 11, // 9.1 Shared 4.15.1 footer; 7.9 with the curated front page; 6.2 (4.5 before the showcase)
htmlBrotliKB: 11, // 9.1 Shared 4.15.1 footer; 7.9 with the curated front page; 6.2 (4.5 before the showcase)
- jsFiles: 5, // 5 (openvibe-shared 2.9 shell: web-runtime.js)
- jsRawKB: 292, // 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)
- jsBrotliKB: 72, // 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)
- cssFiles: 2, // 2 (coupons.css + showcase.css, home only)
- cssRawKB: 33, // 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)
- cssBrotliKB: 8, // 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)
+ jsFiles: 6, // 6 with the OpenVibe Kit's kit-web.js (Shared 4.20.1, 2026-10-28); 5 (openvibe-shared 2.9 shell: web-runtime.js)
+ jsRawKB: 299, // 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)
+ jsBrotliKB: 75, // 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)
+ cssFiles: 3, // 3 with the OpenVibe Kit's kit.css on every page (Shared 4.20); 2 (coupons.css + showcase.css, home only)
+ cssRawKB: 57, // 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)
+ cssBrotliKB: 12, // 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)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};