Files
CashuMints.space/web/src/pages/[...locale]/lnurl-mints.astro
T
michilisandClaude Opus 5 14548179a0 Hydrate the mint lists from the live API after paint.
/mints was a snapshot of whatever the API held when `astro build` ran, and stayed
that until the next build: a mint indexed at noon was reviewable at once — the 404
resolver saw to that — and simply had no card until 03:30. Every card's rating,
review count and status were as stale as the page.

The three index pages and the home page's three top-six strips now refetch
`GET /api/mints?type=…` once, after paint, and rebuild their grids. The
prerendered cards stay: they are the first paint, what a crawler indexes, and the
whole page without JavaScript. Hydration only ever replaces them with something
newer, and never with nothing — neither a failed fetch nor a well-formed empty
array touches a grid that has cards in it.

To make that affordable, the list payload grew the facts a chip is drawn from:
`nuts`, `capabilities`, and the two probed LNURL fields. /mints and /lnurl-mints
were fetching `GET /api/mints/:host` once per mint at build time to read two
booleans off each; that N+1 is gone from both, which takes the build from
fifty-six requests to one and is what makes the same read possible in a browser.
Additive: `MintDetail` already had all four.

web/src/lib/mint-cards.ts is MintCard.astro's parallel renderer, the same
relationship review-cards.ts has with the reviews panel. Same classes, same
data-* attributes — the sort, the search, the rank chips and the shared-element
view transitions all read the DOM — and the same i18n, through the page's own
inlined catalog rather than a build-time one.

Base.astro gained `clientNamespaces`, so the home page can inline the `home.`
catalog its strips need to rewrite "All 60 mints →" without putting 2KB of
marketing copy on 1,300 mint pages. check-i18n reads the prop off the page, so
the two cannot disagree.

Verified in Chromium against the built site: 60 prerendered cards become 61
including a mint inserted after the build; sort, search and hide-offline operate
on the new cards; /es/mints renders "En línea", "54 reseñas", "4,9" and "Solo
fundir"; JavaScript disabled still shows all 60; an aborted or empty API leaves
the grid alone; and a navigation away and back re-hydrates. 2016 pages build,
link and hreflang checks pass, 30 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:27:33 +02:00

483 lines
19 KiB
Plaintext

