diff --git a/.env.example b/.env.example index b7957baa..f751e04c 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,41 @@ -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= +# Stellar network for Stellar Expert explorer links. +# Set to 'mainnet' for production, 'testnet' for development (default). +VITE_STELLAR_NETWORK=testnet + # 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/.gitignore b/.gitignore index d4f78a58..6ef19594 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,19 @@ dist-ssr *.sln *.sw? issue.md -pr.md \ No newline at end of file +pr.md + +# MiMoCode +.mimocode/ +# Test artifacts +__snapshots__ +*.snap +coverage +.nyc_output + +# OS artifacts +Thumbs.db + +# Lock files from other package managers +package-lock.json +yarn.lock diff --git a/.husky/pre-commit b/.husky/pre-commit index e65f5e7b..60e30912 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,6 @@ #!/usr/bin/env sh +export PATH="$PATH:/c/Program Files/Git/bin:/c/Program Files/Git/usr/bin:C:/Program Files/Git/bin:C:/Program Files/Git/usr/bin" + sh ./scripts/check-no-package-lock.sh npx lint-staged + diff --git a/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro b/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro new file mode 100644 index 00000000..908762a0 --- /dev/null +++ b/.kiro/specs/holder-count-cache-invalidation-test/.config.kiro @@ -0,0 +1 @@ +{"specId": "a3f82c1e-9d47-4b8e-bc63-7e5a2f3d1094", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/holder-count-cache-invalidation-test/design.md b/.kiro/specs/holder-count-cache-invalidation-test/design.md new file mode 100644 index 00000000..90fd7a82 --- /dev/null +++ b/.kiro/specs/holder-count-cache-invalidation-test/design.md @@ -0,0 +1,439 @@ +# Design Document + +## Feature: Holder Count Cache Invalidation Test + +## Overview + +This design covers the test infrastructure and minimal production code change needed to validate that the creator detail page's "Audience" holder count chip updates correctly after a React Query cache invalidation triggers a refetch — all within the same mounted component instance, without a page reload. + +The production change is small and surgical: extract the holder count value into a `useCreatorHolderCount` custom hook backed by `useQuery`. This makes the component's data dependency explicit and directly testable via React Query's cache API. The integration test then wraps the component with a fresh `QueryClientProvider`, pre-seeds the cache, invalidates the query key, and asserts the updated count appears. + +**Key constraints:** + +- The test must confirm the update happens within the same mounted component instance (no remount). +- Each test uses a fresh `QueryClient` instance to prevent inter-test cache contamination. +- No production network layer (`courseService`) is imported in the test file; all I/O is replaced by `vi.fn()` stubs. + +--- + +## Architecture + +The feature involves three layers: + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Test Layer (src/pages/__tests__/holderCountCacheInvalidation.test.tsx) │ +│ - Fresh QueryClient per test (beforeEach) │ +│ - vi.fn() mockFetch stub │ +│ - queryClient.setQueryData() to pre-seed cache │ +│ - queryClient.invalidateQueries() to trigger refetch │ +│ - @testing-library/react assertions on MiniStatChip value │ +└─────────────────────┬────────────────────────────────────────────────────┘ + │ renders +┌─────────────────────▼────────────────────────────────────────────────────┐ +│ Component Under Test: FeaturedCreatorAudienceChip │ +│ (src/components/common/FeaturedCreatorAudienceChip.tsx) │ +│ - Calls useCreatorHolderCount(creatorId) │ +│ - Renders │ +└─────────────────────┬────────────────────────────────────────────────────┘ + │ uses +┌─────────────────────▼────────────────────────────────────────────────────┐ +│ Hook: useCreatorHolderCount │ +│ (src/hooks/useCreatorHolderCount.ts) │ +│ - useQuery({ queryKey: ['creator', creatorId, 'holderCount'], ... }) │ +│ - Returns { count: number | null, isLoading, isError } │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +The existing `LandingPage.tsx` continues to work unchanged for users — it renders the same `MiniStatChip` via the new `FeaturedCreatorAudienceChip` component, which replaces the inline constant-backed chip. This keeps the diff minimal and avoids touching unrelated LandingPage logic. + +--- + +## Components and Interfaces + +### 1. `useCreatorHolderCount` hook + +**File:** `src/hooks/useCreatorHolderCount.ts` + +```typescript +import { useQuery } from '@tanstack/react-query'; + +export interface HolderCountResult { + count: number | null; + isLoading: boolean; + isError: boolean; +} + +/** + * Fetches the holder count for a given creator via React Query. + * Query key: ['creator', creatorId, 'holderCount'] + * + * The queryFn is injected as a parameter so tests can supply a mock + * without module-level vi.mock() patching. + */ +export function useCreatorHolderCount( + creatorId: string, + fetchHolderCount: (id: string) => Promise +): HolderCountResult { + const { data, isLoading, isError } = useQuery({ + queryKey: ['creator', creatorId, 'holderCount'], + queryFn: () => fetchHolderCount(creatorId), + staleTime: 30_000, + }); + + return { + count: data ?? null, + isLoading, + isError, + }; +} +``` + +**Design decision — injected `fetchHolderCount`:** Rather than importing a service at module level, the fetch function is a parameter. This means tests pass `vi.fn()` directly as a prop, making the hook trivially mockable without `vi.mock()` hoisting. Production callers pass in the real service method. + +### 2. `FeaturedCreatorAudienceChip` component + +**File:** `src/components/common/FeaturedCreatorAudienceChip.tsx` + +```typescript +import MiniStatChip from '@/components/common/MiniStatChip'; +import { useCreatorHolderCount } from '@/hooks/useCreatorHolderCount'; +import { getFeaturedCreatorKeyHolderCopy } from '@/utils/holderCount.utils'; + +interface FeaturedCreatorAudienceChipProps { + creatorId: string; + fetchHolderCount: (id: string) => Promise; +} + +export function FeaturedCreatorAudienceChip({ + creatorId, + fetchHolderCount, +}: FeaturedCreatorAudienceChipProps) { + const { count } = useCreatorHolderCount(creatorId, fetchHolderCount); + const copy = getFeaturedCreatorKeyHolderCopy(count); + + return ( + + ); +} +``` + +### 3. `getFeaturedCreatorKeyHolderCopy` utility extraction + +The existing inline function in `LandingPage.tsx` is moved to a shared utility so both the component and the test can import it: + +**File:** `src/utils/holderCount.utils.ts` + +```typescript +import { formatCompactNumber } from '@/utils/numberFormat.utils'; + +export interface HolderCountCopy { + value: string; + explanation: string; +} + +export function getFeaturedCreatorKeyHolderCopy( + count: number | null | undefined +): HolderCountCopy { + if (count == null) { + return { + value: 'Key holders unavailable', + explanation: 'Key holder data is not available yet.', + }; + } + if (count === 0) { + return { + value: 'No key holders yet', + explanation: + 'This creator has not unlocked any key holders yet. Be the first to buy a key and start the collector base.', + }; + } + return { + value: `${formatCompactNumber(count)} key holders`, + explanation: 'Number of wallets that currently hold at least one key.', + }; +} +``` + +### 4. `LandingPage.tsx` integration point + +Replace the inline `MiniStatChip` for "Audience" with the new component: + +```tsx +// Before + + +// After + +``` + +Where `realFetchHolderCount` is a thin wrapper over the eventual API call (currently returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` until the real endpoint exists). + +### 5. Test `createWrapper` helper + +**File:** `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` + +```typescript +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import type { ReactNode } from 'react'; + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} +``` + +--- + +## Data Models + +### Cache entry shape + +```typescript +// Query key tuple +type HolderCountKey = ['creator', string, 'holderCount']; + +// Cached value type +type HolderCountData = number | null; +``` + +Pre-seeding in tests uses `queryClient.setQueryData`: + +```typescript +queryClient.setQueryData( + ['creator', CREATOR_ID, 'holderCount'], + initialCount // number | null +); +``` + +### Mock fetch shape + +```typescript +const mockFetchHolderCount = vi.fn<(id: string) => Promise>(); +``` + +--- + +## Correctness Properties + +_A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees._ + +The project already has `fast-check` installed as a dev dependency (`"fast-check": "^4.6.0"` in `package.json`), which will be used for all property-based tests below. Each property test runs a minimum of 100 iterations. + +--- + +### Property 1: Initial render round-trip + +_For any_ non-negative integer `initialCount`, when the React Query cache is pre-seeded with that count and the component renders without a network call, the DOM shall display exactly the string `getFeaturedCreatorKeyHolderCopy(initialCount).value`. + +**Validates: Requirements 1.1, 5.4** + +--- + +### Property 2: Stale-while-revalidate display stability + +_For any_ non-negative integer `initialCount`, while the invalidation-triggered refetch is in-flight (the mock fetch has not yet resolved), the DOM shall continue to display the formatted string derived from `initialCount` and shall not show a blank value or an error state. + +**Validates: Requirements 2.3** + +--- + +### Property 3: Post-invalidation update round-trip + +_For any_ pair of distinct non-negative integers `(initialCount, updatedCount)`, after the cache is pre-seeded with `initialCount`, `queryClient.invalidateQueries` is called, and the mock refetch resolves with `updatedCount`, the DOM shall display `getFeaturedCreatorKeyHolderCopy(updatedCount).value`, shall no longer display `getFeaturedCreatorKeyHolderCopy(initialCount).value`, and this transition shall occur within the same mounted component instance (no unmount–remount cycle). + +**Validates: Requirements 3.1, 3.2, 3.4** + +--- + +### Property 4: Format function round-trip + +_For any_ non-negative integer `n`, the string `getFeaturedCreatorKeyHolderCopy(n).value` shall equal `"No key holders yet"` when `n === 0`, or `formatCompactNumber(n) + " key holders"` when `n > 0` — and this value shall be identical to what the `FeaturedCreatorAudienceChip` renders in the DOM when seeded with `n`. + +**Validates: Requirements 5.1, 5.4** + +--- + +## Error Handling + +| Scenario | Behavior | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `fetchHolderCount` rejects | `useCreatorHolderCount` returns `isError: true`, `count: null`; chip displays `"Key holders unavailable"` | +| `count` is `null` from fetch | Chip displays `"Key holders unavailable"` | +| `count` is `0` | Chip displays `"No key holders yet"` | +| `queryClient.invalidateQueries` with non-matching key | No refetch triggered; mock fetch not called; display unchanged | +| Network timeout during test | Controlled by mock — test resolves or rejects on demand | + +The hook does not implement retry logic beyond React Query's defaults (`retry: 3`). For tests, retry is disabled (`retry: false` on the test-scoped `QueryClient`) to keep assertions deterministic. + +--- + +## Testing Strategy + +### Overview + +This feature uses a **dual testing approach**: + +- **Property-based tests** (via `fast-check`) for universal correctness properties — format round-trips, stale-while-revalidate stability, and post-invalidation update guarantees. +- **Example-based / edge-case tests** for concrete scenarios: zero count, null count, non-matching query key, reload-not-called assertion. + +### Test file + +**Path:** `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` + +### Test setup pattern + +```typescript +import { QueryClient } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import fc from 'fast-check'; + +const CREATOR_ID = 'test-creator-42'; + +let queryClient: QueryClient; +let mockFetchHolderCount: ReturnType; + +beforeEach(() => { + // Fresh QueryClient per test — retry disabled for determinism + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + mockFetchHolderCount = vi.fn(); +}); + +afterEach(() => { + queryClient.clear(); +}); +``` + +### Property-based test outline + +```typescript +// Property 1: Initial render round-trip +it('renders the correct formatted string for any seeded count', async () => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), async count => { + // Feature: holder-count-cache-invalidation-test, Property 1: + // For any non-negative integer initialCount, DOM displays getFeaturedCreatorKeyHolderCopy(initialCount).value + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockFetchHolderCount = vi.fn(); + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count); + + const { unmount } = render( + , + { wrapper: createWrapper(queryClient) } + ); + + expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(count).value)).toBeInTheDocument(); + expect(mockFetchHolderCount).not.toHaveBeenCalled(); + unmount(); + }), + { numRuns: 100 } + ); +}); +``` + +```typescript +// Property 3: Post-invalidation update round-trip +it('displays the updated count after invalidation with the same component instance', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 999 }), + fc.integer({ min: 1000, max: 1_000_000 }), + async (initialCount, updatedCount) => { + // Feature: holder-count-cache-invalidation-test, Property 3: + // For any distinct (initialCount, updatedCount), post-invalidation DOM shows updatedCount + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + mockFetchHolderCount = vi.fn().mockResolvedValue(updatedCount); + queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], initialCount); + + const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => {}); + const { unmount } = render( + , + { wrapper: createWrapper(queryClient) } + ); + + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ['creator', CREATOR_ID, 'holderCount'] }); + }); + + await waitFor(() => { + expect(screen.getByText(getFeaturedCreatorKeyHolderCopy(updatedCount).value)).toBeInTheDocument(); + }); + expect(screen.queryByText(getFeaturedCreatorKeyHolderCopy(initialCount).value)).not.toBeInTheDocument(); + expect(reloadSpy).not.toHaveBeenCalled(); + + reloadSpy.mockRestore(); + unmount(); + } + ), + { numRuns: 100 } + ); +}); +``` + +```typescript +// Property 4: Format function round-trip (pure function — no render needed) +it('getFeaturedCreatorKeyHolderCopy produces the correct format for all non-negative integers', () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 10_000_000 }), count => { + // Feature: holder-count-cache-invalidation-test, Property 4: + // For any positive integer n, value === formatCompactNumber(n) + " key holders" + const { value } = getFeaturedCreatorKeyHolderCopy(count); + expect(value).toBe(`${formatCompactNumber(count)} key holders`); + }), + { numRuns: 200 } + ); +}); +``` + +### Edge-case and example tests + +| Test | Classification | Key assertion | +| ------------------------------------------------- | -------------- | ------------------------------------------------------------------------- | +| `count = 0` renders "No key holders yet" | EDGE_CASE | `screen.getByText('No key holders yet')` | +| `count = null` renders "Key holders unavailable" | EDGE_CASE | `screen.getByText('Key holders unavailable')` | +| Non-matching query key does not call mockFetch | EDGE_CASE | `expect(mockFetchHolderCount).not.toHaveBeenCalled()` | +| Seeded cache — mock fetch call count is zero | EXAMPLE | `expect(mockFetchHolderCount).not.toHaveBeenCalled()` | +| Mock fetch called exactly once after invalidation | EXAMPLE | `expect(mockFetchHolderCount).toHaveBeenCalledTimes(1)` with `CREATOR_ID` | + +### Vitest configuration + +No changes needed to `vitest.config.ts` — existing `jsdom` environment and `@testing-library/jest-dom/vitest` setup are sufficient. `fast-check` is already a dev dependency. + +### Mocks required in test file + +```typescript +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); +// framer-motion and other heavy dependencies mocked as in LandingPage.keyboard.test.tsx +// No vi.mock for courseService — it is NOT imported in this test file +``` diff --git a/.kiro/specs/holder-count-cache-invalidation-test/requirements.md b/.kiro/specs/holder-count-cache-invalidation-test/requirements.md new file mode 100644 index 00000000..f2450643 --- /dev/null +++ b/.kiro/specs/holder-count-cache-invalidation-test/requirements.md @@ -0,0 +1,85 @@ +# Requirements Document + +## Introduction + +This feature adds an integration test that verifies the creator detail page updates its displayed holder count after a React Query cache invalidation triggers a refetch. The page currently renders a `MiniStatChip` whose "Audience" value is derived from `FEATURED_CREATOR_KEY_HOLDER_COUNT`. The test must confirm that when the cache entry for a creator is invalidated and the refetch resolves with a new value, the UI reflects the updated count without a full page reload. + +The scope is purely test infrastructure: no production behaviour changes are required. The test will wrap the component under test with a `QueryClientProvider`, pre-seed the cache with an initial creator payload, then programmatically invalidate the query key and mock the refetch to return an updated holder count. Assertions confirm the new value is visible and the old value is gone. + +## Glossary + +- **Creator_Detail_Page**: The section of `LandingPage` (and its composing components) that displays creator statistics including the holder count "Audience" chip. +- **Holder_Count**: The integer representing the number of wallets that hold at least one key for a given creator. Rendered via `getFeaturedCreatorKeyHolderCopy` as a formatted string inside a `MiniStatChip`. +- **React_Query_Cache**: The in-memory data store managed by `@tanstack/react-query` (v5). Identified by a query key; entries can be invalidated with `queryClient.invalidateQueries`. +- **Query_Key**: The array used to identify a cache entry, e.g. `['creator', creatorId]`. +- **QueryClient**: The TanStack Query client instance that owns the cache and coordinates fetches. +- **QueryClientProvider**: The React context provider that makes a `QueryClient` available to components under test. +- **Test_Wrapper**: A helper that wraps a component under test with all required providers (`QueryClientProvider`, `MemoryRouter`) so it renders in isolation. +- **Mock_Fetch**: A `vi.fn()` stub that replaces the real network call, returning controlled data for each invocation. +- **Invalidation**: The act of marking one or more cache entries as stale, causing React Query to trigger a background refetch on the next render of a subscribed component. +- **Refetch**: The background network request that React Query fires after invalidation; in tests this is fulfilled by the `Mock_Fetch`. + +## Requirements + +### Requirement 1: Initial Holder Count Renders Correctly + +**User Story:** As a developer running the integration test suite, I want the creator detail page to render the correct initial holder count from the seeded cache, so that the test has a verified baseline before invalidation. + +#### Acceptance Criteria + +1. WHEN the `Test_Wrapper` renders the creator detail section with a `QueryClient` pre-seeded with `initialCount` keys in the cache entry, THE `Creator_Detail_Page` SHALL display a formatted string derived from `initialCount` (e.g. `"42 key holders"`) in the holder count element. +2. WHEN the initial render completes without triggering a network call, THE `Mock_Fetch` SHALL have been called zero times. +3. IF the `initialCount` is `0`, THEN THE `Creator_Detail_Page` SHALL display `"No key holders yet"` in the holder count element. +4. IF the `initialCount` is `null`, THEN THE `Creator_Detail_Page` SHALL display `"Key holders unavailable"` in the holder count element. + +--- + +### Requirement 2: Cache Invalidation Triggers a Refetch + +**User Story:** As a developer running the integration test suite, I want calling `queryClient.invalidateQueries` on the creator query key to trigger exactly one refetch call to the `Mock_Fetch`, so that I can confirm React Query's invalidation mechanism is wired correctly. + +#### Acceptance Criteria + +1. WHEN `queryClient.invalidateQueries` is called with the creator's `Query_Key`, THE `QueryClient` SHALL mark the cache entry as stale and schedule a background refetch. +2. WHEN the invalidation-driven refetch executes, THE `Mock_Fetch` SHALL be called exactly once with the creator's identifier as a parameter. +3. WHILE the refetch is in-flight, THE `Creator_Detail_Page` SHALL continue to display the previously cached holder count without showing a blank or error state. +4. IF `queryClient.invalidateQueries` is called with a `Query_Key` that does not match any active query, THEN THE `Mock_Fetch` SHALL NOT be called. + +--- + +### Requirement 3: Updated Holder Count Renders After Refetch + +**User Story:** As a developer running the integration test suite, I want the creator detail page to display the updated holder count returned by the refetch, so that I can confirm the UI reflects fresh data after cache invalidation. + +#### Acceptance Criteria + +1. WHEN the refetch resolves with `updatedCount`, THE `Creator_Detail_Page` SHALL display the formatted string derived from `updatedCount` (e.g. `"99 key holders"`) in the holder count element. +2. WHEN the updated count is visible, THE `Creator_Detail_Page` SHALL NOT display the formatted string that was derived from `initialCount`. +3. THE `Creator_Detail_Page` SHALL display the updated count without requiring a full page reload (i.e. `window.location.reload` SHALL NOT be called during the test). +4. WHEN `updatedCount` differs from `initialCount`, THE display transition SHALL occur within the same mounted component instance, confirming no unmount–remount cycle was required. + +--- + +### Requirement 4: Test Isolation and No Side Effects + +**User Story:** As a developer running the integration test suite, I want each test case to use a fresh `QueryClient` instance and reset all mocks, so that tests do not leak state into one another. + +#### Acceptance Criteria + +1. THE `Test_Wrapper` SHALL instantiate a new `QueryClient` in `beforeEach` (or equivalent per-test setup) so that cache state from one test does not influence another. +2. THE `Mock_Fetch` SHALL be reset (via `vi.resetAllMocks()` or `mockFn.mockReset()`) before each test so that call counts and return values are clean. +3. WHEN a test completes, THE `Test_Wrapper` SHALL unmount cleanly without leaving dangling subscriptions or timers that could affect subsequent tests. +4. THE test file SHALL NOT import or call any production network layer (e.g. `courseService`) directly; all external I/O SHALL be replaced by `Mock_Fetch` stubs. + +--- + +### Requirement 5: Holder Count Display Format Consistency + +**User Story:** As a developer running the integration test suite, I want the holder count format assertions to match the format produced by `getFeaturedCreatorKeyHolderCopy`, so that the test accurately reflects what a real user would see. + +#### Acceptance Criteria + +1. THE `Creator_Detail_Page` SHALL format a positive `holderCount` as `" key holders"` where `` is the output of `formatCompactNumber(holderCount)`. +2. WHEN `holderCount` is `0`, THE `Creator_Detail_Page` SHALL display exactly `"No key holders yet"`. +3. WHEN `holderCount` is `null` or `undefined`, THE `Creator_Detail_Page` SHALL display exactly `"Key holders unavailable"`. +4. FOR ALL valid non-negative integer values of `holderCount`, THE display string produced by `getFeaturedCreatorKeyHolderCopy(holderCount)` SHALL be consistent with the string rendered in the DOM (round-trip equivalence property). diff --git a/.kiro/specs/holder-count-cache-invalidation-test/tasks.md b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md new file mode 100644 index 00000000..1d39f512 --- /dev/null +++ b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md @@ -0,0 +1,107 @@ +# Implementation Plan: Holder Count Cache Invalidation Test + +## Overview + +Extract the holder count utility and introduce a thin React Query–backed component layer (`useCreatorHolderCount` + `FeaturedCreatorAudienceChip`) so that cache invalidation is directly observable in tests. Write a property-based integration test covering all four correctness properties and the key edge cases, then verify the full suite passes. + +The production diff is intentionally small: one utility file, one hook, one component, and a one-line swap in `LandingPage.tsx`. Everything else lives in the test file. + +## Tasks + +- [x] 1. Extract `getFeaturedCreatorKeyHolderCopy` to a shared utility module + - Create `src/utils/holderCount.utils.ts` + - Move the `getFeaturedCreatorKeyHolderCopy` function (currently defined inline in `LandingPage.tsx` at line ~81) into the new file + - Export `HolderCountCopy` interface and `getFeaturedCreatorKeyHolderCopy` function + - Import `formatCompactNumber` from `@/utils/numberFormat.utils` + - Keep the existing inline definition in `LandingPage.tsx` for now — it will be replaced in Task 4 + - _Requirements: 5.1, 5.2, 5.3, 5.4_ + +- [x] 2. Create `useCreatorHolderCount` hook + - Create `src/hooks/useCreatorHolderCount.ts` + - Implement `useQuery` with query key `['creator', creatorId, 'holderCount']` and `staleTime: 30_000` + - Accept `fetchHolderCount: (id: string) => Promise` as an injected parameter (avoids module-level `vi.mock` in tests) + - Export `HolderCountResult` interface `{ count: number | null; isLoading: boolean; isError: boolean }` + - Return `{ count: data ?? null, isLoading, isError }` + - _Requirements: 2.1, 2.2, 2.3_ + +- [x] 3. Create `FeaturedCreatorAudienceChip` component + - Create `src/components/common/FeaturedCreatorAudienceChip.tsx` + - Accept props: `creatorId: string` and `fetchHolderCount: (id: string) => Promise` + - Call `useCreatorHolderCount(creatorId, fetchHolderCount)` and pipe `count` through `getFeaturedCreatorKeyHolderCopy` + - Render `` + - Import `MiniStatChip` from `@/components/common/MiniStatChip` + - Import `useCreatorHolderCount` from `@/hooks/useCreatorHolderCount` + - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` + - _Requirements: 1.1, 1.3, 1.4, 3.1, 3.2, 5.1, 5.2, 5.3_ + +- [x] 4. Update `LandingPage.tsx` to use `FeaturedCreatorAudienceChip` + - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` + - Replace the inline `` block (lines ~1199–1205) with `` + - Pass a `fetchHolderCount` implementation that returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` (preserves existing behaviour until the real endpoint lands) + - Remove the now-unused `featuredCreatorKeyHolderCopy` derived variable (line ~560–563) and the inline `getFeaturedCreatorKeyHolderCopy` function definition (lines ~81–100) + - Verify `LandingPage.tsx` still compiles and the keyboard test (`LandingPage.keyboard.test.tsx`) still passes + - _Requirements: 1.1, 3.4_ + +- [ ] 5. Write the integration test + - Create `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` + - [ ] 5.1 Set up test scaffolding + - Import `QueryClient`, `QueryClientProvider` from `@tanstack/react-query`; `MemoryRouter` from `react-router`; `render`, `screen`, `waitFor`, `act` from `@testing-library/react`; `fc` from `fast-check`; `beforeEach`, `afterEach`, `describe`, `expect`, `it`, `vi` from `vitest` + - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` + - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` + - Import `formatCompactNumber` from `@/utils/numberFormat.utils` + - Add `vi.mock` stubs for `@/hooks/useNetworkMismatch`, `framer-motion`, and any other heavy transitive dependencies pulled in by `FeaturedCreatorAudienceChip` — mirror the pattern from `LandingPage.keyboard.test.tsx` + - Define `CREATOR_ID = 'test-creator-42'`; declare `queryClient` and `mockFetchHolderCount` at describe scope + - `beforeEach`: create fresh `QueryClient({ defaultOptions: { queries: { retry: false } } })` and reset `mockFetchHolderCount` via `vi.fn()` + - `afterEach`: call `queryClient.clear()` + - Implement `createWrapper(queryClient)` returning a component that wraps children in `` + `` + - _Requirements: 4.1, 4.2, 4.3, 4.4_ + + - [ ] 5.2 Write property test for Property 1 — initial render round-trip + - **Property 1: Initial render round-trip** + - **Validates: Requirements 1.1, 5.4** + - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` + - For each `count`: create fresh `queryClient`, seed with `queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count)`, render `FeaturedCreatorAudienceChip` with wrapper, assert `screen.getByText(getFeaturedCreatorKeyHolderCopy(count).value)` is in the document, assert `mockFetchHolderCount` was NOT called, then `unmount()` + - _Requirements: 1.1, 1.2, 5.4_ + + - [ ] 5.3 Write property test for Property 2 — stale-while-revalidate display stability + - **Property 2: Stale-while-revalidate display stability** + - **Validates: Requirements 2.3** + - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` + - For each `initialCount`: seed cache, render component, call `queryClient.invalidateQueries` but do NOT resolve the pending `mockFetchHolderCount` (use a `Promise` that never resolves during the assertion window), assert old value is still visible and no blank/error state + - _Requirements: 2.3_ + + - [ ] 5.4 Write property test for Property 3 — post-invalidation update round-trip + - **Property 3: Post-invalidation update round-trip** + - **Validates: Requirements 3.1, 3.2, 3.4** + - Use `fc.asyncProperty(fc.integer({ min: 1, max: 999 }), fc.integer({ min: 1000, max: 1_000_000 }), ...)` with `numRuns: 100` (disjoint ranges guarantee `initialCount !== updatedCount`) + - For each pair `(initialCount, updatedCount)`: seed cache with `initialCount`, render, spy on `window.location.reload`, invalidate query, await `waitFor` assertion that updated text is visible and old text is gone, assert `reloadSpy` was NOT called, `unmount()` + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + + - [ ] 5.5 Write property test for Property 4 — format function round-trip + - **Property 4: Format function round-trip** + - **Validates: Requirements 5.1, 5.4** + - Use synchronous `fc.property(fc.integer({ min: 1, max: 10_000_000 }), ...)` with `numRuns: 200` + - For each `n > 0`: assert `getFeaturedCreatorKeyHolderCopy(n).value === formatCompactNumber(n) + ' key holders'` + - _Requirements: 5.1, 5.4_ + + - [ ]\* 5.6 Write edge-case tests + - `count = 0` renders `"No key holders yet"` — seed cache with `0`, render, assert text present + - `count = null` renders `"Key holders unavailable"` — seed cache with `null`, render, assert text present + - Non-matching query key: invalidate a different key, assert `mockFetchHolderCount` was NOT called and display is unchanged + - After invalidation + resolved refetch: assert `mockFetchHolderCount` was called exactly once with `CREATOR_ID` + - _Requirements: 1.3, 1.4, 2.2, 2.4_ + +- [ ] 6. Checkpoint — run tests and confirm everything passes + - Run `pnpm test` (or `pnpm vitest run`) from `accesslayer-client--fork/` + - Confirm `holderCountCacheInvalidation.test.tsx` passes all property and edge-case tests + - Confirm `LandingPage.keyboard.test.tsx` still passes (no regression from Task 4 changes) + - Fix any TypeScript or test errors surfaced; ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for a faster MVP +- Each task references specific requirements for traceability +- The `fetchHolderCount` injection pattern in the hook and component avoids `vi.mock` hoisting complexity — tests pass `vi.fn()` directly as a prop +- Property tests use disjoint integer ranges in Property 3 to guarantee `initialCount !== updatedCount` without needing a `fc.filter` +- `retry: false` on the test-scoped `QueryClient` keeps assertions deterministic +- `fast-check` v4 (`"^4.6.0"`) is already installed as a dev dependency — no new packages needed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b606c35f..3d41985d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,12 @@ Thanks for contributing to the frontend for Access Layer, a Stellar-native creat ## Local setup 1. Install Node.js 20+ and `pnpm`. -2. Copy `.env.example` to `.env` and add any local values you need. +2. Copy `.env.example` to `.env` and adjust values as needed (see [Environment variables](#environment-variables)): + + ```bash + cp .env.example .env + ``` + 3. Install dependencies: ```bash @@ -25,6 +30,41 @@ pnpm install pnpm dev ``` +## Environment variables + +All client-exposed variables are prefixed with `VITE_` so Vite can expose them to the +browser. The defaults in `.env.example` are enough to run the client locally — you only +need to fill in optional values for the networks you actually want to test against. +Validation lives in [`src/utils/env.utils.ts`](./src/utils/env.utils.ts). + +### Required (defaults provided) + +| Variable | Description | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `VITE_BACKEND_URL` | Base URL for the backend API. Point this at your local backend during development (e.g. `http://localhost:3000/api/v1`). | +| `VITE_DEFAULT_CHAIN_ID` | Chain ID selected by default on load. `84532` is Base Sepolia, the recommended testnet. | +| `VITE_ANVIL_RPC_URL` | RPC URL for a local [Anvil](https://book.getfoundry.sh/anvil/) node (chain `31337`), used when developing against a local chain. | +| `VITE_BASE_SEPOLIA_RPC_URL` | RPC URL for the Base Sepolia testnet (chain `84532`). The public default `https://sepolia.base.org` works without an account. | + +### Optional + +| Variable | Description | +| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `VITE_SEPOLIA_RPC_URL` | RPC URL for the Ethereum Sepolia testnet (chain `11155111`). Only needed when testing on Sepolia. | +| `VITE_MAINNET_RPC_URL` | RPC URL for Ethereum mainnet (chain `1`). Only needed when testing against mainnet. | +| `VITE_UTM_SOURCE`, `VITE_UTM_MEDIUM`, `VITE_UTM_CAMPAIGN`, `VITE_UTM_TERM`, `VITE_UTM_CONTENT` | UTM parameters appended to shared profile links. Leave blank to disable UTM tracking. | + +### Where to get testnet RPC URLs + +- **Base Sepolia** — the public endpoint `https://sepolia.base.org` is preconfigured and + needs no account. For higher rate limits, create a free Base Sepolia endpoint at + [Alchemy](https://www.alchemy.com/) or [Infura](https://www.infura.io/). +- **Ethereum Sepolia** — create a free Sepolia endpoint at + [Alchemy](https://www.alchemy.com/) or [Infura](https://www.infura.io/), or use a public + endpoint from [Chainlist](https://chainlist.org/?testnets=true&search=sepolia). +- **Local Anvil** — no URL to fetch; run `anvil` from [Foundry](https://book.getfoundry.sh/) + and it serves the default `http://127.0.0.1:8545`. + ## Verification commands Run these before opening a pull request: @@ -51,6 +91,47 @@ 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. +- Non-technical contributors can edit marketing page copy without a local setup — see [docs/marketing-page-copy.md](./docs/marketing-page-copy.md). + +### Folder structure + +- `pages/`: Route-level components (each file maps to a route) +- `components/`: Reusable UI components and shared component logic + - `components/common/`: Application-specific reusable components + - `components/ui/`: Low-level UI primitives (from shadcn/ui or similar) + - `components/home/`: Home/landing-page specific components +- `hooks/`: Custom React hooks +- `utils/` or `lib/`: Pure helper functions and utilities +- `constants/`: Application constants +- `contracts/`: Web3 contract ABIs and related logic +- `assets/`: Static assets (images, icons, etc.) + +### Naming conventions + +- **Components**: PascalCase (e.g., `CreatorCard.tsx`, `ConnectWalletButton.tsx`) +- **Hooks**: camelCase, prefixed with `use` (e.g., `useCopySuccessAnnouncement.ts`, `useNetworkMismatch.ts`) +- **Utilities/helpers**: camelCase (e.g., `formatNumber.ts`) +- **Constants**: UPPER_SNAKE_CASE (e.g., `MAX_KEY_SUPPLY`) + +### Components vs pages: decision guide + +Use `pages/` when: + +- The component is a top-level route or page entry point +- It represents a distinct URL path in the application + +Use `components/` when: + +- The component is reusable across multiple pages or routes +- It's a self-contained UI piece with a single responsibility +- It can be tested independently of route context + +Keep components co-located in a page file only when: + +- They are used exclusively within that single page +- They are small, helper components that don't make sense outside the page context +- Extracting them would add unnecessary indirection ## Good first issue guidance diff --git a/README.md b/README.md index ac51a1d7..30a54f19 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ The client is responsible for: - `Ctrl/Cmd + Alt + R` refreshes creator list data from the marketplace page. The shortcut is ignored while focus is inside text inputs, textareas, selects, or editable text regions. +- `T` opens the trade panel from the creator profile page. The shortcut is + ignored while focus is inside text inputs, textareas, selects, or + editable text regions. ## Local setup @@ -38,6 +41,10 @@ pnpm install pnpm dev ``` +## Environment variables + +See [docs/environment-variables.md](./docs/environment-variables.md). + ## Verification ```bash diff --git a/accesslayer-client.zip b/accesslayer-client.zip new file mode 100644 index 00000000..3262939a Binary files /dev/null and b/accesslayer-client.zip differ diff --git a/docs/adding-page-and-data-fetching.md b/docs/adding-page-and-data-fetching.md new file mode 100644 index 00000000..50b311c9 --- /dev/null +++ b/docs/adding-page-and-data-fetching.md @@ -0,0 +1,464 @@ +# Contributing a New Page: Routing and Data Fetching + +This guide walks you through adding a new page to the Access Layer client. It covers route registration, component structure, React Query data-fetching conventions, and layout component usage. + +--- + +## Quick Start + +1. **Create the page component** at `src/pages/YourNamePage.tsx` (PascalCase + `Page` suffix) +2. **Register the route** in `src/routes.tsx` +3. **Handle data fetching** using React Query hooks following the query key factory pattern +4. **Manage loading and error states** with skeletons and error boundaries +5. **Use shared layouts** where they fit; create new ones only if needed + +--- + +## Part 1: File Structure and Naming Conventions + +### Page Component Location and Naming + +Every page lives in `src/pages/` with a consistent naming pattern: + +| Item | Convention | Example | +| ----------------- | ------------------------------------ | --------------------------------------------- | +| **File location** | `src/pages/` | `src/pages/CreatorDetailPage.tsx` | +| **File name** | PascalCase + `Page` suffix | `CreatorDetailPage.tsx`, `DashboardPage.tsx` | +| **Export** | Default export, function declaration | `export default function CreatorDetailPage()` | + +A page component takes **no props**. It owns its layout, fetching, and state: + +```tsx +// src/pages/CreatorDetailPage.tsx + +export default function CreatorDetailPage() { + // No props here ↑ + + return ( +
+

