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>
314 lines
11 KiB
JavaScript
314 lines
11 KiB
JavaScript
/**
|
||
* The card model: everything one social image renders, and nothing else.
|
||
*
|
||
* This object is the hash input for the manifest, so it must be derived purely from
|
||
* API data plus the day the build runs on. Two rules follow from that:
|
||
*
|
||
* - No relative times finer than a day. "3 minutes ago" in a static PNG is a lie by
|
||
* the time anyone sees it; day counts and month/year stamps go stale slowly enough
|
||
* to survive until the next scheduled rebuild regenerates them.
|
||
* - Everything the template touches is in the model, and the template touches
|
||
* nothing but the model. A design change is a TEMPLATE_VERSION bump, which busts
|
||
* every hash at once; a data change busts exactly the mints it happened to.
|
||
*
|
||
* 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, msatToSat, parseLimits, readCapabilities } from '@cashumints/shared';
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/** English month/year ("Mar 2026") — the images are one English render for all locales. */
|
||
export function monthYear(unix) {
|
||
return new Date(unix * 1000).toLocaleDateString('en-GB', {
|
||
month: 'short',
|
||
year: 'numeric',
|
||
timeZone: 'UTC',
|
||
});
|
||
}
|
||
|
||
/** "3 Aug 2026", matching f.date() on the pages. */
|
||
export function shortDate(unix) {
|
||
return new Date(unix * 1000).toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
timeZone: 'UTC',
|
||
});
|
||
}
|
||
|
||
/* ---- mirrors of web/src/lib/format.ts, which is TypeScript this script cannot load.
|
||
Same algorithms, asserted by eyeball against the site's own fallback tiles. ---- */
|
||
|
||
export function displayDomain(url) {
|
||
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||
}
|
||
|
||
/** The site's deterministic gradient, resolved to hex pairs satori understands. */
|
||
export function iconGradient(seed) {
|
||
let hash = 0;
|
||
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||
const hue = hash % 360;
|
||
const hue2 = (hue + 40) % 360;
|
||
return [hsl(hue, 45, 55), hsl(hue2, 45, 45)];
|
||
}
|
||
|
||
function hsl(h, s, l) {
|
||
s /= 100;
|
||
l /= 100;
|
||
const k = (n) => (n + h / 30) % 12;
|
||
const a = s * Math.min(l, 1 - l);
|
||
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
||
const hex = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
|
||
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
|
||
}
|
||
|
||
export function initials(name) {
|
||
const cleaned = name.replace(/^(https?:\/\/)?(mint\.|www\.)?/, '');
|
||
const first = cleaned.trim()[0] ?? '?';
|
||
if (/\d/.test(first)) return cleaned.slice(0, 2);
|
||
return first.toUpperCase();
|
||
}
|
||
|
||
export function truncateMiddle(value, head = 10, tail = 8) {
|
||
if (value.length <= head + tail + 1) return value;
|
||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||
}
|
||
|
||
/** 4–5 star share vs 1–2 star share, as the site's sentiment bar splits them. */
|
||
export function sentiment(dist) {
|
||
const positive = (dist['5'] ?? 0) + (dist['4'] ?? 0);
|
||
const negative = (dist['2'] ?? 0) + (dist['1'] ?? 0);
|
||
const total = positive + negative + (dist['3'] ?? 0);
|
||
if (total === 0) return null;
|
||
return {
|
||
pos: Math.round((positive / total) * 100),
|
||
neg: Math.round((negative / total) * 100),
|
||
};
|
||
}
|
||
|
||
/** "500k", "1M", "100" — sat amounts at stats-bar size. */
|
||
export function compactSats(n) {
|
||
if (n >= 1_000_000) return trim(n / 1_000_000) + 'M';
|
||
if (n >= 1_000) return trim(n / 1_000) + 'k';
|
||
return String(n);
|
||
}
|
||
|
||
function trim(n) {
|
||
const s = n.toFixed(1);
|
||
return s.endsWith('.0') ? s.slice(0, -2) : s;
|
||
}
|
||
|
||
/** The warning cell's two lines, from the same warning object the page banner uses. */
|
||
function warningCell(warning, mint, now) {
|
||
if (!warning) return null;
|
||
|
||
const since = mint.last_online;
|
||
const label = {
|
||
'gone': 'Likely gone',
|
||
'melt-disabled': 'No withdrawals',
|
||
'frozen': 'Frozen',
|
||
'melt-only': 'Melt only',
|
||
'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;
|
||
|
||
let sub;
|
||
switch (warning.kind) {
|
||
case 'gone':
|
||
case 'offline-long':
|
||
case 'offline':
|
||
case 'fedimint-offline':
|
||
sub = since !== null ? `Since ${shortDate(since)}` : 'Never reached';
|
||
break;
|
||
case 'melt-only':
|
||
sub = 'Deposits disabled';
|
||
break;
|
||
case 'melt-disabled':
|
||
sub = 'Deposits still open';
|
||
break;
|
||
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', 'lnurl-withdrawals-disabled'].includes(warning.kind) &&
|
||
mint.status === 'offline' &&
|
||
since !== null
|
||
) {
|
||
sub = `Offline since ${shortDate(since)}`;
|
||
}
|
||
|
||
return { kind: warning.kind, severity: warning.severity, label, sub };
|
||
}
|
||
|
||
/**
|
||
* One Cashu mint's card model, from its detail payload.
|
||
*
|
||
* `iconHash` arrives from the caller (a digest of the cached icon bytes), so a mint
|
||
* that changes its logo behind the same URL regenerates. `now` is passed in rather
|
||
* than read here so fixtures render the same PNG forever.
|
||
*/
|
||
export function cashuModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||
const domain = displayDomain(mint.url);
|
||
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 limits = parseLimits(mint.info?.nuts, 4);
|
||
const caps = readCapabilities(mint.info?.nuts);
|
||
|
||
return {
|
||
v: TEMPLATE_VERSION,
|
||
type: 'cashu',
|
||
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,
|
||
limits:
|
||
limits && (limits.min !== null || limits.max !== null) && !caps.mintDisabled
|
||
? { min: limits.min, max: limits.max, unit: limits.unit }
|
||
: null,
|
||
warning: warningCell(warning, mint, now),
|
||
iconHash,
|
||
gradient: iconHash ? null : iconGradient(domain),
|
||
initials: iconHash ? null : initials(name),
|
||
};
|
||
}
|
||
|
||
/** One federation's card model. Same skeleton, only honest fields. */
|
||
export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||
const federationId = fed.federation_id ?? '';
|
||
const name = (fed.name ?? '').trim() || 'Unnamed federation';
|
||
const warning = getMintWarnings(fed, { now })[0] ?? null;
|
||
const announced = fed.status === 'announced';
|
||
const offlineDays =
|
||
fed.status === 'offline' && fed.last_online !== null
|
||
? Math.max(0, Math.floor((now - fed.last_online) / DAY))
|
||
: null;
|
||
|
||
return {
|
||
v: TEMPLATE_VERSION,
|
||
type: 'fedimint',
|
||
slug: fed.host,
|
||
name,
|
||
/** `fed11…` prefix plus the id tail: what the identity row shows where a domain sits. */
|
||
shortId: federationId ? `fed11…${federationId.slice(-6)}` : fed.host,
|
||
status: fed.status,
|
||
offlineDays,
|
||
rating: fed.rating_avg,
|
||
reviewCount: fed.review_count,
|
||
sentiment: sentiment(fed.rating_distribution ?? {}),
|
||
lastReviewMonth: fed.last_review_at ? monthYear(fed.last_review_at) : null,
|
||
modules: fed.modules ?? [],
|
||
network: fed.network,
|
||
/** The soft warning: never a red cell, just this muted line under the identity row. */
|
||
announcedNote:
|
||
announced && warning?.kind === 'never-confirmed' && fed.announced_at
|
||
? `Announced ${shortDate(fed.announced_at)}, never confirmed`
|
||
: null,
|
||
warning: fed.status === 'offline' ? warningCell(warning, fed, now) : null,
|
||
iconHash,
|
||
gradient: iconHash ? null : iconGradient(federationId || fed.host),
|
||
initials: iconHash ? null : initials(name),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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 {
|
||
v: TEMPLATE_VERSION,
|
||
type: 'default',
|
||
slug: 'default',
|
||
mints: stats.cashu_total,
|
||
federations: stats.fedimint_total,
|
||
lnurl: stats.lnurl_total,
|
||
reviews: stats.reviews_total,
|
||
};
|
||
}
|