feat(web+infra): polished front page, app at /, tracker at /status

- apps/web: redesigned landing (Hero/Scanner/HowItWorks/Features/Footer),
  honest live-vs-coming-soon badges, same-origin /api/scan, ember theme.
- ecosystem.config.cjs: runnable — pyre-api/worker via `node --import tsx`,
  pyre-web via `next start`, fork mode, env wired. pm2 web+api verified online
  (api /health 200, scan 200, web 200).
- infra/nginx/feedthepyre.com.conf: app at / (proxy :3000), API at /api
  (proxy :4000, prefix preserved), dev tracker at /status (static).
- scripts/deploy-web.sh: sudo cutover (install vhost, nginx -t, reload,
  certbot --nginx --keep-until-expiring).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 03:24:58 +00:00
parent 2101e18b3e
commit d159ad5196
10 changed files with 834 additions and 327 deletions

View File

@@ -25,37 +25,305 @@ body {
}
.page {
max-width: 56rem;
max-width: 64rem;
margin: 0 auto;
padding: 3rem 1.25rem 5rem;
padding: 2.5rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 4.5rem;
}
/* Shared section heading */
.section-heading {
font-size: clamp(1.4rem, 4vw, 1.9rem);
font-weight: 800;
letter-spacing: 0.01em;
margin: 0 0 1.5rem;
text-align: center;
}
/* Status badges (live / partial / coming soon) */
.badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
border: 1px solid transparent;
}
.badge--live {
color: #7be3a3;
background: rgba(60, 200, 120, 0.12);
border-color: rgba(60, 200, 120, 0.35);
}
.badge--partial {
color: var(--color-ember-bright);
background: rgba(255, 138, 61, 0.12);
border-color: rgba(255, 138, 61, 0.35);
}
.badge--soon {
color: var(--color-smoke);
background: rgba(184, 169, 156, 0.1);
border-color: rgba(184, 169, 156, 0.3);
}
/* Hero / header */
.hero {
position: relative;
text-align: center;
margin-bottom: 2.5rem;
padding: 3rem 0 1rem;
overflow: visible;
}
.hero__glow {
position: absolute;
top: -6rem;
left: 50%;
width: min(40rem, 90vw);
height: 28rem;
transform: translateX(-50%);
background: radial-gradient(
50% 50% at 50% 50%,
rgba(255, 87, 34, 0.28),
transparent 70%
);
filter: blur(20px);
pointer-events: none;
z-index: 0;
}
.hero > *:not(.hero__glow) {
position: relative;
z-index: 1;
}
.hero__eyebrow {
text-transform: uppercase;
letter-spacing: 0.32em;
font-size: 0.72rem;
font-weight: 600;
color: var(--color-smoke);
margin: 0 0 0.75rem;
}
.hero__title {
font-size: clamp(3rem, 10vw, 5rem);
font-weight: 800;
font-size: clamp(3.5rem, 13vw, 6.5rem);
font-weight: 900;
letter-spacing: 0.15em;
line-height: 1;
margin: 0;
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 40px rgba(255, 87, 34, 0.35);
text-shadow: 0 0 60px rgba(255, 87, 34, 0.45);
}
.hero__tagline {
font-size: 1.15rem;
font-weight: 600;
font-size: clamp(1.1rem, 3.5vw, 1.45rem);
font-weight: 700;
color: var(--color-ember-bright);
margin: 0.75rem 0 0.25rem;
margin: 1rem 0 0.75rem;
}
.hero__value {
max-width: 38rem;
margin: 0 auto 0.5rem;
font-size: clamp(1rem, 2.5vw, 1.15rem);
color: #f5ede6;
}
.hero__trust {
color: var(--color-smoke);
font-style: italic;
margin: 0;
}
.hero__cta {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
justify-content: center;
align-items: center;
margin: 1.75rem 0 0.85rem;
}
.hero__secondary {
color: var(--color-ember-bright);
text-decoration: none;
font-weight: 600;
font-size: 0.95rem;
padding: 0 1rem;
height: 48px;
display: inline-flex;
align-items: center;
border-radius: 0.5rem;
border: 1px solid rgba(255, 138, 61, 0.35);
transition: background 0.15s ease, border-color 0.15s ease;
}
.hero__secondary:hover {
background: rgba(255, 138, 61, 0.08);
border-color: rgba(255, 138, 61, 0.6);
}
.hero__note {
color: var(--color-smoke);
font-size: 0.85rem;
margin: 0;
}
/* Scanner section */
.scanner__head {
margin-bottom: 1.5rem;
}
.scanner__sub {
text-align: center;
color: var(--color-smoke);
margin: -0.75rem auto 0;
max-width: 34rem;
}
.scanner__panel {
border: 1px solid rgba(255, 138, 61, 0.22);
background: linear-gradient(180deg, rgba(255, 87, 34, 0.06), rgba(26, 20, 18, 0.6));
border-radius: 1rem;
padding: 1.75rem 1.5rem;
}
.scanner__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
margin-top: 1.25rem;
color: var(--color-smoke);
}
.scanner__spinner {
width: 1.05rem;
height: 1.05rem;
border-radius: 50%;
border: 2px solid rgba(255, 138, 61, 0.3);
border-top-color: var(--color-ember-bright);
animation: pyre-spin 0.8s linear infinite;
}
@keyframes pyre-spin {
to {
transform: rotate(360deg);
}
}
/* How it works */
.how__grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 1rem;
}
.how__step {
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--color-coal);
border-radius: 0.85rem;
padding: 1.25rem 1.25rem 1.5rem;
}
.how__num {
font-size: 1.6rem;
font-weight: 900;
color: rgba(255, 138, 61, 0.5);
font-variant-numeric: tabular-nums;
}
.how__title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 1.05rem;
font-weight: 700;
margin: 0.35rem 0 0.45rem;
}
.how__body {
color: var(--color-smoke);
font-size: 0.9rem;
margin: 0;
line-height: 1.5;
}
/* Feature / status cards */
.features__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1rem;
}
.feature-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--color-coal);
border-radius: 0.85rem;
padding: 1.25rem 1.35rem 1.4rem;
}
.feature-card--live {
border-color: rgba(60, 200, 120, 0.3);
}
.feature-card--partial {
border-color: rgba(255, 138, 61, 0.3);
}
.feature-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.feature-card__title {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
}
.feature-card__body {
color: var(--color-smoke);
font-size: 0.9rem;
line-height: 1.5;
margin: 0;
}
/* Footer */
.footer {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.footer__brand {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.footer__wordmark {
font-size: 1.4rem;
font-weight: 900;
letter-spacing: 0.15em;
color: var(--color-ember-bright);
}
.footer__motto {
color: var(--color-smoke);
font-size: 0.85rem;
}
.footer__links {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: center;
}
.footer__links a {
color: #f5ede6;
text-decoration: none;
font-weight: 600;
font-size: 0.92rem;
}
.footer__links a:hover {
color: var(--color-ember-bright);
}
.footer__disclaimer {
max-width: 40rem;
color: var(--color-smoke);
font-size: 0.8rem;
line-height: 1.6;
margin: 0.5rem 0 0;
}
/* Connect / scan controls */
.connect {
@@ -64,7 +332,6 @@ body {
gap: 0.75rem;
justify-content: center;
align-items: center;
margin-bottom: 1.5rem;
}
.scan-btn {

View File

@@ -1,204 +1,22 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { TokenClassification } from "@pyre/core";
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
// Display order + friendly headings. Wording rules: never say "safe"; protected /
// unsupported read as "skipped / not eligible"; eligible reads as "appears eligible".
const SECTIONS: ReadonlyArray<{
classification: TokenClassification;
heading: string;
blurb: string;
}> = [
{
classification: TokenClassification.EMPTY_CLOSE_ONLY,
heading: "Closeable empty accounts",
blurb: "Empty token accounts that appear eligible to close and reclaim rent.",
},
{
classification: TokenClassification.INCINERATE_ONLY,
heading: "Burnable junk",
blurb: "Worthless leftover balances that appear eligible to burn.",
},
{
classification: TokenClassification.TRANSMUTABLE,
heading: "Transmutable scraps",
blurb: "Scraps that appear eligible to feed the fire.",
},
{
classification: TokenClassification.PROTECTED_SKIP,
heading: "Protected / skipped",
blurb: "Skipped — not eligible for cleanup. Left untouched.",
},
{
classification: TokenClassification.UNSUPPORTED,
heading: "Unsupported",
blurb: "Not eligible — PYRE can't reason about these, so they're skipped.",
},
];
function truncate(addr: string): string {
if (addr.length <= 10) return addr;
return `${addr.slice(0, 4)}${addr.slice(-4)}`;
}
function lamportsToSol(lamports: string): number {
// Lamports arrive as a u64 string; BigInt avoids precision loss before scaling.
try {
return Number(BigInt(lamports)) / 1e9;
} catch {
return 0;
}
}
function AccountRow({ account }: { account: TokenAccountDto }) {
const label = account.symbol ?? account.name ?? truncate(account.mint);
return (
<li className="account-row">
<div className="account-row__main">
<span className="account-row__label" title={account.mint}>
{label}
</span>
<span className="account-row__mint" title={account.mint}>
{truncate(account.mint)}
</span>
<span className="account-row__balance">{account.uiBalance}</span>
</div>
{account.warnings.length > 0 && (
<ul className="account-row__warnings">
{account.warnings.map((w, i) => (
<li key={i}> {w}</li>
))}
</ul>
)}
</li>
);
}
import { Hero } from "../components/Hero";
import { Scanner } from "../components/Scanner";
import { HowItWorks } from "../components/HowItWorks";
import { Features } from "../components/Features";
import { Footer } from "../components/Footer";
export default function HomePage() {
const { publicKey } = useWallet();
const [scan, setScan] = useState<ScanResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const wallet = publicKey?.toBase58() ?? null;
const runScan = useCallback(async () => {
if (!wallet) return;
setLoading(true);
setError(null);
setScan(null);
try {
const res = await fetch(`${API_URL}/api/scan`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wallet }),
});
if (!res.ok) {
throw new Error(`Scan failed (${res.status})`);
}
const data = (await res.json()) as ScanResponse;
setScan(data);
} catch (e) {
setError(e instanceof Error ? e.message : "Scan failed.");
} finally {
setLoading(false);
}
}, [wallet]);
const grouped = useMemo(() => {
const map = new Map<TokenClassification, TokenAccountDto[]>();
if (scan) {
for (const acct of scan.accounts) {
const bucket = map.get(acct.classification) ?? [];
bucket.push(acct);
map.set(acct.classification, bucket);
}
}
return map;
}, [scan]);
const reclaimSol = scan ? lamportsToSol(scan.summary.estimatedRentLamports) : 0;
const { connected } = useWallet();
return (
<main className="page">
<header className="hero">
<h1 className="hero__title">PYRE</h1>
<p className="hero__tagline">
Burn the dead. Feed the PYRE. Claim the Spawn.
</p>
<p className="hero__trust">
PYRE returns your rent. The scraps feed the fire.
</p>
</header>
<section className="connect">
<WalletMultiButton />
{wallet && (
<button
type="button"
className="scan-btn"
onClick={runScan}
disabled={loading}
>
{loading ? "Scanning…" : "Scan wallet"}
</button>
)}
</section>
{!wallet && (
<p className="hint">Connect a wallet to scan it for reclaimable rent.</p>
)}
{error && <p className="error">Something went wrong: {error}</p>}
{scan && (
<section className="results">
<div className="reclaim">
<p className="reclaim__headline">
Recover ~{reclaimSol.toFixed(6)} SOL from{" "}
{scan.summary.emptyCloseOnly} empty account
{scan.summary.emptyCloseOnly === 1 ? "" : "s"}.
</p>
<p className="reclaim__sub">
Scanned {scan.summary.totalAccounts} token account
{scan.summary.totalAccounts === 1 ? "" : "s"}.
</p>
</div>
{SECTIONS.map(({ classification, heading, blurb }) => {
const accounts = grouped.get(classification) ?? [];
if (accounts.length === 0) return null;
return (
<div key={classification} className="result-section">
<h2 className="result-section__heading">
{heading}{" "}
<span className="result-section__count">
({accounts.length})
</span>
</h2>
<p className="result-section__blurb">{blurb}</p>
<ul className="account-list">
{accounts.map((a) => (
<AccountRow key={a.ata} account={a} />
))}
</ul>
</div>
);
})}
<p className="preview-note">
This is a scan and preview only nothing has been signed. Closing
empty accounts and burning junk (which require signing in your
wallet) comes next.
</p>
</section>
)}
<Hero connected={connected} />
<Scanner />
<HowItWorks />
<Features />
<Footer />
</main>
);
}

View File

@@ -0,0 +1,68 @@
/**
* Feature / status cards. HONEST about what is live vs coming soon — coming-soon
* items carry a clear badge and must not imply they work today.
* Server component (static content, no hooks).
*/
type Status = "live" | "partial" | "soon";
const FEATURES: ReadonlyArray<{
title: string;
body: string;
status: Status;
statusLabel: string;
}> = [
{
title: "Scan & classify",
body: "Read your SPL token accounts and conservatively classify every one. Unknown means skip.",
status: "live",
statusLabel: "Live",
},
{
title: "Reclaim ATA rent",
body: "The scan already estimates recoverable rent from empty accounts. One-click close to send that SOL back to you is next.",
status: "partial",
statusLabel: "Scan live · close soon",
},
{
title: "Burn dead tokens",
body: "Burn worthless leftover balances to zero, then close the emptied accounts to recover their rent.",
status: "soon",
statusLabel: "Phase 3",
},
{
title: "Prometheus AI meme rebirth",
body: "Turn burned remnants into an AI-generated meme Spawn for human-reviewed launch. Entertainment, not a promise of value.",
status: "soon",
statusLabel: "Coming soon",
},
];
function badgeClass(status: Status): string {
if (status === "live") return "badge badge--live";
if (status === "partial") return "badge badge--partial";
return "badge badge--soon";
}
export function Features() {
return (
<section className="features" aria-labelledby="features-heading">
<h2 className="section-heading" id="features-heading">
What&apos;s live, what&apos;s coming
</h2>
<div className="features__grid">
{FEATURES.map(({ title, body, status, statusLabel }) => (
<article
key={title}
className={`feature-card feature-card--${status}`}
>
<div className="feature-card__top">
<h3 className="feature-card__title">{title}</h3>
<span className={badgeClass(status)}>{statusLabel}</span>
</div>
<p className="feature-card__body">{body}</p>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,33 @@
/**
* Footer: dev status tracker (served separately by nginx as a static /status
* page — plain <a>, NOT a Next route), repo link, and the §2 disclaimer.
* Server component.
*/
const REPO_URL = "https://git.lumiai.dev/RogueWave/pyre";
export function Footer() {
return (
<footer className="footer">
<div className="footer__brand">
<span className="footer__wordmark">PYRE</span>
<span className="footer__motto">
Burn the dead. Feed the PYRE. Claim the Spawn.
</span>
</div>
<nav className="footer__links" aria-label="Footer">
<a href="/status">Dev status</a>
<a href={REPO_URL} target="_blank" rel="noreferrer noopener">
Repository
</a>
<a href="#scanner">Scanner</a>
</nav>
<p className="footer__disclaimer">
PYRE is not an investment product, yield mechanism, trading bot, or
guaranteed-profit system. It is wallet-cleanup utility and ritual
entertainment. Recovered rent returns to you.
</p>
</footer>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
/**
* Landing hero: wordmark, tagline, value prop, trust line, and the primary
* wallet call-to-action. Wording rules apply — this is a scan/preview only.
*/
export function Hero({ connected }: { connected: boolean }) {
return (
<header className="hero">
<div className="hero__glow" aria-hidden="true" />
<p className="hero__eyebrow">Solana wallet cleanup ritual</p>
<h1 className="hero__title">PYRE</h1>
<p className="hero__tagline">
Burn the dead. Feed the PYRE. Claim the Spawn.
</p>
<p className="hero__value">
Clean dead Solana token accounts and reclaim the SOL rent trapped in
them.
</p>
<p className="hero__trust">
PYRE returns your rent. The scraps feed the fire.
</p>
<div className="hero__cta">
<WalletMultiButton />
<a className="hero__secondary" href="#scanner">
{connected ? "Go to scanner" : "See how it works"}
</a>
</div>
<p className="hero__note">
Read-only scan. Nothing is signed you stay in control of your wallet.
</p>
</header>
);
}

View File

@@ -0,0 +1,55 @@
/**
* "How it works" — a 3-4 step visual walking the user from connect to reclaim.
* Honest about phase boundaries: closing/burning is flagged as coming soon.
* Server component (no hooks / interactivity).
*/
const STEPS: ReadonlyArray<{
step: string;
title: string;
body: string;
soon?: boolean;
}> = [
{
step: "01",
title: "Connect",
body: "Connect a Solana wallet. PYRE never holds your keys — every action stays client-side.",
},
{
step: "02",
title: "Scan",
body: "PYRE reads your SPL token accounts and estimates the SOL rent locked inside empty ones.",
},
{
step: "03",
title: "Review classifications",
body: "Each account is conservatively grouped — closeable, burnable, transmutable, or skipped / not eligible.",
},
{
step: "04",
title: "Reclaim rent",
body: "Close empty accounts and burn dead tokens to recover rent to your own wallet.",
soon: true,
},
];
export function HowItWorks() {
return (
<section className="how" id="how-it-works" aria-labelledby="how-heading">
<h2 className="section-heading" id="how-heading">
How it works
</h2>
<ol className="how__grid">
{STEPS.map(({ step, title, body, soon }) => (
<li key={step} className="how__step">
<span className="how__num">{step}</span>
<h3 className="how__title">
{title}
{soon && <span className="badge badge--soon">Coming soon</span>}
</h3>
<p className="how__body">{body}</p>
</li>
))}
</ol>
</section>
);
}

View File

@@ -0,0 +1,221 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { TokenClassification } from "@pyre/core";
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
// Same-origin by default so production hits "/api/scan" behind the same host.
// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev).
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
// Display order + friendly headings. Wording rules: never say "safe"; protected /
// unsupported read as "skipped / not eligible"; eligible reads as "appears eligible".
const SECTIONS: ReadonlyArray<{
classification: TokenClassification;
heading: string;
blurb: string;
}> = [
{
classification: TokenClassification.EMPTY_CLOSE_ONLY,
heading: "Closeable empty accounts",
blurb: "Empty token accounts that appear eligible to close and reclaim rent.",
},
{
classification: TokenClassification.INCINERATE_ONLY,
heading: "Burnable junk",
blurb: "Worthless leftover balances that appear eligible to burn.",
},
{
classification: TokenClassification.TRANSMUTABLE,
heading: "Transmutable scraps",
blurb: "Scraps that appear eligible to feed the fire.",
},
{
classification: TokenClassification.PROTECTED_SKIP,
heading: "Protected / skipped",
blurb: "Skipped — not eligible for cleanup. Left untouched.",
},
{
classification: TokenClassification.UNSUPPORTED,
heading: "Unsupported",
blurb: "Not eligible — PYRE can't reason about these, so they're skipped.",
},
];
function truncate(addr: string): string {
if (addr.length <= 10) return addr;
return `${addr.slice(0, 4)}${addr.slice(-4)}`;
}
function lamportsToSol(lamports: string): number {
// Lamports arrive as a u64 string; BigInt avoids precision loss before scaling.
try {
return Number(BigInt(lamports)) / 1e9;
} catch {
return 0;
}
}
function AccountRow({ account }: { account: TokenAccountDto }) {
const label = account.symbol ?? account.name ?? truncate(account.mint);
return (
<li className="account-row">
<div className="account-row__main">
<span className="account-row__label" title={account.mint}>
{label}
</span>
<span className="account-row__mint" title={account.mint}>
{truncate(account.mint)}
</span>
<span className="account-row__balance">{account.uiBalance}</span>
</div>
{account.warnings.length > 0 && (
<ul className="account-row__warnings">
{account.warnings.map((w, i) => (
<li key={i}> {w}</li>
))}
</ul>
)}
</li>
);
}
export function Scanner() {
const { publicKey } = useWallet();
const [scan, setScan] = useState<ScanResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const wallet = publicKey?.toBase58() ?? null;
const runScan = useCallback(async () => {
if (!wallet) return;
setLoading(true);
setError(null);
setScan(null);
try {
const res = await fetch(`${API_BASE}/api/scan`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ wallet }),
});
if (!res.ok) {
throw new Error(`Scan failed (${res.status})`);
}
const data = (await res.json()) as ScanResponse;
setScan(data);
} catch (e) {
setError(e instanceof Error ? e.message : "Scan failed.");
} finally {
setLoading(false);
}
}, [wallet]);
const grouped = useMemo(() => {
const map = new Map<TokenClassification, TokenAccountDto[]>();
if (scan) {
for (const acct of scan.accounts) {
const bucket = map.get(acct.classification) ?? [];
bucket.push(acct);
map.set(acct.classification, bucket);
}
}
return map;
}, [scan]);
const reclaimSol = scan ? lamportsToSol(scan.summary.estimatedRentLamports) : 0;
return (
<section className="scanner" id="scanner" aria-labelledby="scanner-heading">
<div className="scanner__head">
<h2 className="section-heading" id="scanner-heading">
Live scanner
</h2>
<p className="scanner__sub">
Connect a wallet and run a read-only scan for reclaimable rent.
Nothing is signed.
</p>
</div>
<div className="scanner__panel">
<div className="connect">
<WalletMultiButton />
{wallet && (
<button
type="button"
className="scan-btn"
onClick={runScan}
disabled={loading}
>
{loading ? "Scanning…" : "Scan wallet"}
</button>
)}
</div>
{!wallet && (
<p className="hint">
Connect a wallet above to scan it for reclaimable rent.
</p>
)}
{loading && (
<div className="scanner__loading" role="status" aria-live="polite">
<span className="scanner__spinner" aria-hidden="true" />
Reading token accounts
</div>
)}
{error && (
<p className="error" role="alert">
Something went wrong: {error}
</p>
)}
</div>
{scan && (
<div className="results">
<div className="reclaim">
<p className="reclaim__headline">
Recover ~{reclaimSol.toFixed(6)} SOL from{" "}
{scan.summary.emptyCloseOnly} empty account
{scan.summary.emptyCloseOnly === 1 ? "" : "s"}.
</p>
<p className="reclaim__sub">
Scanned {scan.summary.totalAccounts} token account
{scan.summary.totalAccounts === 1 ? "" : "s"}.
</p>
</div>
{SECTIONS.map(({ classification, heading, blurb }) => {
const accounts = grouped.get(classification) ?? [];
if (accounts.length === 0) return null;
return (
<div key={classification} className="result-section">
<h3 className="result-section__heading">
{heading}{" "}
<span className="result-section__count">
({accounts.length})
</span>
</h3>
<p className="result-section__blurb">{blurb}</p>
<ul className="account-list">
{accounts.map((a) => (
<AccountRow key={a.ata} account={a} />
))}
</ul>
</div>
);
})}
<p className="preview-note">
This is a scan and preview only nothing has been signed. Closing
empty accounts and burning junk (which require signing in your
wallet) comes next.
</p>
</div>
)}
</section>
);
}