Skip to content

Commit 31ffdba

Browse files
authored
Merge pull request #449 from zane1502/main
feat: resolve issues #442 #443 #444 #445
2 parents ed258cc + 54ddefa commit 31ffdba

6 files changed

Lines changed: 690 additions & 51 deletions

File tree

docs/api-layer.md

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# Client API Layer Conventions
2+
3+
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.
4+
5+
---
6+
7+
## Folder structure
8+
9+
All API service files live in `src/services/`:
10+
11+
```
12+
src/services/
13+
├── api.service.ts # Base class — all services extend this
14+
├── auth.service.ts # Authentication endpoints
15+
└── course.service.ts # Creator / course data endpoints
16+
```
17+
18+
Each file exports a **singleton instance** of its service class.
19+
20+
---
21+
22+
## File and class naming convention
23+
24+
| What | Convention | Example |
25+
|---|---|---|
26+
| File name | `<domain>.service.ts` | `wallet.service.ts` |
27+
| Class name | `<Domain>Service` | `WalletService` |
28+
| Exported singleton | `<domain>Service` | `walletService` |
29+
30+
Every service class **extends `BaseApiService`** from `api.service.ts`, which provides:
31+
32+
- A pre-configured Axios instance (`this.api`) pointing at `VITE_BACKEND_URL`
33+
- Automatic token refresh on `401 TOKEN_EXPIRED` responses
34+
- A shared `handleError(error)` method that normalises any thrown value to `ApiError`
35+
36+
---
37+
38+
## Error handling
39+
40+
Every service method wraps its Axios call in a `try/catch` and re-throws via `this.handleError`:
41+
42+
```ts
43+
async getWalletHoldings(address: string): Promise<Holding[]> {
44+
try {
45+
const response = await this.api.get<APIResponse<Holding[]>>(
46+
`/wallets/${address}/holdings`
47+
);
48+
return response.data.data;
49+
} catch (error) {
50+
throw this.handleError(error);
51+
}
52+
}
53+
```
54+
55+
`handleError` always returns an `ApiError` instance with:
56+
57+
| Field | Type | Description |
58+
|---|---|---|
59+
| `message` | `string` | Human-readable error message |
60+
| `status` | `number` | HTTP status code; `0` for network failures |
61+
| `response` | `APIErrorResponse \| undefined` | Full server error payload when available |
62+
63+
Callers can check `error instanceof ApiError` and inspect `error.status` for branching logic.
64+
65+
---
66+
67+
## How to add a new endpoint
68+
69+
### 1. Add the method to the relevant service file
70+
71+
Open `src/services/<domain>.service.ts` (or create a new one if the domain is new). Add a method that:
72+
73+
1. Calls `this.api.get/post/patch/delete`
74+
2. Extracts `response.data.data`
75+
3. Re-throws any error via `this.handleError`
76+
77+
```ts
78+
// src/services/wallet.service.ts
79+
import { BaseApiService, type APIResponse } from './api.service';
80+
81+
export interface Holding {
82+
creatorId: string;
83+
quantity: number;
84+
priceStroops: number;
85+
}
86+
87+
class WalletService extends BaseApiService {
88+
async getHoldings(address: string): Promise<Holding[]> {
89+
try {
90+
const response = await this.api.get<APIResponse<Holding[]>>(
91+
`/wallets/${address}/holdings`
92+
);
93+
return response.data.data;
94+
} catch (error) {
95+
throw this.handleError(error);
96+
}
97+
}
98+
}
99+
100+
export const walletService = new WalletService();
101+
```
102+
103+
### 2. Define a query key in `src/lib/queryKeys.ts`
104+
105+
Add an entry for the new endpoint so all hooks that reference the same data use an identical cache key:
106+
107+
```ts
108+
// src/lib/queryKeys.ts
109+
wallet: {
110+
holdings: (address: string) => ['wallet', address, 'holdings'] as const,
111+
// ...
112+
},
113+
```
114+
115+
### 3. Write a React Query hook
116+
117+
`QueryClientProvider` is already wired up in `src/providers/Web3Provider.tsx` — no setup changes needed.
118+
119+
```ts
120+
// src/hooks/useWalletHoldings.ts
121+
import { useQuery } from '@tanstack/react-query';
122+
import { walletService } from '@/services/wallet.service';
123+
import { queryKeys } from '@/lib/queryKeys';
124+
125+
export function useWalletHoldings(address: string | undefined) {
126+
return useQuery({
127+
queryKey: queryKeys.wallet.holdings(address ?? ''),
128+
queryFn: () => walletService.getHoldings(address!),
129+
enabled: Boolean(address),
130+
});
131+
}
132+
```
133+
134+
### 4. Consume the hook in a component
135+
136+
```tsx
137+
import { useWalletHoldings } from '@/hooks/useWalletHoldings';
138+
139+
function HoldingsList({ address }: { address: string }) {
140+
const { data: holdings, isLoading, error } = useWalletHoldings(address);
141+
142+
if (isLoading) return <p>Loading…</p>;
143+
if (error) return <p>Failed to load holdings.</p>;
144+
145+
return (
146+
<ul>
147+
{holdings?.map(h => (
148+
<li key={h.creatorId}>
149+
{h.creatorId}{h.quantity} keys
150+
</li>
151+
))}
152+
</ul>
153+
);
154+
}
155+
```
156+
157+
---
158+
159+
## Worked example — full GET call
160+
161+
The following shows a complete end-to-end flow for a `GET /wallets/:address/holdings` endpoint.
162+
163+
### Service method
164+
165+
```ts
166+
// src/services/wallet.service.ts
167+
import { BaseApiService, type APIResponse } from './api.service';
168+
169+
export interface Holding {
170+
creatorId: string;
171+
quantity: number;
172+
priceStroops: number;
173+
}
174+
175+
class WalletService extends BaseApiService {
176+
async getHoldings(address: string): Promise<Holding[]> {
177+
try {
178+
const response = await this.api.get<APIResponse<Holding[]>>(
179+
`/wallets/${address}/holdings`
180+
);
181+
return response.data.data;
182+
} catch (error) {
183+
throw this.handleError(error);
184+
}
185+
}
186+
}
187+
188+
export const walletService = new WalletService();
189+
```
190+
191+
### Query key
192+
193+
```ts
194+
// src/lib/queryKeys.ts (existing file — add the entry)
195+
wallet: {
196+
holdings: (address: string) => ['wallet', address, 'holdings'] as const,
197+
},
198+
```
199+
200+
### Hook
201+
202+
```ts
203+
// src/hooks/useWalletHoldings.ts
204+
import { useQuery } from '@tanstack/react-query';
205+
import { walletService } from '@/services/wallet.service';
206+
import { queryKeys } from '@/lib/queryKeys';
207+
208+
export function useWalletHoldings(address: string | undefined) {
209+
return useQuery({
210+
queryKey: queryKeys.wallet.holdings(address ?? ''),
211+
queryFn: () => walletService.getHoldings(address!),
212+
enabled: Boolean(address),
213+
});
214+
}
215+
```
216+
217+
### Component
218+
219+
```tsx
220+
// Usage in any component
221+
import { useAccount } from 'wagmi';
222+
import { useWalletHoldings } from '@/hooks/useWalletHoldings';
223+
224+
function HoldingsSummary() {
225+
const { address } = useAccount();
226+
const { data: holdings, isLoading, error } = useWalletHoldings(address);
227+
228+
if (isLoading) return <p>Loading…</p>;
229+
if (error) return <p>Could not load holdings.</p>;
230+
if (!holdings?.length) return <p>No holdings yet.</p>;
231+
232+
return (
233+
<ul>
234+
{holdings.map(h => (
235+
<li key={h.creatorId}>
236+
{h.creatorId}{h.quantity} keys at {h.priceStroops} stroops
237+
</li>
238+
))}
239+
</ul>
240+
);
241+
}
242+
```
243+
244+
---
245+
246+
## Key files at a glance
247+
248+
| File | Purpose |
249+
|---|---|
250+
| `src/services/api.service.ts` | `BaseApiService`, `ApiError`, `APIResponse` types |
251+
| `src/services/auth.service.ts` | Auth endpoints (login, register, profile) |
252+
| `src/services/course.service.ts` | Creator / course endpoints |
253+
| `src/lib/queryKeys.ts` | Centralised React Query key constants |
254+
| `src/providers/Web3Provider.tsx` | `QueryClientProvider` setup |

