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:
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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(' + ');
|
||||
}
|
||||
Reference in New Issue
Block a user