Files
dark-lord/app/account/add-funds/page.tsx
drjones 348e55b63e Add proper deposit desk: multi-coin, clear custody model, DepositWidget
- /account/add-funds: full rebuild as Deposit Desk
  - 3-step how-it-works explainer: you send crypto → we hold it →
    you shop with USD credit → we pay vendors with your crypto
  - Coin selector: BTC (auto-verify), ETH (manual review), XMR (manual review)
  - Each coin shows deposit address from env var with copy button
  - BTC: immediate on-chain verify via /api/btc/verify (mempool.space)
  - ETH/XMR: submit txhash to /api/pending-deposit for manual review
  - Pending deposit history stored in localStorage with status
  - Important notes block explaining custody, no-withdrawal, confirmations
  - Back-links to checkout, wallets, dashboard

- /api/pending-deposit: new route to log ETH/XMR manual review requests
  (logs to console, ready to wire to DB/webhook)

- DepositWidget component: reusable compact + full variants
  - compact: balance + "Deposit crypto →" CTA (used in checkout)
  - full: balance + how-it-works summary (used in dashboard)

- Dashboard: replace balance section with DepositWidget + stats
- Checkout: add compact DepositWidget above CheckoutFlow, fix back link
  from "Sanctuary" → "Market", update feature cards to be accurate

- .env.example: document all three coin address env vars + optional
  payment processor URL

- .env.example: add NEXT_PUBLIC_ETH_ADDRESS, NEXT_PUBLIC_XMR_ADDRESS

Made-with: Cursor
2026-04-16 01:03:14 -07:00

