Files
CashuMints.space/web/src/pages/[...locale]/mints.astro
T
michilisandCursor c74c7fc187 Ship LNURL mint pages, indexing UI, and reviews rewrite.
Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:43 +02:00

417 lines
16 KiB
Plaintext

---
import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro';
import ReviewByUrl from '../../components/ReviewByUrl.astro';
import { readCapabilities } from '@cashumints/shared';
import { fetchMint, fetchMints } 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';
/** One page per locale: `/mints`, `/es/mints`, `/nl/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);
const mints = await fetchMints();
/*
The list payload carries no NUT information, so the "melt only" and "frozen" chips
need each mint's cached info. One detail fetch per mint, at build time, against the
API on the same machine: the mint pages already do exactly this in getStaticPaths.
A mint whose detail cannot be read simply gets no chip.
*/
const capabilities = await Promise.all(
mints.map((mint) =>
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
),
);
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
const description = t('mints.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('/mints', locale), Astro.site).href;
const schema = [
itemListNode({
canonical,
fragment: 'mints',
name: t('mints.heading'),
items: mints.filter(isIndexableMint).map((mint) => ({
name: mint.name ?? mint.host,
url: new URL(localePath(`/mint/${mint.host}`, locale), Astro.site).href,
})),
}),
];
---
<Base title={t('mints.title')} description={description} current="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('mints.heading')}</h1>
<p class="lede">{t('mints.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="cashu">
{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('mints.search.label')}</label>
<input
id="mint-search"
type="text"
autocomplete="off"
placeholder={t('mints.search.placeholder')}
data-search-input
/>
<kbd aria-hidden="true">/</kbd>
</form>
<div class="control-group">
<label class="sr-only" for="mint-sort">{t('mints.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('mints.hideOffline')}
</button>
</div>
</div>
<p class="result-count" data-result-count aria-live="polite">
{t('mints.showing', { total: mints.length, online, offline })}
</p>
<div class="mint-grid" data-mint-grid>
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
</div>
<p class="no-results" data-no-results hidden>
{t('mints.noResults')}
<button class="link-btn" type="button" data-clear-search>{t('mints.clearSearch')}</button>
</p>
</div>
{/* Two steps in one dialog: identify the mint, then the ordinary review form. */}
<ReviewByUrl type="cashu" />
</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 { 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) {
const 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('mints.showing', { total: visible, online: onlineCount, offline: offlineCount })
: t('mints.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();
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();
});
}
};
onReady(setup);
</script>