| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- "use client";
- import Link from "next/link";
- import { useRouter, useSearchParams } from "next/navigation";
- import { useCallback, useEffect, useState } from "react";
- import { ActivityList } from "@/components/mavi/activity-list";
- import { NetworkSwitcher } from "@/components/mavi/network-switcher";
- import { UnlockPanel } from "@/components/mavi/unlock-panel";
- import { MaviWalletLogo } from "@/components/wallet/wallet-logos";
- import { Badge } from "@/components/ui/badge";
- import { Button, buttonVariants } from "@/components/ui/button";
- import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
- } from "@/components/ui/card";
- import { EmptyState } from "@/components/ui/empty-state";
- import { ErrorBanner } from "@/components/ui/error-banner";
- import {
- addActivity,
- notifyActivityChanged,
- } from "@/lib/mavi/activity";
- import { fetchEthBalance, fundFromAnvil } from "@/lib/mavi/chain";
- import { getActiveNetwork } from "@/lib/mavi/network";
- import {
- deleteVaultLocal,
- lockSession,
- readUnlockedSession,
- readVault,
- shortAddress,
- type UnlockedMavi,
- } from "@/lib/mavi/storage";
- import { readLastLoginMethod, type LoginMethodId } from "@/lib/last-login";
- import { cn } from "@/lib/utils";
- export function MaviHome() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const [mounted, setMounted] = useState(false);
- const [exists, setExists] = useState(false);
- const [address, setAddress] = useState<string | null>(null);
- const [unlocked, setUnlocked] = useState<UnlockedMavi | null>(null);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState<string | null>(null);
- const [info, setInfo] = useState<string | null>(null);
- const [copied, setCopied] = useState(false);
- const [balanceEth, setBalanceEth] = useState<string | null>(null);
- const [chainOk, setChainOk] = useState<boolean | null>(null);
- const [chainNote, setChainNote] = useState<string | null>(null);
- const [lastUsed, setLastUsed] = useState<LoginMethodId | null>(null);
- const nextAfterUnlock = searchParams.get("next");
- const refresh = useCallback(() => {
- const vault = readVault();
- setExists(!!vault);
- setAddress(vault?.address ?? null);
- setUnlocked(readUnlockedSession());
- setLastUsed(readLastLoginMethod());
- }, []);
- const loadBalance = useCallback(async (addr: `0x${string}`) => {
- const b = await fetchEthBalance(addr);
- const net = getActiveNetwork();
- if (b.ok) {
- setBalanceEth(b.eth);
- setChainOk(true);
- setChainNote(
- `${b.networkLabel ?? net.shortLabel} · chain id ${b.chainId ?? net.chainId}`,
- );
- } else {
- setBalanceEth(null);
- setChainOk(false);
- setChainNote(b.error ?? `${net.shortLabel} offline`);
- }
- }, []);
- useEffect(() => {
- setMounted(true);
- refresh();
- const onUnlock = () => setUnlocked(readUnlockedSession());
- window.addEventListener("mavi-unlock", onUnlock);
- window.addEventListener("mavi-network", () => {
- const a = readVault()?.address as `0x${string}` | undefined;
- if (a) void loadBalance(a);
- });
- return () => {
- window.removeEventListener("mavi-unlock", onUnlock);
- };
- }, [loadBalance, refresh]);
- useEffect(() => {
- const addr = (unlocked?.address ?? address) as `0x${string}` | null;
- if (!addr || !exists) return;
- void loadBalance(addr);
- const t = setInterval(() => void loadBalance(addr), 12_000);
- return () => clearInterval(t);
- }, [address, exists, loadBalance, unlocked?.address]);
- function handleUnlocked(u: UnlockedMavi) {
- setUnlocked(u);
- setInfo("Unlocked for this browser tab — Send and tools work until you lock.");
- void loadBalance(u.address);
- if (nextAfterUnlock?.startsWith("/portal/mavi")) {
- router.replace(nextAfterUnlock);
- }
- }
- function onLock() {
- lockSession();
- setUnlocked(null);
- setInfo("Locked. Unlock once to use Send and Get test ETH in this tab.");
- }
- function onCopy() {
- const a = unlocked?.address ?? address;
- if (!a) return;
- void navigator.clipboard.writeText(a).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- });
- }
- async function onFund() {
- if (!unlocked) {
- setError("Unlock mavi first, then get test ETH.");
- return;
- }
- setError(null);
- setInfo(null);
- setBusy(true);
- try {
- const { hash } = await fundFromAnvil(unlocked.address, "1");
- setInfo(`Test ETH sent. Tx ${hash.slice(0, 12)}…`);
- addActivity({
- kind: "fund",
- wallet: unlocked.address,
- hash,
- to: unlocked.address,
- amountEth: "1",
- note: `Test bank · ${getActiveNetwork().shortLabel}`,
- });
- notifyActivityChanged();
- await loadBalance(unlocked.address);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Fund failed";
- setError(
- /connect|fetch|network|ECONNREFUSED/i.test(msg)
- ? "Local chain is not running. Open a terminal and run: bun run chains:up"
- : msg,
- );
- } finally {
- setBusy(false);
- }
- }
- if (!mounted) {
- return (
- <Card>
- <CardContent className="py-10 text-center text-sm text-muted-foreground">
- Loading mavi…
- </CardContent>
- </Card>
- );
- }
- if (!exists) {
- return (
- <div className="space-y-4">
- <EmptyState
- tone="default"
- title="No mavi wallet on this browser yet"
- 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."
- action={
- <div className="flex w-full max-w-sm flex-col gap-2">
- <Link
- href="/portal/mavi/create"
- className={cn(
- buttonVariants({ variant: "primary" }),
- "inline-flex w-full items-center justify-center gap-2",
- )}
- >
- <MaviWalletLogo size={20} />
- Create mavi wallet
- </Link>
- <Link
- href="/portal/mavi/import"
- className={cn(
- buttonVariants({ variant: "outline" }),
- "w-full",
- )}
- >
- Import recovery phrase
- </Link>
- </div>
- }
- />
- {lastUsed === "mavi" ? (
- <p className="text-center text-xs text-muted-foreground">
- You last signed in with mavi on this browser — create or import
- again to continue.
- </p>
- ) : null}
- </div>
- );
- }
- return (
- <div className="space-y-4">
- <Card>
- <CardHeader>
- <div className="mb-2 flex flex-wrap items-center gap-2">
- <MaviWalletLogo size={28} />
- <CardTitle>mavi wallet</CardTitle>
- <Badge variant={unlocked ? "anchored" : "pending"}>
- {unlocked ? "Unlocked" : "Locked"}
- </Badge>
- {lastUsed === "mavi" ? (
- <Badge variant="accent">Last used to sign in</Badge>
- ) : null}
- </div>
- <CardDescription>
- {unlocked
- ? "This tab is unlocked — balance, send, and test ETH work until you lock or close the tab."
- : "Locked — unlock once for this tab, then use Send without typing the password again."}
- {address ? ` · ${shortAddress(address)}` : ""}
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- {error ? (
- <ErrorBanner title="Something went wrong">{error}</ErrorBanner>
- ) : null}
- {info ? (
- <p className="text-sm text-proof" role="status">
- {info}
- </p>
- ) : null}
- <NetworkSwitcher
- onChange={() => {
- const a = (unlocked?.address ?? address) as
- | `0x${string}`
- | null;
- if (a) void loadBalance(a);
- }}
- />
- <div className="grid gap-3 sm:grid-cols-2">
- <div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
- <p className="text-xs uppercase tracking-wide text-muted-foreground">
- Address
- </p>
- <p className="mt-1 break-all font-mono text-xs sm:text-sm">
- {unlocked?.address ?? address}
- </p>
- <Button
- type="button"
- variant="outline"
- size="sm"
- className="mt-2"
- onClick={onCopy}
- >
- {copied ? "Copied" : "Copy address"}
- </Button>
- </div>
- <div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
- <p className="text-xs uppercase tracking-wide text-muted-foreground">
- Balance
- </p>
- <p className="mt-1 font-display text-xl font-semibold tabular-nums">
- {balanceEth != null
- ? `${formatEth(balanceEth)} ETH`
- : chainOk === false
- ? "—"
- : "…"}
- </p>
- <p className="mt-1 text-[11px] text-muted-foreground leading-snug">
- {chainNote ??
- (chainOk === null ? "Checking chain…" : null)}
- </p>
- </div>
- </div>
- {unlocked ? (
- <div className="flex flex-wrap gap-2">
- <Button type="button" variant="ghost" size="sm" onClick={onLock}>
- Lock
- </Button>
- <Link
- href="/portal/mavi/send"
- className={cn(buttonVariants({ size: "sm" }))}
- >
- Send
- </Link>
- <Link
- href="/portal/mavi/receive"
- className={cn(
- buttonVariants({ variant: "outline", size: "sm" }),
- )}
- >
- Receive
- </Link>
- <Button
- type="button"
- variant="outline"
- size="sm"
- disabled={busy || chainOk === false}
- onClick={() => void onFund()}
- >
- {busy ? "Working…" : "Get test ETH"}
- </Button>
- <Link
- href="/portal/sign-in"
- className={cn(
- buttonVariants({ variant: "outline", size: "sm" }),
- )}
- >
- Portal sign-in
- </Link>
- </div>
- ) : (
- <div className="space-y-3 border-t border-border pt-4">
- <UnlockPanel
- onUnlocked={handleUnlocked}
- title="Unlock for this tab"
- description="Enter password once. Stay unlocked until you click Lock or close the tab. Receive still works while locked."
- submitLabel="Unlock mavi"
- />
- <Link
- href="/portal/mavi/receive"
- className={cn(
- buttonVariants({ variant: "outline" }),
- "inline-flex w-full items-center justify-center",
- )}
- >
- Receive (address + QR)
- </Link>
- </div>
- )}
- </CardContent>
- </Card>
- {address ? <ActivityList walletAddress={address} compact /> : null}
- <Card>
- <CardHeader>
- <CardTitle className="text-base">More</CardTitle>
- <CardDescription>
- Import another phrase only after removing this vault (or confirm
- replace on import).
- </CardDescription>
- </CardHeader>
- <CardContent className="flex flex-wrap gap-2">
- <Link
- href="/portal/mavi/import"
- className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
- >
- Import recovery phrase
- </Link>
- <Link
- href="/portal/mavi/create"
- className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
- >
- Create new (after remove)
- </Link>
- <Link
- href="/portal/mavi/activity"
- className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
- >
- Activity
- </Link>
- <p className="w-full text-xs text-muted-foreground leading-relaxed pt-1">
- Browser add-on with its own encrypted keys:{" "}
- <code className="text-[11px]">apps/mavi-extension</code> — run{" "}
- <code className="text-[11px]">bun run --cwd apps/mavi-extension build</code>{" "}
- then load unpacked (Chrome, Brave, Edge, Opera, Firefox). Portal and
- extension vaults are separate storage.
- </p>
- </CardContent>
- </Card>
- <Card>
- <CardHeader>
- <CardTitle className="text-base">Danger zone</CardTitle>
- <CardDescription>
- Removing the local vault only deletes the encrypted copy in this
- browser. Keep your recovery phrase safe.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <Button
- type="button"
- variant="outline"
- size="sm"
- className="border-pending/40 text-pending"
- onClick={() => {
- if (
- window.confirm(
- "Remove mavi vault from this browser? You will need your recovery phrase to restore.",
- )
- ) {
- deleteVaultLocal();
- refresh();
- setError(null);
- setInfo(null);
- setBalanceEth(null);
- }
- }}
- >
- Remove vault from this browser
- </Button>
- </CardContent>
- </Card>
- </div>
- );
- }
- function formatEth(eth: string): string {
- const n = Number(eth);
- if (!Number.isFinite(n)) return eth;
- if (n === 0) return "0";
- if (n >= 1000) return n.toFixed(2);
- if (n >= 1) return n.toFixed(4).replace(/\.?0+$/, "");
- return n.toFixed(6).replace(/\.?0+$/, "");
- }
|