478 lines
19 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 { FormEvent, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Navbar from "@/components/Navbar";
import { useAccount } from "@/contexts/AccountContext";
import { useWallet } from "@/contexts/WalletContext";
import { getMerchantBtcAddress, isMerchantBtcConfigured } from "@/lib/merchantBtc";
type Coin = "BTC" | "ETH" | "XMR";
type PendingDeposit = {
id: string;
coin: Coin;
txid: string;
ts: number;
status: "pending" | "credited";
ref?: string;
};
const PENDING_KEY = "cyberlux-pending-deposits-v1";
function loadPending(): PendingDeposit[] {
if (typeof window === "undefined") return [];
try { return JSON.parse(localStorage.getItem(PENDING_KEY) ?? "[]") as PendingDeposit[]; }
catch { return []; }
}
function savePending(d: PendingDeposit[]) {
if (typeof window !== "undefined") localStorage.setItem(PENDING_KEY, JSON.stringify(d));
}
function shortId() {
return Math.random().toString(36).slice(2, 10).toUpperCase();
}
const COIN_COLORS: Record<Coin, string> = {
BTC: "text-amber-400",
ETH: "text-blue-400",
XMR: "text-orange-400",
};
const COIN_BORDER: Record<Coin, string> = {
BTC: "border-amber-500/30",
ETH: "border-blue-500/30",
XMR: "border-orange-500/30",
};
const COIN_GLOW: Record<Coin, string> = {
BTC: "bg-amber-500/10",
ETH: "bg-blue-500/10",
XMR: "bg-orange-500/10",
};
export default function AddFundsPage() {
const router = useRouter();
const { user, hydrated } = useAccount();
const { usdStoreCredit, luxCredits, verifyBtcDeposit } = useWallet();
const [activeCoin, setActiveCoin] = useState<Coin>("BTC");
const [txid, setTxid] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err" | "pending"; text: string; ref?: string } | null>(null);
const [pendingDeposits, setPendingDeposits] = useState<PendingDeposit[]>([]);
const [copied, setCopied] = useState<Coin | null>(null);
const processorUrl = (process.env.NEXT_PUBLIC_BITCOIN_CHECKOUT_URL || "").trim();
const btcAddr = getMerchantBtcAddress();
const btcReady = isMerchantBtcConfigured();
const ethAddr = (process.env.NEXT_PUBLIC_ETH_ADDRESS || "").trim();
const xmrAddr = (process.env.NEXT_PUBLIC_XMR_ADDRESS || "").trim();
const addresses: Record<Coin, string> = {
BTC: btcAddr,
ETH: ethAddr,
XMR: xmrAddr,
};
const addrReady: Record<Coin, boolean> = {
BTC: btcReady,
ETH: Boolean(ethAddr),
XMR: Boolean(xmrAddr),
};
useEffect(() => {
if (hydrated && !user) router.replace("/sign-in?next=/account/add-funds");
}, [hydrated, user, router]);
useEffect(() => {
setPendingDeposits(loadPending());
}, []);
const copyAddr = async (coin: Coin) => {
const addr = addresses[coin];
if (!addr) return;
try {
await navigator.clipboard.writeText(addr);
setCopied(coin);
setTimeout(() => setCopied(null), 1800);
} catch { /* ignore */ }
};
const onVerifyBtc = async (e: FormEvent) => {
e.preventDefault();
if (!txid.trim()) return;
setMsg(null);
setBusy(true);
try {
const res = await verifyBtcDeposit(txid.trim());
if (res.ok) {
setMsg({
kind: "ok",
text: `$${(res.creditedUsd ?? 0).toFixed(2)} USD credited to @${user!.username}. Balance updated.`,
});
setTxid("");
} else {
setMsg({ kind: "err", text: res.error || "Verification failed. Check the txid and try again." });
}
} finally {
setBusy(false);
}
};
const onSubmitManual = async (e: FormEvent) => {
e.preventDefault();
if (!txid.trim() || !user) return;
setMsg(null);
setBusy(true);
try {
const res = await fetch("/api/pending-deposit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ coin: activeCoin, txid: txid.trim(), handle: user.username }),
});
const json = (await res.json()) as { ok: boolean; message?: string; ref?: string; error?: string };
if (json.ok) {
const entry: PendingDeposit = {
id: shortId(),
coin: activeCoin,
txid: txid.trim(),
ts: Date.now(),
status: "pending",
ref: json.ref,
};
const updated = [entry, ...pendingDeposits];
setPendingDeposits(updated);
savePending(updated);
setMsg({ kind: "pending", text: json.message ?? "Deposit request submitted for manual review.", ref: json.ref });
setTxid("");
} else {
setMsg({ kind: "err", text: json.error ?? "Submission failed." });
}
} catch {
setMsg({ kind: "err", text: "Network error. Try again." });
} finally {
setBusy(false);
}
};
if (!hydrated || !user) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a] font-mono text-sm text-zinc-500">
Loading
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a] text-zinc-200">
<Navbar />
<div className="mx-auto max-w-3xl px-4 py-28">
{/* Header */}
<div className="mb-10">
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/70">
@{user.username} · deposit desk
</p>
<h1 className="mt-2 font-orbitron text-4xl font-bold">Fund Your Account</h1>
<p className="mt-3 max-w-2xl text-sm text-zinc-400 leading-relaxed">
Send crypto to the address below. We verify the transaction on-chain, convert to USD at current
market rate, and credit your account instantly. <strong className="text-zinc-200">You no longer
hold the crypto</strong> we do. When you make a purchase we pay the vendor with it on your behalf.
</p>
</div>
{/* How it works */}
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
{[
{ step: "1", icon: "₿", title: "You send crypto", body: "Send BTC, ETH, or XMR to our receiving address." },
{ step: "2", icon: "📊", title: "We verify & convert", body: "On-chain confirmation. Credited at live market USD rate." },
{ step: "3", icon: "🛒", title: "You shop", body: "Spend USD credits across every CyberLux surface. We pay vendors in crypto." },
].map((s) => (
<div key={s.step} className="glass rounded-2xl border border-white/10 p-5">
<div className="mb-3 flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-neon-cyan/15 text-sm font-bold text-neon-cyan">
{s.step}
</div>
<span className="text-xl">{s.icon}</span>
</div>
<div className="font-bold text-zinc-100">{s.title}</div>
<p className="mt-1 text-xs text-zinc-500">{s.body}</p>
</div>
))}
</div>
{/* Balance card */}
<div className="glass mb-8 rounded-2xl border border-neon-cyan/20 p-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="text-xs text-zinc-500">Current balance @{user.username}</div>
<div className="mt-1 font-orbitron text-3xl text-neon-cyan">${usdStoreCredit.toFixed(2)} USD</div>
<div className="mt-0.5 text-sm text-neon-green">{luxCredits.toLocaleString()} LUX</div>
</div>
<div className="flex gap-3">
<Link
href="/checkout"
className="rounded-lg border border-neon-cyan/30 px-4 py-2 text-sm text-neon-cyan hover:bg-neon-cyan/10"
>
Spend
</Link>
<Link
href="/wallets"
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:border-white/20"
>
Passes
</Link>
</div>
</div>
</div>
{/* Coin selector */}
<div className="mb-6 flex gap-3">
{(["BTC", "ETH", "XMR"] as Coin[]).map((coin) => (
<button
key={coin}
type="button"
onClick={() => { setActiveCoin(coin); setMsg(null); setTxid(""); }}
className={`flex-1 rounded-xl border py-3 text-sm font-bold uppercase transition-all ${
activeCoin === coin
? `${COIN_BORDER[coin]} ${COIN_GLOW[coin]} ${COIN_COLORS[coin]}`
: "border-white/10 text-zinc-500 hover:border-white/20"
}`}
>
{coin}
</button>
))}
</div>
{/* Deposit address */}
<div className={`glass mb-6 rounded-2xl border p-6 ${COIN_BORDER[activeCoin]}`}>
<div className="mb-4 flex items-center justify-between">
<h2 className={`font-orbitron text-lg ${COIN_COLORS[activeCoin]}`}>
{activeCoin} Deposit Address
</h2>
{activeCoin === "BTC" && (
<span className="rounded-full bg-neon-green/15 px-3 py-1 text-xs font-bold text-neon-green">
Auto-verify
</span>
)}
{(activeCoin === "ETH" || activeCoin === "XMR") && (
<span className="rounded-full bg-amber-500/15 px-3 py-1 text-xs font-bold text-amber-400">
Manual review
</span>
)}
</div>
{!addrReady[activeCoin] ? (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-300">
<p className="font-bold">Address not configured</p>
<p className="mt-1 text-amber-400/70">
Set{" "}
<code className="rounded bg-black/40 px-1">
{activeCoin === "BTC"
? "NEXT_PUBLIC_MERCHANT_BTC_ADDRESS"
: activeCoin === "ETH"
? "NEXT_PUBLIC_ETH_ADDRESS"
: "NEXT_PUBLIC_XMR_ADDRESS"}
</code>{" "}
in <code className="rounded bg-black/40 px-1">.env.local</code> and restart Next.
</p>
</div>
) : (
<>
<div
className={`mb-3 rounded-xl border bg-black/60 p-4 font-mono text-sm break-all ${COIN_BORDER[activeCoin]} ${COIN_COLORS[activeCoin]}`}
>
{addresses[activeCoin]}
</div>
<button
type="button"
onClick={() => void copyAddr(activeCoin)}
className={`w-full rounded-xl border py-3 text-sm font-bold uppercase transition-all ${COIN_BORDER[activeCoin]} ${COIN_GLOW[activeCoin]} ${COIN_COLORS[activeCoin]} hover:opacity-90`}
>
{copied === activeCoin ? "✓ Address copied" : `Copy ${activeCoin} address`}
</button>
{activeCoin === "BTC" && (
<div className="mt-2 text-xs text-zinc-600">
Send any amount · 1 confirmation required · credited at CoinGecko spot rate
</div>
)}
{activeCoin === "ETH" && (
<div className="mt-2 text-xs text-zinc-600">
Send ERC-20 or native ETH · submit txhash below for manual review · credited within 224h
</div>
)}
{activeCoin === "XMR" && (
<div className="mt-2 text-xs text-zinc-600">
Monero transactions take ~20 min · submit txid below for manual review · credited within 224h
</div>
)}
</>
)}
</div>
{/* Optional hosted processor (BTCPay etc) */}
{activeCoin === "BTC" && processorUrl && (
<div className="glass mb-6 rounded-2xl border border-neon-purple/25 p-5">
<div className="flex items-center justify-between gap-4">
<div>
<div className="font-bold text-neon-purple">Payment Processor</div>
<p className="mt-1 text-xs text-zinc-500">
Pay through the hosted checkout page (BTCPay or custom processor).
</p>
</div>
<a
href={processorUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-xl bg-gradient-to-r from-neon-purple to-neon-pink px-5 py-3 text-sm font-bold text-white hover:opacity-90"
>
Open
</a>
</div>
</div>
)}
{/* Verify / submit form */}
{addrReady[activeCoin] && (
<div className="glass rounded-2xl border border-white/10 p-6">
<h3 className="mb-1 font-orbitron text-base">
{activeCoin === "BTC" ? "Verify BTC Transaction" : `Submit ${activeCoin} Deposit for Review`}
</h3>
<p className="mb-5 text-xs text-zinc-500">
{activeCoin === "BTC"
? "After at least 1 confirmation, paste the 64-character transaction ID. We check mempool.space and credit immediately."
: `After sending, paste the transaction hash. Our team verifies on-chain and credits your @${user.username} account. You'll see it in your pending history below.`}
</p>
<form
onSubmit={(e) => activeCoin === "BTC" ? void onVerifyBtc(e) : void onSubmitManual(e)}
className="space-y-4"
>
<input
value={txid}
onChange={(e) => setTxid(e.target.value.trim())}
placeholder={
activeCoin === "BTC"
? "Bitcoin txid — 64 hex characters"
: activeCoin === "ETH"
? "Ethereum txhash — 0x…"
: "Monero txid"
}
className="w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm placeholder-zinc-600 focus:border-neon-cyan/40 focus:outline-none"
autoComplete="off"
maxLength={100}
/>
<button
type="submit"
disabled={busy || !txid.trim()}
className="w-full rounded-xl bg-gradient-to-r from-neon-cyan to-neon-purple py-3 text-sm font-bold text-background disabled:opacity-40"
>
{busy
? "Verifying…"
: activeCoin === "BTC"
? "Verify & Credit USD Now"
: `Submit ${activeCoin} for Review`}
</button>
</form>
{msg && (
<div
className={`mt-4 rounded-xl border p-4 text-sm ${
msg.kind === "ok"
? "border-neon-green/40 bg-neon-green/10 text-neon-green"
: msg.kind === "pending"
? "border-amber-500/40 bg-amber-500/10 text-amber-300"
: "border-red-500/40 bg-red-500/10 text-red-400"
}`}
>
<p>{msg.text}</p>
{msg.ref && (
<p className="mt-1 font-mono text-xs opacity-70">Reference: {msg.ref}</p>
)}
</div>
)}
</div>
)}
{/* Pending history */}
{pendingDeposits.length > 0 && (
<div className="mt-8">
<h3 className="mb-4 font-orbitron text-sm text-zinc-400">Pending Manual Reviews</h3>
<div className="space-y-3">
{pendingDeposits.map((d) => (
<div
key={d.id}
className="glass flex items-center justify-between gap-4 rounded-xl border border-white/10 px-4 py-3 text-sm"
>
<div>
<span className={`font-bold ${COIN_COLORS[d.coin]}`}>{d.coin}</span>
<span className="ml-3 font-mono text-xs text-zinc-500">
{d.txid.slice(0, 12)}{d.txid.slice(-8)}
</span>
{d.ref && (
<span className="ml-2 text-[10px] text-zinc-600">ref: {d.ref}</span>
)}
</div>
<div className="flex items-center gap-2">
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase ${
d.status === "credited"
? "bg-neon-green/20 text-neon-green"
: "bg-amber-500/20 text-amber-400"
}`}
>
{d.status}
</span>
<span className="text-xs text-zinc-600">{new Date(d.ts).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Important notes */}
<div className="mt-10 rounded-2xl border border-white/10 bg-white/[0.02] p-6 text-sm">
<div className="mb-3 font-bold text-zinc-200">Important Read Before Depositing</div>
<ul className="space-y-2 text-zinc-500">
<li className="flex items-start gap-2">
<span className="mt-0.5 shrink-0 text-neon-cyan"></span>
Always double-check the address against the one shown here before sending. Verify on each new session.
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 shrink-0 text-neon-cyan"></span>
BTC deposits are credited automatically after 1 confirmation (~10 min). ETH and XMR require manual review (224h).
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 shrink-0 text-neon-cyan"></span>
Your USD credit balance is stored per-handle in your browser. Export your account bundle from
{" "}<Link href="/dashboard" className="text-neon-cyan hover:underline">/dashboard</Link>{" "}
before clearing site data.
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 shrink-0 text-neon-cyan"></span>
Once credited, funds cannot be withdrawn they are USD store credit for purchases only.
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 shrink-0 text-neon-cyan"></span>
Minimum deposit: no minimum enforced, but small amounts may be consumed by exchange rate rounding.
</li>
</ul>
</div>
<div className="mt-8 flex flex-wrap justify-center gap-4 text-xs text-zinc-600">
<Link href="/dashboard" className="text-neon-cyan hover:underline">Dashboard</Link>
<Link href="/checkout" className="hover:text-zinc-400">Checkout</Link>
<Link href="/wallets" className="hover:text-zinc-400">Digital Passes</Link>
<Link href="/account/hidden-services" className="hover:text-zinc-400">Cross-onion identity</Link>
<Link href="/" className="hover:text-zinc-400">Hub</Link>
</div>
</div>
</div>
);
}