diff --git a/.env.example b/.env.example
index b7957ba..ef3afc1 100644
--- a/.env.example
+++ b/.env.example
@@ -1,22 +1,33 @@
-VITE_BACKEND_URL=/api
-# UTM settings for shared profile links (optional)
-# Set any of these to enable UTM parameters on shared profile URLs.
-VITE_UTM_SOURCE=twitter
-VITE_UTM_MEDIUM=social
-VITE_UTM_CAMPAIGN=share_profile
-#VITE_UTM_TERM=
-#VITE_UTM_CONTENT=
+# Access Layer Client — environment variables
+# Copy this file to `.env` and adjust values as needed:
+# cp .env.example .env
+# All client-exposed variables must be prefixed with VITE_ (see https://vitejs.dev/guide/env-and-mode).
+# See CONTRIBUTING.md ("Environment variables") for which vars are required vs optional.
+
+# --- Required (sensible defaults provided) ---
+
+# Base URL for the backend API. Use the local backend during development.
VITE_BACKEND_URL=http://localhost:3000/api/v1
+
+# Chain ID selected by default on load. 84532 = Base Sepolia testnet.
VITE_DEFAULT_CHAIN_ID=84532
+
+# RPC URL for a local Anvil node (chain 31337). Used when developing against a local chain.
VITE_ANVIL_RPC_URL=http://127.0.0.1:8545
+
+# RPC URL for the Base Sepolia testnet (chain 84532). The public default works out of the box.
VITE_BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
+
+# --- Optional ---
+
+# RPC URL for the Ethereum Sepolia testnet (chain 11155111). Get one from Alchemy, Infura, or another provider.
VITE_SEPOLIA_RPC_URL=
+
+# RPC URL for Ethereum mainnet (chain 1). Only needed when testing against mainnet.
VITE_MAINNET_RPC_URL=
-# UTM settings for shared profile links (optional)
-# Set any of these to enable UTM parameters on shared profile URLs.
-# Remove or comment out to disable UTM tracking.
-# Example configuration:
+# UTM parameters appended to shared profile links (optional).
+# Set any of these to enable UTM tracking; remove or leave blank to disable.
VITE_UTM_SOURCE=accesslayer
VITE_UTM_MEDIUM=share
VITE_UTM_CAMPAIGN=profile-sharing
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b606c35..815f054 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,7 +12,12 @@ Thanks for contributing to the frontend for Access Layer, a Stellar-native creat
## Local setup
1. Install Node.js 20+ and `pnpm`.
-2. Copy `.env.example` to `.env` and add any local values you need.
+2. Copy `.env.example` to `.env` and adjust values as needed (see [Environment variables](#environment-variables)):
+
+ ```bash
+ cp .env.example .env
+ ```
+
3. Install dependencies:
```bash
@@ -25,6 +30,41 @@ pnpm install
pnpm dev
```
+## Environment variables
+
+All client-exposed variables are prefixed with `VITE_` so Vite can expose them to the
+browser. The defaults in `.env.example` are enough to run the client locally — you only
+need to fill in optional values for the networks you actually want to test against.
+Validation lives in [`src/utils/env.utils.ts`](./src/utils/env.utils.ts).
+
+### Required (defaults provided)
+
+| Variable | Description |
+| --- | --- |
+| `VITE_BACKEND_URL` | Base URL for the backend API. Point this at your local backend during development (e.g. `http://localhost:3000/api/v1`). |
+| `VITE_DEFAULT_CHAIN_ID` | Chain ID selected by default on load. `84532` is Base Sepolia, the recommended testnet. |
+| `VITE_ANVIL_RPC_URL` | RPC URL for a local [Anvil](https://book.getfoundry.sh/anvil/) node (chain `31337`), used when developing against a local chain. |
+| `VITE_BASE_SEPOLIA_RPC_URL` | RPC URL for the Base Sepolia testnet (chain `84532`). The public default `https://sepolia.base.org` works without an account. |
+
+### Optional
+
+| Variable | Description |
+| --- | --- |
+| `VITE_SEPOLIA_RPC_URL` | RPC URL for the Ethereum Sepolia testnet (chain `11155111`). Only needed when testing on Sepolia. |
+| `VITE_MAINNET_RPC_URL` | RPC URL for Ethereum mainnet (chain `1`). Only needed when testing against mainnet. |
+| `VITE_UTM_SOURCE`, `VITE_UTM_MEDIUM`, `VITE_UTM_CAMPAIGN`, `VITE_UTM_TERM`, `VITE_UTM_CONTENT` | UTM parameters appended to shared profile links. Leave blank to disable UTM tracking. |
+
+### Where to get testnet RPC URLs
+
+- **Base Sepolia** — the public endpoint `https://sepolia.base.org` is preconfigured and
+ needs no account. For higher rate limits, create a free Base Sepolia endpoint at
+ [Alchemy](https://www.alchemy.com/) or [Infura](https://www.infura.io/).
+- **Ethereum Sepolia** — create a free Sepolia endpoint at
+ [Alchemy](https://www.alchemy.com/) or [Infura](https://www.infura.io/), or use a public
+ endpoint from [Chainlist](https://chainlist.org/?testnets=true&search=sepolia).
+- **Local Anvil** — no URL to fetch; run `anvil` from [Foundry](https://book.getfoundry.sh/)
+ and it serves the default `http://127.0.0.1:8545`.
+
## Verification commands
Run these before opening a pull request:
@@ -51,6 +91,43 @@ The repository also uses Husky plus `lint-staged` to run lightweight checks on s
- Do not reintroduce old template-era pages or branding.
- Prefer accessible, keyboard-friendly UI behavior.
- Keep new routes focused and incremental until the main marketplace flows land.
+- See [docs/adding-page-routes.md](./docs/adding-page-routes.md) for how to register a new page, the file naming convention, and the recommended pattern for auth-protected routes.
+
+### Folder structure
+
+- `pages/`: Route-level components (each file maps to a route)
+- `components/`: Reusable UI components and shared component logic
+ - `components/common/`: Application-specific reusable components
+ - `components/ui/`: Low-level UI primitives (from shadcn/ui or similar)
+ - `components/home/`: Home/landing-page specific components
+- `hooks/`: Custom React hooks
+- `utils/` or `lib/`: Pure helper functions and utilities
+- `constants/`: Application constants
+- `contracts/`: Web3 contract ABIs and related logic
+- `assets/`: Static assets (images, icons, etc.)
+
+### Naming conventions
+
+- **Components**: PascalCase (e.g., `CreatorCard.tsx`, `ConnectWalletButton.tsx`)
+- **Hooks**: camelCase, prefixed with `use` (e.g., `useCopySuccessAnnouncement.ts`, `useNetworkMismatch.ts`)
+- **Utilities/helpers**: camelCase (e.g., `formatNumber.ts`)
+- **Constants**: UPPER_SNAKE_CASE (e.g., `MAX_KEY_SUPPLY`)
+
+### Components vs pages: decision guide
+
+Use `pages/` when:
+- The component is a top-level route or page entry point
+- It represents a distinct URL path in the application
+
+Use `components/` when:
+- The component is reusable across multiple pages or routes
+- It's a self-contained UI piece with a single responsibility
+- It can be tested independently of route context
+
+Keep components co-located in a page file only when:
+- They are used exclusively within that single page
+- They are small, helper components that don't make sense outside the page context
+- Extracting them would add unnecessary indirection
## Good first issue guidance
diff --git a/docs/adding-page-routes.md b/docs/adding-page-routes.md
new file mode 100644
index 0000000..3ae401f
--- /dev/null
+++ b/docs/adding-page-routes.md
@@ -0,0 +1,272 @@
+# Adding a New Page Route
+
+This guide explains how to add a new route to the Access Layer client. It covers where routes are registered, the file naming convention for page components, and how to mark a route as auth-protected.
+
+---
+
+## Where routes are registered
+
+Every route in the client is declared in a **single source of truth** at the top of `src/App.tsx`. The router is built once with `createBrowserRouter([...])` and passed to ``. To add a new route, append a `{ path, element }` entry to that array.
+
+```tsx
+// src/App.tsx
+import { createBrowserRouter, RouterProvider } from 'react-router';
+import HomePage from './pages/HomePage';
+import NotFoundPage from './pages/NotFoundPage';
+import AboutPage from './pages/AboutPage'; // ← new import
+
+const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ },
+ {
+ path: '/about', // ← new public route
+ element: ,
+ },
+ {
+ path: '*', // catch-all stays last
+ element: ,
+ },
+]);
+```
+
+Key things to know:
+
+- **Order matters** only for the `*` catch-all — keep it as the **last entry** so it does not shadow real routes.
+- **Imports** for new page components live at the top of `src/App.tsx` alongside the existing ones. Use the relative `'./pages/Page'` path shown above, matching the existing imports.
+- **Do not** create a second router or wrap the app in another ``. The router configured here is the only one.
+- Nested routes for sub-pages (for example `/creators/:handle/keys`) are added the same way — just declare the full pattern on each entry. The current client uses flat routes only.
+
+---
+
+## File naming convention for page components
+
+| What | Convention | Example |
+|---|---|---|
+| File location | `src/pages/` | `src/pages/HomePage.tsx` |
+| File name | PascalCase + `Page` suffix | `AboutPage.tsx` |
+| Exported component | Default export of a function named `Page` | `export default function AboutPage()` |
+
+The component itself uses `export default function Page()` — not a named export, and not an arrow const. This keeps imports straightforward and matches every existing page in `src/pages/`:
+
+```
+src/pages/
+├── HomePage.tsx // registered as '/'
+├── MarketingPage.tsx // exists on disk, not yet registered
+├── LandingPage.tsx // exists on disk, not yet registered
+└── NotFoundPage.tsx // registered as '*' (catch-all)
+```
+
+A few pages exist as files but are not currently wired into the router in `src/App.tsx`. They are kept on disk because they are planned routes waiting on the marketplace flows to land. If you need one of them live, follow this guide to register it like any other page.
+
+### What a page component looks like
+
+Top-level page components take **no props**. They own their own layout, fetching, and state. A minimal page is just a function that returns JSX:
+
+```tsx
+// src/pages/AboutPage.tsx
+export default function AboutPage() {
+ return (
+
+
+ About Access Layer
+
+
+ Access Layer is a Stellar-native creator keys marketplace.
+
+
+ );
+}
+```
+
+Conventions to follow:
+
+- **Default export only.** Named exports break the import in `App.tsx`.
+- **One component per file.** Don't lump multiple pages into a single file.
+- **No props.** Reach for URL params via `useParams()` from `react-router` instead of prop drilling.
+- **Accessibility:** wrap content in a single `` landmark and use semantic headings (`
` for the page title).
+- **Match the project styling.** Use the existing `font-grotesque`, `font-jakarta`, and dark-on-blue palette referenced throughout the codebase. Don't introduce new global styles for a single page.
+- **Use the `@/` alias for component imports.** The project-wide path alias `@/` maps to `src/` (configured via Vite + TypeScript path mapping). Use it freely from any component file; reserve short relative paths like `'../pages/Page'` for tight sibling-file imports.
+
+---
+
+## Public vs auth-protected routes
+
+There is **no existing `RequireAuth` / `ProtectedRoute` wrapper** in the codebase yet. Every route registered today is public. When a feature needs auth gating, follow the pattern below — and ship the wrapper component with that feature, since the client doesn't have a standalone auth-guard component yet.
+
+### Recommended pattern
+
+1. Create a small wrapper component, conventionally `src/components/auth/RequireAuth.tsx` (create the `auth/` subfolder if it doesn't exist yet).
+2. Inside the wrapper, check the appropriate auth state — wagmi's `useAccount` for wallet-gated flows, or `authService.isAuthenticated()` for email/login-gated flows.
+3. While the state is resolving, render a lightweight placeholder (the existing `PendingOnboardingPlaceholder` makes a good model).
+4. If unauthenticated, render a redirect or a connect-wallet CTA — do **not** render the protected page.
+5. Wrap the protected page's `element` with the wrapper inside `App.tsx`.
+
+```tsx
+// src/components/auth/RequireAuth.tsx
+import type { ReactNode } from 'react';
+import { useAccount } from 'wagmi';
+import { Navigate, useLocation } from 'react-router';
+import PendingOnboardingPlaceholder from '@/components/common/PendingOnboardingPlaceholder';
+
+interface RequireAuthProps {
+ children: ReactNode;
+}
+
+export default function RequireAuth({ children }: RequireAuthProps) {
+ const { isConnected, isConnecting } = useAccount();
+ const location = useLocation();
+
+ // Show the placeholder while a fresh wallet connection is in flight
+ // so the user isn't redirected to "/" mid-connect. Once it resolves,
+ // isConnected flips to true and we render the protected page below.
+ // Note: isReconnecting is intentionally NOT in this branch — a
+ // reconnect of a prior session keeps the user authenticated, so
+ // rendering the protected page during a reconnect is fine.
+ if (isConnecting) {
+ return ;
+ }
+
+ if (!isConnected) {
+ // Send unauthenticated users back to the homepage while preserving
+ // the path they tried to reach so a future flow can deep-link them
+ // back here after they connect.
+ return ;
+ }
+
+ return <>{children}>;
+}
+```
+
+Then wire the protection in `src/App.tsx` by wrapping the element rather than registering the page directly:
+
+```tsx
+// src/App.tsx
+import RequireAuth from './components/auth/RequireAuth';
+import DashboardPage from './pages/DashboardPage';
+
+const router = createBrowserRouter([
+ { path: '/', element: },
+ {
+ path: '/dashboard',
+ // Public route → element:
+ // Protected route → wrap in :
+ element: (
+
+
+
+ ),
+ },
+ { path: '*', element: },
+]);
+```
+
+### Choosing which auth check to use
+
+| Use case | Check |
+|---|---|
+| The page reads or writes Stellar assets (keys, trades, portfolio) | `useAccount().isConnected` from wagmi |
+| The page reads or writes user-profile data via the backend REST API | `authService.isAuthenticated()` |
+| Both | Call both. Render the placeholder until both resolve; redirect if either is false. |
+
+Don't mix the two states in a single component without documenting which is the source of truth for that page — that's the kind of bug that's hard to spot in review.
+
+### Known repo state (read before adding a protected route)
+
+Two pre-existing repo facts you should know before shipping a wagmi-based guard:
+
+1. **`` is not currently mounted above `` in `src/main.tsx`.** As of writing this guide, `main.tsx` renders `` directly inside ``. Any wagmi hook — including `useAccount` inside `RequireAuth` — will throw at runtime because the `WagmiProvider` context is missing. Wiring `` here is an app-level change and should be tracked separately; reference the tracking issue in your route PR description rather than embedding the wiring fix in your route PR.
+2. **Wagmi has a transient `isConnecting` state** while a fresh wallet handshake is in flight. During this window `isConnected` is `false`, but redirecting the user mid-connect would bounce them away. The example above handles this by rendering `PendingOnboardingPlaceholder` for the `isConnecting` branch — copy that pattern verbatim. (Note: `isReconnecting` is *not* included in that branch — a reconnect of a prior, already-authenticated session keeps the user authenticated, so rendering the protected page during a reconnect is fine and avoids a UX flash.)
+
+If your guard only uses `authService.isAuthenticated()` (no wagmi hooks), neither caveat applies — the helper reads `localStorage` directly.
+
+---
+
+## Worked example — adding a new public page
+
+This walks a contributor end-to-end through adding `AboutPage` at `/about`. The page is public, so no `RequireAuth` wrapper is involved.
+
+### 1. Create the page component
+
+Add a new file at `src/pages/AboutPage.tsx`:
+
+```tsx
+// src/pages/AboutPage.tsx
+import { Link } from 'react-router';
+import { Button } from '@/components/ui/button';
+
+export default function AboutPage() {
+ return (
+
+
+ About Access Layer
+
+
+ Access Layer is a Stellar-native creator keys marketplace built on
+ the open AccessLayer protocol.
+
+
+
+
+
+
+ );
+}
+```
+
+### 2. Register the route
+
+Add the import and a new entry to the router array in `src/App.tsx`:
+
+```tsx
+// src/App.tsx
+import HomePage from './pages/HomePage';
+import NotFoundPage from './pages/NotFoundPage';
+import AboutPage from './pages/AboutPage'; // ← added
+
+const router = createBrowserRouter([
+ { path: '/', element: },
+ { path: '/about', element: }, // ← added
+ { path: '*', element: },
+]);
+```
+
+### 3. Link to it from another page
+
+Open `src/pages/HomePage.tsx`, import `Link`, and add a `` to the new route:
+
+```tsx
+import { Link } from 'react-router';
+
+// inside the JSX you return
+
+ About this project
+
+```
+
+### 4. Verify locally
+
+```bash
+pnpm dev # visit http://localhost:5173/about
+pnpm lint
+pnpm build
+```
+
+If `pnpm build` succeeds and `/about` renders the page with a working "Back to marketplace" link, you are done.
+
+---
+
+## Key files at a glance
+
+| File | Purpose |
+|---|---|
+| `src/App.tsx` | The single source of truth for routing — the only place routes are registered. |
+| `src/pages/` | Folder where every page component lives. One file per page, PascalCase + `Page` suffix, default export. |
+| `src/components/auth/RequireAuth.tsx` | The recommended wrapper for auth-protected pages. Create it the first time a protected route is added. |
+| `src/main.tsx` | Mounts `` inside React's `createRoot`. **Currently does not wrap `` in `Web3Provider`** — must be updated the first time a wagmi-based route guard lands. |
+| `src/providers/Web3Provider.tsx` | Provides `WagmiProvider` + `QueryClientProvider`. Any auth wrapper that uses `useAccount` only works after this provider is mounted above the router in `main.tsx`. |
+| `CONTRIBUTING.md` | High-level project conventions — read this alongside this guide. |
+| `docs/api-layer.md` | Sibling guide covering how to add backend/API endpoints. |
diff --git a/docs/api-layer.md b/docs/api-layer.md
new file mode 100644
index 0000000..8bfb86e
--- /dev/null
+++ b/docs/api-layer.md
@@ -0,0 +1,254 @@
+# Client API Layer Conventions
+
+This document explains how the client's API layer is structured, how errors are handled, and how to add a new server call end-to-end.
+
+---
+
+## Folder structure
+
+All API service files live in `src/services/`:
+
+```
+src/services/
+├── api.service.ts # Base class — all services extend this
+├── auth.service.ts # Authentication endpoints
+└── course.service.ts # Creator / course data endpoints
+```
+
+Each file exports a **singleton instance** of its service class.
+
+---
+
+## File and class naming convention
+
+| What | Convention | Example |
+|---|---|---|
+| File name | `.service.ts` | `wallet.service.ts` |
+| Class name | `Service` | `WalletService` |
+| Exported singleton | `Service` | `walletService` |
+
+Every service class **extends `BaseApiService`** from `api.service.ts`, which provides:
+
+- A pre-configured Axios instance (`this.api`) pointing at `VITE_BACKEND_URL`
+- Automatic token refresh on `401 TOKEN_EXPIRED` responses
+- A shared `handleError(error)` method that normalises any thrown value to `ApiError`
+
+---
+
+## Error handling
+
+Every service method wraps its Axios call in a `try/catch` and re-throws via `this.handleError`:
+
+```ts
+async getWalletHoldings(address: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/wallets/${address}/holdings`
+ );
+ return response.data.data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+}
+```
+
+`handleError` always returns an `ApiError` instance with:
+
+| Field | Type | Description |
+|---|---|---|
+| `message` | `string` | Human-readable error message |
+| `status` | `number` | HTTP status code; `0` for network failures |
+| `response` | `APIErrorResponse \| undefined` | Full server error payload when available |
+
+Callers can check `error instanceof ApiError` and inspect `error.status` for branching logic.
+
+---
+
+## How to add a new endpoint
+
+### 1. Add the method to the relevant service file
+
+Open `src/services/.service.ts` (or create a new one if the domain is new). Add a method that:
+
+1. Calls `this.api.get/post/patch/delete`
+2. Extracts `response.data.data`
+3. Re-throws any error via `this.handleError`
+
+```ts
+// src/services/wallet.service.ts
+import { BaseApiService, type APIResponse } from './api.service';
+
+export interface Holding {
+ creatorId: string;
+ quantity: number;
+ priceStroops: number;
+}
+
+class WalletService extends BaseApiService {
+ async getHoldings(address: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/wallets/${address}/holdings`
+ );
+ return response.data.data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const walletService = new WalletService();
+```
+
+### 2. Define a query key in `src/lib/queryKeys.ts`
+
+Add an entry for the new endpoint so all hooks that reference the same data use an identical cache key:
+
+```ts
+// src/lib/queryKeys.ts
+wallet: {
+ holdings: (address: string) => ['wallet', address, 'holdings'] as const,
+ // ...
+},
+```
+
+### 3. Write a React Query hook
+
+`QueryClientProvider` is already wired up in `src/providers/Web3Provider.tsx` — no setup changes needed.
+
+```ts
+// src/hooks/useWalletHoldings.ts
+import { useQuery } from '@tanstack/react-query';
+import { walletService } from '@/services/wallet.service';
+import { queryKeys } from '@/lib/queryKeys';
+
+export function useWalletHoldings(address: string | undefined) {
+ return useQuery({
+ queryKey: queryKeys.wallet.holdings(address ?? ''),
+ queryFn: () => walletService.getHoldings(address!),
+ enabled: Boolean(address),
+ });
+}
+```
+
+### 4. Consume the hook in a component
+
+```tsx
+import { useWalletHoldings } from '@/hooks/useWalletHoldings';
+
+function HoldingsList({ address }: { address: string }) {
+ const { data: holdings, isLoading, error } = useWalletHoldings(address);
+
+ if (isLoading) return
+ {h.creatorId} — {h.quantity} keys at {h.priceStroops} stroops
+
+ ))}
+
+ );
+}
+```
+
+---
+
+## Key files at a glance
+
+| File | Purpose |
+|---|---|
+| `src/services/api.service.ts` | `BaseApiService`, `ApiError`, `APIResponse` types |
+| `src/services/auth.service.ts` | Auth endpoints (login, register, profile) |
+| `src/services/course.service.ts` | Creator / course endpoints |
+| `src/lib/queryKeys.ts` | Centralised React Query key constants |
+| `src/providers/Web3Provider.tsx` | `QueryClientProvider` setup |
diff --git a/docs/error-handling-in-hooks.md b/docs/error-handling-in-hooks.md
new file mode 100644
index 0000000..6cfaa74
--- /dev/null
+++ b/docs/error-handling-in-hooks.md
@@ -0,0 +1,348 @@
+# Error Handling in React Query Hooks
+
+This guide documents the standard pattern for handling API errors in React Query hooks across this codebase. Follow it when writing new `useQuery` or `useMutation` hooks so error behavior is consistent and predictable for users.
+
+---
+
+## How Errors Flow In
+
+All HTTP requests go through the service layer (`src/services/`), which extends `BaseApiService`. The `handleError` method on that base class normalises every failure into an `ApiError` before it reaches the hook:
+
+| Raw failure | What you receive |
+|---|---|
+| HTTP response with an error status | `ApiError(message, httpStatus, responseBody)` |
+| Request sent but no response received | `ApiError('Network error - check your connection', 0)` |
+| Unexpected non-HTTP exception | `ApiError(error.message, 500)` |
+
+One important exception: **401 + `TOKEN_EXPIRED`** is handled transparently by the Axios interceptor in `BaseApiService`. The interceptor silently retries the original request after refreshing the access token. If the refresh also fails the user is redirected to `/login`; the hook never sees this error.
+
+---
+
+## The `ApiError` Shape
+
+```ts
+// src/services/api.service.ts
+class ApiError extends Error {
+ status: number; // HTTP status code; 0 means no network response
+ response?: {
+ success: false;
+ message: string;
+ code?: string; // machine-readable code from the API, e.g. "INSUFFICIENT_BALANCE"
+ errors?: Array<{
+ field?: string; // present on 422 validation failures
+ message: string;
+ }>;
+ };
+}
+```
+
+Always cast the error to `ApiError` before inspecting it:
+
+```ts
+import { ApiError } from '@/services/api.service';
+
+onError: (error) => {
+ const apiError = error as ApiError;
+ console.log(apiError.status); // 0, 400, 403, 422, 500 …
+ console.log(apiError.message); // human-readable message from the API
+ console.log(apiError.response?.errors); // field-level details on 422
+}
+```
+
+---
+
+## Distinguishing Error Types
+
+### Network errors (`status === 0`)
+
+No response was received — the user is offline, the server is unreachable, or a timeout occurred. The user cannot fix the request payload; they need to retry later.
+
+```ts
+if (apiError.status === 0) {
+ showToast.error('Network error. Check your connection and try again.');
+ return;
+}
+```
+
+### 4xx — Client errors
+
+The request was received but rejected because of something the client sent. The message from the API is usually safe to show to the user.
+
+| Status | Cause | Typical UI response |
+|---|---|---|
+| 400 | Malformed request | Toast with `apiError.message` |
+| 401 | Session expired | Auto-handled by the interceptor |
+| 403 | Insufficient permissions | Inline error or redirect |
+| 404 | Resource not found | Inline error state |
+| 422 | Validation failure | Inline field errors from `apiError.response?.errors` |
+| 429 | Rate limited | Toast with retry suggestion |
+
+### 5xx — Server errors
+
+The API itself failed. The user cannot fix the payload; they can only retry after the server recovers. Avoid showing raw server messages — use a generic fallback instead.
+
+```ts
+if (apiError.status >= 500) {
+ showToast.error('The server ran into a problem. Please try again shortly.');
+ return;
+}
+```
+
+---
+
+## Deciding: Toast vs. Inline Error vs. Error Boundary
+
+### Use a toast when
+
+- The failure came from a **user-initiated action** (mutation): buying a key, submitting a form, enrolling in a course.
+- The error **does not block the current view** — the page can still render usefully.
+- The fix is to retry or change input: one line of feedback is enough.
+
+```ts
+onError: (error) => {
+ const apiError = error as ApiError;
+ showToast.error(apiError.status >= 500 ? 'Something went wrong. Try again.' : apiError.message);
+}
+```
+
+### Use an inline error state when
+
+- The error **blocks the primary purpose of the screen** — for example, the creator list failed to load so the page is empty.
+- The error contains **field-level detail** (422) that needs to map to specific form inputs.
+- The user needs to take **corrective action** (fix a field, switch networks) before retrying makes sense.
+
+```tsx
+const { data, isError, error } = useCreatorKeys(creatorId);
+
+if (isError) {
+ const apiError = error as ApiError;
+ return (
+
+ {apiError.status >= 500
+ ? 'Unable to load data. Please try again later.'
+ : apiError.message}
+
+ );
+}
+```
+
+### Use `SectionErrorBoundary` when
+
+- A **component throws during render**, not from an API call.
+- You want to **isolate a section** so one broken widget does not crash the whole page.
+- React Query's `throwOnError` option is enabled on a query.
+
+```tsx
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
+
+
+
+
+```
+
+`SectionErrorBoundary` renders a retry button that resets its own error state. Use it as a safety net around sections that fetch and render data together.
+
+---
+
+## `useQuery` Pattern
+
+React Query v5 removed the `onError` callback from `useQuery`. Errors surface through `isError` and `error` in the component. Keep the hook thin and handle the error at the call site:
+
+```ts
+// src/hooks/useCreatorProfile.ts
+import { useQuery } from '@tanstack/react-query';
+import { creatorService } from '@/services/creator.service';
+
+export function useCreatorProfile(creatorId: string) {
+ return useQuery({
+ queryKey: ['creator-profile', creatorId],
+ queryFn: () => creatorService.getProfile(creatorId),
+ staleTime: 30_000,
+ });
+}
+```
+
+```tsx
+// In the component
+import { ApiError } from '@/services/api.service';
+import { useCreatorProfile } from '@/hooks/useCreatorProfile';
+
+function CreatorProfileSection({ creatorId }: { creatorId: string }) {
+ const { data, isLoading, isError, error } = useCreatorProfile(creatorId);
+
+ if (isLoading) return ;
+
+ if (isError) {
+ const apiError = error as ApiError;
+ return (
+
+ {apiError.status >= 500
+ ? 'Unable to load this profile right now.'
+ : apiError.message}
+
+ );
+ }
+
+ return ;
+}
+```
+
+---
+
+## `useMutation` Pattern
+
+`useMutation` still accepts `onError` and `onSuccess` callbacks. Use them for toasts and cache invalidation:
+
+```ts
+// src/hooks/useEnrollInCourse.ts
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { courseService } from '@/services/course.service';
+import { ApiError } from '@/services/api.service';
+import showToast from '@/utils/toast.util';
+
+export function useEnrollInCourse() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (courseId: string) => courseService.enrollInCourse(courseId),
+ onError: (error) => {
+ const apiError = error as ApiError;
+
+ if (apiError.status === 0) {
+ showToast.error('Network error. Check your connection and try again.');
+ return;
+ }
+
+ if (apiError.status >= 500) {
+ showToast.error('Something went wrong on our end. Please try again.');
+ return;
+ }
+
+ // 4xx: the API message is safe and actionable
+ showToast.error(apiError.message);
+ },
+ onSuccess: (_, courseId) => {
+ queryClient.invalidateQueries({ queryKey: ['enrolled-courses'] });
+ queryClient.invalidateQueries({ queryKey: ['course', courseId] });
+ showToast.success('Enrolled successfully!');
+ },
+ });
+}
+```
+
+---
+
+## Worked Example: Handling Both Error Types
+
+The following hook wraps a write operation (buying a creator key) and shows how to handle network errors, 4xx validation failures, and 5xx server errors in a single consistent flow.
+
+```ts
+// src/hooks/useCreatorKeys.ts
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { ApiError } from '@/services/api.service';
+import showToast from '@/utils/toast.util';
+import { creatorKeysService } from '@/services/creatorKeys.service';
+
+// --- Read ---
+export function useCreatorKeys(creatorId: string) {
+ return useQuery({
+ queryKey: ['creator-keys', creatorId],
+ queryFn: () => creatorKeysService.getKeys(creatorId),
+ staleTime: 30_000,
+ });
+}
+
+// --- Write ---
+export function useBuyCreatorKey() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ creatorId, amount }: { creatorId: string; amount: number }) =>
+ creatorKeysService.buyKey(creatorId, amount),
+
+ onError: (error) => {
+ const apiError = error as ApiError;
+
+ // No response received — user is likely offline
+ if (apiError.status === 0) {
+ showToast.error('Network error. Check your connection and try again.');
+ return;
+ }
+
+ // Server-side failure — not actionable by the user
+ if (apiError.status >= 500) {
+ showToast.error('The server ran into a problem. Please try again shortly.');
+ return;
+ }
+
+ // 422 Validation — show the first field error if available
+ if (apiError.status === 422 && apiError.response?.errors?.length) {
+ showToast.error(apiError.response.errors[0].message);
+ return;
+ }
+
+ // All other 4xx — the API message is safe to surface
+ showToast.error(apiError.message);
+ },
+
+ onSuccess: (_, { creatorId }) => {
+ // Invalidate relevant queries so the UI reflects the purchase
+ queryClient.invalidateQueries({ queryKey: ['creator-keys', creatorId] });
+ queryClient.invalidateQueries({ queryKey: ['user-holdings'] });
+ showToast.success('Key purchased successfully!');
+ },
+ });
+}
+```
+
+Usage in a component:
+
+```tsx
+import { ApiError } from '@/services/api.service';
+import { useCreatorKeys, useBuyCreatorKey } from '@/hooks/useCreatorKeys';
+import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
+
+function CreatorKeysSection({ creatorId }: { creatorId: string }) {
+ const { data: keys, isLoading, isError, error } = useCreatorKeys(creatorId);
+ const { mutate: buyKey, isPending } = useBuyCreatorKey();
+
+ if (isLoading) return ;
+
+ if (isError) {
+ const apiError = error as ApiError;
+ return (
+
+
+ {apiError.status >= 500
+ ? 'Unable to load keys right now. Please try again later.'
+ : apiError.message}
+
+
+ );
+ }
+
+ return (
+ // SectionErrorBoundary catches any render-time throws inside KeysList
+
+ buyKey({ creatorId, amount })}
+ isBuying={isPending}
+ />
+
+ );
+}
+```
+
+---
+
+## Quick Reference
+
+| Condition | Check | UI response |
+|---|---|---|
+| No network response | `apiError.status === 0` | Toast: "Network error. Check your connection." |
+| Server error | `apiError.status >= 500` | Toast: generic "something went wrong" message |
+| Validation failure | `apiError.status === 422` | Toast or inline: first item from `apiError.response?.errors` |
+| Other 4xx | `apiError.status >= 400` | Toast or inline: `apiError.message` (safe from the API) |
+| Read query fails | `isError === true` in component | Inline error state replacing the content area |
+| Render throws | Component boundary | Wrap with `` |
diff --git a/docs/shared-hooks.md b/docs/shared-hooks.md
new file mode 100644
index 0000000..08106ee
--- /dev/null
+++ b/docs/shared-hooks.md
@@ -0,0 +1,52 @@
+# Contributing Shared Hooks
+
+The `src/hooks` folder is for reusable stateful logic that is not specific to
+one component. Put a hook here when multiple screens or components can share the
+same state management, browser event handling, async coordination, or derived
+behavior. Keep component-only logic near the component that owns it.
+
+## Naming
+
+Shared hooks must:
+
+- Start with the `use` prefix.
+- Export a hook whose name matches the file name.
+- Use a file name that is identical to the hook name, for example
+ `useExample.ts`.
+
+## Tests
+
+Every shared hook must include a corresponding test file in
+`src/hooks/__tests__`. Name the test after the hook, for example
+`useExample.test.ts` or `useExample.test.tsx`.
+
+## Minimal Example
+
+```ts
+// src/hooks/useCounter.ts
+import { useCallback, useState } from 'react';
+
+export const useCounter = (initialValue = 0) => {
+ const [count, setCount] = useState(initialValue);
+ const increment = useCallback(() => setCount(value => value + 1), []);
+
+ return { count, increment };
+};
+```
+
+```ts
+// src/hooks/__tests__/useCounter.test.ts
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { useCounter } from '@/hooks/useCounter';
+
+describe('useCounter', () => {
+ it('increments from the initial value', () => {
+ const { result } = renderHook(() => useCounter(2));
+
+ act(() => result.current.increment());
+
+ expect(result.current.count).toBe(3);
+ });
+});
+```
diff --git a/docs/state-management.md b/docs/state-management.md
new file mode 100644
index 0000000..24fa7c6
--- /dev/null
+++ b/docs/state-management.md
@@ -0,0 +1,69 @@
+# Client State Management
+
+## The Rule
+
+| Data type | Where it lives |
+| ----------------------------------------------------------------------- | ---------------------------------------- |
+| Server data (creators, holdings, activity feed) | React Query (`useQuery` / `useMutation`) |
+| Ephemeral UI state (modals, input values, selected tabs, loading flags) | Local `useState` |
+
+If the value came from an API response and needs to survive a component unmount or be shared across routes, put it in React Query. If it only controls what the user sees right now and can be re-derived on re-mount, use `useState`.
+
+## Query Invalidation vs Manual Refetch
+
+**Invalidate** after a mutation that changes server data:
+
+```ts
+const queryClient = useQueryClient();
+queryClient.invalidateQueries({ queryKey: queryKeys.creators.list() });
+```
+
+This marks cached data stale and lets React Query refetch in the background the next time the query is observed. Use this after a buy, sell, or profile update so all subscribers see fresh data automatically.
+
+**Refetch manually** only when you need to force an immediate reload independent of staleness — for example, a user-triggered "Refresh" button:
+
+```ts
+const { refetch } = useQuery({ queryKey: queryKeys.wallet.holdings(address), ... });
+
+```
+
+Avoid calling `refetch()` inside effects or after mutations — that bypasses cache coordination and can race with invalidation.
+
+## Do Not Copy Server State into Local State
+
+Storing a React Query result in `useState` breaks cache coherence and causes stale UI after mutations.
+
+### Wrong
+
+```tsx
+function CreatorProfile({ id }: { id: string }) {
+ const { data } = useCreatorDetail(id);
+
+ // Never do this — local state diverges from the cache after mutations.
+ const [creator, setCreator] = useState(data);
+
+ return
{creator?.title}
;
+}
+```
+
+### Right
+
+```tsx
+function CreatorProfile({ id }: { id: string }) {
+ const { data: creator } = useCreatorDetail(id);
+
+ // Read directly from the query result — always in sync with the cache.
+ return
{creator?.title}
;
+}
+```
+
+## Ephemeral UI State Examples
+
+These belong in `useState`, not React Query:
+
+- Modal open/closed: `const [open, setOpen] = useState(false)`
+- Controlled input value: `const [query, setQuery] = useState('')`
+- Active tab: `const [activeTab, setActiveTab] = useState('overview')`
+- Optimistic loading flag: `const [submitting, setSubmitting] = useState(false)`
+
+None of these values need to survive a page navigation or be shared with another component tree, so there is no reason to put them in the server-state layer.
diff --git a/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx
index c72d852..60aae45 100644
--- a/src/components/common/ConnectWalletButton.tsx
+++ b/src/components/common/ConnectWalletButton.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useAccount, useConnect, useDisconnect } from 'wagmi';
+import { Copy, Check } from 'lucide-react';
import {
Dialog,
DialogClose,
@@ -16,12 +17,16 @@ import {
WALLET_CONNECTION_AD_BLOCKER_MESSAGE,
useWalletConnectionStallDetection,
} from '@/hooks/useWalletConnectionStallDetection';
+import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';
+import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
function ConnectWalletButton() {
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
+ const [copied, setCopied] = useState(false);
const { address, isConnected } = useAccount();
const { connect, connectors, error, isPending } = useConnect();
const { disconnect } = useDisconnect();
+ const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();
const primaryConnector = connectors[0];
const showAdBlockerSuggestion = useWalletConnectionStallDetection({
@@ -29,45 +34,80 @@ function ConnectWalletButton() {
hasWalletResponse: isConnected || Boolean(error),
});
+ const handleCopyAddress = async () => {
+ if (!address) return;
+ try {
+ await navigator.clipboard.writeText(address);
+ announceCopySuccess('Wallet address copied.');
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 2000);
+ } catch {
+ setCopied(false);
+ }
+ };
+
if (isConnected && address) {
return (
-