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>
This commit is contained in:
michilis
2026-08-25 16:27:33 +02:00
co-authored by Claude Opus 5
parent 060c7f1a59
commit 14548179a0
11 changed files with 670 additions and 60 deletions
+74 -8
View File
@@ -100,7 +100,18 @@ const signedBody = t('home.why.signed.body', {
});
---
<Base title={t('home.title')} description={description} current="mints">
{/*
`clientNamespaces` inlines the `home.` catalog on this page and no other. The three
grids below refresh from the API after paint and rewrite their own "All 56 mints →"
links, so those strings have to reach the browser; 1,300 mint pages have no use for
them. See `clientCatalog` in src/i18n/index.ts.
*/}
<Base
title={t('home.title')}
description={description}
current="mints"
clientNamespaces={['home']}
>
{/*
The hero sits on an animated colour field: ColorBends from React Bits, ported to
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
@@ -180,9 +191,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
<span class="sec-sub">{t('home.top.sub')}</span>
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a>
<a class="sec-link" href={localePath('/mints', locale)} data-all-link="cashu">
{t('home.top.all', { n: mints.length })}
</a>
</div>
<div class="mint-grid">
<div class="mint-grid" data-home-grid="cashu">
{top.map((mint, i) => (
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
))}
@@ -200,11 +213,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
<a class="sec-link" href={localePath('/fedimints', locale)}>
<a class="sec-link" href={localePath('/fedimints', locale)} data-all-link="fedimint">
{t('home.topFedimints.all', { n: federations.length })}
</a>
</div>
<div class="mint-grid">
<div class="mint-grid" data-home-grid="fedimint">
{topFederations.map((federation, i) => (
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
))}
@@ -225,11 +238,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
<a class="sec-link" href={localePath('/lnurl-mints', locale)}>
<a class="sec-link" href={localePath('/lnurl-mints', locale)} data-all-link="lnurl">
{t('home.topLnurl.all', { n: stats.lnurl_total })}
</a>
</div>
<div class="mint-grid">
<div class="mint-grid" data-home-grid="lnurl">
{topLnurl.map((mint, i) => (
<MintCard
mint={mint}
@@ -615,14 +628,67 @@ const signedBody = t('home.why.signed.body', {
</style>
<script>
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { onLeave, onReady, prefersReducedMotion, scrollBehavior, swapText } from '../../scripts/reveal';
/** One card every six seconds, until the reader touches the track. */
const AUTO_MS = 6000;
/** Matches the track's CSS gap. */
const GAP = 16;
/**
* The three top-six strips, and the "All N" link over each of them.
*
* The home page prerenders six cards per ecosystem and a count beside them, from the
* same API the index pages read. Between builds the strips went stale in two ways at
* once: a new mint could not appear in the top six however good it was, and the count
* beside the link said how many mints existed at 03:30. Both are one fetch away.
*
* The key is the catalog string for that link, which is why this page inlines the
* `home.` namespace (see `clientNamespaces` on Base above). `t()` formats the number
* for the locale, so "All 1,247 mints" and "All 1.247 mints" both come out right.
*
* Sentiment bars are the one thing hydration cannot improve here: the prerendered
* cards get real rating distributions from a per-mint detail fetch at build time, and
* the list payload has no distribution in it. A refreshed card falls back to the
* rating proxy the index pages have always used — a 4.6 average reads as 92% positive
* — which is a slightly coarser bar on a card whose numbers are otherwise newer.
*/
const STRIPS = [
{ type: 'cashu', key: 'home.top.all' },
{ type: 'fedimint', key: 'home.topFedimints.all' },
{ type: 'lnurl', key: 'home.topLnurl.all' },
] as const;
const hydrateStrips = (): void => {
const t = useI18n();
for (const strip of STRIPS) {
const grid = document.querySelector<HTMLElement>(`[data-home-grid="${strip.type}"]`);
// A section with nothing in it is not rendered at all, and a strip that was empty
// at build time stays empty until the next one: there is no heading to hang cards
// under. Rare, and not worth building a section in JavaScript for.
if (!grid) continue;
void hydrateMintGrid({
type: strip.type,
grid,
limit: 6,
// The reveal delay the build gives these six, so a refreshed strip arrives the
// same way the prerendered one did.
revealDelayStep: 50,
onReplaced: (_cards, all) => {
const link = document.querySelector<HTMLElement>(`[data-all-link="${strip.type}"]`);
if (link) swapText(link, t(strip.key, { n: all.length }));
},
});
}
};
const setup = (): void => {
requestAnimationFrame(() => hydrateStrips());
// No track at all means the relays gave the build nothing, and the section is
// showing its empty state instead.
const found = {