Page Title

+
+ ); +} +``` + +### Page Component Best Practices + +- **One component per file.** Don't combine multiple pages into a single file. +- **Always wrap page content in `
` semantic landmark** with `min-h-screen` to ensure full-height coverage. +- **Use PascalCase headings** — wrap page titles in `

` and subsections in `

`, `

` following semantic hierarchy. +- **Import shared components** with the `@/` alias (e.g., `@/components/common/Button`). +- **Use existing fonts and colors** — don't introduce new global styles for a single page. Stick to `font-grotesque`, `font-jakarta`, and the dark blue palette (`bg-[#06111f]`, `text-white/70`). + +--- + +## Part 2: Route Registration + +Routes are registered in a **single source of truth** at `src/routes.tsx`. Add your new page there: + +```tsx +// src/routes.tsx +import HomePage from './pages/HomePage'; +import CreatorDetailPage from './pages/CreatorDetailPage'; +import YourNewPage from './pages/YourNewPage'; // ← import your page + +export const routes = [ + { + path: '/', + element: , + }, + { + path: '/creator/:id', + element: , + }, + { + path: '/your-route', // ← add your route + element: , + }, + { + path: '*', // catch-all must stay last + element: , + }, +]; +``` + +### Route Rules + +- **Keep the catch-all (`*`) route last** — it shadows any route listed after it. +- **Use kebab-case for route paths** (e.g., `/creator-list`, not `/CreatorList`). +- **URL params use colon syntax** (e.g., `/creator/:id`) — extract them inside your page with `useParams()` from `react-router`. + +--- + +## Part 3: Data Fetching with React Query + +### Use a Custom Hook for Data Fetching + +Don't fetch directly in your page. Instead, create a custom hook in `src/hooks/` that wraps the React Query call. + +**Pattern:** + +```ts +// src/hooks/useYourData.ts +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { yourService } from '@/services/your.service'; + +export function useYourData(id: string) { + return useQuery({ + queryKey: queryKeys.yourEntity.detail(id), // see Query Key Factory below + queryFn: () => yourService.fetchData(id), + enabled: !!id, // don't fetch until `id` is defined + }); +} +``` + +Then use it in your page: + +```tsx +// src/pages/YourDetailPage.tsx +import { useYourData } from '@/hooks/useYourData'; + +export default function YourDetailPage() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error } = useYourData(id || ''); + + if (isLoading) return ; + if (error) throw error; + if (!data) throw new ApiError('Not found', 404); + + return
{/* render your page with data */}
; +} +``` + +### Query Key Factory Pattern + +All React Query keys are defined in a **single central factory** at `src/lib/queryKeys.ts`. This keeps key shapes predictable and invalidation reliable. + +**Key structure:** + +```ts +export const queryKeys = { + yourEntity: { + all: ['yourEntity'] as const, + list: (params?: GetYourParams) => + ['yourEntity', 'list', params ?? null] as const, + detail: (id: string) => ['yourEntity', 'detail', id] as const, + holders: (entityId: string) => + ['yourEntity', entityId, 'holders'] as const, + }, +}; +``` + +**Key rules:** + +1. **`all` key** — every entity group has a static `all` key for bulk invalidation. +2. **Shared prefixes** — all keys in a group start with the same entity name so prefix-based invalidation works. +3. **`as const`** — return `as const` tuples so TypeScript infers literal types. +4. **Optional params become `null`** — when a list query has no filters, store `null` at the param position for consistent key shape. + +**Add your entity to the factory:** + +```ts +// src/lib/queryKeys.ts (existing) + +import type { GetYourParams } from '@/services/your.service'; + +export const queryKeys = { + creators: { + /* ... */ + }, + wallet: { + /* ... */ + }, + yourEntity: { + all: ['yourEntity'] as const, + list: (params?: GetYourParams) => + ['yourEntity', 'list', params ?? null] as const, + detail: (id: string) => ['yourEntity', 'detail', id] as const, + }, +}; +``` + +See [docs/react-query-cache-conventions.md](./react-query-cache-conventions.md) for full details on cache invalidation patterns. + +### Loading and Error States + +Always handle the three states: `isLoading`, `error`, and `data`: + +```tsx +import { CreatorProfileHeaderSkeleton } from '@/components/common/CreatorSkeleton'; +import { ApiError } from '@/services/api.service'; + +export default function YourDetailPage() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error } = useYourData(id || ''); + + // 1. LOADING: Show a skeleton while fetching + if (isLoading) { + return ( +
+ +
+ ); + } + + // 2. ERROR: Throw to error boundary or render error UI + if (error) { + throw error; // handled by error boundary (see Part 5) + } + + // 3. NO DATA: Throw a 404 error + if (!data) { + throw new ApiError('Not found', 404); + } + + // 4. SUCCESS: Render your page + return ( +
+

