openvibers/openvibe.coupons Commits

Coupons: the front page's search, recent codes and shops are designed like the rest of the network (#49)

OP OpenVibers committed 29 days ago · b423514c3f · 4 files, +43 −8

**The Coupons front page below the showcase looks like the rest of the network.**

**Before:** the showcase sat in a narrower column than the content under it. The search form used the browser's default look. Recent codes were a bullet list with badges wrapping onto new lines, and the shops were 37 bullets, 30 of them "0 active codes".

**Now:**
- **One column.** The showcase drops its own gutter inside `.page`, which already has one, and the four feature cards sit two by two instead of three plus one alone. Every section uses the showcase heading.
- **Find a shop:** a full-width search bar ("Shop name or domain, like amazon.com") with a primary button. The label is kept for screen readers.
- **Recently added codes:** each code is a row with its title (at most two lines) and, under it, the shop and the status badge.
- **Shops:** a count line ("37 shops listed; 7 with an active code right now"). Shops with active codes come first, most codes first, as chips with their count; the count is worded for screen readers ("3 active codes"). The rest fold under "Shops with no active code right now (30)".
- A search result page uses the same chips.

**Checked** locally with fourteen shops and six codes at 1280 and 390 px: no horizontal overflow.

**Tests:** `pages-discovery` now checks the chip with its worded count and the count line. `perf-budget` CSS is raised to 19 KB as a decision (measured 18.1).

Changes

ddiff --git a/public/css/coupons.css b/public/css/coupons.css
iindex 68f7d16..b8f771b 100644
---- a/public/css/coupons.css
++++ b/public/css/coupons.css
@@@ -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); }
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 78b5ae9..63cf704 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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)}
ddiff --git a/test/pages-discovery.test.js b/test/pages-discovery.test.js
iindex 4c5d511..64cd21d 100644
---- a/test/pages-discovery.test.js
++++ b/test/pages-discovery.test.js
@@@ -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');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 3fe2dc0..7c33ac8 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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
};