Replace remote with local repo
This commit is contained in:
334
app/tools/page.tsx
Normal file
334
app/tools/page.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
"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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user