Files
CashuMints.space/web/scripts/og/model.mjs
T
michilisandCursor c74c7fc187 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>
2026-08-22 03:44:43 +02:00

314 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};
}