openvibers/openvibe.coupons Commits

Coupons: a "Remind me" link for a code's end (plan L48 R3) (#172)

OP OpenVibers committed 12 days ago · ef0289a427 · 6 files, +149 −12

Every code that ends later now says so: a quiet 'Remind me' link beside the other actions on the
code's own page, and one in the card's own line on the front page, the shop's page and the product
deals. It opens openvibe.today/remind with the code's name, the end itself in UTC, the code's
canonical page and this site's host, so the person signs in there, picks how long before, and the
reminder reaches them in the bell and as a push.

A code with no stated end, or one that has ended, gets no link.

Changes

ddiff --git a/server/http/public.js b/server/http/public.js
iindex 72d7cde..a861cf1 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -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 });
});
ddiff --git a/server/render/home.js b/server/render/home.js
iindex cca4af0..afb5c5f 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -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>`;
}
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex ac3d7b9..0f1cd3b 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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>
ddiff --git a/server/render/remind.js b/server/render/remind.js
nnew file mode 100644
iindex 0000000..efd784a
---- /dev/null
++++ b/server/render/remind.js
@@@ -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 };
ddiff --git a/server/render/store.js b/server/render/store.js
iindex 0e4791d..e2e0981 100644
---- a/server/render/store.js
++++ b/server/render/store.js
@@@ -22,6 +22,7 @@ const k = (markup) => raw(String(markup));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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, pr
body = h`${aboutTab({ m, domains, hints, codes: all, price, now, windowDays })}`;
} else if (tab === 'deals') {
body = h`<p class="ovk-note cpn-lede">Codes that take money off one product, as the source posted them.</p>
--${deals.length ? h`${deals.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&amp;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))) })) : ''}`;
ddiff --git a/test/remind-links.test.js b/test/remind-links.test.js
nnew file mode 100644
iindex 0000000..c459f54
---- /dev/null
++++ b/test/remind-links.test.js
@@@ -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(/&amp;/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();
++})();