Files
CashuMints.space/web/src/lib/lnurl-ui.ts
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

189 lines
7.5 KiB
TypeScript

/**
* The LNURL mint page's Features panel and withdraw-limits cell, as HTML strings.
*
* The Cashu counterparts are `nutRowsHtml` and `limitsHtml` in mint-state.ts, and the
* Fedimint one is `moduleRowsHtml` in fedimint-ui.ts. This is deliberately their twin:
* same three-column row, same check, same grey "not published", same classes, so a
* reader who has learned to scan one panel can scan the others without relearning them
* and there is no second stylesheet.
*
* Built as strings rather than as markup in the page for the same reason those files
* give: two things render this panel — the page at build time from the last probe, and
* the live island from a fresh read of the API — and two implementations would drift.
*
* Where it differs from both, and why:
*
* - **Three states, not two.** The Fedimint panel has no disabled state because a
* `modules` tag says nothing about whether a module is switched on. Here there
* genuinely is a third state: a mint can publish `mint` and `melt`, and still be
* unable to do either right now because the Lightning node behind it is unreachable.
* That is checkable over HTTP, so the row says "unavailable" rather than showing a
* tick beside something that would fail if tried.
* - **`features` is never rewritten.** The announcement is the operator's claim about
* what they built; the probe can only mark a claimed capability unavailable, never
* add or remove one. That asymmetry is normative in `docs/KIND-LNURL-MINT.md`, and
* `featureStates` in shared/ is where it is enforced.
*/
import {
FEATURE_NAMES_EN, HIGHLIGHT_FEATURES, featureStates, msatToSat,
type FeatureState, type HighlightFeature,
} from '@cashumints/shared';
import type { Formatters } from '../i18n/format';
const CHECK_ICON =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="2.5" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>';
const PAUSE_ICON =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="2.5" aria-hidden="true"><path d="M10 5v14M16 5v14"/></svg>';
function escape(value: string): string {
return value
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* A feature's plain-language name in this locale.
*
* Same rule as `localNutName` and `localModuleName`: the catalog first, `shared`'s
* English table second, and the published token last. A mint advertising something this
* build has never heard of renders as `quantum-notes` rather than as a guess, which is
* still a true label.
*/
export function localFeatureName(feature: string, f: Formatters): string {
const key = `lnurl.feature.${feature}`;
if (f.t.has(key)) return f.t(key);
return FEATURE_NAMES_EN[feature] ?? feature;
}
/**
* The short mono label in the first column.
*
* The LUD numbers are spec names and are the same in every language, so they are shown
* literally. The note operations have no such name, and the column shows the vocabulary
* token itself — which is exactly what a publisher would put in a `features` tag, and so
* is the string worth being able to recognise.
*/
const ROW_TOKENS: Record<HighlightFeature, string> = {
mint: 'mint',
melt: 'melt',
notes: 'notes',
lud16: 'LUD-16',
lud21: 'LUD-21',
'signed-notes': 'signed',
onion: 'onion',
};
/**
* The seven named rows.
*
* `fundingAvailable` is null when nothing has probed the mint yet, which reads as "no
* reason to doubt the announcement" rather than as a failure — a mint discovered
* minutes ago should not have its capabilities greyed out for not having been checked.
*/
export function featureRowsHtml(
features: readonly string[],
fundingAvailable: boolean | null,
f: Formatters,
): string {
const states = featureStates(features, fundingAvailable);
return HIGHLIGHT_FEATURES.map((key: HighlightFeature) => {
const state: FeatureState = states[key];
const badge =
state === 'ok'
? `<span class="nut-state ok">${CHECK_ICON}</span>`
: state === 'unavailable'
? `<span class="nut-state disabled">${PAUSE_ICON}${escape(f.t('lnurl.features.unavailable'))}</span>`
: `<span class="nut-state none">${escape(f.t('lnurl.features.notListed'))}</span>`;
const classes =
state === 'ok' ? 'nut-row' : state === 'unavailable' ? 'nut-row disabled' : 'nut-row off';
return (
`<div class="${classes}" data-feature-row="${escape(key)}">` +
`<span class="nut-num">${escape(ROW_TOKENS[key])}</span>` +
`<span class="nut-name">${escape(localFeatureName(key, f))}</span>` +
`${badge}</div>`
);
}).join('');
}
/**
* The inside of the verdict strip's withdraw-limits cell.
*
* The Cashu cell shows NUT-04's mint limits and splits in two when a direction is
* switched off. This one shows the withdraw bounds, which are the numbers that actually
* answer a reader's question here: how much can one of this mint's notes be worth?
*
* A ceiling of zero is not rendered as "0 sat". It is the disabling value — no note this
* mint issues can be redeemed for anything — and it gets the struck-through treatment
* the Cashu cell gives a disabled direction, with the banner above doing the explaining.
*/
export function withdrawLimitsHtml(
minMsat: number | null | undefined,
maxMsat: number | null | undefined,
f: Formatters,
): string {
const min = msatToSat(minMsat ?? null);
const max = msatToSat(maxMsat ?? null);
if (min === null && max === null) {
return `<div class="v-mono"><span>${escape(f.t('lnurl.limits.notPublished'))}</span></div>`;
}
if (max === 0) {
return (
'<div class="v-mono lim-split">' +
'<div class="lim-row off"><span class="lim-k">' +
`${escape(f.t('lnurl.limits.withdrawRow'))}</span>` +
'<span class="lim-body">' +
`<b class="lim-v">${escape(f.t('lnurl.limits.sats', { n: 0 }))}</b>` +
`<span class="lim-off">${escape(f.t('lnurl.limits.disabled'))}</span>` +
'</span></div></div>'
);
}
const lines = [
min !== null
? `<span>${escape(f.t('mint.limits.min'))}</span> <b>${escape(f.t('lnurl.limits.sats', { n: min }))}</b>`
: '',
max !== null
? `<span>${escape(f.t('mint.limits.max'))}</span> <b>${escape(f.t('lnurl.limits.sats', { n: max }))}</b>`
: '',
].filter(Boolean);
return `<div class="v-mono">${lines.join('<br />')}</div>`;
}
/**
* The mint fee, as one line, or null when there is nothing to say.
*
* A mint that publishes no `Mint fees:` entry is fee-free by spec, and a row reading
* "Fee: none" on every one of them would be furniture. Both parts are shown when both
* are set, because a flat fee and a percentage answer different questions.
*/
export function feeText(
baseMsat: number | null | undefined,
ppm: number | null | undefined,
f: Formatters,
): string | null {
const base = baseMsat ?? 0;
const percentPpm = ppm ?? 0;
if (base === 0 && percentPpm === 0) return null;
const parts: string[] = [];
if (base > 0) parts.push(f.t('lnurl.fee.base', { n: msatToSat(base) ?? 0 }));
if (percentPpm > 0) {
// ppm to percent, at most three decimals: 100 ppm is 0.01%, and a mint charging
// 25_000 ppm is charging 2.5%. Trailing zeros are trimmed so the common cases read
// as "0.01%" rather than "0.010%".
const percent = (percentPpm / 10_000).toFixed(3).replace(/\.?0+$/, '');
parts.push(f.t('lnurl.fee.percent', { percent }));
}
return parts.join(' + ');
}