{data.title}

+ {/* render data */} +
+ ); +} +``` + +### Stale Time Configuration + +React Query data is stale immediately by default (`staleTime: 0`). Override this for data that changes infrequently: + +```ts +useQuery({ + queryKey: queryKeys.yourEntity.detail(id), + queryFn: () => yourService.fetchData(id), + staleTime: 30_000, // data is fresh for 30 seconds +}); +``` + +| Data Type | Recommended staleTime | Rationale | +| -------------------------------- | --------------------- | ------------------------------ | +| Real-time (prices, balances) | `0` (default) | Always show the latest value | +| Semi-static (profiles, metadata) | `30_000` – `60_000` | Balance freshness vs refetches | +| Rarely-changing (lists, config) | `5 * 60_000` (5 min) | Reduce bandwidth | +| Truly static | `Infinity` | Fetch once per session | + +See [docs/react-query-cache-conventions.md](./react-query-cache-conventions.md#stale-time-and-cache-time) for full details. + +--- + +## Part 4: Service Layer and Data Types + +Data fetching happens through a **service layer** in `src/services/`. Each service is a class that extends `BaseApiService` and handles a domain (creators, wallet, etc.). + +**Example:** + +```ts +// src/services/your.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +export interface YourEntity { + id: string; + title: string; + description: string; + // ... other fields +} + +class YourService extends BaseApiService { + async getYourData(id: string): Promise { + try { + const response = await this.api.get>( + `/your-endpoint/${id}` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const yourService = new YourService(); +``` + +Then import and use it in your hook: + +```ts +// src/hooks/useYourData.ts +import { yourService } from '@/services/your.service'; + +export function useYourData(id: string) { + return useQuery({ + queryKey: queryKeys.yourEntity.detail(id), + queryFn: () => yourService.getYourData(id), + enabled: !!id, + }); +} +``` + +See [docs/api-layer.md](./api-layer.md) for full service layer conventions. + +--- + +## Part 5: Layout Components and Error Boundaries + +### When to Use Shared Layouts + +Check `src/components/common/` for existing layout and wrapper components: + +- **`CreatorPageErrorBoundary`** — wraps creator pages to catch and handle errors +- **`SectionErrorBoundary`** — wraps individual sections to handle errors in one area without crashing the whole page +- **`SectionHeading`** — formats section titles consistently +- **`CardMetaRow`** — displays metadata in a consistent card row style + +Use these when they fit. Don't create a new layout component unless an existing one truly doesn't match your needs. + +### Error Boundaries for Pages + +Wrap your page content in an error boundary to catch and display errors gracefully: + +```tsx +// src/pages/YourDetailPage.tsx +import YourPageErrorBoundary from '@/components/common/YourPageErrorBoundary'; + +function YourDetailPageContent() { + // ... your page logic with data fetching + return
{/* content */}
; +} + +export default function YourDetailPage() { + return ( + + + + ); +} +``` + +If a similar error boundary exists (e.g., `CreatorPageErrorBoundary`), study it and reuse or adapt it. Create a new one only if your error handling is significantly different. + +--- + +## Part 6: Complete Minimal Example + +Here's a full example adding a new page called `/creators` that lists all creators: + +### Step 1: Create the Page + +```tsx +// src/pages/CreatorListPage.tsx +import { useCreatorList } from '@/hooks/useCreatorList'; +import CreatorCard from '@/components/common/CreatorCard'; +import { CreatorCardGridSkeleton } from '@/components/common/CreatorCardSkeleton'; +import { ApiError } from '@/services/api.service'; + +function CreatorListPageContent() { + const { data: creators, isLoading, error } = useCreatorList(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + throw error; + } + + if (!creators || creators.length === 0) { + throw new ApiError('No creators found', 404); + } + + return ( +
+

+ All Creators +

+
+ {creators.map(creator => ( + + ))} +
+
+ ); +} + +export default function CreatorListPage() { + return ; +} +``` + +### Step 2: Add the Query Hook + +```ts +// src/hooks/useCreatorList.ts +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { courseService } from '@/services/course.service'; + +export function useCreatorList() { + return useQuery({ + queryKey: queryKeys.creators.list(), + queryFn: () => courseService.getCourses(), + }); +} +``` + +### Step 3: Register the Route + +```tsx +// src/routes.tsx +import HomePage from './pages/HomePage'; +import CreatorListPage from './pages/CreatorListPage'; // ← add import + +export const routes = [ + { + path: '/', + element: , + }, + { + path: '/creators', + element: , // ← add route + }, + { + path: '*', + element: , + }, +]; +``` + +### Step 4: Verify + +```bash +pnpm dev # visit http://localhost:5173/creators +pnpm lint +pnpm build +``` + +--- + +## Key Files Reference + +| File | Purpose | +| --------------------------------------- | ---------------------------------------------------------- | +| `src/routes.tsx` | Route registration — the single source of truth | +| `src/pages/` | All page components live here (one file per page) | +| `src/hooks/` | Custom React Query hooks for data fetching | +| `src/services/` | Service layer classes wrapping API calls | +| `src/lib/queryKeys.ts` | Central query key factory | +| `src/components/common/` | Shared layout, skeleton, and error boundary components | +| `src/components/ui/` | Reusable UI primitives (Button, Input, etc.) | +| `docs/react-query-cache-conventions.md` | Full React Query patterns (cache invalidation, stale time) | +| `docs/api-layer.md` | Service layer conventions and API error handling | +| `docs/shared-components.md` | Shared component library and styling guide | +| `docs/environment-variables.md` | Environment variables reference | + +--- + +## Cross-references + +- [React Query Cache Conventions](./react-query-cache-conventions.md) — detailed query key design and cache invalidation patterns +- [API Layer Conventions](./api-layer.md) — service layer design, error handling, and request/response patterns +- [Shared Components Guide](./shared-components.md) — existing layout and UI components to reuse +- [Environment Variables](./environment-variables.md) — API endpoints and configuration +- [Contributing Guide](../CONTRIBUTING.md) — general project conventions and PR workflow diff --git a/docs/adding-page-routes.md b/docs/adding-page-routes.md new file mode 100644 index 00000000..519c70de --- /dev/null +++ b/docs/adding-page-routes.md @@ -0,0 +1,273 @@ +# 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. | +| [docs/shared-components.md](file:///Users/marvellous/Desktop/accesslayer-client/docs/shared-components.md) | Guide to the project's shared UI component library, key props, and styling. | diff --git a/docs/api-layer.md b/docs/api-layer.md new file mode 100644 index 00000000..28f50a70 --- /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/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 00000000..fd805cac --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,99 @@ +# Environment Variable Guide + +This guide explains how to add a new client environment variable safely and consistently in Access Layer Client. + +The client is built with Vite, so any value that must be available in browser code must use the `VITE_` prefix. Values without that prefix are not exposed to the client bundle. + +## Files involved + +| File | Purpose | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `.env.example` | Documents every supported variable and provides safe local defaults or blank optional placeholders. | +| `src/utils/env.utils.ts` | Validates environment variables at startup with Zod and exports the typed `env` object used by application code. | +| `.env` | Local developer overrides. This file should not be committed. | + +## Add a new variable + +1. Add the variable to `.env.example`. +2. Add validation for the variable in `src/utils/env.utils.ts`. +3. Pass the raw `import.meta.env` value into the `envSchema.parse(...)` call in `src/utils/env.utils.ts`. +4. Import the validated `env` object in application code. +5. Avoid reading `import.meta.env` directly from components, hooks, or service files. + +## Declaration pattern + +Add the new variable to `.env.example` near related settings. Use a short comment that explains what the value controls and whether it is required. + +```env +# Feature flag for the creator discovery experiment. Use `true` to enable locally. +VITE_ENABLE_CREATOR_DISCOVERY=false +``` + +Prefer safe development defaults when the app can run without secrets. Leave optional third-party keys blank if a contributor can work without them. + +## Runtime validation pattern + +All supported variables should be declared in `src/utils/env.utils.ts` so missing or malformed configuration is caught in one place. + +```ts +const envSchema = z.object({ + VITE_ENABLE_CREATOR_DISCOVERY: z.coerce.boolean().default(false), +}); + +export const env = envSchema.parse({ + VITE_ENABLE_CREATOR_DISCOVERY: import.meta.env.VITE_ENABLE_CREATOR_DISCOVERY, +}); +``` + +Use the Zod type that matches how the app consumes the value: + +| Value type | Validation example | +| --------------- | ----------------------------------------------- | +| Required string | `z.string().min(1, "VITE_API_KEY is required")` | +| Optional string | `z.string().optional()` | +| Number | `z.coerce.number().default(84532)` | +| Boolean flag | `z.coerce.boolean().default(false)` | + +If a value is required for the app to start, avoid a silent fallback. Use `.min(1, "... is required")` or another explicit validation rule so the startup error points to the missing variable. + +## Access pattern in application code + +Import `env` from the validation module and read the typed value from there: + +```ts +import { env } from '@/utils/env.utils'; + +if (env.VITE_ENABLE_CREATOR_DISCOVERY) { + // Render or enable the feature. +} +``` + +This keeps validation, defaults, and type coercion centralized. + +## Anti-pattern: direct component access + +Do not import or read `import.meta.env` directly in components, hooks, services, or utilities outside the validation module. + +```tsx +// Avoid this. +const backendUrl = import.meta.env.VITE_BACKEND_URL; +``` + +Direct access bypasses schema validation, makes defaults inconsistent, and spreads environment knowledge across the app. Use `env` instead: + +```tsx +import { env } from '@/utils/env.utils'; + +const backendUrl = env.VITE_BACKEND_URL; +``` + +## Required vs optional checklist + +Use this checklist before opening a PR that adds a new variable: + +- The variable is listed in `.env.example`. +- The variable has a clear comment describing its purpose. +- Required values fail fast in `src/utils/env.utils.ts` with a useful error. +- Optional values use `.optional()` or a safe `.default(...)`. +- Application code reads from `env`, not `import.meta.env`. +- The variable name starts with `VITE_` if browser code needs it. diff --git a/docs/error-handling-conventions.md b/docs/error-handling-conventions.md new file mode 100644 index 00000000..e8859e9a --- /dev/null +++ b/docs/error-handling-conventions.md @@ -0,0 +1,789 @@ +# Error Handling Conventions + +> Issue #630 — Establish the expected pattern for every error category +> (boundary, query state, mutation feedback, transaction surface, network +> warning) so the UI feels consistent regardless of which component rendered +> the error. + +This guide is the **single source of truth** for how errors are surfaced in +the Access Layer client. Pair it with +[Error Handling in React Query Hooks](./error-handling-in-hooks.md) for the +hook-side `ApiError` branching detail and +[State Management](./state-management.md) for the +[Error Architecture Strategy](./state-management.md#3-error-architecture-strategy-boundary-vs-inline-states) +section that motivates the boundary/inline split. + +--- + +## The Five Error Display Modes + +Every error in the client surfaces through **one of five modes**. Pick the +right one based on where the error came from and what the user still needs +to be able to do. + +| # | Mode | Trigger | Audience | Component (canonical) | +| --- | -------------------- | --------------------------------------- | --------------- | ----------------------------------------------------- | +| 1 | **Toast** | `useMutation.onError` (user action) | Glanceable | `showToast.error` from `@/utils/toast.util` | +| 2 | **Inline state** | `useQuery` `isError` (blocking content) | Focused | `CreatorProfileErrorState`, section-level cards | +| 3 | **Section boundary** | Render-time throw in a sub-component | Isolated retry | `SectionErrorBoundary` from `@/components/common` | +| 4 | **Page boundary** | Render-time throw on a whole page | Whole route | `CreatorPageErrorBoundary`, `AppErrorBoundary` | +| 5 | **Tx surface** | On-chain trade write failure | Detail-oriented | `TransactionRetryNotice` + `TransactionFailureDrawer` | + +**Plus one always-on overlay** for a precondition state: + +| # | Mode | Trigger | Audience | Component | +| --- | ------------------ | ----------------------------- | ----------------- | -------------------------------------------------- | +| 6 | **Network banner** | Connected wallet, wrong chain | Persistent notice | `NetworkMismatchBanner` from `@/components/common` | + +--- + +## Decision Flowchart + +Walk this top to bottom. The first matching branch wins. + +``` +Did the error come from a user-initiated write (buy, sell, enroll, claim, …)? +├── YES → Mode 1 (Toast) — see "Mode 1: Toasts" +│ Special: On-chain writes → also open Mode 5 drawer for detail +└── NO → Did it come from a useQuery that blocks the page? + ├── YES → Mode 2 (Inline state) — see "Mode 2: Inline" + └── NO → Did something throw while rendering JSX? + ├── YES inside a sub-component → + │ Mode 3 (SectionErrorBoundary) + ├── YES at the route level → + │ Mode 4 (Page boundary) + └── NO → Re-check the trigger; you may have an + unhandled case — default to Mode 4 (the last + line of defense is `AppErrorBoundary`). + +Network mismatch? → Mode 6 banner (always-on; renders nothing when healthy). +``` + +> **Cross-link.** The same boundary vs inline split is described from the +> loading-state angle in +> [State Management → 3. Error Architecture Strategy](./state-management.md#3-error-architecture-strategy-boundary-vs-inline-states). +> This document is the full convention; that section is the executive summary. + +--- + +## Mode 1 — Toasts for Mutation Errors + +**Use when:** + +- The failure came from a user-initiated write (buy, sell, course enroll, + profile update, share, copy). +- The page can still render usefully without retrying. +- One short line of feedback is enough — the user needs to know "this + didn't work" and either retry or change input. + +**Don't use when:** + +- The failure blocks the primary purpose of the screen (use Mode 2). +- The failure contains field-level validation detail that must attach to a + specific input (use Mode 2 inline errors with `apiError.response?.errors`). +- You need to retry automatically — toasts disappear, they don't retry. + +### Pattern: `useMutation.onError` + +Cast the error to `ApiError` and branch on `status`. The numeric scale is +documented in +[Error Handling in Hooks → Distinguishing Error Types](./error-handling-in-hooks.md#distinguishing-error-types); +here is the consolidated decision: + +```ts +// src/hooks/useBuyCreatorKey.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ApiError } from '@/services/api.service'; +import showToast from '@/utils/toast.util'; +import { queryKeys } from '@/lib/queryKeys'; +import { creatorKeysService } from '@/services/creatorKeys.service'; + +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; + + // 1. Network failure — user is offline / server unreachable + if (apiError.status === 0) { + showToast.error( + 'Network error. Check your connection and try again.' + ); + return; + } + + // 2. Server failure — not the user's fault; generic + retry + if (apiError.status >= 500) { + showToast.error( + 'The server ran into a problem. Please try again shortly.' + ); + return; + } + + // 3. Validation — surface the first field error if present + if (apiError.status === 422 && apiError.response?.errors?.length) { + showToast.error(apiError.response.errors[0].message); + return; + } + + // 4. Other 4xx — API message is safe for the user + showToast.error(apiError.message); + }, + + onSuccess: (_, { creatorId }) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.detail(creatorId), + }); + queryClient.invalidateQueries({ queryKey: queryKeys.wallet.all }); + showToast.success('Key purchased successfully!'); + }, + }); +} +``` + +### Pattern: User rejection (wallet signature) + +Wallet signatures can be rejected by the user mid-flow. Detect this with +the shared helper and skip the noisy generic toast: + +```ts +// src/hooks/useWalletConnection.ts (excerpt) +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; +import showToast from '@/utils/toast.util'; + +onError: error => { + showToast.error(getSignatureErrorMessage(error)); + // getSignatureErrorMessage returns either: + // - WALLET_ERROR_COPY.SIGNATURE_REJECTED (user clicked "Cancel") + // - WALLET_ERROR_COPY.SIGNATURE_FAILED (other wallet-side failure) +}; +``` + +`getSignatureErrorMessage` is implemented in +[`src/utils/errorHandling.utils.ts`](./../src/utils/errorHandling.utils.ts) +and detects the EIP-1193 code `4001`, ethers' `ACTION_REJECTED`, and the +common message fragments "user rejected" / "declined" / "cancelled". + +### Pattern: Off-chain mutation that should NOT use a drawer + +Copy-to-clipboard, share-to-Twitter, follow a creator — all of these are +writes that have no chain side effects. Just toast: + +```ts +// Excerpt from CreatorProfileHeader +const handleShare = async () => { + try { + await navigator.clipboard.writeText(profileUrl); + showToast.success('Profile link copied to clipboard!'); + } catch { + showToast.error('Could not copy the profile link. Please try again.'); + } +}; +``` + +--- + +## Mode 2 — Inline Error State for Blocking Query Failures + +**Use when:** + +- The query is the primary content of the page (e.g. creator profile header). +- Without the data, the screen is empty; there is nothing else for the user + to do here. +- You want the user to retry without leaving the page — wire `refetch` from + React Query. + +**Don't use when:** + +- The query is enriching a page that can render something else (use Mode 3). +- The failure came from a mutation (use Mode 1). + +### Pattern: Canonical shared component + +`CreatorProfileErrorState` is the canonical inline error component. It +renders a marketplace-styled card with an icon, a message, and an optional +retry button. + +```tsx +// In a creator profile section +import { useCreatorProfile } from '@/hooks/useCreatorProfile'; +import CreatorProfileErrorState from '@/components/common/CreatorProfileErrorState'; +import { ApiError } from '@/services/api.service'; + +function CreatorHeader({ creatorId }: { creatorId: string }) { + const { data, isLoading, isError, error, refetch } = + useCreatorProfile(creatorId); + + if (isLoading) return ; + + if (isError) { + const apiError = error as ApiError; + return ( + void refetch()} + isRetrying={ + false /* wire from your query's isFetching if you want */ + } + title="Unable to load this creator profile" + /> + ); + } + + return ; +} +``` + +Component contract (from `CreatorProfileErrorStateProps`): + +| Prop | Type | Notes | +| ------------ | ------------------------- | ---------------------------------------------------------- | +| `error` | `Error \| string \| null` | Drives the message; falls back to a generic copy when null | +| `onRetry` | `() => void` | When provided, renders a retry button | +| `isRetrying` | `boolean` | Spins the refresh icon and disables the button | +| `title` | `string` | Defaults to "Unable to load this creator profile" | +| `message` | `string` | When present, overrides the error message | + +### Pattern: Hand-rolled inline state + +For sections that aren't creator profiles (holdings table, transaction +history), inline state is a styled `
` with a refresh +button wired to `refetch`. Always include the retry control — fail without +one and the user has no escape hatch. + +```tsx +const { data, isError, error, refetch } = useHoldings(address); + +if (isError) { + const apiError = error as ApiError; + return ( +
+

+ {apiError.status >= 500 + ? 'Unable to load holdings. Please try again later.' + : apiError.message} +

+ +
+ ); +} +``` + +### Inline vs. throw-to-boundary — the rule + +> If the failure should leave the rest of the page interactive → render an +> **inline** state. If it means the route as a whole cannot be useful → throw +> the error and let the page-level boundary catch it. + +Concretely: + +- A creator list failing inside the marketplace page? **Inline.** The hero, + filters, and holdings are still useful. +- The creator profile header failing on `/creator/:id`? **Throw** → + `CreatorPageErrorBoundary` renders a "Creator not found" or + "could not load" page-level state and a back-link. + +--- + +## Mode 3 — Section Error Boundary (Render Falls Here) + +**Use when:** + +- A sub-component (tab body, sidebar, list section) can throw during render + — e.g. it consumes data that is undefined when the parent didn't gate it. +- A failure in **this section** should not crash the whole page. +- The user should be able to retry the section without a full reload. + +**Don't use when:** + +- The error is a query failure (React Query surfaces it via `isError` — + branch in the component, don't throw). +- The error is from an on-chain mutation (use Mode 5). + +### Pattern: Wrap any sub-tree that might throw + +```tsx +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; + +// In LandingPage + + +; +``` + +The retry button on the fallback resets the boundary's internal error +state; it does **not** re-fire the underlying `useQuery`. If you need a +network retry, combine the boundary with a query that has `enabled` tied +to a "retry counter" `useState` and bump it inside the retry handler. + +### Pair with `throwOnError` + +If you want React Query to throw a render-time exception (so the boundary +catches it) instead of branching on `isError` inside the component, opt in +on the query: + +```ts +useQuery({ + queryKey: queryKeys.creators.detail(id), + queryFn: () => courseService.getById(id), + throwOnError: error => { + const api = error as ApiError; + // 4xx: render inline; 5xx: bubble to boundary + return api.status >= 500; + }, +}); +``` + +See [Error Handling in Hooks → SectionErrorBoundary](./error-handling-in-hooks.md#use-sectionerrorboundary-when) +for the full reasoning. + +--- + +## Mode 4 — Page-Level Error Boundaries + +There are two nested page boundaries; pick the **most specific** one that +fits the route. + +### `CreatorPageErrorBoundary` — creator routes + +Scoped to `/creator/:id` and any other creator detail route. When a creator +page throws, this catches it and renders a fallback with a "back to +creators" link. It special-cases `ApiError` with `status === 404` to render +a "Creator not found" message instead of a generic failure. + +```tsx +// src/pages/CreatorDetailPage.tsx +import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; + +export default function CreatorDetailPage() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error } = useCreatorDetail(id ?? ''); + + if (!data && !isLoading) { + throw new ApiError('Creator not found', 404); + } + if (error) { + throw error; // → CreatorPageErrorBoundary + } + + return ; +} + +// Wrap in the route element (already done in src/routes.tsx): +{routes}; +``` + +### `AppErrorBoundary` — last line of defense + +Mounted once in [`src/App.tsx`](./../src/App.tsx) around the router. When +something throws that isn't caught by a more specific boundary, this is +the last chance before React would otherwise unmount the entire tree. The +fallback offers a **full page reload** rather than resetting local state — +the rationale is documented in the component's source comment. + +Reach: anything that escapes a `SectionErrorBoundary` or +`CreatorPageErrorBoundary` will land here. + +--- + +## Mode 5 — Transaction Failure Surfaces + +Buy / sell / claim trades have **two** surfaces: an inline +`TransactionRetryNotice` so the user sees a persistent recovery prompt, +and a modal `TransactionFailureDrawer` for full diagnostic detail. + +### When to use both together + +A failed trade should: + +1. Show a `TransactionRetryNotice` in place of the trade action area — + it stays visible until the user retries or dismisses. +2. Open a `TransactionFailureDrawer` so the user can copy the error code + or transaction hash for support. + +```tsx +// src/components/common/CreatorCard.tsx (excerpt) +import TransactionRetryNotice from '@/components/common/TransactionRetryNotice'; +import TransactionFailureDrawer from '@/components/common/TransactionFailureDrawer'; +import type { TransactionFailureDetails } from '@/components/common/TransactionFailureDrawer'; + +const [failure, setFailure] = useState(null); + +const handleTrade = async (amount: number) => { + try { + await buyKey({ creatorId, amount }); + } catch (err) { + const apiError = err as ApiError; + setFailure({ + errorMessage: apiError.message, + errorCode: apiError.response?.code, + txHash: undefined, // chain-specific; pass when available + timestamp: Date.now(), + developerDetails: apiError.response, + }); + } +}; + +return ( + <> + {failure && ( + <> + { + setFailure(null); + handleTrade(lastAmount); + }} + /> + { + setFailure(null); + handleTrade(lastAmount); + }} + onDismiss={() => setFailure(null)} + /> + + )} + +); +``` + +`TransactionFailureDetails`: + +| Field | Type | Purpose | +| ------------------ | -------------------------- | ---------------------------------------------- | +| `errorMessage` | `string` (required) | User-facing message displayed in the drawer | +| `txHash` | `string?` | On-chain hash for support; copy-to-clipboard | +| `errorCode` | `string?` | Machine code (e.g. `INSUFFICIENT_BALANCE`) | +| `timestamp` | `number?` | Unix ms; rendered via `formatTimestampTooltip` | +| `developerDetails` | `Record?` | Hidden behind a `
` toggle for devs | + +See [`src/components/common/TransactionFailureDrawer.tsx`](./../src/components/common/TransactionFailureDrawer.tsx) +for the component contract. + +### When to NOT use the drawer + +If the action is not on-chain (share, copy, follow) — toast only. The +drawer exists to expose blockchain-detail information; if there is no +chain context, it adds noise. + +--- + +## Mode 6 — Network Mismatch Banner + +`NetworkMismatchBanner` is **always-on**: it reads from +`useNetworkMismatch()` and returns `null` when the wallet is on the +correct network. Mount it once in any layout where users can initiate +trades; it renders nothing in the happy path. + +```tsx +// Wherever the user can trade +
+ + +
+``` + +This is **not** an error per se — it's a precondition warning. Do **not** +also toast "wrong network" on top of this banner; the banner is the +canonical surface. + +--- + +## Branching on `ApiError.status` — Consolidated Cheat Sheet + +The same numeric scale drives every mode. Source: +[`src/services/api.service.ts`](./../src/services/api.service.ts) → +[`BaseApiService.handleError`](./../src/services/api.service.ts). + +| `apiError.status` | Meaning | Toast copy | Inline copy | Boundary fallback | +| ----------------- | ------------------------ | ---------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------- | +| `0` | No network response | "Network error. Check your connection and try again." | "Check your connection and try again." | `AppErrorBoundary` | +| `400` | Malformed request | `apiError.message` (safe) | `apiError.message` | `AppErrorBoundary` | +| `401` | Session expired | (interceptor handles — auto-refresh, redirect to `/login`) | n/a | n/a | +| `403` | Insufficient permissions | `apiError.message` | "You don't have access to this." | `AppErrorBoundary` | +| `404` | Resource not found | `apiError.message` | "Not found." | `CreatorPageErrorBoundary` (404 special-cases) | +| `422` | Validation failure | `apiError.response.errors[0].message` (first field error) | Map every `response.errors[i]` to its input | `AppErrorBoundary` | +| `429` | Rate limited | "Too many requests. Please wait a moment and retry." | "Rate limited. Try again shortly." | `AppErrorBoundary` | +| `>= 500` | Server error | "The server ran into a problem. Please try again shortly." | "Unable to load this. Try again later." | Section-level (skeleton→empty→relies on inline) | + +--- + +## Adding a New Error Type to the Classification System + +The codebase has **two** classification helpers that capture domain +specifics. Pick the one that matches your domain; do not invent a new +helper unless neither fits. + +### 1. Wallet & signature errors → extend `WALLET_ERROR_COPY` + +File: [`src/utils/errorHandling.utils.ts`](./../src/utils/errorHandling.utils.ts). + +Centralized map of wallet-and-signature messages, plus detection helpers: + +```ts +export const WALLET_ERROR_COPY = { + SIGNATURE_REJECTED: + "Signature request was declined. Please try again when you're ready to confirm.", + SIGNATURE_FAILED: + 'The signature request failed. Please ensure your wallet is unlocked and try again.', + GENERIC_TRANSACTION_FAILED: + 'Transaction failed. Please check your balance or connection and try again.', +}; + +export function isUserRejection(error: unknown): boolean { + /* … */ +} +export function getSignatureErrorMessage(error: unknown): string { + /* … */ +} +``` + +**To add a new wallet error type:** + +1. Append a new key to `WALLET_ERROR_COPY` with a sentence that states the + cause and the next step. +2. If the detector matters, extend `isUserRejection`-style recognition in + a new exported predicate (`isRateLimited(error)`, `isChainSwitchRequired(error)`). +3. Wire the predicate + key into the calling hook's `onError` — usually + by extending `getSignatureErrorMessage` or introducing a sibling + `getWalletErrorMessage` if the surface is broader than signatures. +4. Add a Vitest unit test under + `src/utils/__tests__/errorHandling.utils.test.ts` that pins the new + copy and predicate behavior. + +### 2. Pre-action disabled reasons → extend the typed helper + +File: [`src/utils/claimActionDisabledReason.ts`](./../src/utils/claimActionDisabledReason.ts). + +Pattern: a **closed union** of reason keys + a **single `Record`** that +maps each key to a standardized copy. This pattern is reused for other +disabled-reason surfaces (see `BuyActionHelperText` and +`ClaimActionHelperText` documentation). + +```ts +export type ClaimActionDisabledReasonKey = + | 'wallet_not_connected' + | 'no_claimable_rewards' + | 'claim_in_progress' + | 'network_mismatch' + | 'insufficient_gas' + | 'unknown'; + +const CLAIM_ACTION_DISABLED_REASON_TEXT: Record< + ClaimActionDisabledReasonKey, + string +> = { + // Every entry follows the same shape: + // "{Action} is unavailable because {cause}. {Next step}." + wallet_not_connected: + 'Claim is unavailable because your wallet is not connected. Connect your wallet to continue.', + // … +}; + +export const getClaimActionDisabledReasonText = ( + reason: ClaimActionDisabledReasonKey +): string => CLAIM_ACTION_DISABLED_REASON_TEXT[reason]; +``` + +**To add a new disabled reason:** + +1. Append the new key to the union — **don't** fall back to `unknown` + silently; force every caller to make a decision. +2. Add a matching entry to `CLAIM_ACTION_DISABLED_REASON_TEXT` using the + "{Action} is unavailable because {cause}. {Next step}." template so + tones stay consistent. +3. Compute the new key at the call site (the component that evaluates + pre-conditions) and pass it to `getClaimActionDisabledReasonText`. +4. Extend + `src/utils/__tests__/claimActionDisabledReason.test.ts` to cover the + new key. + +### 3. When neither helper fits + +If your error surface is genuinely new (e.g. diverging-chain detection, +on-chain simulation errors), create a new sibling helper in +`src/utils/ErrorHandling.utils.ts` following the same shape: + +1. **Closed key union** — TypeScript exhaustiveness forces callers to + pick a copy. +2. **One Record/Map** that owns _all_ English copy — no copy scattered + across components. +3. **Pure functions** — no React, no hooks, no side effects. Easy to + unit-test. +4. **Side-by-side tests** — colocated unit test in + `src/utils/__tests__/`. + +Do not duplicate copy inline in components; always go through a typed +helper so future copy edits stay coherent. + +--- + +## Shared Error State Components — Reference + +| Component | Mode | Purpose | Key props | +| -------------------------------------------------- | ---- | --------------------------------------------------- | ------------------------------------------------------------------------- | +| `showToast` (`@/utils/toast.util`) | 1 | Mutation feedback (success, error, loading, tx) | `showToast.{success,error,loading,transactionSuccess}(message, options?)` | +| `CreatorProfileErrorState` (`@/components/common`) | 2 | Canonical inline error card for creator profile | `error?, onRetry?, isRetrying?, title?, message?` | +| `SectionErrorBoundary` (`@/components/common`) | 3 | Catches sub-tree render throws with retry | `sectionName?, minHeight?, className?` | +| `CreatorPageErrorBoundary` (`@/components/common`) | 4 | Catches creator-route render throws | none (wraps ``) | +| `AppErrorBoundary` (`@/components/common`) | 4 | App-wide last-line-of-defense; full reload on retry | none (wraps ``) | +| `TransactionRetryNotice` (`@/components/common`) | 5 | Persistent inline retry banner for failed trades | `title?, message, onRetry, retryLabel?, disabled?, className?` | +| `TransactionFailureDrawer` (`@/components/common`) | 5 | Modal with error code, hash, and developer details | `open, onOpenChange?, failureDetails, onRetry?, onDismiss?` | +| `NetworkMismatchBanner` (`@/components/common`) | 6 | Persistent "wrong network" warning | `className?` | + +For accessible state components (skeletons, empty states), see +[Adding a Page and Data Fetching](./adding-page-and-data-fetching.md) +and [Shared Components](./shared-components.md). + +--- + +## Worked Examples + +### Example A — Marketplace search list (read failure → Mode 2) + +The creator list inside the marketplace is critical content, so a query +failure renders an inline state with retry. The rest of the page +(hero, holdings, footer) stays interactive. + +```tsx +// src/pages/LandingPage.tsx (excerpt) +const { data: creators, isError, error, refetch } = useCreatorList(); + +if (isError) { + const apiError = error as ApiError; + return ( +
+

Couldn't load the creator list

+

+ {apiError.status >= 500 + ? 'Something went wrong on our end. Please try again.' + : apiError.message} +

+ +
+ ); +} + +return ; +``` + +### Example B — Creator profile (read failure → Mode 4) + +The creator profile header IS the page. A query failure here becomes a +page-level error: + +```tsx +// src/pages/CreatorDetailPage.tsx (excerpt) +function CreatorDetailPageContent() { + const { id } = useParams<{ id: string }>(); + const { data, isLoading, error } = useCreatorDetail(id ?? ''); + + if (isLoading) return ; + if (!data) throw new ApiError('Creator not found', 404); + if (error) throw error; // caught by CreatorPageErrorBoundary + return ; +} + +export default function CreatorDetailPage() { + return ( + + + + ); +} +``` + +### Example C — Buy flow (mutation failure → Mode 1 + Mode 5) + +A trade mutation has both the prompt (toast), the persistent retry +banner, and the detail drawer: + +```tsx +// src/components/common/CreatorCard.tsx (excerpt) +const buy = useBuyCreatorKey(); + +const handleBuy = async (amount: number) => { + try { + await buy.mutateAsync({ creatorId, amount }); + } catch (err) { + const apiError = err as ApiError; + + // Mode 1: quick prompt + showToast.error( + apiError.status === 0 + ? 'Network error. Check your connection.' + : apiError.message + ); + + // Mode 5: persistent banner + drawer for support detail + setFailure({ + errorMessage: apiError.message, + errorCode: apiError.response?.code, + timestamp: Date.now(), + }); + } +}; +``` + +--- + +## Quick Reference + +| Question | Answer | +| ----------------------------------------------------- | ----------------------------------------------------- | +| Failure from a button click? | Toast (Mode 1), add drawer for on-chain trades | +| Failure from `useQuery` that **is** the page? | Throw → Page boundary (Mode 4) | +| Failure from `useQuery` that **enriches** the page? | Inline state with `refetch` (Mode 2) | +| Component throws during render (not a query error)? | Wrap in `SectionErrorBoundary` (Mode 3) | +| Wallet is connected but wrong chain? | `NetworkMismatchBanner` (Mode 6) | +| User clicked "Cancel" on the wallet signature prompt? | `getSignatureErrorMessage` (Mode 1, specialised copy) | +| New kind of wallet error? | Extend `WALLET_ERROR_COPY` | +| New pre-action disabled reason? | Extend `ClaimActionDisabledReasonKey` + lookup | + +--- + +## Cross-references + +- [State Management](./state-management.md) — particularly + [§3 Error Architecture Strategy](./state-management.md#3-error-architecture-strategy-boundary-vs-inline-states) + for the boundary-vs-inline executive summary and the loading/error/data + three-state pattern. +- [Error Handling in React Query Hooks](./error-handling-in-hooks.md) — + `ApiError` shape, `useQuery` / `useMutation` patterns, and the full + status-code table. +- [API Layer Conventions](./api-layer.md) — the service layer and + `BaseApiService.handleError` that produces every `ApiError`. +- [React Query Cache Conventions](./react-query-cache-conventions.md) — + invalidation patterns that run alongside error handlers. +- [Shared Components](./shared-components.md) — toast, skeleton, and + empty-state families referenced alongside error states. +- [Adding a Page and Data Fetching](./adding-page-and-data-fetching.md) — + end-to-end guide for wiring routes and their error boundaries. +- [BuyActionHelperText — Disabled Reason](./BuyActionHelperText-DisabledReason.md) and + [Claim Action Disabled Reason Helper Text](./ClaimActionHelperText-DisabledReason.md) — + pre-action copy systems that follow the same `Record` + classification pattern documented above. diff --git a/docs/error-handling-in-hooks.md b/docs/error-handling-in-hooks.md new file mode 100644 index 00000000..e5f813cf --- /dev/null +++ b/docs/error-handling-in-hooks.md @@ -0,0 +1,373 @@ +# Error Handling in React Query Hooks + +This guide documents the standard pattern for handling API errors in React Query hooks across this codebase. Follow it when writing new `useQuery` or `useMutation` hooks so error behavior is consistent and predictable for users. + +--- + +## How Errors Flow In + +All HTTP requests go through the service layer (`src/services/`), which extends `BaseApiService`. The `handleError` method on that base class normalises every failure into an `ApiError` before it reaches the hook: + +| Raw failure | What you receive | +| ------------------------------------- | ------------------------------------------------------ | +| HTTP response with an error status | `ApiError(message, httpStatus, responseBody)` | +| Request sent but no response received | `ApiError('Network error - check your connection', 0)` | +| Unexpected non-HTTP exception | `ApiError(error.message, 500)` | + +One important exception: **401 + `TOKEN_EXPIRED`** is handled transparently by the Axios interceptor in `BaseApiService`. The interceptor silently retries the original request after refreshing the access token. If the refresh also fails the user is redirected to `/login`; the hook never sees this error. + +--- + +## The `ApiError` Shape + +```ts +// src/services/api.service.ts +class ApiError extends Error { + status: number; // HTTP status code; 0 means no network response + response?: { + success: false; + message: string; + code?: string; // machine-readable code from the API, e.g. "INSUFFICIENT_BALANCE" + errors?: Array<{ + field?: string; // present on 422 validation failures + message: string; + }>; + }; +} +``` + +Always cast the error to `ApiError` before inspecting it: + +```ts +import { ApiError } from '@/services/api.service'; + +onError: error => { + const apiError = error as ApiError; + console.log(apiError.status); // 0, 400, 403, 422, 500 … + console.log(apiError.message); // human-readable message from the API + console.log(apiError.response?.errors); // field-level details on 422 +}; +``` + +--- + +## Distinguishing Error Types + +### Network errors (`status === 0`) + +No response was received — the user is offline, the server is unreachable, or a timeout occurred. The user cannot fix the request payload; they need to retry later. + +```ts +if (apiError.status === 0) { + showToast.error('Network error. Check your connection and try again.'); + return; +} +``` + +### 4xx — Client errors + +The request was received but rejected because of something the client sent. The message from the API is usually safe to show to the user. + +| Status | Cause | Typical UI response | +| ------ | ------------------------ | ---------------------------------------------------- | +| 400 | Malformed request | Toast with `apiError.message` | +| 401 | Session expired | Auto-handled by the interceptor | +| 403 | Insufficient permissions | Inline error or redirect | +| 404 | Resource not found | Inline error state | +| 422 | Validation failure | Inline field errors from `apiError.response?.errors` | +| 429 | Rate limited | Toast with retry suggestion | + +### 5xx — Server errors + +The API itself failed. The user cannot fix the payload; they can only retry after the server recovers. Avoid showing raw server messages — use a generic fallback instead. + +```ts +if (apiError.status >= 500) { + showToast.error('The server ran into a problem. Please try again shortly.'); + return; +} +``` + +--- + +## Deciding: Toast vs. Inline Error vs. Error Boundary + +### Use a toast when + +- The failure came from a **user-initiated action** (mutation): buying a key, submitting a form, enrolling in a course. +- The error **does not block the current view** — the page can still render usefully. +- The fix is to retry or change input: one line of feedback is enough. + +```ts +onError: error => { + const apiError = error as ApiError; + showToast.error( + apiError.status >= 500 + ? 'Something went wrong. Try again.' + : apiError.message + ); +}; +``` + +### Use an inline error state when + +- The error **blocks the primary purpose of the screen** — for example, the creator list failed to load so the page is empty. +- The error contains **field-level detail** (422) that needs to map to specific form inputs. +- The user needs to take **corrective action** (fix a field, switch networks) before retrying makes sense. + +```tsx +const { data, isError, error } = useCreatorKeys(creatorId); + +if (isError) { + const apiError = error as ApiError; + return ( +
+ {apiError.status >= 500 + ? 'Unable to load data. Please try again later.' + : apiError.message} +
+ ); +} +``` + +### Use `SectionErrorBoundary` when + +- A **component throws during render**, not from an API call. +- You want to **isolate a section** so one broken widget does not crash the whole page. +- React Query's `throwOnError` option is enabled on a query. + +```tsx +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; + + + +; +``` + +`SectionErrorBoundary` renders a retry button that resets its own error state. Use it as a safety net around sections that fetch and render data together. + +--- + +## `useQuery` Pattern + +React Query v5 removed the `onError` callback from `useQuery`. Errors surface through `isError` and `error` in the component. Keep the hook thin and handle the error at the call site: + +```ts +// src/hooks/useCreatorProfile.ts +import { useQuery } from '@tanstack/react-query'; +import { creatorService } from '@/services/creator.service'; + +export function useCreatorProfile(creatorId: string) { + return useQuery({ + queryKey: ['creator-profile', creatorId], + queryFn: () => creatorService.getProfile(creatorId), + staleTime: 30_000, + }); +} +``` + +```tsx +// In the component +import { ApiError } from '@/services/api.service'; +import { useCreatorProfile } from '@/hooks/useCreatorProfile'; + +function CreatorProfileSection({ creatorId }: { creatorId: string }) { + const { data, isLoading, isError, error } = useCreatorProfile(creatorId); + + if (isLoading) return ; + + if (isError) { + const apiError = error as ApiError; + return ( +
+ {apiError.status >= 500 + ? 'Unable to load this profile right now.' + : apiError.message} +
+ ); + } + + return ; +} +``` + +--- + +## `useMutation` Pattern + +`useMutation` still accepts `onError` and `onSuccess` callbacks. Use them for toasts and cache invalidation: + +```ts +// src/hooks/useEnrollInCourse.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { courseService } from '@/services/course.service'; +import { ApiError } from '@/services/api.service'; +import showToast from '@/utils/toast.util'; + +export function useEnrollInCourse() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (courseId: string) => courseService.enrollInCourse(courseId), + onError: error => { + const apiError = error as ApiError; + + if (apiError.status === 0) { + showToast.error( + 'Network error. Check your connection and try again.' + ); + return; + } + + if (apiError.status >= 500) { + showToast.error( + 'Something went wrong on our end. Please try again.' + ); + return; + } + + // 4xx: the API message is safe and actionable + showToast.error(apiError.message); + }, + onSuccess: (_, courseId) => { + queryClient.invalidateQueries({ queryKey: ['enrolled-courses'] }); + queryClient.invalidateQueries({ queryKey: ['course', courseId] }); + showToast.success('Enrolled successfully!'); + }, + }); +} +``` + +--- + +## Worked Example: Handling Both Error Types + +The following hook wraps a write operation (buying a creator key) and shows how to handle network errors, 4xx validation failures, and 5xx server errors in a single consistent flow. + +```ts +// src/hooks/useCreatorKeys.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ApiError } from '@/services/api.service'; +import showToast from '@/utils/toast.util'; +import { creatorKeysService } from '@/services/creatorKeys.service'; + +// --- Read --- +export function useCreatorKeys(creatorId: string) { + return useQuery({ + queryKey: ['creator-keys', creatorId], + queryFn: () => creatorKeysService.getKeys(creatorId), + staleTime: 30_000, + }); +} + +// --- Write --- +export function useBuyCreatorKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + creatorId, + amount, + }: { + creatorId: string; + amount: number; + }) => creatorKeysService.buyKey(creatorId, amount), + + onError: error => { + const apiError = error as ApiError; + + // No response received — user is likely offline + if (apiError.status === 0) { + showToast.error( + 'Network error. Check your connection and try again.' + ); + return; + } + + // Server-side failure — not actionable by the user + if (apiError.status >= 500) { + showToast.error( + 'The server ran into a problem. Please try again shortly.' + ); + return; + } + + // 422 Validation — show the first field error if available + if (apiError.status === 422 && apiError.response?.errors?.length) { + showToast.error(apiError.response.errors[0].message); + return; + } + + // All other 4xx — the API message is safe to surface + showToast.error(apiError.message); + }, + + onSuccess: (_, { creatorId }) => { + // Invalidate relevant queries so the UI reflects the purchase + queryClient.invalidateQueries({ + queryKey: ['creator-keys', creatorId], + }); + queryClient.invalidateQueries({ queryKey: ['user-holdings'] }); + showToast.success('Key purchased successfully!'); + }, + }); +} +``` + +Usage in a component: + +```tsx +import { ApiError } from '@/services/api.service'; +import { useCreatorKeys, useBuyCreatorKey } from '@/hooks/useCreatorKeys'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; + +function CreatorKeysSection({ creatorId }: { creatorId: string }) { + const { data: keys, isLoading, isError, error } = useCreatorKeys(creatorId); + const { mutate: buyKey, isPending } = useBuyCreatorKey(); + + if (isLoading) return ; + + if (isError) { + const apiError = error as ApiError; + return ( +
+

