page.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. "use client";
  2. import Link from "next/link";
  3. import { useRouter } from "next/navigation";
  4. import { useEffect, useState } from "react";
  5. import { useAccount } from "wagmi";
  6. import {
  7. IdentityConsumer,
  8. type PortalIdentity,
  9. } from "@/components/auth/identity-gate";
  10. import { PageShell } from "@/components/layout/page-shell";
  11. import { Badge } from "@/components/ui/badge";
  12. import { Button, buttonVariants } from "@/components/ui/button";
  13. import {
  14. Card,
  15. CardContent,
  16. CardDescription,
  17. CardHeader,
  18. CardTitle,
  19. } from "@/components/ui/card";
  20. import { WalletsPanel } from "@/components/wallet/wallets-panel";
  21. import { MOCK_TRANSACTIONS } from "@/lib/explorer-mock";
  22. import { hasVault, readVault, shortAddress } from "@/lib/mavi/storage";
  23. import {
  24. membershipFor,
  25. roleCapabilities,
  26. roleLabel,
  27. type MembershipProfile,
  28. } from "@/lib/membership";
  29. import { cn } from "@/lib/utils";
  30. export default function DashboardPage() {
  31. return (
  32. <IdentityConsumer>
  33. {(identity) => <DashboardBody identity={identity} />}
  34. </IdentityConsumer>
  35. );
  36. }
  37. function DashboardBody({ identity }: { identity: PortalIdentity }) {
  38. const router = useRouter();
  39. const [indexerLive, setIndexerLive] = useState<boolean | null>(null);
  40. const [maviShort, setMaviShort] = useState<string | null>(null);
  41. const { address: wagmiAddress, isConnected } = useAccount();
  42. useEffect(() => {
  43. if (hasVault()) {
  44. const a = readVault()?.address;
  45. setMaviShort(a ? shortAddress(a) : null);
  46. } else {
  47. setMaviShort(null);
  48. }
  49. }, []);
  50. useEffect(() => {
  51. if (!identity.isLoading && !identity.isSignedIn) {
  52. router.replace("/portal/sign-in");
  53. }
  54. }, [identity.isLoading, identity.isSignedIn, router]);
  55. useEffect(() => {
  56. let cancelled = false;
  57. void fetch("/api/explorer-health")
  58. .then((r) => r.json())
  59. .then((d: { ok?: boolean }) => {
  60. if (!cancelled) setIndexerLive(Boolean(d.ok));
  61. })
  62. .catch(() => {
  63. if (!cancelled) setIndexerLive(false);
  64. });
  65. return () => {
  66. cancelled = true;
  67. };
  68. }, []);
  69. if (identity.isLoading) {
  70. return (
  71. <PageShell title="Dashboard" description="Checking your session…">
  72. <div className="space-y-4" aria-busy="true" aria-live="polite">
  73. <div className="h-8 w-40 animate-pulse rounded-md bg-muted" />
  74. <div className="grid gap-4 sm:grid-cols-3">
  75. {[0, 1, 2].map((i) => (
  76. <div
  77. key={i}
  78. className="h-32 animate-pulse rounded-xl border border-border bg-muted/40"
  79. />
  80. ))}
  81. </div>
  82. <p className="text-sm text-muted-foreground">Loading your session…</p>
  83. </div>
  84. </PageShell>
  85. );
  86. }
  87. if (!identity.isSignedIn) {
  88. return (
  89. <PageShell title="Dashboard" description="Sign in required.">
  90. <p className="text-sm text-muted-foreground">
  91. Redirecting to sign in…
  92. </p>
  93. </PageShell>
  94. );
  95. }
  96. const linkedWallet =
  97. wagmiAddress || identity.walletAddress || null;
  98. const membership = membershipFor({
  99. isSignedIn: identity.isSignedIn,
  100. source: identity.source,
  101. walletAddress: linkedWallet,
  102. label: identity.label,
  103. });
  104. const recent = MOCK_TRANSACTIONS.slice(0, 4);
  105. const walletsTitle = (() => {
  106. const parts: string[] = [];
  107. if (linkedWallet) parts.push(shortAddress(linkedWallet));
  108. if (maviShort && maviShort !== (linkedWallet ? shortAddress(linkedWallet) : "")) {
  109. parts.push(maviShort);
  110. }
  111. if (parts.length === 0) return "Not connected";
  112. if (parts.length === 1) return parts[0]!;
  113. return `${parts[0]} · ${parts[1]}`;
  114. })();
  115. return (
  116. <PageShell
  117. title="Dashboard"
  118. description="Your personal view of the krypco network."
  119. >
  120. <div className="mb-6 flex flex-wrap items-center gap-3">
  121. <Badge variant="accent">
  122. {identity.source === "wallet"
  123. ? "Wallet login"
  124. : identity.source === "konnos"
  125. ? "Konnos"
  126. : identity.source === "briven"
  127. ? "Email login"
  128. : "Demo session"}
  129. </Badge>
  130. {isConnected || identity.walletAddress ? (
  131. <Badge variant="outline">Wallet linked</Badge>
  132. ) : null}
  133. <span className="text-sm text-muted-foreground">{identity.label}</span>
  134. <Button
  135. type="button"
  136. variant="outline"
  137. size="sm"
  138. className="ml-auto"
  139. onClick={() =>
  140. void identity.signOut().then(() => router.push("/portal/sign-in"))
  141. }
  142. >
  143. Sign out
  144. </Button>
  145. </div>
  146. <div className="grid gap-4 sm:grid-cols-3">
  147. <MembershipCard membership={membership} />
  148. <Card>
  149. <CardHeader>
  150. <CardDescription>Wallets</CardDescription>
  151. <CardTitle className="font-mono text-base">{walletsTitle}</CardTitle>
  152. </CardHeader>
  153. <CardContent className="space-y-3">
  154. <WalletsPanel
  155. onConnected={() => {
  156. /* stay on dashboard */
  157. }}
  158. />
  159. <Link
  160. href="/portal/connect-wallet"
  161. className={cn(
  162. buttonVariants({ variant: "ghost", size: "sm" }),
  163. "px-0",
  164. )}
  165. >
  166. Full wallet guide →
  167. </Link>
  168. </CardContent>
  169. </Card>
  170. <Card>
  171. <CardHeader>
  172. <CardDescription>Network</CardDescription>
  173. <CardTitle className="text-lg">
  174. {indexerLive === null
  175. ? "Checking…"
  176. : indexerLive
  177. ? "Hybrid live"
  178. : "Hybrid preview"}
  179. </CardTitle>
  180. </CardHeader>
  181. <CardContent className="text-sm text-muted-foreground">
  182. {indexerLive
  183. ? "Indexer is up — explorer shows live sim data."
  184. : "Explorer falls back to mock until the indexer is running."}
  185. </CardContent>
  186. </Card>
  187. </div>
  188. <Card className="mt-6">
  189. <CardHeader>
  190. <CardTitle>Roles (light)</CardTitle>
  191. <CardDescription>
  192. Simple access labels for this portal. Deep chain membership and paid
  193. tiers come later.
  194. </CardDescription>
  195. </CardHeader>
  196. <CardContent className="space-y-3">
  197. <div className="flex flex-wrap gap-2">
  198. {membership.roles.map((r) => (
  199. <Badge key={r} variant="outline">
  200. {roleLabel(r)}
  201. </Badge>
  202. ))}
  203. </div>
  204. <ul className="list-inside list-disc text-sm text-muted-foreground">
  205. {membership.roles.flatMap((r) =>
  206. roleCapabilities(r).map((c) => (
  207. <li key={`${r}-${c}`}>{c}</li>
  208. )),
  209. )}
  210. </ul>
  211. <div className="flex flex-wrap gap-2 pt-1">
  212. <Link
  213. href="/portal/machine-auth"
  214. className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
  215. >
  216. Machine auth status
  217. </Link>
  218. <Link
  219. href="/portal/explorer"
  220. className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
  221. >
  222. Open explorer
  223. </Link>
  224. </div>
  225. </CardContent>
  226. </Card>
  227. <Card className="mt-6">
  228. <CardHeader>
  229. <CardTitle>Recent activity (sample)</CardTitle>
  230. <CardDescription>
  231. Sample transactions so the dashboard feels real. Live personal
  232. history needs the indexer + your addresses.
  233. </CardDescription>
  234. </CardHeader>
  235. <CardContent className="space-y-3">
  236. {recent.map((tx) => (
  237. <div
  238. key={tx.hash}
  239. className="flex flex-wrap items-center justify-between gap-2 border-b border-border/50 pb-3 last:border-0 last:pb-0"
  240. >
  241. <Link
  242. href={`/portal/explorer/tx/${encodeURIComponent(tx.hash)}`}
  243. className="font-mono text-[13px] text-accent-hover hover:underline"
  244. >
  245. {tx.hash.slice(0, 10)}…{tx.hash.slice(-6)}
  246. </Link>
  247. <Badge variant={tx.status}>{tx.status}</Badge>
  248. <span className="text-sm tabular-nums text-muted-foreground">
  249. {tx.value}
  250. </span>
  251. </div>
  252. ))}
  253. <Link
  254. href="/portal/explorer"
  255. className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
  256. >
  257. Open explorer
  258. </Link>
  259. </CardContent>
  260. </Card>
  261. </PageShell>
  262. );
  263. }
  264. function MembershipCard({ membership }: { membership: MembershipProfile }) {
  265. return (
  266. <Card>
  267. <CardHeader>
  268. <CardDescription>Membership</CardDescription>
  269. <CardTitle className="text-lg">{membership.tierLabel}</CardTitle>
  270. </CardHeader>
  271. <CardContent className="text-sm text-muted-foreground">
  272. {membership.blurb}
  273. </CardContent>
  274. </Card>
  275. );
  276. }