openvibers/openvibe.deals Commits

A deal card says what a deal is worth, and a shop says who it is (market outcomes) (#168)

OP OpenVibers committed 12 days ago · bf61e0529b · 7 files, +224 −17

* A deal card says what a deal is worth, and a shop says who it is (market outcomes)

The deal card, from what Deals already stores:
- the price, the stated list price struck through and the saving;
- 'Price seen 12 hours ago · 4 prices on record' (or 'Last seen … it may be gone');
- the stated expiry with its precision ('Ends in 15 hours', a date said as a date), else
  'Expiry unknown', never an assumed one;
- the price over time as a sparkline once there are two prices, its lowest point marked;
- what the votes count ('7 found this useful') and the band: Editors' pick, Popular, New today.

A shop's page leads with the shop: its letter, its link, its active deals and how many times its
prices were seen in 24 hours, and 'Alert me about deals here', which opens the watch form filled
in for it.

* The card and shop styles in their own file, off the home page

The home draws its own cards, so deal-cards.css loads on every other page and the home's CSS
budget stays where it was.

Changes

ddiff --git a/public/css/deal-cards.css b/public/css/deal-cards.css
nnew file mode 100644
iindex 0000000..38ac811
---- /dev/null
++++ b/public/css/deal-cards.css
@@@ -0,0 +1,44 @@
++/* The deal list's cards and a shop's head (market outcomes 1-6). Every page but the home loads this after
++ deals.css (server/render/layout.js); the home draws its own cards, so its budget does not carry these. */
++/* ── The card (market outcomes 1-5): price, was-price and saving; when it was seen and how much sits behind it; the
++ stated expiry or "Expiry unknown"; on the right the price over time and what the votes say ── */
++.dl-seen { margin: 0 0 4px; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; font-size: .82rem; color: var(--text-secondary, #96a7c2); }
++.dl-seen time { color: var(--text-primary, #e8eef8); font-weight: 650; }
++.dl-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--success, #22c55e); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success, #22c55e) 18%, transparent); }
++.dl-dot--stale { background: var(--warning, #f59e0b); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning, #f59e0b) 18%, transparent); }
++.dl-when { margin: 0 0 4px; font-size: .8rem; }
++.dl-ends { color: var(--text-secondary, #96a7c2); }
++.dl-ends--soon { color: var(--warning-text, var(--warning, #f59e0b)); font-weight: 700; }
++.dl-ends--unknown, .dl-ends--past { color: var(--text-muted, #7386a3); }
++.deal-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; min-width: 112px; }
++.dl-spark { margin: 0; display: grid; justify-items: end; gap: 2px; }
++.dl-spark svg { display: block; overflow: visible; }
++.dl-spark polyline { fill: none; stroke: var(--accent, #3b82f6); stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
++.dl-spark circle.lo { fill: var(--success, #22c55e); }
++.dl-spark circle.now { fill: var(--bg-card, #131c2e); stroke: var(--accent, #3b82f6); stroke-width: 1.8; }
++.dl-spark circle.now.at-low { stroke: var(--success, #22c55e); }
++.dl-spark figcaption { font-size: .7rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; white-space: nowrap; }
++.dl-vote { margin: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px; }
++.dl-useful { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); font-size: .78rem; font-weight: 700;
++ color: var(--text-secondary, #96a7c2); text-decoration: none; white-space: nowrap; }
++.dl-useful.has { color: var(--success-text, var(--success, #22c55e)); border-color: color-mix(in srgb, var(--success, #22c55e) 40%, var(--border, #1f2d47)); }
++.dl-useful:hover { border-color: var(--accent, #3b82f6); }
++.dl-band { padding: 2px 8px; border-radius: 999px; font-size: .7rem; font-weight: 800; letter-spacing: .02em; white-space: nowrap; }
++.dl-band--pick { color: #f5b301; background: color-mix(in srgb, #f5b301 16%, transparent); }
++.dl-band--popular { color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.dl-band--new { color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--text-primary, #e8eef8) 7%, transparent); }
++@media (max-width: 600px) {
++ .deal-item { grid-template-columns: minmax(0, 1fr); padding: 12px; }
++ .deal-side { flex-direction: row; align-items: center; min-width: 0; padding-top: 8px; border-top: 1px solid var(--border, #1f2d47); }
++ .dl-spark { grid-template-columns: auto auto; align-items: center; gap: 8px; justify-items: start; }
++}
++/* ── A shop's page (market outcome 6): who it is, how alive it is, and an alert ── */
++.dl-shop { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; margin: 6px 0 14px; }
++.dl-shop-mark { display: grid; place-items: center; width: 54px; height: 54px; border-radius: 16px; font-size: 1.5rem; font-weight: 900;
++ color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-card, #131c2e)); border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
++.dl-shop-t h1 { margin: 0; overflow-wrap: anywhere; }
++.dl-shop-t .lede { margin: 2px 0 0; font-size: .9rem; }
++.dl-alert { display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 999px; font-weight: 700; font-size: .88rem; text-decoration: none;
++ color: var(--on-accent, #fff); background: var(--accent, #3b82f6); white-space: nowrap; }
++.dl-alert:hover { filter: brightness(1.08); }
++@media (max-width: 600px) { .dl-shop { grid-template-columns: auto minmax(0, 1fr); } .dl-alert { grid-column: 1 / -1; justify-content: center; } }
ddiff --git a/public/css/deals.css b/public/css/deals.css
iindex a20587b..d0211d7 100644
---- a/public/css/deals.css
++++ b/public/css/deals.css
@@@ -48,7 +48,7 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
.tabs form.search input { width: 200px; }
.tabs form.search button { margin: 0; }
.deal-list { list-style: none; padding: 0; margin: 12px 0 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
--.deal-item { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 14px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
++.deal-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
.deal-item:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
.deal-item .score { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; height: 56px; border-radius: 12px; font-weight: 800; font-size: 1rem; color: var(--text-secondary, #96a7c2); background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); }
.deal-item .score span { font-size: .7rem; line-height: 1; color: var(--text-muted, #7386a3); }
@@@ -69,7 +69,7 @@ form.inline button { margin: 0 0 0 8px; padding: 2px 10px; font-size: .85rem; }
color: var(--success-text, var(--success, #22c55e)); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
.attribution { margin: 0; font-size: .82rem; color: var(--text-muted, #7386a3); }
.deal-item .meta { margin: 2px 0 0; font-size: .82rem; }
--@media (max-width: 600px) { .deal-item { grid-template-columns: 44px minmax(0, 1fr); padding: 12px; } .deal-item .score { height: 48px; } }
++@media (max-width: 600px) { .deal-item { padding: 12px; } }
@media (prefers-reduced-motion: reduce) { .deal-item { transition: none; } }
.badge.fresh { border-color: var(--success, #22c55e); color: var(--success-text, var(--success, #22c55e)); }
.badge.stale, .badge.expired { border-color: var(--warning, #f59e0b); color: var(--warning-text, var(--warning, #f59e0b)); }
ddiff --git a/server/domain/listings.js b/server/domain/listings.js
iindex c1f7f54..290d0b8 100644
---- a/server/domain/listings.js
++++ b/server/domain/listings.js
@@@ -30,6 +30,10 @@ function createListings({ store }) {
countActive: db.prepare("SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL"),
byStore: db.prepare(`SELECT * FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
countStore: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status <> 'disabled'`),
++ // A shop's own line (market outcome 6): how many deals are active, and how often its prices were seen today.
++ storeStats: db.prepare(`SELECT (SELECT COUNT(*) FROM deal_offers WHERE store_id = ? AND merged_into IS NULL AND status = 'active') AS active,
++ (SELECT COUNT(*) FROM deal_price_observations p JOIN deal_offers o ON o.id = p.offer_id
++ WHERE o.store_id = ? AND p.observed_at >= ?) AS seen24`),
// Sellers: the shop a source names (migration 0006), as stated; a seller page lists by that exact name.
sellers: db.prepare(`SELECT seller, COUNT(*) AS n, MAX(created_at) AS latest FROM deal_offers WHERE seller IS NOT NULL AND merged_into IS NULL AND status <> 'disabled' GROUP BY seller ORDER BY n DESC, seller LIMIT 2000`),
bySeller: db.prepare(`SELECT * FROM deal_offers WHERE seller = ? AND merged_into IS NULL AND status <> 'disabled' ORDER BY status = 'active' DESC, created_at DESC LIMIT ? OFFSET ?`),
@@@ -78,6 +82,10 @@ function createListings({ store }) {
countActive: async () => (await q.countActive.get()).n,
byStore: async (storeId, limit, offset) => await q.byStore.all(storeId, limit, offset),
countStore: async (storeId) => (await q.countStore.get(storeId)).n,
++ storeStats: async (storeId, now = Date.now()) => {
++ const r = await q.storeStats.get(storeId, storeId, now - 24 * 3_600_000);
++ return { active: Number(r.active) || 0, seen24: Number(r.seen24) || 0 };
++ },
sellers: async () => (await q.sellers.all()).map((r) => ({ seller: r.seller, slug: sellerSlug(r.seller), count: Number(r.n), latest: r.latest })),
bySeller: async (seller, limit, offset) => await q.bySeller.all(seller, limit, offset),
countSeller: async (seller) => (await q.countSeller.get(seller)).n,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f3b7c00..6718604 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -428,7 +428,7 @@ function createPages(ctx) {
agent: true,
title: `Deals at ${st.name || st.domain}`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
-- body: views.storePage({ st, views: list, pager, urls }),
++ body: views.storePage({ st, views: list, pager, urls, stats: await listings.storeStats(st.id, store.now()) }),
};
}
@@@ -558,9 +558,14 @@ function createPages(ctx) {
// ── watches ─────────────────────────────────────────────
++ /** /watches?kind=keyword&query=zappos.com, from a shop's "Alert me": only a known kind and plain words. */
++ const watchValues = (q = {}) => ({
++ kind: ['keyword', 'price_below', 'product', 'search'].includes(String(q.kind)) ? String(q.kind) : 'keyword',
++ query: typeof q.query === 'string' ? q.query.replace(/[\u0000-\u001f]/g, '').trim().slice(0, 200) : '',
++ });
async function watchesPage(req, res, status = 200, { error, notice } = {}) {
const list = await Promise.all((await watches.list(req.viewer.subject)).map(watches.dto));
-- send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls }) });
++ send(req, res, status, { title: 'Watches', decision: pageDecision('/watches', { indexable: false }), body: views.watchesPage({ csrf: csrf(req), watches: list, error, notice, urls, values: watchValues(req.query) }) });
}
router.get('/watches', wrap(async (req, res) => {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 2e59f5a..135dc5d 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -93,7 +93,7 @@ function renderPage(o) {
return layout.renderDocument({
// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick
// block (there is one per pane, written for the page).
-- head: [agentHead, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
++ head: [agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
site: 'deals',
siteName: SITE_NAME,
lang: o.lang,
ddiff --git a/server/render/views.js b/server/render/views.js
iindex d71323f..dfe4408 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -83,17 +83,85 @@ ${o && o.availability ? html` · <span class="meta">${AVAILABILITY_LABEL[o.avail
${o ? html` <span class="meta as-of">as of ${timeEl(o.observed_at)}</span>` : ''} ${freshnessBadge(v.freshness, v.now)}</p>`;
}
++/**
++ * The price over time as a line the size of a word (plan: market outcome 4): the stated prices of the offer's
++ * currency, oldest to newest, its lowest point marked. Nothing when there is only one price.
++ */
++function sparkline(t) {
++ if (!t || !Array.isArray(t.points) || t.points.length < 2) return '';
++ const pts = t.points.slice(-24);
++ const W = 104, H = 30, P = 3;
++ const lo = Math.min(...pts.map((p) => p.num)), hi = Math.max(...pts.map((p) => p.num));
++ const x = (i) => P + (i * (W - 2 * P)) / (pts.length - 1);
++ const y = (n) => (hi === lo ? H / 2 : P + ((hi - n) * (H - 2 * P)) / (hi - lo));
++ const line = pts.map((p, i) => `${x(i).toFixed(1)},${y(p.num).toFixed(1)}`).join(' ');
++ const li = pts.reduce((a, p, i) => (p.num < pts[a].num ? i : a), 0);
++ const last = pts.length - 1;
++ const label = `Price over ${t.count} observations: ${t.first.price} to ${t.latest.price}${t.currency ? ` ${t.currency}` : ''}, lowest ${t.lowest.price}`;
++ return html`<figure class="dl-spark" data-points="${pts.length}"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}"><polyline points="${line}"/><circle class="lo" cx="${x(li).toFixed(1)}" cy="${y(pts[li].num).toFixed(1)}" r="2.4"/><circle class="now${t.atLowest ? ' at-low' : ''}" cx="${x(last).toFixed(1)}" cy="${y(pts[last].num).toFixed(1)}" r="2.6"/></svg>
++<figcaption>${t.atLowest ? 'Lowest seen' : html`low ${t.lowest.price}`}</figcaption></figure>`;
++}
++
++/** What the people who voted said, and the band it puts the deal in (market outcome 5). */
++function bandOf(v) {
++ const r = v.root;
++ if (r.staff_pick) return ['pick', 'Editors\' pick'];
++ if (v.tally && v.tally.up >= 5) return ['popular', 'Popular'];
++ if (r.created_at && v.now - Number(r.created_at) < 24 * 3_600_000) return ['new', 'New today'];
++ return null;
++}
++function voteLine(v, href) {
++ const t = v.tally || { up: 0, down: 0 };
++ const band = bandOf(v);
++ const said = t.up ? `${t.up} found this useful` : 'Be the first to rate it';
++ return html`<p class="dl-vote"><a href="${href}#vote" class="dl-useful${t.up ? ' has' : ''}" title="${t.up} up, ${t.down} down"><span aria-hidden="true">▲</span> ${said}</a>${band ? html` <span class="dl-band dl-band--${band[0]}">${band[1]}</span>` : ''}</p>`;
++}
++
++/** The expiry a source stated, with how precise it was; never an assumed one (market outcome 3). */
++function expiryLine(r, now) {
++ if (r.status === 'expired') return '';
++ if (r.expires_at == null) return html`<span class="dl-ends dl-ends--unknown">Expiry unknown</span>`;
++ const at = Number(r.expires_at);
++ const left = at - now;
++ if (left <= 0) return html`<span class="dl-ends dl-ends--past">Stated to have ended ${timeEl(at)}</span>`;
++ const soon = left < 48 * 3_600_000;
++ const d = new Date(at);
++ // A stated time at midnight UTC is a date, not a moment: say the day.
++ const day = d.getUTCHours() === 0 && d.getUTCMinutes() === 0;
++ const inWords = left < 90 * 60_000 ? `${Math.max(1, Math.round(left / 60_000))} minutes` : `${Math.round(left / 3_600_000)} hours`;
++ return html`<span class="dl-ends${soon ? ' dl-ends--soon' : ''}">${soon ? `Ends in ${inWords}:` : 'Ends'} <time datetime="${d.toISOString()}">${day ? when(at).slice(0, 10) : when(at)}</time>${day ? ' (a date, no time stated)' : ''}</span>`;
++}
++
++/** When the price was seen and how much sits behind it (market outcome 2), in the card's own words. */
++function seenLine(v) {
++ const o = v.latest;
++ if (!o) return html`<p class="dl-seen"><span class="badge stale" data-freshness="unobserved">No price seen yet</span></p>`;
++ const n = v.trend ? v.trend.count : 1;
++ const f = v.freshness || {};
++ return html`<p class="dl-seen"><span class="dl-dot dl-dot--${f.state === 'fresh' ? 'fresh' : 'stale'}" data-freshness="${f.state || 'unobserved'}" aria-hidden="true"></span>${f.state === 'stale' ? 'Last seen' : 'Price seen'} <time datetime="${new Date(Number(o.observed_at)).toISOString()}" title="${when(Number(o.observed_at))}">${ago(Number(o.observed_at), v.now)}</time>${f.state === 'stale' ? ' — it may be gone' : ''} · ${n} price${n === 1 ? '' : 's'} on record${v.root.deal_reason === 'price_drop' ? ' · lowest so far' : ''}</p>`;
++}
++
++/** The price, the was-price struck through and the saving, shipping and stock (market outcome 1). */
++function priceRow(v) {
++ const o = v.latest;
++ const stated = o && o.price != null;
++ return html`<p class="price-line"><strong class="price${stated ? '' : ' price-none'}" data-price="${stated ? o.price : ''}">${priceText(o)}</strong>${listPriceLine(o)}
++${o && shippingText(o) ? html` <span class="meta">${shippingText(o)}</span>` : ''}${o && o.availability ? html` <span class="meta">· ${AVAILABILITY_LABEL[o.availability]}</span>` : ''}</p>`;
++}
++
function offerCard(v, urls) {
const r = v.root;
-- const score = v.tally.up - v.tally.down;
++ const href = urls.offerPath(r);
return html`<li class="deal-item">
--<div class="score${score > 0 ? ' up' : score < 0 ? ' down' : ''}" title="${v.tally.up} up, ${v.tally.down} down"><span aria-hidden="true">▲</span>${score > 0 ? `+${score}` : score}</div>
<div class="deal-body">
--<h2 class="deal-title"><a href="${urls.offerPath(r)}">${r.title}</a> ${statusBadge(r)}</h2>
--${latestLine(v)}
++<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>`}
${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></li>`;
++</div>
++<div class="deal-side">${sparkline(v.trend)}${voteLine(v, href)}</div></li>`;
}
function offerList({ heading, intro, views, pager, urls, tabs, empty, lead }) {
@@@ -301,7 +369,9 @@ ${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
</form>`;
}
--function watchesPage({ csrf, watches, error, notice, urls }) {
++function watchesPage({ csrf, watches, error, notice, urls, values = {} }) {
++ // Arriving from a shop's "Alert me" (/watches?kind=keyword&query=zappos.com): the form says so, filled in.
++ const kindOpt = (k, label) => html`<option value="${k}"${values.kind === k ? raw(' selected') : ''}>${label}</option>`;
return html`<h1>Watches and saved searches</h1>
<p class="lede">A watch tells you once when a new observation matches it (at most one notification per observation). A saved search never notifies. Notifications are delivered by the OpenVibe network; Deals never emails.</p>
${error ? html`<p class="notice error" role="alert">${error}</p>` : ''}
@@@ -311,9 +381,9 @@ ${w.query ? html` “${w.query}”` : ''}${w.product ? html` <a href="${w.produc
${w.kind === 'search' ? html` · <a href="/search?q=${encodeURIComponent(w.query)}">run</a>` : html` · ${w.notifications} notifications`}
<form class="inline" method="post" action="/watches/${w.id}/delete">${csrfField(csrf)}<button type="submit">Delete</button></form></li>`)}</ul>` : html`<p class="empty">No watches yet.</p>`}
<form method="post" action="/watches" class="card">${csrfField(csrf)}
--<h2>Add</h2>
--<label for="w-kind">Kind</label><select id="w-kind" name="kind"><option value="keyword">Keywords (notify)</option><option value="price_below">Price below (notify)</option><option value="product">Product (notify)</option><option value="search">Saved search (no notifications)</option></select>
--<label for="w-q">Keywords</label><input id="w-q" name="query" maxlength="200">
++<h2>${values.query ? html`Alert me about “${values.query}”` : 'Add'}</h2>
++<label for="w-kind">Kind</label><select id="w-kind" name="kind">${kindOpt('keyword', 'Keywords (notify)')}${kindOpt('price_below', 'Price below (notify)')}${kindOpt('product', 'Product (notify)')}${kindOpt('search', 'Saved search (no notifications)')}</select>
++<label for="w-q">Keywords</label><input id="w-q" name="query" maxlength="200" value="${values.query || ''}">
<label for="w-p">Product (slug, for product and price watches)</label><input id="w-p" name="product">
<label for="w-max">Price below</label><input id="w-max" name="max_price" inputmode="decimal">
<label for="w-cur">Currency</label><input id="w-cur" name="currency" maxlength="3" placeholder="USD">
@@@ -344,10 +414,12 @@ ${duplicates.length ? html`<ul>${duplicates.map((d) => html`<li><a href="${urls.
<form class="inline" method="post" action="/mod/offers/${d.b.slug}/merge">${csrfField(csrf)}<input type="hidden" name="into" value="${d.a.slug}"><button type="submit">Merge the newer into the older</button></form></li>`)}</ul>` : html`<p class="empty">None found.</p>`}`;
}
--function storePage({ st, views, pager, urls }) {
++function storePage({ st, views, pager, urls, stats = null }) {
++ const n = (x) => Number(x || 0).toLocaleString('en-US');
return html`${raw(breadcrumbsHtml([{ name: 'Deals', url: '/' }, { name: st.name || st.domain }]))}
--<h1>${st.name || st.domain}</h1>
--<p class="lede">Deals at <code>${st.domain}</code>.</p>
++<header class="dl-shop"><span class="dl-shop-mark" aria-hidden="true">${String(st.name || st.domain).replace(/^www\./, '').slice(0, 1).toUpperCase()}</span>
++<div class="dl-shop-t"><h1>${st.name || st.domain}</h1><p class="lede"><a href="https://${st.domain}" rel="nofollow noopener">${st.domain}</a>${stats ? html` · ${n(stats.active)} active deal${Number(stats.active) === 1 ? '' : 's'} · prices seen ${n(stats.seen24)} time${Number(stats.seen24) === 1 ? '' : 's'} in 24 hours` : ''}</p></div>
++<a class="dl-alert" href="/watches?kind=keyword&amp;query=${encodeURIComponent(st.domain)}">Alert me about deals here</a></header>
${views.length ? html`<ol class="deal-list">${views.map((v) => offerCard(v, urls))}</ol>` : html`<p class="empty">No deals from this store.</p>`}
${raw(pager ? paginationHtml(pager) : '')}`;
}
ddiff --git a/test/deal-cards.test.js b/test/deal-cards.test.js
nnew file mode 100644
iindex 0000000..c1efb2b
---- /dev/null
++++ b/test/deal-cards.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * The deal card and the shop page (market outcomes 1-6): the price with the was-price struck through and the saving;
++ * when the price was seen and how many prices sit behind it; the stated expiry with its precision or "Expiry
++ * unknown"; the price over time as a sparkline once there are two; what the votes count and the band; and a shop's
++ * own line: active deals, prices seen in 24 hours, and an alert prefilled for it.
++ */
++const assert = require('assert');
++const { boot, check, done, HOUR } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const [alice, bob, cara] = ['alice', 'bob', 'cara'].map((n) => t.network.addUser(n));
++ const db = t.ctx.store.db;
++ let a, b;
++ const card = (html, title) => {
++ const i = html.indexOf(`>${title}</a>`);
++ assert.ok(i > 0, `a card for ${title}`);
++ return html.slice(html.lastIndexOf('<li class="deal-item">', i), html.indexOf('</li>', i));
++ };
++
++ await check('one price: the price, when it was seen, one price on record, expiry unknown, no sparkline', async () => {
++ a = await t.submit(alice, { url: 'https://shop.example/headphones', title: 'Studio headphones', price: '49.99', currency: 'USD', availability: 'in_stock' });
++ const c = card((await t.get('/s/shop.example')).text, 'Studio headphones');
++ assert.match(c, /<strong class="price" data-price="49.99">49.99 USD<\/strong>/);
++ assert.match(c, /Price seen <time datetime="[^"]+" title="[^"]+">just now<\/time> · 1 price on record/);
++ assert.match(c, /data-freshness="fresh"/);
++ assert.match(c, /<span class="dl-ends dl-ends--unknown">Expiry unknown<\/span>/, 'never an assumed expiry');
++ assert.doesNotMatch(c, /dl-spark/, 'one price is not a line');
++ assert.match(c, /Be the first to rate it/);
++ assert.match(c, /dl-band--new">New today/);
++ });
++
++ await check('more prices, a stated list price, a stated end, votes: the whole card', async () => {
++ for (const [h, price] of [[2, '45.00'], [4, '39.99']]) {
++ t.clock.advance(h * HOUR);
++ const r = await t.get(`/d/${a.slug}/observe`, { as: bob, form: { price, currency: 'USD', csrf: t.csrf(bob) } });
++ assert.ok([200, 303].includes(r.status), `observe ${r.status}: ${r.text.slice(0, 200)}`);
++ }
++ await db.prepare("UPDATE deal_price_observations SET list_price = '59.99', list_price_num = 59.99 WHERE offer_id = ? AND price = '39.99'").run(a.id);
++ await db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(t.clock.now() + 30 * HOUR, a.id);
++ for (const u of [bob, cara]) await t.get(`/d/${a.slug}/vote`, { as: u, form: { value: 'up', csrf: t.csrf(u) } });
++ const c = card((await t.get('/s/shop.example')).text, 'Studio headphones');
++ assert.match(c, /<s class="dl-list" data-list-price="59.99">59.99 USD<\/s> <span class="dl-saving">−33%<\/span>/, 'the was-price and the saving');
++ assert.match(c, /· 3 prices on record/);
++ assert.match(c, /<figure class="dl-spark" data-points="3"><svg [^>]*role="img" aria-label="Price over 3 observations: 49.99 to 39.99 USD, lowest 39.99">/);
++ assert.match(c, /<figcaption>Lowest seen<\/figcaption>/, 'the latest is the lowest');
++ assert.match(c, /<span class="dl-ends dl-ends--soon">Ends in 30 hours: <time datetime=/, 'a stated end, soon');
++ assert.match(c, /2 found this useful/, 'the vote says what it counts');
++ t.clock.advance(25 * HOUR);
++ await db.prepare('UPDATE deal_offers SET expires_at = ? WHERE id = ?').run(Date.UTC(2027, 0, 15), a.id);
++ const later = card((await t.get('/s/shop.example')).text, 'Studio headphones');
++ assert.match(later, /Ends <time datetime="2027-01-15T00:00:00.000Z">2027-01-15<\/time> \(a date, no time stated\)/, 'a date is said as a date');
++ assert.doesNotMatch(later, /New today/);
++ });
++
++ await check('the band of an editors\' pick', async () => {
++ b = await t.submit(alice, { url: 'https://shop.example/lamp', title: 'Desk lamp', price: '20.00', currency: 'USD' });
++ await db.prepare('UPDATE deal_offers SET staff_pick = true WHERE id = ?').run(b.id);
++ assert.match(card((await t.get('/s/shop.example')).text, 'Desk lamp'), /dl-band--pick">Editors&#39; pick|dl-band--pick">Editors' pick/);
++ });
++
++ await check('the shop: its line, its active deals, prices seen in 24 hours, an alert prefilled for it', async () => {
++ const r = await t.get('/s/shop.example');
++ assert.match(r.text, /<header class="dl-shop">/);
++ assert.match(r.text, /<a href="https:\/\/shop\.example" rel="nofollow noopener">shop\.example<\/a> · 2 active deals · prices seen \d+ times? in 24 hours/);
++ assert.match(r.text, /<a class="dl-alert" href="\/watches\?kind=keyword&amp;query=shop\.example">Alert me about deals here<\/a>/);
++ const w = await t.get('/watches?kind=keyword&query=shop.example', { as: alice });
++ assert.match(w.text, /<h2>Alert me about “shop\.example”<\/h2>/);
++ assert.match(w.text, /<option value="keyword" selected>/);
++ assert.match(w.text, /<input id="w-q" name="query" maxlength="200" value="shop\.example">/);
++ const odd = await t.get('/watches?kind=evil&query=%3Cb%3Ex%3C%2Fb%3E', { as: alice });
++ assert.match(odd.text, /<option value="keyword" selected>/, 'an unknown kind is a keyword watch');
++ assert.ok(!odd.text.includes('<b>x</b>'), 'the words are text');
++ });
++
++ await done(t);
++})();