335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import Link from "next/link";
|
||
import Navbar from "@/components/Navbar";
|
||
import { useAccount } from "@/contexts/AccountContext";
|
||
import { TOOLS_CATALOG, CATEGORY_LABELS, type Tool, type ToolCategory } from "@/lib/toolsCatalog";
|
||
|
||
type ServerBalance = {
|
||
voidCredits: number;
|
||
unlockedToolIds: string[];
|
||
};
|
||
|
||
const CATEGORY_ORDER: ToolCategory[] = ["opsec", "crypto", "network", "intel", "comms", "identity"];
|
||
|
||
const CATEGORY_ICONS: Record<ToolCategory, string> = {
|
||
opsec: "🛡️",
|
||
crypto: "🔐",
|
||
network: "🌐",
|
||
intel: "🔍",
|
||
comms: "📡",
|
||
identity: "🪪",
|
||
};
|
||
|
||
function VoidBadge({ credits }: { credits: number }) {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 rounded-full border border-neon-purple/40 bg-neon-purple/10 px-3 py-1 font-mono text-sm text-neon-purple">
|
||
✦ {credits.toLocaleString()} VOID
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ToolCard({
|
||
tool,
|
||
unlocked,
|
||
voidBalance,
|
||
onUnlock,
|
||
unlocking,
|
||
}: {
|
||
tool: Tool;
|
||
unlocked: boolean;
|
||
voidBalance: number;
|
||
onUnlock: (tool: Tool) => void;
|
||
unlocking: boolean;
|
||
}) {
|
||
const canAfford = voidBalance >= tool.cost;
|
||
|
||
return (
|
||
<div
|
||
className={`relative flex flex-col rounded-xl border p-5 transition-all duration-200 ${
|
||
unlocked
|
||
? "border-neon-cyan/40 bg-neon-cyan/5"
|
||
: tool.comingSoon
|
||
? "border-zinc-700/40 bg-black/30 opacity-60"
|
||
: canAfford
|
||
? "border-zinc-600/40 bg-black/40 hover:border-neon-purple/50"
|
||
: "border-zinc-700/40 bg-black/30"
|
||
}`}
|
||
>
|
||
{unlocked && (
|
||
<div className="absolute right-3 top-3 rounded-full bg-neon-cyan/15 px-2 py-0.5 text-[10px] font-bold tracking-widest text-neon-cyan">
|
||
UNLOCKED
|
||
</div>
|
||
)}
|
||
{tool.comingSoon && !unlocked && (
|
||
<div className="absolute right-3 top-3 rounded-full bg-zinc-700/50 px-2 py-0.5 text-[10px] font-bold tracking-widest text-zinc-400">
|
||
SOON
|
||
</div>
|
||
)}
|
||
|
||
<div className="mb-3 flex items-start gap-3">
|
||
<span className="text-3xl">{tool.icon}</span>
|
||
<div className="min-w-0">
|
||
<p className="font-mono text-[10px] uppercase tracking-widest text-neon-purple/60">
|
||
{CATEGORY_ICONS[tool.category]} {CATEGORY_LABELS[tool.category]}
|
||
</p>
|
||
<h3 className="font-orbitron mt-0.5 text-base font-bold text-foreground">{tool.name}</h3>
|
||
<p className="mt-0.5 text-xs text-foreground/50">{tool.tagline}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="mb-4 flex-1 text-sm text-foreground/70 leading-relaxed">{tool.description}</p>
|
||
|
||
<ul className="mb-5 space-y-1">
|
||
{tool.features.map((f) => (
|
||
<li key={f} className="flex items-center gap-2 text-xs text-foreground/55">
|
||
<span className="text-neon-cyan/70">›</span> {f}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<div className="mt-auto flex items-center justify-between gap-3">
|
||
<span className="font-mono text-sm font-bold text-neon-purple">
|
||
✦ {tool.cost} VOID
|
||
</span>
|
||
{unlocked ? (
|
||
<Link
|
||
href={`/tools/${tool.id}`}
|
||
className="rounded-lg bg-neon-cyan/15 px-4 py-2 text-xs font-bold text-neon-cyan transition hover:bg-neon-cyan/25"
|
||
>
|
||
Open →
|
||
</Link>
|
||
) : tool.comingSoon ? (
|
||
<button disabled className="cursor-not-allowed rounded-lg bg-zinc-700/40 px-4 py-2 text-xs text-zinc-500">
|
||
Coming soon
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => onUnlock(tool)}
|
||
disabled={!canAfford || unlocking}
|
||
className={`rounded-lg px-4 py-2 text-xs font-bold transition ${
|
||
canAfford
|
||
? "bg-neon-purple/20 text-neon-purple hover:bg-neon-purple/35 border border-neon-purple/30"
|
||
: "cursor-not-allowed bg-zinc-800/50 text-zinc-500 border border-zinc-700/30"
|
||
}`}
|
||
>
|
||
{unlocking ? "Unlocking…" : canAfford ? "Unlock" : "Need more VOID"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ToolsPage() {
|
||
const { user, hydrated } = useAccount();
|
||
const [balance, setBalance] = useState<ServerBalance | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [unlocking, setUnlocking] = useState<string | null>(null);
|
||
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
|
||
const [filter, setFilter] = useState<ToolCategory | "all">("all");
|
||
|
||
const fetchBalance = useCallback(async () => {
|
||
if (!user?.username) return;
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(`/api/credits/balance?handle=${encodeURIComponent(user.username)}`);
|
||
const data = (await res.json()) as { ok: boolean; voidCredits: number; unlockedToolIds: string[] };
|
||
if (data.ok) setBalance({ voidCredits: data.voidCredits, unlockedToolIds: data.unlockedToolIds });
|
||
} catch { /* ignore */ } finally {
|
||
setLoading(false);
|
||
}
|
||
}, [user?.username]);
|
||
|
||
useEffect(() => { void fetchBalance(); }, [fetchBalance]);
|
||
|
||
const showToast = (msg: string, ok: boolean) => {
|
||
setToast({ msg, ok });
|
||
setTimeout(() => setToast(null), 4000);
|
||
};
|
||
|
||
const handleUnlock = async (tool: Tool) => {
|
||
if (!user?.username || !balance) return;
|
||
setUnlocking(tool.id);
|
||
try {
|
||
const res = await fetch("/api/tools/unlock", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ handle: user.username, toolId: tool.id }),
|
||
});
|
||
const data = (await res.json()) as { ok: boolean; error?: string; voidBalance?: number; voidSpent?: number };
|
||
if (data.ok) {
|
||
setBalance((prev) =>
|
||
prev
|
||
? {
|
||
voidCredits: data.voidBalance ?? prev.voidCredits - tool.cost,
|
||
unlockedToolIds: [...prev.unlockedToolIds, tool.id],
|
||
}
|
||
: prev,
|
||
);
|
||
showToast(`✦ ${tool.name} unlocked! ${tool.cost} VOID spent.`, true);
|
||
} else {
|
||
showToast(data.error ?? "Unlock failed", false);
|
||
}
|
||
} catch {
|
||
showToast("Network error", false);
|
||
} finally {
|
||
setUnlocking(null);
|
||
}
|
||
};
|
||
|
||
const filtered = filter === "all" ? TOOLS_CATALOG : TOOLS_CATALOG.filter((t) => t.category === filter);
|
||
|
||
if (!hydrated) return null;
|
||
|
||
return (
|
||
<>
|
||
<Navbar />
|
||
<main className="min-h-screen bg-[#0a0a0a] pt-24 pb-20">
|
||
{/* Header */}
|
||
<section className="container mx-auto max-w-6xl px-4 mb-12">
|
||
<p className="mb-2 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-purple/70">
|
||
access-gated · void credits required
|
||
</p>
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<h1 className="font-orbitron text-4xl font-bold md:text-5xl">
|
||
VOID<span className="text-neon-purple"> TOOLS</span>
|
||
</h1>
|
||
<p className="mt-3 max-w-xl text-foreground/60">
|
||
Premium privacy, OPSEC, and intelligence tools. Unlock permanently with VOID credits earned from BTC deposits. No subscriptions — own it forever.
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col items-end gap-2">
|
||
{user ? (
|
||
<>
|
||
<VoidBadge credits={balance?.voidCredits ?? 0} />
|
||
{loading && <p className="text-xs text-foreground/40">Syncing…</p>}
|
||
<Link href="/account/add-funds" className="text-xs text-neon-cyan/70 underline hover:text-neon-cyan">
|
||
+ Deposit BTC for VOID credits
|
||
</Link>
|
||
</>
|
||
) : (
|
||
<Link
|
||
href="/sign-in"
|
||
className="rounded-lg border border-neon-purple/40 bg-neon-purple/10 px-5 py-2.5 text-sm font-bold text-neon-purple hover:bg-neon-purple/20"
|
||
>
|
||
Sign in to unlock tools →
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Category filter */}
|
||
<section className="container mx-auto max-w-6xl px-4 mb-8">
|
||
<div className="flex flex-wrap gap-2">
|
||
<button
|
||
onClick={() => setFilter("all")}
|
||
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition border ${
|
||
filter === "all"
|
||
? "border-neon-purple/60 bg-neon-purple/20 text-neon-purple"
|
||
: "border-zinc-700 bg-zinc-900 text-foreground/60 hover:border-zinc-500"
|
||
}`}
|
||
>
|
||
All
|
||
</button>
|
||
{CATEGORY_ORDER.map((cat) => (
|
||
<button
|
||
key={cat}
|
||
onClick={() => setFilter(cat)}
|
||
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition border ${
|
||
filter === cat
|
||
? "border-neon-purple/60 bg-neon-purple/20 text-neon-purple"
|
||
: "border-zinc-700 bg-zinc-900 text-foreground/60 hover:border-zinc-500"
|
||
}`}
|
||
>
|
||
{CATEGORY_ICONS[cat]} {CATEGORY_LABELS[cat]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* How VOID credits work */}
|
||
{!user && (
|
||
<section className="container mx-auto max-w-6xl px-4 mb-10">
|
||
<div className="rounded-xl border border-neon-purple/20 bg-neon-purple/5 p-6">
|
||
<h2 className="font-orbitron mb-3 text-lg font-bold text-neon-purple">How VOID Credits Work</h2>
|
||
<div className="grid gap-4 sm:grid-cols-3 text-sm text-foreground/70">
|
||
<div>
|
||
<p className="mb-1 font-bold text-foreground">1. Deposit BTC</p>
|
||
<p>Go to Add Funds. BTCPay generates a unique BTC address. Send any amount.</p>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 font-bold text-foreground">2. Receive VOID</p>
|
||
<p>After 1 confirmation, credits appear instantly. 1000 sats ≈ 1 VOID credit.</p>
|
||
</div>
|
||
<div>
|
||
<p className="mb-1 font-bold text-foreground">3. Unlock Forever</p>
|
||
<p>Spend VOID to unlock tools permanently. Credits are server-side — work on any device.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Tools grid */}
|
||
<section className="container mx-auto max-w-6xl px-4">
|
||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||
{filtered.map((tool) => (
|
||
<ToolCard
|
||
key={tool.id}
|
||
tool={tool}
|
||
unlocked={balance?.unlockedToolIds.includes(tool.id) ?? false}
|
||
voidBalance={balance?.voidCredits ?? 0}
|
||
onUnlock={handleUnlock}
|
||
unlocking={unlocking === tool.id}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Stats bar */}
|
||
<section className="container mx-auto max-w-6xl px-4 mt-16">
|
||
<div className="rounded-xl border border-zinc-800 bg-black/40 p-5 flex flex-wrap gap-6 text-sm">
|
||
<div>
|
||
<p className="text-foreground/40 text-xs font-mono mb-1">TOTAL TOOLS</p>
|
||
<p className="font-bold text-foreground">{TOOLS_CATALOG.filter((t) => !t.comingSoon).length} available</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-foreground/40 text-xs font-mono mb-1">YOUR UNLOCKED</p>
|
||
<p className="font-bold text-neon-cyan">{balance?.unlockedToolIds.length ?? 0}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-foreground/40 text-xs font-mono mb-1">VOID BALANCE</p>
|
||
<p className="font-bold text-neon-purple">✦ {(balance?.voidCredits ?? 0).toLocaleString()}</p>
|
||
</div>
|
||
<div className="ml-auto flex items-center">
|
||
<Link
|
||
href="/account/add-funds"
|
||
className="rounded-lg bg-gradient-to-r from-neon-cyan to-neon-purple px-5 py-2.5 text-sm font-bold text-background hover:opacity-90"
|
||
>
|
||
+ Add VOID Credits
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
{/* Toast */}
|
||
{toast && (
|
||
<div
|
||
className={`fixed bottom-6 right-6 z-50 rounded-xl border px-5 py-3 text-sm font-semibold shadow-xl ${
|
||
toast.ok
|
||
? "border-neon-cyan/40 bg-black/90 text-neon-cyan"
|
||
: "border-red-500/40 bg-black/90 text-red-400"
|
||
}`}
|
||
>
|
||
{toast.msg}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|