From 819448df34ed0042fafc9603c377a51aea1cb0f9 Mon Sep 17 00:00:00 2001 From: Andy Huang Date: Tue, 5 May 2026 23:58:49 +1200 Subject: [PATCH 1/6] feat: formalise wallet connection as a site-wide feature - Add useWallet hook centralising wagmi/AppKit state with SSR mount guard - Add WalletButton component with connected/disconnected/loading states - Add /profile page showing wallet address, network, and disconnect - Add WalletButton to desktop navbar (was missing) and mobile menu - Update navbar to use fixed+backdrop on non-home pages; fix anchor links to /#section - Refactor ens-claim and admin to use useWallet, removing duplicated boilerplate - Replace deprecated useAccount with useConnection, disconnect with mutate - Add docs/wallet-connection.md for team reference --- docs/wallet-connection.md | 180 +++++++++++++++++++++++++++++++ src/app/admin/page.tsx | 15 ++- src/app/profile/page.tsx | 113 +++++++++++++++++++ src/components/ens-claim.tsx | 23 +--- src/components/navbar.tsx | 31 +++--- src/components/wallet-button.tsx | 55 ++++++++++ src/hooks/use-wallet.ts | 29 +++++ 7 files changed, 401 insertions(+), 45 deletions(-) create mode 100644 docs/wallet-connection.md create mode 100644 src/app/profile/page.tsx create mode 100644 src/components/wallet-button.tsx create mode 100644 src/hooks/use-wallet.ts 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..585275a --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useWallet } from "@/hooks/use-wallet"; +import { WalletButton } from "@/components/wallet-button"; +import { Button } from "@/components/ui/button"; +import { Copy, CheckCheck } from "lucide-react"; +import { useState } from "react"; +import { mainnet, sepolia } from "wagmi/chains"; + +function getNetworkName(chainId?: number) { + if (chainId === mainnet.id) return "Ethereum Mainnet"; + if (chainId === sepolia.id) return "Sepolia Testnet"; + return "Unknown Network"; +} + +export default function ProfilePage() { + const { address, isConnected, chainId, disconnect, mounted } = useWallet(); + const [copied, setCopied] = useState(false); + + const copyAddress = () => { + if (!address) return; + navigator.clipboard.writeText(address); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (!mounted) { + return ( +
+

Loading...

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

Your Profile

+

+ Connect your wallet to view your profile. +

+
+ +
+
+ ); + } + + return ( +
+ {/* Subtle background blobs matching site style */} +
+
+ +
+
+ + Profile + +

Your Wallet

+
+ + {/* Wallet card */} +
+ {/* Connected indicator */} +
+
+ Connected + · {getNetworkName(chainId)} +
+ + {/* Address */} +
+

+ Wallet Address +

+
+ {address} + +
+
+ + {/* Disconnect */} +
+ +
+
+
+
+ ); +} 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..afcafbe 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 ( -
+
+ +
+ {/* Badges*/} +
+

Badges

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

Events Attended

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

{event.title}

+
+ ))}
From 768202b00a74917daf3753b3f35f2e71fd88a545 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Wed, 6 May 2026 17:46:43 +1200 Subject: [PATCH 6/6] feat: responsiveness to smaller devices --- src/app/profile/page.tsx | 164 ++++++++++++++++++++------------------- 1 file changed, 86 insertions(+), 78 deletions(-) diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 7c7e4cf..c46f407 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -76,105 +76,113 @@ export default function ProfilePage() { } return ( -
+
{/* Profile Card */} -
-
- {profileImage ? ( - Profile - ) : ( -
No Image
- )} - -
-
- {isEditingName ? ( -
- setDisplayName(e.target.value)} - className="text-xl font-bold border rounded px-2 py-1" - onKeyDown={(e) => { - if (e.key === "Enter") { - setIsEditingName(false); - } - }} +
+
+
+ {profileImage ? ( + Profile - -
- ) : ( -
-

{displayName}

- -
- )} - - {address} {""} - -
- + ) : ( +
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}