openvibers/openvibe.shared Commits

4.177.0: the offer card (openvibe-shared/offer-card), designed (#378)

OP OpenVibers committed 4 days ago · cdb4f486f9 · 10 files, +97 −7

* Add shared offer card renderer

* Merge remote-tracking branch 'origin/main' into shoes-offer-card-1109

* 4.177.0: the offer card, designed: money-formatted all-in price, what it includes, how fresh, kit styles

* Showcase: a section's line is indented only when its heading has an icon

* Merge remote-tracking branch 'origin/main' into shoes-offer-card-1109

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9041272..6ec54f8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,15 @@
# Changelog
++## 4.177.0 — 2026-11-09
++
++- **`openvibe-shared/offer-card`: one offer from the network's shops** (`offer-card.js`, styles in `kit-data.css`). It
++ shows the title and the shop, the all-in price formatted as money (`$90.00`, `€50.00`) with what it includes ("Free
++ shipping", "+ $4.99 shipping", "Shipping not stated"), and how fresh the price is ("Checked 3 hours ago"). Nothing
++ unknown is invented: no price or check time says so. A stale offer says "may have changed". Only http(s) links are
++ kept. On a phone the price goes under the title.
++- **A section's line sits under its title** (`showcase.css`). The line under a showcase section's heading was always
++ indented to clear a heading icon, so a heading without one (Blog's "Latest from the OpenVibe blog") had its line
++ pushed right. It is indented only when the heading has its icon.
## 4.176.0 — 2026-11-09
- **The model chip and its choice sheet** (`ai-choice.js`, `ov-ai-choice.js`, `ov-ai-choice.css`): a chip beside any
ddiff --git a/README.md b/README.md
iindex 20f401a..0a3514a 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.176.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.177.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex f380759..854249b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.176.0",
++ "version": "4.177.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/kit-data.css b/kit-data.css
iindex 8a3aafa..bcce9da 100644
---- a/kit-data.css
++++ b/kit-data.css
@@@ -275,3 +275,20 @@ a.ovk-tool-crumb:hover { color: var(--ovk-text); }
.ovk-family-full { display: inline; }
}
@media (pointer: coarse) { .ovk-try > a { min-height: 36px; } }
++
++/* An offer (offer-card.js, Opus 11-09): title and shop left, the all-in price right with what it includes, one quiet
++ freshness line. Stale offers fade a little and say so. */
++.ov-offer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: start; padding: 14px 16px; border: 1px solid var(--ovk-line, var(--border, #1f2d47)); border-radius: 14px; background: var(--ovk-card, var(--bg-card, #101828)); min-width: 0; }
++.ov-offer-main { min-width: 0; }
++.ov-offer-title { margin: 0; font-size: 1rem; line-height: 1.3; overflow-wrap: anywhere; }
++.ov-offer-title a { color: var(--ovk-text, var(--text-primary, #e6edf7)); text-decoration: none; }
++.ov-offer-title a:hover { text-decoration: underline; text-underline-offset: 3px; }
++.ov-offer-facts { margin: 4px 0 0; color: var(--ovk-dim, var(--text-secondary, #96a7c2)); font-size: .88rem; overflow-wrap: anywhere; }
++.ov-offer-price { display: grid; justify-items: end; text-align: right; }
++.ov-offer-price b { font-size: 1.25rem; font-variant-numeric: tabular-nums; color: var(--ovk-text, var(--text-primary, #e6edf7)); }
++.ov-offer-price b.is-unknown { font-size: .95rem; color: var(--ovk-dim, var(--text-secondary, #96a7c2)); }
++.ov-offer-price small { color: var(--ovk-dim, var(--text-secondary, #96a7c2)); font-size: .8rem; }
++.ov-offer-fresh { grid-column: 1 / -1; margin: 6px 0 0; color: var(--ovk-faint, var(--text-muted, #7386a3)); font-size: .78rem; }
++.ov-offer.is-stale { opacity: .85; }
++.ov-offer-stale { color: var(--warning, #f59f00); }
++@media (max-width: 420px) { .ov-offer { grid-template-columns: minmax(0, 1fr); } .ov-offer-price { justify-items: start; text-align: left; } }
ddiff --git a/offer-card.js b/offer-card.js
nnew file mode 100644
iindex 0000000..cf50c70
---- /dev/null
++++ b/offer-card.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++/**
++ * One offer from the network's shops (open.shoes, Deals, Coupons): what it is, where, the all-in price and how fresh
++ * that price is. Unknown shipping and an unknown check time stay unknown; the card never invents them. Opus's design
++ * (11-09): the title and the shop on the left, the price large on the right with what it includes under it, one quiet
++ * freshness line; styles in kit-data.css (`.ov-offer`), so a site that loads the kit gets the look.
++ *
++ * offerCard({ title, url, store, price | all_in, currency, shipping, size, condition, checked_at, stale })
++ */
++const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++function money(amount, currency) {
++ const code = /^[A-Z]{3}$/.test(String(currency || '')) ? currency : 'USD';
++ try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(amount); } catch { return `${code} ${amount.toFixed(2)}`; }
++}
++
++function checkedText(iso, now = Date.now()) {
++ const t = Date.parse(String(iso || ''));
++ if (!Number.isFinite(t)) return 'Check time not known yet';
++ const mins = Math.max(0, Math.round((now - t) / 60000));
++ if (mins < 2) return 'Checked just now';
++ if (mins < 60) return `Checked ${mins} minutes ago`;
++ const hours = Math.round(mins / 60);
++ if (hours < 24) return `Checked ${hours} hour${hours === 1 ? '' : 's'} ago`;
++ return `Checked ${new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}`;
++}
++
++function offerCard(offer = {}, { now = Date.now() } = {}) {
++ const href = typeof offer.url === 'string' && /^https?:\/\//i.test(offer.url) ? offer.url : '';
++ const allIn = Number(offer.all_in ?? offer.price);
++ const price = Number.isFinite(allIn) ? money(allIn, offer.currency) : '';
++ const shipping = offer.shipping == null || offer.shipping === '' ? 'Shipping not stated'
++ : Number(offer.shipping) === 0 ? 'Free shipping' : `+ ${money(Number(offer.shipping), offer.currency)} shipping`;
++ const title = esc(offer.title || offer.store || 'Offer');
++ const facts = [offer.store ? esc(offer.store) : 'Shop not known', offer.size ? `Size ${esc(offer.size)}` : '', offer.condition ? esc(offer.condition) : ''].filter(Boolean).join(' · ');
++ return `<article class="ov-offer${offer.stale ? ' is-stale' : ''}" data-offer-card>`
++ + `<div class="ov-offer-main"><h3 class="ov-offer-title">${href ? `<a href="${esc(href)}" rel="nofollow noopener">${title}</a>` : title}</h3><p class="ov-offer-facts">${facts}</p></div>`
++ + `<div class="ov-offer-price">${price ? `<b>${esc(price)}</b>` : '<b class="is-unknown">Price not known yet</b>'}<small>${esc(shipping)}</small></div>`
++ + `<p class="ov-offer-fresh">${esc(checkedText(offer.checked_at, now))}${offer.stale ? ' · <span class="ov-offer-stale">may have changed</span>' : ''}</p>`
++ + '</article>';
++}
++
++module.exports = { offerCard };
ddiff --git a/package-lock.json b/package-lock.json
iindex aebce6d..b175209 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.176.0",
++ "version": "4.177.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.176.0",
++ "version": "4.177.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 53c1c99..1182a0a 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.176.0",
++ "version": "4.177.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -8,6 +8,7 @@
},
"exports": {
".": "./index.js",
++ "./offer-card": "./offer-card.js",
"./account-switcher": "./account-switcher.js",
"./analytics": "./analytics/index.js",
"./analytics/event": "./analytics/event.js",
ddiff --git a/showcase.css b/showcase.css
iindex f90486e..3045641 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -57,7 +57,7 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
.sc-h-ic svg{display:block}
--.sc-sec>h2+.sc-lede{margin-left:48px}
++.sc-sec>h2:has(.sc-h-ic)+.sc-lede{margin-left:48px}
@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
ddiff --git a/showcase.js b/showcase.js
iindex f669856..4b85213 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -509,7 +509,7 @@ html[data-ov-motion=reduced] .sc-ring,html[data-ov-motion=reduced] .sc-aura-glow
.sc-sec>h2,.sc-cta>h2{display:flex;align-items:center;gap:12px;font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:0 0 6px}
.sc-h-ic{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:11px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)}
.sc-h-ic svg{display:block}
--.sc-sec>h2+.sc-lede{margin-left:48px}
++.sc-sec>h2:has(.sc-h-ic)+.sc-lede{margin-left:48px}
@media (max-width:520px){.sc-sec>h2+.sc-lede{margin-left:0}}
.sc-lede{color:var(--text-secondary,#96a7c2);margin:0 0 18px;max-width:760px;line-height:1.55}
.sc-grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(250px,1fr))}
ddiff --git a/test/offer-card.test.js b/test/offer-card.test.js
nnew file mode 100644
iindex 0000000..27dc6f6
---- /dev/null
++++ b/test/offer-card.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++
++const assert = require('assert');
++const { offerCard } = require('../offer-card');
++
++const now = Date.parse('2026-11-09T12:00:00Z');
++const html = offerCard({ title: '<Boot>', url: 'https://store.example/item', store: 'Shop', price: 90, shipping: null, currency: 'USD', size: '9', checked_at: '2026-11-09T09:00:00Z' }, { now });
++assert.ok(html.includes('&lt;Boot&gt;'), 'the title is escaped');
++assert.ok(html.includes('Shipping not stated'), 'unknown shipping stays unknown');
++assert.ok(html.includes('$90.00'), 'the price is formatted as money');
++assert.ok(html.includes('Size 9'));
++assert.ok(html.includes('Checked 3 hours ago'));
++assert.ok(offerCard({ price: 50, shipping: 0, currency: 'EUR' }).includes('€50.00') && offerCard({ price: 50, shipping: 0 }).includes('Free shipping'));
++assert.ok(offerCard({ title: 'X' }).includes('Price not known yet') && offerCard({ title: 'X' }).includes('Check time not known yet'), 'nothing is invented');
++assert.ok(offerCard({ title: 'X', price: 1, stale: true }).includes('may have changed'));
++assert.ok(!offerCard({ url: 'javascript:alert(1)', title: 'Unsafe' }).includes('href="javascript:'), 'only http(s) links');
++assert.ok(!offerCard({ title: 'X', price: 1, currency: '"><script>' }).includes('<script>'), 'a bad currency code falls back');
++console.log('offer card: all checks passed');