Files
CashuMints.space/web/src/pages/[...locale]/index.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

823 lines
34 KiB
Plaintext

---
import Base from '../../layouts/Base.astro';
import ColorBends from '../../components/ColorBends.astro';
import MintCard from '../../components/MintCard.astro';
import PulseTicker from '../../components/PulseTicker.astro';
import {
fetchFedimint, fetchFedimints, fetchHealth, fetchLnurlMint, fetchLnurlMints, fetchMint,
fetchMints, fetchStats,
} from '../../lib/api';
import { fetchLatestReviews } from '../../lib/nostr-build';
import { feedTargets, targetPath } from '../../lib/feed-resolve';
import { displayDomain, iconGradient, sentiment, shortNpub } from '../../lib/format';
import { profileName, readCapabilities } from '@cashumints/shared';
import { localePath, useI18n, type Locale } from '../../i18n';
import { formatters } from '../../i18n/format';
import { localePaths } from '../../i18n/paths';
/** One page per locale: `/`, `/es`, `/nl`. */
export const getStaticPaths = localePaths;
interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const t = useI18n(locale);
const f = formatters(t);
const [mints, federations, lnurlMints, stats, health] = await Promise.all([
fetchMints(),
fetchFedimints(),
fetchLnurlMints(),
fetchStats(),
fetchHealth(),
]);
const top = mints.slice(0, 6);
const topFederations = federations.slice(0, 6);
const topLnurl = lnurlMints.slice(0, 6);
/*
* The reviews strip resolves against every ecosystem, because its heading is "latest
* reviews" and not "latest mint reviews". Federations rarely reach it in practice — the
* junk filter wants a written body and nearly every 38173 review is a bare `[5/5]` —
* but when one does, it is genuinely among the latest and it links to its own page. The
* same is true of an LNURL mint, which today has the further handicap that 38174 is a
* proposed kind with barely any events on the network at all.
*/
const reviewTargets = feedTargets(mints, federations, lnurlMints);
/*
* The mint details and the relay read have nothing to say to each other — the reviews
* only need the target list above — so they wait together rather than one behind the
* other.
*
* The carousel holds a screenful and a bit: enough that scrolling it is worth doing,
* few enough that they are all real, recent, written reviews. Anything the relays
* cannot fill is simply fewer cards, and the carousel's controls disappear with them.
*/
const [details, fediDetails, lnurlDetails, latest] = await Promise.all([
// Distributions give the card sentiment bars real data instead of a rating proxy.
Promise.all(top.map((m) => fetchMint(m.host).catch(() => null))),
Promise.all(topFederations.map((federation) => fetchFedimint(federation.host).catch(() => null))),
Promise.all(topLnurl.map((mint) => fetchLnurlMint(mint.host).catch(() => null))),
fetchLatestReviews(reviewTargets, 10),
]);
const sentiments = details.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
// Same details, read a second way: the card's "melt only" or "frozen" chip.
const capabilities = details.map((d) => (d ? readCapabilities(d.info?.nuts) : null));
// No capability read for federations: they publish no NUT switches to draw a chip from.
const fediSentiments = fediDetails.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
const lnurlSentiments = lnurlDetails.map((d) => (d ? sentiment(d.rating_distribution) : undefined));
/*
* An LNURL card's chip comes from two probed facts rather than from NUT switches: an
* advertised withdraw ceiling of zero, and a Lightning node the mint cannot reach.
*/
const lnurlChips = lnurlDetails.map((d) =>
d
? {
max_withdrawable_msat: d.max_withdrawable_msat ?? null,
funding_available: d.funding_available ?? null,
}
: null,
);
const description = t('home.description', {
mints: stats.mints_total,
reviews: stats.reviews_total,
});
/*
* The one sentence on the page with a link inside it. Interpolated as markup rather
* than split into three strings, so a translator can put "NIP-87" wherever the
* sentence needs it instead of wherever English left a gap.
*/
const signedBody = t('home.why.signed.body', {
link: `<a href="${localePath('/about', locale)}#nip87">${t('home.why.signed.link')}</a>`,
});
---
{/*
`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
is full width, so the wash runs edge to edge rather than stopping at the 1240px
column the text keeps to.
*/}
<div class="hero-wrap">
<ColorBends
rotation={90}
speed={0.2}
colors={['#A78BFA']}
transparent
autoRotate={1}
scale={0.4}
frequency={1}
warpStrength={1}
mouseInfluence={0.5}
parallax={0.2}
noise={0.2}
iterations={1}
intensity={1.3}
bandWidth={7.5}
/>
{/*
The hero arrives as a sequence, 60ms apart, starting the moment the page is
parsed. `data-reveal="load"` means exactly once, on a full page load: coming back
here through the router puts it on screen without replaying the greeting.
*/}
<section class="hero">
{/*
Two strings, because the emphasised half is a phrase and not the tail of a
sentence: Spanish and Dutch both put "you can trust" somewhere English does not,
and a translator needs to be able to move it.
*/}
<h1 data-reveal="load" data-reveal-delay="0">
{t('home.hero.title')} <em>{t('home.hero.titleEm')}</em>
</h1>
<p data-reveal="load" data-reveal-delay="60">{t('home.hero.lede')}</p>
<div class="search-wrap">
<form
class="search"
action={localePath('/mints', locale)}
method="get"
role="search"
data-reveal="load"
data-reveal-delay="120"
>
<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="home-search">{t('home.search.label')}</label>
<input
id="home-search"
name="q"
type="text"
autocomplete="off"
placeholder={t('home.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="quick-filters" data-reveal="load" data-reveal-delay="180">
<a class="qf" href={`${localePath('/mints', locale)}?sort=reviews`}>{t('home.filters.mostReviewed')}</a>
<a class="qf" href={`${localePath('/mints', locale)}?sort=rating`}>{t('home.filters.highestRated')}</a>
<a class="qf" href={`${localePath('/mints', locale)}?sort=recent`}>{t('home.filters.recentlyActive')}</a>
<a class="qf" href={`${localePath('/mints', locale)}?nut=17`}>{t('home.filters.websockets')}</a>
</div>
</div>
</section>
</div>
<PulseTicker stats={stats} health={health} />
<section class="section" aria-labelledby="top-mints">
<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)} data-all-link="cashu">
{t('home.top.all', { n: mints.length })}
</a>
</div>
<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} />
))}
</div>
</section>
{/*
The Fedimint grid: the mint grid's twin, under it and above the reviews. The same
ranked cards as /fedimints, with the same real sentiment bars the Cashu grid gets,
so a reader moving between the two sections does not have to re-learn a card.
*/}
{
topFederations.length > 0 && (
<section class="section" aria-labelledby="top-fedimints">
<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)} data-all-link="fedimint">
{t('home.topFedimints.all', { n: federations.length })}
</a>
</div>
<div class="mint-grid" data-home-grid="fedimint">
{topFederations.map((federation, i) => (
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
))}
</div>
</section>
)
}
{/*
The LNURL grid: the third ecosystem strip, in the same order the nav lists them.
Same ranked cards as /lnurl-mints, same real sentiment bars, and the same chip rule
— so a reader moving down the page meets one card three times rather than three
cards once each.
*/}
{
topLnurl.length > 0 && (
<section class="section" aria-labelledby="top-lnurl">
<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)} data-all-link="lnurl">
{t('home.topLnurl.all', { n: stats.lnurl_total })}
</a>
</div>
<div class="mint-grid" data-home-grid="lnurl">
{topLnurl.map((mint, i) => (
<MintCard
mint={mint}
rank={i + 1}
sentiment={lnurlSentiments[i]}
lnurl={lnurlChips[i]}
revealDelay={i * 50}
/>
))}
</div>
</section>
)
}
<section class="section" aria-labelledby="latest-reviews-title" id="latest-reviews">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="latest-reviews-title">{t('home.latest.title')}</h2>
<span class="sec-sub">{t('home.latest.sub')}</span>
{/*
The controls sit in the header row, never over the cards: the previous site
put arrows on top of the reviews and they covered the text. They start hidden
and the island only shows them when the track actually overflows, so with
JavaScript off, or with three reviews on a wide screen, there is nothing here
that does not work.
*/}
<div class="sec-actions">
<button class="car-btn" type="button" data-carousel-prev aria-label={t('home.latest.prev')} hidden>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button class="car-btn" type="button" data-carousel-next aria-label={t('home.latest.next')} hidden>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<a class="sec-link" href={localePath('/reviews', locale)}>{t('home.latest.all')}</a>
</div>
</div>
{
latest.length === 0 ? (
<p class="empty">{t('home.latest.empty')}</p>
) : (
<div class="rev-track" data-carousel-track>
{latest.map((review) => {
// One identity per card: the name from kind 0, or "Anon". The npub is
// display only here, because the whole card is one link and a copy button
// inside it would be a control inside a control.
const name = profileName(review.profile);
const initial = (name?.[0] ?? review.npub[5] ?? '?').toUpperCase();
const filled = review.rating === null ? 0 : Math.round(review.rating);
return (
<a
class="rev-card"
href={localePath(targetPath({ type: review.mint_type, host: review.mint_host }), locale)}
data-reveal
>
<span class="rc-top">
<span class="rc-avatar-wrap">
<span class="rc-avatar" style={`background:${iconGradient(review.pubkey)}`} aria-hidden="true">
{initial}
</span>
{review.profile?.picture && (
<img
class="rc-avatar pic"
src={review.profile.picture}
alt=""
width="34"
height="34"
loading="lazy"
referrerpolicy="no-referrer"
onload="this.classList.add('loaded')"
onerror="this.remove()"
/>
)}
</span>
<span class="rc-who">
<span class="rc-identity">
{
// No NIP-05 here: at this card width it truncates to half a
// domain, which says less than nothing. It renders in full on
// the mint page, where the row has the room.
name ? (
<span class="rc-name">{name}</span>
) : (
<span class="rc-name anon">{t('reviews.anon')}</span>
)
}
</span>
<span class="rc-npub mono">{shortNpub(review.npub)}</span>
</span>
<span class:list={['rc-stars', { bad: review.rating !== null && review.rating <= 2 }]}>
<span class="sr-only">
{
review.rating === null
? t('reviews.srNoRating')
: t('reviews.srRating', { rating: String(review.rating) })
}
</span>
{/* Five elements rather than one string, so the row fills star by
star when the card arrives. */}
<span class="rc-star-row" aria-hidden="true">
{[0, 1, 2, 3, 4].map((i) => (
<span class="rc-star" style={`--star-i:${i}`}>{i < filled ? '★' : '☆'}</span>
))}
</span>
</span>
</span>
{/* The review, exactly as its author wrote it. Never translated. */}
<span class="rc-body">{review.content}</span>
<span class="rc-foot">
<span class="rc-mint">{displayDomain(review.mint_url)}</span>
<span class="rc-arrow" aria-hidden="true">→</span>
<span class="rc-date">{f.relative(review.created_at)}</span>
</span>
</a>
);
})}
</div>
)
}
</section>
<section class="section" aria-labelledby="why-trust">
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="why-trust">{t('home.why.title')}</h2>
</div>
<div class="how">
<div class="how-card" data-reveal>
<span class="how-icon" aria-hidden="true">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
</svg>
</span>
<h3>{t('home.why.signed.title')}</h3>
<p set:html={signedBody} />
</div>
<div class="how-card" data-reveal>
<span class="how-icon" aria-hidden="true">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z" />
</svg>
</span>
<h3>{t('home.why.verify.title')}</h3>
<p>{t('home.why.verify.body')}</p>
</div>
<div class="how-card" data-reveal>
<span class="how-icon" aria-hidden="true">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z" />
</svg>
</span>
<h3>{t('home.why.checked.title')}</h3>
<p>{t('home.why.checked.body')}</p>
</div>
</div>
</section>
<div class="cta">
<div class="cta-box" data-reveal>
<h2>{t('home.cta.title')}</h2>
<p>{t('home.cta.body')}</p>
<div class="cta-actions">
<a class="btn primary" href={localePath('/mints', locale)}>{t('home.cta.explore')}</a>
<a class="btn" href={localePath('/about', locale)}>{t('home.cta.learn')}</a>
</div>
</div>
</div>
</Base>
<style>
/* Positions the colour field, which is absolutely placed against this box. */
.hero-wrap { position: relative; }
/* Above the field, and its own stacking context so the search box stays clickable. */
.hero { position: relative; z-index: 1; }
.hero {
max-width: var(--page-max);
margin: 0 auto;
/* A third off the old 76/52. The pulse ticker is the proof that the site is
live, and it belongs on screen at 1280x800 without scrolling. */
padding: 136px var(--page-pad) 80px;
text-align: center;
}
.hero h1 {
font-family: var(--display);
font-weight: 700;
font-size: clamp(34px, 5vw, 56px);
letter-spacing: -.03em;
line-height: 1.06;
/* Narrow enough to break after "mint", balanced so the two lines are even:
"Find a Cashu mint" over "you can trust", never "you can" over "trust". */
max-width: 620px;
text-wrap: balance;
margin: 0 auto;
}
.hero h1 em { font-style: normal; color: var(--amber); }
.hero p { color: var(--muted); font-size: 17px; max-width: 560px; margin: 16px auto 0; text-wrap: balance; }
.search-wrap { max-width: 640px; margin: 26px auto 0; }
.search {
display: flex; align-items: center; gap: 12px;
background: var(--card); border: 1px solid var(--line);
border-radius: 14px; padding: 16px 18px;
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;
color: var(--text); font-family: var(--body); font-size: 16px; min-width: 0;
}
.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;
}
.quick-filters { display: flex; gap: 8px; justify-content: center; margin-top: 14px; margin-bottom: 28px; flex-wrap: wrap; }
.qf {
font-size: 13px; font-weight: 500; color: var(--muted);
border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px;
transition: color .15s, border-color .15s, background .15s;
}
.qf:hover { color: var(--text); border-color: #38342A; background: var(--card); }
/* ---------- latest reviews carousel ---------- */
/*
* A scroll-snap track, not a JavaScript slider: native touch and trackpad swiping,
* native momentum, and it still works with the island switched off. The buttons and
* the auto-advance are additions on top of something that already scrolls.
*/
.sec-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.sec-actions .sec-link { margin-left: 4px; }
.car-btn {
width: 30px; height: 30px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--line); border-radius: 8px;
background: var(--card); color: var(--muted); cursor: pointer;
transition: color var(--dur-quick), border-color var(--dur-quick), background var(--dur-quick);
}
.car-btn:hover { color: var(--text); border-color: #38342A; background: var(--card2); }
.car-btn:active { transform: translateY(1px); }
.car-btn[disabled] { opacity: .4; cursor: not-allowed; }
.car-btn[disabled]:hover { color: var(--muted); border-color: var(--line); background: var(--card); }
.rev-track {
display: flex;
gap: 16px;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
/* Snap points land on the container's own left edge, so a card is never half cut
at the start of the track. */
scroll-padding-left: 0;
/* Room for the card's hover lift and its focus ring, which an overflow container
would otherwise clip. */
padding: 4px 0 12px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.rev-track::-webkit-scrollbar { display: none; }
/* Soft edge while there is more to come, so the track reads as scrollable. Removed
at the end of the track, where there is nothing left to hint at. */
.rev-track.has-next {
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent 100%);
mask-image: linear-gradient(90deg, #000 calc(100% - 44px), transparent 100%);
}
.rev-card {
flex: 0 0 380px;
/* Never wider than the viewport: at 360px the card fits the column instead of
running its text under the edge. */
max-width: calc(100vw - var(--page-pad) * 2);
scroll-snap-align: start;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--card); padding: 20px; min-width: 0;
display: flex; flex-direction: column; gap: 12px;
transition: border-color var(--dur-quick), transform var(--dur-quick) var(--ease-out),
box-shadow var(--dur-quick);
}
.rev-card:hover {
border-color: #3A362B;
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, .3);
}
.rev-card:active { transform: scale(.995); }
.rc-top { display: flex; align-items: center; gap: 11px; }
.rc-avatar-wrap { position: relative; width: 34px; height: 34px; flex-shrink: 0; }
.rc-avatar {
width: 34px; height: 34px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-family: var(--display); font-weight: 600; font-size: 14px; color: #0E0D0B;
border: 1px solid var(--line); object-fit: cover;
}
/* The picture covers the generated initial, fades in when it decodes, and removes
itself if it 404s. */
.rc-avatar.pic { position: absolute; inset: 0; background: var(--card2); }
.rc-who { min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 1px; height: 34px; }
.rc-identity { display: flex; align-items: center; gap: 7px; min-width: 0; height: 19px; }
.rc-name { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-name.anon { color: var(--muted); font-weight: 500; }
/* Display only on this card: the whole card is a link, so there is no button in it. */
.rc-npub { font-size: 11px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rc-stars { margin-left: auto; color: var(--amber); font-size: 12.5px; flex-shrink: 0; }
.rc-stars.bad { color: var(--red); }
.rc-star-row { display: inline-flex; gap: 1.5px; }
/* The row fills star by star once the card has arrived, 40ms apart. */
.rc-star {
transition:
opacity var(--dur-quick) var(--ease-out) var(--star-in),
transform var(--dur-quick) var(--ease-out) var(--star-in);
--star-in: calc(var(--reveal-delay, 0ms) + 140ms + var(--star-i) * 40ms);
}
:global([data-reveal]:not(.in)) .rc-star { opacity: 0; transform: scale(.8); }
/* Three lines and an ellipsis, so every card in the row is the same height. */
.rc-body {
font-size: 14px; color: #D8D2C4; line-height: 1.55; flex: 1; overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3;
overflow: hidden;
}
.rc-foot {
display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--faint);
border-top: 1px solid var(--line-soft); padding-top: 12px;
}
.rc-mint {
display: block; min-width: 0; font-family: var(--mono); font-size: 12px; color: var(--muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
transition: color var(--dur-quick);
}
/* The destination, nudged: hovering the card says where it goes. */
.rc-arrow {
color: var(--amber); opacity: 0; flex-shrink: 0;
transform: translateX(-4px);
transition: opacity var(--dur-quick) var(--ease-out), transform var(--dur-quick) var(--ease-out);
}
.rev-card:hover .rc-mint, .rev-card:focus-visible .rc-mint { color: var(--amber); }
.rev-card:hover .rc-arrow, .rev-card:focus-visible .rc-arrow { opacity: 1; transform: none; }
.rc-date { margin-left: auto; flex-shrink: 0; }
.empty { color: var(--muted); font-size: 14.5px; }
.how { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.how-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--card); padding: 24px; }
.how-icon {
width: 40px; height: 40px; border-radius: 10px;
background: var(--card2); border: 1px solid var(--line);
display: flex; align-items: center; justify-content: center;
color: var(--amber); margin-bottom: 16px;
}
.how-card h3 { font-family: var(--display); font-size: 16.5px; font-weight: 600; margin-bottom: 8px; letter-spacing: -.01em; }
.how-card p { font-size: 13.5px; color: var(--muted); line-height: 1.6; }
.how-card p a { color: var(--violet); border-bottom: 1px dotted rgba(167, 139, 250, .4); }
.cta { max-width: var(--page-max); margin: 64px auto 0; padding: 0 var(--page-pad); }
.cta-box {
border: 1px solid var(--line); border-radius: 18px;
background: linear-gradient(135deg, var(--card) 0%, #1B1712 100%);
padding: 52px 40px; text-align: center; position: relative; overflow: hidden;
}
.cta-box::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(600px 200px at 50% 0%, rgba(240, 169, 59, .07), transparent 70%);
}
.cta-box h2 { font-family: var(--display); font-size: 30px; font-weight: 700; letter-spacing: -.02em; position: relative; }
.cta-box p { color: var(--muted); max-width: 480px; margin: 12px auto 26px; position: relative; }
.cta-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; position: relative; }
@media (max-width: 1279px) {
.how { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.how { grid-template-columns: minmax(0, 1fr); }
.hero { padding: 116px var(--page-pad) 72px; }
.section { padding: 44px var(--page-pad) 8px; }
.cta-box { padding: 38px 22px; }
.cta-box h2 { font-size: 25px; }
}
</style>
<script>
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 = {
track: document.querySelector<HTMLElement>('[data-carousel-track]'),
prev: document.querySelector<HTMLButtonElement>('[data-carousel-prev]'),
next: document.querySelector<HTMLButtonElement>('[data-carousel-next]'),
};
if (!found.track || !found.prev || !found.next) return;
const track: HTMLElement = found.track;
const prev: HTMLButtonElement = found.prev;
const next: HTMLButtonElement = found.next;
let timer = 0;
/**
* Auto-advance is a hint that the track scrolls, not a feature. The first time
* the reader scrolls it themselves, by button, swipe, wheel or key, it is their
* track and it never moves on its own again. With reduced motion set it never
* starts at all.
*/
let surrendered = prefersReducedMotion();
const cardStep = (): number => {
const card = track.querySelector<HTMLElement>('.rev-card');
return card ? card.getBoundingClientRect().width + GAP : track.clientWidth;
};
const overflows = (): boolean => track.scrollWidth - track.clientWidth > 4;
const atStart = (): boolean => track.scrollLeft <= 2;
const atEnd = (): boolean => track.scrollLeft >= track.scrollWidth - track.clientWidth - 2;
const stop = (): void => {
if (timer) {
window.clearInterval(timer);
timer = 0;
}
};
/** Buttons only exist when there is something to scroll to. */
function sync(): void {
const scrollable = overflows();
prev.hidden = !scrollable;
next.hidden = !scrollable;
track.classList.toggle('has-next', scrollable && !atEnd());
if (!scrollable) {
stop();
return;
}
prev.disabled = atStart();
next.disabled = atEnd();
}
const scrollByCard = (direction: number): void => {
track.scrollBy({ left: direction * cardStep(), behavior: scrollBehavior() });
};
function tick(): void {
// Paused while the pointer is over the track or focus is inside it: nothing
// should slide out from under a reader who is reading it.
if (surrendered || track.matches(':hover, :focus-within') || !overflows()) return;
if (atEnd()) track.scrollTo({ left: 0, behavior: scrollBehavior() });
else scrollByCard(1);
}
const start = (): void => {
if (surrendered || timer || !overflows()) return;
timer = window.setInterval(tick, AUTO_MS);
};
const surrender = (): void => {
surrendered = true;
stop();
};
prev.addEventListener('click', () => {
surrender();
scrollByCard(-1);
});
next.addEventListener('click', () => {
surrender();
scrollByCard(1);
});
// Deliberate interaction, as opposed to the smooth scroll this island starts
// itself. Watching these rather than the scroll event is what keeps the two
// apart without a flag and a timeout guessing at which is which.
for (const event of ['pointerdown', 'touchstart', 'keydown'] as const) {
track.addEventListener(event, surrender, { passive: true });
}
// A wheel only counts when it is aimed at the track. Scrolling the page with the
// pointer resting over the carousel is not a request to take it over.
track.addEventListener(
'wheel',
(event) => {
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) surrender();
},
{ passive: true },
);
let frame = 0;
track.addEventListener(
'scroll',
() => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
sync();
});
},
{ passive: true },
);
// A window narrow enough to make the track scrollable brings the controls, and
// the auto-advance, back with it.
const onResize = (): void => {
sync();
start();
};
window.addEventListener('resize', onResize);
sync();
start();
onLeave(() => {
stop();
window.removeEventListener('resize', onResize);
if (frame) cancelAnimationFrame(frame);
});
};
onReady(setup);
</script>