/** * The mint card, as HTML strings, and the hydration that puts them on a page. * * `MintCard.astro` renders the same card at build time and cannot run in the browser, so * this is its parallel renderer — the same relationship `review-cards.ts` has with the * reviews panel. The two must agree on every class name and every `data-*` attribute, * because the sort, the filter, the rank chips and the shared-element view transitions * on the three index pages all read the DOM rather than any model: * * data-mint-card what the sort collects and the transition arms * data-name / data-domain what the search box matches * data-status "hide offline", and the online/offline counts * data-score / -rating / -reviews the sort keys * data-last-review / -last-online the other two sort keys * data-vt-icon / data-vt-name shared-element names, applied on click * * Why this exists at all: /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 * immediately — the 404 resolver saw to that — and simply had no card until the nightly * rebuild. The list now refetches after paint. The prerendered cards stay exactly as they * were: they are the first paint, they are what a crawler and a reader with no JavaScript * get, and this only ever replaces them with something newer. * * Nothing here talks to a relay. Card counts come from the API's ingested aggregates, * which is what they always were; review *bodies* remain a detail-page and /reviews * concern. See docs/dynamic-mint-data.md. * * Everything interpolated goes through `escapeHtml`. A mint's name comes from its own * `/v1/info` — a string an operator controls — and this builds markup with strings. */ import { baseUrlFromKey, federationIdFromSlug, getMintWarnings, mintChip, type MintListItem, } from '@cashumints/shared'; import { apiBase, escapeHtml, iconGradient } from './client'; import { targetPath } from './feed-resolve'; import { displayDomain, initials, transitionName } from './format'; import { localePath, splitLocale } from '../i18n/routing'; import type { Locale } from '../i18n/config'; import { useI18n } from '../i18n/client'; import { formatters, starString, type Formatters } from '../i18n/format'; import { chipStrings, statusLabel, warningOptions } from '../i18n/mint'; import { initReveal } from '../scripts/reveal'; export interface CardOptions { /** 1-based position in the default order. Omitted, the card has no rank chip. */ rank?: number; /** Entrance delay in ms, for a grid of known size that arrives as one gesture. */ revealDelay?: number; /** * Render already revealed, with no entrance. * * Set for a card replacing one the reader is already looking at. `[data-reveal]` is * `opacity: 0` until `.in` lands, so without this a hydration would fade the whole * visible grid back in a second after load — an animation that says "something * changed" about forty cards where at most one did. */ revealed?: boolean; } /** * One card's markup. * * `f` carries both the translator and the locale's number and date formatting, and * `locale` is what prefixes the href. Both are passed in rather than read here, because * a grid renders dozens of these and rebuilding the formatter per card would be the * expensive part of the whole hydration. */ export function mintCardHtml( mint: MintListItem, locale: Locale, f: Formatters, options: CardOptions = {}, ): string { const t = f.t; const { rank, revealDelay, revealed } = options; /* * One card, all three ecosystems — the same reasoning as MintCard.astro. * * A federation has no URL, so its second line is a shortened federation id rather than * `fedimint:aeca6c…`, which is a database key. An LNURL mint's row key carries an * `lnurl:` scheme in front of its URL, so the domain comes out of the key. */ const fedimint = mint.type === 'fedimint'; const lnurlBase = baseUrlFromKey(mint.url); const domain = fedimint ? federationIdFromSlug(mint.host) : displayDomain(lnurlBase ?? mint.url); const name = fedimint ? mint.name ?? t('card.unnamedFederation') : mint.name ?? domain.split('/')[0] ?? domain; // Never `API_URL`: that is a build-machine address and a visitor's browser cannot // reach it. `apiBase` is PUBLIC_API_URL, empty in production, which resolves /icons // against whatever origin is serving the page. const icon = mint.icon ? `${apiBase}${mint.icon}` : null; const offline = mint.status === 'offline'; const announced = mint.status === 'announced'; const href = localePath(targetPath(mint), locale); /* * The chip, from facts the list payload now carries. * * It used to take one `GET /api/mints/:host` per mint to read these — fine for a build * machine rendering the grid once a night, ruinous as an N+1 in every visitor's * browser. `capabilities` and the two LNURL fields were added to `MintListItem` for * exactly this. A federation has no chip and never will: `mintChip` returns null for * one, because a federation publishes no capability list to draw a claim from. */ const chipSource = mint.capabilities || mint.max_withdrawable_msat !== undefined || mint.funding_available !== undefined ? { type: mint.type, status: mint.status, last_online: mint.last_online, capabilities: mint.capabilities ?? null, max_withdrawable_msat: mint.max_withdrawable_msat ?? null, funding_available: mint.funding_available ?? null, } : null; const chip = chipSource ? mintChip(getMintWarnings(chipSource, warningOptions(f)), chipStrings(t)) : null; // Falls back to the rating split when there is no distribution, exactly as the build // does for /mints: a 4.6 average is roughly 92% positive, which is what the bar says. const pos = mint.rating_avg === null ? 0 : Math.round(((mint.rating_avg - 1) / 4) * 100); const neg = mint.rating_avg === null ? 0 : 100 - pos; const vtIcon = escapeHtml(transitionName('icon', mint.host)); const vtName = escapeHtml(transitionName('name', mint.host)); const iconHtml = icon ? `` : `${escapeHtml(initials(name))}`; const statsHtml = mint.rating_avg === null ? `${escapeHtml(t('card.noRatings'))}` : `` + `${escapeHtml(f.decimal(mint.rating_avg))}` + `` + ``; /* * "Announced" has no "last seen" to print, and printing one anyway is exactly the fake * status this site refuses to show. What it has instead is the fact that nothing has * checked it, said in as many words. */ const last = announced ? t('card.notChecked') : offline ? mint.last_online ? t('card.lastSeen', { when: f.relative(mint.last_online) }) : t('card.neverSeen') : mint.last_review_at ? t('card.reviewed', { when: f.relative(mint.last_review_at) }) : t('card.noReviews'); const classes = ['mint-card']; if (offline) classes.push('is-offline'); if (revealed) classes.push('in'); return ( `` + `
` + iconHtml + `` + `${escapeHtml(name)}` + `${escapeHtml(domain)}` + `` + (rank === undefined ? '' : `#${rank}`) + `
` + `
` + statsHtml + `${escapeHtml(t('card.reviews', { n: mint.review_count }))}` + `
` + `` + `
` + `` + `${escapeHtml(statusLabel(mint.status, t))}` + (chip ? `${escapeHtml(chip.label)}` : '') + `${escapeHtml(last)}` + `
` + `
` ); } /** * One ecosystem's full listing, or null. * * Null on anything at all going wrong, and the caller's job is then to do nothing: the * prerendered grid is already on screen and correct as of the last build, so a failed * refresh should be invisible rather than an error message about a list the reader can * see. This is the same rule the reviews panel and the pulse ticker follow. */ export async function fetchListing(type: string): Promise { try { const res = await fetch(`${apiBase}/api/mints?type=${encodeURIComponent(type)}`, { headers: { Accept: 'application/json' }, }); if (!res.ok) return null; const items = (await res.json()) as MintListItem[]; // A well-formed empty answer is still not a reason to empty a grid that has cards in // it. An API serving nothing is the failure the build gate exists to catch, and a // page that renders it as "no mints" would be this bug wearing a different hat. return Array.isArray(items) && items.length > 0 ? items : null; } catch { return null; } } /** * Replace a grid's cards with freshly rendered ones. * * Cards whose host was already on screen and revealed are rendered revealed, so the * common case — the list is the same list, with newer numbers — is a silent swap rather * than forty cards fading in again. Genuinely new hosts get the ordinary entrance from * `initReveal`, which is also what reveals anything below the fold on scroll. * * Returns the new card elements, in DOM order, for the caller to re-apply its sort and * filter to. */ export function renderMintGrid( grid: HTMLElement, items: MintListItem[], options: { ranked?: boolean; revealDelayStep?: number } = {}, ): HTMLElement[] { const { ranked = true, revealDelayStep } = options; const t = useI18n(); const f = formatters(t); const { locale } = splitLocale(window.location.pathname); // Which hosts the reader can already see. Keyed by host rather than by index: the list // may have grown, shrunk or reordered, and the question is per mint. const revealed = new Set(); for (const card of grid.querySelectorAll('[data-mint-card]')) { if (card.classList.contains('in')) { const host = card.getAttribute('href')?.split('/').pop(); if (host) revealed.add(decodeURIComponent(host)); } } grid.innerHTML = items .map((mint, i) => mintCardHtml(mint, locale, f, { ...(ranked ? { rank: i + 1 } : {}), ...(revealDelayStep === undefined ? {} : { revealDelay: i * revealDelayStep }), revealed: revealed.has(mint.host), }), ) .join(''); initReveal(grid); return [...grid.querySelectorAll('[data-mint-card]')]; } export interface HydrateOptions { /** `cashu`, `fedimint` or `lnurl`. */ type: string; /** The grid to rebuild. */ grid: HTMLElement; /** Keep only the first N, for the home page's top-six strips. */ limit?: number; ranked?: boolean; revealDelayStep?: number; /** Called with the new cards and the payload they were built from, on success only. */ onReplaced?: (cards: HTMLElement[], items: MintListItem[]) => void; } /** * Fetch one ecosystem and rebuild its grid, after paint. * * Deliberately silent on failure — see `fetchListing`. Deliberately unconditional on * success: the API is the newer of the two by construction, since the prerendered grid * is a copy of what this same endpoint said at build time. */ export async function hydrateMintGrid(options: HydrateOptions): Promise { const { type, grid, limit, ranked, revealDelayStep, onReplaced } = options; const all = await fetchListing(type); if (!all) return; const items = limit === undefined ? all : all.slice(0, limit); const cards = renderMintGrid(grid, items, { ...(ranked === undefined ? {} : { ranked }), ...(revealDelayStep === undefined ? {} : { revealDelayStep }), }); onReplaced?.(cards, all); }