+ {apiError.status >= 500 + ? 'Unable to load keys right now. Please try again later.' + : apiError.message} +

+
+ ); + } + + return ( + // SectionErrorBoundary catches any render-time throws inside KeysList + + buyKey({ creatorId, amount })} + isBuying={isPending} + /> + + ); +} +``` + +--- + +## Quick Reference + +| Condition | Check | UI response | +| ------------------- | ------------------------------- | ------------------------------------------------------------ | +| No network response | `apiError.status === 0` | Toast: "Network error. Check your connection." | +| Server error | `apiError.status >= 500` | Toast: generic "something went wrong" message | +| Validation failure | `apiError.status === 422` | Toast or inline: first item from `apiError.response?.errors` | +| Other 4xx | `apiError.status >= 400` | Toast or inline: `apiError.message` (safe from the API) | +| Read query fails | `isError === true` in component | Inline error state replacing the content area | +| Render throws | Component boundary | Wrap with `` | diff --git a/docs/marketing-page-copy.md b/docs/marketing-page-copy.md new file mode 100644 index 00000000..81079db1 --- /dev/null +++ b/docs/marketing-page-copy.md @@ -0,0 +1,91 @@ +# Editing Marketing Page Copy + +This guide is for non-technical contributors who want to suggest changes to the +marketing page copy. You can edit the text directly on GitHub and open a pull +request — no local development environment is required. + +## Where the copy lives + +All marketing page copy is in a single file: + +**`src/pages/MarketingPage.tsx`** + +The page is a single React component. Each visible section is a block of JSX +with inline text. Use the table below to find the section you want to change. + +| Visible section on the page | Location in `MarketingPage.tsx` | What to look for | +| ------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------ | +| Page title ("Access Layer") | Hero / Title | `

` with the `Access Layer` heading | +| Intro paragraph under the title | Intro | First `

` after the title | +| **The idea** | `{/* The idea */}` section | Eyebrow text `The idea` and the two body paragraphs below it | +| **How it works** | `{/* How it works */}` section | Eyebrow text `How it works` and the two body paragraphs below it | +| **What makes it different** | `{/* What makes it different */}` section | Eyebrow text `What makes it different` and the body paragraph below it | +| **Built on Stellar** | `{/* Built on Stellar */}` section | Eyebrow text `Built on Stellar` and the two body paragraphs below it | +| **Join the community** | `{/* Community */}` section | Eyebrow text `Join the community`, the subtitle, and the GitHub/Telegram links | +| Footer | `{/* Footer */}` section | Logo label and the "Built on Stellar" tagline | + +Section eyebrows use this pattern — a short uppercase label in blue: + +```tsx +

+ The idea +

+``` + +Body copy sits in `

` tags directly below each eyebrow. Edit the text inside +the quotes; leave the surrounding JSX and class names unchanged unless you know +what you are doing. + +## Edit copy on GitHub (no local setup) + +You do not need to install Node.js, pnpm, or run the app locally to submit a +copy change. GitHub's web editor lets you edit the file in your browser. + +### Step 1 — Open the file on GitHub + +1. Go to the repository on GitHub. +2. Navigate to **`src/pages/MarketingPage.tsx`** using the file browser. +3. Click the **pencil icon** (Edit this file) in the top-right corner of the + file view. + +### Step 2 — Make your copy changes + +1. Find the section you want to update using the table above. +2. Edit only the visible text inside the JSX (the strings between tags). +3. Do not change file structure, imports, or class names unless instructed. +4. Scroll down and choose **"Create a new branch for this commit"**. +5. Give the branch a short descriptive name (for example + `update-marketing-intro-copy`). +6. Click **"Commit changes"**. + +### Step 3 — Open a pull request targeting `dev` + +1. After committing, GitHub shows a banner to **"Compare & pull request"**. + Click it (or go to the **Pull requests** tab and click **New pull request**). +2. Set the **base branch** to **`dev`** (not `main`). +3. Set the **compare branch** to the branch you just created. +4. Write a clear title and description explaining what copy you changed and why. +5. Click **Create pull request**. + +A maintainer will review your change and merge it when it looks good. + +## Verifying your change + +Copy-only edits do not require running the app locally. Review your diff on the +pull request page to confirm the text reads correctly. Maintainers may preview +the page in a staging environment before merging. + +If you do have a local setup and want to preview, run `pnpm dev` and open the +marketing page route once it is registered in the app router. This step is +optional for copy contributors. + +## Tips + +- Keep sentences concise and product-specific. +- Preserve existing punctuation and paragraph breaks unless you are intentionally + restructuring the copy. +- Link URLs (GitHub, Telegram) are in `` tags in the Community + section — update the link text, not the URL, unless you are changing the + destination. +- If you are unsure which section a sentence belongs to, open an issue and ask + before editing. diff --git a/docs/react-query-cache-conventions.md b/docs/react-query-cache-conventions.md new file mode 100644 index 00000000..c4aa4cd8 --- /dev/null +++ b/docs/react-query-cache-conventions.md @@ -0,0 +1,259 @@ +# React Query Cache Conventions + +This document describes the conventions for React Query cache keys and cache +invalidation used across the client. Following these conventions keeps query +keys predictable, invalidation reliable, and cache behaviour consistent. + +--- + +## Query Key Structure + +Every query key follows the general shape: + +``` +[entity, identifier?, scope?] +``` + +- **entity** — the domain object (e.g. `'creators'`, `'wallet'`) +- **identifier** — a specific record id or address when targeting one item +- **scope** — the view or sub-resource (e.g. `'list'`, `'detail'`, `'holders'`) + +### The Query Key Factory + +All keys are defined in a **single central factory** at +`src/lib/queryKeys.ts`. Hooks and mutations import from it rather than +constructing inline arrays. + +```ts +// src/lib/queryKeys.ts +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, + }, +}; +``` + +Key design rules: + +1. **`all` key** — every entity group exposes a static `all` key + (`['creators']`) so a single `invalidateQueries` call can target every key + in that domain. +2. **Shared prefixes** — keys within a group share the leading segment so + prefix-based invalidation works. Invalidating `['creators']` will mark every + creator key stale. +3. **`as const`** — factory functions return `as const` tuples so TypeScript + infers literal types instead of `string[]`. +4. **Optional params** — when a list key receives no filter, it stores `null` + at the param position so the key shape is always consistent. +5. **No inline keys** — production hooks must use the factory. (Existing code + in `useCreatorHolderCount.ts` uses an inline key as a deliberate exception + because the `queryFn` is injected for testability.) + +### Adding a New Entity + +To add a new entity type — for example `courses` — extend the factory with the +same patterns: + +```ts +import type { GetCoursesParams } from '@/services/course.service'; + +export const queryKeys = { + creators: { /* … */ }, + wallet: { /* … */ }, + courses: { + all: ['courses'] as const, + list: (params?: GetCoursesParams) => + ['courses', 'list', params ?? null] as const, + detail: (id: string) => ['courses', 'detail', id] as const, + enrollments: (courseId: string) => + ['courses', courseId, 'enrollments'] as const, + }, +}; +``` + +Then use it in hooks: + +```ts +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { courseService } from '@/services/course.service'; + +export function useCourseDetail(id: string) { + return useQuery({ + queryKey: queryKeys.courses.detail(id), + queryFn: () => courseService.getById(id), + enabled: !!id, + }); +} +``` + +The corresponding unit tests in `src/lib/__tests__/queryKeys.test.ts` verify key +shapes and shared prefixes: + +```ts +it('courses.detail shares the courses prefix with courses.all', () => { + expect(queryKeys.courses.detail('x')[0]).toBe( + queryKeys.courses.all[0], + ); +}); + +it('courses.detail embeds the id at index 2', () => { + expect(queryKeys.courses.detail('course-123')[2]).toBe('course-123'); +}); +``` + +--- + +## Cache Invalidation Patterns + +### `invalidateQueries` (preferred after writes) + +After a mutation that changes server data, **invalidate** stale queries and let +React Query refetch in the background: + +```ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useEnrollInCourse() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (courseId: string) => courseService.enroll(courseId), + onSuccess: (_, courseId) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.courses.enrollments(courseId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.courses.detail(courseId), + }); + }, + }); +} +``` + +Use `invalidateQueries` when: + +- The server is the source of truth for the mutated data. +- The mutation response does not contain the full updated entity. +- Multiple queries might be affected and you want them all to refetch. + +### `setQueryData` (optimistic or server-returned data) + +Use `setQueryData` when the mutation response contains the **exact** updated +data and you want to avoid an extra network roundtrip: + +```ts +export function useUpdateCourseTitle() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + courseId, + title, + }: { courseId: string; title: string }) => + courseService.updateTitle(courseId, title), + onSuccess: (updatedCourse, { courseId }) => { + queryClient.setQueryData( + queryKeys.courses.detail(courseId), + updatedCourse, + ); + }, + }); +} +``` + +Use `setQueryData` when: + +- The server returns the complete updated entity in the mutation response. +- You are implementing **optimistic updates** and need to roll back on error. +- The updated data is needed immediately without waiting for a refetch. + +### Decision Table + +| Situation | Approach | +|---|---| +| Mutation changes server state, response is minimal | `invalidateQueries` | +| Mutation response includes full updated object | `setQueryData` | +| Optimistic update with rollback | `setQueryData` + `onError` rollback | +| Multiple entities affected by one mutation | `invalidateQueries` on shared prefix | +| User clicks "Refresh" button | `refetch()` on the specific query | + +See [docs/state-management.md](./state-management.md) for the general rule on +when data belongs in React Query vs local state. + +--- + +## Stale Time and Cache Time + +### Defaults + +The client does not set global overrides, so React Query v5 defaults apply: + +| Option | Default | Meaning | +|---|---|---| +| `staleTime` | `0` | Data is stale immediately. Queries refetch on mount, window focus, and reconnect. | +| `gcTime` | `5 * 60 * 1000` (5 minutes) | Unused/inactive data stays in the cache for 5 minutes before garbage collection. | + +### When to Override + +Override `staleTime` for data that changes infrequently. This reduces +unnecessary network requests: + +```ts +// Price data that updates every 30 seconds +useQuery({ + queryKey: queryKeys.creators.holders(creatorId), + queryFn: () => fetchHolderCount(creatorId), + staleTime: 30_000, +}); +``` + +| Scenario | Recommended `staleTime` | Rationale | +|---|---|---| +| Real-time or live data (prices, balances) | `0` (default) | Always show the latest value. | +| Semi-static data (profile details, course metadata) | `30_000` – `60_000` (30–60 s) | Balances freshness against unnecessary refetches. | +| Rarely-changing data (creator list, static config) | `5 * 60_000` (5 min) or longer | Reduce bandwidth for data that barely changes. | +| Data that never changes during a session | `Infinity` | Fetch once; never refetch until the page reloads. | + +Override `gcTime` only when you want to keep data in the cache longer (or +shorter) than the 5 minute default — for example, to preserve form draft data +across navigation: + +```ts +useQuery({ + queryKey: queryKeys.courses.detail(courseId), + queryFn: () => courseService.getById(courseId), + gcTime: 10 * 60_000, // keep in cache for 10 minutes after unmount +}); +``` + +### Important + +- `gcTime` must always be **greater than** `staleTime` (if both are set). +- React Query v5 renamed `cacheTime` to `gcTime`. Use `gcTime` everywhere. +- The `MutationCache` in `src/providers/web3Utils.ts` logs structured error + data on mutation failures. There is no need to add per-hook error logging. + +--- + +## Cross-references + +- [State Management Overview](./state-management.md) — when to use React Query + vs local state +- [Error Handling in Hooks](./error-handling-in-hooks.md) — `useMutation` + patterns with toasts and invalidation +- [API Layer Conventions](./api-layer.md) — service layer and `ApiError` class +- [Contribution Guide](../CONTRIBUTING.md) — verification commands, naming + conventions, and PR workflow +- [Adding a Page Route](./adding-page-routes.md) — how to register a new route + that consumes these hooks diff --git a/docs/shared-components.md b/docs/shared-components.md new file mode 100644 index 00000000..1476841c --- /dev/null +++ b/docs/shared-components.md @@ -0,0 +1,101 @@ +# Shared Component Library + +This guide documents the shared UI components available in the Access Layer client. It provides guidance on when to use each component, how to extend them, and the conventions for adding new shared components to the repository. + +Refer to the [Adding a New Page Route Guide](file:///Users/marvellous/Desktop/accesslayer-client/docs/adding-page-routes.md) when you are ready to wire these components into a new route or screen. + +--- + +## Shared Components List + +### 1. Button (`Button` & `AsyncButton`) + +- **Purpose**: Render consistent visual states for standard CTA actions and async operations. +- **File location**: `src/components/ui/button.tsx` & `src/components/ui/async-button.tsx` +- **Key Props**: + - `variant`: `'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'` + - `size`: `'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'` + - `asChild`: `boolean` (when true, delegates rendering to its child using Radix `@radix-ui/react-slot`) + - `isLoading` (on `AsyncButton`): `boolean` (renders a loading spinner and disables the button during async flows) +- **When to use**: Use `Button` for all static actions, standard routing links, and interactive buttons. Use `AsyncButton` whenever the action triggers a promise or network request (e.g. submitting a form or executing a transaction) to prevent duplicate submissions. +- **When to build a new one**: Avoid building custom buttons. If you need a completely unique button layout (e.g. with complex custom graphic animations), create a local component inside your feature folder instead of overriding the shared button. + +### 2. Inputs (`FormInput`) + +- **Purpose**: Render styled text inputs with validation states, labels, and error messages. +- **File location**: `src/components/common/FormInput.tsx` +- **Key Props**: + - `label`: `string` + - `error`: `string` (displays validation errors below the input) + - `required`: `boolean` + - `leftIcon` / `rightIcon`: `React.ReactNode` +- **When to use**: Use `FormInput` for user inputs, forms, price filter fields, and onboarding details. +- **When to build a new one**: If you need specialized inputs like dates or select dropdowns, use the existing `FormDate` or `FormSelector` sibling components rather than expanding `FormInput` excessively. + +### 3. Card (`CreatorCard`) + +- **Purpose**: Displays a summary of a creator's portfolio, verification badge, daily price change, and on-chain supply. +- **File location**: `src/components/common/CreatorCard.tsx` +- **Key Props**: + - `creator`: `Course` (object containing creator details) + - `isPinned`: `boolean` + - `onTrade`: `() => void` +- **When to use**: Use `CreatorCard` when displaying creators in grid or list views, such as on the Marketplace discover page. +- **When to build a new one**: If a feature requires displaying non-creator summary information (like transaction details or logs), design a new semantic list row/card rather than modifying `CreatorCard`. + +### 4. Toast Notifications (`showToast`) + +- **Purpose**: Surface success, error, loading, and transaction status feedback to the user. +- **File location**: `src/utils/toast.util.tsx` +- **Usage**: + - `showToast.success(message, options)` + - `showToast.error(message, options)` + - `showToast.loading(message, options)` + - `showToast.transactionSuccess(title, description)` +- **When to use**: Trigger toast notifications on any key lifecycle milestone, such as trade completion, address copying, or request failure. +- **When to build a new one**: Never build custom toast wrappers. Standardize on the `showToast` API which is pre-configured with the app's brand colors and accessibility attributes. + +### 5. Skeletons (`Skeleton`, `CreatorCardSkeleton`, `CreatorSkeleton`) + +- **Purpose**: Render placeholders during data loading phases to reduce layout shifts. +- **File location**: `src/components/ui/skeleton.tsx` & `src/components/common/CreatorCardSkeleton.tsx` +- **Key Props**: + - `className`: `string` (for sizing and styling) +- **When to use**: Use `Skeleton` to construct localized skeleton layouts, or use the prepackaged `CreatorCardSkeleton` when loading list grids. +- **When to build a new one**: When building a completely new page layout, construct a dedicated page skeleton from the primitive `Skeleton` blocks. + +--- + +## Tailwind Class Conventions + +Our shared UI components follow standard class naming conventions for consistency: + +1. **Utility Merging**: Shared components use the `cn` utility (`src/lib/utils.ts`) to merge standard tailwind classes with custom classes provided via `className`. + ```tsx + import { cn } from '@/lib/utils'; + // Always wrap variant/base styles in cn to allow overriding + return

; + ``` +2. **Harmonious Palette**: Use Tailwind classes that match our dark/gold palette: + - Primary buttons/highlights: `bg-primary`, `text-primary-foreground` + - Border accents: `border-white/15`, `border-amber-500/30` + - Muted typography: `text-white/60`, `text-white/40` +3. **Responsive Spacing**: Wrap multi-device layouts in standard margins/paddings (`px-6 md:px-12`). + +--- + +## Process for Adding New Shared Components + +Follow these conventions when contributing a new shared component: + +### 1. Naming & File Location Conventions + +- Place generic primitive UI elements under `src/components/ui/` (e.g. inputs, drawers, tooltips). +- Place feature-rich common components under `src/components/common/` (e.g. search bars, fee badges, creator avatars). +- Component files must use PascalCase naming matching the exported component, for example `src/components/ui/Switch.tsx`. +- Use a single default export or clean named exports where appropriate. + +### 2. Naming Tests + +- Every new shared component must have a corresponding unit or integration test file under `src/components/ui/__tests__/` or `src/components/common/__tests__/`. +- Name the test file using the component name followed by `.test.tsx`, e.g., `src/components/ui/__tests__/Switch.test.tsx`. diff --git a/docs/shared-hooks.md b/docs/shared-hooks.md new file mode 100644 index 00000000..08106eea --- /dev/null +++ b/docs/shared-hooks.md @@ -0,0 +1,52 @@ +# Contributing Shared Hooks + +The `src/hooks` folder is for reusable stateful logic that is not specific to +one component. Put a hook here when multiple screens or components can share the +same state management, browser event handling, async coordination, or derived +behavior. Keep component-only logic near the component that owns it. + +## Naming + +Shared hooks must: + +- Start with the `use` prefix. +- Export a hook whose name matches the file name. +- Use a file name that is identical to the hook name, for example + `useExample.ts`. + +## Tests + +Every shared hook must include a corresponding test file in +`src/hooks/__tests__`. Name the test after the hook, for example +`useExample.test.ts` or `useExample.test.tsx`. + +## Minimal Example + +```ts +// src/hooks/useCounter.ts +import { useCallback, useState } from 'react'; + +export const useCounter = (initialValue = 0) => { + const [count, setCount] = useState(initialValue); + const increment = useCallback(() => setCount(value => value + 1), []); + + return { count, increment }; +}; +``` + +```ts +// src/hooks/__tests__/useCounter.test.ts +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useCounter } from '@/hooks/useCounter'; + +describe('useCounter', () => { + it('increments from the initial value', () => { + const { result } = renderHook(() => useCounter(2)); + + act(() => result.current.increment()); + + expect(result.current.count).toBe(3); + }); +}); +``` diff --git a/docs/state-management.md b/docs/state-management.md new file mode 100644 index 00000000..80a33945 --- /dev/null +++ b/docs/state-management.md @@ -0,0 +1,149 @@ +# Client State Management + +## The Rule + +| Data type | Where it lives | +| ----------------------------------------------------------------------- | ---------------------------------------- | +| Server data (creators, holdings, activity feed) | React Query (`useQuery` / `useMutation`) | +| Ephemeral UI state (modals, input values, selected tabs, loading flags) | Local `useState` | + +If the value came from an API response and needs to survive a component unmount or be shared across routes, put it in React Query. If it only controls what the user sees right now and can be re-derived on re-mount, use `useState`. + +## Query Invalidation vs Manual Refetch + +**Invalidate** after a mutation that changes server data: + +```ts +const queryClient = useQueryClient(); +queryClient.invalidateQueries({ queryKey: queryKeys.creators.list() }); +``` + +This marks cached data stale and lets React Query refetch in the background the next time the query is observed. Use this after a buy, sell, or profile update so all subscribers see fresh data automatically. + +See [React Query Cache Conventions](./react-query-cache-conventions.md) for the query key naming convention, the `invalidateQueries` vs `setQueryData` decision guide, and stale time defaults. + +## Refetch manually + +Refetch manually only when you need to force an immediate reload independent of staleness — for example, a user-triggered "Refresh" button: + +```ts +const { refetch } = useQuery({ queryKey: queryKeys.wallet.holdings(address), ... }); + +``` + +Avoid calling `refetch()` inside effects or after mutations — that bypasses cache coordination and can race with invalidation. + +## Do Not Copy Server State into Local State + +Storing a React Query result in `useState` breaks cache coherence and causes stale UI after mutations. + +### Wrong + +```tsx +function CreatorProfile({ id }: { id: string }) { + const { data } = useCreatorDetail(id); + const [creator, setCreator] = useState(data); + return
{creator?.title}
; +} +``` + +### Right + +```tsx +function CreatorProfile({ id }: { id: string }) { + const { data: creator } = useCreatorDetail(id); + return
{creator?.title}
; +} +``` + +## Ephemeral UI State Examples + +These belong in `useState`, not React Query: + +- Modal open/closed: `const [open, setOpen] = useState(false)` +- Controlled input value: `const [query, setQuery] = useState('')` +- Active tab: `const [activeTab, setActiveTab] = useState('overview')` +- Optimistic loading flag: `const [submitting, setSubmitting] = useState(false)` + +--- + +## Handling Asynchronous States (Loading, Error, Data) + +To avoid inconsistent layout shift and unhandled application crashes, every page component introducing server mutations or asynchronous fetching must explicitly handle the three lifecycle states: **Loading**, **Error**, and **Data**. + +### 1. The Three-State Pattern Flowchart + +1. **Loading State:** Immediately show a structural fallback layout matching the structural scale of the destination layout. Never present empty blank states or unformatted spinning animations. +2. **Error State:** Intercept request faults gracefully. Provide an isolated contextual failure notice alongside a trigger to manually execute a `refetch()` query call. +3. **Data State:** Render layout presentation markup smoothly once the data successfully hydrates. + +### 2. Choosing a Skeleton Component + +Match your structural loading fallbacks strictly to your structural data card layout sizes: + +- Use `` for complete full-bleed layout views or single entity profile view roots. +- Use `` wrapped inside layout grids for multi-item entity dashboards, galleries, or listing blocks. + +### 3. Error Architecture Strategy: Boundary vs. Inline States + +- **Error Boundaries (`CreatorPageErrorBoundary`):** Use at the route level to safely isolate catastrophic runtime engine failures, critical layout state breakdowns, or complete backend authorization drops across whole pages. +- **Inline Contextual States (`SectionErrorBoundary`):** Use for sub-components, standalone layout modules, tabs, or localized search bars where a remote service query issue shouldn't block a user from browsing the remainder of the active application canvas. Always supply the React Query context `refetch` callback method directly to retry controls. + +> The full convention — every error display mode (toast, inline, section boundary, page boundary, transaction surfaces, network banner), the `ApiError` status cheat sheet, and how to add a new error type to the classification system — lives in **[Error Handling Conventions](./error-handling-conventions.md)**. + +### 4. Code Implementation Blueprint + +```tsx +import React from 'react'; +import { useCreatorDetail } from '@/hooks/useCreatorDetail'; +import { CreatorSkeleton } from '@/components/common/CreatorSkeleton'; + +interface CreatorDashboardPageProps { + creatorId: string; +} + +export function CreatorDashboardPage({ creatorId }: CreatorDashboardPageProps) { + const { + data: creator, + isLoading, + isError, + error, + refetch, + } = useCreatorDetail(creatorId); + + if (isLoading) { + return ; + } + + if (isError) { + return ( +
+

+ Failed to load profile details +

+

+ {error instanceof Error + ? error.message + : 'An unexpected data layer exception occurred.'} +

+ +
+ ); + } + + return ( +
+

{creator?.name}

+

{creator?.bio}

+
+ ); +} +``` diff --git a/docs/testing-conventions.md b/docs/testing-conventions.md new file mode 100644 index 00000000..a44bf63c --- /dev/null +++ b/docs/testing-conventions.md @@ -0,0 +1,163 @@ +# Testing Conventions + +How tests are structured in this repo, how to mock the seams (React Query, +wallet, browser APIs), and how to set up an integration test. For +util-specific guidance see the [Utils Testing Guide](./utils-testing-guide.md); +for what hooks should do on failure paths (and therefore what your tests +should assert), see [Error Handling in Hooks](./error-handling-in-hooks.md). + +The runner is **Vitest** (`vitest.config.ts`: jsdom environment, globals +enabled, setup in `src/test/setup.ts`). Run everything with `pnpm test`, or a +single file with `pnpm test `. + +## File naming and co-location + +Tests live in a `__tests__/` folder next to the code they exercise: + +``` +src/hooks/ + ├─ useFormatXlm.ts + └─ __tests__/ + └─ useFormatXlm.test.ts +src/pages/ + ├─ LandingPage.tsx + └─ __tests__/ + ├─ LandingPage.holdings.test.tsx ← unit-ish page test + └─ LandingPage.sellFlow.integration.test.tsx ← integration test +``` + +- **Unit tests**: `.test.ts` / `.test.tsx`. +- **Integration tests**: `..integration.test.tsx` — one flow + per file, named after the feature under test. Components may also co-locate + a test directly beside the file (e.g. + `src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx`). +- Reference the issue number in the top-level `describe` when the test + exists to lock in an issue's acceptance criteria, e.g. + `describe('LandingPage sell flow end-to-end (#644)', …)`. + +## Mocking React Query responses + +There are two established patterns — pick based on what the test is about. + +**1. Mock the service, keep React Query real** (preferred for integration +tests — caching, invalidation and optimistic updates stay honest): + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { courseService } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); +const mockGetCourses = vi.mocked(courseService.getCourses); + +const renderPage = () => + render( + + + + + + ); + +// in the test: +mockGetCourses.mockResolvedValue([…fixtures…]); +``` + +Always create a **fresh `QueryClient` per render** (never share one between +tests — cached data leaks across cases) and disable retries so failure-path +tests don't wait on backoff. + +**2. Mock the hook module wholesale** (for unit tests where query machinery +is noise): + +```tsx +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); +``` + +Anything rendering a component that calls `useQuery`/`useMutation` **must** +be wrapped in a `QueryClientProvider` unless every such hook is mocked out — +a missing provider fails with `No QueryClient set`. + +## Mocking wallet connection state + +Wallet state flows through the hooks in `src/hooks/useWallet.ts` +(`useWalletHoldings`, `useWalletActivity`, `useTradeMutation`). Component +tests mock at that seam: + +```tsx +vi.mock('@/hooks/useWallet', () => ({ + // "connected wallet holding 2 keys of creator-a" + useWalletHoldings: () => ({ + data: [{ creatorId: 'creator-a', quantity: 2, priceStroops: 500_000, price: 0.05, pending: false }], + }), + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); +``` + +For full-flow tests, prefer **not** mocking `useWallet` at all: the demo +wallet seeds the featured creator with 3 held keys, and the real +`useTradeMutation` exercises the optimistic-update and invalidation paths +(see `LandingPage.sellFlow.integration.test.tsx`). Trade submissions resolve +on real timers (~1.2s), so assert with +`waitFor(…, { timeout: 5000 })` rather than fake timers. + +## Integration test setup + +The standard shell for a page-level integration test: + +1. **Providers**: wrap in `QueryClientProvider` (fresh client) and + `MemoryRouter` — pages use react-router hooks. +2. **Service mocks**: `vi.mock('@/services/course.service')` and resolve + fixture data per test. +3. **Toast sink**: mock `@/utils/toast.util` and assert on + `showToast.success` / `error` / `transactionSuccess` calls instead of + scraping toast DOM (no `` is mounted in tests). +4. **Presentation mocks** (copy from an existing integration test): + `framer-motion` (pass-through elements), `@/components/common/CreatorCard` + (lightweight article), `StellarConnectionQualityBadge`, + `FeaturedCreatorAudienceChip`, and network/staleness hooks + (`useNetworkMismatch`, `useStaleData`) pinned to healthy values. +5. **Browser API stubs**, in `beforeEach`: + - `matchMedia` — jsdom doesn't implement it; use the `mockMatchMedia` + helper pattern found in the page tests. + - `localStorage` / `sessionStorage` — newer Node versions (v22+ + WebStorage, default in v25) shadow jsdom's storage with a global that + has no working methods, so `window.localStorage.clear()` throws. New + suites should install an in-memory stub (see `installStorageStub` in + `LandingPage.sellFlow.integration.test.tsx`) instead of touching the + global directly. +6. **Cleanup**: `afterEach(cleanup)` — automatic unmount is not enabled. + +## Available test utilities + +There is deliberately no shared custom `render` yet; each suite composes its +own providers. The reusable pieces to copy today: + +| Utility | Where | What it does | +|---|---|---| +| `src/test/setup.ts` | global setup | registers `@testing-library/jest-dom` matchers | +| `mockMatchMedia()` | page test files | stubs `window.matchMedia` for jsdom | +| `installStorageStub()` | `LandingPage.sellFlow.integration.test.tsx` | Node-version-proof localStorage/sessionStorage stub | +| `makeQueryClient()` | `LandingPage.sort.integration.test.tsx` | fresh `QueryClient` with retries disabled | +| `confirmTrade(side, amount)` | `LandingPage.holdingsSellBalanceUpdate.integration.test.tsx` | drives the trade dialog: open → amount → confirm | +| `dispatchRejection(reason)` | `unhandledRejectionLogger.test.ts` | synthesizes an unhandled-rejection event | + +If you find yourself copying more than two of these into a new file, that is +the signal to promote them into `src/test/` as shared utilities — do it in +the same PR. + +## What good assertions look like here + +- Assert **user-visible outcomes** (rendered text, toast calls, holdings + rows), not internal state. +- For flows with optimistic updates, assert both the intermediate state + (pending) and the settled state where practical. +- Error paths deserve their own tests — see + [Error Handling in Hooks](./error-handling-in-hooks.md) for the expected + failure behaviour to pin down. diff --git a/docs/utils-testing-guide.md b/docs/utils-testing-guide.md new file mode 100644 index 00000000..f316176d --- /dev/null +++ b/docs/utils-testing-guide.md @@ -0,0 +1,69 @@ +# Utils Testing Guide + +## Naming Convention & Co-location + +- Test files should be named **`.test.ts`** (or `.test.tsx` for React‑related helpers). +- Place the test file **side‑by‑side** with the helper it exercises, inside the same directory. + + Example directory layout: + + ``` + src/utils/ + ├─ formatNumber.utils.ts + └─ formatNumber.utils.test.ts ← test file + ``` + +## Running Only Util Tests + +The project uses **Vitest** as the test runner (configured in `vitest.config.ts`). + +- To run **all** tests: `pnpm test` +- To run **only utils** tests: + ```bash + pnpm test "src/utils/**/*.test.ts" + ``` + This pattern matches every test file under `src/utils`. + +## Worked Example Test + +Below is a simple example for a pure helper `formatNumber` that formats a number with commas and two decimal places. + +```ts +// src/utils/formatNumber.utils.test.ts +import { describe, expect, it } from 'vitest'; +import { formatNumber } from './formatNumber.utils'; + +describe('formatNumber utils', () => { + it('formats an integer with commas', () => { + expect(formatNumber(1234567)).toBe('1,234,567.00'); + }); + + it('formats a floating‑point number with two decimals', () => { + expect(formatNumber(1234.5)).toBe('1,234.50'); + }); + + it('handles negative numbers', () => { + expect(formatNumber(-9876.543)).toBe('-9,876.54'); + }); +}); +``` + +### Explanation + +- **`describe`** groups related tests under a readable heading. +- **`it`** defines individual test cases. +- **`expect(...).toBe(...)`** performs the assertion. +- Because `formatNumber` is a **pure function** (no side‑effects), we can achieve **100 % branch coverage** with the three cases above (positive, decimal, negative). + +## Branch Coverage Expectation + +- **Pure helpers** (functions that depend only on their inputs) must have **100 % branch coverage**. +- Run the coverage report with: + ```bash + pnpm test --coverage + ``` +- Ensure the generated `coverage` report shows `100%` for each pure helper file. + +--- + +_This guide lives in the repository under `docs/utils-testing-guide.md` and should be referenced by contributors when adding new utility helpers._ diff --git a/index.html b/index.html index a5734ba4..5e7a099a 100644 --- a/index.html +++ b/index.html @@ -46,6 +46,21 @@ + + +