diff --git a/backend/src/index.js b/backend/src/index.js
index ff484bba..fbac84ed 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -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)
diff --git a/backend/src/routes/embedWidget.js b/backend/src/routes/embedWidget.js
index dd98eca4..918d4f46 100644
--- a/backend/src/routes/embedWidget.js
+++ b/backend/src/routes/embedWidget.js
@@ -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';
@@ -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 `
| ${medal} | ${displayName} | ${points} |
`;
})
@@ -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;
@@ -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 };
@@ -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;
}
diff --git a/backend/src/routes/embedWidget.test.js b/backend/src/routes/embedWidget.test.js
new file mode 100644
index 00000000..ca94f2e4
--- /dev/null
+++ b/backend/src/routes/embedWidget.test.js
@@ -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 ', 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: '', 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, /
```
+### 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
+
+```
+
+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:
diff --git a/frontend/public/embed.js b/frontend/public/embed.js
index 9ff313e8..16b7af01 100644
--- a/frontend/public/embed.js
+++ b/frontend/public/embed.js
@@ -13,6 +13,14 @@
* data-color="#3b82f6">
*
*
+ * Leaderboard widget (live top participants, auto-refreshing):
+ *
+ *
* Programmatic usage (after the script loads):
* const widget = new TrivelaWidget({ campaign: 'id', partner: 'pid' });
* widget.on('trivela:ready', (e) => console.log('loaded', e))
@@ -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.
@@ -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) {
@@ -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 : '');
};
/**
@@ -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.
@@ -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;
};
@@ -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;
@@ -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.
diff --git a/frontend/src/embedScript.test.js b/frontend/src/embedScript.test.js
new file mode 100644
index 00000000..102acf2e
--- /dev/null
+++ b/frontend/src/embedScript.test.js
@@ -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);
+ });
+});