Skip to content

Commit 6b21bfc

Browse files
markets/: shared MARKET links unfurl as the market, not the site
A market is the unit people actually send each other, but the in-app router is hash-based and a fragment NEVER reaches a server — so every shared #m/<id> link unfurled as the generic site card, with none of the question and none of the odds. Two server-visible share forms now render the same app with per-market meta: the path /m/<id> and the query ?m=<id> (both reach the server, unlike a hash). og:title is the question, og:description the live prices plus the close time and topic, og:url the share form; an unknown id degrades to the site card rather than 404ing someone's link. The client accepts either form and hands off to the hash router, and the detail view rewrites the address bar to the path form — most people copy the URL rather than press a button — with a Copy link button beside it for those who do.
1 parent a0b95d5 commit 6b21bfc

2 files changed

Lines changed: 80 additions & 6 deletions

File tree

markets/plugin.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1348,13 +1348,44 @@ export async function activate(api) {
13481348
});
13491349

13501350
// ---------------------------------------------------------------- UI
1351+
/** Public share view of a market, or null. */
1352+
function shareOf(id) {
1353+
if (typeof id !== 'string' || !Object.prototype.hasOwnProperty.call(state.markets, id)) return null;
1354+
const m = state.markets[id];
1355+
return {
1356+
id: m.id,
1357+
title: m.title,
1358+
category: m.category || '',
1359+
status: displayStatus(m),
1360+
closesAt: m.closesAt,
1361+
outcomes: m.outcomes,
1362+
prices: lmsrPrices(m.q, m.bMicro),
1363+
};
1364+
}
1365+
const uiOpts = (market) => ({
1366+
accounts: accountsUi, brand: cfg.brand, tagline: cfg.tagline,
1367+
ogImage: cfg.ogImage, ogUrl: cfg.ogUrl, favicon: cfg.favicon, market,
1368+
});
1369+
13511370
const uiHandler = async (request, reply) => {
13521371
for (const [k, v] of Object.entries(UI_HEADERS)) reply.header(k, v);
1353-
return reply.header('content-type', 'text/html; charset=utf-8').send(renderUi(prefix, { accounts: accountsUi, brand: cfg.brand, tagline: cfg.tagline, ogImage: cfg.ogImage, ogUrl: cfg.ogUrl, favicon: cfg.favicon }));
1372+
return reply.header('content-type', 'text/html; charset=utf-8')
1373+
.send(renderUi(prefix, uiOpts(shareOf(request.query && request.query.m))));
13541374
};
13551375
if (prefix) api.fastify.get(prefix, uiHandler); // '' would be an empty route path
13561376
api.fastify.get(`${prefix}/`, uiHandler);
13571377

1378+
// Share links: a market is the unit people actually send each other, but
1379+
// the in-app router is hash-based and a fragment never reaches a server —
1380+
// so every shared market unfurled as the generic site card. This path
1381+
// route renders the same app with meta describing THAT market (question,
1382+
// live odds, close time), and the client hands over to the hash router.
1383+
api.fastify.get(`${prefix}/m/:id`, async (request, reply) => {
1384+
for (const [k, v] of Object.entries(UI_HEADERS)) reply.header(k, v);
1385+
return reply.header('content-type', 'text/html; charset=utf-8')
1386+
.send(renderUi(prefix, uiOpts(shareOf(request.params.id))));
1387+
});
1388+
13581389
tick();
13591390
const snap = setInterval(() => store.snapshot(), num(cfg.snapshotIntervalMs, 30_000));
13601391
snap.unref?.();

