Feat: add stats page

This commit is contained in:
jwinterm
2026-02-01 12:53:29 -05:00
parent f63bafbc48
commit 31be623856
5 changed files with 193 additions and 3 deletions
+14
View File
@@ -91,3 +91,17 @@ body {
0 0 20px rgba(251, 235, 10, 0.4),
0 0 30px rgba(251, 235, 10, 0.2);
}
/* Hide scrollbar when content fits viewport on tall screens */
@media (min-height: 700px) {
html {
overflow-y: auto;
scrollbar-gutter: stable;
}
/* Hide scrollbar but keep scrolling functional */
html::-webkit-scrollbar {
width: 0;
background: transparent;
}
}
+5 -1
View File
@@ -21,7 +21,11 @@ export default function RootLayout({
<html lang="en">
<body className={`${geistMono.variable} font-mono antialiased min-h-screen flex flex-col`}>
<main className="flex-1">{children}</main>
<footer className="py-4 text-center text-xs text-zinc-500 flex items-center justify-center gap-3">
<footer className="py-4 text-center text-xs text-zinc-500 flex items-center justify-center gap-3 flex-wrap">
<a href="/stats" className="hover:text-[#fbeb0a] transition-colors">
Stats
</a>
<span className="text-zinc-700">•</span>
<a href="/privacy" className="hover:text-[#fbeb0a] transition-colors">
Privacy Policy
</a>
+5 -2
View File
@@ -153,10 +153,13 @@ export default function Home() {
<p className="creepster-text text-2xl mt-4">smirk.ca$h</p>
{/* Only show user count before logging in */}
{!user && userCount !== null && userCount > 0 && (
<p className="text-zinc-500 text-sm mt-4">
<Link
href="/stats"
className="text-zinc-500 text-sm mt-4 block hover:text-zinc-400 transition-colors"
>
<span className="text-[#fbeb0a] font-semibold">{userCount.toLocaleString()}</span>{' '}
{userCount === 1 ? 'smirker' : 'smirkers'} and counting
</p>
</Link>
)}
</div>
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useState, useEffect } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { getPublicStats, type PublicStats } from '@/lib/api';
// Asset display names and colors
const ASSETS: Record<string, { name: string; color: string }> = {
btc: { name: 'Bitcoin', color: '#f7931a' },
ltc: { name: 'Litecoin', color: '#bfbbbb' },
xmr: { name: 'Monero', color: '#ff6600' },
wow: { name: 'Wownero', color: '#ff69b4' },
grin: { name: 'Grin', color: '#ffc107' },
unknown: { name: 'Unknown', color: '#6b7280' },
};
// Platform display names
const PLATFORMS: Record<string, string> = {
telegram: 'Telegram',
discord: 'Discord',
signal: 'Signal',
matrix: 'Matrix',
};
export default function StatsPage() {
const [stats, setStats] = useState<PublicStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getPublicStats()
.then(setStats)
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load stats'))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-[#fbeb0a] border-t-transparent rounded-full" />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-8">
<p className="text-red-400 mb-4">{error}</p>
<Link href="/" className="text-[#fbeb0a] hover:underline">
Go home
</Link>
</div>
);
}
// Calculate total for percentage bars
const totalByAsset = Object.values(stats?.users_by_preferred_asset || {}).reduce((a, b) => a + b, 0);
return (
<div className="min-h-screen p-8 max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="Smirk" width={40} height={40} />
<span className="creepster-text text-xl">SMIRK</span>
</Link>
</div>
<h1 className="text-2xl font-bold mb-8">Platform Stats</h1>
{/* Total Users */}
<div className="bg-zinc-900 rounded-xl p-6 mb-6 text-center">
<div className="text-5xl font-bold text-[#fbeb0a] mb-2">
{stats?.total_users.toLocaleString()}
</div>
<div className="text-zinc-400">Total Smirkers</div>
</div>
{/* Users by Preferred Coin */}
<div className="bg-zinc-900 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Favorite Login Coin</h2>
<div className="space-y-3">
{Object.entries(stats?.users_by_preferred_asset || {})
.sort(([, a], [, b]) => b - a)
.map(([asset, count]) => {
const info = ASSETS[asset] || ASSETS.unknown;
const percentage = totalByAsset > 0 ? (count / totalByAsset) * 100 : 0;
return (
<div key={asset}>
<div className="flex justify-between text-sm mb-1">
<span>{info.name}</span>
<span className="text-zinc-400">
{count} ({percentage.toFixed(1)}%)
</span>
</div>
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${percentage}%`,
backgroundColor: info.color,
}}
/>
</div>
</div>
);
})}
</div>
</div>
{/* Linked Accounts */}
{Object.keys(stats?.linked_accounts_by_platform || {}).length > 0 && (
<div className="bg-zinc-900 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Linked Accounts</h2>
<div className="grid grid-cols-2 gap-4">
{Object.entries(stats?.linked_accounts_by_platform || {})
.sort(([, a], [, b]) => b - a)
.map(([platform, count]) => (
<div key={platform} className="bg-zinc-800 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-[#fbeb0a]">{count}</div>
<div className="text-sm text-zinc-400">{PLATFORMS[platform] || platform}</div>
</div>
))}
</div>
</div>
)}
{/* Tips Sent */}
<div className="bg-zinc-900 rounded-xl p-6">
<h2 className="text-lg font-semibold mb-4">Tips Sent</h2>
<div className="text-center">
<div className="text-3xl font-bold text-[#fbeb0a]">
{stats?.total_tips_sent.toLocaleString()}
</div>
<div className="text-sm text-zinc-400 mt-1">Total tips created</div>
</div>
</div>
{/* Back link */}
<div className="mt-8 text-center">
<Link href="/" className="text-zinc-500 hover:text-[#fbeb0a] transition-colors">
&larr; Back to home
</Link>
</div>
</div>
);
}
+21
View File
@@ -406,3 +406,24 @@ export async function getUserCount(): Promise<UserCountResponse> {
return res.json();
}
// =============================================================================
// Public Stats
// =============================================================================
export interface PublicStats {
total_users: number;
users_by_preferred_asset: Record<string, number>;
linked_accounts_by_platform: Record<string, number>;
total_tips_sent: number;
}
export async function getPublicStats(): Promise<PublicStats> {
const res = await fetch(`${API_URL}/api/v1/stats/public`);
if (!res.ok) {
throw new Error('Failed to get stats');
}
return res.json();
}