From a642a6d6385ee0a09f482cd6ed243ece0edbfe66 Mon Sep 17 00:00:00 2001 From: Keshinro Tanitoluwa Joseph Date: Tue, 23 Jun 2026 23:26:58 +0100 Subject: [PATCH 01/20] Closes #425 - Rewrite .env.example with a one-line description for every variable and remove duplicate entries; group into required vs optional - Add an 'Environment variables' section to CONTRIBUTING.md covering required vs optional vars and where to source testnet RPC URLs --- .env.example | 35 +++++++++++++++++++++++------------ CONTRIBUTING.md | 42 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 13 deletions(-) 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..ea6c10e 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: From 60d194e762a35c0aa4ec39cfc800387b64df1a17 Mon Sep 17 00:00:00 2001 From: Samuel Ojetunde Date: Wed, 24 Jun 2026 22:30:28 +0100 Subject: [PATCH 02/20] test: add integration tests for MarketingPage community links Verifies that the GitHub and Telegram community links render with the correct href values, open in a new tab, and carry the expected rel="noopener noreferrer" attribute so a future refactor cannot silently break these URLs. Closes #428 --- src/pages/__tests__/MarketingPage.test.tsx | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/pages/__tests__/MarketingPage.test.tsx diff --git a/src/pages/__tests__/MarketingPage.test.tsx b/src/pages/__tests__/MarketingPage.test.tsx new file mode 100644 index 0000000..d1bd491 --- /dev/null +++ b/src/pages/__tests__/MarketingPage.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import MarketingPage from '@/pages/MarketingPage'; + +describe('MarketingPage community links', () => { + it('GitHub link points to the correct URL and opens in a new tab', () => { + render(); + + const githubLink = screen.getByRole('link', { name: /github/i }); + + expect(githubLink).toHaveAttribute('href', 'https://github.com/accesslayerorg'); + expect(githubLink).toHaveAttribute('target', '_blank'); + expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('Telegram link points to the correct URL and opens in a new tab', () => { + render(); + + const telegramLink = screen.getByRole('link', { name: /telegram/i }); + + expect(telegramLink).toHaveAttribute('href', 'https://t.me/c/accesslayerorg/'); + expect(telegramLink).toHaveAttribute('target', '_blank'); + expect(telegramLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); From 5fcd6cc20c2239cccdf9c8bfc1c0dcd8ef9e5c41 Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Thu, 25 Jun 2026 02:25:13 +0100 Subject: [PATCH 03/20] feat: #420 Add helper for formatting key price display in XLM with correct decimal places --- .../__tests__/keyPriceDisplay.utils.test.ts | 24 +++++++++++++++++++ src/utils/keyPriceDisplay.utils.ts | 19 +++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/utils/__tests__/keyPriceDisplay.utils.test.ts b/src/utils/__tests__/keyPriceDisplay.utils.test.ts index e1f0e01..b53e727 100644 --- a/src/utils/__tests__/keyPriceDisplay.utils.test.ts +++ b/src/utils/__tests__/keyPriceDisplay.utils.test.ts @@ -3,6 +3,7 @@ import { formatCreatorKeyPriceDisplay, formatDisplayKeyPrice, resolveCreatorKeyPriceStroops, + formatKeyPrice, } from '../keyPriceDisplay.utils'; import { STROOPS_PER_XLM } from '@/constants/stellar'; @@ -41,3 +42,26 @@ describe('formatCreatorKeyPriceDisplay', () => { ); }); }); + +describe('formatKeyPrice', () => { + it('formats zero correctly with 4 decimal places', () => { + expect(formatKeyPrice(0n)).toBe('0.0000 XLM'); + }); + + it('formats sub-1 XLM values with 4 decimal places', () => { + expect(formatKeyPrice(5_000_000n)).toBe('0.5000 XLM'); + expect(formatKeyPrice(123_456n)).toBe('0.0123 XLM'); + expect(formatKeyPrice(123_556n)).toBe('0.0124 XLM'); // rounds up + }); + + it('formats exactly 1 XLM with 2 decimal places', () => { + expect(formatKeyPrice(10_000_000n)).toBe('1.00 XLM'); + }); + + it('formats large values with 2 decimal places and commas', () => { + expect(formatKeyPrice(15_000_000n)).toBe('1.50 XLM'); + expect(formatKeyPrice(123_456_789n)).toBe('12.35 XLM'); + expect(formatKeyPrice(10_000_000_000n)).toBe('1,000.00 XLM'); + }); +}); + diff --git a/src/utils/keyPriceDisplay.utils.ts b/src/utils/keyPriceDisplay.utils.ts index 9251fb1..ea32a65 100644 --- a/src/utils/keyPriceDisplay.utils.ts +++ b/src/utils/keyPriceDisplay.utils.ts @@ -58,3 +58,22 @@ export function formatCreatorKeyPriceDisplay( ): string { return formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator)); } + +/** + * Formats a key price in stroops (bigint) to XLM with proper decimal precision. + * Always displays 2 decimal places for prices >= 1 XLM, and 4 decimal places for prices < 1 XLM. + */ +export function formatKeyPrice(stroops: bigint): string { + const STROOPS_PER_XLM_BI = 10_000_000n; + const isBelowOneXlm = stroops < STROOPS_PER_XLM_BI; + const decimals = isBelowOneXlm ? 4 : 2; + + const xlm = Number(stroops) / 10_000_000; + const formattedValue = formatNumber(xlm, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + + return `${formattedValue} XLM`; +} + From 56f7157bdd4e98dad05033758bbad9653434300b Mon Sep 17 00:00:00 2001 From: Ghadaffijr Date: Thu, 25 Jun 2026 10:00:15 +0100 Subject: [PATCH 04/20] feat: add computePriceChange helper for key price percentage change - Add computePriceChange(current: bigint, previous: bigint) in src/utils/priceChange.utils.ts - Returns { percent: number, direction: 'up' | 'down' | 'flat' } - Returns flat when previous is zero or current equals previous - 7 unit tests covering: price up, price down, no change, zero previous, both zero Closes #429 --- src/utils/__tests__/priceChange.utils.test.ts | 55 +++++++++++++++++++ src/utils/priceChange.utils.ts | 46 ++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/utils/__tests__/priceChange.utils.test.ts create mode 100644 src/utils/priceChange.utils.ts diff --git a/src/utils/__tests__/priceChange.utils.test.ts b/src/utils/__tests__/priceChange.utils.test.ts new file mode 100644 index 0000000..631a9c9 --- /dev/null +++ b/src/utils/__tests__/priceChange.utils.test.ts @@ -0,0 +1,55 @@ +// src/utils/__tests__/priceChange.utils.test.ts +import { describe, expect, it } from 'vitest'; +import { computePriceChange } from '../priceChange.utils'; + +describe('computePriceChange', () => { + describe('price up', () => { + it('returns positive percent and direction up when price increases', () => { + const result = computePriceChange(112n, 100n); + expect(result.direction).toBe('up'); + expect(result.percent).toBeCloseTo(12); + }); + + it('handles a large price increase', () => { + const result = computePriceChange(200n, 100n); + expect(result.direction).toBe('up'); + expect(result.percent).toBeCloseTo(100); + }); + }); + + describe('price down', () => { + it('returns negative percent and direction down when price decreases', () => { + const result = computePriceChange(88n, 100n); + expect(result.direction).toBe('down'); + expect(result.percent).toBeCloseTo(-12); + }); + + it('handles a large price decrease', () => { + const result = computePriceChange(1n, 100n); + expect(result.direction).toBe('down'); + expect(result.percent).toBeCloseTo(-99); + }); + }); + + describe('flat — no change', () => { + it('returns flat with 0 percent when current equals previous', () => { + const result = computePriceChange(100n, 100n); + expect(result.direction).toBe('flat'); + expect(result.percent).toBe(0); + }); + }); + + describe('flat — zero previous value', () => { + it('returns flat with 0 percent when previous is zero', () => { + const result = computePriceChange(100n, 0n); + expect(result.direction).toBe('flat'); + expect(result.percent).toBe(0); + }); + + it('returns flat when both current and previous are zero', () => { + const result = computePriceChange(0n, 0n); + expect(result.direction).toBe('flat'); + expect(result.percent).toBe(0); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/priceChange.utils.ts b/src/utils/priceChange.utils.ts new file mode 100644 index 0000000..77e12f1 --- /dev/null +++ b/src/utils/priceChange.utils.ts @@ -0,0 +1,46 @@ +// src/utils/priceChange.utils.ts + +/** + * Result of a price change computation between two key price values. + */ +export interface PriceChangeResult { + /** Percentage change as a number, e.g. 12.4 or -3.1. Always 0 when direction is 'flat'. */ + percent: number; + /** Direction of the price movement relative to the previous value. */ + direction: 'up' | 'down' | 'flat'; +} + +/** + * Computes the percentage price change between two key price values expressed + * in stroops (bigint). + * + * Returns `flat` with `percent: 0` when: + * - `previous` is zero (division by zero is undefined) + * - `current` equals `previous` (no change) + * + * @param current - The current key price in stroops + * @param previous - The previous key price in stroops + * @returns PriceChangeResult with percent and direction + * + * @example + * computePriceChange(112n, 100n) // { percent: 12, direction: 'up' } + * computePriceChange(88n, 100n) // { percent: -12, direction: 'down' } + * computePriceChange(100n, 100n) // { percent: 0, direction: 'flat' } + * computePriceChange(100n, 0n) // { percent: 0, direction: 'flat' } + */ +export function computePriceChange( + current: bigint, + previous: bigint +): PriceChangeResult { + if (previous === 0n || current === previous) { + return { percent: 0, direction: 'flat' }; + } + + const percent = + (Number(current - previous) / Number(previous)) * 100; + + return { + percent, + direction: percent > 0 ? 'up' : 'down', + }; +} \ No newline at end of file From 0c1f5e1a0ea2f861eef96a042ea63b70e3b4106b Mon Sep 17 00:00:00 2001 From: pixels26 Date: Thu, 25 Jun 2026 10:08:36 +0000 Subject: [PATCH 05/20] feat: add shortenAddress helper with first 4 + ... + last 4 formatting Closes #423 --- src/lib/web3/__tests__/format.test.ts | 28 +++++++++++++++++++++++++++ src/lib/web3/format.ts | 9 +++------ 2 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 src/lib/web3/__tests__/format.test.ts diff --git a/src/lib/web3/__tests__/format.test.ts b/src/lib/web3/__tests__/format.test.ts new file mode 100644 index 0000000..1e9cf28 --- /dev/null +++ b/src/lib/web3/__tests__/format.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { shortenAddress } from '../format'; + +describe('shortenAddress', () => { + it('shortens a standard Stellar address to first 4 + ... + last 4', () => { + const address = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234'; + expect(shortenAddress(address)).toBe('GABC...1234'); + }); + + it('returns the full address unchanged when it is shorter than 10 characters', () => { + const address = 'GABCDEFG'; + expect(shortenAddress(address)).toBe('GABCDEFG'); + }); + + it('returns the full address unchanged when it is exactly 9 characters', () => { + const address = 'GABCDEFGH'; + expect(shortenAddress(address)).toBe('GABCDEFGH'); + }); + + it('shortens an address that is exactly 10 characters', () => { + const address = 'GABCDEFGHI'; + expect(shortenAddress(address)).toBe('GABC...FGHI'); + }); + + it('returns an empty string when given an empty string', () => { + expect(shortenAddress('')).toBe(''); + }); +}); diff --git a/src/lib/web3/format.ts b/src/lib/web3/format.ts index e760dbf..ce38564 100644 --- a/src/lib/web3/format.ts +++ b/src/lib/web3/format.ts @@ -1,8 +1,5 @@ -export function shortenAddress( - address: string, - startLength = 6, - endLength = 4 -) { +export function shortenAddress(address: string) { if (!address) return ''; - return `${address.slice(0, startLength)}...${address.slice(-endLength)}`; + if (address.length < 10) return address; + return `${address.slice(0, 4)}...${address.slice(-4)}`; } From 54ddefa86730140da47e88c09c0b6cf09fe1df22 Mon Sep 17 00:00:00 2001 From: zane1502 Date: Thu, 25 Jun 2026 07:41:20 -0700 Subject: [PATCH 06/20] feat: resolve issues #442 #443 #444 #445 - Add copy-to-clipboard button to ConnectWalletButton with 2s feedback and screen-reader announcement (#442) - Add src/lib/queryKeys.ts factory-style React Query key constants with unit tests (#443) - Add LandingPage.holdings.test.tsx integration tests asserting portfolio totals and per-holding values (#444) - Add docs/api-layer.md documenting service conventions, error handling, and a worked end-to-end example (#445) --- docs/api-layer.md | 254 ++++++++++++++++++ src/components/common/ConnectWalletButton.tsx | 106 +++++--- .../__tests__/ConnectWalletButton.test.tsx | 109 ++++++-- src/lib/__tests__/queryKeys.test.ts | 70 +++++ src/lib/queryKeys.ts | 16 ++ .../__tests__/LandingPage.holdings.test.tsx | 186 +++++++++++++ 6 files changed, 690 insertions(+), 51 deletions(-) create mode 100644 docs/api-layer.md create mode 100644 src/lib/__tests__/queryKeys.test.ts create mode 100644 src/lib/queryKeys.ts create mode 100644 src/pages/__tests__/LandingPage.holdings.test.tsx 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

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 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 ( - - + <> +
+ + + + + + + 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 261204f..5dde3a3 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 0000000..ff09991 --- /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 0000000..22314ac --- /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 0000000..c6e531b --- /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(); + }); +}); From f5f8f8225c33e086fdd3a8c567b327946c7a5fd1 Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Thu, 25 Jun 2026 18:46:22 +0100 Subject: [PATCH 07/20] Closes #446, #157, #155, #437 - Add ledgerToTimestamp helper for converting Stellar ledger sequence numbers to estimated timestamps - Add transaction status badge legend tooltip for user-friendly status explanations - Add creator card keyboard shortcut hint (press 'B' to quick buy) with desktop-only display - Add integration test for creator card price update on snapshot data change --- src/components/common/CreatorCard.tsx | 28 +++++++++++++-- .../common/TransactionStatusBadge.tsx | 29 +++++++++++++++ .../CreatorCard.accessibility.test.tsx | 23 ++++++++++++ .../__tests__/stellarLedger.utils.test.ts | 35 +++++++++++++++++++ src/utils/stellarLedger.utils.ts | 20 +++++++++++ 5 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/components/common/TransactionStatusBadge.tsx create mode 100644 src/utils/__tests__/stellarLedger.utils.test.ts create mode 100644 src/utils/stellarLedger.utils.ts diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index cc9edae..61a56fc 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { useAccount } from 'wagmi'; import type { Course } from '@/services/course.service'; import { cn } from '@/lib/utils'; @@ -100,6 +100,23 @@ const CreatorCard: React.FC = ({ }); const hasFailedOnceRef = useRef(false); const trackTransactionEvent = useTransactionTelemetry(); + const cardRef = useRef(null); + + // Keyboard shortcut for quick buy (press 'b' when card is focused) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Only trigger if 'b' is pressed and card is focused + if (e.key === 'b' || e.key === 'B') { + handleBuy(); + } + }; + + const cardElement = cardRef.current; + if (cardElement) { + cardElement.addEventListener('keydown', handleKeyDown); + return () => cardElement.removeEventListener('keydown', handleKeyDown); + } + }, [isConnected, isNetworkMismatch, displayCreatorName]); const runPurchaseAttempt = () => { setTransactionState('submitting'); @@ -197,6 +214,8 @@ const CreatorCard: React.FC = ({ return (
= ({ > Purchase actions for {displayCreatorName} - +
+ + + Press B to quick buy + +
= { + success: 'Transaction completed successfully', + pending: 'Transaction is being processed', + failed: 'Transaction failed or was rejected', +}; + +const TransactionStatusBadge: React.FC = ({ + status, + className, +}) => { + return ( + + + + ); +}; + +export default TransactionStatusBadge; diff --git a/src/components/common/__tests__/CreatorCard.accessibility.test.tsx b/src/components/common/__tests__/CreatorCard.accessibility.test.tsx index 5ce1ecc..51f58d5 100644 --- a/src/components/common/__tests__/CreatorCard.accessibility.test.tsx +++ b/src/components/common/__tests__/CreatorCard.accessibility.test.tsx @@ -65,4 +65,27 @@ describe('CreatorCard accessibility', () => { }) ).toBeInTheDocument(); }); + + it('updates displayed price when price snapshot data changes', () => { + const { rerender } = render(); + + // Initial price should be 12 XLM + const initialPriceBadge = screen.getByTestId('creator-card-price-badge'); + expect(initialPriceBadge).toHaveTextContent(/12/i); + + // Update creator with new price + const updatedCreator: Course = { + ...creator, + price: 25, + }; + + rerender(); + + // New price should be 25 XLM + const updatedPriceBadge = screen.getByTestId('creator-card-price-badge'); + expect(updatedPriceBadge).toHaveTextContent(/25/i); + + // Old price (12) should no longer be visible + expect(updatedPriceBadge).not.toHaveTextContent(/12/i); + }); }); diff --git a/src/utils/__tests__/stellarLedger.utils.test.ts b/src/utils/__tests__/stellarLedger.utils.test.ts new file mode 100644 index 0000000..0473b80 --- /dev/null +++ b/src/utils/__tests__/stellarLedger.utils.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { ledgerToTimestamp } from '../stellarLedger.utils'; + +describe('ledgerToTimestamp', () => { + it('returns correct estimated Date for ledger in the past', () => { + const referenceLedger = 1000; + const referenceTimestamp = Date.now(); + const pastLedger = 900; // 100 ledgers before reference + + const result = ledgerToTimestamp(pastLedger, referenceLedger, referenceTimestamp); + const expectedTimestamp = referenceTimestamp - 100 * 5000; // 100 * 5 seconds in ms + + expect(result.getTime()).toBe(expectedTimestamp); + }); + + it('returns correct estimated Date for ledger in the future', () => { + const referenceLedger = 1000; + const referenceTimestamp = Date.now(); + const futureLedger = 1100; // 100 ledgers after reference + + const result = ledgerToTimestamp(futureLedger, referenceLedger, referenceTimestamp); + const expectedTimestamp = referenceTimestamp + 100 * 5000; // 100 * 5 seconds in ms + + expect(result.getTime()).toBe(expectedTimestamp); + }); + + it('returns exactly the reference timestamp when ledger equals reference', () => { + const referenceLedger = 1000; + const referenceTimestamp = Date.now(); + + const result = ledgerToTimestamp(referenceLedger, referenceLedger, referenceTimestamp); + + expect(result.getTime()).toBe(referenceTimestamp); + }); +}); diff --git a/src/utils/stellarLedger.utils.ts b/src/utils/stellarLedger.utils.ts new file mode 100644 index 0000000..a256971 --- /dev/null +++ b/src/utils/stellarLedger.utils.ts @@ -0,0 +1,20 @@ +/** + * Converts a Stellar ledger sequence number to an estimated UTC timestamp. + * Uses Stellar's ~5 second ledger time for estimation. + * + * @param ledger - The ledger sequence number to convert + * @param referenceLedger - A known ledger sequence number with a known timestamp + * @param referenceTimestamp - The timestamp (in ms) corresponding to the reference ledger + * @returns Estimated Date for the target ledger + */ +export function ledgerToTimestamp( + ledger: number, + referenceLedger: number, + referenceTimestamp: number +): Date { + const LEDGER_TIME_MS = 5000; // 5 seconds per ledger in milliseconds + const ledgerDelta = ledger - referenceLedger; + const timeDelta = ledgerDelta * LEDGER_TIME_MS; + const estimatedTimestamp = referenceTimestamp + timeDelta; + return new Date(estimatedTimestamp); +} From bf4abb5eb97ebac751a6efe36336c6b1c267d2e3 Mon Sep 17 00:00:00 2001 From: Vvictor-commits Date: Fri, 26 Jun 2026 00:01:43 +0100 Subject: [PATCH 08/20] docs: update CONTRIBUTING.md with folder structure and conventions --- CONTRIBUTING.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b606c35..fb3ccfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,42 @@ The repository also uses Husky plus `lint-staged` to run lightweight checks on s - Prefer accessible, keyboard-friendly UI behavior. - Keep new routes focused and incremental until the main marketplace flows land. +### 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 Issues labeled `good first issue` should: From 7cfe5988d4ccb7e856982da4a05078e2d7cb5946 Mon Sep 17 00:00:00 2001 From: Vvictor-commits Date: Fri, 26 Jun 2026 00:06:14 +0100 Subject: [PATCH 09/20] fix: show creator list skeleton correctly during API request, remove artificial delay --- src/pages/LandingPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index b61b52c..52b2d23 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -477,7 +477,7 @@ function LandingPage() { setFetchRetryAttempt(0); setCreators(DEMO_CREATORS); } finally { - setTimeout(() => setIsLoading(false), 800); + setIsLoading(false); } }; From 91c4a2159c311dc4fdad01a19cc573a57d39be5a Mon Sep 17 00:00:00 2001 From: Yormee 103 Date: Fri, 26 Jun 2026 00:10:02 +0100 Subject: [PATCH 10/20] Add formatHolderCount helper for compact holder display. Closes #438 by formatting counts under 1K as plain strings and larger values with one-decimal K/M suffixes, with unit tests for each range and boundary values. Co-authored-by: Cursor --- .../__tests__/numberFormat.utils.test.ts | 29 +++++++++++++++++++ src/utils/numberFormat.utils.ts | 13 ++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/numberFormat.utils.test.ts b/src/utils/__tests__/numberFormat.utils.test.ts index b215130..b717784 100644 --- a/src/utils/__tests__/numberFormat.utils.test.ts +++ b/src/utils/__tests__/numberFormat.utils.test.ts @@ -3,6 +3,7 @@ import { formatNumber, formatCompactNumber, formatFollowerCount, + formatHolderCount, formatPercent, } from '../numberFormat.utils'; @@ -145,6 +146,34 @@ describe('formatFollowerCount: Legacy follower abbreviation', () => { }); }); +// --------------------------------------------------------------------------- +// Feature: Holder count formatting +// Validates: Issue #438 acceptance criteria +// --------------------------------------------------------------------------- +describe('formatHolderCount: Holder count abbreviation', () => { + it('returns values under 1000 as a plain string', () => { + expect(formatHolderCount(0)).toBe('0'); + expect(formatHolderCount(42)).toBe('42'); + expect(formatHolderCount(999)).toBe('999'); + }); + + it('formats values in the K range with one decimal place', () => { + expect(formatHolderCount(1200)).toBe('1.2K'); + expect(formatHolderCount(1500)).toBe('1.5K'); + expect(formatHolderCount(999_999)).toBe('1000K'); + }); + + it('formats values in the M range with one decimal place', () => { + expect(formatHolderCount(2_400_000)).toBe('2.4M'); + expect(formatHolderCount(1_250_000)).toBe('1.3M'); + }); + + it('handles boundary values at 1000 and 1000000', () => { + expect(formatHolderCount(1000)).toBe('1K'); + expect(formatHolderCount(1_000_000)).toBe('1M'); + }); +}); + // --------------------------------------------------------------------------- // Feature: Percentage formatting // Validates: Acceptance Criteria for badge display diff --git a/src/utils/numberFormat.utils.ts b/src/utils/numberFormat.utils.ts index ea6ecca..18b48bc 100644 --- a/src/utils/numberFormat.utils.ts +++ b/src/utils/numberFormat.utils.ts @@ -45,7 +45,14 @@ export function formatCompactNumber( return formatNumber(value, { ...options, style: 'compact' }); } -export function formatFollowerCount(count: number): string { +/** + * Formats holder counts for compact display across creator profile surfaces. + * + * - Below 1,000: plain string (e.g. `999`) + * - 1,000–999,999: one decimal K suffix (e.g. `1.2K`, `1K` at exactly 1,000) + * - 1,000,000+: one decimal M suffix (e.g. `2.4M`, `1M` at exactly 1,000,000) + */ +export function formatHolderCount(count: number): string { if (count >= 1_000_000) { return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; } @@ -55,6 +62,10 @@ export function formatFollowerCount(count: number): string { return count.toString(); } +export function formatFollowerCount(count: number): string { + return formatHolderCount(count); +} + export interface FormatPercentOptions { /** Maximum fractional digits in the rendered value. Defaults to 2. */ maximumFractionDigits?: number; From 86014083fed482e7a9495526482bff31c71b2ae8 Mon Sep 17 00:00:00 2001 From: Samuel Ojetunde Date: Fri, 26 Jun 2026 00:14:05 +0100 Subject: [PATCH 11/20] docs: add error handling guide for React Query hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the standard pattern contributors should follow when writing new useQuery and useMutation hooks — covering the onError callback convention, when to show a toast vs inline error vs error boundary, how to distinguish 4xx client errors from 5xx server errors and network failures, and a worked example hook that handles all three cases. Closes #460 --- docs/error-handling-in-hooks.md | 348 ++++++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 docs/error-handling-in-hooks.md 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 `` | From 6b0f7332b23cbf326e663d9de5bed3739f11e74e Mon Sep 17 00:00:00 2001 From: Kaycee276 Date: Fri, 26 Jun 2026 04:24:47 +0100 Subject: [PATCH 12/20] test: add integration test for ledgerToTimestamp (#461) --- .../stellarLedger.integration.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/utils/__tests__/stellarLedger.integration.test.ts diff --git a/src/utils/__tests__/stellarLedger.integration.test.ts b/src/utils/__tests__/stellarLedger.integration.test.ts new file mode 100644 index 0000000..8f173f3 --- /dev/null +++ b/src/utils/__tests__/stellarLedger.integration.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { ledgerToTimestamp } from '../stellarLedger.utils'; + +describe('ledgerToTimestamp - Integration Test with Known Values', () => { + // A known reference point: ledger 50000000 and some arbitrary timestamp. + const KNOWN_REFERENCE_LEDGER = 50000000; + // e.g. 2024-01-01T00:00:00.000Z + const KNOWN_REFERENCE_TIMESTAMP = new Date('2024-01-01T00:00:00.000Z').getTime(); + + it('returns exactly the reference timestamp when ledger equals reference', () => { + const result = ledgerToTimestamp( + KNOWN_REFERENCE_LEDGER, + KNOWN_REFERENCE_LEDGER, + KNOWN_REFERENCE_TIMESTAMP + ); + expect(result.getTime()).toBe(KNOWN_REFERENCE_TIMESTAMP); + }); + + it('returns correctly estimated future date (100 ledgers ahead)', () => { + const futureLedger = KNOWN_REFERENCE_LEDGER + 100; + const result = ledgerToTimestamp( + futureLedger, + KNOWN_REFERENCE_LEDGER, + KNOWN_REFERENCE_TIMESTAMP + ); + const expectedTimestamp = KNOWN_REFERENCE_TIMESTAMP + 100 * 5000; + expect(result.getTime()).toBe(expectedTimestamp); + }); + + it('returns correctly estimated past date (100 ledgers behind)', () => { + const pastLedger = KNOWN_REFERENCE_LEDGER - 100; + const result = ledgerToTimestamp( + pastLedger, + KNOWN_REFERENCE_LEDGER, + KNOWN_REFERENCE_TIMESTAMP + ); + const expectedTimestamp = KNOWN_REFERENCE_TIMESTAMP - 100 * 5000; + expect(result.getTime()).toBe(expectedTimestamp); + }); +}); From c1fc6213cf7c345afdce416b8db9c1b59c93b623 Mon Sep 17 00:00:00 2001 From: Kaycee276 Date: Fri, 26 Jun 2026 04:33:04 +0100 Subject: [PATCH 13/20] feat: add generic localStorage preference helpers (#458) --- src/utils/__tests__/preferences.utils.test.ts | 33 +++++++++++++++++ src/utils/preferences.utils.ts | 36 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/utils/__tests__/preferences.utils.test.ts create mode 100644 src/utils/preferences.utils.ts diff --git a/src/utils/__tests__/preferences.utils.test.ts b/src/utils/__tests__/preferences.utils.test.ts new file mode 100644 index 0000000..161c438 --- /dev/null +++ b/src/utils/__tests__/preferences.utils.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getPreference, setPreference } from '../preferences.utils'; + +describe('preferences.utils', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('returns stored value for a key that exists', () => { + window.localStorage.setItem('testKey', JSON.stringify({ theme: 'dark' })); + const result = getPreference('testKey', { theme: 'light' }); + expect(result).toEqual({ theme: 'dark' }); + }); + + it('returns default value for a missing key', () => { + const result = getPreference('missingKey', 'default-value'); + expect(result).toBe('default-value'); + }); + + it('returns default value if stored JSON is malformed', () => { + window.localStorage.setItem('malformedKey', '{ invalid json '); + const result = getPreference('malformedKey', 'default-value'); + expect(result).toBe('default-value'); + }); + + it('set and get round-trip correctly', () => { + const complexValue = { sort: 'desc', filter: ['active', 'verified'] }; + setPreference('complexKey', complexValue); + + const result = getPreference('complexKey', { sort: 'asc', filter: [] }); + expect(result).toEqual(complexValue); + }); +}); diff --git a/src/utils/preferences.utils.ts b/src/utils/preferences.utils.ts new file mode 100644 index 0000000..c8c0d23 --- /dev/null +++ b/src/utils/preferences.utils.ts @@ -0,0 +1,36 @@ +/** + * Retrieves a parsed value from localStorage. + * Handles missing keys and malformed JSON safely by returning the defaultValue. + * + * @param key The localStorage key to retrieve + * @param defaultValue The default value to return if the key is missing or parsing fails + * @returns The parsed value or the default value + */ +export function getPreference(key: string, defaultValue: T): T { + try { + const item = window.localStorage.getItem(key); + if (item === null) { + return defaultValue; + } + return JSON.parse(item) as T; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return defaultValue; + } +} + +/** + * Serializes and stores a value in localStorage. + * Safely handles potential storage errors (e.g., quota exceeded). + * + * @param key The localStorage key to set + * @param value The value to serialize and store + */ +export function setPreference(key: string, value: T): void { + try { + const serialized = JSON.stringify(value); + window.localStorage.setItem(key, serialized); + } catch (error) { + console.warn(`Error setting localStorage key "${key}":`, error); + } +} From 127b0e46a63b78d67114ea5ab4af6a29423aac24 Mon Sep 17 00:00:00 2001 From: Keshinro Tanitoluwa Joseph Date: Fri, 26 Jun 2026 05:30:15 +0100 Subject: [PATCH 14/20] feat: add CreatorPageErrorBoundary for creator page render errors Closes #424 Add a React error boundary scoped to creator detail pages so a render error shows a fallback UI instead of crashing the whole app to a blank screen. Follows the existing SectionErrorBoundary class pattern. - Fallback shows a short message and a "Back to creators" link (/creators) - Logs the error to console only in development (import.meta.env.DEV) - Add tests covering normal render, fallback on error, and the list link --- .../common/CreatorPageErrorBoundary.tsx | 69 +++++++++++++++++++ .../CreatorPageErrorBoundary.test.tsx | 60 ++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/components/common/CreatorPageErrorBoundary.tsx create mode 100644 src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx diff --git a/src/components/common/CreatorPageErrorBoundary.tsx b/src/components/common/CreatorPageErrorBoundary.tsx new file mode 100644 index 0000000..8dffcdf --- /dev/null +++ b/src/components/common/CreatorPageErrorBoundary.tsx @@ -0,0 +1,69 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { Link } from 'react-router'; +import { AlertCircle, ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; +} + +/** + * Error boundary scoped to creator detail routes. When a creator page throws + * during render, this catches the error and shows a fallback with a link back + * to the creator list instead of crashing the whole app to a blank screen. + */ +class CreatorPageErrorBoundary extends Component { + public state: State = { + hasError: false, + }; + + public static getDerivedStateFromError(): State { + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + if (import.meta.env.DEV) { + console.error('Error rendering creator page:', error, errorInfo); + } + } + + public render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+ ); + } + + return this.props.children; + } +} + +export default CreatorPageErrorBoundary; diff --git a/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx b/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx new file mode 100644 index 0000000..e1b5d11 --- /dev/null +++ b/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; + +// Mock component that throws a render error on demand +const BuggyCreatorPage = ({ shouldThrow = false }: { shouldThrow?: boolean }) => { + if (shouldThrow) { + throw new Error('Creator page render error'); + } + return
Creator profile content
; +}; + +const renderInRouter = (ui: ReactNode) => + render({ui}); + +describe('CreatorPageErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders children when no error occurs', () => { + renderInRouter( + + + + ); + + expect(screen.getByText('Creator profile content')).toBeInTheDocument(); + }); + + it('renders fallback UI instead of crashing when a creator page throws', () => { + // Suppress React's expected error logging for the thrown error + vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderInRouter( + + + + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/could not load/i)).toBeInTheDocument(); + expect(screen.queryByText('Creator profile content')).not.toBeInTheDocument(); + }); + + it('fallback includes a link back to the creator list', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderInRouter( + + + + ); + + const link = screen.getByRole('link', { name: /back to creators/i }); + expect(link).toHaveAttribute('href', '/creators'); + }); +}); From d21d8ebff20013fd0676abd753a7a40bf3e19827 Mon Sep 17 00:00:00 2001 From: Charity Irone Date: Fri, 26 Jun 2026 07:15:44 +0000 Subject: [PATCH 15/20] docs: add contributor guide for adding a new page route Closes #426. Adds docs/adding-page-routes.md covering where routes are registered in src/App.tsx, the file naming convention for page components, the recommended pattern for auth-protected routes (with a RequireAuth wrapper example), and a four-step worked example adding a public AboutPage at /about. Adds a one-line pointer in CONTRIBUTING.md under Frontend conventions so contributors can find the guide. --- CONTRIBUTING.md | 1 + docs/adding-page-routes.md | 272 +++++++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 docs/adding-page-routes.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b606c35..3c8c6f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,7 @@ 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. ## 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. | From 46a17482538191c5eb22e4a6f18ffe2ad4747529 Mon Sep 17 00:00:00 2001 From: adajala Date: Fri, 26 Jun 2026 08:57:11 +0100 Subject: [PATCH 16/20] test: add query key integration tests and placeholder hooks --- .../__tests__/queryKeyIntegration.test.tsx | 55 +++++++++++++++++++ src/hooks/useCreators.ts | 18 ++++++ src/hooks/useWallet.ts | 18 ++++++ 3 files changed, 91 insertions(+) create mode 100644 src/hooks/__tests__/queryKeyIntegration.test.tsx create mode 100644 src/hooks/useCreators.ts create mode 100644 src/hooks/useWallet.ts diff --git a/src/hooks/__tests__/queryKeyIntegration.test.tsx b/src/hooks/__tests__/queryKeyIntegration.test.tsx new file mode 100644 index 0000000..0ec7280 --- /dev/null +++ b/src/hooks/__tests__/queryKeyIntegration.test.tsx @@ -0,0 +1,55 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +import { queryKeys } from '@/lib/queryKeys'; +import { useCreatorList, useCreatorDetail } from '../useCreators'; +import { useWalletHoldings, useWalletActivity } from '../useWallet'; + +describe('queryKeyIntegration', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('useCreatorList uses the correct query key constant', () => { + const params = { page: 1 }; + renderHook(() => useCreatorList(params), { wrapper }); + + const cache = queryClient.getQueryCache().getAll(); + expect(cache).toHaveLength(1); + expect(cache[0].queryKey).toEqual(queryKeys.creators.list(params)); + }); + + it('useCreatorDetail uses the correct query key constant', () => { + renderHook(() => useCreatorDetail('creator-1'), { wrapper }); + + const cache = queryClient.getQueryCache().getAll(); + expect(cache).toHaveLength(1); + expect(cache[0].queryKey).toEqual(queryKeys.creators.detail('creator-1')); + }); + + it('useWalletHoldings uses the correct query key constant', () => { + renderHook(() => useWalletHoldings('0x123'), { wrapper }); + + const cache = queryClient.getQueryCache().getAll(); + expect(cache).toHaveLength(1); + expect(cache[0].queryKey).toEqual(queryKeys.wallet.holdings('0x123')); + }); + + it('useWalletActivity uses the correct query key constant', () => { + renderHook(() => useWalletActivity('0x123'), { wrapper }); + + const cache = queryClient.getQueryCache().getAll(); + expect(cache).toHaveLength(1); + expect(cache[0].queryKey).toEqual(queryKeys.wallet.activity('0x123')); + }); +}); diff --git a/src/hooks/useCreators.ts b/src/hooks/useCreators.ts new file mode 100644 index 0000000..b2690fe --- /dev/null +++ b/src/hooks/useCreators.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import type { GetCoursesParams } from '@/services/course.service'; + +export function useCreatorList(params?: GetCoursesParams) { + return useQuery({ + queryKey: queryKeys.creators.list(params), + queryFn: async () => [], + }); +} + +export function useCreatorDetail(id: string) { + return useQuery({ + queryKey: queryKeys.creators.detail(id), + queryFn: async () => null, + enabled: !!id, + }); +} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts new file mode 100644 index 0000000..fa3e550 --- /dev/null +++ b/src/hooks/useWallet.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useWalletHoldings(address: string) { + return useQuery({ + queryKey: queryKeys.wallet.holdings(address), + queryFn: async () => [], + enabled: !!address, + }); +} + +export function useWalletActivity(address: string) { + return useQuery({ + queryKey: queryKeys.wallet.activity(address), + queryFn: async () => [], + enabled: !!address, + }); +} From 45173e1af0793411a424c273e11a768bb877b6ba Mon Sep 17 00:00:00 2001 From: Ghadaffijr Date: Fri, 26 Jun 2026 11:30:44 +0100 Subject: [PATCH 17/20] feat: replace copied tooltip with react-hot-toast success notification - Import showToast from @/utils/toast.util in CopyField.tsx - Fire showToast.success('Address copied to clipboard', { duration: 2000 }) on copy - Retain CopySuccessAnnouncement for screen reader accessibility - Keep copied state for Check icon visual feedback on the button Closes #454 --- src/components/common/CopyField.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/common/CopyField.tsx b/src/components/common/CopyField.tsx index 6ecfffc..659e616 100644 --- a/src/components/common/CopyField.tsx +++ b/src/components/common/CopyField.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'; import { useAutoSelectOnFocus } from '@/hooks/useAutoSelectOnFocus'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; +import showToast from '@/utils/toast.util'; interface CopyFieldProps { value: string; @@ -25,15 +26,16 @@ const CopyField: React.FC = ({ const { announcement, announceCopySuccess } = useCopySuccessAnnouncement(); const handleCopy = async () => { - try { - await navigator.clipboard.writeText(value); - announceCopySuccess(`${label} copied.`); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setCopied(false); - } - }; + try { + await navigator.clipboard.writeText(value); + announceCopySuccess(`${label} copied.`); + showToast.success('Address copied to clipboard', { duration: 2000 }); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } +}; return (
From d5b09bac128f7f895933b22795524aa1b9dc2e52 Mon Sep 17 00:00:00 2001 From: DammyAji Date: Fri, 26 Jun 2026 16:26:16 +0100 Subject: [PATCH 18/20] test: add integration tests for empty state on zero-result creator search (#452) --- ...andingPage.emptyState.integration.test.tsx | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/pages/__tests__/LandingPage.emptyState.integration.test.tsx diff --git a/src/pages/__tests__/LandingPage.emptyState.integration.test.tsx b/src/pages/__tests__/LandingPage.emptyState.integration.test.tsx new file mode 100644 index 0000000..254ae44 --- /dev/null +++ b/src/pages/__tests__/LandingPage.emptyState.integration.test.tsx @@ -0,0 +1,172 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } 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', + }), +})); + +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 mockGetCourses = vi.mocked(courseService.getCourses); + +const creator: Course = { + id: 'creator-a', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +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(), + })), + }); +}; + +describe('LandingPage empty state integration (#452)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('renders the empty state when API returns zero creators and a search term is entered', async () => { + mockGetCourses.mockResolvedValue([]); + render(); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + fireEvent.change( + screen.getByPlaceholderText(/search creators by name or handle/i), + { target: { value: 'nobody' } } + ); + + expect( + await screen.findByRole('status', { name: /no creators found/i }) + ).toBeInTheDocument(); + }); + + it('renders the empty state when no creators match the search query', async () => { + mockGetCourses.mockResolvedValue([creator]); + render(); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + fireEvent.change( + screen.getByPlaceholderText(/search creators by name or handle/i), + { target: { value: 'xyznotfound' } } + ); + + expect( + await screen.findByRole('status', { name: /no creators found/i }) + ).toBeInTheDocument(); + }); + + it('clear button resets the search input, hides the empty state, and re-fetches the full list', async () => { + mockGetCourses.mockResolvedValue([creator]); + render(); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + // Type a query that yields no matches + fireEvent.change( + screen.getByPlaceholderText(/search creators by name or handle/i), + { target: { value: 'xyznotfound' } } + ); + await screen.findByRole('status', { name: /no creators found/i }); + + // Click the "Reset Search" button rendered by EmptyState + fireEvent.click(screen.getByRole('button', { name: /reset search/i })); + + // Empty state must disappear and the creator card must reappear + await waitFor(() => { + expect( + screen.queryByRole('status', { name: /no creators found/i }) + ).not.toBeInTheDocument(); + }); + expect( + screen.getByRole('article', { name: /creator alpha/i }) + ).toBeInTheDocument(); + + // Search input must be cleared + expect( + screen.getByPlaceholderText(/search creators by name or handle/i) + ).toHaveValue(''); + }); +}); From e3c60bb66610cfd23545b7929d4dae63417df0b0 Mon Sep 17 00:00:00 2001 From: Big Della Date: Sat, 27 Jun 2026 06:46:10 +0000 Subject: [PATCH 19/20] address creator list stability and filter coverage --- docs/shared-hooks.md | 52 ++++++ src/pages/LandingPage.tsx | 157 ++++++++++++++---- ...ingPage.apiErrorToast.integration.test.tsx | 138 +++++++++++++++ ...dingPage.priceFilters.integration.test.tsx | 146 ++++++++++++++++ src/services/course.service.ts | 2 + .../__tests__/creatorListKey.utils.test.ts | 12 ++ src/utils/creatorListKey.utils.ts | 2 + 7 files changed, 481 insertions(+), 28 deletions(-) create mode 100644 docs/shared-hooks.md create mode 100644 src/pages/__tests__/LandingPage.apiErrorToast.integration.test.tsx create mode 100644 src/pages/__tests__/LandingPage.priceFilters.integration.test.tsx create mode 100644 src/utils/__tests__/creatorListKey.utils.test.ts create mode 100644 src/utils/creatorListKey.utils.ts 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/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 52b2d23..fe670bd 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -62,6 +62,7 @@ import { } from '@/utils/portfolioValue.utils'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition'; +import { creatorListKey } from '@/utils/creatorListKey.utils'; import { AlertCircle, ChevronDown, RefreshCw } from 'lucide-react'; import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState'; import CreatorListPagination from '@/components/common/CreatorListPagination'; @@ -228,6 +229,15 @@ const isCreatorRefreshShortcut = (event: KeyboardEvent) => !event.shiftKey && event.key.toLowerCase() === 'r'; +const toPriceFilterValue = (value: string) => { + if (!value.trim()) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +}; + +const getCreatorListKey = (creator: Course) => + creatorListKey(Number(creator.id)); + type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; interface CreatorProfileLoadErrorProps { @@ -283,6 +293,8 @@ function LandingPage() { const [isFilterLoading, setIsFilterLoading] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); const [searchQuery, setSearchQuery] = useState(''); + const [minPriceFilter, setMinPriceFilter] = useState(''); + const [maxPriceFilter, setMaxPriceFilter] = useState(''); const searchQueryRef = useRef(''); const sortOptionRef = useRef('featured'); const PROFILE_TABS = ['overview', 'creations', 'collectors', 'activity']; @@ -299,7 +311,10 @@ function LandingPage() { const prefersReducedMotion = usePrefersReducedMotion(); const [sortOption, setSortOption] = useState(() => { const sort = searchParams.get('sort') as SortOption | null; - if (sort && ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort)) { + if ( + sort && + ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort) + ) { sortOptionRef.current = sort; return sort; } @@ -386,7 +401,13 @@ function LandingPage() { setSearchQuery(''); } const sort = searchParams.get('sort') as SortOption | null; - if (sort && ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort) && sort !== sortOptionRef.current) { + if ( + sort && + ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes( + sort + ) && + sort !== sortOptionRef.current + ) { setSortOption(sort); } else if (sort === null && sortOptionRef.current !== 'featured') { setSortOption('featured'); @@ -447,7 +468,15 @@ function LandingPage() { setShowRetryBanner(false); setFinalFetchError(''); try { - const data = await courseService.getCourses(); + const minPrice = toPriceFilterValue(minPriceFilter); + const maxPrice = toPriceFilterValue(maxPriceFilter); + const params = { + ...(minPrice !== undefined ? { min_price: minPrice } : {}), + ...(maxPrice !== undefined ? { max_price: maxPrice } : {}), + }; + const data = await courseService.getCourses( + Object.keys(params).length > 0 ? params : undefined + ); if (data && data.length > 0) { setCreators(data); } else { @@ -458,6 +487,11 @@ function LandingPage() { setCreatorsFetchedAt(Date.now()); setFetchRetryAttempt(0); } catch { + if (fetchRetryAttempt === 0) { + showToast.error( + 'Unable to load creators. Check your connection and try again.' + ); + } if (fetchRetryAttempt < MAX_CREATOR_FETCH_RETRIES) { const nextAttempt = fetchRetryAttempt + 1; setShowRetryBanner(true); @@ -482,7 +516,7 @@ function LandingPage() { }; fetchCreators(); - }, [fetchRetryAttempt, fetchRequestId]); + }, [fetchRetryAttempt, fetchRequestId, maxPriceFilter, minPriceFilter]); const searchSuggestions = useMemo(() => { const fromCategories = creators @@ -581,6 +615,10 @@ function LandingPage() { }; const handleResetSearch = () => setSearchQuery(''); + const handleClearPriceFilters = () => { + setMinPriceFilter(''); + setMaxPriceFilter(''); + }; const handleRetryCreatorFetch = useCallback(() => { setFinalFetchError(''); @@ -824,6 +862,57 @@ function LandingPage() {
+
+
+ + + setMinPriceFilter(event.target.value) + } + className="mt-1 h-10 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60" + /> +
+
+ + + setMaxPriceFilter(event.target.value) + } + className="mt-1 h-10 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60" + /> +
+ +
Shortcut -