mavi-home.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. "use client";
  2. import Link from "next/link";
  3. import { useRouter, useSearchParams } from "next/navigation";
  4. import { useCallback, useEffect, useState } from "react";
  5. import { ActivityList } from "@/components/mavi/activity-list";
  6. import { NetworkSwitcher } from "@/components/mavi/network-switcher";
  7. import { UnlockPanel } from "@/components/mavi/unlock-panel";
  8. import { MaviWalletLogo } from "@/components/wallet/wallet-logos";
  9. import { Badge } from "@/components/ui/badge";
  10. import { Button, buttonVariants } from "@/components/ui/button";
  11. import {
  12. Card,
  13. CardContent,
  14. CardDescription,
  15. CardHeader,
  16. CardTitle,
  17. } from "@/components/ui/card";
  18. import { EmptyState } from "@/components/ui/empty-state";
  19. import { ErrorBanner } from "@/components/ui/error-banner";
  20. import {
  21. addActivity,
  22. notifyActivityChanged,
  23. } from "@/lib/mavi/activity";
  24. import { fetchEthBalance, fundFromAnvil } from "@/lib/mavi/chain";
  25. import { getActiveNetwork } from "@/lib/mavi/network";
  26. import {
  27. deleteVaultLocal,
  28. lockSession,
  29. readUnlockedSession,
  30. readVault,
  31. shortAddress,
  32. type UnlockedMavi,
  33. } from "@/lib/mavi/storage";
  34. import { readLastLoginMethod, type LoginMethodId } from "@/lib/last-login";
  35. import { cn } from "@/lib/utils";
  36. export function MaviHome() {
  37. const router = useRouter();
  38. const searchParams = useSearchParams();
  39. const [mounted, setMounted] = useState(false);
  40. const [exists, setExists] = useState(false);
  41. const [address, setAddress] = useState<string | null>(null);
  42. const [unlocked, setUnlocked] = useState<UnlockedMavi | null>(null);
  43. const [busy, setBusy] = useState(false);
  44. const [error, setError] = useState<string | null>(null);
  45. const [info, setInfo] = useState<string | null>(null);
  46. const [copied, setCopied] = useState(false);
  47. const [balanceEth, setBalanceEth] = useState<string | null>(null);
  48. const [chainOk, setChainOk] = useState<boolean | null>(null);
  49. const [chainNote, setChainNote] = useState<string | null>(null);
  50. const [lastUsed, setLastUsed] = useState<LoginMethodId | null>(null);
  51. const nextAfterUnlock = searchParams.get("next");
  52. const refresh = useCallback(() => {
  53. const vault = readVault();
  54. setExists(!!vault);
  55. setAddress(vault?.address ?? null);
  56. setUnlocked(readUnlockedSession());
  57. setLastUsed(readLastLoginMethod());
  58. }, []);
  59. const loadBalance = useCallback(async (addr: `0x${string}`) => {
  60. const b = await fetchEthBalance(addr);
  61. const net = getActiveNetwork();
  62. if (b.ok) {
  63. setBalanceEth(b.eth);
  64. setChainOk(true);
  65. setChainNote(
  66. `${b.networkLabel ?? net.shortLabel} · chain id ${b.chainId ?? net.chainId}`,
  67. );
  68. } else {
  69. setBalanceEth(null);
  70. setChainOk(false);
  71. setChainNote(b.error ?? `${net.shortLabel} offline`);
  72. }
  73. }, []);
  74. useEffect(() => {
  75. setMounted(true);
  76. refresh();
  77. const onUnlock = () => setUnlocked(readUnlockedSession());
  78. window.addEventListener("mavi-unlock", onUnlock);
  79. window.addEventListener("mavi-network", () => {
  80. const a = readVault()?.address as `0x${string}` | undefined;
  81. if (a) void loadBalance(a);
  82. });
  83. return () => {
  84. window.removeEventListener("mavi-unlock", onUnlock);
  85. };
  86. }, [loadBalance, refresh]);
  87. useEffect(() => {
  88. const addr = (unlocked?.address ?? address) as `0x${string}` | null;
  89. if (!addr || !exists) return;
  90. void loadBalance(addr);
  91. const t = setInterval(() => void loadBalance(addr), 12_000);
  92. return () => clearInterval(t);
  93. }, [address, exists, loadBalance, unlocked?.address]);
  94. function handleUnlocked(u: UnlockedMavi) {
  95. setUnlocked(u);
  96. setInfo("Unlocked for this browser tab — Send and tools work until you lock.");
  97. void loadBalance(u.address);
  98. if (nextAfterUnlock?.startsWith("/portal/mavi")) {
  99. router.replace(nextAfterUnlock);
  100. }
  101. }
  102. function onLock() {
  103. lockSession();
  104. setUnlocked(null);
  105. setInfo("Locked. Unlock once to use Send and Get test ETH in this tab.");
  106. }
  107. function onCopy() {
  108. const a = unlocked?.address ?? address;
  109. if (!a) return;
  110. void navigator.clipboard.writeText(a).then(() => {
  111. setCopied(true);
  112. setTimeout(() => setCopied(false), 1500);
  113. });
  114. }
  115. async function onFund() {
  116. if (!unlocked) {
  117. setError("Unlock mavi first, then get test ETH.");
  118. return;
  119. }
  120. setError(null);
  121. setInfo(null);
  122. setBusy(true);
  123. try {
  124. const { hash } = await fundFromAnvil(unlocked.address, "1");
  125. setInfo(`Test ETH sent. Tx ${hash.slice(0, 12)}…`);
  126. addActivity({
  127. kind: "fund",
  128. wallet: unlocked.address,
  129. hash,
  130. to: unlocked.address,
  131. amountEth: "1",
  132. note: `Test bank · ${getActiveNetwork().shortLabel}`,
  133. });
  134. notifyActivityChanged();
  135. await loadBalance(unlocked.address);
  136. } catch (e) {
  137. const msg = e instanceof Error ? e.message : "Fund failed";
  138. setError(
  139. /connect|fetch|network|ECONNREFUSED/i.test(msg)
  140. ? "Local chain is not running. Open a terminal and run: bun run chains:up"
  141. : msg,
  142. );
  143. } finally {
  144. setBusy(false);
  145. }
  146. }
  147. if (!mounted) {
  148. return (
  149. <Card>
  150. <CardContent className="py-10 text-center text-sm text-muted-foreground">
  151. Loading mavi…
  152. </CardContent>
  153. </Card>
  154. );
  155. }
  156. if (!exists) {
  157. return (
  158. <div className="space-y-4">
  159. <EmptyState
  160. tone="default"
  161. title="No mavi wallet on this browser yet"
  162. description="Create a new wallet and back up 24 words, or import a recovery phrase you already wrote down. Keys stay in this browser only."
  163. action={
  164. <div className="flex w-full max-w-sm flex-col gap-2">
  165. <Link
  166. href="/portal/mavi/create"
  167. className={cn(
  168. buttonVariants({ variant: "primary" }),
  169. "inline-flex w-full items-center justify-center gap-2",
  170. )}
  171. >
  172. <MaviWalletLogo size={20} />
  173. Create mavi wallet
  174. </Link>
  175. <Link
  176. href="/portal/mavi/import"
  177. className={cn(
  178. buttonVariants({ variant: "outline" }),
  179. "w-full",
  180. )}
  181. >
  182. Import recovery phrase
  183. </Link>
  184. </div>
  185. }
  186. />
  187. {lastUsed === "mavi" ? (
  188. <p className="text-center text-xs text-muted-foreground">
  189. You last signed in with mavi on this browser — create or import
  190. again to continue.
  191. </p>
  192. ) : null}
  193. </div>
  194. );
  195. }
  196. return (
  197. <div className="space-y-4">
  198. <Card>
  199. <CardHeader>
  200. <div className="mb-2 flex flex-wrap items-center gap-2">
  201. <MaviWalletLogo size={28} />
  202. <CardTitle>mavi wallet</CardTitle>
  203. <Badge variant={unlocked ? "anchored" : "pending"}>
  204. {unlocked ? "Unlocked" : "Locked"}
  205. </Badge>
  206. {lastUsed === "mavi" ? (
  207. <Badge variant="accent">Last used to sign in</Badge>
  208. ) : null}
  209. </div>
  210. <CardDescription>
  211. {unlocked
  212. ? "This tab is unlocked — balance, send, and test ETH work until you lock or close the tab."
  213. : "Locked — unlock once for this tab, then use Send without typing the password again."}
  214. {address ? ` · ${shortAddress(address)}` : ""}
  215. </CardDescription>
  216. </CardHeader>
  217. <CardContent className="space-y-4">
  218. {error ? (
  219. <ErrorBanner title="Something went wrong">{error}</ErrorBanner>
  220. ) : null}
  221. {info ? (
  222. <p className="text-sm text-proof" role="status">
  223. {info}
  224. </p>
  225. ) : null}
  226. <NetworkSwitcher
  227. onChange={() => {
  228. const a = (unlocked?.address ?? address) as
  229. | `0x${string}`
  230. | null;
  231. if (a) void loadBalance(a);
  232. }}
  233. />
  234. <div className="grid gap-3 sm:grid-cols-2">
  235. <div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
  236. <p className="text-xs uppercase tracking-wide text-muted-foreground">
  237. Address
  238. </p>
  239. <p className="mt-1 break-all font-mono text-xs sm:text-sm">
  240. {unlocked?.address ?? address}
  241. </p>
  242. <Button
  243. type="button"
  244. variant="outline"
  245. size="sm"
  246. className="mt-2"
  247. onClick={onCopy}
  248. >
  249. {copied ? "Copied" : "Copy address"}
  250. </Button>
  251. </div>
  252. <div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
  253. <p className="text-xs uppercase tracking-wide text-muted-foreground">
  254. Balance
  255. </p>
  256. <p className="mt-1 font-display text-xl font-semibold tabular-nums">
  257. {balanceEth != null
  258. ? `${formatEth(balanceEth)} ETH`
  259. : chainOk === false
  260. ? "—"
  261. : "…"}
  262. </p>
  263. <p className="mt-1 text-[11px] text-muted-foreground leading-snug">
  264. {chainNote ??
  265. (chainOk === null ? "Checking chain…" : null)}
  266. </p>
  267. </div>
  268. </div>
  269. {unlocked ? (
  270. <div className="flex flex-wrap gap-2">
  271. <Button type="button" variant="ghost" size="sm" onClick={onLock}>
  272. Lock
  273. </Button>
  274. <Link
  275. href="/portal/mavi/send"
  276. className={cn(buttonVariants({ size: "sm" }))}
  277. >
  278. Send
  279. </Link>
  280. <Link
  281. href="/portal/mavi/receive"
  282. className={cn(
  283. buttonVariants({ variant: "outline", size: "sm" }),
  284. )}
  285. >
  286. Receive
  287. </Link>
  288. <Button
  289. type="button"
  290. variant="outline"
  291. size="sm"
  292. disabled={busy || chainOk === false}
  293. onClick={() => void onFund()}
  294. >
  295. {busy ? "Working…" : "Get test ETH"}
  296. </Button>
  297. <Link
  298. href="/portal/sign-in"
  299. className={cn(
  300. buttonVariants({ variant: "outline", size: "sm" }),
  301. )}
  302. >
  303. Portal sign-in
  304. </Link>
  305. </div>
  306. ) : (
  307. <div className="space-y-3 border-t border-border pt-4">
  308. <UnlockPanel
  309. onUnlocked={handleUnlocked}
  310. title="Unlock for this tab"
  311. description="Enter password once. Stay unlocked until you click Lock or close the tab. Receive still works while locked."
  312. submitLabel="Unlock mavi"
  313. />
  314. <Link
  315. href="/portal/mavi/receive"
  316. className={cn(
  317. buttonVariants({ variant: "outline" }),
  318. "inline-flex w-full items-center justify-center",
  319. )}
  320. >
  321. Receive (address + QR)
  322. </Link>
  323. </div>
  324. )}
  325. </CardContent>
  326. </Card>
  327. {address ? <ActivityList walletAddress={address} compact /> : null}
  328. <Card>
  329. <CardHeader>
  330. <CardTitle className="text-base">More</CardTitle>
  331. <CardDescription>
  332. Import another phrase only after removing this vault (or confirm
  333. replace on import).
  334. </CardDescription>
  335. </CardHeader>
  336. <CardContent className="flex flex-wrap gap-2">
  337. <Link
  338. href="/portal/mavi/import"
  339. className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
  340. >
  341. Import recovery phrase
  342. </Link>
  343. <Link
  344. href="/portal/mavi/create"
  345. className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
  346. >
  347. Create new (after remove)
  348. </Link>
  349. <Link
  350. href="/portal/mavi/activity"
  351. className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
  352. >
  353. Activity
  354. </Link>
  355. <p className="w-full text-xs text-muted-foreground leading-relaxed pt-1">
  356. Browser add-on with its own encrypted keys:{" "}
  357. <code className="text-[11px]">apps/mavi-extension</code> — run{" "}
  358. <code className="text-[11px]">bun run --cwd apps/mavi-extension build</code>{" "}
  359. then load unpacked (Chrome, Brave, Edge, Opera, Firefox). Portal and
  360. extension vaults are separate storage.
  361. </p>
  362. </CardContent>
  363. </Card>
  364. <Card>
  365. <CardHeader>
  366. <CardTitle className="text-base">Danger zone</CardTitle>
  367. <CardDescription>
  368. Removing the local vault only deletes the encrypted copy in this
  369. browser. Keep your recovery phrase safe.
  370. </CardDescription>
  371. </CardHeader>
  372. <CardContent>
  373. <Button
  374. type="button"
  375. variant="outline"
  376. size="sm"
  377. className="border-pending/40 text-pending"
  378. onClick={() => {
  379. if (
  380. window.confirm(
  381. "Remove mavi vault from this browser? You will need your recovery phrase to restore.",
  382. )
  383. ) {
  384. deleteVaultLocal();
  385. refresh();
  386. setError(null);
  387. setInfo(null);
  388. setBalanceEth(null);
  389. }
  390. }}
  391. >
  392. Remove vault from this browser
  393. </Button>
  394. </CardContent>
  395. </Card>
  396. </div>
  397. );
  398. }
  399. function formatEth(eth: string): string {
  400. const n = Number(eth);
  401. if (!Number.isFinite(n)) return eth;
  402. if (n === 0) return "0";
  403. if (n >= 1000) return n.toFixed(2);
  404. if (n >= 1) return n.toFixed(4).replace(/\.?0+$/, "");
  405. return n.toFixed(6).replace(/\.?0+$/, "");
  406. }