diff --git a/docs/wallet-connection.md b/docs/wallet-connection.md new file mode 100644 index 0000000..9f13d43 --- /dev/null +++ b/docs/wallet-connection.md @@ -0,0 +1,180 @@ +# Wallet Connection + +This document covers how wallet connectivity works in the WEB3UOA site and how to use it when building new features. + +## Stack + +| Library | Version | Role | +|---|---|---| +| [wagmi](https://wagmi.sh) | v3 | React hooks for wallet state | +| [viem](https://viem.sh) | v2 | Low-level Ethereum client | +| [@reown/appkit](https://reown.com/appkit) | v1 | Wallet connection modal UI | +| [@tanstack/react-query](https://tanstack.com/query) | — | Async state management for wagmi | + +## Architecture + +``` +Web3Provider (src/components/web3-provider.tsx) + └─ WagmiProvider + └─ QueryClientProvider + └─ App (all pages and components) +``` + +`Web3Provider` wraps the entire app in `src/app/layout.tsx`, so wallet state is globally available — no prop drilling or per-page setup needed. + +### Supported networks + +- Ethereum Mainnet +- Sepolia Testnet + +### Reown project ID + +The modal requires a `NEXT_PUBLIC_REOWN_PROJECT_ID` environment variable. A fallback dev ID is hardcoded for local development. For production, set the real project ID in your environment config. + +--- + +## How to access wallet state + +**Always use the `useWallet` hook.** Never import wagmi/appkit hooks directly in components — `useWallet` centralises the SSR mounting guard and exposes everything you need. + +```ts +import { useWallet } from "@/hooks/use-wallet"; + +const { address, isConnected, chainId, isAdmin, connect, disconnect } = useWallet(); +``` + +### Returned values + +| Field | Type | Description | +|---|---|---| +| `address` | `string \| undefined` | Connected wallet address | +| `isConnected` | `boolean` | True only after client-side mount (safe for SSR) | +| `chainId` | `number \| undefined` | Current network chain ID | +| `isAdmin` | `boolean` | True if address is in the admin allowlist | +| `connect` | `() => void` | Opens the Reown wallet modal | +| `disconnect` | `() => void` | Disconnects the wallet | + +### Why `mounted` matters + +Wallet state is only available in the browser. Without the mounting guard, server-rendered HTML will mismatch the client, causing React hydration errors. `useWallet` handles this internally — `isConnected` will always be `false` on the server and during the first render, becoming accurate once the component mounts. + +If you need to suppress a layout shift during that first render (e.g. a button that changes label), check `mounted` directly: + +```ts +const { mounted, isConnected } = useWallet(); + +if (!mounted) return ; +``` + +--- + +## The WalletButton component + +`src/components/wallet-button.tsx` is a ready-made button that handles all three states automatically: + +| State | What renders | +|---|---| +| Not mounted | Invisible placeholder (prevents layout shift) | +| Disconnected | "Connect Wallet" button — opens the modal on click | +| Connected | Truncated address (e.g. `0xAB…CD`) — links to `/profile` | + +Use it anywhere a connect/disconnect control is needed: + +```tsx +import { WalletButton } from "@/components/wallet-button"; + + +``` + +--- + +## Building a wallet-gated feature + +The standard pattern for any page or section that requires a connected wallet: + +```tsx +"use client"; + +import { useWallet } from "@/hooks/use-wallet"; +import { WalletButton } from "@/components/wallet-button"; + +export function MyFeature() { + const { address, isConnected, mounted } = useWallet(); + + if (!mounted) return null; // or a loading skeleton + + if (!isConnected) { + return ( +
+

Connect your wallet to continue.

+ +
+ ); + } + + return
Connected as {address}
; +} +``` + +--- + +## Admin authentication + +The admin panel (`src/app/admin/page.tsx`) uses a **sign-to-authenticate** pattern. No private keys are involved — the user proves ownership of their address by signing a message, and the signature is verified server-side. + +### Flow + +1. User connects wallet (must be an address in the admin allowlist) +2. User clicks "Sign Message" — `useSignMessage` from wagmi prompts their wallet +3. The signature, address, and timestamp are sent as HTTP headers on all subsequent admin API requests +4. The server verifies the signature before processing any request + +### Sending authenticated requests + +```ts +import { useSignMessage } from "wagmi"; + +const { signMessageAsync } = useSignMessage(); + +const timestamp = Date.now().toString(); +const signature = await signMessageAsync({ message: `Admin Auth ${timestamp}` }); + +const authHeaders = { + "x-admin-address": address, + "x-admin-signature": signature, + "x-admin-timestamp": timestamp, +}; + +// Pass authHeaders to any protected API route +fetch("/api/admin/claims", { headers: authHeaders }); +``` + +### Adding a new protected API route + +Check for the auth headers at the top of your route handler. See any existing route in `src/app/api/admin/` for the verification pattern. + +--- + +## Profile page + +`/profile` (`src/app/profile/page.tsx`) is the wallet-connected user's home base. Currently it shows: + +- Connection status and network +- Full wallet address with a copy button +- Disconnect button + +As new features are added (event attendance, ENS claim status, etc.), they should be surfaced here. + +--- + +## Key files + +| File | Purpose | +|---|---| +| `src/components/web3-provider.tsx` | App-level wagmi + Reown setup | +| `src/hooks/use-wallet.ts` | **Shared hook — use this in all components** | +| `src/components/wallet-button.tsx` | Reusable connect/profile button | +| `src/app/profile/page.tsx` | User profile page | +| `src/components/ens-claim.tsx` | ENS subname claim flow | +| `src/app/admin/page.tsx` | Admin panel with sign-to-auth | +| `src/lib/admin-auth.ts` | Admin address allowlist | diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 11b42d1..7d7b3e1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,24 +1,21 @@ "use client"; -import { useState, useEffect } from "react"; -import { useAccount, useSignMessage } from "wagmi"; +import { useState } from "react"; +import { useWallet } from "@/hooks/use-wallet"; +import { useSignMessage } from "wagmi"; import { Button } from "@/components/ui/button"; +import { WalletButton } from "@/components/wallet-button"; export default function AdminPage() { - const { address, isConnected } = useAccount(); + const { address, isConnected, mounted } = useWallet(); const { signMessageAsync } = useSignMessage(); const [authHeader, setAuthHeader] = useState(null); - const [mounted, setMounted] = useState(false); const [claims, setClaims] = useState([]); const [activeNames, setActiveNames] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - useEffect(() => { - setMounted(true); - }, []); - const authenticate = async () => { if (!address) return; try { @@ -137,7 +134,7 @@ export default function AdminPage() { Connect owner wallet to access.

