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>
189 lines
7.5 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* 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(' + ');
|
|
}
|