openvibers/openvibe.coupons Commits
Coupons: a "Remind me" link for a code's end (plan L48 R3) (#172)
Changes
@@@ -338,7 +338,7 @@ function createPublicRoutes(ctx) {title: `${v.code} — ${v.title} at ${m.name}`,description: couponDescription(v, m),decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),-- body: pages.couponPage({ v, m, history: await coupons.history(c.id), weeks: await coupons.reportWeeks(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }), chart: true,++ body: pages.couponPage({ v, m, history: await coupons.history(c.id), weeks: await coupons.reportWeeks(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg, now: store.now() }), chart: true,}, { cacheable: !flashMsg });});@@@ -11,6 +11,7 @@const ssr = require('openvibe-publishing/ssr');const showcase = require('openvibe-shared/showcase');const { STATUS_LABEL, shopsSection } = require('./pages');++const { remindLink } = require('./remind');// The short home (the owner's rule, 2026-10-30): the three code lists become one pick block, a row of chips picks// one of them, and the parts a reader rarely needs become folds.const { pickBlock, foldBlock } = require('./short');@@@ -31,11 +32,15 @@ function until(iso, now) {function codeCard(v, m, now) {const min = (v.restrictions || []).find((r) => r.kind === 'min_spend');const rules = [min ? `Minimum spend ${min.amount}` : null, (v.restrictions || []).some((r) => r.kind === 'new_customers_only') ? 'New customers only' : null].filter(Boolean);++ // "Remind me" for the moment the code ends (plan L48 R3): a quiet link in the card's own line, only when the end++ // is known and still ahead.++ const remind = remindLink(v, { now, shop: m.name });return h`<li class="cp-card"><p class="cp-top"><a class="cp-shop" href="/m/${m.slug}">${m.name}</a> <span class="status status-${v.status}">${STATUS_LABEL[v.status] || v.status}</span></p><p class="cp-code"><span class="sr-only">Code: </span><code class="code">${v.code}</code></p><h3><a href="/c/${v.id}">${v.title}</a></h3><p class="meta">${v.expiry.known ? h`Ends <time datetime="${v.expiry.expires_at}" title="${v.expiry.expires_at.slice(0, 16).replace('T', ' ')} UTC">${until(v.expiry.expires_at, now)}</time>` : h`<span class="unknown">No end date stated</span>`}${rules.length ? h` · ${rules.join(' · ')}` : ''}</p>++${remind ? h`<p class="meta">${raw(remind)}</p>` : ''}${v.attribution ? h`<p class="meta cp-via">via <a href="${v.attribution.url}" rel="nofollow noopener">${v.attribution.text}</a></p>` : ''}</li>`;}@@@ -10,6 +10,7 @@*/const ssr = require('openvibe-publishing/ssr');const kit = require('openvibe-shared/kit');++const { remindLink } = require('./remind');const { html: h, raw } = ssr;const k = (markup) => raw(String(markup)); // a kit builder's markup (already escaped) inside an h`` template@@@ -171,17 +172,21 @@ function quickReport(v, { viewer, csrf, back }) {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) */++/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page, shopUrl, now, shop } (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;// The currency the code states for its money, shown beside the amount on the code's own page: the page// says which currency it is in, and the page's structured data claims nothing more than that.const currency = opts.page ? currencyOf(v, moneyOf(v)) : null;++ // "Remind me" for the moment the code ends (plan L48 R3): on the code's own page it sits with the other actions,++ // on a card it is a quiet link in the card's own line. Only when the end is known and still ahead.++ const remind = remindLink(v, { now: opts.now, shop: opts.shop });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>` : ''}${currency ? h`<span class="cpn-scope">${currency}</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>++<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' } })) : ''}${opts.page && remind ? k(remind) : ''}</div>${statusLine(v)}++${!opts.page && remind ? h`<p class="meta">${k(remind)}</p>` : ''}${restrictionChips(v.restrictions)}${quickReport(v, opts)}<details class="cpn-more"${opts.page ? ' open' : ''}><summary>Details</summary>@@@ -303,11 +308,11 @@ function merchantTrend(trend, name) {<p class="meta">A week with fewer than ${trend.min_people} people is too few to show, so it stays blank.</p>`);}--function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {++function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg, now = Date.now() }) {return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name, url: `/m/${m.slug}` }, { name: v.code }]))}${flash(flashMsg)}${v.active ? '' : h`<p class="notice">This code is not in active results (${STATUS_LABEL[v.status] ? STATUS_LABEL[v.status].toLowerCase() : v.status}${v.expiry.known && !v.active && v.status !== 'expired' ? ', past its expiry' : ''}).</p>`}--${couponCard(v, { viewer, csrf, back, page: true })}++${couponCard(v, { viewer, csrf, back, page: true, now, shop: m.name })}<p class="cpn-people">${peopleLine(v)}</p>${reportTrend(weeks)}<h2>Status history</h2>nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++++/**++ * "Remind me" on a code's end (plan L48 R3): a link to openvibe.today/remind that sets a one-off reminder for the++ * moment the code ends. The person signs in there, picks how long before, and it reaches them in the bell and as a++ * push on every OpenVibe site.++ *++ * The link is only there when the end is known and still ahead: a code that ended, or one nobody dated, gets none.++ * The instant is the end itself, in UTC (a stated day means the end of that day in UTC, as the page says).++ */++const kit = require('openvibe-shared/kit');++++const HOST = 'openvibe.coupons';++const REMIND = 'https://openvibe.today/remind';++++/** The moment a code ends, in UTC, or null when nobody stated an end. */++function endsAt(v) {++ const e = v && v.expiry;++ if (!e || !e.known || !e.expires_at) return null;++ const ms = Date.parse(e.expires_at);++ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;++}++++/** The reminder's title: the code's own name, the shop it is at, and that it ends. */++function remindTitle(v, shop = '') {++ const name = String(v && v.title ? v.title : '').trim();++ const where = String(shop || '').trim();++ return where ? `${name} at ${where} ends` : `${name} ends`;++}++++/** Today's "Remind me" link for this code, or '' when there is nothing to remind about. */++function remindHref(v, { now = Date.now(), shop = '', url = '' } = {}) {++ const at = endsAt(v);++ if (!at || Date.parse(at) <= now) return '';++ const query = new URLSearchParams({ title: remindTitle(v, shop), at, url: url || (v && v.url) || '', from: HOST });++ return `${REMIND}?${query}`;++}++++/** The link, as a quiet small button. */++function remindLink(v, opts = {}) {++ const href = remindHref(v, opts);++ if (!href) return '';++ return kit.button({ href, label: 'Remind me', icon: 'bell', kind: 'quiet', small: true });++}++++/** The same link as a bell only, for a row that has no room for words. */++function remindIcon(v, opts = {}) {++ const href = remindHref(v, opts);++ if (!href) return '';++ return kit.iconButton({ href, icon: 'bell', label: 'Remind me' });++}++++module.exports = { endsAt, remindTitle, remindHref, remindLink, remindIcon, HOST, REMIND };@@@ -22,6 +22,7 @@ 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 { remindLink, remindIcon } = require('./remind');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. */@@@ -133,34 +134,39 @@ function reportForms(v, { csrf, back }) {}/** 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 }) {++function bestCard(v, { now, windowDays, csrf, back, shop = '' }) {const lr = lastReport(v, now, windowDays);const ex = expiryOf(v);const limits = restrictionText(v.restrictions);const codeId = v.id;++ // "Remind me" for the moment the code ends (plan L48 R3): only when the end is known and still ahead.++ const remind = remindLink(v, { now, shop });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>++<p class="cpn-meta">${ex ? h`${ex.short}${ex.said ? h` (${ex.said})` : ''}` : h`<span class="unknown">Expiry unknown</span>`}${remind ? k(remind) : ''}</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 }) {++function codeRow(v, { now, windowDays, csrf, back, shop = '' }) {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;++ // "Remind me" for the moment the code ends (plan L48 R3), with the row's other actions; only when the end is++ // known and still ahead. The row is narrow, so the link is the bell alone.++ const remind = remindIcon(v, { now, shop });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 })}`,++ trail: `${copyButton(codeId, { small: true, kind: 'quiet' })}${remind}${reportForms(v, { csrf, back })}`,});}@@@ -248,7 +254,7 @@ function storePage({ m, domains, hints, codes, deals, ended, reportsThisWeek, prbody = 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.slice(0, shown).map((v) => couponCard(v, { viewer, csrf, back, link: true, shopUrl: m.homepage_url }))}++${deals.length ? h`${deals.slice(0, shown).map((v) => couponCard(v, { viewer, csrf, back, link: true, shopUrl: m.homepage_url, now, shop: m.name }))}${shown < deals.length ? h`<p class="cpn-more"><a class="ovk-btn ovk-btn--quiet" href="${base}?tab=deals&deals=${String(Math.min(deals.length, shown + DEALS_PAGE))}">Show ${String(Math.min(DEALS_PAGE, deals.length - shown))} more <span class="cpn-muted">(${String(shown)} of ${String(deals.length)})</span></a></p>` : ''}` : h`<p class="empty">No product deals for ${m.name} right now.</p>`}${merchantTrend(success, m.name)}`;} else if (!codes.length) {@@@ -261,8 +267,8 @@ ${deals.length ? h`<p class="ovk-note">${String(deals.length)} product deal${dea</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>`) : ''}++ body = h`${best ? bestCard(best, { now, windowDays, csrf, back, shop: m.name }) : ''}++${others.length ? k(`<div class="cpn-list">${kit.list(others.map((v) => codeRow(v, { now, windowDays, csrf, back, shop: m.name })), { 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))) })) : ''}`;nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * "Remind me" on a code's end (plan L48 R3): the link to openvibe.today/remind on the code's own page and on the cards++ * that show the end, with the right title, time and page — and no link at all once the end has passed.++ */++const assert = require('assert');++++const { boot, check, done } = require('./helpers/boot');++++/** Every "Remind me" link on a page, as the URL it points at. */++function remindLinks(html) {++ return [...String(html).matchAll(/href="(https:\/\/openvibe\.today\/remind\?[^"]+)"/g)]++ .map((m) => m[1].replace(/&/g, '&'));++}++const query = (href) => Object.fromEntries(new URL(href).searchParams);++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });++++ await check('a code that ends later links to Today\'s "Remind me" on its page and on the cards', async () => {++ const r = await t.submit(alice, { merchant_id: shop.id, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '2026-12-01' });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ const id = r.json().coupon.id;++++ const page = await t.get(`/c/${id}`);++ const links = remindLinks(page.text);++ assert.ok(links.length, 'the code\'s own page has a "Remind me" link');++ const q = query(links[0]);++ assert.strictEqual(q.title, 'Five pounds off veg boxes at Green Grocer ends');++ assert.strictEqual(q.at, '2026-12-01T23:59:59.999Z', 'the end itself, in UTC');++ assert.strictEqual(q.url, `https://openvibe.coupons/c/${id}`, 'the code\'s canonical page');++ assert.strictEqual(q.from, 'openvibe.coupons');++++ const home = await t.get('/');++ const homeLink = remindLinks(home.text).find((h) => query(h).url === `https://openvibe.coupons/c/${id}`);++ assert.ok(homeLink, 'the front page\'s card has the link too');++ assert.strictEqual(query(homeLink).at, '2026-12-01T23:59:59.999Z');++++ const store = await t.get(`/m/${shop.slug}`);++ const storeLink = remindLinks(store.text).find((h) => query(h).url === `https://openvibe.coupons/c/${id}`);++ assert.ok(storeLink, 'the shop page\'s row has the link too');++ assert.strictEqual(query(storeLink).title, 'Five pounds off veg boxes at Green Grocer ends');++ });++++ await check('a code with no stated end has no "Remind me" link', async () => {++ const r = await t.submit(alice, { merchant_id: shop.id, code: 'NODATE', title: 'Ten percent off anything' });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ const id = r.json().coupon.id;++ const page = await t.get(`/c/${id}`);++ assert.strictEqual(remindLinks(page.text).length, 0, 'no link without an end');++ });++++ await check('a code that has ended has no "Remind me" link', async () => {++ const r = await t.submit(alice, { merchant_id: shop.id, code: 'GONE', title: 'Half off the lot' });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ const id = r.json().coupon.id;++ // The end moves into the past: nobody may be reminded for a code that is over.++ await t.ctx.store.db.prepare('UPDATE coupons SET expires_at = ?, expires_precision = ? WHERE id = ?')++ .run(t.clock.now() - 60_000, 'instant', id);++ const page = await t.get(`/c/${id}`);++ assert.strictEqual(remindLinks(page.text).length, 0, 'no link once the end has passed');++ });++++ await t.close();++ done();++})();
@@ -338,7 +338,7 @@ function createPublicRoutes(ctx) {
@@ -338,7 +338,7 @@ function createPublicRoutes(ctx) {
title: `${v.code} — ${v.title} at ${m.name}`,
title: `${v.code} — ${v.title} at ${m.name}`,
description: couponDescription(v, m),
description: couponDescription(v, m),
decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),
decision: publication.couponDecision(c, m), canonical: v.url, jsonLd: jsonLd(m, v),
- body: pages.couponPage({ v, m, history: await coupons.history(c.id), weeks: await coupons.reportWeeks(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg }), chart: true,
+ body: pages.couponPage({ v, m, history: await coupons.history(c.id), weeks: await coupons.reportWeeks(c.id), viewer: req.viewer, csrf: csrf(req), back: `/c/${c.id}`, flashMsg, now: store.now() }), chart: true,
}, { cacheable: !flashMsg });
}, { cacheable: !flashMsg });
});
});
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { STATUS_LABEL, shopsSection } = require('./pages');
const { STATUS_LABEL, shopsSection } = require('./pages');
+const { remindLink } = require('./remind');
// The short home (the owner's rule, 2026-10-30): the three code lists become one pick block, a row of chips picks
// The short home (the owner's rule, 2026-10-30): the three code lists become one pick block, a row of chips picks
// one of them, and the parts a reader rarely needs become folds.
// one of them, and the parts a reader rarely needs become folds.
const { pickBlock, foldBlock } = require('./short');
const { pickBlock, foldBlock } = require('./short');
@@ -31,11 +32,15 @@ function until(iso, now) {
@@ -31,11 +32,15 @@ function until(iso, now) {
function codeCard(v, m, now) {
function codeCard(v, m, now) {
const min = (v.restrictions || []).find((r) => r.kind === 'min_spend');
const min = (v.restrictions || []).find((r) => r.kind === 'min_spend');
const rules = [min ? `Minimum spend ${min.amount}` : null, (v.restrictions || []).some((r) => r.kind === 'new_customers_only') ? 'New customers only' : null].filter(Boolean);
const rules = [min ? `Minimum spend ${min.amount}` : null, (v.restrictions || []).some((r) => r.kind === 'new_customers_only') ? 'New customers only' : null].filter(Boolean);
+ // "Remind me" for the moment the code ends (plan L48 R3): a quiet link in the card's own line, only when the end
+ // is known and still ahead.
+ const remind = remindLink(v, { now, shop: m.name });
return h`<li class="cp-card">
return h`<li class="cp-card">
<p class="cp-top"><a class="cp-shop" href="/m/${m.slug}">${m.name}</a> <span class="status status-${v.status}">${STATUS_LABEL[v.status] || v.status}</span></p>
<p class="cp-top"><a class="cp-shop" href="/m/${m.slug}">${m.name}</a> <span class="status status-${v.status}">${STATUS_LABEL[v.status] || v.status}</span></p>
<p class="cp-code"><span class="sr-only">Code: </span><code class="code">${v.code}</code></p>
<p class="cp-code"><span class="sr-only">Code: </span><code class="code">${v.code}</code></p>
<h3><a href="/c/${v.id}">${v.title}</a></h3>
<h3><a href="/c/${v.id}">${v.title}</a></h3>
<p class="meta">${v.expiry.known ? h`Ends <time datetime="${v.expiry.expires_at}" title="${v.expiry.expires_at.slice(0, 16).replace('T', ' ')} UTC">${until(v.expiry.expires_at, now)}</time>` : h`<span class="unknown">No end date stated</span>`}${rules.length ? h` · ${rules.join(' · ')}` : ''}</p>
<p class="meta">${v.expiry.known ? h`Ends <time datetime="${v.expiry.expires_at}" title="${v.expiry.expires_at.slice(0, 16).replace('T', ' ')} UTC">${until(v.expiry.expires_at, now)}</time>` : h`<span class="unknown">No end date stated</span>`}${rules.length ? h` · ${rules.join(' · ')}` : ''}</p>
+${remind ? h`<p class="meta">${raw(remind)}</p>` : ''}
${v.attribution ? h`<p class="meta cp-via">via <a href="${v.attribution.url}" rel="nofollow noopener">${v.attribution.text}</a></p>` : ''}
${v.attribution ? h`<p class="meta cp-via">via <a href="${v.attribution.url}" rel="nofollow noopener">${v.attribution.text}</a></p>` : ''}
</li>`;
</li>`;
}
}
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const { remindLink } = require('./remind');
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 k = (markup) => raw(String(markup)); // a kit builder's markup (already escaped) inside an h`` template
@@ -171,17 +172,21 @@ function quickReport(v, { viewer, csrf, back }) {
@@ -171,17 +172,21 @@ function quickReport(v, { viewer, csrf, back }) {
return h`<div class="cpn-report"><span>Did it work?</span>${one('worked', 'It worked')}${one('failed', "It didn't")}</div>`;
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) */
+/** One code. v: coupons.view(); opts: { viewer, csrf, back, link, page, shopUrl, now, shop } (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;
// The currency the code states for its money, shown beside the amount on the code's own page: the page
// The currency the code states for its money, shown beside the amount on the code's own page: the page
// says which currency it is in, and the page's structured data claims nothing more than that.
// says which currency it is in, and the page's structured data claims nothing more than that.
const currency = opts.page ? currencyOf(v, moneyOf(v)) : null;
const currency = opts.page ? currencyOf(v, moneyOf(v)) : null;
+ // "Remind me" for the moment the code ends (plan L48 R3): on the code's own page it sits with the other actions,
+ // on a card it is a quiet link in the card's own line. Only when the end is known and still ahead.
+ const remind = remindLink(v, { now: opts.now, shop: opts.shop });
return h`<article class="coupon cpn cpn--${v.status}" id="${v.id}">
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>` : ''}${currency ? h`<span class="cpn-scope">${currency}</span>` : ''}</div>
<div class="cpn-top"><strong class="cpn-off">${headline(v)}</strong>${isProductDeal(v) ? h`<span class="cpn-scope">One product</span>` : ''}${currency ? h`<span class="cpn-scope">${currency}</span>` : ''}</div>
${opts.page ? h`<h1 class="coupon-title">${title}</h1>` : h`<h3 class="cpn-title">${title}</h3>`}
${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>
+<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' } })) : ''}${opts.page && remind ? k(remind) : ''}</div>
${statusLine(v)}
${statusLine(v)}
+${!opts.page && remind ? h`<p class="meta">${k(remind)}</p>` : ''}
${restrictionChips(v.restrictions)}
${restrictionChips(v.restrictions)}
${quickReport(v, opts)}
${quickReport(v, opts)}
<details class="cpn-more"${opts.page ? ' open' : ''}><summary>Details</summary>
<details class="cpn-more"${opts.page ? ' open' : ''}><summary>Details</summary>
@@ -303,11 +308,11 @@ function merchantTrend(trend, name) {
@@ -303,11 +308,11 @@ function merchantTrend(trend, name) {
<p class="meta">A week with fewer than ${trend.min_people} people is too few to show, so it stays blank.</p>`);
<p class="meta">A week with fewer than ${trend.min_people} people is too few to show, so it stays blank.</p>`);
}
}
-function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg }) {
+function couponPage({ v, m, history, weeks, viewer, csrf, back, flashMsg, now = Date.now() }) {
return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name, url: `/m/${m.slug}` }, { name: v.code }]))}
return h`${raw(ssr.breadcrumbsHtml([{ name: 'Coupons', url: '/' }, { name: m.name, url: `/m/${m.slug}` }, { name: v.code }]))}
${flash(flashMsg)}
${flash(flashMsg)}
${v.active ? '' : h`<p class="notice">This code is not in active results (${STATUS_LABEL[v.status] ? STATUS_LABEL[v.status].toLowerCase() : v.status}${v.expiry.known && !v.active && v.status !== 'expired' ? ', past its expiry' : ''}).</p>`}
${v.active ? '' : h`<p class="notice">This code is not in active results (${STATUS_LABEL[v.status] ? STATUS_LABEL[v.status].toLowerCase() : v.status}${v.expiry.known && !v.active && v.status !== 'expired' ? ', past its expiry' : ''}).</p>`}
-${couponCard(v, { viewer, csrf, back, page: true })}
+${couponCard(v, { viewer, csrf, back, page: true, now, shop: m.name })}
<p class="cpn-people">${peopleLine(v)}</p>
<p class="cpn-people">${peopleLine(v)}</p>
${reportTrend(weeks)}
${reportTrend(weeks)}
<h2>Status history</h2>
<h2>Status history</h2>
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+
+/**
+ * "Remind me" on a code's end (plan L48 R3): a link to openvibe.today/remind that sets a one-off reminder for the
+ * moment the code ends. The person signs in there, picks how long before, and it reaches them in the bell and as a
+ * push on every OpenVibe site.
+ *
+ * The link is only there when the end is known and still ahead: a code that ended, or one nobody dated, gets none.
+ * The instant is the end itself, in UTC (a stated day means the end of that day in UTC, as the page says).
+ */
+const kit = require('openvibe-shared/kit');
+
+const HOST = 'openvibe.coupons';
+const REMIND = 'https://openvibe.today/remind';
+
+/** The moment a code ends, in UTC, or null when nobody stated an end. */
+function endsAt(v) {
+ const e = v && v.expiry;
+ if (!e || !e.known || !e.expires_at) return null;
+ const ms = Date.parse(e.expires_at);
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
+}
+
+/** The reminder's title: the code's own name, the shop it is at, and that it ends. */
+function remindTitle(v, shop = '') {
+ const name = String(v && v.title ? v.title : '').trim();
+ const where = String(shop || '').trim();
+ return where ? `${name} at ${where} ends` : `${name} ends`;
+}
+
+/** Today's "Remind me" link for this code, or '' when there is nothing to remind about. */
+function remindHref(v, { now = Date.now(), shop = '', url = '' } = {}) {
+ const at = endsAt(v);
+ if (!at || Date.parse(at) <= now) return '';
+ const query = new URLSearchParams({ title: remindTitle(v, shop), at, url: url || (v && v.url) || '', from: HOST });
+ return `${REMIND}?${query}`;
+}
+
+/** The link, as a quiet small button. */
+function remindLink(v, opts = {}) {
+ const href = remindHref(v, opts);
+ if (!href) return '';
+ return kit.button({ href, label: 'Remind me', icon: 'bell', kind: 'quiet', small: true });
+}
+
+/** The same link as a bell only, for a row that has no room for words. */
+function remindIcon(v, opts = {}) {
+ const href = remindHref(v, opts);
+ if (!href) return '';
+ return kit.iconButton({ href, icon: 'bell', label: 'Remind me' });
+}
+
+module.exports = { endsAt, remindTitle, remindHref, remindLink, remindIcon, HOST, REMIND };
@@ -22,6 +22,7 @@ const k = (markup) => raw(String(markup));
@@ -22,6 +22,7 @@ const k = (markup) => raw(String(markup));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const { isProductDeal, headline, shopTab, merchantTrend, couponCard, regionName, BASIS_LABEL } = require('./pages');
const { isProductDeal, headline, shopTab, merchantTrend, couponCard, regionName, BASIS_LABEL } = require('./pages');
+const { remindLink, remindIcon } = require('./remind');
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
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. */
/** Below this many reports a code's rate is not shown at all: the page says only that validity is unknown. */
@@ -133,34 +134,39 @@ function reportForms(v, { csrf, back }) {
@@ -133,34 +134,39 @@ function reportForms(v, { csrf, back }) {
}
}
/** The best code, first on the page: the code in a dashed box, its rate, when it was last reported and its expiry. */
/** 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 }) {
+function bestCard(v, { now, windowDays, csrf, back, shop = '' }) {
const lr = lastReport(v, now, windowDays);
const lr = lastReport(v, now, windowDays);
const ex = expiryOf(v);
const ex = expiryOf(v);
const limits = restrictionText(v.restrictions);
const limits = restrictionText(v.restrictions);
const codeId = v.id;
const codeId = v.id;
+ // "Remind me" for the moment the code ends (plan L48 R3): only when the end is known and still ahead.
+ const remind = remindLink(v, { now, shop });
return h`<section class="cpn-best-card" id="cpn-best-card" aria-labelledby="cpn-best-h">
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>
<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>
<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>
<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-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">${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>
+<p class="cpn-meta">${ex ? h`${ex.short}${ex.said ? h` (${ex.said})` : ''}` : h`<span class="unknown">Expiry unknown</span>`}${remind ? k(remind) : ''}</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>`}
${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>
<div class="cpn-votes">${reportForms(v, { csrf, back })}</div>
</section>`;
</section>`;
}
}
/** One code as a compact row: the code, the same facts and the same buttons. */
/** One code as a compact row: the code, the same facts and the same buttons. */
-function codeRow(v, { now, windowDays, csrf, back }) {
+function codeRow(v, { now, windowDays, csrf, back, shop = '' }) {
const lr = lastReport(v, now, windowDays);
const lr = lastReport(v, now, windowDays);
const ex = expiryOf(v);
const ex = expiryOf(v);
const facts = [statusText(v), lr.text, ex ? (ex.said ? `${ex.short} (${ex.said})` : ex.short) : 'Expiry unknown'];
const facts = [statusText(v), lr.text, ex ? (ex.said ? `${ex.short} (${ex.said})` : ex.short) : 'Expiry unknown'];
const limits = restrictionText(v.restrictions);
const limits = restrictionText(v.restrictions);
const codeId = v.id;
const codeId = v.id;
+ // "Remind me" for the moment the code ends (plan L48 R3), with the row's other actions; only when the end is
+ // known and still ahead. The row is narrow, so the link is the bell alone.
+ const remind = remindIcon(v, { now, shop });
return kit.row({
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>`,
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>` : ''}`,
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 })}`,
+ trail: `${copyButton(codeId, { small: true, kind: 'quiet' })}${remind}${reportForms(v, { csrf, back })}`,
});
});
}
}
@@ -248,7 +254,7 @@ function storePage({ m, domains, hints, codes, deals, ended, reportsThisWeek, pr
@@ -248,7 +254,7 @@ function storePage({ m, domains, hints, codes, deals, ended, reportsThisWeek, pr
body = h`${aboutTab({ m, domains, hints, codes: all, price, now, windowDays })}`;
body = h`${aboutTab({ m, domains, hints, codes: all, price, now, windowDays })}`;
} else if (tab === 'deals') {
} 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>
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.slice(0, shown).map((v) => couponCard(v, { viewer, csrf, back, link: true, shopUrl: m.homepage_url }))}
+${deals.length ? h`${deals.slice(0, shown).map((v) => couponCard(v, { viewer, csrf, back, link: true, shopUrl: m.homepage_url, now, shop: m.name }))}
${shown < deals.length ? h`<p class="cpn-more"><a class="ovk-btn ovk-btn--quiet" href="${base}?tab=deals&deals=${String(Math.min(deals.length, shown + DEALS_PAGE))}">Show ${String(Math.min(DEALS_PAGE, deals.length - shown))} more <span class="cpn-muted">(${String(shown)} of ${String(deals.length)})</span></a></p>` : ''}` : h`<p class="empty">No product deals for ${m.name} right now.</p>`}
${shown < deals.length ? h`<p class="cpn-more"><a class="ovk-btn ovk-btn--quiet" href="${base}?tab=deals&deals=${String(Math.min(deals.length, shown + DEALS_PAGE))}">Show ${String(Math.min(DEALS_PAGE, deals.length - shown))} more <span class="cpn-muted">(${String(shown)} of ${String(deals.length)})</span></a></p>` : ''}` : h`<p class="empty">No product deals for ${m.name} right now.</p>`}
${merchantTrend(success, m.name)}`;
${merchantTrend(success, m.name)}`;
} else if (!codes.length) {
} else if (!codes.length) {
@@ -261,8 +267,8 @@ ${deals.length ? h`<p class="ovk-note">${String(deals.length)} product deal${dea
@@ -261,8 +267,8 @@ ${deals.length ? h`<p class="ovk-note">${String(deals.length)} product deal${dea
</section>
</section>
${ended.length ? k(kit.fold({ title: 'Expired', meta: String(ended.length), body: String(kit.list(ended.map(endedRow))) })) : ''}`;
${ended.length ? k(kit.fold({ title: 'Expired', meta: String(ended.length), body: String(kit.list(ended.map(endedRow))) })) : ''}`;
} else {
} 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>`) : ''}
+ body = h`${best ? bestCard(best, { now, windowDays, csrf, back, shop: m.name }) : ''}
+${others.length ? k(`<div class="cpn-list">${kit.list(others.map((v) => codeRow(v, { now, windowDays, csrf, back, shop: m.name })), { title: 'Other codes' })}</div>`) : ''}
${merchantTrend(success, m.name)}
${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>
<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))) })) : ''}`;
${ended.length ? k(kit.fold({ title: 'Expired', meta: String(ended.length), body: String(kit.list(ended.map(endedRow))) })) : ''}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * "Remind me" on a code's end (plan L48 R3): the link to openvibe.today/remind on the code's own page and on the cards
+ * that show the end, with the right title, time and page — and no link at all once the end has passed.
+ */
+const assert = require('assert');
+
+const { boot, check, done } = require('./helpers/boot');
+
+/** Every "Remind me" link on a page, as the URL it points at. */
+function remindLinks(html) {
+ return [...String(html).matchAll(/href="(https:\/\/openvibe\.today\/remind\?[^"]+)"/g)]
+ .map((m) => m[1].replace(/&/g, '&'));
+}
+const query = (href) => Object.fromEntries(new URL(href).searchParams);
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const shop = await t.merchant({ name: 'Green Grocer', host: 'greengrocer.co.uk' });
+
+ await check('a code that ends later links to Today\'s "Remind me" on its page and on the cards', async () => {
+ const r = await t.submit(alice, { merchant_id: shop.id, code: 'VEG5', title: 'Five pounds off veg boxes', expires: '2026-12-01' });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ const id = r.json().coupon.id;
+
+ const page = await t.get(`/c/${id}`);
+ const links = remindLinks(page.text);
+ assert.ok(links.length, 'the code\'s own page has a "Remind me" link');
+ const q = query(links[0]);
+ assert.strictEqual(q.title, 'Five pounds off veg boxes at Green Grocer ends');
+ assert.strictEqual(q.at, '2026-12-01T23:59:59.999Z', 'the end itself, in UTC');
+ assert.strictEqual(q.url, `https://openvibe.coupons/c/${id}`, 'the code\'s canonical page');
+ assert.strictEqual(q.from, 'openvibe.coupons');
+
+ const home = await t.get('/');
+ const homeLink = remindLinks(home.text).find((h) => query(h).url === `https://openvibe.coupons/c/${id}`);
+ assert.ok(homeLink, 'the front page\'s card has the link too');
+ assert.strictEqual(query(homeLink).at, '2026-12-01T23:59:59.999Z');
+
+ const store = await t.get(`/m/${shop.slug}`);
+ const storeLink = remindLinks(store.text).find((h) => query(h).url === `https://openvibe.coupons/c/${id}`);
+ assert.ok(storeLink, 'the shop page\'s row has the link too');
+ assert.strictEqual(query(storeLink).title, 'Five pounds off veg boxes at Green Grocer ends');
+ });
+
+ await check('a code with no stated end has no "Remind me" link', async () => {
+ const r = await t.submit(alice, { merchant_id: shop.id, code: 'NODATE', title: 'Ten percent off anything' });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ const id = r.json().coupon.id;
+ const page = await t.get(`/c/${id}`);
+ assert.strictEqual(remindLinks(page.text).length, 0, 'no link without an end');
+ });
+
+ await check('a code that has ended has no "Remind me" link', async () => {
+ const r = await t.submit(alice, { merchant_id: shop.id, code: 'GONE', title: 'Half off the lot' });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ const id = r.json().coupon.id;
+ // The end moves into the past: nobody may be reminded for a code that is over.
+ await t.ctx.store.db.prepare('UPDATE coupons SET expires_at = ?, expires_precision = ? WHERE id = ?')
+ .run(t.clock.now() - 60_000, 'instant', id);
+ const page = await t.get(`/c/${id}`);
+ assert.strictEqual(remindLinks(page.text).length, 0, 'no link once the end has passed');
+ });
+
+ await t.close();
+ done();
+})();