Files
dark-lord/app/tools/page.tsx
2026-04-26 22:28:40 -07:00

335 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)}
</>
);
}