diff --git a/frontend/src/app/(auth)/connect-wallet/page.tsx b/frontend/src/app/(auth)/connect-wallet/page.tsx index 7a6d65c..86d2258 100644 --- a/frontend/src/app/(auth)/connect-wallet/page.tsx +++ b/frontend/src/app/(auth)/connect-wallet/page.tsx @@ -48,12 +48,12 @@ export default function ConnectWalletPage() { : ''; return ( -
+
-
+
Quid Logo -
+
{availableWallets.map((wallet) => { const isActive = connected && walletName === wallet.name; @@ -86,7 +86,7 @@ export default function ConnectWalletPage() { key={wallet.id} type="button" disabled={isConnecting} - className={`flex px-2 py-2 rounded-lg backdrop-blur-md border shadow-xl justify-between w-72 mx-auto transition-colors disabled:opacity-60 ${ + className={`flex px-3 py-2.5 min-h-11 items-center rounded-lg backdrop-blur-md border shadow-xl justify-between w-full transition-colors disabled:opacity-60 ${ isActive ? 'bg-[#9011FF]/30 border-[#9011FF]/50' : 'bg-white/20 border-white/10 hover:bg-white/30' @@ -140,7 +140,7 @@ export default function ConnectWalletPage() { )} -
+

Quid will never ask for your private keys or seed phrases. Only diff --git a/frontend/src/app/creator/layout.tsx b/frontend/src/app/creator/layout.tsx index 06efb74..5531d92 100644 --- a/frontend/src/app/creator/layout.tsx +++ b/frontend/src/app/creator/layout.tsx @@ -6,9 +6,11 @@ export default function DashboardLayout({ children: React.ReactNode; }) { return ( -

+
-
{children}
+
+ {children} +
); } diff --git a/frontend/src/app/creator/wallet/page.tsx b/frontend/src/app/creator/wallet/page.tsx index 76e0226..e4d14c4 100644 --- a/frontend/src/app/creator/wallet/page.tsx +++ b/frontend/src/app/creator/wallet/page.tsx @@ -1,3 +1,458 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { + Wallet, + Copy, + Check, + RefreshCw, + ExternalLink, + AlertTriangle, + Droplets, + Bell, + ChevronDown, + ArrowDownToLine, + ArrowDownLeft, + ArrowUpRight, +} from "lucide-react"; +import { useWallet } from "@/app/hooks/useWallet"; + +/** App-level earnings figure shown on the balance card. There is no earnings + * API yet, so this uses a placeholder (consistent with the project's MockData + * approach); the on-chain XLM/USDC balances and transactions below are real. */ +const EARNINGS_USD = 2150.02; +const MIN_WITHDRAWAL_USD = 25; + +function formatXlm(value: string | null): string { + if (value === null) return "0"; + const n = Number(value); + if (Number.isNaN(n)) return value; + return n.toLocaleString("en-US", { maximumFractionDigits: 7 }); +} + +function formatUsd(value: number): string { + return value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +function truncateKey(key: string): string { + if (key.length <= 8) return key; + return `${key.slice(0, 4)}...${key.slice(-4)}`; +} + +function timeAgo(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return iso; + const sec = Math.floor((Date.now() - then) / 1000); + if (sec < 60) return "just now"; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + if (day < 30) return `${day}d ago`; + return new Date(iso).toLocaleDateString("en-US"); +} + export default function WalletPage() { - return

Wallet

; + const { + publicKey, + isConnected, + xlm, + usdc, + transactions, + funded, + loading, + error, + refreshBalances, + fundWithFriendbot, + funding, + } = useWallet(); + + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(publicKey); + setCopied(true); + } catch (err) { + console.error("Copy failed:", err); + } + }; + + const [usdWhole, usdFraction] = formatUsd(EARNINGS_USD).split("."); + + if (!isConnected || !publicKey) { + return ( +
+ +
+
+ +

No wallet connected

+

+ Connect a Stellar wallet to view your balances and address. +

+ + Connect Wallet + +
+
+
+ ); + } + + return ( +
+ + +
+ {error && ( +
+ +

{error}

+
+ )} + + {/* Unfunded testnet account → Friendbot */} + {!loading && !funded && ( +
+
+ +
+

Account not funded

+

+ This account does not exist on the network yet. Fund it with + Friendbot to get free testnet XLM. +

+
+ + + Open Stellar Laboratory + + +
+
+
+
+ )} + + {/* Wallet Balance card */} +
+
+
+
+ + Wallet Balance +
+ +
+ + ${usdWhole} + + + .{usdFraction} + +
+ +
+
+ XLM + {loading ? ( + + ) : ( + {formatXlm(xlm)} XLM + )} +
+ + + +
+ {loading ? ( + + ) : usdc !== null ? ( + {formatXlm(usdc)} USDC + ) : ( + No USDC trustline + )} +
+
+
+ +
+ + +
+
+ +
+
+

+ Your earnings from completed quests and approved responses. +

+ {!loading && funded && ( + + )} +
+

+ Minimum withdrawal: ${MIN_WITHDRAWAL_USD} +

+
+
+ + {/* Transactions + Withdrawal method */} +
+ {/* Recent Transactions */} +
+

Recent Transactions

+
+ + + + + + + + + + + + {loading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + + + )) + ) : transactions.length === 0 ? ( + + + + ) : ( + transactions.map((tx) => ( + + + + + + + + )) + )} + +
TypeFrom / ToAmountStatusDate
+ +
+ No transactions yet. +
+
+ + {tx.direction === "in" ? ( + + ) : ( + + )} + + + {tx.direction === "in" ? "Received" : "Sent"} + +
+
+ + {truncateKey(tx.counterparty)} + + + {tx.direction === "in" ? "+" : "-"} + {formatXlm(tx.amount)} {tx.assetCode} + + + {tx.successful ? "Successful" : "Failed"} + + + {timeAgo(tx.date)} +
+
+
+ + {/* Withdrawal Method */} +
+

Withdrawal Method

+
+
+
+ + + +
+

+ Stellar Wallet 1 (XLM) +

+
+ + {truncateKey(publicKey)} + + +
+
+
+ +
+
+ +
+
+
+
+ ); +} + +function WalletTopBar({ + onRefresh, + loading, +}: { + onRefresh: () => void; + loading: boolean; +}) { + return ( +
+
+

Wallet

+ +
+ + + + +
+ + $0 +
+ + +
+
+
+ ); } diff --git a/frontend/src/app/hooks/useWallet.ts b/frontend/src/app/hooks/useWallet.ts new file mode 100644 index 0000000..283b93d --- /dev/null +++ b/frontend/src/app/hooks/useWallet.ts @@ -0,0 +1,276 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useWallet as useWalletContext, + type Balance, +} from "@/context/WalletProvider"; + +/** + * Horizon endpoint. Defaults to the public testnet so unfunded accounts can be + * funded via Friendbot. Override with NEXT_PUBLIC_HORIZON_URL for other networks. + */ +export const HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + +export const FRIENDBOT_URL = + process.env.NEXT_PUBLIC_FRIENDBOT_URL ?? "https://friendbot.stellar.org"; + +/** A single balance line item, normalized from the wallet context. */ +export interface WalletBalance { + assetType: string; + assetCode: string; + assetIssuer?: string; + balance: string; +} + +/** A normalized payment, derived from Horizon's `/accounts/{id}/payments`. */ +export interface WalletTransaction { + id: string; + hash: string; + type: string; + direction: "in" | "out"; + counterparty: string; + amount: string; + assetCode: string; + date: string; + successful: boolean; +} + +interface HorizonPayment { + id: string; + type: string; + transaction_hash: string; + transaction_successful?: boolean; + created_at: string; + amount?: string; + asset_type?: string; + asset_code?: string; + from?: string; + to?: string; + funder?: string; + account?: string; + starting_balance?: string; +} + +export interface UseWalletResult { + /** Connected account public key (empty string when not connected). */ + publicKey: string; + isConnected: boolean; + /** All balances on the account, native first. */ + balances: WalletBalance[]; + /** Native XLM balance, or null when the account is unfunded. */ + xlm: string | null; + /** USDC balance, or null when the account holds no USDC trustline. */ + usdc: string | null; + /** Recent payments to/from the account, newest first. */ + transactions: WalletTransaction[]; + /** False when Horizon has no record of the account yet (needs funding). */ + funded: boolean; + loading: boolean; + error: string | null; + /** Re-fetch balances + transactions from Horizon without a page reload. */ + refreshBalances: () => void; + /** Fund the account on testnet via Friendbot, then refresh. */ + fundWithFriendbot: () => Promise; + funding: boolean; +} + +function parseBalances(raw: Balance[]): WalletBalance[] { + return raw + .map((b) => ({ + assetType: b.asset_type, + assetCode: b.asset_type === "native" ? "XLM" : b.asset_code ?? "", + assetIssuer: b.asset_issuer, + balance: b.balance, + })) + // Native asset first, then the rest in the context's order. + .sort((a, b) => + a.assetType === "native" ? -1 : b.assetType === "native" ? 1 : 0, + ); +} + +function parsePayments( + records: HorizonPayment[], + pk: string, +): WalletTransaction[] { + const out: WalletTransaction[] = []; + + for (const r of records) { + let amount: string | undefined; + let assetCode = "XLM"; + let direction: "in" | "out"; + let counterparty: string | undefined; + + if (r.type === "create_account") { + amount = r.starting_balance; + direction = r.account === pk ? "in" : "out"; + counterparty = direction === "in" ? r.funder : r.account; + } else if ( + r.type === "payment" || + r.type === "path_payment_strict_send" || + r.type === "path_payment_strict_receive" + ) { + amount = r.amount; + assetCode = r.asset_type === "native" ? "XLM" : r.asset_code ?? ""; + direction = r.to === pk ? "in" : "out"; + counterparty = direction === "in" ? r.from : r.to; + } else { + // Skip operation types we can't represent as a simple debit/credit. + continue; + } + + out.push({ + id: r.id, + hash: r.transaction_hash, + type: r.type, + direction, + counterparty: counterparty ?? "", + amount: amount ?? "0", + assetCode, + date: r.created_at, + successful: r.transaction_successful !== false, + }); + } + + return out; +} + +/** + * Wallet view-model for the creator wallet page. Balances come from the shared + * WalletProvider (single source of truth); this hook adds the pieces the page + * needs on top — normalized balances, real transaction history and an unfunded + * flag from Horizon's payments endpoint, plus Friendbot funding and refresh. + */ +export function useWallet(): UseWalletResult { + const { + publicKey: contextPublicKey, + connected, + balances: contextBalances, + refreshBalances: refreshContextBalances, + } = useWalletContext(); + + const publicKey = contextPublicKey ?? ""; + const isConnected = connected; + + const [transactions, setTransactions] = useState([]); + const [funded, setFunded] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [funding, setFunding] = useState(false); + + const balances = useMemo( + () => parseBalances(contextBalances), + [contextBalances], + ); + + // Transaction history + funded status come from the account's payments + // endpoint, which 404s for accounts that have not been created on the ledger. + const fetchActivity = useCallback(async () => { + if (!publicKey) { + setTransactions([]); + setFunded(true); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch( + `${HORIZON_URL}/accounts/${publicKey}/payments?order=desc&limit=15`, + ); + + if (res.status === 404) { + setTransactions([]); + setFunded(false); + return; + } + + if (!res.ok) { + throw new Error(`Horizon responded with ${res.status}`); + } + + const data = await res.json(); + const records: HorizonPayment[] = data?._embedded?.records ?? []; + setTransactions(parsePayments(records, publicKey)); + setFunded(true); + } catch (err) { + console.error("Failed to load wallet activity:", err); + setError("Unable to load wallet activity. Check your connection and retry."); + } finally { + setLoading(false); + } + }, [publicKey]); + + useEffect(() => { + // Defer to a microtask so the loading/reset setState calls don't run + // synchronously inside the effect (avoids cascading renders). + let active = true; + void Promise.resolve().then(() => { + if (active) void fetchActivity(); + }); + return () => { + active = false; + }; + }, [fetchActivity]); + + const refreshBalances = useCallback(async () => { + await Promise.all([refreshContextBalances(), fetchActivity()]); + }, [refreshContextBalances, fetchActivity]); + + const fundWithFriendbot = useCallback(async () => { + if (!publicKey) return; + setFunding(true); + setError(null); + try { + const res = await fetch(`${FRIENDBOT_URL}/?addr=${publicKey}`); + if (!res.ok) { + // Friendbot replies 400 "account already funded to starting balance" + // for accounts that already exist — surface that clearly. + let detail = `Friendbot responded with ${res.status}`; + try { + const body = await res.json(); + if (typeof body?.detail === "string") detail = body.detail; + } catch { + // non-JSON body; keep the status-based message + } + if (detail.toLowerCase().includes("already funded")) { + throw new Error( + "This account is already funded. Friendbot only funds new testnet accounts.", + ); + } + throw new Error(detail); + } + await refreshBalances(); + } catch (err) { + console.error("Friendbot funding failed:", err); + setError( + err instanceof Error + ? err.message + : "Friendbot funding failed. Try again in a moment.", + ); + } finally { + setFunding(false); + } + }, [publicKey, refreshBalances]); + + const find = (code: string) => + balances.find((b) => b.assetCode === code)?.balance ?? null; + + return { + publicKey, + isConnected, + balances, + xlm: find("XLM"), + usdc: find("USDC"), + transactions, + funded, + loading, + error, + refreshBalances, + fundWithFriendbot, + funding, + }; +} diff --git a/frontend/src/components/creator/Sidebar.tsx b/frontend/src/components/creator/Sidebar.tsx index 6e17a3d..ef14edd 100644 --- a/frontend/src/components/creator/Sidebar.tsx +++ b/frontend/src/components/creator/Sidebar.tsx @@ -14,7 +14,7 @@ export default function Sidebar() { const [isOpen, setIsOpen] = useState(false); const linkClasses = (href: string) => - `flex items-center gap-2 pl-3 py-2 transition-colors + `flex items-center gap-2 pl-3 py-2.5 min-h-11 transition-colors ${ pathname === href ? "border-l-[3px] border-[#9011FF] text-[#FFFFFF]" @@ -26,7 +26,8 @@ export default function Sidebar() { {/* Hamburger button - only visible on mobile */} @@ -34,7 +35,7 @@ export default function Sidebar() { {/* Overlay - only visible on mobile when sidebar is open */} {isOpen && (
setIsOpen(false)} /> )} @@ -42,7 +43,7 @@ export default function Sidebar() { {/* Sidebar */}