openvibers/openvibe.deals Commits

Deals: a "Remind me" link for a deal's end (plan L48 R3) (#169)

OP OpenVibers committed 12 days ago · 73e9cac7b3 · 6 files, +129 −4

* Deals: a Remind me link for a deal's end (plan L48 R3)

A deal that ends later now says so: a quiet 'Remind me' link beside the page's own button on the
deal's page, and one in the card's own line on the front page and in the listings. It opens
openvibe.today/remind with the deal's name, the end itself in UTC, the deal'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 deal with no stated end, or one that has ended, gets no link. This site carries no kit button
styles, so the link wears the site's own and the bell comes from the shared kit.

* Deals: run the checks for the Remind me link

* Merge main into the Remind me branch

Changes

ddiff --git a/public/css/deals.css b/public/css/deals.css
iindex d0211d7..8131279 100644
---- a/public/css/deals.css
++++ b/public/css/deals.css
@@@ -158,6 +158,12 @@ code { overflow-wrap: anywhere; }
.hm-card .badge { font-size: .72rem; }
.hm-ends { margin: 0; font-size: .85rem; font-weight: 600; }
.hm-ends .meta { font-weight: 400; font-size: .78rem; }
++
++/* "Remind me" (plan L48 R3): a quiet link with the shared kit's bell, in a card's own line and beside a page's
++ own buttons. This site carries no kit button styles, so the link wears the site's own. */
++.remind { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; text-decoration: none; white-space: nowrap; }
++.remind-action { margin: 10px 0 0 10px; }
++.remind .ovk-i { width: 1.05em; height: 1.05em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.hm-where { margin: 0; font-size: .82rem; overflow-wrap: anywhere; }
.hm-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.hm-chips li { min-width: 0; max-width: 100%; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 6718604..1a38ac7 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -47,7 +47,7 @@ function createPages(ctx) {
// Per-actor limits on the forms (http/actor-limits.js), before the form is read: each shares its budget
// with the API route that does the same thing. Reading pages is left to the per-address limits.
const B = (name) => actorLimits.budget(name);
-- const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath, sellerPath: publication.sellerPath, categoryPath: publication.categoryPath };
++ const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath, sellerPath: publication.sellerPath, categoryPath: publication.categoryPath, abs: publication.abs };
const form = express.urlencoded({ extended: false, limit: '32kb' });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
ddiff --git a/server/render/home.js b/server/render/home.js
iindex be0094c..9beaa36 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -12,6 +12,7 @@
const { html, raw } = require('openvibe-publishing/ssr');
const showcase = require('openvibe-shared/showcase');
const { ago, when, priceText } = require('./views');
++const { remindLink } = require('./remind');
const { campaignCard } = require('../http/crowdfunding');
const { pickBlock, pickRules, foldBlock } = require('./short');
@@@ -55,10 +56,13 @@ function card(v, urls, { ends = false } = {}) {
const where = r.seller ? html`at <a href="${urls.sellerPath(r.seller)}">${r.seller}</a>`
: v.store && !v.attribution ? html`at <a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a>` : '';
const via = v.attribution ? html`via <a href="${safeHref(v.attribution.url)}" rel="nofollow noopener">${v.attribution.text}</a>` : '';
++ // "Remind me" for the moment the deal ends (plan L48 R3): a quiet link in the card's own line, only when the end
++ // is stated and still ahead.
++ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(urls.offerPath(r)) : '' });
return html`<li class="hm-card">
<h3><a href="${urls.offerPath(r)}">${r.title}</a></h3>
<p class="hm-price">${price} ${seenBadge(v)}</p>
--${ends && r.expires_at ? html`<p class="hm-ends">Ends <time datetime="${new Date(r.expires_at).toISOString()}" title="${when(r.expires_at)}">${until(r.expires_at, v.now)}</time> <span class="meta">(as the source states it)</span></p>` : ''}
++${ends && r.expires_at ? html`<p class="hm-ends">Ends <time datetime="${new Date(r.expires_at).toISOString()}" title="${when(r.expires_at)}">${until(r.expires_at, v.now)}</time> <span class="meta">(as the source states it)</span>${remind ? raw(remind) : ''}</p>` : ''}
${where || via ? html`<p class="meta hm-where">${where}${where && via ? ' · ' : ''}${via}</p>` : ''}
</li>`;
}
ddiff --git a/server/render/remind.js b/server/render/remind.js
nnew file mode 100644
iindex 0000000..4ddd50e
---- /dev/null
++++ b/server/render/remind.js
@@@ -0,0 +1,41 @@
++'use strict';
++
++/**
++ * "Remind me" on a deal's end (plan L48 R3): a link to openvibe.today/remind that sets a one-off reminder for the
++ * moment the deal 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 stated and still ahead: a deal with no end, or one that has ended, gets
++ * none. The instant is the end itself, in UTC.
++ */
++const kit = require('openvibe-shared/kit');
++
++const HOST = 'openvibe.deals';
++const REMIND = 'https://openvibe.today/remind';
++
++/** The moment a deal ends, in UTC, or null when no end is stated. */
++function endsAt(offer) {
++ const raw = offer && offer.expires_at;
++ if (raw == null || raw === '') return null;
++ const d = new Date(raw);
++ return Number.isFinite(d.getTime()) ? d.toISOString() : null;
++}
++
++/** The reminder's title: the deal's own name, the shop it is at, and that it ends. */
++function remindTitle(offer, shop = '') {
++ const name = String(offer && offer.title ? offer.title : '').trim();
++ const where = String(shop || '').trim();
++ return where ? `${name} at ${where} ends` : `${name} ends`;
++}
++
++/** Today's "Remind me" link for this deal, or '' when there is nothing to remind about. */
++function remindLink(offer, { now = Date.now(), shop = '', url = '', action = false } = {}) {
++ const at = endsAt(offer);
++ if (!at || Date.parse(at) <= now) return '';
++ const query = new URLSearchParams({ title: remindTitle(offer, shop), at, url, from: HOST });
++ const href = `${REMIND}?${query}`.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
++ const cls = action ? 'remind remind-action' : 'remind';
++ return `<a class="${cls}" href="${href}">${kit.icon('bell', { size: 16 })}<span>Remind me</span></a>`;
++}
++
++module.exports = { endsAt, remindTitle, remindLink, HOST, REMIND };
ddiff --git a/server/render/views.js b/server/render/views.js
iindex dfe4408..19dad5b 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -10,6 +10,7 @@
const { html, raw, renderMarkdown, paginationHtml, breadcrumbsHtml } = require('openvibe-publishing/ssr');
const { AVAILABILITY_LABEL, CONDITIONS, AVAILABILITY } = require('../domain/values');
const { savingOf } = require('../domain/price-trend');
++const { remindLink } = require('./remind');
const pad = (n) => String(n).padStart(2, '0');
function when(ms) {
@@@ -152,12 +153,15 @@ ${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : '
function offerCard(v, urls) {
const r = v.root;
const href = urls.offerPath(r);
++ // "Remind me" for the moment the deal ends (plan L48 R3): a quiet link beside the end the card states, only when
++ // the end is stated and still ahead.
++ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(href) : '' });
return html`<li class="deal-item">
<div class="deal-body">
<h2 class="deal-title"><a href="${href}">${r.title}</a> ${statusBadge(r)}</h2>
${priceRow(v)}
${seenLine(v)}
--${r.status === 'expired' ? '' : html`<p class="dl-when">${expiryLine(r, v.now)}</p>`}
++${r.status === 'expired' ? '' : html`<p class="dl-when">${expiryLine(r, v.now)}${remind ? raw(remind) : ''}</p>`}
${attributionLine(v.attribution)}
<p class="meta">${r.seller ? html`<a class="seller" href="${urls.sellerPath ? urls.sellerPath(r.seller) : '#'}">at ${r.seller}</a> · ` : ''}${v.store ? html`<a href="${urls.storePath(v.store)}">${v.store.name || v.store.domain}</a> · ` : ''}${v.product ? html`<a href="${urls.productPath(v.product)}">${v.product.name}</a> · ` : ''}posted ${timeEl(r.created_at)}${r.origin === 'import' ? ' · imported from a source' : ''}</p>
</div>
@@@ -258,6 +262,9 @@ function offerPage({ v, dto, viewer, csrf, myVote, comments: c, mergedComments,
const signedIn = viewer.kind === 'user' && viewer.subject;
const own = Boolean(signedIn && [r, ...v.members].some((o) => o.submitted_by === viewer.subject));
const crumbs = [{ name: 'Deals', url: '/' }, ...(v.store ? [{ name: v.store.name || v.store.domain, url: urls.storePath(v.store) }] : []), { name: r.title }];
++ // "Remind me" for the moment the deal ends (plan L48 R3), beside the page's other actions; only when the end is
++ // stated and still ahead.
++ const remind = remindLink(r, { now: v.now, shop: v.store ? (v.store.name || v.store.domain) : '', url: urls.abs ? urls.abs(urls.offerPath(r)) : '', action: true });
const voteForm = signedIn && r.status !== 'disabled' ? (own ? html`<p class="meta">You posted this deal.</p>` : html`<form class="inline vote" method="post" action="${urls.offerPath(r)}/vote">${csrfField(csrf)}
<button name="value" value="up"${myVote === 1 ? raw(' aria-pressed="true"') : ''}>▲ Hot</button>
<button name="value" value="down"${myVote === -1 ? raw(' aria-pressed="true"') : ''}>▼ Cold</button>
@@@ -271,7 +278,7 @@ ${r.status === 'expired' ? html`<p class="notice">This deal expired${r.expired_a
<section class="latest" aria-label="Latest observation">
${latestLine(v)}
${v.freshness.state === 'stale' ? html`<p class="notice stale-notice">This price was last observed ${timeEl(v.latest.observed_at)}. Deals shows it as history, not as the price now. If you see the deal, report what the page says below.</p>` : ''}
--<p><a class="button" href="${safeHref(r.url)}" rel="nofollow noopener ugc">Go to the deal at ${v.store ? v.store.name || v.store.domain : 'the store'}</a></p>
++<p><a class="button" href="${safeHref(r.url)}" rel="nofollow noopener ugc">Go to the deal at ${v.store ? v.store.name || v.store.domain : 'the store'}</a>${remind ? raw(remind) : ''}</p>
${r.expires_at && r.status === 'active' ? html`<p class="meta">Stated end: ${timeEl(r.expires_at)}</p>` : html`<p class="meta">End date: not stated</p>`}
</section>
<section class="votes" aria-label="Votes">
ddiff --git a/test/remind-links.test.js b/test/remind-links.test.js
nnew file mode 100644
iindex 0000000..e430561
---- /dev/null
++++ b/test/remind-links.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * "Remind me" on a deal's end (plan L48 R3): the link to openvibe.today/remind on the deal'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.replace(/&amp;/g, '&')).searchParams);
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++
++ await check('a deal that ends later links to Today\'s "Remind me" on its page and on the cards', async () => {
++ const offer = await t.submit(alice, {
++ url: 'https://shop.example/steam-deck', title: 'Steam Deck OLED 512GB', price: '549.00', currency: 'USD',
++ seller: 'Corner Shop', expires_at: '2026-09-25T18:00',
++ });
++ const slug = offer.slug;
++
++ const page = await t.get(`/d/${slug}`);
++ const links = remindLinks(page.text);
++ assert.ok(links.length, 'the deal\'s own page has a "Remind me" link');
++ const q = query(links[0]);
++ assert.strictEqual(q.at, '2026-09-25T18:00:00.000Z', 'the end itself, in UTC');
++ assert.strictEqual(q.url, `https://openvibe.deals/d/${slug}`, 'the deal\'s canonical page');
++ assert.strictEqual(q.from, 'openvibe.deals');
++ assert.ok(q.title.includes('Steam Deck OLED 512GB'), q.title);
++ assert.ok(q.title.endsWith(' ends'), q.title);
++
++ const listing = await t.get('/hot');
++ const cardLink = remindLinks(listing.text).find((h) => query(h).url === `https://openvibe.deals/d/${slug}`);
++ assert.ok(cardLink, 'the listing card has the link too');
++
++ const home = await t.get('/');
++ const homeLink = remindLinks(home.text).find((h) => query(h).url === `https://openvibe.deals/d/${slug}`);
++ assert.ok(homeLink, 'the front page\'s card has the link too');
++ assert.strictEqual(query(homeLink).at, '2026-09-25T18:00:00.000Z');
++ });
++
++ await check('a deal with no stated end has no "Remind me" link', async () => {
++ const offer = await t.submit(alice, { url: 'https://shop.example/kettle', title: 'Kettle', price: '20.00', currency: 'EUR' });
++ const page = await t.get(`/d/${offer.slug}`);
++ assert.strictEqual(remindLinks(page.text).length, 0, 'no link without an end');
++ });
++
++ await check('a deal that has ended has no "Remind me" link', async () => {
++ const offer = await t.submit(alice, {
++ url: 'https://shop.example/lamp', title: 'Reading lamp', price: '30.00', currency: 'EUR',
++ expires_at: '2026-09-25T18:00',
++ });
++ // The end moves into the past: nobody may be reminded for a deal that is over.
++ await t.ctx.store.db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(t.clock.now() - 60_000, offer.id);
++ const page = await t.get(`/d/${offer.slug}`);
++ assert.strictEqual(remindLinks(page.text).length, 0, 'no link once the end has passed');
++ });
++
++ await t.close();
++ done();
++})();