openvibers/openvibe.coupons Commits
Coupons: the front page's search, recent codes and shops are designed like the rest of the network (#49)
Changes
@@@ -31,6 +31,26 @@ input, select, textarea { font: inherit; padding: 5px 8px; border-radius: 6px; b.form label { display: block; margin: 8px 0; }.form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; margin: 12px 0; }.search { margin: 12px 0; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }++.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++/* The front page under the showcase: one column, the showcase's own gutter dropped (the page has one). */++.page .sc-hero, .page .sc-sec { padding-left: 0; padding-right: 0; }++.page .sc-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); }++.page .sc-sec + .sc-sec, .page section.sc-sec { margin-top: 28px; }++.find .search { margin: 0; flex-wrap: nowrap; }++.find .search input { flex: 1; min-width: 0; padding: 10px 12px; font-size: 1rem; border-radius: 10px; }++.find .search button { padding: 10px 18px; border-radius: 10px; }++button.primary { background: var(--accent-strong, #2563eb); border-color: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); font-weight: 600; }++.offers { list-style: none; padding: 0; margin: 0; }++.offers li { display: flex; flex-direction: column; gap: 2px; padding: 10px 0; border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, .1)); }++.offer-title { font-weight: 600; text-decoration: none; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++.offer-title:hover { text-decoration: underline; }++.offer-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: .9rem; color: var(--text-secondary, #96a7c2); }++.chips { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 8px; }++.chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .95rem; }++.chip:hover { border-color: var(--accent, #3b82f6); }++.chip-n { font-size: .78rem; font-weight: 700; padding: 0 7px; border-radius: 999px; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }++.quiet { margin-top: 8px; }++.quiet summary { cursor: pointer; color: var(--text-secondary, #96a7c2); }.merchant-list, .recent, .ended, .history { padding-left: 1.2rem; }.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }.errors { border-left: 3px solid var(--error, #ef4444); padding: 6px 12px; background: var(--bg-card, #131c2e); }@@@ -120,15 +120,29 @@ function errors(list) {function home({ merchants, recent, q, results, total, showcase = false }) {return h`${showcase ? '' : h`<h1>Coupon codes, honestly labelled</h1><p class="lede">Codes for online shops with their restrictions, their expiry (or “unknown”) and what people reported when they tried them. A code is only ever called working because people said it worked recently.</p>--`}<form method="get" action="/" class="search${showcase ? ' after-showcase' : ''}" role="search"><label for="q">Find a shop</label> <input id="q" name="q" value="${q || ''}" maxlength="60" placeholder="name or domain"> <button type="submit">Search</button></form>--${q ? h`<h2>Shops matching “${q}”</h2>${results.length ? h`<ul class="merchant-list">${results.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">No shop matches. <a href="/submit">Submit a code</a> for a new one.</p>`}` : ''}--<h2>Recently added codes</h2>--${recent.length ? h`<ul class="recent">${recent.map((r) => h`<li><a href="/c/${r.coupon.id}">${r.coupon.title}</a> at <a href="/m/${r.merchant.slug}">${r.merchant.name}</a> ${statusBadge(r.coupon.status)}</li>`)}</ul>` : h`<p class="empty">No codes yet.</p>`}--<h2>Shops (${total})</h2>--${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.merchant.slug}">${m.merchant.name}</a> <span class="meta">${m.active} active code${m.active === 1 ? '' : 's'}</span></li>`)}</ul>` : h`<p class="empty">No shops listed yet.</p>`}++`}<section class="find sc-sec${showcase ? ' after-showcase' : ''}" aria-labelledby="find-h"><h2 id="find-h">Find a shop</h2>++<form method="get" action="/" class="search" role="search"><label class="sr-only" for="q">Shop name or domain</label><input id="q" name="q" type="search" value="${q || ''}" maxlength="60" placeholder="Shop name or domain, like amazon.com"> <button type="submit" class="primary">Search</button></form></section>++${q ? h`<h2>Shops matching “${q}”</h2>${results.length ? h`<ul class="chips">${results.map((m) => h`<li><a class="chip" href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">No shop matches. <a href="/submit">Submit a code</a> for a new one.</p>`}` : ''}++<section class="sc-sec" aria-labelledby="recent-h"><h2 id="recent-h">Recently added codes</h2>++${recent.length ? h`<ul class="offers">${recent.map((r) => h`<li><a class="offer-title" href="/c/${r.coupon.id}">${r.coupon.title}</a><span class="offer-meta"><a href="/m/${r.merchant.slug}">${r.merchant.name}</a> ${statusBadge(r.coupon.status)}</span></li>`)}</ul>` : h`<p class="empty">No codes yet.</p>`}</section>++${shopsSection(merchants, total)}<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;}++// The shops: the ones with an active code first (most codes first) as chips with their count; the rest fold away under++// one line, so thirty shops with nothing today do not bury the few that have something.++function shopsSection(merchants, total) {++ if (!merchants.length) return h`<section class="sc-sec" aria-labelledby="shops-h"><h2 id="shops-h">Shops</h2><p class="empty">No shops listed yet.</p></section>`;++ const byName = (a, b) => a.merchant.name.localeCompare(b.merchant.name, 'en', { sensitivity: 'base' });++ const live = merchants.filter((m) => m.active > 0).sort((a, b) => (b.active - a.active) || byName(a, b));++ const quiet = merchants.filter((m) => !(m.active > 0)).sort(byName);++ const chip = (m) => h`<li><a class="chip" href="/m/${m.merchant.slug}">${m.merchant.name}${m.active > 0 ? h` <span class="chip-n">${String(m.active)}<span class="sr-only"> active code${m.active === 1 ? '' : 's'}</span></span>` : ''}</a></li>`;++ return h`<section class="sc-sec" aria-labelledby="shops-h"><h2 id="shops-h">Shops</h2>++<p class="meta">${String(total)} shop${total === 1 ? '' : 's'} listed${live.length ? h`; ${String(live.length)} with an active code right now` : ''}.</p>++${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 }]))}${flash(flashMsg)}@@@ -140,7 +140,8 @@ const { boot, check, done } = require('./helpers/boot');await check('home, search and the about page (with the formula) render without JavaScript', async () => {const home = await t.get('/');assert.match(home.text, /Green Grocer/);-- assert.match(home.text, /1 active code</);++ assert.match(home.text, /Green Grocer <span class="chip-n">1<span class="sr-only"> active code<\/span><\/span>/, 'a shop with a code is a chip with its count, worded for screen readers');++ assert.match(home.text, /1 shop listed; 1 with an active code right now\./);// The front page opens with the showcase (openvibe-shared/showcase): its hero is the one h1 and its stylesheet is linked;// the shops still follow. A search result page has neither.assert.ok(home.text.includes('class="sc-hero') && home.text.includes('class="sc-sec"'), 'the home opens with the showcase');@@@ -18,7 +18,7 @@ const BUDGETS = {jsRawKB: 245, // 239.1 (212.2 before the shell's web-runtime.js)jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 2 (coupons.css + showcase.css, home only)-- cssRawKB: 17.5, // 14.8 (4.4 before showcase.css)++ cssRawKB: 19, // 18.1 (14.8 before 2026-10-15's search bar, offers list and shop chips; 4.4 before showcase.css)cssBrotliKB: 4.5, // 3.6 (1.2 before showcase.css)externalFiles: 1, // 0};
@@ -31,6 +31,26 @@ input, select, textarea { font: inherit; padding: 5px 8px; border-radius: 6px; b
@@ -31,6 +31,26 @@ input, select, textarea { font: inherit; padding: 5px 8px; border-radius: 6px; b
.form label { display: block; margin: 8px 0; }
.form label { display: block; margin: 8px 0; }
.form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; margin: 12px 0; }
.form fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; margin: 12px 0; }
.search { margin: 12px 0; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.search { margin: 12px 0; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+/* The front page under the showcase: one column, the showcase's own gutter dropped (the page has one). */
+.page .sc-hero, .page .sc-sec { padding-left: 0; padding-right: 0; }
+.page .sc-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); }
+.page .sc-sec + .sc-sec, .page section.sc-sec { margin-top: 28px; }
+.find .search { margin: 0; flex-wrap: nowrap; }
+.find .search input { flex: 1; min-width: 0; padding: 10px 12px; font-size: 1rem; border-radius: 10px; }
+.find .search button { padding: 10px 18px; border-radius: 10px; }
+button.primary { background: var(--accent-strong, #2563eb); border-color: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); font-weight: 600; }
+.offers { list-style: none; padding: 0; margin: 0; }
+.offers li { display: flex; flex-direction: column; gap: 2px; padding: 10px 0; border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, .1)); }
+.offer-title { font-weight: 600; text-decoration: none; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.offer-title:hover { text-decoration: underline; }
+.offer-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: .9rem; color: var(--text-secondary, #96a7c2); }
+.chips { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .95rem; }
+.chip:hover { border-color: var(--accent, #3b82f6); }
+.chip-n { font-size: .78rem; font-weight: 700; padding: 0 7px; border-radius: 999px; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
+.quiet { margin-top: 8px; }
+.quiet summary { cursor: pointer; color: var(--text-secondary, #96a7c2); }
.merchant-list, .recent, .ended, .history { padding-left: 1.2rem; }
.merchant-list, .recent, .ended, .history { padding-left: 1.2rem; }
.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }
.notice { border-left: 3px solid var(--info, #38bdf8); padding: 6px 12px; background: var(--bg-card, #131c2e); }
.errors { border-left: 3px solid var(--error, #ef4444); padding: 6px 12px; background: var(--bg-card, #131c2e); }
.errors { border-left: 3px solid var(--error, #ef4444); padding: 6px 12px; background: var(--bg-card, #131c2e); }
@@ -120,15 +120,29 @@ function errors(list) {
@@ -120,15 +120,29 @@ function errors(list) {
function home({ merchants, recent, q, results, total, showcase = false }) {
function home({ merchants, recent, q, results, total, showcase = false }) {
return h`${showcase ? '' : h`<h1>Coupon codes, honestly labelled</h1>
return h`${showcase ? '' : h`<h1>Coupon codes, honestly labelled</h1>
<p class="lede">Codes for online shops with their restrictions, their expiry (or “unknown”) and what people reported when they tried them. A code is only ever called working because people said it worked recently.</p>
<p class="lede">Codes for online shops with their restrictions, their expiry (or “unknown”) and what people reported when they tried them. A code is only ever called working because people said it worked recently.</p>
-`}<form method="get" action="/" class="search${showcase ? ' after-showcase' : ''}" role="search"><label for="q">Find a shop</label> <input id="q" name="q" value="${q || ''}" maxlength="60" placeholder="name or domain"> <button type="submit">Search</button></form>
-${q ? h`<h2>Shops matching “${q}”</h2>${results.length ? h`<ul class="merchant-list">${results.map((m) => h`<li><a href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">No shop matches. <a href="/submit">Submit a code</a> for a new one.</p>`}` : ''}
-<h2>Recently added codes</h2>
-${recent.length ? h`<ul class="recent">${recent.map((r) => h`<li><a href="/c/${r.coupon.id}">${r.coupon.title}</a> at <a href="/m/${r.merchant.slug}">${r.merchant.name}</a> ${statusBadge(r.coupon.status)}</li>`)}</ul>` : h`<p class="empty">No codes yet.</p>`}
-<h2>Shops (${total})</h2>
-${merchants.length ? h`<ul class="merchant-list">${merchants.map((m) => h`<li><a href="/m/${m.merchant.slug}">${m.merchant.name}</a> <span class="meta">${m.active} active code${m.active === 1 ? '' : 's'}</span></li>`)}</ul>` : h`<p class="empty">No shops listed yet.</p>`}
+`}<section class="find sc-sec${showcase ? ' after-showcase' : ''}" aria-labelledby="find-h"><h2 id="find-h">Find a shop</h2>
+<form method="get" action="/" class="search" role="search"><label class="sr-only" for="q">Shop name or domain</label><input id="q" name="q" type="search" value="${q || ''}" maxlength="60" placeholder="Shop name or domain, like amazon.com"> <button type="submit" class="primary">Search</button></form></section>
+${q ? h`<h2>Shops matching “${q}”</h2>${results.length ? h`<ul class="chips">${results.map((m) => h`<li><a class="chip" href="/m/${m.slug}">${m.name}</a></li>`)}</ul>` : h`<p class="empty">No shop matches. <a href="/submit">Submit a code</a> for a new one.</p>`}` : ''}
+<section class="sc-sec" aria-labelledby="recent-h"><h2 id="recent-h">Recently added codes</h2>
+${recent.length ? h`<ul class="offers">${recent.map((r) => h`<li><a class="offer-title" href="/c/${r.coupon.id}">${r.coupon.title}</a><span class="offer-meta"><a href="/m/${r.merchant.slug}">${r.merchant.name}</a> ${statusBadge(r.coupon.status)}</span></li>`)}</ul>` : h`<p class="empty">No codes yet.</p>`}</section>
+${shopsSection(merchants, total)}
<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;
<p class="meta"><a href="/about">How statuses and confidence work</a> · <a href="/feed.xml">RSS</a> · <a href="/atom.xml">Atom</a> · <a href="/feed.json">JSON Feed</a></p>`;
}
}
+// The shops: the ones with an active code first (most codes first) as chips with their count; the rest fold away under
+// one line, so thirty shops with nothing today do not bury the few that have something.
+function shopsSection(merchants, total) {
+ if (!merchants.length) return h`<section class="sc-sec" aria-labelledby="shops-h"><h2 id="shops-h">Shops</h2><p class="empty">No shops listed yet.</p></section>`;
+ const byName = (a, b) => a.merchant.name.localeCompare(b.merchant.name, 'en', { sensitivity: 'base' });
+ const live = merchants.filter((m) => m.active > 0).sort((a, b) => (b.active - a.active) || byName(a, b));
+ const quiet = merchants.filter((m) => !(m.active > 0)).sort(byName);
+ const chip = (m) => h`<li><a class="chip" href="/m/${m.merchant.slug}">${m.merchant.name}${m.active > 0 ? h` <span class="chip-n">${String(m.active)}<span class="sr-only"> active code${m.active === 1 ? '' : 's'}</span></span>` : ''}</a></li>`;
+ return h`<section class="sc-sec" aria-labelledby="shops-h"><h2 id="shops-h">Shops</h2>
+<p class="meta">${String(total)} shop${total === 1 ? '' : 's'} listed${live.length ? h`; ${String(live.length)} with an active code right now` : ''}.</p>
+${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 }) {
function merchant({ m, domains, hints, active, ended, viewer, csrf, watching, back, flashMsg }) {
return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name }]))}
return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name }]))}
${flash(flashMsg)}
${flash(flashMsg)}
@@ -140,7 +140,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -140,7 +140,8 @@ const { boot, check, done } = require('./helpers/boot');
await check('home, search and the about page (with the formula) render without JavaScript', async () => {
await check('home, search and the about page (with the formula) render without JavaScript', async () => {
const home = await t.get('/');
const home = await t.get('/');
assert.match(home.text, /Green Grocer/);
assert.match(home.text, /Green Grocer/);
- assert.match(home.text, /1 active code</);
+ assert.match(home.text, /Green Grocer <span class="chip-n">1<span class="sr-only"> active code<\/span><\/span>/, 'a shop with a code is a chip with its count, worded for screen readers');
+ assert.match(home.text, /1 shop listed; 1 with an active code right now\./);
// The front page opens with the showcase (openvibe-shared/showcase): its hero is the one h1 and its stylesheet is linked;
// The front page opens with the showcase (openvibe-shared/showcase): its hero is the one h1 and its stylesheet is linked;
// the shops still follow. A search result page has neither.
// the shops still follow. A search result page has neither.
assert.ok(home.text.includes('class="sc-hero') && home.text.includes('class="sc-sec"'), 'the home opens with the showcase');
assert.ok(home.text.includes('class="sc-hero') && home.text.includes('class="sc-sec"'), 'the home opens with the showcase');
@@ -18,7 +18,7 @@ const BUDGETS = {
@@ -18,7 +18,7 @@ const BUDGETS = {
jsRawKB: 245, // 239.1 (212.2 before the shell's web-runtime.js)
jsRawKB: 245, // 239.1 (212.2 before the shell's web-runtime.js)
jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 59, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (coupons.css + showcase.css, home only)
cssFiles: 2, // 2 (coupons.css + showcase.css, home only)
- cssRawKB: 17.5, // 14.8 (4.4 before showcase.css)
+ cssRawKB: 19, // 18.1 (14.8 before 2026-10-15's search bar, offers list and shop chips; 4.4 before showcase.css)
cssBrotliKB: 4.5, // 3.6 (1.2 before showcase.css)
cssBrotliKB: 4.5, // 3.6 (1.2 before showcase.css)
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};