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:
co-authored by
Claude Opus 5
parent
060c7f1a59
commit
14548179a0
@@ -2,8 +2,7 @@
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import MintCard from '../../components/MintCard.astro';
|
||||
import ReviewByUrl from '../../components/ReviewByUrl.astro';
|
||||
import type { LnurlDetail } from '@cashumints/shared';
|
||||
import { fetchLnurlMint, fetchLnurlMints } from '../../lib/api';
|
||||
import { fetchLnurlMints } from '../../lib/api';
|
||||
import { localePath, useI18n, type Locale } from '../../i18n';
|
||||
import { itemListNode } from '../../lib/schema';
|
||||
import { isIndexableMint } from '../../lib/seo';
|
||||
@@ -32,25 +31,17 @@ const { locale } = Astro.props;
|
||||
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
|
||||
const t = useI18n(locale);
|
||||
|
||||
const mints = await fetchLnurlMints();
|
||||
|
||||
/*
|
||||
The list payload carries no LNURL fields, so the "no withdrawals" and "no mint / melt"
|
||||
chips need each mint's detail. One fetch per mint, at build time, against the API on
|
||||
the same machine — exactly what /mints does for its NUT switches. A mint whose detail
|
||||
cannot be read simply gets no chip.
|
||||
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 details = await Promise.all(
|
||||
mints.map((mint) => fetchLnurlMint(mint.host).catch(() => null)),
|
||||
);
|
||||
const chipSources = details.map((detail: LnurlDetail | null) =>
|
||||
detail
|
||||
? {
|
||||
max_withdrawable_msat: detail.max_withdrawable_msat ?? null,
|
||||
funding_available: detail.funding_available ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const mints = await fetchLnurlMints();
|
||||
|
||||
const online = mints.filter((m) => m.status === 'online').length;
|
||||
const offline = mints.filter((m) => m.status === 'offline').length;
|
||||
@@ -152,7 +143,16 @@ const schema = [
|
||||
</p>
|
||||
|
||||
<div class="mint-grid" data-mint-grid>
|
||||
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} lnurl={chipSources[i]} />)}
|
||||
{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>
|
||||
@@ -258,6 +258,7 @@ const schema = [
|
||||
|
||||
<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';
|
||||
|
||||
@@ -280,7 +281,15 @@ const schema = [
|
||||
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
|
||||
|
||||
if (grid && searchInput && sortSelect && hideOffline) {
|
||||
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
|
||||
/*
|
||||
* 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);
|
||||
|
||||
@@ -431,6 +440,37 @@ const schema = [
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user