Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,12 @@ export async function createApp(options = {}) {
embedRateLimiter,
createEmbedWidgetRoute(campaignRepository, siteOrigin, {
embedSecret: process.env.EMBED_ATTRIBUTION_SECRET,
getLeaderboard: (/** @type {string} */ campaignId, /** @type {number} */ limit) =>
referralRepository.getLeaderboard(campaignId, { limit }).rows.map((/** @type {any} */ r) => ({
address: r.referrerAddress,
rank: r.rank,
points: r.referralCount,
})),
}),
);
// Developer portal (#807)
Expand Down
37 changes: 31 additions & 6 deletions backend/src/routes/embedWidget.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ function sanitiseText(raw, maxLen) {
.replace(/'/g, ''');
}

/**
* Shorten a wallet address so the public widget never shows a full account id.
* @param {unknown} raw
* @returns {string|null}
*/
function shortenAddress(raw) {
if (!raw) return null;
const s = String(raw);
return s.length > 12 ? `${s.slice(0, 5)}…${s.slice(-4)}` : s;
}

function statusLabel(campaign) {
if (!campaign.active) return 'Ended';
if (campaign.endDate && new Date(campaign.endDate) < new Date()) return 'Ended';
Expand Down Expand Up @@ -128,9 +139,12 @@ function renderLeaderboardWidget(campaign, entries, params) {
const rows = (entries ?? [])
.slice(0, limit)
.map((entry, i) => {
const rank = i + 1;
const displayName = sanitiseText(entry.displayName ?? entry.address ?? 'Anonymous', 32);
const points = entry.points ?? entry.score ?? 0;
const displayName = sanitiseText(
entry.displayName ?? shortenAddress(entry.address ?? entry.walletAddress) ?? 'Anonymous',
32,
);
const points = Number(entry.points ?? entry.score ?? 0) || 0;
const rank = Number.isInteger(entry.rank) && entry.rank > 0 ? entry.rank : i + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : `#${rank}`;
return `<tr><td style="padding:8px 12px;border-bottom:1px solid ${borderColor};font-weight:${rank <= 3 ? 700 : 400}">${medal}</td><td style="padding:8px 12px;border-bottom:1px solid ${borderColor};color:${textPrimary}">${displayName}</td><td style="padding:8px 12px;border-bottom:1px solid ${borderColor};text-align:right;color:${accent};font-weight:600">${points}</td></tr>`;
})
Expand Down Expand Up @@ -211,7 +225,11 @@ body{font-family:system-ui,-apple-system,sans-serif;background:${bg};padding:12p
* @param {object} options
* @returns {import('express').RequestHandler}
*/
export function createEmbedWidgetRoute(campaignRepository, siteOrigin, { embedSecret = '' } = {}) {
export function createEmbedWidgetRoute(
campaignRepository,
siteOrigin,
{ embedSecret = '', getLeaderboard } = {},
) {
return function embedWidget(req, res) {
const { widgetType, campaignId } = req.params;

Expand Down Expand Up @@ -248,7 +266,7 @@ export function createEmbedWidgetRoute(campaignRepository, siteOrigin, { embedSe
// Set CSP headers
res.setHeader('Content-Security-Policy', buildCspHeader(siteOrigin));
res.setHeader('X-Frame-Options', 'ALLOWALL');
res.setHeader('Cache-Control', 'public, max-age=60');
res.setHeader('Cache-Control', 'public, max-age=30');

const params = { theme, color, partner, org, siteOrigin, limit };

Expand All @@ -258,7 +276,14 @@ export function createEmbedWidgetRoute(campaignRepository, siteOrigin, { embedSe
html = renderCardWidget(campaign, params);
break;
case 'leaderboard': {
const entries = campaignRepository.getLeaderboard?.(campaignId, limit) ?? [];
let entries = [];
try {
const source = getLeaderboard ?? campaignRepository.getLeaderboard;
entries = source?.(campaignId, limit) ?? [];
} catch {
// Fail soft: an empty board beats a broken widget on a partner's page.
entries = [];
}
html = renderLeaderboardWidget(campaign, entries, params);
break;
}
Expand Down
86 changes: 86 additions & 0 deletions backend/src/routes/embedWidget.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import { createEmbedWidgetRoute } from './embedWidget.js';

const repo = {
getById: (id) => (id === 'c1' ? { id: 'c1', name: 'Camp <b>', active: true } : null),
};

function run(handler, params, query = {}) {
const res = {
headers: {},
statusCode: 200,
body: undefined,
setHeader(k, v) {
this.headers[k] = v;
},
status(c) {
this.statusCode = c;
return this;
},
type() {
return this;
},
json(b) {
this.body = b;
return this;
},
send(b) {
this.body = b;
return this;
},
};
handler({ params, query }, res);
return res;
}

describe('leaderboard embed widget', () => {
test('renders ranked rows with shortened addresses and escaped names', () => {
const calls = [];
const h = createEmbedWidgetRoute(repo, 'https://t.app', {
getLeaderboard: (id, limit) => {
calls.push([id, limit]);
return [
{ address: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234', rank: 1, points: 9 },
{ displayName: '<script>x</script>', rank: 1, points: 9 },
{ address: 'GZZZ', rank: 4, points: 2 },
];
},
});
const res = run(h, { widgetType: 'leaderboard', campaignId: 'c1' }, { limit: '3' });
assert.deepEqual(calls, [['c1', 3]]);
assert.match(res.body, /GABCD…1234/);
assert.doesNotMatch(res.body, /GABCDEFGHIJKLMNOPQRSTUVWXYZ1234/);
assert.doesNotMatch(res.body, /<script>/);
assert.match(res.body, /Camp &lt;b&gt; Leaderboard/);
assert.match(res.body, />#4</);
assert.equal(res.headers['Cache-Control'], 'public, max-age=30');
});

test('clamps limit to 50 and defaults to 10', () => {
const limits = [];
const h = createEmbedWidgetRoute(repo, 'https://t.app', {
getLeaderboard: (_id, l) => (limits.push(l), []),
});
run(h, { widgetType: 'leaderboard', campaignId: 'c1' }, { limit: '999' });
run(h, { widgetType: 'leaderboard', campaignId: 'c1' }, {});
assert.deepEqual(limits, [50, 10]);
});

test('empty state and data-source failure both render gracefully', () => {
const h = createEmbedWidgetRoute(repo, 'https://t.app', {
getLeaderboard: () => {
throw new Error('db down');
},
});
const res = run(h, { widgetType: 'leaderboard', campaignId: 'c1' });
assert.equal(res.statusCode, 200);
assert.match(res.body, /No participants yet/);
});

test('404 for unknown campaign, 400 for unknown widget type', () => {
const h = createEmbedWidgetRoute(repo, 'https://t.app');
assert.equal(run(h, { widgetType: 'leaderboard', campaignId: 'nope' }).statusCode, 404);
assert.equal(run(h, { widgetType: 'bogus', campaignId: 'c1' }).statusCode, 400);
});
});
18 changes: 18 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,24 @@ Partners can embed Trivela campaign widgets on third-party sites using either an
></script>
```

### Leaderboard embed

Show a live top-participants table for a campaign. `data-limit` (1–50, default 10) sets the row
count and `data-refresh` (seconds, minimum 30) reloads the board periodically. Wallet addresses are
shortened; no full account ids are exposed.

```html
<script
src="https://trivela.app/embed.js"
data-campaign="CAMPAIGN_ID"
data-widget="leaderboard"
data-limit="10"
data-refresh="60"
></script>
```

Or as a plain iframe: `https://trivela.app/embed/v1/leaderboard/CAMPAIGN_ID?limit=10&theme=light`.

### Programmatic usage

After the script loads, you can also mount widgets manually:
Expand Down
41 changes: 39 additions & 2 deletions frontend/public/embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
* data-color="#3b82f6">
* </script>
*
* Leaderboard widget (live top participants, auto-refreshing):
* <script src="https://trivela.app/embed.js"
* data-campaign="<campaign-id>"
* data-widget="leaderboard"
* data-limit="10"
* data-refresh="60">
* </script>
*
* Programmatic usage (after the script loads):
* const widget = new TrivelaWidget({ campaign: 'id', partner: 'pid' });
* widget.on('trivela:ready', (e) => console.log('loaded', e))
Expand Down Expand Up @@ -56,6 +64,9 @@

// Default iframe dimensions per size preset.
var SIZE_HEIGHTS = { sm: '260px', md: '320px', lg: '380px' };
var WIDGET_TYPES = { card: 1, leaderboard: 1, progress: 1 };
var MIN_REFRESH_SECONDS = 30;
var MAX_LIMIT = 50;

/**
* Validate and sanitise a partner ID.
Expand Down Expand Up @@ -89,6 +100,9 @@
* @param {string} [config.size] 'sm' | 'md' | 'lg'
* @param {string} [config.org] Partner org display name
* @param {string} [config.color] Button hex colour override
* @param {string} [config.widget] 'card' (default) | 'leaderboard' | 'progress'
* @param {number} [config.limit] Leaderboard rows (1-50)
* @param {number} [config.refresh] Reload interval in seconds (min 30); 0/omitted disables
* @param {string} [config.origin] Override Trivela origin (testing only)
*/
function TrivelaWidget(config) {
Expand Down Expand Up @@ -169,8 +183,15 @@
if (cfg.size && SIZE_HEIGHTS[cfg.size]) params.set('size', cfg.size);
if (cfg.org) params.set('org', String(cfg.org).slice(0, 48));

var widget = WIDGET_TYPES[cfg.widget] ? cfg.widget : 'card';
if (widget === 'leaderboard') {
var limit = parseInt(cfg.limit, 10);
if (limit > 0) params.set('limit', String(Math.min(limit, MAX_LIMIT)));
}

var qs = params.toString();
return origin + '/embed/campaign/' + encodeURIComponent(cfg.campaign) + (qs ? '?' + qs : '');
var path = widget === 'card' ? '/embed/campaign/' : '/embed/v1/' + widget + '/';
return origin + path + encodeURIComponent(cfg.campaign) + (qs ? '?' + qs : '');
};

/**
Expand All @@ -190,7 +211,8 @@

var iframe = document.createElement('iframe');
iframe.src = this._buildSrc();
iframe.title = 'Trivela Campaign Widget';
iframe.title =
cfg.widget === 'leaderboard' ? 'Trivela Campaign Leaderboard' : 'Trivela Campaign Widget';
iframe.setAttribute('loading', 'lazy');
iframe.setAttribute('allow', 'payment');
// Sandbox: minimal permissions required for the widget to function.
Expand Down Expand Up @@ -225,6 +247,14 @@
};

global.addEventListener('message', this._messageHandler);

var refresh = parseInt(cfg.refresh, 10);
if (refresh > 0) {
var ms = Math.max(refresh, MIN_REFRESH_SECONDS) * 1000;
this._refreshTimer = setInterval(function () {
if (self._iframe) self._iframe.src = self._buildSrc();
}, ms);
}
return this;
};

Expand All @@ -236,6 +266,10 @@
global.removeEventListener('message', this._messageHandler);
this._messageHandler = null;
}
if (this._refreshTimer) {
clearInterval(this._refreshTimer);
this._refreshTimer = null;
}
if (this._iframe && this._iframe.parentNode) {
this._iframe.parentNode.removeChild(this._iframe);
this._iframe = null;
Expand Down Expand Up @@ -268,6 +302,9 @@
size: script.getAttribute('data-size') || 'md',
org: script.getAttribute('data-org') || '',
color: script.getAttribute('data-color') || '',
widget: script.getAttribute('data-widget') || 'card',
limit: script.getAttribute('data-limit') || '',
refresh: script.getAttribute('data-refresh') || '',
});

// Insert a container div immediately after the script tag.
Expand Down
61 changes: 61 additions & 0 deletions frontend/src/embedScript.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { readFileSync } from 'node:fs';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

const source = readFileSync('public/embed.js', 'utf8');

function load() {
delete window.TrivelaWidget;
window.eval(source);
return window.TrivelaWidget;
}

describe('embed.js leaderboard widget', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());

it('builds a leaderboard URL with a clamped limit', () => {
const W = load();
const w = new W({
campaign: 'c 1',
widget: 'leaderboard',
limit: '500',
origin: 'https://t.app',
});
expect(w._buildSrc()).toBe('https://t.app/embed/v1/leaderboard/c%201?limit=50');
});

it('defaults to the card endpoint and ignores unknown widget types', () => {
const W = load();
expect(new W({ campaign: 'a', origin: 'https://t.app' })._buildSrc()).toBe(
'https://t.app/embed/campaign/a',
);
expect(new W({ campaign: 'a', widget: 'evil', origin: 'https://t.app' })._buildSrc()).toBe(
'https://t.app/embed/campaign/a',
);
});

it('refreshes the iframe on an interval (min 30s) and stops on destroy', () => {
const W = load();
const host = document.createElement('div');
document.body.appendChild(host);
const w = new W({
campaign: 'a',
widget: 'leaderboard',
refresh: '5',
origin: 'https://t.app',
});
w.mount(host);
const iframe = host.querySelector('iframe');
const spy = vi.spyOn(iframe, 'src', 'set');
vi.advanceTimersByTime(29_000);
expect(spy).not.toHaveBeenCalled();
vi.advanceTimersByTime(1_000);
expect(spy).toHaveBeenCalledTimes(1);
w.destroy();
vi.advanceTimersByTime(120_000);
expect(spy).toHaveBeenCalledTimes(1);
});
});