---
import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro';
import ReviewByUrl from '../../components/ReviewByUrl.astro';
import { fetchLnurlMints } from '../../lib/api';
import { localePath, useI18n, type Locale } from '../../i18n';
import { itemListNode } from '../../lib/schema';
import { isIndexableMint } from '../../lib/seo';
import { localePaths } from '../../i18n/paths';
/**
* The LNURL mint index. /mints' twin, and built from the same parts for the same reason
* /fedimints is: same container, same card, same search, same result line, same
* leave-and-return animation.
*
* Unlike /fedimints it keeps the full sort set including "recently seen online". That
* page drops it because a third of its rows have no `last_online` at all — nothing
* checks those federations, so ordering on the column is a shuffle rather than a sort.
* An LNURL mint is genuinely probed over HTTPS, exactly as a Cashu mint is, so every row
* here has a real answer and the sort means what it says.
*/
/** One page per locale: `/lnurl-mints`, `/es/lnurl-mints`, `/nl/lnurl-mints`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
const t = useI18n(locale);
/*
One request, no N+1.
This used to be `fetchLnurlMints()` followed by one `fetchLnurlMint(host)` per mint,
purely to read the two probed facts behind the "no withdrawals" and "no mint / melt"
chips: an advertised withdraw ceiling of zero, and an unreachable Lightning node.
`MintListItem` carries both now, exactly as it carries the Cashu NUT switches, so the
chips come off the same payload as everything else — which is what makes the hydration
below possible without an N+1 in every visitor's browser.
*/
const mints = await fetchLnurlMints();
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
const description = t('lnurlMints.description', { total: mints.length, online, offline });
/*
* The index, as a list a crawler can read in order.
*
* Only the mints whose own pages are indexable: listing a page here while telling a
* crawler not to index it is a contradiction. The order is the server-rendered one,
* which is the score order; the sort control rewrites the DOM but not this markup,
* so `ItemListOrderDescending` describes what shipped.
*/
const canonical = new URL(localePath('/lnurl-mints', locale), Astro.site).href;
const schema = [
itemListNode({
canonical,
fragment: 'lnurl-mints',
name: t('lnurlMints.heading'),
items: mints.filter(isIndexableMint).map((mint) => ({
name: mint.name ?? mint.host,
url: new URL(localePath(`/lnurl-mint/${mint.host}`, locale), Astro.site).href,
})),
}),
];
---
<Base
title={t('lnurlMints.title')}
description={description}
current="lnurl-mints"
schema={schema}
>
{/*
The same .page container the home page and the mint pages use, so the heading,
the controls, the count line and the grid all share one left edge and one width.
*/}
<div class="page">
<div class="index-head">
<div class="index-head-text">
<h1>{t('lnurlMints.heading')}</h1>
<p class="lede">{t('lnurlMints.lede')}</p>
</div>
{/*
The way in for something this site has never seen. Every other route to writing
a review starts from a page that already exists, which is exactly the case a
reader with a brand new mint does not have.
*/}
<button class="btn primary index-head-action" type="button" data-review-url-open="lnurl">
{t('reviews.byUrl.open')}
</button>
</div>
<div class="controls" data-mint-controls>
<form class="search" role="search" onsubmit="return false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
</svg>
<label class="sr-only" for="mint-search">{t('lnurlMints.search.label')}</label>
<input
id="mint-search"
type="text"
autocomplete="off"
placeholder={t('lnurlMints.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="control-group">
<label class="sr-only" for="mint-sort">{t('lnurlMints.sort.label')}</label>
<div class="select-wrap">
<select id="mint-sort" data-sort>
<option value="score">{t('mints.sort.score')}</option>
<option value="rating">{t('mints.sort.rating')}</option>
<option value="reviews">{t('mints.sort.reviews')}</option>
<option value="recent">{t('mints.sort.recent')}</option>
<option value="seen">{t('mints.sort.seen')}</option>
</select>
<svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</div>
{/*
A pressed pill rather than a native checkbox: it is the same control the
review filters use, and it is a real button, so it keeps the focus ring.
*/}
<button class="toggle-chip" type="button" data-hide-offline aria-pressed="false">
<span class="toggle-dot" aria-hidden="true"></span>
{t('lnurlMints.hideOffline')}
</button>
</div>
</div>
<p class="result-count" data-result-count aria-live="polite">
{t('lnurlMints.showing', { total: mints.length, online, offline })}
</p>
<div class="mint-grid" data-mint-grid>
{mints.map((mint, i) => (
<MintCard
mint={mint}
rank={i + 1}
lnurl={{
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
funding_available: mint.funding_available ?? null,
}}
/>
))}
</div>
<p class="no-results" data-no-results hidden>
{t('lnurlMints.noResults')}
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
</p>
{mints.length === 0 && <p class="no-results">{t('lnurlMints.emptyIndex')}</p>}
</div>
{/* Two steps in one dialog: identify the mint, then the ordinary review form. */}
<ReviewByUrl type="lnurl" />
</Base>
<style>
/* Heading and lede on the left, the one primary action on the right, wrapping to
its own line before the lede has to give up any width. */
.index-head { margin-bottom: 24px; display: flex; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
.index-head-text { flex: 1 1 320px; }
.index-head-action { flex-shrink: 0; }
.index-head h1 { font-family: var(--display); font-size: 32px; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
.index-head .lede { max-width: 620px; font-size: 15.5px; margin-top: 10px; }
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.search {
display: flex; align-items: center; gap: 12px; flex: 1 1 320px; min-width: 240px;
background: var(--card); border: 1px solid var(--line);
border-radius: 12px; padding: 10px 16px;
transition: border-color .15s, box-shadow .15s;
}
.search:focus-within { border-color: var(--amber-dim); box-shadow: 0 0 0 3px rgba(240, 169, 59, .08); }
.search svg { color: var(--faint); flex-shrink: 0; }
.search input {
flex: 1; background: none; border: none; outline: none; min-width: 0;
color: var(--text); font-family: var(--body); font-size: 15px;
}
.search input::placeholder { color: var(--faint); }
.search kbd {
font-family: var(--mono); font-size: 11.5px; color: var(--faint);
border: 1px solid var(--line); border-radius: 6px; padding: 2px 7px; flex-shrink: 0;
}
/* Search grows; sort and toggle sit together at the right and wrap as a pair. */
.control-group { display: flex; gap: 10px; align-items: center; margin-left: auto; flex-wrap: wrap; }
.select-wrap { position: relative; display: flex; align-items: center; }
.select-wrap select {
appearance: none; -webkit-appearance: none;
background: var(--card); border: 1px solid var(--line); border-radius: 9px;
color: var(--text); font-family: var(--body); font-size: 14px;
padding: 10px 34px 10px 14px; cursor: pointer;
transition: border-color .15s;
}
.select-wrap select:hover { border-color: #38342A; }
.select-wrap .chevron { position: absolute; right: 12px; color: var(--faint); pointer-events: none; }
.select-wrap option { background: var(--card); color: var(--text); }
.toggle-chip {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--body); font-size: 13.5px; font-weight: 500;
color: var(--muted); cursor: pointer;
border: 1px solid var(--line); background: none; border-radius: 999px;
padding: 9px 15px;
transition: color .15s, border-color .15s, background .15s;
}
.toggle-chip:hover { color: var(--text); border-color: #38342A; }
.toggle-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--faint); flex-shrink: 0;
transition: background .15s;
}
.toggle-chip[aria-pressed='true'] {
color: #141109; background: var(--amber); border-color: var(--amber); font-weight: 600;
}
.toggle-chip[aria-pressed='true'] .toggle-dot { background: #141109; }
.result-count { font-size: 13px; color: var(--faint); margin-bottom: 16px; font-family: var(--mono); }
/*
* A card leaving the results fades and settles back a touch, 120ms, then it is
* hidden and the grid reflows around it. Positions are not animated: the browser's
* own reflow is instant and honest, and 58 cards sliding to new places would be a
* light show rather than a search result.
*/
.mint-grid :global(.mint-card.leaving) {
transform: scale(.98);
transition: opacity 120ms var(--ease-out), transform 120ms var(--ease-out);
}
.no-results { color: var(--muted); font-size: 14.5px; padding: 34px 0; }
.link-btn {
background: none; border: none; padding: 0; cursor: pointer;
font-family: var(--body); font-size: inherit; color: var(--amber);
border-bottom: 1px solid var(--amber-dim);
}
.link-btn:hover { border-bottom-color: var(--amber); }
@media (max-width: 640px) {
/* Sort and toggle take their own row, still sharing the grid's left edge. */
.control-group { margin-left: 0; width: 100%; }
.select-wrap, .select-wrap select { flex: 1; }
}
</style>
<script>
import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
/** How long a card takes to fade out of the results before it is hidden. */
const LEAVE_MS = 120;
const setup = (): void => {
wireCopyableIds();
// Re-read on every arrival, so the count line under the filters follows the page.
// `t` formats its own numbers, so the grouping separator follows the locale too.
const t = useI18n();
const grid = document.querySelector<HTMLElement>('[data-mint-grid]');
const searchInput = document.querySelector<HTMLInputElement>('[data-search-input]');
const sortSelect = document.querySelector<HTMLSelectElement>('[data-sort]');
const hideOffline = document.querySelector<HTMLButtonElement>('[data-hide-offline]');
const countEl = document.querySelector<HTMLElement>('[data-result-count]');
const noResults = document.querySelector<HTMLElement>('[data-no-results]');
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) {
/*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
const SORTS: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
// Default keeps the API's rule: online first, then score. Every other sort
// ranks offline mints on their merits, so a dead mint with 40 good reviews
// still shows up under "Most reviewed".
score: (a, b) => offlineRank(a) - offlineRank(b) || num(b, 'score') - num(a, 'score'),
rating: (a, b) => num(b, 'rating') - num(a, 'rating') || num(b, 'reviews') - num(a, 'reviews'),
reviews: (a, b) => num(b, 'reviews') - num(a, 'reviews'),
recent: (a, b) => num(b, 'lastReview') - num(a, 'lastReview'),
seen: (a, b) => num(b, 'lastOnline') - num(a, 'lastOnline'),
};
/**
* One pending fade per card, at most.
*
* Typing into the search box calls `apply` on every keystroke, so a card can be
* told to leave and then to come back before its fade has finished. Cancelling
* the timer is what keeps those from stacking: the card either goes out once, or
* never starts.
*/
const leaving = new Map<HTMLElement, number>();
function show(card: HTMLElement, visible: boolean): void {
const pending = leaving.get(card);
if (pending !== undefined) {
window.clearTimeout(pending);
leaving.delete(card);
}
if (visible) {
const wasHidden = card.hidden;
card.classList.remove('leaving');
card.hidden = false;
// Back in the results after being filtered out: a plain fade, no rise. It
// was here before, it is not new.
if (wasHidden && !prefersReducedMotion()) {
card.classList.add('appearing');
card.addEventListener('animationend', () => card.classList.remove('appearing'), { once: true });
}
return;
}
if (card.hidden) return;
if (prefersReducedMotion()) {
card.hidden = true;
return;
}
card.classList.add('leaving');
leaving.set(
card,
window.setTimeout(() => {
leaving.delete(card);
card.hidden = true;
card.classList.remove('leaving');
}, LEAVE_MS),
);
}
function apply(): void {
const query = searchInput!.value.trim().toLowerCase();
// A pasted mint URL should find the mint, so match on the bare domain too.
const needle = query.replace(/^https?:\/\//, '').replace(/\/+$/, '');
const hide = hideOffline!.getAttribute('aria-pressed') === 'true';
let visible = 0;
let onlineCount = 0;
let offlineCount = 0;
for (const card of cards) {
const matches =
needle === '' ||
(card.dataset['name'] ?? '').includes(needle) ||
(card.dataset['domain'] ?? '').includes(needle);
const isOffline = card.dataset['status'] === 'offline';
const keep = matches && !(hide && isOffline);
show(card, keep);
if (keep) {
visible++;
if (isOffline) offlineCount++;
else if (card.dataset['status'] === 'online') onlineCount++;
}
}
const sorted = [...cards].sort(SORTS[sortSelect!.value] ?? SORTS['score']!);
for (const card of sorted) grid!.append(card);
// A rank chip is a claim about position in the default order. It means nothing
// in a rating sort and nothing in a filtered list, so it is hidden in both.
const ranked = sortSelect!.value === 'score' && needle === '' && !hide;
let rank = 0;
for (const card of sorted) {
const chip = card.querySelector<HTMLElement>('.mc-rank');
if (!chip) continue;
if (!ranked) {
chip.hidden = true;
continue;
}
rank++;
chip.hidden = false;
chip.textContent = `#${rank}`;
chip.classList.toggle('gold', rank === 1);
}
if (countEl) {
// The count is the answer to what the reader just typed, so it cross-fades
// rather than flicking from one number to another.
swapText(
countEl,
visible === cards.length
? t('lnurlMints.showing', { total: visible, online: onlineCount, offline: offlineCount })
: t('lnurlMints.showingFiltered', {
shown: visible,
total: cards.length,
online: onlineCount,
offline: offlineCount,
}),
);
}
if (noResults) noResults.hidden = visible > 0;
}
searchInput.addEventListener('input', apply);
sortSelect.addEventListener('change', () => {
apply();
// A new order is a new list: it arrives the way the grid arrived, once, and
// the stagger is capped so the last card is never more than 400ms behind.
enterStagger(cards.filter((card) => !card.hidden));
});
hideOffline.addEventListener('click', () => {
const on = hideOffline.getAttribute('aria-pressed') === 'true';
hideOffline.setAttribute('aria-pressed', String(!on));
apply();
});
clearSearch?.addEventListener('click', () => {
searchInput.value = '';
searchInput.focus();
apply();
});
// Deep links from the home page quick filters: /mints?q=…&sort=…
const params = new URLSearchParams(window.location.search);
const q = params.get('q');
const sort = params.get('sort');
if (q) searchInput.value = q;
if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'lnurl',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();
});
}
};
onReady(setup);
</script>