markets/ui.js

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,18 @@ export function renderUi(prefix, opts = {}) {
2727
const brand = typeof opts.brand === 'string' && opts.brand ? opts.brand : 'Markets';
2828
const tagline = typeof opts.tagline === 'string' && opts.tagline ? opts.tagline : 'prediction markets on your pod';
2929
// Social/OG affordances for standalone hosts; harmless defaults for JSS.
30-
const ogTitle = `${brand}${tagline}`;
30+
const mk = opts.market && typeof opts.market === 'object' ? opts.market : null;
31+
const esc0 = (v) => String(v == null ? '' : v)
32+
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
33+
// A shared market should unfurl as the QUESTION and its live odds — that
34+
// is the whole reason someone sends the link.
35+
const mkOdds = mk ? mk.outcomes
36+
.map((o, i) => `${o} ${(mk.prices[i] * 100).toFixed(0)}%`).slice(0, 4).join(' · ') : '';
37+
const mkWhen = mk ? (() => {
38+
const d = Math.round((mk.closesAt - Date.now()) / 86400000);
39+
return mk.status === 'open' && d > 0 ? `closes in ${d}d` : mk.status;
40+
})() : '';
41+
const ogTitle = mk ? esc0(mk.title) : `${brand}${tagline}`;
3142
const ogImage = typeof opts.ogImage === 'string' ? opts.ogImage : '';
3243
const ogUrl = typeof opts.ogUrl === 'string' ? opts.ogUrl : '';
3344
const favicon = typeof opts.favicon === 'string' && opts.favicon ? opts.favicon
@@ -37,12 +48,15 @@ export function renderUi(prefix, opts = {}) {
3748
`<meta property="og:type" content="website">`,
3849
`<meta property="og:site_name" content="${brand}">`,
3950
`<meta property="og:title" content="${ogTitle}">`,
40-
`<meta property="og:description" content="Play-money prediction markets: bet paper credits, watch the odds move, climb the leaderboard, ask your own questions.">`,
41-
ogUrl ? `<meta property="og:url" content="${ogUrl}">` : '',
51+
`<meta property="og:description" content="${mk
52+
? esc0(`${mkOdds}${mkWhen}${mk.category ? ` · ${mk.category}` : ''} · play money on ${brand}`)
53+
: 'Play-money prediction markets: bet paper credits, watch the odds move, climb the leaderboard, ask your own questions.'}">`,
54+
ogUrl ? `<meta property="og:url" content="${ogUrl}${prefix}${mk ? `/m/${esc0(mk.id)}` : '/'}">` : '',
4255
ogImage ? `<meta property="og:image" content="${ogImage}">` : '',
4356
ogImage ? `<meta property="og:image:width" content="1200">\n<meta property="og:image:height" content="630">` : '',
4457
`<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">`,
4558
`<meta name="twitter:title" content="${ogTitle}">`,
59+
mk ? `<meta name="twitter:description" content="${esc0(`${mkOdds}${mkWhen}`)}">` : '',
4660
ogImage ? `<meta name="twitter:image" content="${ogImage}">` : '',
4761
].filter(Boolean).join('\n');
4862
const P = JSON.stringify(prefix);
@@ -479,7 +493,8 @@ ${ogMeta}
479493
480494
<!-- ========================== detail view ========================== -->
481495
<div id="detail-view" class="hidden">
482-
<p><a href="#" id="back">&larr; All markets</a></p>
496+
<p style="display:flex;align-items:center;gap:var(--s3)"><a href="#" id="back">&larr; All markets</a>
497+
<button type="button" id="share" class="ghost" style="font-size:var(--f-sm)">Copy link</button></p>
483498
<div class="cols">
484499
<aside class="rail">
485500
<div class="ticket" id="ticket">
@@ -1417,9 +1432,17 @@ ${ogMeta}
14171432
async function route() {
14181433
renderBoard();
14191434
const h = location.hash;
1420-
if (h.startsWith('#m/')) return renderDetail(h.slice(3), true);
1435+
if (h.startsWith('#m/')) {
1436+
const id = h.slice(3);
1437+
// Make the ADDRESS BAR shareable: most people copy the URL rather
1438+
// than press a button, and the path form is the one that unfurls
1439+
// with this market's question and odds.
1440+
try { history.replaceState(null, '', PREFIX + '/m/' + id); } catch { /* file:// etc */ }
1441+
return renderDetail(id, true);
1442+
}
14211443
$('detail-view').classList.add('hidden');
14221444
$('list-view').classList.remove('hidden');
1445+
try { if (location.pathname !== (PREFIX || '/')) history.replaceState(null, '', (PREFIX || '/')); } catch { /* ignore */ }
14231446
document.title = ${JSON.stringify(brand + ' — ' + tagline)};
14241447
current = null; cursor = null; paged = false;
14251448
$('d-position').dataset.shape = '';
@@ -1486,6 +1509,15 @@ ${ogMeta}
14861509
$('acct-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') acctAuth('/login'); });
14871510
}
14881511
$('back').onclick = (e) => { e.preventDefault(); location.hash = ''; };
1512+
// The share URL is the PATH form (/m/<id>), which the server renders with
1513+
// this market's question and odds in its meta — a hash link would unfurl
1514+
// as the generic site card.
1515+
$('share').onclick = async () => {
1516+
if (!current) return;
1517+
const url = location.origin + PREFIX + '/m/' + current.id;
1518+
try { await navigator.clipboard.writeText(url); toast('Link copied'); }
1519+
catch { window.prompt('Copy this link', url); }
1520+
};
14891521
$('t-buy').onclick = () => {
14901522
if (!me) {
14911523
$('auth-card').classList.remove('hidden');
@@ -1581,6 +1613,17 @@ ${ogMeta}
15811613
: 'Escrow: —';
15821614
};
15831615
$('c-outcomes').oninput = $('c-b').oninput = escrowPreview;
1616+
// Arrived via a share link? Hand off to the hash router.
1617+
(function shareEntry() {
1618+
// Both server-visible forms are honoured: the path (/m/<id>) and the
1619+
// query (?m=<id>). A fragment never reaches the server, which is why
1620+
// shared #m/ links unfurled as the generic card.
1621+
const byPath = /\/m\/([A-Za-z0-9_-]+)\/?$/.exec(location.pathname);
1622+
const byQuery = new URLSearchParams(location.search).get('m');
1623+
const id = (byPath && byPath[1]) || byQuery;
1624+
if (!id || location.hash) return;
1625+
history.replaceState(null, '', (PREFIX || '/') + '#m/' + id);
1626+
})();
15841627
window.addEventListener('hashchange', () => route());
15851628
15861629
// Live feed, with reconnect. Only re-render what is on screen.

0 commit comments

Comments
 (0)