openvibers/openvibe.deals Commits

The Deals front page shows editors' picks, the newest deals, what ends soon, deals under $20, categories, shops and crowdfunding, with the site's numbers, how it works and answers to common questions (#75)

OP OpenVibers committed 22 days ago · c5808fb2a8 · 16 files, +533 −59

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6910e9e..ef19fc6 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,7 @@
## Unreleased
++- The front page is curated (`server/render/home.js`): a search and the site's numbers beside the hero, then editors' picks, hot, just in, ending soon, under $20, categories, shops and crowdfunding, how it works and six questions (FAQPage and ItemList JSON-LD). The hot list moved to `/hot` (`/?page=N` → 301); new `/ending`, `/picks`, `/c` and `/c/:category`; `/sitemaps/pages.xml` lists them all (the sitemap had no page a person browses). Imported deals keep their source's category and editors' pick (Sources feed@3; migration `0007_offer_category_pick.sql` adds `staff_pick`). Budgets: html 29.1 KB, css 24.2 KB.
- Account export and deletion (ADR-033): `network.account.export_requested` and `network.account.deleted` are answered at `POST /internal/events` by `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0). The person's watches, votes, name and rate windows are deleted; what they contributed to the shared catalog stays without their id; staff moderation records stay attributed. The two subscriptions are created at boot. Migration `0003_account_data.sql` adds `account_data_events`. `test/account-data.test.js`.
- openvibe-contracts moves from v0.79.0 to v0.97.0 (pin, lockfile and `node_modules`); nothing in the range breaks Deals, and the contracts' own service check is green. All twelve `deals.*` ids are now defined by the release, so `server/auth/capabilities.js` drops its local fallback for proposed ids and sends every check through the library's grant rule; `test/capabilities.test.js` pins that every guarded id is defined and that exact, prefix, denied and unknown ids answer as `capabilities.check()` does. README and STATUS.json name v0.97.0.
- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. Styling is unchanged.
ddiff --git a/README.md b/README.md
iindex 6e31143..4568bc5 100644
---- a/README.md
++++ b/README.md
@@@ -62,7 +62,9 @@ gauges to `telemetry_samples` every minute and on stop, and deletes rows older t
| Route | What it is |
|---|---|
--| `GET /`, `GET /new` | hot deals (latest `hot@1` snapshots) and newest deals; each card shows the latest price **as of** its time with a fresh/stale badge |
++| `GET /` | the front page (`server/render/home.js`): a search, the site's numbers (active deals, shops, prices seen in 24 hours, ending this week, categories, crowdfunding campaigns), then editors' picks, hot (voted up), just in, ending soon, under $20 (a fresh stated price), categories, shops and crowdfunding, how it works and questions (FAQPage JSON-LD). A deal shows once; a section with nothing to show is left out. `/?page=N` moved to `/hot?page=N` (301). |
++| `GET /hot`, `GET /new`, `GET /ending`, `GET /picks` | hot deals (latest `hot@1` snapshots), newest deals, deals whose stated end is in the next 7 days (soonest first) and a source's editors' picks; each card shows the latest price **as of** its time with a fresh/stale badge |
++| `GET /c`, `GET /c/:category` | deals by category, as each deal's source names it (DealNews' `dealnews:category`, Sources feed@3; migration 0007) |
| `GET /d/:slug` | an offer: latest observation "as of", freshness, stated end (or "not stated"), votes and hotness with the formula, description (safe Markdown), AI/import disclosure, **price history table** (every observation, newest first, with its source), sources, merged listings, discussion. Forms: vote, report what the page says now, report a problem, mark expired (submitter), moderation. `…/:slug.json` is the same facts as data. A merged listing answers 301 to its canonical offer; a disabled one 410. |
| `GET /p/:slug` | a product: **price comparison** across its offers, each with its own latest observation and time (`.json` too) |
| `GET /s/:domain` | a store's deals |
@@@ -72,7 +74,7 @@ gauges to `telemetry_samples` every minute and on stop, and deletes rows older t
| `GET/POST /watches` | watches and saved searches; `POST /watches/:id/delete` |
| `GET /mod` | moderators: open flags (incl. vote rings), text waiting for review, possible duplicates; `POST /mod/offers/:slug/(merge\|unmerge\|disable\|enable\|review\|expire)`, `POST /mod/flags/:id/(resolve\|dismiss)` |
| `GET /feed.xml`, `/atom.xml`, `/feed.json` | newest active deals, with "as of" times and stable ids `deals:offer:<id>` |
--| `GET /robots.txt`, `/llms.txt`, `/sitemap.xml`, `/sitemaps/offers.xml`, `/sitemaps/products.xml` | discovery (below) |
++| `GET /robots.txt`, `/llms.txt`, `/sitemap.xml`, `/sitemaps/pages.xml`, `/sitemaps/offers.xml`, `/sitemaps/products.xml` | discovery (below); `pages.xml` lists the front page and every list (hot, new, ending, picks, each category and shop, crowdfunding) while it has deals |
| `GET /api/health`, `/api/ready`, `/release.json`, `/metrics` | operations (`/metrics` loopback only; nginx never proxies it) |
| `/auth/*` | Network SSO (the same session layer as Community/Blog) |
ddiff --git a/migrations/0007_offer_category_pick.sql b/migrations/0007_offer_category_pick.sql
nnew file mode 100644
iindex 0000000..7e5131f
---- /dev/null
++++ b/migrations/0007_offer_category_pick.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- What a deal source says about a deal besides its price (server/domain/source-items.js): DealNews' feed names each
++-- deal's category ("TVs", "Cleaning Supplies"; kept in the existing deal_offers.category, exactly as stated) and
++-- whether its editors picked it. staff_pick is that statement (true/false), null when no source said. The home page
++-- and /c/<category> list by them.
++ALTER TABLE deal_offers ADD COLUMN IF NOT EXISTS staff_pick boolean;
++CREATE INDEX IF NOT EXISTS deal_offers_category ON deal_offers (category) WHERE category IS NOT NULL AND merged_into IS NULL;
ddiff --git a/public/css/deals.css b/public/css/deals.css
iindex fda765c..2397f41 100644
---- a/public/css/deals.css
++++ b/public/css/deals.css
@@@ -99,3 +99,48 @@ code { overflow-wrap: anywhere; }
.cf-links .button { margin: 0; padding: 6px 12px; }
.seller-list { columns: 3 220px; padding-left: 1.1rem; }
.seller-list li { margin: 4px 0; break-inside: avoid; }
++/* The front page (server/render/home.js): stats beside the hero, then sections of deal cards, chips and questions. */
++.hm-hero { padding-top: 32px; }
++.hm-search { display: flex; gap: 8px; margin-top: 20px; max-width: 560px; }
++.hm-search input { flex: 1 1 auto; min-width: 0; width: auto; }
++.hm-search button { margin: 0; }
++.hm-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
++.hm-stats li { min-width: 0; }
++.hm-stats a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; text-decoration: none; transition: border-color .15s; }
++.hm-stats a:hover, .hm-stats a:focus-visible { border-color: var(--accent, #3b82f6); }
++.hm-stats b { font-size: 1.6rem; line-height: 1.1; font-variant-numeric: tabular-nums; }
++.hm-stats span { font-size: .85rem; line-height: 1.35; color: var(--text-secondary, #96a7c2); }
++.hm-sec { margin-top: 48px; }
++.hm-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; }
++.hm-head h2 { margin: 0; font-size: clamp(1.3rem, 2.4vw, 1.75rem); letter-spacing: -.01em; }
++.hm-more { font-weight: 600; font-size: .95rem; text-decoration: none; white-space: nowrap; }
++.hm-more:hover { text-decoration: underline; text-underline-offset: 3px; }
++.hm-lede { margin: 4px 0 14px; color: var(--text-secondary, #96a7c2); font-size: .98rem; }
++.hm-cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }
++.hm-card { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s; }
++.hm-card:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); }
++.hm-card h3 { margin: 0; font-size: 1rem; line-height: 1.35; overflow-wrap: anywhere; }
++.hm-card h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }
++.hm-card h3 a:hover { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 3px; }
++.hm-price { margin: auto 0 0; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
++.hm-card .price { font-size: 1.15rem; }
++.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; }
++.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%; }
++.hm-chips a { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; overflow-wrap: anywhere; }
++.hm-chips a:hover, .hm-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
++.hm-n { font-size: .78rem; font-weight: 700; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
++.hm-cf { margin: 0; }
++.hm-faq { display: grid; gap: 8px; }
++.hm-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 0 16px; }
++.hm-faq summary { cursor: pointer; font-weight: 600; padding: 12px 0; }
++.hm-faq p { margin: 0 0 14px; color: var(--text-secondary, #96a7c2); }
++.hm-empty { margin-top: 24px; }
++@media (max-width: 600px) { .hm-search { flex-wrap: wrap; } .hm-search button { width: 100%; } .hm-stats b { font-size: 1.3rem; } .hm-stats a { padding: 12px; } }
++@media (prefers-reduced-motion: reduce) { .hm-card, .hm-stats a { transition: none; } }
++.hm-card .price-none { font-size: .88rem; font-weight: 500; }
++/* A phone shows four cards a section (all eight stay in the page for crawlers and the "see all" link has the rest). */
++@media (max-width: 600px) { .hm-cards > li:nth-child(n+5) { display: none; } }
ddiff --git a/server/domain/listings.js b/server/domain/listings.js
iindex af86f48..c1f7f54 100644
---- a/server/domain/listings.js
++++ b/server/domain/listings.js
@@@ -7,6 +7,11 @@
* newest active offers by first posting time
* search active (or all non-disabled, with expired) offers whose title/description contain every word
* store a store's offers, expired ones included and marked
++ * category active offers in one category as their source states it (migration 0007), newest first
++ * ending active offers whose STATED end falls in a window, soonest first (an unknown end is never assumed)
++ * picks active offers a source's editors picked (staff_pick), newest first
++ * upvoted active offers whose latest hotness is above zero (people voted them up), hottest first
++ * under active offers whose latest observation states a price at or below a limit in one currency
*/
const { tokens } = require('./values');
@@@ -36,6 +41,20 @@ function createListings({ store }) {
AND ((a.product_id IS NOT NULL AND b.product_id = a.product_id) OR lower(b.title) = lower(a.title))
WHERE a.merged_into IS NULL AND b.merged_into IS NULL AND a.status <> 'disabled' AND b.status <> 'disabled'
ORDER BY b.created_at DESC LIMIT ?`),
++ categories: db.prepare(`SELECT category, COUNT(*) AS n, MAX(created_at) AS latest FROM deal_offers WHERE category IS NOT NULL AND status = 'active' AND merged_into IS NULL GROUP BY category ORDER BY n DESC, category LIMIT 1000`),
++ byCategory: db.prepare(`SELECT * FROM deal_offers WHERE category = ? AND status = 'active' AND merged_into IS NULL ORDER BY created_at DESC, seq DESC LIMIT ? OFFSET ?`),
++ countCategory: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE category = ? AND status = 'active' AND merged_into IS NULL`),
++ ending: db.prepare(`SELECT * FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND expires_at > ? AND expires_at <= ? ORDER BY expires_at, seq LIMIT ? OFFSET ?`),
++ countEnding: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND expires_at > ? AND expires_at <= ?`),
++ picks: db.prepare(`SELECT * FROM deal_offers WHERE staff_pick = true AND status = 'active' AND merged_into IS NULL ORDER BY created_at DESC, seq DESC LIMIT ? OFFSET ?`),
++ countPicks: db.prepare(`SELECT COUNT(*) AS n FROM deal_offers WHERE staff_pick = true AND status = 'active' AND merged_into IS NULL`),
++ upvoted: db.prepare(`SELECT o.*, h.hot AS hot FROM deal_offers o JOIN deal_hotness_snapshots h ON ${LATEST}
++ WHERE o.status = 'active' AND o.merged_into IS NULL AND h.hot > 0 ORDER BY h.hot DESC, o.created_at DESC, o.seq DESC LIMIT ?`),
++ // The latest observation of each offer (the one a card shows), when it states a price in this currency at or below the limit.
++ under: db.prepare(`SELECT o.* FROM deal_offers o JOIN deal_price_observations p ON p.id = (SELECT id FROM deal_price_observations WHERE offer_id = o.id ORDER BY observed_at DESC, seq DESC LIMIT 1)
++ WHERE o.status = 'active' AND o.merged_into IS NULL AND p.price_num IS NOT NULL AND p.price_num <= ? AND p.currency = ? AND p.observed_at > ?
++ ORDER BY o.created_at DESC, o.seq DESC LIMIT ?`),
++ seenSince: db.prepare('SELECT COUNT(*) AS n FROM deal_price_observations WHERE observed_at > ?'),
pendingReview: db.prepare(`SELECT * FROM deal_offers WHERE review_state = 'pending' AND merged_into IS NULL AND status <> 'disabled' ORDER BY created_at DESC LIMIT ?`),
};
@@@ -66,11 +85,21 @@ function createListings({ store }) {
products: async () => await q.products.all(),
possibleDuplicates: async (limit = 50) => await q.duplicates.all(limit),
pendingReview: async (limit = 50) => await q.pendingReview.all(limit),
++ categories: async () => (await q.categories.all()).map((r) => ({ category: r.category, slug: sellerSlug(r.category), count: Number(r.n), latest: r.latest == null ? null : Number(r.latest) })),
++ byCategory: async (category, limit, offset) => await q.byCategory.all(category, limit, offset),
++ countCategory: async (category) => Number((await q.countCategory.get(category)).n),
++ endingSoon: async (from, to, limit, offset = 0) => await q.ending.all(from, to, limit, offset),
++ countEnding: async (from, to) => Number((await q.countEnding.get(from, to)).n),
++ picks: async (limit, offset = 0) => await q.picks.all(limit, offset),
++ countPicks: async () => Number((await q.countPicks.get()).n),
++ upvoted: async (limit) => await q.upvoted.all(limit),
++ under: async (max, currency, observedAfter, limit) => await q.under.all(max, currency, observedAfter, limit),
++ seenSince: async (t) => Number((await q.seenSince.get(t)).n),
search,
};
}
--/** "Best Buy" → "best-buy", "Target.com" → "target-com": a seller's address under /at/. */
++/** "Best Buy" → "best-buy", "Target.com" → "target-com": a seller's address under /at/ (and a category's under /c/). */
const sellerSlug = (name) => String(name || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
module.exports = { createListings, sellerSlug };
ddiff --git a/server/domain/publication.js b/server/domain/publication.js
iindex 634f12f..dc76f39 100644
---- a/server/domain/publication.js
++++ b/server/domain/publication.js
@@@ -28,6 +28,7 @@ function createPublication({ config, store, reads, catalog }) {
const productPath = (p) => `/p/${encodeURIComponent(p.slug)}`;
const storePath = (s) => `/s/${encodeURIComponent(s.domain)}`;
const sellerPath = (name) => `/at/${require('./listings').sellerSlug(name)}`;
++ const categoryPath = (name) => `/c/${require('./listings').sellerSlug(name)}`;
function freshness(obs, now = store.now()) {
if (!obs) return { state: 'unobserved', observed_at: null, age_ms: null, max_age_ms: config.freshnessMs };
@@@ -89,6 +90,17 @@ function createPublication({ config, store, reads, catalog }) {
};
}
++ /**
++ * The little a card needs (the home page's sections): the group's latest observation, its freshness, the store and
++ * the attribution a source requires. Four reads instead of offerView's ten, so a page of 40 cards stays quick.
++ */
++ async function cardView(offer, now = store.now()) {
++ const root = offer.merged_into ? await reads.root(offer) : offer;
++ const ids = await reads.groupIds(root.id);
++ const latest = await reads.latestObservation(ids);
++ return { root, ids, store: await catalog.store(root.store_id), latest, freshness: freshness(latest, now), attribution: attributionOf(await reads.sources(ids)), now };
++ }
++
function observationDto(o) {
return {
id: o.id, observed_at: iso(o.observed_at), recorded_at: iso(o.recorded_at),
@@@ -190,7 +202,7 @@ function createPublication({ config, store, reads, catalog }) {
});
}
-- return { abs, offerPath, productPath, storePath, sellerPath, freshness, decideOffer, decideProduct, offerView, offerDto, observationDto, offerLd, offerJsonLd, productView, productJsonLd };
++ return { abs, offerPath, productPath, storePath, sellerPath, categoryPath, freshness, decideOffer, decideProduct, offerView, cardView, offerDto, observationDto, offerLd, offerJsonLd, productView, productJsonLd };
}
module.exports = { createPublication };
ddiff --git a/server/domain/source-items.js b/server/domain/source-items.js
iindex f21e492..43f3c74 100644
---- a/server/domain/source-items.js
++++ b/server/domain/source-items.js
@@@ -25,6 +25,9 @@
* seller the shop the item names (fields.seller) is the offer's `seller`. It names the link's store
* too, except for a source whose links are kept verbatim (an aggregator linking its own pages:
* the store is the aggregator's domain, and the retailer must not become that store's name).
++ * category the item's first stated category (fields.categories[0], e.g. DealNews' "TVs") is the offer's
++ * category, as stated; staff_pick (fields.staff_pick, true/false) says whether the source's
++ * editors picked it (migration 0007). Neither is ever guessed.
* other kinds skipped
*
* A source policy (server/domain/source-policy.js) may require a source's links kept verbatim and
@@@ -106,10 +109,13 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
const seller = typeof raw === 'string' && raw.trim() ? raw.trim().slice(0, 120) : null;
return { seller, store_name: verbatim ? null : seller };
};
++ // The source's own labels for the item, as stated (feed@3): its first category and its editors' pick.
++ const first = Array.isArray(f.categories) ? f.categories.find((c) => typeof c === 'string' && c.trim()) : null;
++ const labels = { category: first ? first.trim().replace(/\s+/g, ' ').slice(0, 60) : null, staff_pick: typeof f.staff_pick === 'boolean' ? f.staff_pick : null };
if (item.kind === 'offer') {
const url = linkOf(f.url || item.canonical_url);
if (!url || !item.title) return [];
-- return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), ...sellerOf(f.seller) }];
++ return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), ...sellerOf(f.seller), ...labels }];
}
if (item.kind === 'product') {
if (!item.title) return [];
@@@ -122,7 +128,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
out.push({
// A verbatim source's title is used as stated, with no added words.
part: urlKey(url), url, title: !verbatim && o.seller ? `${item.title} — ${o.seller}` : item.title, description: item.summary,
-- obs: obsFields(o), expires_at: stated(o.valid_until), ...sellerOf(o.seller),
++ obs: obsFields(o), expires_at: stated(o.valid_until), ...sellerOf(o.seller), ...labels,
product: { name: item.title, brand: f.brand, gtin: f.gtin, mpn: f.mpn, sku: f.sku },
});
}
@@@ -131,7 +137,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
if (item.kind === 'article' || item.kind === 'record') {
const url = linkOf(item.canonical_url);
if (!url || !item.title) return [];
-- return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), ...sellerOf(f.seller) }];
++ return [{ part: '', url, title: item.title, description: item.summary, obs: obsFields(f), expires_at: stated(f.valid_until), ...sellerOf(f.seller), ...labels }];
}
return [];
}
@@@ -156,6 +162,15 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
return touched.length ? 'removed' : 'removed:unknown';
}
++ /** A source's stated category / editors' pick onto an offer, when they differ. → the names of what changed. */
++ async function applyLabels(offer, c) {
++ const category = c.category && c.category !== offer.category ? c.category : null;
++ const pick = c.staff_pick != null && c.staff_pick !== offer.staff_pick ? c.staff_pick : null;
++ if (category == null && pick == null) return [];
++ await db.prepare('UPDATE deal_offers SET category = COALESCE(?, category), staff_pick = COALESCE(?, staff_pick), updated_at = ? WHERE id = ?').run(category, pick, store.now(), offer.id);
++ return [...(category != null ? ['category'] : []), ...(pick != null ? ['staff_pick'] : [])];
++ }
++
/** One item, inside the caller's transaction (the chassis' per-item savepoint). */
async function importItem(item) {
if (!item || item.category !== 'deals') return 'skipped:category';
@@@ -188,6 +203,7 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
await db.prepare('UPDATE deal_offers SET expires_at = ?, updated_at = ? WHERE id = ?').run(c.expires_at, store.now(), offer.id);
changed.push('expires_at');
}
++ if (origin === 'import' && offer.origin === 'import') changed.push(...await applyLabels(offer, c));
// Anything else the observation moved on the offer (price, product) is named too, so consumers never miss a field.
const after = await reads.get(offer.id);
for (const k of ['product_id', 'title', 'status']) if (after && offer && after[k] !== offer[k] && !changed.includes(k)) changed.push(k);
@@@ -206,15 +222,17 @@ function createImporter({ config, store, reads, catalog, offers, indexing, sourc
const obs = await offers.recordObservation(dup.id, s.id, c.obs, { origin: 'import', observedAt: retrievedAt, sourceRevision: item.revision });
if (productId && !dup.product_id) await db.prepare('UPDATE deal_offers SET product_id = ?, updated_at = ? WHERE id = ?').run(productId, store.now(), dup.id);
if (c.seller && !dup.seller) await db.prepare('UPDATE deal_offers SET seller = ?, updated_at = ? WHERE id = ?').run(c.seller, store.now(), dup.id);
++ if (!dup.category || dup.staff_pick == null) await applyLabels(dup, { category: dup.category ? null : c.category, staff_pick: dup.staff_pick == null ? c.staff_pick : null });
await indexing.emitOffer('deals.offer.updated', await reads.get(dup.id), { actor: 'svc:deals', extra: { changed: ['sources'], observation_id: obs.id } });
await indexing.reindex(await reads.get(dup.id));
outcomes.push('attached');
continue;
}
-- await offers.createImported({
-- url: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId,
++ const made = await offers.createImported({
++ url: c.url, title: String(c.title).slice(0, 200), description: c.description, product_id: productId, category: c.category,
store_name: c.store_name, seller: c.seller, expires_at: c.expires_at != null && c.expires_at > store.now() ? c.expires_at : null,
}, src, c.obs, { observedAt: retrievedAt, sourceRevision: item.revision });
++ if (c.staff_pick != null) await applyLabels(made, { staff_pick: c.staff_pick });
outcomes.push('created');
}
return outcomes.join(',');
ddiff --git a/server/http/crowdfunding.js b/server/http/crowdfunding.js
iindex 81eaba1..ce3c2dd 100644
---- a/server/http/crowdfunding.js
++++ b/server/http/crowdfunding.js
@@@ -117,4 +117,4 @@ function createCrowdfundingRoutes(ctx) {
return router;
}
--module.exports = { createCrowdfundingRoutes };
++module.exports = { createCrowdfundingRoutes, campaignCard };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex ad91daf..e1dd2af 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -12,6 +12,8 @@
* expired, reviewed where review is needed); lastmod = the latest
* observation or edit, never "now"
* GET /sitemaps/products.xml product pages with at least one fresh offer
++ * GET /sitemaps/pages.xml the front page and the lists: hot, new, ending soon, picks, every category and shop,
++ * crowdfunding (an empty list is noindex and left out)
*
* Built from the database on every request, never from the viewer. Stale, expired, disabled,
* merged and unreviewed offers never appear.
@@@ -42,6 +44,27 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
}));
}
++ /** The lists a person browses (all indexable while they have something to show); lastmod = their newest deal. */
++ async function pageEntries() {
++ const decide = (path, empty) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: abs(path), wordCount: 0, noindex: empty }, { policy: { minWords: 0 }, now: store.now() });
++ const newest = (await listings.newest(1, 0))[0];
++ const active = await listings.countActive();
++ const now = store.now();
++ const lists = [
++ { path: '/', empty: active === 0, lastmod: newest && newest.created_at },
++ { path: '/hot', empty: active === 0, lastmod: newest && newest.created_at },
++ { path: '/new', empty: active === 0, lastmod: newest && newest.created_at },
++ { path: '/ending', empty: (await listings.countEnding(now, now + 7 * 86400000)) === 0 },
++ { path: '/picks', empty: (await listings.countPicks()) === 0 },
++ { path: '/crowdfunding', empty: false },
++ ];
++ const categories = await listings.categories();
++ const sellers = await listings.sellers();
++ lists.push({ path: '/c', empty: categories.length === 0 }, ...categories.map((c) => ({ path: `/c/${c.slug}`, empty: c.count === 0, lastmod: c.latest })));
++ lists.push({ path: '/at', empty: sellers.length === 0 }, ...sellers.map((x) => ({ path: `/at/${x.slug}`, empty: x.count === 0, lastmod: x.latest == null ? null : Number(x.latest) })));
++ return lists.map((l) => ({ loc: abs(l.path), lastmod: l.lastmod || null, decision: decide(l.path, l.empty) }));
++ }
++
router.get('/robots.txt', (_req, res) => {
const body = [
'# openvibe.deals automated-consumer policy: search engines and AI crawlers are welcome to read',
@@@ -61,7 +84,9 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
details: 'A price on this site is an observation: it has a source and an observed_at time, and it is only claimed "as of" that time. Offers whose latest observation is older than the freshness window are marked stale and are noindex; expired offers are noindex; a price nobody stated is shown as "not stated" and omitted from structured data (never 0). Every deal page has a JSON twin at <deal URL>.json with the full observation history, sources, votes and indexability reasons. Imported and AI-assisted text is noindex until a person reviews it.',
sections: [
{ title: 'Start here', links: [
-- { title: 'Hot deals', url: abs('/') }, { title: 'New deals', url: abs('/new') }, { title: 'Crowdfunding', url: abs('/crowdfunding'), note: 'one search across Kickstarter, Indiegogo, GoFundMe, Open Collective and more, and open-source campaigns with what they raised (/crowdfunding.json)' }, { title: 'Sitemap', url: abs('/sitemap.xml') },
++ { title: 'Front page', url: abs('/'), note: 'editors\' picks, newest, ending soon, under $20, categories, shops and crowdfunding' },
++ { title: 'Hot deals', url: abs('/hot') }, { title: 'New deals', url: abs('/new') }, { title: 'Ending soon', url: abs('/ending') },
++ { title: 'Editors\' picks', url: abs('/picks') }, { title: 'Categories', url: abs('/c') }, { title: 'Shops', url: abs('/at') }, { title: 'Crowdfunding', url: abs('/crowdfunding'), note: 'one search across Kickstarter, Indiegogo, GoFundMe, Open Collective and more, and open-source campaigns with what they raised (/crowdfunding.json)' }, { title: 'Sitemap', url: abs('/sitemap.xml') },
] },
{ title: 'Feeds', links: [{ title: 'RSS', url: abs('/feed.xml') }, { title: 'Atom', url: abs('/atom.xml') }, { title: 'JSON Feed', url: abs('/feed.json') }] },
{ title: 'Data', links: [
@@@ -96,10 +121,13 @@ function createDiscoveryRoutes({ config, store, publication, listings }) {
router.get('/sitemap.xml', async (_req, res) => {
const o = (await offerEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
const p = (await productEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
++ const g = (await pageEntries()).filter((e) => e.decision.indexable).map((e) => e.lastmod).filter(Boolean);
const iso = (list) => (list.length ? { lastmod: new Date(Math.max(...list)).toISOString() } : {});
-- xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));
++ xml(res, seo.sitemapIndex([{ loc: abs('/sitemaps/pages.xml'), ...iso(g) }, { loc: abs('/sitemaps/offers.xml'), ...iso(o) }, { loc: abs('/sitemaps/products.xml'), ...iso(p) }]));
});
++ router.get('/sitemaps/pages.xml', async (_req, res) => xml(res, seo.sitemap(await pageEntries()).files[0]));
++
router.get('/sitemaps/offers.xml', async (_req, res) => xml(res, seo.sitemap(await offerEntries()).files[0]));
router.get('/sitemaps/products.xml', async (_req, res) => xml(res, seo.sitemap(await productEntries()).files[0]));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex aff8677..754311d 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -4,7 +4,11 @@ const showcase = require('openvibe-shared/showcase');
/**
* Public, server-rendered routes — useful without JavaScript (every action is a plain form):
*
-- * GET / hot deals (hot@1 snapshots) GET /new newest deals
++ * GET / the front page: editors' picks, hot, newest, ending soon, under a price, categories,
++ * shops and crowdfunding, then how it works and questions (render/home.js)
++ * GET /hot hot deals (hot@1 snapshots) GET /new newest deals
++ * GET /ending deals whose stated end is in the next 7 days, soonest first
++ * GET /picks deals a source's editors picked GET /c, /c/:category deals by category
* GET /d/:slug an offer: latest observation "as of", freshness, price history,
* sources, votes, discussion (…/:slug.json: the same facts as data)
* POST /d/:slug/vote value=up|down|remove POST /d/:slug/observe
@@@ -29,6 +33,7 @@ const cache = require('openvibe-shared/cache-policy');
const ssr = require('openvibe-publishing/ssr');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const views = require('../render/views');
++const home = require('../render/home');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { ApiError, iso } = require('../domain/values');
@@@ -41,7 +46,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 };
++ const urls = { offerPath: publication.offerPath, productPath: publication.productPath, storePath: publication.storePath, sellerPath: publication.sellerPath, categoryPath: publication.categoryPath };
const form = express.urlencoded({ extended: false, limit: '32kb' });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@@ -107,37 +112,118 @@ function createPages(ctx) {
// ── lists ───────────────────────────────────────────────
++ const DAY = 24 * 3600 * 1000;
++ const FEEDS = [{ type: 'rss', href: '/feed.xml', title: 'New deals (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New deals (Atom)' }, { type: 'json', href: '/feed.json', title: 'New deals (JSON Feed)' }];
++ /** The list pages: each one's path, heading, intro and rows (newest-first unless said). */
++ const LISTS = {
++ hot: { path: '/hot', heading: 'Hot deals', intro: 'Ranked by community votes over time (hotness formula hot@1). Every price shows when it was observed.',
++ count: async () => await listings.countActive(), rows: async (n, off) => await listings.hot(n, off), empty: 'No deals have been posted yet. Submit the first one.' },
++ new: { path: '/new', heading: 'New deals', intro: 'The newest deals first. Every price shows when it was observed.',
++ count: async () => await listings.countActive(), rows: async (n, off) => await listings.newest(n, off), empty: 'No deals have been posted yet. Submit the first one.' },
++ ending: { path: '/ending', heading: 'Deals ending soon', intro: 'Deals whose source states an end in the next seven days, soonest first. A deal with no stated end is never listed here.',
++ description: 'Deals ending in the next seven days, soonest first, each with its stated end, its price and when that price was seen.',
++ count: async () => await listings.countEnding(store.now(), store.now() + 7 * DAY), rows: async (n, off) => await listings.endingSoon(store.now(), store.now() + 7 * DAY, n, off), empty: 'No deal states an end in the next seven days.' },
++ picks: { path: '/picks', heading: 'Editors\' picks', intro: 'Deals a source\'s editors marked as their picks (DealNews\' staff picks), newest first.',
++ description: 'Deals picked by DealNews\' editors, newest first, each with its price and the time it was seen.',
++ count: async () => await listings.countPicks(), rows: async (n, off) => await listings.picks(n, off), empty: 'No picks right now.' },
++ };
++
async function listPage(req, res, kind) {
++ const L = LISTS[kind];
const page = pageNumber(req);
-- const path = kind === 'hot' ? '/' : '/new';
-- const total = await listings.countActive();
++ const path = L.path;
++ const total = await L.count();
const pager = ssr.paginate({ page, perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
if (pager.outOfRange && total) return notFound(req, res);
-- const rows = kind === 'hot' ? await listings.hot(PER_PAGE, pager.offset) : await listings.newest(PER_PAGE, pager.offset);
-- const list = await Promise.all(rows.map(async (o) => await publication.offerView(o)));
++ const list = await Promise.all((await L.rows(PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
send(req, res, 200, {
-- title: kind === 'hot' ? 'Hot deals' : 'New deals',
-- description: 'Deals submitted and voted on by the community, each with its source and the time its price was observed.',
-- // The home page carries the site summary an AI can read (ai-summary meta + WebPage JSON-LD).
-- ...(kind === 'hot' && pager.page === 1 ? { summary: SITE_SUMMARY, styles: [showcase.STYLESHEET] } : {}),
++ title: L.heading,
++ description: L.description || 'Deals submitted and voted on by the community, each with its source and the time its price was observed.',
decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
canonical,
-- feeds: [{ type: 'rss', href: '/feed.xml', title: 'New deals (RSS)' }, { type: 'atom', href: '/atom.xml', title: 'New deals (Atom)' }, { type: 'json', href: '/feed.json', title: 'New deals (JSON Feed)' }],
++ feeds: FEEDS,
prev: pager.prev ? pager.prev.href : null,
next: pager.next ? pager.next.href : null,
-- jsonLd: [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Deals', url: publication.abs('/'), potentialAction: { '@type': 'SearchAction', target: `${publication.abs('/search')}?q={q}`, 'query-input': 'required name=q' } }],
-- body: views.offerList({
-- heading: kind === 'hot' ? 'Hot deals' : 'New deals',
-- intro: kind === 'hot' ? 'Ranked by community votes over time (hotness formula hot@1). Every price shows when it was observed.' : 'The newest deals first. Every price shows when it was observed.',
-- views: list, pager, urls, tabs: views.tabs(kind),
-- lead: kind === 'hot' && pager.page === 1 ? views.dealsShowcase() : '',
-- empty: 'No deals have been posted yet. Submit the first one.',
-- }),
++ body: views.offerList({ heading: L.heading, intro: L.intro, views: list, pager, urls, tabs: views.tabs(kind), empty: L.empty }),
++ }, { cacheable: true });
++ }
++
++ // ── the front page ──────────────────────────────────────
++
++ // "Under $20": the most common currency's round limit. A card shows only a fresh, stated price at or below it.
++ const UNDER = { amount: 20, currency: 'USD' };
++ const HOME_CARDS = 8;
++
++ async function homePage(req, res) {
++ const now = store.now();
++ const fresh = now - config.freshnessMs;
++ const [total, sellers, categories, seenDay, endingWeek, cf] = await Promise.all([
++ listings.countActive(), listings.sellers(), listings.categories(), listings.seenSince(now - DAY),
++ listings.countEnding(now, now + 7 * DAY), ctx.campaigns ? ctx.campaigns.list({ sort: 'backers', limit: 6 }) : { total: 0, campaigns: [] },
++ ]);
++ // A deal shows once: picks and hot lead, the newest follow, and "under $20" skips all of those. "Ending soon"
++ // skips only the picks and hot ones (a new deal that ends this week belongs there too). The candidates' cards are
++ // read in parallel, then dealt out in that order.
++ const [pickRows, upRows, newRows, endRows, underRows] = await Promise.all([
++ listings.picks(HOME_CARDS * 2, 0), listings.upvoted(HOME_CARDS * 2), listings.newest(HOME_CARDS * 3, 0),
++ listings.endingSoon(now, now + 7 * DAY, HOME_CARDS * 2, 0), listings.under(UNDER.amount, UNDER.currency, fresh, HOME_CARDS * 4),
++ ]);
++ const viewOf = new Map();
++ for (const o of [...pickRows, ...upRows, ...newRows, ...endRows, ...underRows]) if (!viewOf.has(o.id)) viewOf.set(o.id, publication.cardView(o, now));
++ await Promise.all(viewOf.values());
++ const top = new Set();
++ const shown = new Set();
++ const take = async (rows, { skip = shown, keep = () => true, into = [top, shown] } = {}) => {
++ const out = [];
++ for (const o of rows) {
++ if (out.length >= HOME_CARDS || skip.has(o.id)) continue;
++ const v = await viewOf.get(o.id);
++ if (!keep(v)) continue;
++ for (const set of into) set.add(o.id);
++ out.push(v);
++ }
++ return out;
++ };
++ const picks = await take(pickRows);
++ const upvoted = await take(upRows);
++ const latest = await take(newRows, { into: [shown] });
++ const ending = await take(endRows, { skip: top, into: [shown] });
++ const under = await take(underRows, {
++ into: [shown], keep: (v) => v.freshness.state === 'fresh' && v.latest && v.latest.currency === UNDER.currency && Number(v.latest.price) <= UNDER.amount,
++ });
++ const faq = home.faqItems({ freshnessHours: Math.round(config.freshnessMs / 3600000) });
++ const listed = [...picks, ...upvoted, ...latest];
++ send(req, res, 200, {
++ title: 'Today\'s deals, each price with the time it was seen',
++ description: 'Deals from Amazon, Walmart, Best Buy and more, by shop, category and end date, with price watches and crowdfunding. Every price says when it was seen.',
++ // The home page carries the site summary an AI can read (ai-summary meta + WebPage JSON-LD).
++ summary: SITE_SUMMARY, styles: [showcase.STYLESHEET],
++ decision: pageDecision('/', { empty: total === 0 }),
++ canonical: seo.canonicalUrl(config.baseUrl, '/'),
++ feeds: FEEDS,
++ jsonLd: [
++ { '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Deals', url: publication.abs('/'), potentialAction: { '@type': 'SearchAction', target: `${publication.abs('/search')}?q={q}`, 'query-input': 'required name=q' } },
++ ...(listed.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Deals on OpenVibe.Deals', itemListElement: listed.map((v, i) => ({ '@type': 'ListItem', position: i + 1, url: publication.abs(publication.offerPath(v.root)), name: v.root.title })) }] : []),
++ home.faqJsonLd(faq),
++ ],
++ bodyClass: 'home',
++ body: home.homePage({
++ empty: total === 0, freshnessHours: Math.round(config.freshnessMs / 3600000), faq,
++ stats: { active: total, shops: sellers.length, seenDay, endingWeek, categories: categories.length, campaigns: cf.total },
++ picks, upvoted, latest, ending, under, underLimit: UNDER, categories, sellers, campaigns: cf.campaigns,
++ }, urls),
}, { cacheable: true });
}
-- router.get('/', wrap(async (req, res) => await listPage(req, res, 'hot')));
++ router.get('/', wrap(async (req, res) => {
++ // The hot list's later pages moved to /hot with the front page.
++ if (req.query.page && pageNumber(req) > 1) return res.redirect(301, `/hot?page=${pageNumber(req)}`);
++ await homePage(req, res);
++ }));
++ router.get('/hot', wrap(async (req, res) => await listPage(req, res, 'hot')));
++ router.get('/ending', wrap(async (req, res) => await listPage(req, res, 'ending')));
++ router.get('/picks', wrap(async (req, res) => await listPage(req, res, 'picks')));
// What shipped on OpenVibe.Deals: the shared update log every OpenVibe site has.
router.get('/updates', (req, res) => send(req, res, 200, {
title: 'What shipped on OpenVibe.Deals', description: 'Every change deployed to OpenVibe.Deals, newest first.',
@@@ -322,6 +408,33 @@ function createPages(ctx) {
}, { cacheable: true });
}));
++ // ── categories (as each deal's source names it, migration 0007) ──
++ router.get('/c', wrap(async (req, res) => {
++ const categories = await listings.categories();
++ send(req, res, 200, {
++ title: 'Deals by category', decision: pageDecision('/c', { empty: categories.length === 0 }), canonical: seo.canonicalUrl(config.baseUrl, '/c'),
++ description: 'Every deal category on OpenVibe.Deals, from TVs to cleaning supplies, with how many active deals each has.',
++ body: views.categoriesPage({ categories }),
++ }, { cacheable: true });
++ }));
++ router.get('/c/:category', wrap(async (req, res) => {
++ const want = String(req.params.category || '').toLowerCase();
++ const hit = /^[a-z0-9-]{1,80}$/.test(want) ? (await listings.categories()).find((x) => x.slug === want) : null;
++ if (!hit) return notFound(req, res);
++ const path = `/c/${hit.slug}`;
++ const total = await listings.countCategory(hit.category);
++ const pager = ssr.paginate({ page: pageNumber(req), perPage: PER_PAGE, total, href: (p) => (p === 1 ? path : `${path}?page=${p}`) });
++ if (pager.outOfRange && total) return notFound(req, res);
++ const list = await Promise.all((await listings.byCategory(hit.category, PER_PAGE, pager.offset)).map(async (o) => await publication.offerView(o)));
++ const canonical = seo.canonicalUrl(config.baseUrl, pager.page === 1 ? path : `${path}?page=${pager.page}`, { query: ['page'] });
++ send(req, res, 200, {
++ title: `${hit.category} deals`, decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }), canonical,
++ description: `${total} active ${hit.category} deal${total === 1 ? '' : 's'}, each with its shop, its price and when that price was seen.`,
++ prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
++ body: views.categoryPage({ category: hit.category, views: list, pager, urls, total }),
++ }, { cacheable: true });
++ }));
++
// ── search ──────────────────────────────────────────────
router.get('/search', actorLimits.reads('deals.search'), wrap(async (req, res) => {
ddiff --git a/server/render/home.js b/server/render/home.js
nnew file mode 100644
iindex 0000000..a2d7041
---- /dev/null
++++ b/server/render/home.js
@@@ -0,0 +1,165 @@
++'use strict';
++
++/**
++ * The front page (/): what OpenVibe.Deals is for, then sections of real deals, shops, categories and campaigns, then
++ * how it works and the questions people ask. Every section is built from rows the page passes; a section with nothing
++ * to show renders nothing (never an empty shell or a "coming soon").
++ *
++ * Wording rules (as in views.js): a price is always shown with the time it was seen; a stale one says it may be gone;
++ * an unknown one says "not stated". Nothing here calls a price "current". A source's required attribution
++ * ("via DealNews") is on every card that shows its deal.
++ */
++const { html, raw } = require('openvibe-publishing/ssr');
++const showcase = require('openvibe-shared/showcase');
++const { ago, when, priceText } = require('./views');
++const { campaignCard } = require('../http/crowdfunding');
++
++const count = (n) => Number(n).toLocaleString('en-US');
++const safeHref = (u) => (typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null);
++
++/** "$59.00" for a stated price in a known currency; views' wording otherwise. */
++function money(o) {
++ if (!o || o.price == null || !o.currency) return null;
++ try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: o.currency }).format(Number(o.price)); } catch { return null; }
++}
++
++/** "in 5 hours" / "in 3 days" until a stated end. */
++function until(ms, now) {
++ const h = Math.max(0, (ms - now) / 3600000);
++ if (h < 1) return 'within the hour';
++ if (h < 36) return `in ${Math.round(h)} hour${Math.round(h) === 1 ? '' : 's'}`;
++ const d = Math.round(h / 24);
++ return `in ${d} day${d === 1 ? '' : 's'}`;
++}
++
++const timeAgo = (ms, now) => html`<time datetime="${new Date(ms).toISOString()}" title="${when(ms)}">${ago(ms, now)}</time>`;
++
++/** When the card's price was seen: fresh, stale (may be gone) or never. */
++function seenBadge(v) {
++ const f = v.freshness;
++ if (f.state === 'fresh') return html`<span class="badge fresh" data-freshness="fresh">seen ${timeAgo(v.latest.observed_at, v.now)}</span>`;
++ if (f.state === 'stale') return html`<span class="badge stale" data-freshness="stale">last seen ${timeAgo(v.latest.observed_at, v.now)}, may be gone</span>`;
++ return html`<span class="badge stale" data-freshness="unobserved">no price seen yet</span>`;
++}
++
++/** One deal as a card (publication.cardView). */
++function card(v, urls, { ends = false } = {}) {
++ const r = v.root;
++ const o = v.latest;
++ const shown = money(o);
++ const price = shown
++ ? html`<strong class="price" data-price="${o.price}">${shown}</strong>`
++ : o && o.price != null ? html`<strong class="price" data-price="${o.price}">${priceText(o)}</strong>` : html`<span class="price price-none">Price not stated</span>`;
++ // An aggregator's own domain (the store of a verbatim DealNews link) is not "where it is sold": the seller is.
++ 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>` : '';
++ 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>` : ''}
++${where || via ? html`<p class="meta hm-where">${where}${where && via ? ' · ' : ''}${via}</p>` : ''}
++</li>`;
++}
++
++/** A titled section with an optional "see all" link; nothing when it has no body. */
++function section({ id, title, lede, more, body }) {
++ if (!body) return '';
++ return html`<section class="hm-sec" aria-labelledby="${id}">
++<div class="hm-head"><h2 id="${id}">${title}</h2>${more ? html`<a class="hm-more" href="${more.href}">${more.label}<span aria-hidden="true"> →</span></a>` : ''}</div>
++${lede ? html`<p class="hm-lede">${lede}</p>` : ''}
++${raw(String(body))}
++</section>`;
++}
++
++const cards = (list, urls, opts) => (list.length ? html`<ol class="hm-cards">${list.map((v) => card(v, urls, opts))}</ol>` : '');
++
++/** Chips: [{ label, href, n }] → a wrapped list of links with their counts. */
++function chips(list, noun) {
++ if (!list.length) return '';
++ return html`<ul class="hm-chips">${list.map((x) => html`<li><a href="${x.href}">${x.label}<span class="hm-n"><span class="sr">: </span>${count(x.n)}<span class="sr"> ${x.n === 1 ? noun : `${noun}s`}</span></span></a></li>`)}</ul>`;
++}
++
++/** The numbers under the hero, each a link to the list it counts. Zero counts are left out. */
++function stats(items) {
++ const shown = items.filter((x) => x.n > 0);
++ if (!shown.length) return '';
++ return html`<ul class="hm-stats" aria-label="OpenVibe.Deals in numbers">${shown.map((x) => html`<li><a href="${x.href}"><b>${count(x.n)}</b><span>${x.label}</span></a></li>`)}</ul>`;
++}
++
++/** Questions people ask, answered from how the site works (also the page's FAQPage JSON-LD). */
++function faqItems({ freshnessHours }) {
++ return [
++ { q: 'Where do the deals come from?', a: 'Two places: DealNews, whose editors publish deals in a public feed that OpenVibe.Sources reads every hour, and people who post deals here with their OpenVibe account. Every deal page lists its sources and links to the original.' },
++ { q: 'Is the price still right?', a: `Every price is an observation with the time it was seen. When the newest one is more than ${freshnessHours} hours old, the deal is marked stale and its price is shown as history, not as the price now. Check the store's page before you buy, and if it changed, report what you see on the deal's page.` },
++ { q: 'Why does a deal say "Price not stated"?', a: 'Some deals are store-wide sales with no single price, and Deals never reads a price out of a headline. It shows a price only when a source or a person states one.' },
++ { q: 'Do you change the store links?', a: 'Links people post are cleaned of tracking and affiliate tags. Links from DealNews are kept exactly as DealNews publishes them, as its feed terms require, and each of its deals says "via DealNews".' },
++ { q: 'How do watches work?', a: 'Sign in, then watch some words, a product or a price limit. When a new price matches, you get one notification through your OpenVibe account. A saved search runs again whenever you open it and never notifies.' },
++ { q: 'What does it cost?', a: 'Nothing. Anyone can read the deals; posting, voting, comments and watches need a free OpenVibe account.' },
++ ];
++}
++
++function faq(items) {
++ return section({
++ id: 'hm-faq', title: 'Questions and answers',
++ body: html`<div class="hm-faq">${items.map((x) => html`<details><summary>${x.q}</summary><p>${x.a}</p></details>`)}</div>`,
++ });
++}
++
++function faqJsonLd(items) {
++ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: items.map((x) => ({ '@type': 'Question', name: x.q, acceptedAnswer: { '@type': 'Answer', text: x.a } })) };
++}
++
++/**
++ * d: { stats: { active, shops, seenDay, endingWeek, categories, campaigns }, picks, upvoted, latest, ending, under,
++ * underLimit: { amount, currency }, categories: [{ category, slug, count }], sellers: [{ seller, slug, count }],
++ * campaigns: [row], freshnessHours, empty }
++ * (card lists are publication.cardView results)
++ */
++function homePage(d, urls) {
++ const hero = html`<section class="sc-hero sc-split hm-hero"><div class="sc-hero-main">
++<p class="sc-eyebrow">OpenVibe.Deals</p>
++<h1>Today's deals,<span class="sc-accent"> with the time every price was seen</span></h1>
++<p class="sc-hero-lede">Deals from DealNews' editors and from the OpenVibe community, by shop, category and end date. Every price says when it was seen, and an old one says it may be gone.</p>
++<form class="hm-search" action="/search" method="get" role="search"><label class="sr" for="hm-q">Search deals</label><input id="hm-q" name="q" type="search" maxlength="100" placeholder="Search deals, like air fryer or lululemon"><button type="submit">Search</button></form>
++<div class="sc-actions"><a class="sc-btn sc-primary" href="/new">See the newest deals</a><a class="sc-btn" href="/watches">Watch a price</a><a class="sc-btn" href="/submit">Post a deal</a></div>
++</div>
++<div class="sc-hero-aside">${stats([
++ { n: d.stats.active, label: 'active deals', href: '/new' },
++ { n: d.stats.shops, label: 'shops', href: '/at' },
++ { n: d.stats.seenDay, label: 'prices seen in the last 24 hours', href: '/new' },
++ { n: d.stats.endingWeek, label: 'ending in the next 7 days', href: '/ending' },
++ { n: d.stats.categories, label: 'categories', href: '/c' },
++ { n: d.stats.campaigns, label: 'crowdfunding campaigns tracked', href: '/crowdfunding' },
++ ])}</div></section>`;
++
++ const shopLinks = d.sellers.slice(0, 18).map((x) => ({ label: x.seller, href: `/at/${x.slug}`, n: x.count }));
++ const catLinks = d.categories.slice(0, 24).map((x) => ({ label: x.category, href: `/c/${x.slug}`, n: x.count }));
++ let under = null;
++ try { under = d.underLimit ? new Intl.NumberFormat('en-US', { style: 'currency', currency: d.underLimit.currency, maximumFractionDigits: 0 }).format(d.underLimit.amount) : null; } catch { under = null; }
++
++ return html`${hero}
++${d.empty ? html`<p class="empty hm-empty">No deals have been posted yet. <a href="/submit">Submit the first one</a>.</p>` : ''}
++${section({ id: 'hm-picks', title: 'Editors\' picks', lede: 'Deals DealNews\' editors marked as their picks, newest first.', more: { href: '/picks', label: 'All picks' }, body: cards(d.picks, urls) })}
++${section({ id: 'hm-hot', title: 'Hot right now', lede: 'Voted up by people on OpenVibe, hottest first.', more: { href: '/hot', label: 'All hot deals' }, body: cards(d.upvoted, urls) })}
++${section({ id: 'hm-new', title: 'Just in', lede: 'The newest deals from every source.', more: { href: '/new', label: 'All new deals' }, body: cards(d.latest, urls) })}
++${section({ id: 'hm-ending', title: 'Ending soon', lede: 'Deals whose stated end is in the next seven days, soonest first.', more: { href: '/ending', label: 'Everything ending soon' }, body: cards(d.ending, urls, { ends: true }) })}
++${under ? section({ id: 'hm-under', title: `Under ${under}`, lede: `Deals with a stated price of ${under} or less, seen in the last ${d.freshnessHours} hours.`, body: cards(d.under, urls) }) : ''}
++${section({ id: 'hm-categories', title: 'Browse by category', lede: 'Categories as each deal\'s source names them, the most deals first.', more: { href: '/c', label: 'All categories' }, body: chips(catLinks, 'deal') })}
++${section({ id: 'hm-shops', title: 'Shop by store', lede: 'Where the deals are sold, as their sources name the shop.', more: { href: '/at', label: `All ${count(d.sellers.length)} shops` }, body: chips(shopLinks, 'deal') })}
++${section({ id: 'hm-crowdfunding', title: 'Crowdfunding', lede: 'Open-source and community campaigns from Open Collective with what they received, plus one search across Kickstarter, Indiegogo, GoFundMe and more.', more: { href: '/crowdfunding', label: 'Search crowdfunding' }, body: d.campaigns.length ? html`<ol class="cf-grid hm-cf">${d.campaigns.map(campaignCard)}</ol>` : '' })}
++${raw(showcase.features({
++ title: 'How it works',
++ lede: 'A deal is a link, a title and every price anyone saw for it, each with its source and time.',
++ items: [
++ { icon: 'ov:history', title: 'Prices with their time', text: 'Each deal keeps every price it was seen at, newest first, each with its source. Nothing is called current without a fresh one.', href: '/new' },
++ { icon: 'ov:check', title: 'Votes decide what is hot', text: 'One vote per person, counted on the server. A deal page shows its hotness with the formula and the inputs.', href: '/hot' },
++ { icon: 'ov:search', title: 'By shop and category', text: 'Every deal is filed under the shop that sells it and the category its source gives it.', href: '/at' },
++ { icon: 'ov:bell', title: 'Watches and saved searches', text: 'Watch words, a product or a price limit for new prices that match, or save a search to run again.', href: '/watches' },
++ ],
++ }))}
++${faq(d.faq)}
++${raw(showcase.cta({ title: 'Know a good deal?', text: 'Post it with the price you see and the time is recorded with it. People vote, and the best rise to the top.', actions: [{ label: 'Post a deal', href: '/submit', primary: true }, { label: 'Find coupon codes on OpenVibe.Coupons', href: 'https://openvibe.coupons/' }] }))}`;
++}
++
++module.exports = { homePage, faqItems, faqJsonLd, card, money };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 345c357..192e505 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -18,11 +18,12 @@ const frame = require('openvibe-shared/frame');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Deals';
// One site summary, shared by /llms.txt, /llms-full.txt and the home page's ai-summary.
--const SITE_SUMMARY = 'Deals submitted and voted on by the OpenVibe community or imported from registered sources, with the source and observation time of every price.';
++const SITE_SUMMARY = 'Deals from DealNews and the OpenVibe community, by shop, category and end date, with price watches and crowdfunding search; every price has its source and the time it was seen.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV_LINKS = [
-- { label: 'Hot', href: '/' },
++ { label: 'Hot', href: '/hot' },
{ label: 'New', href: '/new' },
++ { label: 'Categories', href: '/c' },
{ label: 'Shops', href: '/at' },
{ label: 'Crowdfunding', href: '/crowdfunding' },
{ label: 'Submit', href: '/submit', icon: 'fa-plus' },
ddiff --git a/server/render/views.js b/server/render/views.js
iindex d8a8121..6c8a7f5 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -9,7 +9,6 @@
*/
const { html, raw, renderMarkdown, paginationHtml, breadcrumbsHtml } = require('openvibe-publishing/ssr');
const { AVAILABILITY_LABEL, CONDITIONS, AVAILABILITY } = require('../domain/values');
--const showcase = require('openvibe-shared/showcase');
const pad = (n) => String(n).padStart(2, '0');
function when(ms) {
@@@ -88,24 +87,6 @@ ${attributionLine(v.attribution)}
</div></li>`;
}
--/** The front page opens with what OpenVibe.Deals is for (openvibe-shared/showcase); the hot list follows it. */
--function dealsShowcase() {
-- return showcase.hero({
-- eyebrow: 'OpenVibe.Deals',
-- title: 'Deals where every price', accent: 'says when',
-- lede: 'People post deals and vote them up or down. Every price is an observation with its source and the time it was seen: an old one says it may no longer be available, and a price nobody stated stays "not stated".',
-- actions: [{ label: 'Browse hot deals', href: '#deals', primary: true }, { label: 'Submit a deal', href: '/submit' }],
-- }) + showcase.features({
-- title: 'How it works',
-- items: [
-- { icon: 'ov:history', title: 'Prices as of a time', text: 'Each deal keeps every price it was seen at, newest first, each with its source. Nothing is called current without a fresh one.' },
-- { icon: 'ov:check', title: 'Votes decide what is hot', text: 'One vote per person, counted on the server. A deal page shows its hotness with the formula and the inputs.' },
-- { icon: 'ov:search', title: 'Compare across stores', text: 'A product page lists every offer for it, each with its own latest price and the time it was seen.' },
-- { icon: 'ov:bell', title: 'Watches and saved searches', text: 'Watch words, a product or a price limit for new prices that match, or save a search to run again.' },
-- ],
-- });
--}
--
function offerList({ heading, intro, views, pager, urls, tabs, empty, lead }) {
// Under the showcase the list's heading is an h2 above the sort tabs, so #deals lands on both.
return html`${lead ? html`${raw(lead)}<h2 id="deals">${heading}</h2>` : ''}${tabs ? raw(tabs) : ''}
@@@ -117,7 +98,7 @@ ${raw(pager ? paginationHtml(pager) : '')}`;
function tabs(active) {
const t = (href, label, key) => (key === active ? html`<span aria-current="page">${label}</span>` : html`<a href="${href}">${label}</a>`);
-- return html`<nav class="tabs" aria-label="Sort">${t('/', 'Hot', 'hot')} ${t('/new', 'New', 'new')} <form class="inline search" action="/search" method="get" role="search"><label class="sr" for="q">Search deals</label><input id="q" name="q" type="search" placeholder="Search deals"><button type="submit">Search</button></form></nav>`.toString();
++ return html`<nav class="tabs" aria-label="Sort">${t('/hot', 'Hot', 'hot')} ${t('/new', 'New', 'new')} ${t('/ending', 'Ending soon', 'ending')} ${t('/picks', 'Picks', 'picks')} <form class="inline search" action="/search" method="get" role="search"><label class="sr" for="q">Search deals</label><input id="q" name="q" type="search" placeholder="Search deals"><button type="submit">Search</button></form></nav>`.toString();
}
function observationTable(v, urls) {
@@@ -330,8 +311,25 @@ function sellersPage({ sellers }) {
${sellers.length ? html`<ul class="seller-list">${sellers.map((x) => html`<li><a href="/at/${x.slug}">${x.seller}</a> <span class="meta">${x.count} deal${x.count === 1 ? '' : 's'}</span></li>`)}</ul>` : html`<p class="empty">No shops yet.</p>`}`;
}
++/** /c/:category: active deals in one category, as their sources name it. */
++function categoryPage({ category, views: list, pager, urls, total }) {
++ return html`${raw(breadcrumbsHtml([{ name: 'Deals', url: '/' }, { name: 'Categories', url: '/c' }, { name: category }]))}
++<h1>${category} deals</h1>
++<p class="lede">${total} active deal${total === 1 ? '' : 's'} in ${category}, as the deal sources file them, newest first. Every price shows when it was seen.</p>
++${list.length ? html`<ol class="deal-list">${list.map((v) => offerCard(v, urls))}</ol>` : html`<p class="empty">No active deals in this category right now.</p>`}
++${raw(pager ? paginationHtml(pager) : '')}`;
++}
++
++/** /c: every category with active deals, most deals first. */
++function categoriesPage({ categories }) {
++ return html`${raw(breadcrumbsHtml([{ name: 'Deals', url: '/' }, { name: 'Categories' }]))}
++<h1>Deals by category</h1>
++<p class="lede">Every category with an active deal, as the deal sources name it, the most deals first.</p>
++${categories.length ? html`<ul class="seller-list">${categories.map((x) => html`<li><a href="/c/${x.slug}">${x.category}</a> <span class="meta">${x.count} deal${x.count === 1 ? '' : 's'}</span></li>`)}</ul>` : html`<p class="empty">No deal has a category yet.</p>`}`;
++}
++
function message({ heading, text, action }) {
return html`<h1>${heading}</h1><p>${text}</p>${action ? html`<p><a href="${action.href}">${action.label}</a></p>` : ''}`;
}
--module.exports = { dealsShowcase, offerList, tabs, offerPage, productPage, submitForm, watchesPage, searchPage, modPage, storePage, sellerPage, sellersPage, message, priceText, when };
++module.exports = { offerList, tabs, offerPage, productPage, submitForm, watchesPage, searchPage, modPage, storePage, sellerPage, sellersPage, categoryPage, categoriesPage, message, priceText, when, ago };
ddiff --git a/test/import.test.js b/test/import.test.js
iindex 8580e60..be68a5d 100644
---- a/test/import.test.js
++++ b/test/import.test.js
@@@ -221,6 +221,45 @@ const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
assert.strictEqual((await t.get('/at/..%2Fadmin')).status, 404);
});
++ await check('a deal feed\'s category and editors\' pick (Sources feed@3): on the offer, /c, /picks and the front page', async () => {
++ const link = 'https://www.dealnews.com/TCL-65-4K-TV/22300001.html?iref=rss';
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAC1', sourceKey: 'dealnews-daily', kind: 'article', title: 'TCL 65" 4K TV for $398 + free shipping', url: link,
++ fields: { categories: ['TVs'], staff_pick: true, price: '398.00', currency: 'USD', seller: 'Best Buy', valid_until: new Date(t.clock.now() + 2 * 86400000).toISOString() }, retrievedAt: t.clock.now() }));
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAC2', sourceKey: 'dealnews-daily', kind: 'article', title: 'Scrub sponges 12-pack for $9 + free shipping w/ Prime', url: 'https://www.dealnews.com/sponges/22300002.html?iref=rss',
++ fields: { categories: ['Cleaning Supplies'], staff_pick: false, price: '9.00', currency: 'USD', seller: 'Amazon', valid_until: new Date(t.clock.now() + 3 * 86400000).toISOString() }, retrievedAt: t.clock.now() }));
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAC3', sourceKey: 'dealnews-daily', kind: 'article', title: 'Phone charger for $12', url: 'https://www.dealnews.com/charger/22300003.html?iref=rss',
++ fields: { categories: ['Cell Phones'], price: '12.00', currency: 'USD', seller: 'Walmart' }, retrievedAt: t.clock.now() }));
++ await t.ctx.importer.pull();
++ const tv = await byUrl(link);
++ assert.deepStrictEqual([tv.category, tv.staff_pick], ['TVs', true], 'the stated category and pick');
++ assert.strictEqual((await byUrl('https://www.dealnews.com/sponges/22300002.html?iref=rss')).staff_pick, false);
++ assert.strictEqual((await t.offerJson(tv.slug)).category, 'TVs');
++ const cats = await t.get('/c');
++ assert.ok(cats.text.includes('<a href="/c/tvs">TVs</a>') && cats.text.includes('<a href="/c/cleaning-supplies">Cleaning Supplies</a>'));
++ const tvs = await t.get('/c/tvs');
++ assert.ok(tvs.status === 200 && tvs.text.includes('<h1>TVs deals</h1>') && tvs.text.includes('TCL 65') && !tvs.text.includes('Scrub sponges'));
++ assert.strictEqual((await t.get('/c/nothing-here')).status, 404);
++ const picks = await t.get('/picks');
++ assert.ok(picks.text.includes('TCL 65') && !picks.text.includes('Scrub sponges'), 'only picks');
++ const home = await t.get('/');
++ const at = (id) => home.text.indexOf(`id="${id}"`);
++ const body = home.text.slice(home.text.indexOf('<main'));
++ assert.ok(at('hm-picks') > 0 && body.split('TCL 65').length === 2 && home.text.indexOf('TCL 65', at('hm-picks')) < at('hm-new'), 'the pick leads the page, once');
++ assert.ok(home.text.includes('href="/c/tvs">TVs'), 'categories on the front page');
++ assert.ok(home.text.includes('via <a href="https://www.dealnews.com/" rel="nofollow noopener">DealNews</a>'), 'every DealNews card says so');
++ assert.ok(at('hm-ending') > 0 && home.text.includes('Ends <time'), 'the stated end, in Ending soon');
++ assert.ok(home.text.slice(at('hm-ending'), at('hm-under')).includes('Scrub sponges'), 'a new deal that ends this week is in Ending soon too');
++ assert.ok(!home.text.slice(at('hm-under'), at('hm-categories')).includes('$398.00'), 'nothing above the limit under it');
++ // A later revision filing it elsewhere moves it.
++ t.sources.put(sourceItem({ id: 'itm_01K5AAAAAAAAAAAAAAAAAAAAC1', sourceKey: 'dealnews-daily', kind: 'article', title: 'TCL 65" 4K TV for $398 + free shipping', url: link,
++ fields: { categories: ['Electronics'], staff_pick: false, price: '398.00', currency: 'USD', seller: 'Best Buy' }, revision: 2, retrievedAt: t.clock.now() + 1000 }));
++ await t.ctx.importer.pull();
++ assert.deepStrictEqual([(await byUrl(link)).category, (await byUrl(link)).staff_pick], ['Electronics', false]);
++ assert.strictEqual((await t.get('/c/tvs')).status, 404, 'an empty category is gone');
++ const sm = await t.get('/sitemaps/pages.xml');
++ assert.ok(sm.text.includes('<loc>https://openvibe.deals/c/electronics</loc>') && sm.text.includes('<loc>https://openvibe.deals/at/amazon</loc>'));
++ });
++
await check('Sources down: the pull fails honestly (readiness says so), keeps its cursor, invents nothing', async () => {
const cursor = await t.ctx.importer.cursor();
const offers = (await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM deal_offers').get()).n;
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 9de8fad..c709fe0 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -248,6 +248,22 @@ const { boot, check, done, robotsOf, jsonLd, HOUR } = require('./helpers/boot');
assert.ok(ld.some((x) => x['@type'] === 'WebPage' && x.description && x.url === 'https://openvibe.deals/'), 'WebPage JSON-LD names the home URL and summary');
});
++ await check('the front page: one h1, a search, its sections and questions (FAQPage JSON-LD); the hot list lives at /hot', async () => {
++ const home = await t.get('/');
++ assert.strictEqual((home.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
++ assert.ok(home.text.includes('action="/search"') && home.text.includes('id="hm-new"') && home.text.includes('id="hm-faq"'));
++ const ld = jsonLd(home.text);
++ for (const type of ['WebSite', 'FAQPage', 'ItemList']) assert.ok(ld.some((x) => x['@type'] === type), type);
++ const faq = ld.find((x) => x['@type'] === 'FAQPage');
++ for (const q of faq.mainEntity) assert.ok(home.text.includes(`<summary>${q.name.replace(/"/g, '&quot;').replace(/'/g, '&#39;')}</summary>`), `the question is on the page: ${q.name}`);
++ assert.doesNotMatch(home.text, /coming soon/i);
++ const old = await t.get('/?page=2');
++ assert.deepStrictEqual([old.status, old.headers.get('location')], [301, '/hot?page=2']);
++ for (const p of ['/hot', '/ending', '/picks', '/c']) assert.strictEqual((await t.get(p)).status, 200, p);
++ assert.ok((await t.get('/sitemap.xml')).text.includes('<loc>https://openvibe.deals/sitemaps/pages.xml</loc>'));
++ assert.ok((await t.get('/sitemaps/pages.xml')).text.includes('<loc>https://openvibe.deals/</loc>'));
++ });
++
await check('operations: health, readiness, release.json, metrics (loopback), legal pages', async () => {
assert.strictEqual((await t.get('/api/health')).json().service, 'openvibe-deals');
const ready = await t.get('/api/ready');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 4f92051..807941b 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 25, // measured 23.5 (fresh database, 2026-10-07; 17.2 before the showcase hero and features)
-- htmlBrotliKB: 6.5, // 6.0 (4.5 before the showcase)
++ htmlRawKB: 32, // 29.1 with the curated front page's search, questions (FAQ + JSON-LD) and closing call (2026-10-22); 23.5 before
++ htmlBrotliKB: 8, // 7.1 with the curated front page (6.0 before; 4.5 before the showcase)
jsFiles: 5, // 5 (openvibe-shared 2.10's shell adds web-runtime.js)
jsRawKB: 250, // 239.1 (212.2 before web-runtime.js)
jsBrotliKB: 60, // 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 2 (deals.css, openvibe-shared showcase.css)
-- cssRawKB: 21, // 19.0 with the deal cards
-- cssBrotliKB: 5, // 4.7 with the crowdfunding rules (2026-10-15); 4.1 before (1.5 before showcase.css)
++ cssRawKB: 26, // 24.2 with the front page's stats, cards, chips and questions (19.0 before)
++ cssBrotliKB: 6, // 5.4 with the front page (2026-10-22); 4.7 with the crowdfunding rules; 1.5 before showcase.css
externalFiles: 1, // 0
};