diff --git a/app/account/add-funds/page.tsx b/app/account/add-funds/page.tsx index 0b8d2d0..c26b9d1 100644 --- a/app/account/add-funds/page.tsx +++ b/app/account/add-funds/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Navbar from "@/components/Navbar"; @@ -8,157 +8,292 @@ import { useAccount } from "@/contexts/AccountContext"; import { useWallet } from "@/contexts/WalletContext"; import { getMerchantBtcAddress, isMerchantBtcConfigured } from "@/lib/merchantBtc"; -type Coin = "BTC" | "ETH" | "XMR"; +// ─── Types ───────────────────────────────────────────────────────────────── -type PendingDeposit = { +type Tab = "BTCPAY" | "BTC" | "ETH" | "XMR"; + +type BtcPayInvoiceLocal = { + invoiceId: string; + handle: string; + usdAmount: number; + btcAddress: string | null; + btcAmount: string | null; + btcRate: string | null; + status: "New" | "Processing" | "Expired" | "Invalid" | "Settled"; + createdAt: number; + credited: boolean; +}; + +type ManualDeposit = { id: string; - coin: Coin; + coin: "ETH" | "XMR"; txid: string; ts: number; status: "pending" | "credited"; ref?: string; }; -const PENDING_KEY = "cyberlux-pending-deposits-v1"; +// ─── localStorage helpers ─────────────────────────────────────────────────── -function loadPending(): PendingDeposit[] { +const INVOICE_KEY = "cyberlux-btcpay-invoices-v1"; +const MANUAL_KEY = "cyberlux-pending-deposits-v1"; + +function loadInvoices(): BtcPayInvoiceLocal[] { if (typeof window === "undefined") return []; - try { return JSON.parse(localStorage.getItem(PENDING_KEY) ?? "[]") as PendingDeposit[]; } + try { return JSON.parse(localStorage.getItem(INVOICE_KEY) ?? "[]") as BtcPayInvoiceLocal[]; } catch { return []; } } - -function savePending(d: PendingDeposit[]) { - if (typeof window !== "undefined") localStorage.setItem(PENDING_KEY, JSON.stringify(d)); +function saveInvoices(d: BtcPayInvoiceLocal[]) { + if (typeof window !== "undefined") localStorage.setItem(INVOICE_KEY, JSON.stringify(d)); } - -function shortId() { - return Math.random().toString(36).slice(2, 10).toUpperCase(); +function loadManual(): ManualDeposit[] { + if (typeof window === "undefined") return []; + try { return JSON.parse(localStorage.getItem(MANUAL_KEY) ?? "[]") as ManualDeposit[]; } + catch { return []; } } +function saveManual(d: ManualDeposit[]) { + if (typeof window !== "undefined") localStorage.setItem(MANUAL_KEY, JSON.stringify(d)); +} +function shortId() { return Math.random().toString(36).slice(2, 10).toUpperCase(); } -const COIN_COLORS: Record = { - BTC: "text-amber-400", - ETH: "text-blue-400", - XMR: "text-orange-400", +// ─── Style maps ──────────────────────────────────────────────────────────── + +const TAB_COLOR: Record = { + BTCPAY: "text-neon-purple", + BTC: "text-amber-400", + ETH: "text-blue-400", + XMR: "text-orange-400", +}; +const TAB_BORDER: Record = { + BTCPAY: "border-neon-purple/30", + BTC: "border-amber-500/30", + ETH: "border-blue-500/30", + XMR: "border-orange-500/30", +}; +const TAB_BG: Record = { + BTCPAY: "bg-neon-purple/10", + BTC: "bg-amber-500/10", + ETH: "bg-blue-500/10", + XMR: "bg-orange-500/10", }; -const COIN_BORDER: Record = { - BTC: "border-amber-500/30", - ETH: "border-blue-500/30", - XMR: "border-orange-500/30", -}; - -const COIN_GLOW: Record = { - BTC: "bg-amber-500/10", - ETH: "bg-blue-500/10", - XMR: "bg-orange-500/10", -}; +// ─── Component ───────────────────────────────────────────────────────────── export default function AddFundsPage() { - const router = useRouter(); - const { user, hydrated } = useAccount(); - const { usdStoreCredit, luxCredits, verifyBtcDeposit } = useWallet(); + const router = useRouter(); + const { user, hydrated } = useAccount(); + const { usdStoreCredit, luxCredits, verifyBtcDeposit, creditBtcPayInvoice } = useWallet(); - const [activeCoin, setActiveCoin] = useState("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([]); - const [copied, setCopied] = useState(null); + const btcPayEnabled = (process.env.NEXT_PUBLIC_BTCPAY_ENABLED ?? "").toLowerCase() === "true"; - const processorUrl = (process.env.NEXT_PUBLIC_BITCOIN_CHECKOUT_URL || "").trim(); + const [activeTab, setActiveTab] = useState(btcPayEnabled ? "BTCPAY" : "BTC"); + const [copied, setCopied] = useState(null); + + // ── BTCPay state ────────────────────────────────────────────────────────── + const [desiredUsd, setDesiredUsd] = useState("25"); + const [bpBusy, setBpBusy] = useState(false); + const [bpError, setBpError] = useState(null); + const [activeInvoice, setActiveInvoice] = useState(null); + const [invoiceHistory, setInvoiceHistory] = useState([]); + const [bpSuccess, setBpSuccess] = useState(null); + const pollRef = useRef | null>(null); + + // ── Manual BTC verify state ─────────────────────────────────────────────── + const [btcTxid, setBtcTxid] = useState(""); + const [btcBusy, setBtcBusy] = useState(false); + const [btcMsg, setBtcMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + + // ── ETH / XMR manual state ──────────────────────────────────────────────── + const [manualTxid, setManualTxid] = useState(""); + const [manualBusy, setManualBusy] = useState(false); + const [manualMsg, setManualMsg] = useState<{ kind: "ok" | "err" | "pending"; text: string; ref?: string } | null>(null); + const [manualHistory, setManualHistory] = useState([]); 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 = { - BTC: btcAddr, - ETH: ethAddr, - XMR: xmrAddr, - }; - - const addrReady: Record = { - BTC: btcReady, - ETH: Boolean(ethAddr), - XMR: Boolean(xmrAddr), - }; + const ethAddr = (process.env.NEXT_PUBLIC_ETH_ADDRESS || "").trim(); + const xmrAddr = (process.env.NEXT_PUBLIC_XMR_ADDRESS || "").trim(); + // ── Auth guard ──────────────────────────────────────────────────────────── useEffect(() => { if (hydrated && !user) router.replace("/sign-in?next=/account/add-funds"); }, [hydrated, user, router]); + // ── Load local state ────────────────────────────────────────────────────── useEffect(() => { - setPendingDeposits(loadPending()); + setInvoiceHistory(loadInvoices()); + setManualHistory(loadManual()); }, []); - const copyAddr = async (coin: Coin) => { - const addr = addresses[coin]; - if (!addr) return; + // ── Poll active BTCPay invoice ──────────────────────────────────────────── + const stopPoll = useCallback(() => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + }, []); + + const pollInvoice = useCallback( + async (invoice: BtcPayInvoiceLocal) => { + if (invoice.credited || invoice.status === "Settled" || invoice.status === "Expired" || invoice.status === "Invalid") { + stopPoll(); + return; + } + try { + const res = await fetch(`/api/btcpay/status/${invoice.invoiceId}`); + const data = (await res.json()) as { + ok: boolean; + status?: string; + usdAmount?: number; + btcAddress?: string | null; + btcAmount?: string | null; + btcRate?: string | null; + settled?: boolean; + error?: string; + }; + if (!data.ok) return; + + const newStatus = (data.status ?? invoice.status) as BtcPayInvoiceLocal["status"]; + + // Update address/amount if BTCPay just returned them (first poll after creation) + const updated: BtcPayInvoiceLocal = { + ...invoice, + status: newStatus, + btcAddress: data.btcAddress ?? invoice.btcAddress, + btcAmount: data.btcAmount ?? invoice.btcAmount, + btcRate: data.btcRate ?? invoice.btcRate, + credited: invoice.credited, + }; + + if (data.settled && !invoice.credited && user) { + const result = creditBtcPayInvoice(invoice.invoiceId, invoice.usdAmount); + if (result.ok) { + updated.credited = true; + setBpSuccess(`✓ $${invoice.usdAmount.toFixed(2)} USD credited to @${user.username}!`); + } + stopPoll(); + } + + if (newStatus === "Expired" || newStatus === "Invalid") stopPoll(); + + setActiveInvoice(updated); + setInvoiceHistory((prev) => { + const next = prev.map((inv) => inv.invoiceId === updated.invoiceId ? updated : inv); + if (!next.find((i) => i.invoiceId === updated.invoiceId)) next.unshift(updated); + saveInvoices(next); + return next; + }); + } catch { /* ignore network blips */ } + }, + [user, creditBtcPayInvoice, stopPoll], + ); + + useEffect(() => { + if (!activeInvoice) { stopPoll(); return; } + if (activeInvoice.credited || activeInvoice.status === "Settled" || activeInvoice.status === "Expired") return; + stopPoll(); + pollRef.current = setInterval(() => { void pollInvoice(activeInvoice); }, 10_000); + // also poll immediately to fetch btcAddress on first render + void pollInvoice(activeInvoice); + return stopPoll; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeInvoice?.invoiceId]); + + useEffect(() => { return stopPoll; }, [stopPoll]); + + // ── Handlers ────────────────────────────────────────────────────────────── + + const createInvoice = async (e: FormEvent) => { + e.preventDefault(); + if (!user) return; + const usd = parseFloat(desiredUsd); + if (!Number.isFinite(usd) || usd < 0.5) { setBpError("Enter an amount ≥ $0.50"); return; } + setBpBusy(true); + setBpError(null); + setBpSuccess(null); try { - await navigator.clipboard.writeText(addr); - setCopied(coin); - setTimeout(() => setCopied(null), 1800); - } catch { /* ignore */ } + const res = await fetch("/api/btcpay/invoice", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ usdAmount: usd, handle: user.username }), + }); + const data = (await res.json()) as { + ok: boolean; + invoiceId?: string; + checkoutLink?: string; + status?: string; + expiresAt?: number; + error?: string; + }; + if (!data.ok || !data.invoiceId) { + setBpError(data.error ?? "Failed to create invoice. Check BTCPay config."); + return; + } + const newInv: BtcPayInvoiceLocal = { + invoiceId: data.invoiceId, + handle: user.username, + usdAmount: usd, + btcAddress: null, + btcAmount: null, + btcRate: null, + status: (data.status ?? "New") as BtcPayInvoiceLocal["status"], + createdAt: Date.now(), + credited: false, + }; + setActiveInvoice(newInv); + setInvoiceHistory((prev) => { const n = [newInv, ...prev]; saveInvoices(n); return n; }); + } catch { + setBpError("Network error. Is BTCPay reachable?"); + } finally { + setBpBusy(false); + } + }; + + const copyText = async (text: string, tab: Tab) => { + try { await navigator.clipboard.writeText(text); setCopied(tab); setTimeout(() => setCopied(null), 1800); } + catch { /* ignore */ } }; const onVerifyBtc = async (e: FormEvent) => { e.preventDefault(); - if (!txid.trim()) return; - setMsg(null); - setBusy(true); + if (!btcTxid.trim()) return; + setBtcBusy(true); setBtcMsg(null); try { - const res = await verifyBtcDeposit(txid.trim()); + const res = await verifyBtcDeposit(btcTxid.trim()); if (res.ok) { - setMsg({ - kind: "ok", - text: `✓ $${(res.creditedUsd ?? 0).toFixed(2)} USD credited to @${user!.username}. Balance updated.`, - }); - setTxid(""); + setBtcMsg({ kind: "ok", text: `✓ $${(res.creditedUsd ?? 0).toFixed(2)} USD credited to @${user!.username}.` }); + setBtcTxid(""); } else { - setMsg({ kind: "err", text: res.error || "Verification failed. Check the txid and try again." }); + setBtcMsg({ kind: "err", text: res.error ?? "Verification failed." }); } - } finally { - setBusy(false); - } + } finally { setBtcBusy(false); } }; const onSubmitManual = async (e: FormEvent) => { e.preventDefault(); - if (!txid.trim() || !user) return; - setMsg(null); - setBusy(true); + if (!manualTxid.trim() || !user || (activeTab !== "ETH" && activeTab !== "XMR")) return; + setManualBusy(true); setManualMsg(null); try { - const res = await fetch("/api/pending-deposit", { - method: "POST", + 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 }), + body: JSON.stringify({ coin: activeTab, txid: manualTxid.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 entry: ManualDeposit = { + id: shortId(), coin: activeTab as "ETH" | "XMR", + txid: manualTxid.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(""); + setManualHistory((prev) => { const n = [entry, ...prev]; saveManual(n); return n; }); + setManualMsg({ kind: "pending", text: json.message ?? "Submitted for manual review.", ref: json.ref }); + setManualTxid(""); } else { - setMsg({ kind: "err", text: json.error ?? "Submission failed." }); + setManualMsg({ kind: "err", text: json.error ?? "Submission failed." }); } } catch { - setMsg({ kind: "err", text: "Network error. Try again." }); - } finally { - setBusy(false); - } + setManualMsg({ kind: "err", text: "Network error." }); + } finally { setManualBusy(false); } }; + // ── Loading state ───────────────────────────────────────────────────────── if (!hydrated || !user) { return (
@@ -167,6 +302,8 @@ export default function AddFundsPage() { ); } + const tabs: Tab[] = btcPayEnabled ? ["BTCPAY", "BTC", "ETH", "XMR"] : ["BTC", "ETH", "XMR"]; + return (
@@ -180,22 +317,23 @@ export default function AddFundsPage() {

Fund Your Account

Send crypto to the address below. We verify the transaction on-chain, convert to USD at current - market rate, and credit your account instantly. You no longer - hold the crypto — we do. When you make a purchase we pay the vendor with it on your behalf. + market rate, and credit your account.{" "} + You no longer hold the crypto — we do. + When you purchase, we pay the vendor in crypto on your behalf.

{/* How it works */}
{[ - { 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." }, + { n: "1", icon: "₿", title: "Send crypto", body: "BTC via BTCPay, or BTC/ETH/XMR to a static address." }, + { n: "2", icon: "📊", title: "We verify", body: "On-chain confirmation. Credited at live market USD rate." }, + { n: "3", icon: "🛒", title: "You shop", body: "Spend USD credits site-wide. We pay vendors in crypto." }, ].map((s) => ( -
+
- {s.step} + {s.n}
{s.icon}
@@ -214,220 +352,345 @@ export default function AddFundsPage() {
{luxCredits.toLocaleString()} LUX
- + Spend → - + Passes
- {/* Coin selector */} -
- {(["BTC", "ETH", "XMR"] as Coin[]).map((coin) => ( + {/* Tab selector */} +
+ {tabs.map((tab) => ( ))}
- {/* Deposit address */} -
-
-

- {activeCoin} Deposit Address -

- {activeCoin === "BTC" && ( - - Auto-verify - - )} - {(activeCoin === "ETH" || activeCoin === "XMR") && ( - - Manual review - - )} -
- - {!addrReady[activeCoin] ? ( -
-

Address not configured

-

- Set{" "} - - {activeCoin === "BTC" - ? "NEXT_PUBLIC_MERCHANT_BTC_ADDRESS" - : activeCoin === "ETH" - ? "NEXT_PUBLIC_ETH_ADDRESS" - : "NEXT_PUBLIC_XMR_ADDRESS"} - {" "} - in .env.local and restart Next. + {/* ── BTCPAY TAB ─────────────────────────────────────────────────── */} + {activeTab === "BTCPAY" && ( +

+
+
+

BTCPay Invoice

+ + Auto-settle + +
+

+ Generates a unique Bitcoin address per deposit. BTCPay monitors the chain — your account is + credited automatically the moment the payment confirms. No txid entry needed.

+ + {!activeInvoice || activeInvoice.status === "Expired" || activeInvoice.status === "Invalid" ? ( +
void createInvoice(e)} className="space-y-4"> +
+ +
+ {["10", "25", "50", "100", "250"].map((v) => ( + + ))} +
+ setDesiredUsd(e.target.value)} + className="mt-3 w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm placeholder-zinc-600 focus:border-neon-purple/40 focus:outline-none" + placeholder="Custom amount" + /> +
+ + {bpError && ( +
+ {bpError} +
+ )} +
+ ) : ( + /* Active invoice */ +
+ {/* Status badge */} +
+ + {activeInvoice.status === "New" ? "Waiting for payment…" : activeInvoice.status} + + + ${activeInvoice.usdAmount.toFixed(2)} USD + +
+ + {/* BTC address (fetched from payment-methods on first poll) */} + {activeInvoice.btcAddress ? ( +
+
Send exactly
+
+
+ {activeInvoice.btcAmount ?? "…"} BTC +
+
+
To this address
+
+ {activeInvoice.btcAddress} +
+ + {activeInvoice.btcRate && ( +

+ Rate locked at 1 BTC ≈ ${parseFloat(activeInvoice.btcRate).toLocaleString()} USD +

+ )} +
+ ) : ( +
Fetching payment address…
+ )} + + {/* Success */} + {(bpSuccess || activeInvoice.credited) && ( +
+ {bpSuccess ?? `✓ $${activeInvoice.usdAmount.toFixed(2)} USD credited to @${user.username}`} +
+ )} + + {!activeInvoice.credited && activeInvoice.status !== "Settled" && ( +

+ Polling for confirmation every 10 s. Do not close this tab until confirmed. + This invoice is valid for 60 minutes. +

+ )} + + +
+ )}
- ) : ( - <> -
- {addresses[activeCoin]} -
- - {activeCoin === "BTC" && ( -
- Send any amount · 1 confirmation required · credited at CoinGecko spot rate -
- )} - {activeCoin === "ETH" && ( -
- Send ERC-20 or native ETH · submit txhash below for manual review · credited within 2–24h -
- )} - {activeCoin === "XMR" && ( -
- Monero transactions take ~20 min · submit txid below for manual review · credited within 2–24h -
- )} - - )} -
- - {/* Optional hosted processor (BTCPay etc) */} - {activeCoin === "BTC" && processorUrl && ( -
-
+ {/* Invoice history */} + {invoiceHistory.filter((i) => i.handle === user.username).length > 0 && (
-
Payment Processor
-

- Pay through the hosted checkout page (BTCPay or custom processor). -

-
- - Open → - -
-
- )} - - {/* Verify / submit form */} - {addrReady[activeCoin] && ( -
-

- {activeCoin === "BTC" ? "Verify BTC Transaction" : `Submit ${activeCoin} Deposit for Review`} -

-

- {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.`} -

-
activeCoin === "BTC" ? void onVerifyBtc(e) : void onSubmitManual(e)} - className="space-y-4" - > - 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} - /> - -
- - {msg && ( -
-

{msg.text}

- {msg.ref && ( -

Reference: {msg.ref}

- )} +

Invoice history

+
+ {invoiceHistory.filter((i) => i.handle === user.username).map((inv) => ( +
+
+ {inv.invoiceId.slice(0, 12)}… · ${inv.usdAmount.toFixed(2)} +
+
+ + {inv.credited ? "credited" : inv.status.toLowerCase()} + + {new Date(inv.createdAt).toLocaleDateString()} + {!inv.credited && inv.status !== "Expired" && inv.status !== "Invalid" && inv.status !== "Settled" && ( + + )} +
+
+ ))} +
)}
)} - {/* Pending history */} - {pendingDeposits.length > 0 && ( -
-

Pending Manual Reviews

-
- {pendingDeposits.map((d) => ( -
+ {/* ── BTC TAB (manual txid verify) ──────────────────────────────── */} + {activeTab === "BTC" && ( +
+
+
+

BTC — Manual Verify

+ Auto-credit +
+ {!btcReady ? ( +
+

Address not configured

+

+ Set NEXT_PUBLIC_MERCHANT_BTC_ADDRESS in{" "} + .env.local +

+
+ ) : ( + <> +

+ Send BTC to the static address below, then paste the 64-character transaction ID. + We verify via mempool.space and credit instantly. +

+
+ {btcAddr} +
+ +
void onVerifyBtc(e)} className="space-y-3"> + setBtcTxid(e.target.value.trim())} + placeholder="Paste 64-character Bitcoin txid" + className="w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-xs placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none" + maxLength={64} + /> + +
+ {btcMsg && ( +
+ {btcMsg.text} +
+ )} + + )} +
+
+ )} + + {/* ── ETH / XMR TAB (manual review) ─────────────────────────────── */} + {(activeTab === "ETH" || activeTab === "XMR") && (() => { + const addr = activeTab === "ETH" ? ethAddr : xmrAddr; + const addrReady = Boolean(addr); + const envVar = activeTab === "ETH" ? "NEXT_PUBLIC_ETH_ADDRESS" : "NEXT_PUBLIC_XMR_ADDRESS"; + const note = activeTab === "ETH" + ? "Send ERC-20 or native ETH · submit txhash below · credited within 2–24h after manual review" + : "Monero transactions take ~20 min · submit txid below · credited within 2–24h after manual review"; + return ( +
+
+

{activeTab} Deposit

+ Manual review +
+ {!addrReady ? ( +
+

Address not configured

+

+ Set {envVar} in{" "} + .env.local +

+
+ ) : ( + <> +

{note}

+
+ {addr} +
+ +
void onSubmitManual(e)} className="space-y-3"> + setManualTxid(e.target.value.trim())} + placeholder={activeTab === "ETH" ? "Ethereum txhash — 0x…" : "Monero transaction ID"} + className="w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-xs placeholder-zinc-600 focus:outline-none" + maxLength={100} + /> + +
+ {manualMsg && ( +
+

{manualMsg.text}

+ {manualMsg.ref &&

Ref: {manualMsg.ref}

} +
+ )} + + )} +
+ ); + })()} + + {/* Manual deposit history (ETH/XMR) */} + {manualHistory.length > 0 && (activeTab === "ETH" || activeTab === "XMR") && ( +
+

Manual review history

+
+ {manualHistory.filter((d) => d.coin === activeTab).map((d) => ( +
- {d.coin} - - {d.txid.slice(0, 12)}…{d.txid.slice(-8)} - - {d.ref && ( - ref: {d.ref} - )} + {d.coin} + {d.txid.slice(0, 12)}…{d.txid.slice(-6)} + {d.ref && ref: {d.ref}}
- + {d.status} - {new Date(d.ts).toLocaleDateString()} + {new Date(d.ts).toLocaleDateString()}
))} @@ -439,28 +702,18 @@ export default function AddFundsPage() {
Important — Read Before Depositing
    -
  • - - Always double-check the address against the one shown here before sending. Verify on each new session. -
  • -
  • - - BTC deposits are credited automatically after 1 confirmation (~10 min). ETH and XMR require manual review (2–24h). -
  • -
  • - - Your USD credit balance is stored per-handle in your browser. Export your account bundle from - {" "}/dashboard{" "} - before clearing site data. -
  • -
  • - - Once credited, funds cannot be withdrawn — they are USD store credit for purchases only. -
  • -
  • - - Minimum deposit: no minimum enforced, but small amounts may be consumed by exchange rate rounding. -
  • + {[ + "Always verify the receiving address on each new session before sending.", + "BTCPay invoices expire after 60 minutes. Static BTC deposits are valid until a new address is published.", + "BTC (BTCPay) credits automatically on settlement. Manual ETH/XMR review takes 2–24h.", + "Deposited funds become USD store credit. No withdrawals — credits are for purchases only.", + "Export your account bundle from /dashboard before clearing browser data.", + ].map((note, i) => ( +
  • + + {note} +
  • + ))}
@@ -468,7 +721,6 @@ export default function AddFundsPage() { Dashboard Checkout Digital Passes - Cross-onion identity Hub
diff --git a/app/api/btcpay/invoice/route.ts b/app/api/btcpay/invoice/route.ts new file mode 100644 index 0000000..f723fbe --- /dev/null +++ b/app/api/btcpay/invoice/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { createBtcPayInvoice, isBtcPayConfigured } from "@/lib/btcpay"; + +export async function POST(req: Request) { + if (!isBtcPayConfigured()) { + return NextResponse.json( + { ok: false, error: "BTCPay not configured — set BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID in .env.local" }, + { status: 503 }, + ); + } + + let body: unknown; + try { body = await req.json(); } catch { + return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 }); + } + + const { usdAmount, handle } = (body as Record) ?? {}; + + if (typeof usdAmount !== "number" || usdAmount <= 0 || !Number.isFinite(usdAmount)) { + return NextResponse.json({ ok: false, error: "usdAmount must be a positive number" }, { status: 400 }); + } + if (typeof handle !== "string" || !handle.trim()) { + return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 }); + } + if (usdAmount < 0.5) { + return NextResponse.json({ ok: false, error: "Minimum deposit is $0.50" }, { status: 400 }); + } + if (usdAmount > 50000) { + return NextResponse.json({ ok: false, error: "Maximum single deposit is $50,000" }, { status: 400 }); + } + + const result = await createBtcPayInvoice(usdAmount, handle.trim()); + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 502 }); + } + + return NextResponse.json({ + ok: true, + invoiceId: result.invoice.id, + checkoutLink: result.invoice.checkoutLink, + status: result.invoice.status, + expiresAt: result.invoice.expirationTime, + }); +} diff --git a/app/api/btcpay/status/[invoiceId]/route.ts b/app/api/btcpay/status/[invoiceId]/route.ts new file mode 100644 index 0000000..618d6e9 --- /dev/null +++ b/app/api/btcpay/status/[invoiceId]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { getBtcPayInvoiceStatus, isBtcPayConfigured } from "@/lib/btcpay"; + +export async function GET( + _req: Request, + { params }: { params: { invoiceId: string } }, +) { + if (!isBtcPayConfigured()) { + return NextResponse.json({ ok: false, error: "BTCPay not configured" }, { status: 503 }); + } + + const { invoiceId } = params; + if (!invoiceId || invoiceId.length < 4) { + return NextResponse.json({ ok: false, error: "Invalid invoice ID" }, { status: 400 }); + } + + const result = await getBtcPayInvoiceStatus(invoiceId); + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 502 }); + } + + return NextResponse.json({ + ok: true, + status: result.status, + usdAmount: result.usdAmount, + btcAddress: result.paymentMethod?.destination ?? null, + btcAmount: result.paymentMethod?.due ?? null, + btcRate: result.paymentMethod?.rate ?? null, + totalPaid: result.paymentMethod?.totalPaid ?? null, + settled: result.status === "Settled", + }); +} diff --git a/contexts/WalletContext.tsx b/contexts/WalletContext.tsx index f90e09d..d371279 100644 --- a/contexts/WalletContext.tsx +++ b/contexts/WalletContext.tsx @@ -13,6 +13,7 @@ interface WalletContextType { earnLuxCredits: (amount: number) => void; spendUsdStoreCredit: (amount: number) => boolean; verifyBtcDeposit: (txid: string) => Promise<{ ok: boolean; error?: string; creditedUsd?: number }>; + creditBtcPayInvoice: (invoiceId: string, usdAmount: number) => { ok: boolean; error?: string }; vault: VaultState; collectKey: (key: string) => boolean; addVaultReceipt: (receipt: VaultReceipt) => void; @@ -141,6 +142,23 @@ export const WalletProvider = ({ children }: WalletProviderProps) => { [handle], ); + const creditBtcPayInvoice = useCallback( + (invoiceId: string, usdAmount: number) => { + if (!handle) return { ok: false, error: "Sign in first" }; + const id = String(invoiceId || "").trim(); + if (!id) return { ok: false, error: "Invalid invoice ID" }; + const safe = Number.isFinite(usdAmount) ? Math.max(0, Math.round(usdAmount * 100) / 100) : 0; + if (safe <= 0) return { ok: false, error: "Invalid amount" }; + const row = getLedgerRow(handle); + if (row.claimedTxids.includes(id)) return { ok: false, error: "Invoice already credited" }; + const nextUsd = Math.round((row.usd + safe) * 100) / 100; + setLedgerRow(handle, { usd: nextUsd, lux: row.lux, claimedTxids: [...row.claimedTxids, id] }); + setUsdStoreCredit(nextUsd); + return { ok: true }; + }, + [handle], + ); + const collectKey = (key: string) => { const trimmed = String(key || "").trim(); if (!trimmed) return false; @@ -167,6 +185,7 @@ export const WalletProvider = ({ children }: WalletProviderProps) => { earnLuxCredits, spendUsdStoreCredit, verifyBtcDeposit, + creditBtcPayInvoice, vault, collectKey, addVaultReceipt, @@ -179,6 +198,7 @@ export const WalletProvider = ({ children }: WalletProviderProps) => { earnLuxCredits, spendUsdStoreCredit, verifyBtcDeposit, + creditBtcPayInvoice, vault, ], ); diff --git a/lib/btcpay.ts b/lib/btcpay.ts new file mode 100644 index 0000000..2d6049b --- /dev/null +++ b/lib/btcpay.ts @@ -0,0 +1,149 @@ +/** + * BTCPay Server Greenfield API v1 client. + * + * Env vars required (set in .env.local): + * BTCPAY_URL e.g. http://10.30.20.237 + * BTCPAY_API_KEY generated in BTCPay → Account → Manage account → API keys + * BTCPAY_STORE_ID found in BTCPay → Settings → General (the alphanumeric ID in the URL) + */ + +export type BtcPayInvoiceStatus = + | "New" + | "Processing" + | "Expired" + | "Invalid" + | "Settled"; + +export type BtcPayInvoice = { + id: string; + status: BtcPayInvoiceStatus; + amount: string; + currency: string; + checkoutLink: string; + createdTime: number; + expirationTime: number; + metadata?: Record; +}; + +export type BtcPayPaymentMethod = { + paymentMethod: string; // "BTC" + destination: string; // bc1q... address + paymentLink: string; // bitcoin:... URI + rate: string; // BTC/USD rate at time of invoice + due: string; // BTC amount customer must send + amount: string; // total BTC amount + totalPaid: string; // BTC paid so far +}; + +function btcPayConfig() { + const url = (process.env.BTCPAY_URL ?? "").replace(/\/$/, ""); + const apiKey = process.env.BTCPAY_API_KEY ?? ""; + const storeId = process.env.BTCPAY_STORE_ID ?? ""; + return { url, apiKey, storeId }; +} + +export function isBtcPayConfigured(): boolean { + const { url, apiKey, storeId } = btcPayConfig(); + return Boolean(url && apiKey && storeId); +} + +function authHeaders(apiKey: string) { + return { + "Content-Type": "application/json", + Authorization: `token ${apiKey}`, + }; +} + +/** + * Create a new BTCPay invoice. + * `usdAmount` is the dollar amount the customer wants to deposit. + * BTCPay converts this to the BTC equivalent at current rate. + */ +export async function createBtcPayInvoice( + usdAmount: number, + handle: string, +): Promise<{ ok: true; invoice: BtcPayInvoice } | { ok: false; error: string }> { + const { url, apiKey, storeId } = btcPayConfig(); + if (!url || !apiKey || !storeId) { + return { ok: false, error: "BTCPay not configured — set BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID" }; + } + + try { + const res = await fetch(`${url}/api/v1/stores/${storeId}/invoices`, { + method: "POST", + headers: authHeaders(apiKey), + body: JSON.stringify({ + amount: usdAmount.toFixed(2), + currency: "USD", + metadata: { + cyberlux_handle: handle, + source: "cyberlux-deposit-desk", + }, + checkout: { + speedPolicy: "LowSpeed", // 1 confirmation required + paymentMethods: ["BTC"], + defaultPaymentMethod: "BTC", + expirationMinutes: 60, + }, + }), + cache: "no-store", + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + return { ok: false, error: `BTCPay error ${res.status}: ${text.slice(0, 120)}` }; + } + + const invoice = (await res.json()) as BtcPayInvoice; + return { ok: true, invoice }; + } catch (err) { + return { ok: false, error: `Network error reaching BTCPay: ${String(err).slice(0, 100)}` }; + } +} + +/** + * Fetch current status and payment method details for an invoice. + */ +export async function getBtcPayInvoiceStatus(invoiceId: string): Promise<{ + ok: true; + status: BtcPayInvoiceStatus; + usdAmount: number; + paymentMethod?: BtcPayPaymentMethod; +} | { ok: false; error: string }> { + const { url, apiKey, storeId } = btcPayConfig(); + if (!url || !apiKey || !storeId) { + return { ok: false, error: "BTCPay not configured" }; + } + + try { + // Fetch invoice + const invRes = await fetch(`${url}/api/v1/stores/${storeId}/invoices/${invoiceId}`, { + headers: authHeaders(apiKey), + cache: "no-store", + }); + if (!invRes.ok) { + return { ok: false, error: `Invoice not found (${invRes.status})` }; + } + const invoice = (await invRes.json()) as BtcPayInvoice; + + // Fetch payment methods to get the BTC address + const pmRes = await fetch( + `${url}/api/v1/stores/${storeId}/invoices/${invoiceId}/payment-methods`, + { headers: authHeaders(apiKey), cache: "no-store" }, + ); + let paymentMethod: BtcPayPaymentMethod | undefined; + if (pmRes.ok) { + const methods = (await pmRes.json()) as BtcPayPaymentMethod[]; + paymentMethod = methods.find((m) => m.paymentMethod === "BTC"); + } + + return { + ok: true, + status: invoice.status, + usdAmount: parseFloat(invoice.amount), + paymentMethod, + }; + } catch (err) { + return { ok: false, error: `Network error: ${String(err).slice(0, 100)}` }; + } +}