From 768116f5e339c77455325c5e8122a3d6e0aea44a Mon Sep 17 00:00:00 2001 From: Framma Williansyah Akbar Date: Wed, 30 Sep 2026 11:26:40 +0700 Subject: [PATCH] feat(growth): Add campaign leaderboard widget embed snippet for external blogs (#1370) --- backend/src/index.js | 6 ++ backend/src/routes/embedWidget.js | 37 +++++++++-- backend/src/routes/embedWidget.test.js | 86 ++++++++++++++++++++++++++ frontend/README.md | 18 ++++++ frontend/public/embed.js | 41 +++++++++++- frontend/src/embedScript.test.js | 61 ++++++++++++++++++ 6 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 backend/src/routes/embedWidget.test.js create mode 100644 frontend/src/embedScript.test.js 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); + }); +});