src/components/common/ConnectWalletButton.tsx

Lines changed: 73 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from 'react';
22
import { useAccount, useConnect, useDisconnect } from 'wagmi';
3+
import { Copy, Check } from 'lucide-react';
34
import {
45
Dialog,
56
DialogClose,
@@ -16,58 +17,97 @@ import {
1617
WALLET_CONNECTION_AD_BLOCKER_MESSAGE,
1718
useWalletConnectionStallDetection,
1819
} from '@/hooks/useWalletConnectionStallDetection';
20+
import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';
21+
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
1922

2023
function ConnectWalletButton() {
2124
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
25+
const [copied, setCopied] = useState(false);
2226
const { address, isConnected } = useAccount();
2327
const { connect, connectors, error, isPending } = useConnect();
2428
const { disconnect } = useDisconnect();
29+
const { announcement, announceCopySuccess } = useCopySuccessAnnouncement();
2530

2631
const primaryConnector = connectors[0];
2732
const showAdBlockerSuggestion = useWalletConnectionStallDetection({
2833
isAwaitingWalletResponse: isPending,
2934
hasWalletResponse: isConnected || Boolean(error),
3035
});
3136

37+
const handleCopyAddress = async () => {
38+
if (!address) return;
39+
try {
40+
await navigator.clipboard.writeText(address);
41+
announceCopySuccess('Wallet address copied.');
42+
setCopied(true);
43+
window.setTimeout(() => setCopied(false), 2000);
44+
} catch {
45+
setCopied(false);
46+
}
47+
};
48+
3249
if (isConnected && address) {
3350
return (
34-
<Dialog
35-
open={showDisconnectDialog}
36-
onOpenChange={setShowDisconnectDialog}
37-
>
38-
<DialogTrigger asChild>
51+
<>
52+
<div className="flex items-center gap-1.5">
53+
<Dialog
54+
open={showDisconnectDialog}
55+
onOpenChange={setShowDisconnectDialog}
56+
>
57+
<DialogTrigger asChild>
58+
<button
59+
type="button"
60+
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
61+
>
62+
{shortenAddress(address)}
63+
</button>
64+
</DialogTrigger>
65+
<DialogContent>
66+
<DialogHeader>
67+
<DialogTitle>Disconnect wallet?</DialogTitle>
68+
<DialogDescription>
69+
Disconnecting clears your current wallet session and any
70+
pending wallet state. You will need to reconnect to
71+
continue.
72+
</DialogDescription>
73+
</DialogHeader>
74+
<DialogFooter>
75+
<DialogClose asChild>
76+
<Button variant="outline">Cancel</Button>
77+
</DialogClose>
78+
<Button
79+
type="button"
80+
variant="destructive"
81+
onClick={() => {
82+
disconnect();
83+
setShowDisconnectDialog(false);
84+
}}
85+
>
86+
Disconnect
87+
</Button>
88+
</DialogFooter>
89+
</DialogContent>
90+
</Dialog>
3991
<button
4092
type="button"
41-
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
93+
onClick={handleCopyAddress}
94+
aria-label={copied ? 'Wallet address copied' : 'Copy wallet address'}
95+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-white/5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
4296
>
43-
{shortenAddress(address)}
97+
{copied ? (
98+
<Check className="size-4 text-emerald-400" aria-hidden="true" />
99+
) : (
100+
<Copy className="size-4" aria-hidden="true" />
101+
)}
44102
</button>
45-
</DialogTrigger>
46-
<DialogContent>
47-
<DialogHeader>
48-
<DialogTitle>Disconnect wallet?</DialogTitle>
49-
<DialogDescription>
50-
Disconnecting clears your current wallet session and any
51-
pending wallet state. You will need to reconnect to continue.
52-
</DialogDescription>
53-
</DialogHeader>
54-
<DialogFooter>
55-
<DialogClose asChild>
56-
<Button variant="outline">Cancel</Button>
57-
</DialogClose>
58-
<Button
59-
type="button"
60-
variant="destructive"
61-
onClick={() => {
62-
disconnect();
63-
setShowDisconnectDialog(false);
64-
}}
65-
>
66-
Disconnect
67-
</Button>
68-
</DialogFooter>
69-
</DialogContent>
70-
</Dialog>
103+
{copied && (
104+
<span className="text-xs font-medium text-emerald-400" aria-hidden="true">
105+
Copied!
106+
</span>
107+
)}
108+
</div>
109+
<CopySuccessAnnouncement message={announcement} />
110+
</>
71111
);
72112
}
73113

0 commit comments

Comments
 (0)