Backend: - Fix activity score (followersCount check), NIP-98 URL proto, wallet balance guard - Add payment_hash to confirm response, spentTodaySats to stats - Add idx_claims_ip_hash; security headers, graceful shutdown, periodic nonce cleanup Frontend: - Remove 8 legacy components; polish wizard (rules summary, profile card, confetti, share) - Stats budget bar uses spentTodaySats; ErrorBoundary with reload SEO & production: - Full meta/OG/Twitter, favicon, JSON-LD, robots.txt, sitemap.xml - Mobile CSS fixes; nginx static dist + gzip + cache + security headers - Vite manualChunks; aria-labels, dynamic page titles Made-with: Cursor
124 lines
3.8 KiB
TypeScript
124 lines
3.8 KiB
TypeScript
import React from "react";
|
|
import { motion } from "framer-motion";
|
|
import { getStats, type Stats } from "../api";
|
|
|
|
const REFRESH_MS = 45_000;
|
|
|
|
export interface StatsSectionProps {
|
|
/** Optional refetch trigger: when this value changes, stats are refetched. */
|
|
refetchTrigger?: number;
|
|
}
|
|
|
|
function AnimatedNumber({ value }: { value: number }) {
|
|
return (
|
|
<motion.span
|
|
key={value}
|
|
initial={{ opacity: 0.6 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{value.toLocaleString()}
|
|
</motion.span>
|
|
);
|
|
}
|
|
|
|
export function StatsSection({ refetchTrigger }: StatsSectionProps) {
|
|
const [stats, setStats] = React.useState<Stats | null>(null);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [refreshing, setRefreshing] = React.useState(false);
|
|
|
|
const load = React.useCallback(async (userRefresh = false) => {
|
|
if (userRefresh) setRefreshing(true);
|
|
try {
|
|
const s = await getStats();
|
|
setStats(s);
|
|
} catch {
|
|
setStats(null);
|
|
} finally {
|
|
setLoading(false);
|
|
if (userRefresh) setRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
load();
|
|
const t = setInterval(load, REFRESH_MS);
|
|
return () => clearInterval(t);
|
|
}, [load, refetchTrigger]);
|
|
|
|
if (loading && !stats) {
|
|
return (
|
|
<div className="stats-box stats-skeleton">
|
|
<div className="skeleton-line balance" />
|
|
<div className="skeleton-line short" />
|
|
<div className="skeleton-line" />
|
|
<div className="skeleton-line" />
|
|
<div className="skeleton-line" />
|
|
</div>
|
|
);
|
|
}
|
|
if (!stats) return <div className="stats-box"><p>Stats unavailable</p></div>;
|
|
|
|
const n = (v: number | undefined | null) => Number(v ?? 0);
|
|
const ts = (v: number | undefined | null) => new Date(Number(v ?? 0) * 1000).toLocaleString();
|
|
const dailyBudget = Number(stats.dailyBudgetSats) || 1;
|
|
const budgetUsed = n(stats.spentTodaySats);
|
|
const budgetPct = Math.min(100, (budgetUsed / dailyBudget) * 100);
|
|
|
|
return (
|
|
<div className="stats-box">
|
|
<h3>Faucet stats</h3>
|
|
<p className="stats-balance">
|
|
<AnimatedNumber value={n(stats.balanceSats)} /> sats
|
|
</p>
|
|
<p className="stats-balance-label">Pool balance</p>
|
|
|
|
<div className="stats-progress-wrap">
|
|
<div className="stats-progress-label">
|
|
Daily budget: <AnimatedNumber value={budgetUsed} /> / <AnimatedNumber value={n(stats.dailyBudgetSats)} /> sats
|
|
</div>
|
|
<div className="stats-progress-bar">
|
|
<div className="stats-progress-fill" style={{ width: `${budgetPct}%` }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stats-rows">
|
|
<div className="stats-row">
|
|
<span>Total paid</span>
|
|
<span><AnimatedNumber value={n(stats.totalPaidSats)} /> sats</span>
|
|
</div>
|
|
<div className="stats-row">
|
|
<span>Total claims</span>
|
|
<span><AnimatedNumber value={n(stats.totalClaims)} /></span>
|
|
</div>
|
|
<div className="stats-row">
|
|
<span>Claims (24h)</span>
|
|
<span><AnimatedNumber value={n(stats.claimsLast24h)} /></span>
|
|
</div>
|
|
</div>
|
|
|
|
{stats.recentPayouts?.length > 0 && (
|
|
<>
|
|
<h3 className="stats-recent-title">Recent payouts</h3>
|
|
<ul className="stats-recent-list">
|
|
{stats.recentPayouts.slice(0, 10).map((p, i) => (
|
|
<li key={i}>
|
|
{p.pubkey_prefix ?? "…"} — {n(p.payout_sats).toLocaleString()} sats — {ts(p.claimed_at)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={`entropy-btn stats-refresh ${refreshing ? "stats-refresh--spinning" : ""}`}
|
|
onClick={() => load(true)}
|
|
disabled={refreshing}
|
|
>
|
|
<span className="stats-refresh-icon" aria-hidden>↻</span>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|