Ship LNURL mint pages, indexing UI, and reviews rewrite.

Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-22 03:44:43 +02:00
co-authored by Cursor
parent 2a9444942b
commit c74c7fc187
66 changed files with 7603 additions and 1241 deletions
+10 -4
View File
@@ -28,7 +28,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Resvg } from '@resvg/resvg-js';
import { renderSvg } from './card.mjs';
import { cashuModel, defaultModel, fedimintModel } from './model.mjs';
import { cashuModel, defaultModel, fedimintModel, lnurlModel } from './model.mjs';
import { FIXTURES, NOW as FIXTURE_NOW } from './fixtures.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -96,7 +96,9 @@ async function buildFixtures() {
const model =
raw.type === 'fedimint'
? fedimintModel(raw, { now: FIXTURE_NOW })
: cashuModel(raw, { now: FIXTURE_NOW });
: raw.type === 'lnurl'
? lnurlModel(raw, { now: FIXTURE_NOW })
: cashuModel(raw, { now: FIXTURE_NOW });
const png = await renderPng(model, null);
const file = path.join(dir, `${raw.host}.png`);
await writeFile(file, png);
@@ -114,9 +116,10 @@ async function build() {
const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}'));
const previousImages = previous.images ?? {};
const [cashu, fedimint, stats] = await Promise.all([
const [cashu, fedimint, lnurl, stats] = await Promise.all([
getJson('/api/mints?type=cashu'),
getJson('/api/mints?type=fedimint'),
getJson('/api/mints?type=lnurl'),
getJson('/api/stats'),
]);
@@ -135,6 +138,7 @@ async function build() {
const listings = [
...cashu.map((m) => ({ listing: m, kind: 'cashu' })),
...fedimint.map((m) => ({ listing: m, kind: 'fedimint' })),
...lnurl.map((m) => ({ listing: m, kind: 'lnurl' })),
];
// A handful at a time: satori renders in milliseconds, the icon fetches are local,
@@ -158,7 +162,9 @@ async function build() {
const model =
kind === 'fedimint'
? fedimintModel(detail, { iconHash: icon?.hash ?? null, now })
: cashuModel(detail, { iconHash: icon?.hash ?? null, now });
: kind === 'lnurl'
? lnurlModel(detail, { iconHash: icon?.hash ?? null, now })
: cashuModel(detail, { iconHash: icon?.hash ?? null, now });
const hash = hashModel(model);
const file = `${model.slug}.${hash}.png`;
+101 -15
View File
@@ -29,6 +29,8 @@ const AMBER = '#F0A93B';
const VIOLET = '#A78BFA';
const MINT = '#5FD68F';
const RED = '#E06450';
/** The LNURL ecosystem label. The only cool blue here — see DESIGN.md. */
const LNURL = '#4EA8DE';
const GROTESK = 'Space Grotesk';
const INTER = 'Inter';
@@ -137,22 +139,29 @@ function statusChip(model) {
}
}
/** The type badge that makes a federation card unmistakable at thumbnail size. */
function fedimintBadge() {
/**
* The type badge that makes a non-Cashu card unmistakable at thumbnail size.
*
* Three things separate the three ecosystems at 300px wide, and the badge is only one
* of them: the word itself, the badge's colour, and the glow behind the whole card all
* change together. Cashu gets no badge at all, which is its own signal — it is the
* default and the majority of the index.
*/
function typeBadge(label, color, borderColor) {
return h(
'div',
{
display: 'flex',
padding: '9px 14px',
borderRadius: '9px',
border: `1.5px solid rgba(167,139,250,.5)`,
color: VIOLET,
border: `1.5px solid ${borderColor}`,
color,
fontFamily: MONO,
fontWeight: 500,
fontSize: '18px',
letterSpacing: '3px',
},
'FEDIMINT',
label,
);
}
@@ -380,15 +389,86 @@ function thirdCellFedimint(model) {
);
}
/** The background glow: amber for a healthy card, red-tinted for trouble, violet for Fedimint. */
/**
* Third cell, LNURL: the withdraw range — never NUT limits, never a module list.
*
* This is the number that answers an LNURL reader's actual question: how much can one
* of this mint's notes be worth? It is shown in sats, converted from millisatoshi in
* the model, and it is a *range* rather than a pair of independent bounds, which is
* why the two rows read "min"/"max" the way the Cashu limits cell does.
*
* The lightning address takes the space the Fedimint cell gives its network badge: it
* is the one string on the whole card someone might copy out of a screenshot.
*/
function thirdCellLnurl(model) {
const base = {
display: 'flex',
flexDirection: 'column',
flex: 1.1,
padding: '30px 36px',
borderLeft: `1px solid ${LINE_SOFT}`,
};
if (model.warning) {
const color = model.warning.severity === 'critical' ? RED : AMBER;
return h(
'div',
{ ...base, backgroundColor: model.warning.severity === 'critical' ? 'rgba(224,100,80,.06)' : 'rgba(240,169,59,.05)' },
cellLabel('WARNING', color),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '34px', lineHeight: 1.05, color }, model.warning.label),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
);
}
const lines = [];
if (model.withdraw) {
if (model.withdraw.min !== null) lines.push(['min', `${compact(model.withdraw.min)} sat`]);
if (model.withdraw.max !== null) lines.push(['max', `${compact(model.withdraw.max)} sat`]);
}
return h(
'div',
base,
cellLabel('WITHDRAW'),
lines.length === 0
? h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '24px', color: MUTED, marginTop: '8px' }, 'Not published')
: h(
'div',
{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '4px' },
...lines.map(([k, v]) =>
h(
'div',
{ display: 'flex', alignItems: 'baseline', gap: '14px' },
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '20px', color: FAINT }, k),
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '27px', color: TEXT }, v),
),
),
),
model.address &&
h(
'div',
{ fontFamily: MONO, fontWeight: 400, fontSize: '18px', color: FAINT, marginTop: '16px', maxWidth: '300px' },
fitMono(model.address, 30),
),
);
}
/** The background glow: amber healthy, red for trouble, violet Fedimint, blue LNURL. */
function glow(model) {
const danger =
model.status === 'offline' || (model.type === 'cashu' && model.warning && model.warning.severity === 'critical');
model.status === 'offline' ||
(model.type !== 'fedimint' && model.warning && model.warning.severity === 'critical');
const color = danger
? 'rgba(224,100,80,.13)'
: model.type === 'fedimint'
? 'rgba(167,139,250,.11)'
: 'rgba(240,169,59,.10)';
: model.type === 'lnurl'
// Deliberately the strongest of the three. Amber and violet only have to
// separate a card from the site's own background; this one has to separate an
// LNURL card from a Fedimint card at 300px, where the badge text is a smudge
// and the tint is the first thing a reader actually resolves.
? 'rgba(78,168,222,.20)'
: 'rgba(240,169,59,.10)';
return h('div', {
position: 'absolute',
top: '-260px',
@@ -414,10 +494,12 @@ function brandRow(tagline) {
/** One mint or federation card. `iconData` is a data: URI or null. */
export function mintCard(model, iconData) {
const isFed = model.type === 'fedimint';
const isLnurl = model.type === 'lnurl';
const monoLine = isFed ? model.shortId : fitMono(model.domain, 46);
// Room beside the icon: canvas minus padding, icon, gaps and the chips.
const chipRoom = 240 + (isFed ? 180 : 0);
// Room beside the icon: canvas minus padding, icon, gaps and the chips. A badge is
// ~180px for FEDIMINT and ~120px for the shorter LNURL.
const chipRoom = 240 + (isFed ? 180 : isLnurl ? 120 : 0);
const { size, clamp } = nameSize(model.name, 1200 - 128 - 148 - chipRoom);
return h(
@@ -433,7 +515,7 @@ export function mintCard(model, iconData) {
fontFamily: INTER,
},
glow(model),
brandRow(isFed ? 'Fedimint reviews' : 'Cashu mint reviews'),
brandRow(isFed ? 'Fedimint reviews' : isLnurl ? 'LNURL mint reviews' : 'Cashu mint reviews'),
h(
'div',
{ display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 },
@@ -459,7 +541,8 @@ export function mintCard(model, iconData) {
model.name,
),
statusChip(model),
isFed && fedimintBadge(),
isFed && typeBadge('FEDIMINT', VIOLET, 'rgba(167,139,250,.5)'),
isLnurl && typeBadge('LNURL', LNURL, 'rgba(78,168,222,.5)'),
),
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '25px', color: MUTED, marginTop: '14px' }, monoLine),
// The Fedimint soft warning is a muted line, deliberately not a red cell.
@@ -479,7 +562,7 @@ export function mintCard(model, iconData) {
},
ratingCell(model),
reviewsCell(model),
isFed ? thirdCellFedimint(model) : thirdCellCashu(model),
isFed ? thirdCellFedimint(model) : isLnurl ? thirdCellLnurl(model) : thirdCellCashu(model),
),
);
}
@@ -525,13 +608,16 @@ export function defaultCard(model) {
h(
'div',
{ fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' },
'Cashu mint and Fedimint reviews, signed on Nostr',
'Cashu, Fedimint and LNURL mint reviews, signed on Nostr',
),
h(
'div',
{ display: 'flex', gap: '86px', marginTop: '64px' },
// Four stats where there were three, so the gap tightens rather than the row
// running past the card's own margins.
{ display: 'flex', gap: '64px', marginTop: '64px' },
stat(model.mints, 'Cashu mints'),
stat(model.federations, 'federations'),
stat(model.lnurl, 'LNURL mints'),
stat(model.reviews, 'signed reviews'),
),
);
+64 -1
View File
@@ -1,7 +1,7 @@
/**
* The edge cases the template must survive, as synthetic API payloads.
*
* Run through the same cashuModel/fedimintModel derivation as real data — a fixture
* Run through the same cashuModel/fedimintModel/lnurlModel derivation as real data — a fixture
* that bypassed the model would test a card no build can produce. `NOW` is pinned so
* the rendered PNGs under og-fixtures/ are byte-stable and can live in the repo as
* eyeball snapshots.
@@ -32,6 +32,31 @@ const base = {
},
};
/**
* The shared shape of an LNURL row's detail payload, as `GET /api/mints/:host` returns
* it: the ecosystem fields flattened across the top level, millisatoshi on the wire.
*/
const lnurlBase = {
type: 'lnurl',
status: 'online',
last_online: NOW - 300,
last_probe: NOW - 300,
first_seen: NOW - 200 * 86400,
review_count: 9,
rating_avg: 4.4,
rating_distribution: dist(6, 2, 1),
last_review_at: NOW - 20 * 86400,
features: ['mint', 'melt', 'rotate', 'split', 'merge', 'lud06', 'lud03', 'lud16', 'signed-notes'],
network: 'mainnet',
mint_pubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555',
funding_available: true,
invalid_reason: null,
min_withdrawable_msat: 5_000,
max_withdrawable_msat: 999_899_000,
lightning_address: 'mint@lnurl.example',
};
export const FIXTURES = [
{
// A name at the 30-character mark: must step down, never clip.
@@ -109,4 +134,42 @@ export const FIXTURES = [
network: 'mainnet',
status_source: null,
},
{
// The healthy LNURL card: blue glow, LNURL badge, a withdraw range in the third
// cell, and the lightning address under it.
...lnurlBase,
host: 'fixture-lnurl-online',
url: 'lnurl:https://lnurl.example',
base_url: 'https://lnurl.example',
name: null,
icon: null,
},
{
// Offline 20 days: red glow beats the blue one, warning cell replaces the range.
// The blue badge stays, because which ecosystem it is has not changed.
...lnurlBase,
host: 'fixture-lnurl-offline',
url: 'lnurl:https://lnurl.gone.example',
base_url: 'https://lnurl.gone.example',
name: 'Departed Notes',
icon: null,
status: 'offline',
last_online: NOW - 20 * 86400,
rating_avg: 2.1,
rating_distribution: dist(0, 1, 1, 3, 5),
review_count: 10,
},
{
// A withdraw ceiling of zero, while ONLINE: green status chip stays, and the
// critical warning cell appears anyway. The one card that must never print
// "max 0 sat" as though it were a limit.
...lnurlBase,
host: 'fixture-lnurl-no-withdrawals',
url: 'lnurl:https://lnurl.locked.example',
base_url: 'https://lnurl.locked.example',
name: 'Locked Notes Mint',
icon: null,
min_withdrawable_msat: 0,
max_withdrawable_msat: 0,
},
];
+77 -4
View File
@@ -14,10 +14,15 @@
* The warning logic is imported from shared/, not restated: the image must never
* disagree with the page it decorates about whether a mint is frozen or gone.
*/
import { getMintWarnings, parseLimits, readCapabilities } from '@cashumints/shared';
import { getMintWarnings, msatToSat, parseLimits, readCapabilities } from '@cashumints/shared';
/** Bump when the template's layout or copy changes, to regenerate every image. */
export const TEMPLATE_VERSION = 1;
/**
* Bump when the template's layout or copy changes, to regenerate every image.
*
* 2: the LNURL variant. Its badge, glow and third cell are new, and the shared
* `warningCell` learned three LNURL warning kinds, so every card's hash input moved.
*/
export const TEMPLATE_VERSION = 2;
const DAY = 86_400;
@@ -115,6 +120,11 @@ function warningCell(warning, mint, now) {
'offline-long': 'Offline',
'offline': 'Offline',
'fedimint-offline': 'Reported offline',
// LNURL. `lnurl-withdrawals-disabled` gets the same two words as the Cashu
// melt-disabled cell, because it is the same statement to a reader.
'lnurl-withdrawals-disabled': 'No withdrawals',
'lnurl-no-funding': 'No mint / melt',
'lnurl-invalid': 'Not responding properly',
}[warning.kind];
if (!label) return null;
@@ -135,11 +145,26 @@ function warningCell(warning, mint, now) {
case 'frozen':
sub = 'Nothing moves in or out';
break;
case 'lnurl-withdrawals-disabled':
sub = 'Notes cannot be redeemed';
break;
case 'lnurl-no-funding':
// The precise half of the story, and the half that stops a reader writing the
// mint off: its notes still work, only Lightning is shut.
sub = 'Notes still rotate and split';
break;
case 'lnurl-invalid':
sub = 'Endpoint answered with junk';
break;
}
// The mint is also offline while a capability warning outranks that fact: the cell
// still has to say so, the way the page banner folds it into its last sentence.
if (['melt-only', 'melt-disabled', 'frozen'].includes(warning.kind) && mint.status === 'offline' && since !== null) {
if (
['melt-only', 'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled'].includes(warning.kind) &&
mint.status === 'offline' &&
since !== null
) {
sub = `Offline since ${shortDate(since)}`;
}
@@ -227,6 +252,53 @@ export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now(
};
}
/**
* One LNURL mint's card model.
*
* The same skeleton as the other two, with the third cell answering this ecosystem's
* own question: how much can one of this mint's bearer notes be worth? That is the
* withdraw range, in sats, converted here once from the millisatoshi the wire carries.
*
* `withdraw` is null when the ceiling is zero, and deliberately: a card printing
* "max 0 sat" reads as a number rather than as a refusal, and the warning cell that
* outranks it says the true thing instead.
*/
export function lnurlModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const baseUrl = mint.base_url ?? mint.url.replace(/^lnurl:/, '');
const domain = displayDomain(baseUrl);
const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain;
const warning = getMintWarnings(mint, { now })[0] ?? null;
const offline = mint.status === 'offline';
const offlineDays =
offline && mint.last_online !== null
? Math.max(0, Math.floor((now - mint.last_online) / DAY))
: null;
const min = msatToSat(mint.min_withdrawable_msat ?? null);
const max = msatToSat(mint.max_withdrawable_msat ?? null);
return {
v: TEMPLATE_VERSION,
type: 'lnurl',
slug: mint.host,
name,
domain,
status: mint.status,
offlineDays,
rating: mint.rating_avg,
reviewCount: mint.review_count,
sentiment: sentiment(mint.rating_distribution ?? {}),
lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null,
withdraw: max !== null && max > 0 ? { min, max } : null,
/** The LUD-16 address, which is the one string a reader might actually copy. */
address: mint.lightning_address ?? null,
warning: warningCell(warning, mint, now),
iconHash,
gradient: iconHash ? null : iconGradient(domain),
initials: iconHash ? null : initials(name),
};
}
/** The default card: brand, tagline, and the network's own numbers. */
export function defaultModel(stats) {
return {
@@ -235,6 +307,7 @@ export function defaultModel(stats) {
slug: 'default',
mints: stats.cashu_total,
federations: stats.fedimint_total,
lnurl: stats.lnurl_total,
reviews: stats.reviews_total,
};
}
+46
View File
@@ -0,0 +1,46 @@
/**
* A contact sheet of the three ecosystems' cards at thumbnail size.
*
* Not part of the build: run it by hand (`node scripts/og/thumbs.mjs`) to check the
* one thing a full-size render cannot show you — whether a reader scrolling a timeline
* can tell a Cashu card from a Fedimint one from an LNURL one before any text is
* legible. Writes og-fixtures/_thumbs.png.
*/
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Resvg } from '@resvg/resvg-js';
const here = path.dirname(fileURLToPath(import.meta.url));
const dir = path.resolve(here, '../../og-fixtures');
/** 300px wide: roughly how a link preview renders in a feed. */
const W = 300;
const H = Math.round((630 / 1200) * W);
const GAP = 16;
const LABEL = 22;
const cards = [
['Cashu', 'fixture-no-icon.png'],
['Fedimint', 'fixture-fed-announced.png'],
['LNURL', 'fixture-lnurl-online.png'],
];
const parts = await Promise.all(
cards.map(async ([label, file], i) => {
const data = await readFile(path.join(dir, file));
const x = i * (W + GAP);
return (
`<image x="${x}" y="${LABEL}" width="${W}" height="${H}" ` +
`href="data:image/png;base64,${data.toString('base64')}"/>` +
`<text x="${x}" y="15" font-family="monospace" font-size="13" fill="#EDE8DC">${label}</text>`
);
}),
);
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${cards.length * (W + GAP) - GAP}" ` +
`height="${H + LABEL}"><rect width="100%" height="100%" fill="#0E0D0B"/>${parts.join('')}</svg>`;
await writeFile(path.join(dir, '_thumbs.png'), new Resvg(svg).render().asPng());
console.log(`og: og-fixtures/_thumbs.png (${cards.length} cards at ${W}px)`);