Integrate BTCPay Server: auto-settling invoice deposits
- lib/btcpay.ts: Greenfield API client (createBtcPayInvoice, getBtcPayInvoiceStatus) - /api/btcpay/invoice: creates per-deposit invoice (USD amount + handle in metadata) - /api/btcpay/status/[id]: polls invoice status + returns BTC address/amount - add-funds page: BTCPay tab with amount picker, live address display, 10s auto-poll, settlement auto-credits localStorage ledger - WalletContext: creditBtcPayInvoice() with duplicate-invoice guard - .env.example: BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID, NEXT_PUBLIC_BTCPAY_ENABLED Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Navbar from "@/components/Navbar";
|
import Navbar from "@/components/Navbar";
|
||||||
@@ -8,157 +8,292 @@ import { useAccount } from "@/contexts/AccountContext";
|
|||||||
import { useWallet } from "@/contexts/WalletContext";
|
import { useWallet } from "@/contexts/WalletContext";
|
||||||
import { getMerchantBtcAddress, isMerchantBtcConfigured } from "@/lib/merchantBtc";
|
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;
|
id: string;
|
||||||
coin: Coin;
|
coin: "ETH" | "XMR";
|
||||||
txid: string;
|
txid: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
status: "pending" | "credited";
|
status: "pending" | "credited";
|
||||||
ref?: string;
|
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 [];
|
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 []; }
|
catch { return []; }
|
||||||
}
|
}
|
||||||
|
function saveInvoices(d: BtcPayInvoiceLocal[]) {
|
||||||
function savePending(d: PendingDeposit[]) {
|
if (typeof window !== "undefined") localStorage.setItem(INVOICE_KEY, JSON.stringify(d));
|
||||||
if (typeof window !== "undefined") localStorage.setItem(PENDING_KEY, JSON.stringify(d));
|
|
||||||
}
|
}
|
||||||
|
function loadManual(): ManualDeposit[] {
|
||||||
function shortId() {
|
if (typeof window === "undefined") return [];
|
||||||
return Math.random().toString(36).slice(2, 10).toUpperCase();
|
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<Coin, string> = {
|
// ─── Style maps ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TAB_COLOR: Record<Tab, string> = {
|
||||||
|
BTCPAY: "text-neon-purple",
|
||||||
BTC: "text-amber-400",
|
BTC: "text-amber-400",
|
||||||
ETH: "text-blue-400",
|
ETH: "text-blue-400",
|
||||||
XMR: "text-orange-400",
|
XMR: "text-orange-400",
|
||||||
};
|
};
|
||||||
|
const TAB_BORDER: Record<Tab, string> = {
|
||||||
const COIN_BORDER: Record<Coin, string> = {
|
BTCPAY: "border-neon-purple/30",
|
||||||
BTC: "border-amber-500/30",
|
BTC: "border-amber-500/30",
|
||||||
ETH: "border-blue-500/30",
|
ETH: "border-blue-500/30",
|
||||||
XMR: "border-orange-500/30",
|
XMR: "border-orange-500/30",
|
||||||
};
|
};
|
||||||
|
const TAB_BG: Record<Tab, string> = {
|
||||||
const COIN_GLOW: Record<Coin, string> = {
|
BTCPAY: "bg-neon-purple/10",
|
||||||
BTC: "bg-amber-500/10",
|
BTC: "bg-amber-500/10",
|
||||||
ETH: "bg-blue-500/10",
|
ETH: "bg-blue-500/10",
|
||||||
XMR: "bg-orange-500/10",
|
XMR: "bg-orange-500/10",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Component ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AddFundsPage() {
|
export default function AddFundsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, hydrated } = useAccount();
|
const { user, hydrated } = useAccount();
|
||||||
const { usdStoreCredit, luxCredits, verifyBtcDeposit } = useWallet();
|
const { usdStoreCredit, luxCredits, verifyBtcDeposit, creditBtcPayInvoice } = useWallet();
|
||||||
|
|
||||||
const [activeCoin, setActiveCoin] = useState<Coin>("BTC");
|
const btcPayEnabled = (process.env.NEXT_PUBLIC_BTCPAY_ENABLED ?? "").toLowerCase() === "true";
|
||||||
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 [activeTab, setActiveTab] = useState<Tab>(btcPayEnabled ? "BTCPAY" : "BTC");
|
||||||
|
const [copied, setCopied] = useState<Tab | null>(null);
|
||||||
|
|
||||||
|
// ── BTCPay state ──────────────────────────────────────────────────────────
|
||||||
|
const [desiredUsd, setDesiredUsd] = useState("25");
|
||||||
|
const [bpBusy, setBpBusy] = useState(false);
|
||||||
|
const [bpError, setBpError] = useState<string | null>(null);
|
||||||
|
const [activeInvoice, setActiveInvoice] = useState<BtcPayInvoiceLocal | null>(null);
|
||||||
|
const [invoiceHistory, setInvoiceHistory] = useState<BtcPayInvoiceLocal[]>([]);
|
||||||
|
const [bpSuccess, setBpSuccess] = useState<string | null>(null);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | 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<ManualDeposit[]>([]);
|
||||||
|
|
||||||
const btcAddr = getMerchantBtcAddress();
|
const btcAddr = getMerchantBtcAddress();
|
||||||
const btcReady = isMerchantBtcConfigured();
|
const btcReady = isMerchantBtcConfigured();
|
||||||
const ethAddr = (process.env.NEXT_PUBLIC_ETH_ADDRESS || "").trim();
|
const ethAddr = (process.env.NEXT_PUBLIC_ETH_ADDRESS || "").trim();
|
||||||
const xmrAddr = (process.env.NEXT_PUBLIC_XMR_ADDRESS || "").trim();
|
const xmrAddr = (process.env.NEXT_PUBLIC_XMR_ADDRESS || "").trim();
|
||||||
|
|
||||||
const addresses: Record<Coin, string> = {
|
// ── Auth guard ────────────────────────────────────────────────────────────
|
||||||
BTC: btcAddr,
|
|
||||||
ETH: ethAddr,
|
|
||||||
XMR: xmrAddr,
|
|
||||||
};
|
|
||||||
|
|
||||||
const addrReady: Record<Coin, boolean> = {
|
|
||||||
BTC: btcReady,
|
|
||||||
ETH: Boolean(ethAddr),
|
|
||||||
XMR: Boolean(xmrAddr),
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hydrated && !user) router.replace("/sign-in?next=/account/add-funds");
|
if (hydrated && !user) router.replace("/sign-in?next=/account/add-funds");
|
||||||
}, [hydrated, user, router]);
|
}, [hydrated, user, router]);
|
||||||
|
|
||||||
|
// ── Load local state ──────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingDeposits(loadPending());
|
setInvoiceHistory(loadInvoices());
|
||||||
|
setManualHistory(loadManual());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copyAddr = async (coin: Coin) => {
|
// ── Poll active BTCPay invoice ────────────────────────────────────────────
|
||||||
const addr = addresses[coin];
|
const stopPoll = useCallback(() => {
|
||||||
if (!addr) return;
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(addr);
|
const res = await fetch(`/api/btcpay/status/${invoice.invoiceId}`);
|
||||||
setCopied(coin);
|
const data = (await res.json()) as {
|
||||||
setTimeout(() => setCopied(null), 1800);
|
ok: boolean;
|
||||||
} catch { /* ignore */ }
|
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 {
|
||||||
|
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) => {
|
const onVerifyBtc = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!txid.trim()) return;
|
if (!btcTxid.trim()) return;
|
||||||
setMsg(null);
|
setBtcBusy(true); setBtcMsg(null);
|
||||||
setBusy(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await verifyBtcDeposit(txid.trim());
|
const res = await verifyBtcDeposit(btcTxid.trim());
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setMsg({
|
setBtcMsg({ kind: "ok", text: `✓ $${(res.creditedUsd ?? 0).toFixed(2)} USD credited to @${user!.username}.` });
|
||||||
kind: "ok",
|
setBtcTxid("");
|
||||||
text: `✓ $${(res.creditedUsd ?? 0).toFixed(2)} USD credited to @${user!.username}. Balance updated.`,
|
|
||||||
});
|
|
||||||
setTxid("");
|
|
||||||
} else {
|
} 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) => {
|
const onSubmitManual = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!txid.trim() || !user) return;
|
if (!manualTxid.trim() || !user || (activeTab !== "ETH" && activeTab !== "XMR")) return;
|
||||||
setMsg(null);
|
setManualBusy(true); setManualMsg(null);
|
||||||
setBusy(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/pending-deposit", {
|
const res = await fetch("/api/pending-deposit", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 };
|
const json = (await res.json()) as { ok: boolean; message?: string; ref?: string; error?: string };
|
||||||
if (json.ok) {
|
if (json.ok) {
|
||||||
const entry: PendingDeposit = {
|
const entry: ManualDeposit = {
|
||||||
id: shortId(),
|
id: shortId(), coin: activeTab as "ETH" | "XMR",
|
||||||
coin: activeCoin,
|
txid: manualTxid.trim(), ts: Date.now(), status: "pending", ref: json.ref,
|
||||||
txid: txid.trim(),
|
|
||||||
ts: Date.now(),
|
|
||||||
status: "pending",
|
|
||||||
ref: json.ref,
|
|
||||||
};
|
};
|
||||||
const updated = [entry, ...pendingDeposits];
|
setManualHistory((prev) => { const n = [entry, ...prev]; saveManual(n); return n; });
|
||||||
setPendingDeposits(updated);
|
setManualMsg({ kind: "pending", text: json.message ?? "Submitted for manual review.", ref: json.ref });
|
||||||
savePending(updated);
|
setManualTxid("");
|
||||||
setMsg({ kind: "pending", text: json.message ?? "Deposit request submitted for manual review.", ref: json.ref });
|
|
||||||
setTxid("");
|
|
||||||
} else {
|
} else {
|
||||||
setMsg({ kind: "err", text: json.error ?? "Submission failed." });
|
setManualMsg({ kind: "err", text: json.error ?? "Submission failed." });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setMsg({ kind: "err", text: "Network error. Try again." });
|
setManualMsg({ kind: "err", text: "Network error." });
|
||||||
} finally {
|
} finally { setManualBusy(false); }
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Loading state ─────────────────────────────────────────────────────────
|
||||||
if (!hydrated || !user) {
|
if (!hydrated || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a] font-mono text-sm text-zinc-500">
|
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a] font-mono text-sm text-zinc-500">
|
||||||
@@ -167,6 +302,8 @@ export default function AddFundsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabs: Tab[] = btcPayEnabled ? ["BTCPAY", "BTC", "ETH", "XMR"] : ["BTC", "ETH", "XMR"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-zinc-200">
|
<div className="min-h-screen bg-[#0a0a0a] text-zinc-200">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@@ -180,22 +317,23 @@ export default function AddFundsPage() {
|
|||||||
<h1 className="mt-2 font-orbitron text-4xl font-bold">Fund Your Account</h1>
|
<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">
|
<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
|
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
|
market rate, and credit your account.{" "}
|
||||||
hold the crypto</strong> — we do. When you make a purchase we pay the vendor with it on your behalf.
|
<strong className="text-zinc-200">You no longer hold the crypto</strong> — we do.
|
||||||
|
When you purchase, we pay the vendor in crypto on your behalf.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* How it works */}
|
{/* How it works */}
|
||||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<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." },
|
{ n: "1", icon: "₿", title: "Send crypto", body: "BTC via BTCPay, or BTC/ETH/XMR to a static address." },
|
||||||
{ step: "2", icon: "📊", title: "We verify & convert", body: "On-chain confirmation. Credited at live market USD rate." },
|
{ n: "2", icon: "📊", title: "We verify", 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: "3", icon: "🛒", title: "You shop", body: "Spend USD credits site-wide. We pay vendors in crypto." },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div key={s.step} className="glass rounded-2xl border border-white/10 p-5">
|
<div key={s.n} className="glass rounded-2xl border border-white/10 p-5">
|
||||||
<div className="mb-3 flex items-center gap-3">
|
<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">
|
<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}
|
{s.n}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl">{s.icon}</span>
|
<span className="text-xl">{s.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,220 +352,345 @@ export default function AddFundsPage() {
|
|||||||
<div className="mt-0.5 text-sm text-neon-green">{luxCredits.toLocaleString()} LUX</div>
|
<div className="mt-0.5 text-sm text-neon-green">{luxCredits.toLocaleString()} LUX</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Link
|
<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">
|
||||||
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 →
|
Spend →
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link href="/wallets" className="rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:border-white/20">
|
||||||
href="/wallets"
|
|
||||||
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:border-white/20"
|
|
||||||
>
|
|
||||||
Passes
|
Passes
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coin selector */}
|
{/* Tab selector */}
|
||||||
<div className="mb-6 flex gap-3">
|
<div className="mb-6 flex gap-2 flex-wrap">
|
||||||
{(["BTC", "ETH", "XMR"] as Coin[]).map((coin) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={coin}
|
key={tab}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setActiveCoin(coin); setMsg(null); setTxid(""); }}
|
onClick={() => { setActiveTab(tab); setBpError(null); setBtcMsg(null); setManualMsg(null); }}
|
||||||
className={`flex-1 rounded-xl border py-3 text-sm font-bold uppercase transition-all ${
|
className={`flex-1 min-w-[80px] rounded-xl border py-3 text-xs font-bold uppercase transition-all ${
|
||||||
activeCoin === coin
|
activeTab === tab
|
||||||
? `${COIN_BORDER[coin]} ${COIN_GLOW[coin]} ${COIN_COLORS[coin]}`
|
? `${TAB_BORDER[tab]} ${TAB_BG[tab]} ${TAB_COLOR[tab]}`
|
||||||
: "border-white/10 text-zinc-500 hover:border-white/20"
|
: "border-white/10 text-zinc-500 hover:border-white/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{coin}
|
{tab === "BTCPAY" ? "BTCPay ✦" : tab}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deposit address */}
|
{/* ── BTCPAY TAB ─────────────────────────────────────────────────── */}
|
||||||
<div className={`glass mb-6 rounded-2xl border p-6 ${COIN_BORDER[activeCoin]}`}>
|
{activeTab === "BTCPAY" && (
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="space-y-5">
|
||||||
<h2 className={`font-orbitron text-lg ${COIN_COLORS[activeCoin]}`}>
|
<div className="glass rounded-2xl border border-neon-purple/30 p-6">
|
||||||
{activeCoin} Deposit Address
|
<div className="mb-1 flex items-center justify-between">
|
||||||
</h2>
|
<h2 className="font-orbitron text-lg text-neon-purple">BTCPay Invoice</h2>
|
||||||
{activeCoin === "BTC" && (
|
|
||||||
<span className="rounded-full bg-neon-green/15 px-3 py-1 text-xs font-bold text-neon-green">
|
<span className="rounded-full bg-neon-green/15 px-3 py-1 text-xs font-bold text-neon-green">
|
||||||
Auto-verify
|
Auto-settle
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mb-5 text-xs text-zinc-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!activeInvoice || activeInvoice.status === "Expired" || activeInvoice.status === "Invalid" ? (
|
||||||
|
<form onSubmit={(e) => void createInvoice(e)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs text-zinc-500 uppercase tracking-wider">
|
||||||
|
Amount to deposit (USD)
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{["10", "25", "50", "100", "250"].map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDesiredUsd(v)}
|
||||||
|
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition-all ${
|
||||||
|
desiredUsd === v
|
||||||
|
? "border-neon-purple/50 bg-neon-purple/15 text-neon-purple"
|
||||||
|
: "border-white/10 text-zinc-500 hover:border-white/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
${v}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.50"
|
||||||
|
step="0.01"
|
||||||
|
value={desiredUsd}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={bpBusy}
|
||||||
|
className="w-full rounded-xl bg-gradient-to-r from-neon-purple to-neon-pink py-3 text-sm font-bold text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{bpBusy ? "Creating invoice…" : "Generate Payment Invoice →"}
|
||||||
|
</button>
|
||||||
|
{bpError && (
|
||||||
|
<div className="rounded-xl border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-400">
|
||||||
|
{bpError}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{(activeCoin === "ETH" || activeCoin === "XMR") && (
|
</form>
|
||||||
<span className="rounded-full bg-amber-500/15 px-3 py-1 text-xs font-bold text-amber-400">
|
) : (
|
||||||
Manual review
|
/* Active invoice */
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status badge */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`rounded-full px-3 py-1 text-xs font-bold uppercase ${
|
||||||
|
activeInvoice.status === "Settled"
|
||||||
|
? "bg-neon-green/20 text-neon-green"
|
||||||
|
: activeInvoice.status === "Processing"
|
||||||
|
? "bg-amber-500/20 text-amber-400"
|
||||||
|
: "bg-neon-purple/20 text-neon-purple"
|
||||||
|
}`}>
|
||||||
|
{activeInvoice.status === "New" ? "Waiting for payment…" : activeInvoice.status}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
${activeInvoice.usdAmount.toFixed(2)} USD
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BTC address (fetched from payment-methods on first poll) */}
|
||||||
|
{activeInvoice.btcAddress ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs text-zinc-500 uppercase tracking-wider">Send exactly</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 rounded-xl border border-amber-500/30 bg-black/60 px-4 py-3 font-mono text-sm text-amber-400 break-all">
|
||||||
|
{activeInvoice.btcAmount ?? "…"} BTC
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 mb-1 text-xs text-zinc-500 uppercase tracking-wider">To this address</div>
|
||||||
|
<div className="rounded-xl border border-amber-500/30 bg-black/60 px-4 py-3 font-mono text-xs text-amber-400 break-all">
|
||||||
|
{activeInvoice.btcAddress}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(activeInvoice.btcAddress!, "BTC")}
|
||||||
|
className="mt-2 w-full rounded-xl border border-amber-500/30 bg-amber-500/10 py-2.5 text-xs font-bold text-amber-400 uppercase hover:opacity-90"
|
||||||
|
>
|
||||||
|
{copied === "BTC" ? "✓ Copied" : "Copy BTC Address"}
|
||||||
|
</button>
|
||||||
|
{activeInvoice.btcRate && (
|
||||||
|
<p className="mt-2 text-[11px] text-zinc-600">
|
||||||
|
Rate locked at 1 BTC ≈ ${parseFloat(activeInvoice.btcRate).toLocaleString()} USD
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="animate-pulse text-xs text-zinc-500">Fetching payment address…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{(bpSuccess || activeInvoice.credited) && (
|
||||||
|
<div className="rounded-xl border border-neon-green/40 bg-neon-green/10 p-4 text-sm font-bold text-neon-green">
|
||||||
|
{bpSuccess ?? `✓ $${activeInvoice.usdAmount.toFixed(2)} USD credited to @${user.username}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!activeInvoice.credited && activeInvoice.status !== "Settled" && (
|
||||||
|
<p className="text-[11px] text-zinc-600">
|
||||||
|
Polling for confirmation every 10 s. Do not close this tab until confirmed.
|
||||||
|
This invoice is valid for 60 minutes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setActiveInvoice(null); setBpSuccess(null); setBpError(null); }}
|
||||||
|
className="mt-2 rounded-xl border border-white/10 px-5 py-2 text-xs text-zinc-500 hover:border-white/20"
|
||||||
|
>
|
||||||
|
← New invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!addrReady[activeCoin] ? (
|
{/* Invoice history */}
|
||||||
|
{invoiceHistory.filter((i) => i.handle === user.username).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 font-mono text-xs uppercase tracking-wider text-zinc-600">Invoice history</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{invoiceHistory.filter((i) => i.handle === user.username).map((inv) => (
|
||||||
|
<div key={inv.invoiceId} className="glass flex items-center justify-between gap-4 rounded-xl border border-white/10 px-4 py-3 text-xs">
|
||||||
|
<div className="font-mono text-zinc-400">
|
||||||
|
{inv.invoiceId.slice(0, 12)}… · ${inv.usdAmount.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase ${
|
||||||
|
inv.credited ? "bg-neon-green/20 text-neon-green"
|
||||||
|
: inv.status === "Processing" ? "bg-amber-500/20 text-amber-400"
|
||||||
|
: inv.status === "Expired" ? "bg-red-500/20 text-red-400"
|
||||||
|
: "bg-zinc-500/20 text-zinc-400"
|
||||||
|
}`}>
|
||||||
|
{inv.credited ? "credited" : inv.status.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600">{new Date(inv.createdAt).toLocaleDateString()}</span>
|
||||||
|
{!inv.credited && inv.status !== "Expired" && inv.status !== "Invalid" && inv.status !== "Settled" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveInvoice(inv)}
|
||||||
|
className="rounded border border-neon-purple/30 px-2 py-0.5 text-[10px] text-neon-purple hover:bg-neon-purple/10"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── BTC TAB (manual txid verify) ──────────────────────────────── */}
|
||||||
|
{activeTab === "BTC" && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="glass rounded-2xl border border-amber-500/30 p-6">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<h2 className="font-orbitron text-lg text-amber-400">BTC — Manual Verify</h2>
|
||||||
|
<span className="rounded-full bg-neon-green/15 px-3 py-1 text-xs font-bold text-neon-green">Auto-credit</span>
|
||||||
|
</div>
|
||||||
|
{!btcReady ? (
|
||||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-300">
|
<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="font-bold">Address not configured</p>
|
||||||
<p className="mt-1 text-amber-400/70">
|
<p className="mt-1 text-amber-400/70">
|
||||||
Set{" "}
|
Set <code className="rounded bg-black/40 px-1">NEXT_PUBLIC_MERCHANT_BTC_ADDRESS</code> in{" "}
|
||||||
<code className="rounded bg-black/40 px-1">
|
<code className="rounded bg-black/40 px-1">.env.local</code>
|
||||||
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div
|
<p className="mb-4 text-xs text-zinc-500">
|
||||||
className={`mb-3 rounded-xl border bg-black/60 p-4 font-mono text-sm break-all ${COIN_BORDER[activeCoin]} ${COIN_COLORS[activeCoin]}`}
|
Send BTC to the static address below, then paste the 64-character transaction ID.
|
||||||
>
|
We verify via mempool.space and credit instantly.
|
||||||
{addresses[activeCoin]}
|
</p>
|
||||||
|
<div className="mb-2 rounded-xl border border-amber-500/30 bg-black/60 px-4 py-3 font-mono text-xs text-amber-400 break-all">
|
||||||
|
{btcAddr}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copyAddr(activeCoin)}
|
onClick={() => void copyText(btcAddr, "BTC")}
|
||||||
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`}
|
className="mb-5 w-full rounded-xl border border-amber-500/30 bg-amber-500/10 py-2.5 text-xs font-bold text-amber-400 uppercase hover:opacity-90"
|
||||||
>
|
>
|
||||||
{copied === activeCoin ? "✓ Address copied" : `Copy ${activeCoin} address`}
|
{copied === "BTC" ? "✓ Copied" : "Copy BTC Address"}
|
||||||
</button>
|
</button>
|
||||||
|
<form onSubmit={(e) => void onVerifyBtc(e)} className="space-y-3">
|
||||||
{activeCoin === "BTC" && (
|
<input
|
||||||
<div className="mt-2 text-xs text-zinc-600">
|
value={btcTxid}
|
||||||
Send any amount · 1 confirmation required · credited at CoinGecko spot rate
|
onChange={(e) => setBtcTxid(e.target.value.trim())}
|
||||||
</div>
|
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"
|
||||||
{activeCoin === "ETH" && (
|
maxLength={64}
|
||||||
<div className="mt-2 text-xs text-zinc-600">
|
/>
|
||||||
Send ERC-20 or native ETH · submit txhash below for manual review · credited within 2–24h
|
<button
|
||||||
</div>
|
type="submit"
|
||||||
)}
|
disabled={btcBusy || !btcTxid.trim()}
|
||||||
{activeCoin === "XMR" && (
|
className="w-full rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 py-3 text-sm font-bold text-black disabled:opacity-40"
|
||||||
<div className="mt-2 text-xs text-zinc-600">
|
>
|
||||||
Monero transactions take ~20 min · submit txid below for manual review · credited within 2–24h
|
{btcBusy ? "Verifying…" : "Verify & Credit Now"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{btcMsg && (
|
||||||
|
<div className={`mt-4 rounded-xl border p-3 text-sm ${btcMsg.kind === "ok" ? "border-neon-green/40 bg-neon-green/10 text-neon-green" : "border-red-500/40 bg-red-500/10 text-red-400"}`}>
|
||||||
|
{btcMsg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Verify / submit form */}
|
{/* ── ETH / XMR TAB (manual review) ─────────────────────────────── */}
|
||||||
{addrReady[activeCoin] && (
|
{(activeTab === "ETH" || activeTab === "XMR") && (() => {
|
||||||
<div className="glass rounded-2xl border border-white/10 p-6">
|
const addr = activeTab === "ETH" ? ethAddr : xmrAddr;
|
||||||
<h3 className="mb-1 font-orbitron text-base">
|
const addrReady = Boolean(addr);
|
||||||
{activeCoin === "BTC" ? "Verify BTC Transaction" : `Submit ${activeCoin} Deposit for Review`}
|
const envVar = activeTab === "ETH" ? "NEXT_PUBLIC_ETH_ADDRESS" : "NEXT_PUBLIC_XMR_ADDRESS";
|
||||||
</h3>
|
const note = activeTab === "ETH"
|
||||||
<p className="mb-5 text-xs text-zinc-500">
|
? "Send ERC-20 or native ETH · submit txhash below · credited within 2–24h after manual review"
|
||||||
{activeCoin === "BTC"
|
: "Monero transactions take ~20 min · submit txid below · credited within 2–24h after manual review";
|
||||||
? "After at least 1 confirmation, paste the 64-character transaction ID. We check mempool.space and credit immediately."
|
return (
|
||||||
: `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.`}
|
<div className="glass rounded-2xl border p-6" style={{borderColor: activeTab === "ETH" ? "rgba(59,130,246,0.3)" : "rgba(249,115,22,0.3)"}}>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<h2 className={`font-orbitron text-lg ${TAB_COLOR[activeTab]}`}>{activeTab} Deposit</h2>
|
||||||
|
<span className="rounded-full bg-amber-500/15 px-3 py-1 text-xs font-bold text-amber-400">Manual review</span>
|
||||||
|
</div>
|
||||||
|
{!addrReady ? (
|
||||||
|
<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">{envVar}</code> in{" "}
|
||||||
|
<code className="rounded bg-black/40 px-1">.env.local</code>
|
||||||
</p>
|
</p>
|
||||||
<form
|
</div>
|
||||||
onSubmit={(e) => activeCoin === "BTC" ? void onVerifyBtc(e) : void onSubmitManual(e)}
|
) : (
|
||||||
className="space-y-4"
|
<>
|
||||||
|
<p className="mb-4 text-xs text-zinc-500">{note}</p>
|
||||||
|
<div className={`mb-2 rounded-xl border bg-black/60 px-4 py-3 font-mono text-xs break-all ${TAB_BORDER[activeTab]} ${TAB_COLOR[activeTab]}`}>
|
||||||
|
{addr}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(addr, activeTab)}
|
||||||
|
className={`mb-5 w-full rounded-xl border py-2.5 text-xs font-bold uppercase hover:opacity-90 ${TAB_BORDER[activeTab]} ${TAB_BG[activeTab]} ${TAB_COLOR[activeTab]}`}
|
||||||
>
|
>
|
||||||
|
{copied === activeTab ? `✓ Copied` : `Copy ${activeTab} Address`}
|
||||||
|
</button>
|
||||||
|
<form onSubmit={(e) => void onSubmitManual(e)} className="space-y-3">
|
||||||
<input
|
<input
|
||||||
value={txid}
|
value={manualTxid}
|
||||||
onChange={(e) => setTxid(e.target.value.trim())}
|
onChange={(e) => setManualTxid(e.target.value.trim())}
|
||||||
placeholder={
|
placeholder={activeTab === "ETH" ? "Ethereum txhash — 0x…" : "Monero transaction ID"}
|
||||||
activeCoin === "BTC"
|
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"
|
||||||
? "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}
|
maxLength={100}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || !txid.trim()}
|
disabled={manualBusy || !manualTxid.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"
|
className={`w-full rounded-xl py-3 text-sm font-bold text-white disabled:opacity-40 bg-gradient-to-r ${activeTab === "ETH" ? "from-blue-600 to-blue-400" : "from-orange-600 to-orange-400"}`}
|
||||||
>
|
>
|
||||||
{busy
|
{manualBusy ? "Submitting…" : `Submit ${activeTab} for Review`}
|
||||||
? "Verifying…"
|
|
||||||
: activeCoin === "BTC"
|
|
||||||
? "Verify & Credit USD Now"
|
|
||||||
: `Submit ${activeCoin} for Review`}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{manualMsg && (
|
||||||
{msg && (
|
<div className={`mt-4 rounded-xl border p-3 text-sm ${manualMsg.kind === "ok" ? "border-neon-green/40 bg-neon-green/10 text-neon-green" : manualMsg.kind === "pending" ? "border-amber-500/40 bg-amber-500/10 text-amber-300" : "border-red-500/40 bg-red-500/10 text-red-400"}`}>
|
||||||
<div
|
<p>{manualMsg.text}</p>
|
||||||
className={`mt-4 rounded-xl border p-4 text-sm ${
|
{manualMsg.ref && <p className="mt-1 font-mono text-xs opacity-70">Ref: {manualMsg.ref}</p>}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Pending history */}
|
{/* Manual deposit history (ETH/XMR) */}
|
||||||
{pendingDeposits.length > 0 && (
|
{manualHistory.length > 0 && (activeTab === "ETH" || activeTab === "XMR") && (
|
||||||
<div className="mt-8">
|
<div className="mt-6">
|
||||||
<h3 className="mb-4 font-orbitron text-sm text-zinc-400">Pending Manual Reviews</h3>
|
<h3 className="mb-3 font-mono text-xs uppercase tracking-wider text-zinc-600">Manual review history</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{pendingDeposits.map((d) => (
|
{manualHistory.filter((d) => d.coin === activeTab).map((d) => (
|
||||||
<div
|
<div key={d.id} className="glass flex items-center justify-between gap-4 rounded-xl border border-white/10 px-4 py-3 text-xs">
|
||||||
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>
|
<div>
|
||||||
<span className={`font-bold ${COIN_COLORS[d.coin]}`}>{d.coin}</span>
|
<span className={`font-bold ${TAB_COLOR[d.coin]}`}>{d.coin}</span>
|
||||||
<span className="ml-3 font-mono text-xs text-zinc-500">
|
<span className="ml-2 font-mono text-zinc-500">{d.txid.slice(0, 12)}…{d.txid.slice(-6)}</span>
|
||||||
{d.txid.slice(0, 12)}…{d.txid.slice(-8)}
|
{d.ref && <span className="ml-2 text-[10px] text-zinc-600">ref: {d.ref}</span>}
|
||||||
</span>
|
|
||||||
{d.ref && (
|
|
||||||
<span className="ml-2 text-[10px] text-zinc-600">ref: {d.ref}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<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"}`}>
|
||||||
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}
|
{d.status}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-zinc-600">{new Date(d.ts).toLocaleDateString()}</span>
|
<span className="text-zinc-600">{new Date(d.ts).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -439,28 +702,18 @@ export default function AddFundsPage() {
|
|||||||
<div className="mt-10 rounded-2xl border border-white/10 bg-white/[0.02] p-6 text-sm">
|
<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>
|
<div className="mb-3 font-bold text-zinc-200">Important — Read Before Depositing</div>
|
||||||
<ul className="space-y-2 text-zinc-500">
|
<ul className="space-y-2 text-zinc-500">
|
||||||
<li className="flex items-start gap-2">
|
{[
|
||||||
|
"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) => (
|
||||||
|
<li key={i} className="flex items-start gap-2">
|
||||||
<span className="mt-0.5 shrink-0 text-neon-cyan">→</span>
|
<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.
|
{note}
|
||||||
</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 (2–24h).
|
|
||||||
</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>
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -468,7 +721,6 @@ export default function AddFundsPage() {
|
|||||||
<Link href="/dashboard" className="text-neon-cyan hover:underline">Dashboard</Link>
|
<Link href="/dashboard" className="text-neon-cyan hover:underline">Dashboard</Link>
|
||||||
<Link href="/checkout" className="hover:text-zinc-400">Checkout</Link>
|
<Link href="/checkout" className="hover:text-zinc-400">Checkout</Link>
|
||||||
<Link href="/wallets" className="hover:text-zinc-400">Digital Passes</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>
|
<Link href="/" className="hover:text-zinc-400">Hub</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
app/api/btcpay/invoice/route.ts
Normal file
44
app/api/btcpay/invoice/route.ts
Normal file
@@ -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<string, unknown>) ?? {};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
32
app/api/btcpay/status/[invoiceId]/route.ts
Normal file
32
app/api/btcpay/status/[invoiceId]/route.ts
Normal file
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ interface WalletContextType {
|
|||||||
earnLuxCredits: (amount: number) => void;
|
earnLuxCredits: (amount: number) => void;
|
||||||
spendUsdStoreCredit: (amount: number) => boolean;
|
spendUsdStoreCredit: (amount: number) => boolean;
|
||||||
verifyBtcDeposit: (txid: string) => Promise<{ ok: boolean; error?: string; creditedUsd?: number }>;
|
verifyBtcDeposit: (txid: string) => Promise<{ ok: boolean; error?: string; creditedUsd?: number }>;
|
||||||
|
creditBtcPayInvoice: (invoiceId: string, usdAmount: number) => { ok: boolean; error?: string };
|
||||||
vault: VaultState;
|
vault: VaultState;
|
||||||
collectKey: (key: string) => boolean;
|
collectKey: (key: string) => boolean;
|
||||||
addVaultReceipt: (receipt: VaultReceipt) => void;
|
addVaultReceipt: (receipt: VaultReceipt) => void;
|
||||||
@@ -141,6 +142,23 @@ export const WalletProvider = ({ children }: WalletProviderProps) => {
|
|||||||
[handle],
|
[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 collectKey = (key: string) => {
|
||||||
const trimmed = String(key || "").trim();
|
const trimmed = String(key || "").trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) return false;
|
||||||
@@ -167,6 +185,7 @@ export const WalletProvider = ({ children }: WalletProviderProps) => {
|
|||||||
earnLuxCredits,
|
earnLuxCredits,
|
||||||
spendUsdStoreCredit,
|
spendUsdStoreCredit,
|
||||||
verifyBtcDeposit,
|
verifyBtcDeposit,
|
||||||
|
creditBtcPayInvoice,
|
||||||
vault,
|
vault,
|
||||||
collectKey,
|
collectKey,
|
||||||
addVaultReceipt,
|
addVaultReceipt,
|
||||||
@@ -179,6 +198,7 @@ export const WalletProvider = ({ children }: WalletProviderProps) => {
|
|||||||
earnLuxCredits,
|
earnLuxCredits,
|
||||||
spendUsdStoreCredit,
|
spendUsdStoreCredit,
|
||||||
verifyBtcDeposit,
|
verifyBtcDeposit,
|
||||||
|
creditBtcPayInvoice,
|
||||||
vault,
|
vault,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
149
lib/btcpay.ts
Normal file
149
lib/btcpay.ts
Normal file
@@ -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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user