diff --git a/docs/api-layer.md b/docs/api-layer.md new file mode 100644 index 00000000..8bfb86eb --- /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

Loading…

; + if (error) return

Failed to load holdings.

; + + return ( +
    + {holdings?.map(h => ( +
  • + {h.creatorId} — {h.quantity} keys +
  • + ))} +
+ ); +} +``` + +--- + +## Worked example — full GET call + +The following shows a complete end-to-end flow for a `GET /wallets/:address/holdings` endpoint. + +### Service method + +```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(); +``` + +### Query key + +```ts +// src/lib/queryKeys.ts (existing file — add the entry) +wallet: { + holdings: (address: string) => ['wallet', address, 'holdings'] as const, +}, +``` + +### Hook + +```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), + }); +} +``` + +### Component + +```tsx +// Usage in any component +import { useAccount } from 'wagmi'; +import { useWalletHoldings } from '@/hooks/useWalletHoldings'; + +function HoldingsSummary() { + const { address } = useAccount(); + const { data: holdings, isLoading, error } = useWalletHoldings(address); + + if (isLoading) return

Loading…

; + if (error) return

Could not load holdings.

; + if (!holdings?.length) return

No holdings yet.

; + + return ( +
    + {holdings.map(h => ( +
  • + {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/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx index c72d8520..60aae452 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 ( - - + <> +
+ + + + + + + Disconnect wallet? + + Disconnecting clears your current wallet session and any + pending wallet state. You will need to reconnect to + continue. + + + + + + + + + + - - - - Disconnect wallet? - - Disconnecting clears your current wallet session and any - pending wallet state. You will need to reconnect to continue. - - - - - - - - - -
+ {copied && ( + + )} + + + ); } diff --git a/src/components/common/__tests__/ConnectWalletButton.test.tsx b/src/components/common/__tests__/ConnectWalletButton.test.tsx index 261204f8..5dde3a32 100644 --- a/src/components/common/__tests__/ConnectWalletButton.test.tsx +++ b/src/components/common/__tests__/ConnectWalletButton.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import ConnectWalletButton from '@/components/common/ConnectWalletButton'; import { useAccount, useConnect, useDisconnect } from 'wagmi'; @@ -14,25 +14,31 @@ const mockUseAccount = vi.mocked(useAccount); const mockUseConnect = vi.mocked(useConnect); const mockUseDisconnect = vi.mocked(useDisconnect); +const FULL_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; + +function setupConnectedWalletMocks(disconnect = vi.fn()) { + mockUseAccount.mockReturnValue({ + address: FULL_ADDRESS, + isConnected: true, + } as ReturnType); + mockUseConnect.mockReturnValue({ + connect: vi.fn(), + connectors: [], + error: null, + isPending: false, + } as unknown as ReturnType); + mockUseDisconnect.mockReturnValue({ + disconnect, + } as unknown as ReturnType); + + return { disconnect }; +} + describe('ConnectWalletButton wallet disconnect confirmation', () => { function renderConnectedWallet(disconnect = vi.fn()) { - mockUseAccount.mockReturnValue({ - address: '0x1234567890abcdef1234567890abcdef12345678', - isConnected: true, - } as ReturnType); - mockUseConnect.mockReturnValue({ - connect: vi.fn(), - connectors: [], - error: null, - isPending: false, - } as unknown as ReturnType); - mockUseDisconnect.mockReturnValue({ - disconnect, - } as unknown as ReturnType); - + const result = setupConnectedWalletMocks(disconnect); render(); - - return { disconnect }; + return result; } it('opens a confirmation dialog before disconnecting', () => { @@ -79,3 +85,70 @@ describe('ConnectWalletButton wallet disconnect confirmation', () => { expect(disconnect).not.toHaveBeenCalled(); }); }); + +describe('ConnectWalletButton copy wallet address', () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + function renderConnectedWallet() { + setupConnectedWalletMocks(); + render(); + } + + it('shows a copy button when the wallet is connected', () => { + renderConnectedWallet(); + + expect( + screen.getByRole('button', { name: /copy wallet address/i }) + ).toBeInTheDocument(); + }); + + it('copies the full unmasked address to the clipboard on click', async () => { + renderConnectedWallet(); + + fireEvent.click( + screen.getByRole('button', { name: /copy wallet address/i }) + ); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FULL_ADDRESS); + }); + }); + + it('shows a Copied! confirmation after clicking', async () => { + renderConnectedWallet(); + + fireEvent.click( + screen.getByRole('button', { name: /copy wallet address/i }) + ); + + expect(await screen.findByText('Copied!')).toBeInTheDocument(); + }); + + it('removes the Copied! confirmation after 2 seconds', async () => { + vi.useFakeTimers(); + renderConnectedWallet(); + + fireEvent.click( + screen.getByRole('button', { name: /copy wallet address/i }) + ); + + // Flush the clipboard promise microtask so state updates land + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText('Copied!')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.queryByText('Copied!')).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); +}); diff --git a/src/lib/__tests__/queryKeys.test.ts b/src/lib/__tests__/queryKeys.test.ts new file mode 100644 index 00000000..ff099914 --- /dev/null +++ b/src/lib/__tests__/queryKeys.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { queryKeys } from '../queryKeys'; + +describe('queryKeys – key shapes', () => { + it('every static key is an array', () => { + expect(Array.isArray(queryKeys.creators.all)).toBe(true); + }); + + it('every factory function returns an array', () => { + expect(Array.isArray(queryKeys.creators.list())).toBe(true); + expect(Array.isArray(queryKeys.creators.detail('abc'))).toBe(true); + expect(Array.isArray(queryKeys.creators.holders('abc'))).toBe(true); + expect(Array.isArray(queryKeys.wallet.holdings('0xabc'))).toBe(true); + expect(Array.isArray(queryKeys.wallet.activity('0xabc'))).toBe(true); + }); +}); + +describe('queryKeys – shared prefixes for cache invalidation', () => { + it('creators.list shares the creators prefix with creators.all', () => { + expect(queryKeys.creators.list()[0]).toBe(queryKeys.creators.all[0]); + }); + + it('creators.detail shares the creators prefix with creators.all', () => { + expect(queryKeys.creators.detail('x')[0]).toBe(queryKeys.creators.all[0]); + }); + + it('creators.holders shares the creators prefix with creators.all', () => { + expect(queryKeys.creators.holders('x')[0]).toBe( + queryKeys.creators.all[0] + ); + }); + + it('wallet.holdings and wallet.activity share the wallet + address prefix', () => { + const addr = '0x1234'; + expect(queryKeys.wallet.holdings(addr)[0]).toBe( + queryKeys.wallet.activity(addr)[0] + ); + expect(queryKeys.wallet.holdings(addr)[1]).toBe( + queryKeys.wallet.activity(addr)[1] + ); + }); +}); + +describe('queryKeys – parameter embedding', () => { + it('creators.list embeds params at index 2', () => { + const params = { page: 2, limit: 10 }; + const key = queryKeys.creators.list(params); + expect(key[2]).toStrictEqual(params); + }); + + it('creators.list uses null when no params given', () => { + expect(queryKeys.creators.list()[2]).toBeNull(); + }); + + it('creators.detail embeds the id at index 2', () => { + expect(queryKeys.creators.detail('creator-123')[2]).toBe('creator-123'); + }); + + it('creators.holders embeds creatorId at index 1', () => { + expect(queryKeys.creators.holders('creator-456')[1]).toBe('creator-456'); + }); + + it('wallet.holdings embeds address at index 1', () => { + expect(queryKeys.wallet.holdings('0xabc')[1]).toBe('0xabc'); + }); + + it('wallet.activity embeds address at index 1', () => { + expect(queryKeys.wallet.activity('0xabc')[1]).toBe('0xabc'); + }); +}); diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts new file mode 100644 index 00000000..22314ac7 --- /dev/null +++ b/src/lib/queryKeys.ts @@ -0,0 +1,16 @@ +import type { GetCoursesParams } from '@/services/course.service'; + +export const queryKeys = { + creators: { + all: ['creators'] as const, + list: (params?: GetCoursesParams) => + ['creators', 'list', params ?? null] as const, + detail: (id: string) => ['creators', 'detail', id] as const, + holders: (creatorId: string) => + ['creators', creatorId, 'holders'] as const, + }, + wallet: { + holdings: (address: string) => ['wallet', address, 'holdings'] as const, + activity: (address: string) => ['wallet', address, 'activity'] as const, + }, +}; diff --git a/src/pages/__tests__/LandingPage.holdings.test.tsx b/src/pages/__tests__/LandingPage.holdings.test.tsx new file mode 100644 index 00000000..c6e531bb --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdings.test.tsx @@ -0,0 +1,186 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourses: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +// Prevent the stale-data hook from triggering a background re-fetch on mount +// (creatorsFetchedAt starts as null → stale=true on first render, which fires +// onStale → re-fetch → resets isLoading=true → delays the portfolio display). +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +const mockGetCourses = vi.mocked(courseService.getCourses); + +// Two seeded creators at known prices: +// Creator A (index 0 → featuredHoldings = 3): priceStroops 500_000 +// → 3 × 500_000 = 1_500_000 stroops = 0.15 XLM per position +// Creator B (index 1 → DEMO_HELD_KEY_QUANTITIES[1] = 2): priceStroops 1_200_000 +// → 2 × 1_200_000 = 2_400_000 stroops = 0.24 XLM per position +// Total: 3_900_000 stroops = 0.39 XLM +const seededCreators: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-b', + title: 'Creator B', + description: 'Developer', + price: 0.12, + priceStroops: 1_200_000, + creatorShareSupply: 50, + instructorId: 'creator-b', + category: 'Tech', + level: 'ADVANCED', + isVerified: false, + }, +]; + +describe('LandingPage wallet holdings', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('displays total portfolio value equal to the sum of all held positions', async () => { + mockGetCourses.mockResolvedValue(seededCreators); + render(); + + // 3 × 500_000 + 2 × 1_200_000 = 3_900_000 stroops = 0.39 XLM + expect(await screen.findByText('0.39 XLM')).toBeInTheDocument(); + }); + + it('shows each holding card with the correct per-key price', async () => { + mockGetCourses.mockResolvedValue(seededCreators); + render(); + + // Wait for the portfolio total to load (past the 800 ms loading skeleton) + await screen.findByText('0.39 XLM'); + + // Holdings grid shows "N keys · price" text unique to each card + // Creator A: 3 × 500_000 stroops → 0.05 XLM/key + expect(screen.getByText('3 keys · 0.05 XLM')).toBeInTheDocument(); + // Creator B: 2 × 1_200_000 stroops → 0.12 XLM/key + expect(screen.getByText('2 keys · 0.12 XLM')).toBeInTheDocument(); + }); + + it('shows the correct total and helper text for a single held position', async () => { + const singleCreator = [ + { + id: 'solo', + title: 'Solo Creator', + description: 'Solo', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 50, + instructorId: 'solo', + category: 'Art', + level: 'BEGINNER' as const, + }, + ]; + mockGetCourses.mockResolvedValue(singleCreator); + render(); + + // 3 (featuredHoldings) × 1_000_000 stroops = 3_000_000 stroops = 0.3 XLM + expect(await screen.findByText('0.3 XLM')).toBeInTheDocument(); + expect( + screen.getByText('Across 1 held creator position.') + ).toBeInTheDocument(); + }); +});