- +
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..c46f407 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useState } from "react"; +import { useWallet } from "@/hooks/use-wallet"; +import { WalletButton } from "@/components/wallet-button"; +import { Copy, CheckCheck, PenTool } from "lucide-react"; + +export default function ProfilePage() { + const { address, isConnected, disconnect, mounted } = useWallet(); + const [copied, setCopied] = useState(false); + const [displayName, setDisplayName] = useState("Name"); + const [isEditingName, setIsEditingName] = useState(false); + const [profileImage, setProfileImage] = useState(null); + + const copyAddress = () => { + if (!address) return; + navigator.clipboard.writeText(address); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleImageUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + setProfileImage(e.target?.result as string); + }; + reader.readAsDataURL(file); + } + }; + + // Hardcoded 'badge elements', replace with a component later + const badgeItems: Record = {}; + for (let i = 0; i < 9; i += 1) { + badgeItems[i] = null; + } + + // Hardcoded 'event history', replace this with a component later + const eventHistory = [ + { id: 1, title: "Launch Party" }, + { id: 2, title: "Hackathon" }, + ]; + + if (!mounted) { + return ( +
+

Loading...

+
+ ); + } + + if (!isConnected) { + return ( +
+
+
+ WEB3UOA +
+
+

+ Your Profile +

+

+ Connect your wallet to view your profile. +

+
+ +
+
+ ); + } + + return ( +
+ {/* Profile Card */} +
+
+
+ {profileImage ? ( + Profile + ) : ( +
No Image
+ )} + +
+ +
+ {isEditingName ? ( +
+ setDisplayName(e.target.value)} + className="w-full md:w-auto text-xl font-bold border rounded px-3 py-2" + onKeyDown={(e) => { + if (e.key === "Enter") { + setIsEditingName(false); + } + }} + /> + +
+ ) : ( +
+

{displayName}

+ +
+ )} +
+ + {address} + +
+ + +
+
+
+
+
+ +
+ {/* Badges*/} +
+

Badges

+ +
+ {Object.keys(badgeItems).map((key) => ( +
+ ))} +
+
+ + {/* Event History*/} +
+

Events Attended

+ +
+ {eventHistory.map((event) => ( +
+

{event.title}

+
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/ens-claim.tsx b/src/components/ens-claim.tsx index 8176f81..78fd6ed 100644 --- a/src/components/ens-claim.tsx +++ b/src/components/ens-claim.tsx @@ -1,9 +1,8 @@ "use client"; import { useState, useEffect } from "react"; -import { useAccount, useDisconnect } from "wagmi"; -import { useAppKit } from "@reown/appkit/react"; -import { Wallet } from "lucide-react"; +import { useWallet } from "@/hooks/use-wallet"; +import { WalletButton } from "@/components/wallet-button"; import { Button } from "./ui/button"; interface Claim { @@ -13,18 +12,11 @@ interface Claim { } export function EnsClaim() { - const { address, isConnected } = useAccount(); - const { disconnect } = useDisconnect(); - const { open } = useAppKit(); + const { address, isConnected, disconnect, mounted } = useWallet(); const [requestedName, setRequestedName] = useState(""); const [claims, setClaims] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); useEffect(() => { if (address) { @@ -104,14 +96,7 @@ export function EnsClaim() { Connect your wallet to reserve your subname.

- +
); diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 3563f5d..24038a3 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -1,31 +1,27 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { Menu, X } from "lucide-react"; -import { useAccount } from "wagmi"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { useWallet } from "@/hooks/use-wallet"; +import { WalletButton } from "@/components/wallet-button"; +import { usePathname } from "next/navigation"; import Link from "next/link"; const navLinks = [ - { label: "About", href: "/pages/about" }, - { label: "Events", href: "/pages/events" }, - { label: "Partners", href: "/pages/partners" }, - { label: "Claim your Web3 ID!", href: "/pages/claim-id", isCTA: true }, + { label: "About", href: "/#about" }, + { label: "Events", href: "/#events" }, + { label: "Partners", href: "/#partners" }, + { label: "Claim your Web3 ID!", href: "/#identity", isCTA: true }, ]; export function Navbar() { const [mobileOpen, setMobileOpen] = useState(false); - const [mounted, setMounted] = useState(false); - const { address } = useAccount(); - - useEffect(() => { - setMounted(true); - }, []); - - const isAdmin = mounted && isAllowedAdminAddress(address); + const { isAdmin } = useWallet(); + const pathname = usePathname(); + const isHome = pathname === "/"; return ( -