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..32ceb457 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,4 +26,17 @@ dist-ssr
*.sln
*.sw?
issue.md
-pr.md
\ No newline at end of file
+pr.md
+
+# Test artifacts
+__snapshots__
+*.snap
+coverage
+.nyc_output
+
+# OS artifacts
+Thumbs.db
+
+# Lock files from other package managers
+package-lock.json
+yarn.lock
\ No newline at end of file
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..648afeb7
--- /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
+ {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
+
+ );
+};
+
+export default CreatorCardSkeleton;
diff --git a/src/components/common/CreatorMarketplaceInfiniteList.tsx b/src/components/common/CreatorMarketplaceInfiniteList.tsx
new file mode 100644
index 00000000..27774a74
--- /dev/null
+++ b/src/components/common/CreatorMarketplaceInfiniteList.tsx
@@ -0,0 +1,129 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace';
+import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
+import CreatorCard from '@/components/common/CreatorCard';
+import SearchBar from '@/components/common/SearchBar';
+import { CreatorGridSkeleton } from '@/components/common/CreatorSkeleton';
+import type { GetCoursesParams } from '@/services/course.service';
+
+export interface CreatorMarketplaceInfiniteListProps {
+ params?: Omit;
+}
+
+const SEARCH_DEBOUNCE_MS = 300;
+
+/**
+ * Creator key marketplace listing with IntersectionObserver-driven infinite
+ * scroll (#685): fetches the first page on mount, then automatically fetches
+ * subsequent pages via useInfiniteQuery as the user scrolls the sentinel
+ * element into view. Shows a skeleton row while the next page is loading and
+ * stops fetching once the backend reports no more pages.
+ *
+ * A search bar (#699) filters the currently-loaded creators client-side by
+ * display name (case-insensitive substring match), debounced by 300ms. This
+ * filters within the page(s) already fetched rather than issuing a new
+ * server request per keystroke — the cursor-based pagination in
+ * useInfiniteCreatorMarketplace has no server-side name filter today.
+ */
+export default function CreatorMarketplaceInfiniteList({
+ params,
+}: CreatorMarketplaceInfiniteListProps) {
+ const {
+ creators,
+ hasMore,
+ isLoadingFirstPage,
+ isFetchingNextPage,
+ isRefreshing,
+ fetchNextPage,
+ } = useInfiniteCreatorMarketplace(params);
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [debouncedQuery, setDebouncedQuery] = useState('');
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedQuery(searchQuery), SEARCH_DEBOUNCE_MS);
+ return () => clearTimeout(timer);
+ }, [searchQuery]);
+
+ // Reset the filter whenever the loaded creator set changes identity due
+ // to a new cursor/page landing, per the issue's "filter resets when the
+ // page changes" acceptance criterion — a stale query shouldn't silently
+ // keep hiding newly-loaded creators from a previous page's context. Both
+ // the live input value and the debounced value used for filtering are
+ // cleared together so there's no window where they disagree.
+ const creatorsKey = creators.map(creator => creator.id).join(',');
+ const [lastCreatorsKey, setLastCreatorsKey] = useState(creatorsKey);
+ if (creatorsKey !== lastCreatorsKey) {
+ setLastCreatorsKey(creatorsKey);
+ if (searchQuery) setSearchQuery('');
+ if (debouncedQuery) setDebouncedQuery('');
+ }
+
+ const trimmedQuery = debouncedQuery.trim().toLowerCase();
+ const filteredCreators = useMemo(() => {
+ if (!trimmedQuery) return creators;
+ return creators.filter(creator => creator.title.toLowerCase().includes(trimmedQuery));
+ }, [creators, trimmedQuery]);
+
+ const sentinelRef = useInfiniteScroll({
+ enabled: !isLoadingFirstPage && !isFetchingNextPage,
+ hasMore: Boolean(hasMore),
+ onLoadMore: () => {
+ void fetchNextPage();
+ },
+ });
+
+ if (isLoadingFirstPage) {
+ return (
+
+ );
+}
diff --git a/src/components/common/CreatorPageErrorBoundary.tsx b/src/components/common/CreatorPageErrorBoundary.tsx
new file mode 100644
index 00000000..8b7edf45
--- /dev/null
+++ b/src/components/common/CreatorPageErrorBoundary.tsx
@@ -0,0 +1,79 @@
+import { Component, type ErrorInfo, type ReactNode } from 'react';
+import { Link } from 'react-router';
+import { AlertCircle, ArrowLeft } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { ApiError } from '@/services/api.service';
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+/**
+ * Error boundary scoped to creator detail routes. When a creator page throws
+ * during render, this catches the error and shows a fallback with a link back
+ * to the creator list instead of crashing the whole app to a blank screen.
+ */
+class CreatorPageErrorBoundary extends Component {
+ public state: State = {
+ hasError: false,
+ error: null,
+ };
+
+ public static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ if (import.meta.env.DEV) {
+ console.error('Error rendering creator page:', error, errorInfo);
+ }
+ }
+
+ public render() {
+ if (this.state.hasError) {
+ const isNotFound =
+ this.state.error instanceof ApiError &&
+ this.state.error.status === 404;
+
+ return (
+
+
+
+
+ {isNotFound
+ ? 'Creator not found'
+ : 'This creator page could not load'}
+
+
+ {isNotFound
+ ? "We couldn't find a creator with that ID. Return to the creator list to keep browsing."
+ : 'Something went wrong while loading this creator. The rest of the marketplace is still available.'}
+
+
+
+
+
+ Back to creators
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default CreatorPageErrorBoundary;
diff --git a/src/components/common/CreatorProfileErrorState.tsx b/src/components/common/CreatorProfileErrorState.tsx
new file mode 100644
index 00000000..da5b9b24
--- /dev/null
+++ b/src/components/common/CreatorProfileErrorState.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { AlertCircle, RefreshCw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+export interface CreatorProfileErrorStateProps {
+ /** Optional specific error object or message */
+ error?: Error | string | null;
+ /** Callback function to retry fetching creator profile data */
+ onRetry?: () => void;
+ /** Whether a retry fetch operation is currently in-flight */
+ isRetrying?: boolean;
+ /** Custom title override */
+ title?: string;
+ /** Custom message override */
+ message?: string;
+}
+
+export const CreatorProfileErrorState: React.FC = ({
+ error,
+ onRetry,
+ isRetrying = false,
+ title = 'Unable to load this creator profile',
+ message,
+}) => {
+ const errorMessage =
+ message ||
+ (error instanceof Error
+ ? error.message
+ : typeof error === 'string'
+ ? error
+ : "We couldn't load the latest profile details due to a network error. Check your connection and try again.");
+
+ return (
+
+
+ {formatRelativeTime(notification.createdAt)}
+
+
+ ))}
+
+
+
+ void navigate('/notifications')}
+ >
+ View all
+
+
+
+ );
+}
+
+export default NotificationBell;
diff --git a/src/components/common/PriceSparkline.tsx b/src/components/common/PriceSparkline.tsx
new file mode 100644
index 00000000..e26f8e32
--- /dev/null
+++ b/src/components/common/PriceSparkline.tsx
@@ -0,0 +1,90 @@
+import { cn } from '@/lib/utils';
+
+interface PriceSparklineProps {
+ dataPoints: number[];
+ width?: number;
+ height?: number;
+ className?: string;
+}
+
+const POSITIVE_COLOR = '#34d399';
+const NEGATIVE_COLOR = '#ef4444';
+const NEUTRAL_COLOR = 'currentColor';
+
+export function PriceSparkline({
+ dataPoints,
+ width = 120,
+ height = 40,
+ className,
+}: PriceSparklineProps) {
+ if (dataPoints.length === 0) return null;
+
+ const getLineColor = () => {
+ if (dataPoints.length < 2) return NEUTRAL_COLOR;
+ const last = dataPoints[dataPoints.length - 1];
+ const first = dataPoints[0];
+ if (last > first) return POSITIVE_COLOR;
+ if (last < first) return NEGATIVE_COLOR;
+ return NEUTRAL_COLOR;
+ };
+
+ const lineColor = getLineColor();
+
+ const padding = 2;
+ const innerWidth = width - padding * 2;
+ const innerHeight = height - padding * 2;
+
+ if (dataPoints.length === 1) {
+ return (
+
+ );
+ }
+
+ const min = Math.min(...dataPoints);
+ const max = Math.max(...dataPoints);
+ const range = max - min || 1;
+
+ const buildPathD = () =>
+ dataPoints
+ .map((value, index) => {
+ const x = padding + (index / (dataPoints.length - 1)) * innerWidth;
+ const y =
+ padding + (1 - (value - min) / range) * innerHeight;
+ return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`;
+ })
+ .join(' ');
+
+ return (
+
+ );
+}
diff --git a/src/components/common/SectionErrorBoundary.tsx b/src/components/common/SectionErrorBoundary.tsx
index 97494039..b49f7d34 100644
--- a/src/components/common/SectionErrorBoundary.tsx
+++ b/src/components/common/SectionErrorBoundary.tsx
@@ -2,12 +2,24 @@ import { Component, type ErrorInfo, type ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
+import { markErrorAsCaught } from '@/utils/globalErrorHandler.utils';
interface Props {
children: ReactNode;
sectionName?: string;
minHeight?: string | number;
className?: string;
+ /**
+ * Overrides the default "Something went wrong in this section" heading.
+ * Use for a short, section-specific message (e.g. "Chart unavailable —
+ * try refreshing").
+ */
+ title?: string;
+ /**
+ * Overrides the default explanatory paragraph. Pass an empty string to
+ * show only the title (no secondary copy).
+ */
+ description?: string;
}
interface State {
@@ -26,6 +38,7 @@ class SectionErrorBoundary extends Component {
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ markErrorAsCaught(error);
console.error(`Uncaught error in section ${this.props.sectionName || 'Unknown'}:`, error, errorInfo);
}
@@ -48,14 +61,16 @@ class SectionErrorBoundary extends Component {
- Something went wrong in this section
+ {this.props.title ?? 'Something went wrong in this section'}
-
- {this.props.sectionName
- ? `We encountered an error while loading the ${this.props.sectionName}.`
- : 'We encountered an error while loading this content.'}
- Please try again or contact support if the issue persists.
-
+ {this.props.description !== '' && (
+
+ {this.props.description ??
+ (this.props.sectionName
+ ? `We encountered an error while loading the ${this.props.sectionName}. Please try again or contact support if the issue persists.`
+ : 'We encountered an error while loading this content. Please try again or contact support if the issue persists.')}
+
+ )}
= ({
const [amountText, setAmountText] = useState('1');
const [touched, setTouched] = useState(false);
const amountInputRef = useRef(null);
+ const pricePreviewFailureLogged = useRef(false);
+ // TradeDialog is opened via `open`/`onOpenChange` props from several
+ // different external trigger buttons (see LandingPage.tsx), never via
+ // Radix's own . That means Radix's built-in
+ // focus-return-to-trigger (which targets its own internal triggerRef)
+ // is always a no-op here — there is no triggerRef to return to. We
+ // capture whatever had focus right before the dialog opened ourselves
+ // and restore it in onCloseAutoFocus instead.
+ const triggerElementRef = useRef(null);
useEffect(() => {
if (open) {
+ triggerElementRef.current = document.activeElement as HTMLElement | null;
setAmountText('1');
setTouched(false);
+ pricePreviewFailureLogged.current = false;
}
}, [open]);
+ const handleBlur = () => {
+ setTouched(true);
+ const normalized = amountText.trim();
+ if (normalized) {
+ const clampedResult = clampBuyQuantity(amountText);
+ if (clampedResult.adjusted) {
+ setAmountText(clampedResult.value.toString());
+ }
+ }
+ };
+
const parsedAmount = useMemo(() => {
const normalized = amountText.trim();
if (!normalized) return NaN;
@@ -88,6 +111,38 @@ const TradeDialog: React.FC = ({
return estimateSellProceeds(keyPriceStroops, currentSupply, parsedAmount);
}, [side, keyPriceStroops, currentSupply, parsedAmount]);
+ useEffect(() => {
+ if (process.env.NODE_ENV === 'test') return;
+ if (!open || pricePreviewFailureLogged.current) return;
+
+ if (side === 'buy' && keyPriceStroops == null) {
+ console.debug('[price-preview-failure]', {
+ creator_name: creatorName,
+ quantity: Number.isFinite(parsedAmount) ? parsedAmount : null,
+ side: 'buy',
+ reason: 'key_price_missing',
+ timestamp: new Date().toISOString(),
+ });
+ pricePreviewFailureLogged.current = true;
+ }
+ }, [open, side, keyPriceStroops, creatorName, parsedAmount]);
+
+ useEffect(() => {
+ if (process.env.NODE_ENV === 'test') return;
+ if (!open || pricePreviewFailureLogged.current) return;
+
+ if (side === 'sell' && estimatedProceedsStroops == null) {
+ console.debug('[price-preview-failure]', {
+ creator_name: creatorName,
+ quantity: Number.isFinite(parsedAmount) ? parsedAmount : null,
+ side: 'sell',
+ reason: 'estimate_unavailable',
+ timestamp: new Date().toISOString(),
+ });
+ pricePreviewFailureLogged.current = true;
+ }
+ }, [open, side, estimatedProceedsStroops, creatorName, parsedAmount]);
+
return (
+
+
+ );
+}
+
+export default WalletConnectionPopover;
diff --git a/src/components/common/WalletStatusChip.tsx b/src/components/common/WalletStatusChip.tsx
new file mode 100644
index 00000000..bd8c5c5e
--- /dev/null
+++ b/src/components/common/WalletStatusChip.tsx
@@ -0,0 +1,112 @@
+import { useAccount, useChainId, useDisconnect } from 'wagmi';
+import { Link } from 'react-router';
+import { Copy, Check, LogOut } from 'lucide-react';
+import { useState } from 'react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { shortenAddress } from '@/lib/web3/format';
+import { describeNetwork } from '@/lib/web3/network';
+import { copyTextToClipboard } from '@/utils/clipboard.utils';
+import showToast from '@/utils/toast.util';
+
+/**
+ * Persistent wallet status chip for the nav bar (issue #686).
+ *
+ * Reads `useAccount` and `useChainId` directly rather than taking props, so the
+ * chip re-renders from wagmi's own state when the user switches network in
+ * their extension. Passing the address down would leave it stale until whatever
+ * owns that prop happened to re-render.
+ */
+export function WalletStatusChip({ className = '' }: { className?: string }) {
+ const { address, isConnected } = useAccount();
+ const chainId = useChainId();
+ const { disconnect } = useDisconnect();
+ const [copied, setCopied] = useState(false);
+
+ if (!isConnected || !address) {
+ return (
+
+ Connect Wallet
+
+ );
+ }
+
+ const network = describeNetwork(chainId);
+
+ const handleCopy = async () => {
+ try {
+ await copyTextToClipboard(address);
+ } catch {
+ showToast.error('Could not copy address');
+ return;
+ }
+ setCopied(true);
+ showToast.success('Address copied');
+ // Reverting the icon gives the user a second, unambiguous confirmation
+ // that the action completed rather than leaving a permanent tick.
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+
+ {shortenAddress(address)}
+
+ {network.kind === 'unsupported' ? 'Unsupported' : network.label}
+
+
+
+
+
+
+ Connected wallet
+ {/* break-all so a full address wraps instead of overflowing the menu */}
+ {address}
+
+ Network: {network.label}
+
+
+
+
+
+ {
+ // Keep the menu open so the copied-state tick is visible.
+ event.preventDefault();
+ void handleCopy();
+ }}>
+ {copied ? : }
+ {copied ? 'Copied' : 'Copy address'}
+
+
+ disconnect()}
+ className="text-red-600 focus:text-red-600"
+ >
+
+ Disconnect
+
+
+
+ );
+}
+
+export default WalletStatusChip;
diff --git a/src/components/common/__tests__/BuyPriceEstimate.test.tsx b/src/components/common/__tests__/BuyPriceEstimate.test.tsx
new file mode 100644
index 00000000..3784bc96
--- /dev/null
+++ b/src/components/common/__tests__/BuyPriceEstimate.test.tsx
@@ -0,0 +1,132 @@
+/**
+ * Unit tests for BuyPriceEstimate — the bonding curve price preview
+ * component that renders the correct total XLM amount for buying N keys
+ * (#684).
+ */
+import { render, screen } from '@testing-library/react';
+import { act } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import BuyPriceEstimate from '@/components/common/BuyPriceEstimate';
+import * as bondingCurveUtils from '@/utils/bondingCurve.utils';
+import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
+
+describe('BuyPriceEstimate', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('displays the correct XLM price for quantity 1', () => {
+ render();
+
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ const expectedCostStroops = bondingCurveUtils.computeBuyCost(
+ 0,
+ 1,
+ bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS
+ );
+ expect(screen.getByTestId('buy-price-total')).toHaveTextContent(
+ formatDisplayKeyPrice(expectedCostStroops)
+ );
+ expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeInTheDocument();
+ });
+
+ it('displays 0 XLM and no buy button for quantity 0', () => {
+ render();
+
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ expect(screen.getByTestId('buy-price-total')).toHaveTextContent('0 XLM');
+ expect(
+ screen.queryByTestId('buy-price-estimate-buy-button')
+ ).not.toBeInTheDocument();
+ });
+
+ it('displays a higher total price for a large quantity than for quantity 1', () => {
+ const { unmount } = render();
+ act(() => {
+ vi.runAllTimers();
+ });
+ const priceForOneText = screen.getByTestId('buy-price-total').textContent;
+ unmount();
+
+ render();
+ act(() => {
+ vi.runAllTimers();
+ });
+ const priceForHundredText = screen.getByTestId('buy-price-total').textContent;
+
+ const expectedForHundred = bondingCurveUtils.computeBuyCost(
+ 0,
+ 100,
+ bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS
+ );
+ const expectedForOne = bondingCurveUtils.computeBuyCost(
+ 0,
+ 1,
+ bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS
+ );
+
+ expect(expectedForHundred).toBeGreaterThan(expectedForOne);
+ expect(priceForHundredText).not.toBe(priceForOneText);
+ expect(priceForHundredText).toContain('XLM');
+ });
+
+ it('updates the displayed price when the quantity prop changes, without unmounting', () => {
+ const { rerender } = render();
+ act(() => {
+ vi.runAllTimers();
+ });
+ const firstPrice = screen.getByTestId('buy-price-total').textContent;
+
+ rerender();
+ act(() => {
+ vi.runAllTimers();
+ });
+ const secondPrice = screen.getByTestId('buy-price-total').textContent;
+
+ expect(secondPrice).not.toBe(firstPrice);
+ });
+
+ it('shows a loading state while the price is being (re)calculated', () => {
+ const { rerender } = render();
+ act(() => {
+ vi.runAllTimers();
+ });
+ expect(screen.queryByTestId('buy-price-loading')).not.toBeInTheDocument();
+
+ rerender();
+ // Before the debounce timer fires, the loading state should be visible
+ // and the buy button disabled (it stays mounted for layout stability,
+ // but must not be clickable while the price is stale/recalculating).
+ expect(screen.getByTestId('buy-price-loading')).toBeInTheDocument();
+ expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeDisabled();
+
+ act(() => {
+ vi.runAllTimers();
+ });
+ expect(screen.queryByTestId('buy-price-loading')).not.toBeInTheDocument();
+ expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeEnabled();
+ });
+
+ it('calls the bonding curve calculation function with the correct arguments', () => {
+ const spy = vi.spyOn(bondingCurveUtils, 'computeBuyCost');
+ const params = { basePriceStroops: 5_000_000, growthFactor: 1.02 };
+
+ render();
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ expect(spy).toHaveBeenCalledWith(42, 7, params);
+ });
+});
diff --git a/src/components/common/__tests__/Change24hBadge.test.tsx b/src/components/common/__tests__/Change24hBadge.test.tsx
new file mode 100644
index 00000000..5ef32db7
--- /dev/null
+++ b/src/components/common/__tests__/Change24hBadge.test.tsx
@@ -0,0 +1,101 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import Change24hBadge from '../Change24hBadge';
+
+describe('Change24hBadge', () => {
+ describe('positive change', () => {
+ it('renders green badge with up arrow for positive value', () => {
+ render();
+
+ const badge = screen.getByText('+5.25%');
+ expect(badge).toBeInTheDocument();
+ expect(badge.closest('div')).toHaveClass('text-emerald-400');
+ });
+
+ it('formats positive value to two decimal places with sign', () => {
+ render();
+ expect(screen.getByText('+3.46%')).toBeInTheDocument();
+ });
+
+ it('shows TrendingUp icon for positive change', () => {
+ const { container } = render();
+ const svg = container.querySelector('svg');
+ expect(svg).toBeInTheDocument();
+ expect(svg).toHaveClass('lucide-trending-up');
+ });
+ });
+
+ describe('negative change', () => {
+ it('renders red badge with down arrow for negative value', () => {
+ render();
+
+ const badge = screen.getByText('-2.1%');
+ expect(badge).toBeInTheDocument();
+ expect(badge.closest('div')).toHaveClass('text-red-400');
+ });
+
+ it('formats negative value to two decimal places', () => {
+ render();
+ expect(screen.getByText('-0.12%')).toBeInTheDocument();
+ });
+
+ it('shows TrendingDown icon for negative change', () => {
+ const { container } = render();
+ const svg = container.querySelector('svg');
+ expect(svg).toBeInTheDocument();
+ expect(svg).toHaveClass('lucide-trending-down');
+ });
+ });
+
+ describe('zero change', () => {
+ it('renders grey badge with dash for zero value', () => {
+ render();
+
+ const badge = screen.getByText('0%');
+ expect(badge).toBeInTheDocument();
+ expect(badge.closest('div')).toHaveClass('text-white/40');
+ });
+
+ it('shows Minus icon for zero change', () => {
+ const { container } = render();
+ const svg = container.querySelector('svg');
+ expect(svg).toBeInTheDocument();
+ expect(svg).toHaveClass('lucide-minus');
+ });
+ });
+
+ describe('null / undefined (no data)', () => {
+ it('renders nothing when change is undefined', () => {
+ const { container } = render();
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders nothing when change is explicitly null', () => {
+ const { container } = render();
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('renders correct badge for very small positive value', () => {
+ render();
+ expect(screen.getByText('+0.01%')).toBeInTheDocument();
+ });
+
+ it('renders correct badge for very small negative value', () => {
+ render();
+ expect(screen.getByText('-0.01%')).toBeInTheDocument();
+ });
+
+ it('applies custom className', () => {
+ render();
+ const badge = screen.getByText('+1%').closest('div');
+ expect(badge).toHaveClass('my-custom-class');
+ });
+
+ it('includes accessible title with 24h context', () => {
+ render();
+ expect(screen.getByTitle('+5% (24h)')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/common/__tests__/ConnectWalletButton.test.tsx b/src/components/common/__tests__/ConnectWalletButton.test.tsx
index 261204f8..bbeac672 100644
--- a/src/components/common/__tests__/ConnectWalletButton.test.tsx
+++ b/src/components/common/__tests__/ConnectWalletButton.test.tsx
@@ -1,5 +1,6 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
import ConnectWalletButton from '@/components/common/ConnectWalletButton';
import { useAccount, useConnect, useDisconnect } from 'wagmi';
@@ -14,68 +15,275 @@ const mockUseAccount = vi.mocked(useAccount);
const mockUseConnect = vi.mocked(useConnect);
const mockUseDisconnect = vi.mocked(useDisconnect);
-describe('ConnectWalletButton wallet disconnect confirmation', () => {
- function renderConnectedWallet(disconnect = vi.fn()) {
- mockUseAccount.mockReturnValue({
- address: '0x1234567890abcdef1234567890abcdef12345678',
- isConnected: true,
- } as ReturnType);
- mockUseConnect.mockReturnValue({
- connect: vi.fn(),
- connectors: [],
- error: null,
- isPending: false,
- } as unknown as ReturnType);
- mockUseDisconnect.mockReturnValue({
- disconnect,
- } as unknown as ReturnType);
+const FULL_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678';
+const TRUNCATED_ADDRESS_PATTERN = /0x12.*5678/i;
- render();
+function setupConnectedWalletMocks(disconnect = vi.fn()) {
+ mockUseAccount.mockReturnValue({
+ address: FULL_ADDRESS,
+ isConnected: true,
+ } as ReturnType);
+ mockUseConnect.mockReturnValue({
+ connect: vi.fn(),
+ connectors: [],
+ error: null,
+ isPending: false,
+ } as unknown as ReturnType);
+ mockUseDisconnect.mockReturnValue({
+ disconnect,
+ } as unknown as ReturnType);
+
+ return { disconnect };
+}
+
+function openAddressPopover() {
+ fireEvent.click(
+ screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
+ );
+}
- return { disconnect };
+describe('ConnectWalletButton wallet address popover', () => {
+ function renderConnectedWallet(disconnect = vi.fn()) {
+ const result = setupConnectedWalletMocks(disconnect);
+ render();
+ return result;
}
- it('opens a confirmation dialog before disconnecting', () => {
+ it('opens the address popover when clicking the truncated address', () => {
const { disconnect } = renderConnectedWallet();
- fireEvent.click(screen.getByRole('button', { name: /0x1234/i }));
+ openAddressPopover();
- expect(
- screen.getByRole('dialog', { name: /disconnect wallet/i })
- ).toBeInTheDocument();
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+ expect(screen.getByText('Wallet address')).toBeInTheDocument();
expect(disconnect).not.toHaveBeenCalled();
});
- it('disconnects when the confirmation action is clicked', () => {
+ it('disconnects when the disconnect button is clicked', () => {
const { disconnect } = renderConnectedWallet();
- fireEvent.click(screen.getByRole('button', { name: /0x1234/i }));
+ openAddressPopover();
fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
expect(disconnect).toHaveBeenCalledTimes(1);
});
- it('cancels without disconnecting', async () => {
+ it('emits a structured disconnect log with session duration outside test env', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-07-27T10:00:00.000Z'));
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'development';
+ const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const { disconnect } = renderConnectedWallet();
- fireEvent.click(screen.getByRole('button', { name: /0x1234/i }));
- fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ act(() => {
+ vi.advanceTimersByTime(4_500);
+ });
+
+ openAddressPopover();
+ fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
+
+ expect(disconnect).toHaveBeenCalledTimes(1);
+ expect(debugSpy).toHaveBeenCalledWith('[wallet-disconnect]', {
+ truncated_address: '0x12...5678',
+ session_duration_ms: 4_500,
+ disconnected_at: '2026-07-27T10:00:04.500Z',
+ });
+ expect(JSON.stringify(debugSpy.mock.calls[0][1])).not.toContain(FULL_ADDRESS);
+
+ debugSpy.mockRestore();
+ process.env.NODE_ENV = originalEnv;
+ vi.useRealTimers();
+ });
+
+ it('closes the popover without disconnecting when Escape is pressed', async () => {
+ const { disconnect } = renderConnectedWallet();
+
+ openAddressPopover();
+
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(screen.queryByText(FULL_ADDRESS)).not.toBeInTheDocument();
});
expect(disconnect).not.toHaveBeenCalled();
});
- it('dismisses with Escape without disconnecting', async () => {
+ it('closes the popover without disconnecting when clicking outside', async () => {
+ const user = userEvent.setup();
const { disconnect } = renderConnectedWallet();
- fireEvent.click(screen.getByRole('button', { name: /0x1234/i }));
+ openAddressPopover();
+
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+
+ // Click outside the popover on the document body
+ await user.click(document.body);
+
+ expect(screen.queryByText(FULL_ADDRESS)).not.toBeInTheDocument();
+ expect(disconnect).not.toHaveBeenCalled();
+ });
+});
+
+describe('ConnectWalletButton popover dismissal with parent header', () => {
+ function renderConnectedWalletInHeader(disconnect = vi.fn()) {
+ const result = setupConnectedWalletMocks(disconnect);
+ render(
+
+
+ Access Layer
+
+
+
+ );
+ return result;
+ }
+
+ it('popover is visible after clicking the header address display', () => {
+ renderConnectedWalletInHeader();
+
+ openAddressPopover();
+
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+ expect(screen.getByText('Wallet address')).toBeInTheDocument();
+ });
+
+ it('closes the popover on outside click without unmounting the header', async () => {
+ const user = userEvent.setup();
+ renderConnectedWalletInHeader();
+
+ openAddressPopover();
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+
+ // Click outside the popover on the header itself
+ await user.click(screen.getByTestId('app-header'));
+
+ expect(screen.queryByText(FULL_ADDRESS)).not.toBeInTheDocument();
+
+ // Header address display should still be visible
+ expect(
+ screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
+ ).toBeInTheDocument();
+ expect(screen.getByTestId('app-header')).toBeInTheDocument();
+ });
+
+ it('closes the popover on Escape without unmounting the header', async () => {
+ renderConnectedWalletInHeader();
+
+ openAddressPopover();
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(screen.queryByText(FULL_ADDRESS)).not.toBeInTheDocument();
});
- expect(disconnect).not.toHaveBeenCalled();
+
+ // Header address display should still be visible
+ expect(
+ screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
+ ).toBeInTheDocument();
+ expect(screen.getByTestId('app-header')).toBeInTheDocument();
+ });
+
+ it('header address display remains visible after reopen and close via both dismissal paths', async () => {
+ const user = userEvent.setup();
+ renderConnectedWalletInHeader();
+
+ // First: outside click dismissal
+ openAddressPopover();
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+ await user.click(screen.getByTestId('app-header'));
+
+ expect(screen.queryByText(FULL_ADDRESS)).not.toBeInTheDocument();
+
+ expect(
+ screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
+ ).toBeInTheDocument();
+
+ // Second: Escape dismissal
+ openAddressPopover();
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ await waitFor(() => {
+ expect(screen.queryByText(FULL_ADDRESS)).not.toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
+ ).toBeInTheDocument();
+ expect(screen.getByTestId('app-header')).toBeInTheDocument();
+ });
+});
+
+describe('ConnectWalletButton copy wallet address', () => {
+ beforeEach(() => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ function renderConnectedWallet() {
+ setupConnectedWalletMocks();
+ render();
+ }
+
+ function openAndFindCopyButton() {
+ openAddressPopover();
+ return screen.getByRole('button', { name: /copy address/i });
+ }
+
+ it('shows the full wallet address inside the popover', () => {
+ renderConnectedWallet();
+
+ openAddressPopover();
+
+ expect(screen.getByText(FULL_ADDRESS)).toBeInTheDocument();
+ expect(screen.getByText('Wallet address')).toBeInTheDocument();
+ });
+
+ it('copies the full unmasked address to the clipboard on click', async () => {
+ renderConnectedWallet();
+
+ fireEvent.click(openAndFindCopyButton());
+
+ await waitFor(() => {
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FULL_ADDRESS);
+ });
+ });
+
+ it('shows a Copied state on the copy button after clicking', async () => {
+ renderConnectedWallet();
+
+ fireEvent.click(openAndFindCopyButton());
+
+ expect(await screen.findByText('Copied')).toBeInTheDocument();
+ });
+
+ it('removes the Copied state after 2 seconds', async () => {
+ vi.useFakeTimers();
+ renderConnectedWallet();
+
+ fireEvent.click(openAndFindCopyButton());
+
+ // Flush the clipboard promise microtask so state updates land
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(screen.getByText('Copied')).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(screen.queryByText('Copied')).not.toBeInTheDocument();
+
+ vi.useRealTimers();
});
});
diff --git a/src/components/common/__tests__/CopyField.test.tsx b/src/components/common/__tests__/CopyField.test.tsx
new file mode 100644
index 00000000..95a09bbe
--- /dev/null
+++ b/src/components/common/__tests__/CopyField.test.tsx
@@ -0,0 +1,159 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+import CopyField from '@/components/common/CopyField';
+import { copyTextToClipboard } from '@/utils/clipboard.utils';
+
+vi.mock('@/utils/toast.util', () => ({
+ default: { success: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock('@/utils/clipboard.utils', () => ({
+ copyTextToClipboard: vi.fn().mockResolvedValue(undefined),
+}));
+
+const mockCopyTextToClipboard = vi.mocked(copyTextToClipboard);
+
+const FULL_ADDRESS =
+ 'GBUKOFF6RS5OTIHMGMH4MOVKPAS4JJIZGYXS4DOVZDNBH5YXKJXFNEC';
+
+describe('CopyField clipboard integration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('copies the full unmasked address to clipboard on button click', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ const copyBtn = screen.getByRole('button', {
+ name: /copy wallet address/i,
+ });
+ await user.click(copyBtn);
+
+ expect(mockCopyTextToClipboard).toHaveBeenCalledOnce();
+ expect(mockCopyTextToClipboard).toHaveBeenCalledWith(FULL_ADDRESS);
+ });
+
+ it('displays the full address in the input field', () => {
+ render();
+
+ const input = screen.getByRole('textbox', { name: /wallet address/i });
+ expect(input).toHaveValue(FULL_ADDRESS);
+ });
+
+ it('shows copied state after clicking copy', async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ );
+
+ expect(
+ screen.getByRole('button', { name: /wallet address copied/i })
+ ).toBeInTheDocument();
+ });
+});
+
+describe('CopyField copy confirmation timing (#600)', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('shows Copied confirmation immediately after click', async () => {
+ render();
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ );
+ });
+
+ expect(
+ screen.getByRole('button', { name: /wallet address copied/i })
+ ).toBeInTheDocument();
+ });
+
+ it('keeps Copied confirmation visible at 1999ms', async () => {
+ render();
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ );
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(1999);
+ });
+
+ expect(
+ screen.getByRole('button', { name: /wallet address copied/i })
+ ).toBeInTheDocument();
+ });
+
+ it('reverts button to icon state at 2000ms', async () => {
+ render();
+
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ );
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /wallet address copied/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it('resets confirmation on second click after timeout', async () => {
+ render();
+
+ // First click — confirmation appears
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ );
+ });
+
+ expect(
+ screen.getByRole('button', { name: /wallet address copied/i })
+ ).toBeInTheDocument();
+
+ // Advance past the 2000ms timeout so the button reverts
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ ).toBeInTheDocument();
+
+ // Second click — confirmation resets and appears again
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ );
+ });
+
+ expect(
+ screen.getByRole('button', { name: /wallet address copied/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/CreatorActivityFeed.test.tsx b/src/components/common/__tests__/CreatorActivityFeed.test.tsx
new file mode 100644
index 00000000..ababee82
--- /dev/null
+++ b/src/components/common/__tests__/CreatorActivityFeed.test.tsx
@@ -0,0 +1,110 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import CreatorActivityFeed from '@/components/common/CreatorActivityFeed';
+import { useCreatorActivityFeed } from '@/hooks/useCreatorActivityFeed';
+import type { CreatorActivityTrade } from '@/services/creatorActivity.service';
+
+vi.mock('@/hooks/useCreatorActivityFeed');
+vi.mock('@/services/creatorActivity.service', () => ({
+ creatorActivityService: { getCreatorActivity: vi.fn() },
+}));
+
+const mockUseCreatorActivityFeed = vi.mocked(useCreatorActivityFeed);
+
+function makeTrade(id: string, overrides: Partial = {}): CreatorActivityTrade {
+ return {
+ id,
+ type: 'buy',
+ traderHandle: `trader-${id}`,
+ amount: 2,
+ price: 0.05,
+ timestamp: Date.now(),
+ txHash: `0x${id}`,
+ status: 'completed',
+ ...overrides,
+ };
+}
+
+describe('CreatorActivityFeed', () => {
+ it('shows skeleton rows while the query is loading', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [],
+ isLoading: true,
+ isError: false,
+ });
+
+ render();
+
+ expect(screen.getByTestId('creator-activity-feed-skeleton')).toBeInTheDocument();
+ expect(screen.queryByTestId('creator-activity-feed-empty-state')).not.toBeInTheDocument();
+ });
+
+ it('does not show the empty state while loading, even though trades is an empty array', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [],
+ isLoading: true,
+ isError: false,
+ });
+
+ render();
+
+ expect(screen.queryByText('No activity yet — buy or sell keys to get started')).not.toBeInTheDocument();
+ });
+
+ it('shows the empty state once the query has settled with no transactions', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [],
+ isLoading: false,
+ isError: false,
+ });
+
+ render();
+
+ expect(screen.getByTestId('creator-activity-feed-empty-state')).toBeInTheDocument();
+ expect(
+ screen.getByText('No activity yet — buy or sell keys to get started')
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId('creator-activity-feed-skeleton')).not.toBeInTheDocument();
+ });
+
+ it('renders real activity rows in place of the empty state as soon as data arrives', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [makeTrade('a'), makeTrade('b', { type: 'sell' })],
+ isLoading: false,
+ isError: false,
+ });
+
+ render();
+
+ expect(screen.queryByTestId('creator-activity-feed-empty-state')).not.toBeInTheDocument();
+ expect(screen.getByTestId('creator-activity-item-a')).toBeInTheDocument();
+ expect(screen.getByTestId('creator-activity-item-b')).toBeInTheDocument();
+ });
+
+ it('renders the exact empty-state message copy from the design spec', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [],
+ isLoading: false,
+ isError: false,
+ });
+
+ render();
+
+ expect(
+ screen.getByText('No activity yet — buy or sell keys to get started')
+ ).toBeInTheDocument();
+ });
+
+ it('distinguishes buy and sell trade types in the rendered rows', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [makeTrade('a', { type: 'buy' }), makeTrade('b', { type: 'sell' })],
+ isLoading: false,
+ isError: false,
+ });
+
+ render();
+
+ expect(screen.getByTestId('creator-activity-item-a')).toHaveTextContent('Buy');
+ expect(screen.getByTestId('creator-activity-item-b')).toHaveTextContent('Sell');
+ });
+});
diff --git a/src/components/common/__tests__/CreatorCard.accessibility.test.tsx b/src/components/common/__tests__/CreatorCard.accessibility.test.tsx
index 5ce1eccb..51f58d58 100644
--- a/src/components/common/__tests__/CreatorCard.accessibility.test.tsx
+++ b/src/components/common/__tests__/CreatorCard.accessibility.test.tsx
@@ -65,4 +65,27 @@ describe('CreatorCard accessibility', () => {
})
).toBeInTheDocument();
});
+
+ it('updates displayed price when price snapshot data changes', () => {
+ const { rerender } = render();
+
+ // Initial price should be 12 XLM
+ const initialPriceBadge = screen.getByTestId('creator-card-price-badge');
+ expect(initialPriceBadge).toHaveTextContent(/12/i);
+
+ // Update creator with new price
+ const updatedCreator: Course = {
+ ...creator,
+ price: 25,
+ };
+
+ rerender();
+
+ // New price should be 25 XLM
+ const updatedPriceBadge = screen.getByTestId('creator-card-price-badge');
+ expect(updatedPriceBadge).toHaveTextContent(/25/i);
+
+ // Old price (12) should no longer be visible
+ expect(updatedPriceBadge).not.toHaveTextContent(/12/i);
+ });
});
diff --git a/src/components/common/__tests__/CreatorCard.sparkline.test.tsx b/src/components/common/__tests__/CreatorCard.sparkline.test.tsx
new file mode 100644
index 00000000..e315c753
--- /dev/null
+++ b/src/components/common/__tests__/CreatorCard.sparkline.test.tsx
@@ -0,0 +1,80 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import CreatorCard from '@/components/common/CreatorCard';
+import type { Course } from '@/services/course.service';
+
+vi.mock('wagmi', () => ({
+ useAccount: () => ({ isConnected: false }),
+ useConnect: () => ({ connectAsync: vi.fn(), connectors: [] }),
+ useReconnect: () => ({ reconnectAsync: vi.fn(), connectors: [] }),
+}));
+
+vi.mock('@/hooks/useNetworkMismatch', () => ({
+ useNetworkMismatch: () => ({
+ isMismatch: false,
+ expectedChainName: 'Stellar Testnet',
+ }),
+}));
+
+vi.mock('@/hooks/useTransactionTelemetry', () => ({
+ useTransactionTelemetry: () => vi.fn(),
+}));
+
+vi.mock('@/utils/useSystemTheme', () => ({
+ useSystemTheme: () => ({ isDarkMode: true }),
+}));
+
+function createCreator(overrides: Partial = {}): Course {
+ return {
+ id: 'test-creator',
+ title: 'Test Creator',
+ description: 'A test creator.',
+ price: 10,
+ creatorShareSupply: 100,
+ instructorId: 'test',
+ category: 'Art',
+ level: 'BEGINNER',
+ ...overrides,
+ };
+}
+
+describe('CreatorCard sparkline', () => {
+ it('does not render sparkline for zero price history points', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector('polyline')).not.toBeInTheDocument();
+ });
+
+ it('does not render sparkline for one price history point', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector('polyline')).not.toBeInTheDocument();
+ });
+
+ it('renders sparkline for two price history points', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector('polyline')).toBeInTheDocument();
+ });
+
+ it('renders sparkline for seven price history points', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector('polyline')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/CreatorCardSkeleton.noLayoutShift.test.tsx b/src/components/common/__tests__/CreatorCardSkeleton.noLayoutShift.test.tsx
new file mode 100644
index 00000000..fc0079c6
--- /dev/null
+++ b/src/components/common/__tests__/CreatorCardSkeleton.noLayoutShift.test.tsx
@@ -0,0 +1,73 @@
+/**
+ * CreatorCardSkeleton no-layout-shift coverage (#643).
+ *
+ * #643 asks for the skeleton to mirror the real card so no layout shift
+ * occurs when data loads, and to render no real content while loading.
+ * The skeleton (added for #421) already exceeds a minimal 4-region design —
+ * it mirrors every content region of CreatorCard (avatar, title/badges,
+ * handle, bio, sparkline, stat chips, meta rows, social links, action row,
+ * helper text) rather than only avatar/name/price/holder-count. Redesigning
+ * it down to 4 regions would regress the #421 suite
+ * (CreatorCardSkeleton.test.tsx), so this suite verifies the properties
+ * #643 actually cares about against the current, richer design: every
+ * placeholder region carries the animated pulse class, no real text or
+ * images are rendered, and the root surface carries fixed sizing
+ * constraints (rounded-2xl card surface, aspect-square avatar) so swapping
+ * in the real card does not shift layout.
+ */
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import CreatorCardSkeleton from '../CreatorCardSkeleton';
+
+describe('CreatorCardSkeleton no-layout-shift (#643)', () => {
+ it('applies the animated pulse class to every placeholder region', () => {
+ const { container } = render();
+
+ const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer');
+ expect(shimmerBlocks.length).toBeGreaterThan(0);
+
+ shimmerBlocks.forEach(block => {
+ expect(block.className).toMatch(/skeleton-shimmer/);
+ });
+
+ // No placeholder block should be missing the shared block styling
+ // (rounded corners are part of the shared block class in every
+ // region except the divider rules, so every shimmer block is
+ // expected to carry a rounded-* utility).
+ const nonRounded = Array.from(shimmerBlocks).filter(
+ block => !/rounded-/.test(block.className)
+ );
+ expect(nonRounded).toHaveLength(0);
+ });
+
+ it('renders no real creator content — no text nodes, no images', () => {
+ const { container, queryByRole } = render();
+
+ expect(container.querySelectorAll('img')).toHaveLength(0);
+ expect(queryByRole('img')).toBeNull();
+
+ // Only the visually-hidden "Loading creator card" label should be
+ // present as text — no creator name, price, or bio strings.
+ const srOnly = container.querySelector('.sr-only');
+ expect(srOnly?.textContent).toBe('Loading creator card');
+
+ const visibleText = Array.from(container.querySelectorAll('div'))
+ .filter(el => el.children.length === 0)
+ .map(el => el.textContent?.trim())
+ .filter(Boolean);
+ expect(visibleText).toHaveLength(0);
+ });
+
+ it('constrains the root surface and avatar block the same way CreatorCard does', () => {
+ const { getByTestId } = render();
+
+ const card = getByTestId('creator-card-skeleton');
+ // Same card-surface + rounding classes CreatorCard's root uses, so
+ // swapping skeleton -> real card does not change the card footprint.
+ expect(card).toHaveClass('marketplace-card-surface');
+ expect(card).toHaveClass('rounded-2xl');
+
+ const avatarBlock = card.querySelector('.aspect-square');
+ expect(avatarBlock).not.toBeNull();
+ });
+});
diff --git a/src/components/common/__tests__/CreatorCardSkeleton.test.tsx b/src/components/common/__tests__/CreatorCardSkeleton.test.tsx
new file mode 100644
index 00000000..8612dca1
--- /dev/null
+++ b/src/components/common/__tests__/CreatorCardSkeleton.test.tsx
@@ -0,0 +1,92 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import CreatorCardSkeleton, {
+ CreatorCardGridSkeleton,
+} from '../CreatorCardSkeleton';
+
+describe('CreatorCardSkeleton', () => {
+ it('renders shimmer blocks for the loading affordance', () => {
+ const { container, getByRole, getAllByTestId } = render(
+
+ );
+
+ // role="status" announces loading to assistive tech.
+ expect(
+ getByRole('status', { name: 'Loading creator card' })
+ ).toBeInTheDocument();
+
+ // Sanity-check that the card test id is exposed so other
+ // components / tests can target a single placeholder card.
+ expect(getAllByTestId('creator-card-skeleton')).toHaveLength(1);
+
+ // Animated shimmer is present by default — many blocks are
+ // expected because CreatorCardSkeleton mirrors the full card
+ // (avatar, title, badges, handle, bio, sparkline, chips, meta
+ // rows, social links, action row, helper text).
+ const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer');
+ expect(shimmerBlocks.length).toBeGreaterThanOrEqual(10);
+ });
+
+ it('disables the shimmer with `disableShimmer` and falls back to the static block', () => {
+ const { container } = render();
+
+ expect(container.querySelectorAll('.skeleton-shimmer')).toHaveLength(0);
+ expect(
+ container.querySelectorAll('.ring-white\\/15').length
+ ).toBeGreaterThanOrEqual(10);
+ });
+
+ it('merges additional className onto the card surface', () => {
+ const { container } = render(
+
+ );
+
+ const card = container.querySelector('[data-testid="creator-card-skeleton"]');
+ expect(card).not.toBeNull();
+ expect(card).toHaveClass('custom-class');
+ expect(card).toHaveClass('rounded-2xl');
+ });
+});
+
+describe('CreatorCardGridSkeleton', () => {
+ it('renders 6 skeletons by default (#421 acceptance criterion)', () => {
+ const { container, getAllByTestId, getByTestId } = render(
+
+ );
+
+ expect(
+ getByTestId('creator-card-grid-skeleton')
+ ).toBeInTheDocument();
+ expect(getAllByTestId('creator-card-skeleton')).toHaveLength(6);
+ // Each card contributes at least 10 shimmer blocks, so the grid
+ // should expose 60+ shimmer nodes.
+ const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer');
+ expect(shimmerBlocks.length).toBeGreaterThanOrEqual(60);
+ });
+
+ it('renders the requested number of cards', () => {
+ const { getAllByTestId } = render();
+ expect(getAllByTestId('creator-card-skeleton')).toHaveLength(3);
+ });
+
+ it('propagates `disableShimmer` to every card in the grid', () => {
+ const { container, getAllByTestId } = render(
+
+ );
+
+ expect(getAllByTestId('creator-card-skeleton')).toHaveLength(2);
+ expect(container.querySelectorAll('.skeleton-shimmer')).toHaveLength(0);
+ expect(
+ container.querySelectorAll('.ring-white\\/15').length
+ ).toBeGreaterThanOrEqual(20);
+ });
+
+ it('applies extra className to the grid wrapper', () => {
+ const { getByTestId } = render(
+
+ );
+ const grid = getByTestId('creator-card-grid-skeleton');
+ expect(grid).toHaveClass('extra-grid-class');
+ expect(grid).toHaveClass('grid-cols-1');
+ });
+});
diff --git a/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx
new file mode 100644
index 00000000..a4a80fc8
--- /dev/null
+++ b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx
@@ -0,0 +1,168 @@
+/**
+ * Unit tests for the marketplace search bar (#699): filters the currently
+ * loaded creators client-side by display name, debounced by 300ms.
+ */
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import CreatorMarketplaceInfiniteList from '@/components/common/CreatorMarketplaceInfiniteList';
+import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace';
+import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
+import type { Course } from '@/services/course.service';
+
+vi.mock('@/hooks/useInfiniteCreatorMarketplace');
+vi.mock('@/hooks/useInfiniteScroll');
+
+vi.mock('@/components/common/CreatorCard', async () => {
+ const React = await import('react');
+ return {
+ default: ({ creator }: { creator: { id: string; title: string } }) =>
+ React.createElement('article', { 'aria-label': `Creator ${creator.title}` }, creator.title),
+ };
+});
+
+const mockUseInfiniteCreatorMarketplace = vi.mocked(useInfiniteCreatorMarketplace);
+const mockUseInfiniteScroll = vi.mocked(useInfiniteScroll);
+
+function makeCreator(id: string, title: string): Course {
+ return {
+ id,
+ title,
+ description: 'desc',
+ price: 0.1,
+ instructorId: id,
+ category: 'Art',
+ level: 'BEGINNER',
+ };
+}
+
+const baseHookReturn = {
+ creators: [] as Course[],
+ hasMore: false,
+ isLoadingFirstPage: false,
+ isFetchingNextPage: false,
+ fetchNextPage: vi.fn(),
+ error: null,
+};
+
+describe('CreatorMarketplaceInfiniteList search', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ mockUseInfiniteScroll.mockReturnValue({ current: null });
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [
+ makeCreator('a', 'Alice Wonderland'),
+ makeCreator('b', 'Bob Builder'),
+ makeCreator('c', 'ALICIA Keys'),
+ ],
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders the search input above the key list', () => {
+ render();
+ expect(screen.getByPlaceholderText('Search creators')).toBeInTheDocument();
+ });
+
+ it('filters the list to matching creators within 300ms of the user stopping typing', () => {
+ render();
+
+ fireEvent.change(screen.getByPlaceholderText('Search creators'), {
+ target: { value: 'bob' },
+ });
+
+ // Before the debounce window elapses, the full list is still shown.
+ expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument();
+ expect(screen.getByLabelText('Creator Bob Builder')).toBeInTheDocument();
+ });
+
+ it('matches case-insensitively', () => {
+ render();
+
+ // "alic" is a substring of both "Alice" and "ALICIA" regardless of case.
+ fireEvent.change(screen.getByPlaceholderText('Search creators'), {
+ target: { value: 'alic' },
+ });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator ALICIA Keys')).toBeInTheDocument();
+ expect(screen.queryByLabelText('Creator Bob Builder')).not.toBeInTheDocument();
+ });
+
+ it('shows a "No results" empty state when no creators match the query', () => {
+ render();
+
+ fireEvent.change(screen.getByPlaceholderText('Search creators'), {
+ target: { value: 'zzz-no-match' },
+ });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.getByTestId('creator-marketplace-search-empty-state')).toHaveTextContent(
+ 'No results for "zzz-no-match"'
+ );
+ expect(screen.queryByRole('article')).not.toBeInTheDocument();
+ });
+
+ it('restores the full list when the input is cleared', () => {
+ render();
+
+ const input = screen.getByPlaceholderText('Search creators');
+ fireEvent.change(input, { target: { value: 'bob' } });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument();
+
+ fireEvent.change(input, { target: { value: '' } });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator Bob Builder')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator ALICIA Keys')).toBeInTheDocument();
+ });
+
+ it('resets the filter when the loaded creator set changes (new cursor/page)', () => {
+ const { rerender } = render();
+
+ fireEvent.change(screen.getByPlaceholderText('Search creators'), {
+ target: { value: 'bob' },
+ });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument();
+
+ // Simulate a new page landing, changing the identity of the creators array.
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [
+ makeCreator('a', 'Alice Wonderland'),
+ makeCreator('b', 'Bob Builder'),
+ makeCreator('c', 'ALICIA Keys'),
+ makeCreator('d', 'Dave Newcomer'),
+ ],
+ });
+ rerender();
+
+ expect(screen.getByPlaceholderText('Search creators')).toHaveValue('');
+ expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator Dave Newcomer')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx
new file mode 100644
index 00000000..3284bea7
--- /dev/null
+++ b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx
@@ -0,0 +1,188 @@
+/**
+ * Unit tests for CreatorMarketplaceInfiniteList — the IntersectionObserver-
+ * driven infinite scroll marketplace listing (#685).
+ */
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import CreatorMarketplaceInfiniteList from '@/components/common/CreatorMarketplaceInfiniteList';
+import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace';
+import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
+import type { Course } from '@/services/course.service';
+
+vi.mock('@/hooks/useInfiniteCreatorMarketplace');
+vi.mock('@/hooks/useInfiniteScroll');
+
+vi.mock('@/components/common/CreatorCard', async () => {
+ const React = await import('react');
+ return {
+ default: ({ creator }: { creator: { id: string; title: string } }) =>
+ React.createElement('article', { 'aria-label': `Creator ${creator.title}` }, creator.title),
+ };
+});
+
+const mockUseInfiniteCreatorMarketplace = vi.mocked(useInfiniteCreatorMarketplace);
+const mockUseInfiniteScroll = vi.mocked(useInfiniteScroll);
+
+function makeCreator(id: string): Course {
+ return {
+ id,
+ title: `Creator ${id}`,
+ description: 'desc',
+ price: 0.1,
+ instructorId: id,
+ category: 'Art',
+ level: 'BEGINNER',
+ };
+}
+
+const baseHookReturn = {
+ creators: [] as Course[],
+ hasMore: false,
+ isLoadingFirstPage: false,
+ isFetchingNextPage: false,
+ isRefreshing: false,
+ fetchNextPage: vi.fn(),
+ error: null,
+};
+
+describe('CreatorMarketplaceInfiniteList', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseInfiniteScroll.mockReturnValue({ current: null });
+ });
+
+ it('shows the initial skeleton while the first page is loading', () => {
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ isLoadingFirstPage: true,
+ });
+
+ render();
+
+ expect(screen.getByTestId('creator-marketplace-initial-skeleton')).toBeInTheDocument();
+ expect(screen.queryByTestId('creator-marketplace-infinite-list')).not.toBeInTheDocument();
+ });
+
+ it('renders every creator returned by the hook, with no duplicates', () => {
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [makeCreator('a'), makeCreator('b')],
+ });
+
+ render();
+
+ expect(screen.getByLabelText('Creator Creator a')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator Creator b')).toBeInTheDocument();
+ expect(screen.getAllByRole('article')).toHaveLength(2);
+ });
+
+ it('shows a skeleton row while the next page is fetching', () => {
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [makeCreator('a')],
+ isFetchingNextPage: true,
+ hasMore: true,
+ });
+
+ render();
+
+ expect(screen.getByTestId('creator-marketplace-next-page-skeleton')).toBeInTheDocument();
+ });
+
+ it('does not show the next-page skeleton once no more pages remain', () => {
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [makeCreator('a')],
+ hasMore: false,
+ isFetchingNextPage: false,
+ });
+
+ render();
+
+ expect(
+ screen.queryByTestId('creator-marketplace-next-page-skeleton')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId('creator-marketplace-sentinel')).not.toBeInTheDocument();
+ });
+
+ it('renders the sentinel and calls fetchNextPage via useInfiniteScroll when more pages remain', () => {
+ const fetchNextPage = vi.fn();
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [makeCreator('a')],
+ hasMore: true,
+ fetchNextPage,
+ });
+
+ render();
+
+ expect(screen.getByTestId('creator-marketplace-sentinel')).toBeInTheDocument();
+
+ // Simulate the sentinel scrolling into view by invoking the
+ // onLoadMore callback useInfiniteScroll was configured with.
+ const call = mockUseInfiniteScroll.mock.calls[0]![0];
+ call.onLoadMore();
+
+ expect(fetchNextPage).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables the scroll observer while a page is already loading (enabled: false)', () => {
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [makeCreator('a')],
+ hasMore: true,
+ isFetchingNextPage: true,
+ });
+
+ render();
+
+ const call = mockUseInfiniteScroll.mock.calls[0]![0];
+ expect(call.enabled).toBe(false);
+ expect(call.hasMore).toBe(true);
+ });
+
+ describe('background refresh indicator (#691)', () => {
+ it('shows a "Refreshing…" indicator while isRefreshing is true', () => {
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [makeCreator('a')],
+ isRefreshing: true,
+ });
+
+ render();
+
+ expect(
+ screen.getByTestId('creator-marketplace-refreshing-indicator')
+ ).toBeInTheDocument();
+ expect(screen.getByText('Refreshing…')).toBeInTheDocument();
+ });
+
+ it('does not show the refreshing indicator when isRefreshing is false', () => {
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [makeCreator('a')],
+ isRefreshing: false,
+ });
+
+ render();
+
+ expect(
+ screen.queryByTestId('creator-marketplace-refreshing-indicator')
+ ).not.toBeInTheDocument();
+ });
+
+ it('still renders cached creators (no spinner) while refreshing in the background', () => {
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [makeCreator('a'), makeCreator('b')],
+ isRefreshing: true,
+ isLoadingFirstPage: false,
+ });
+
+ render();
+
+ expect(screen.queryByTestId('creator-marketplace-initial-skeleton')).not.toBeInTheDocument();
+ expect(screen.getAllByRole('article')).toHaveLength(2);
+ });
+ });
+});
diff --git a/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx b/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx
new file mode 100644
index 00000000..e1b5d11a
--- /dev/null
+++ b/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx
@@ -0,0 +1,60 @@
+import type { ReactNode } from 'react';
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary';
+
+// Mock component that throws a render error on demand
+const BuggyCreatorPage = ({ shouldThrow = false }: { shouldThrow?: boolean }) => {
+ if (shouldThrow) {
+ throw new Error('Creator page render error');
+ }
+ return
Creator profile content
;
+};
+
+const renderInRouter = (ui: ReactNode) =>
+ render({ui});
+
+describe('CreatorPageErrorBoundary', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders children when no error occurs', () => {
+ renderInRouter(
+
+
+
+ );
+
+ expect(screen.getByText('Creator profile content')).toBeInTheDocument();
+ });
+
+ it('renders fallback UI instead of crashing when a creator page throws', () => {
+ // Suppress React's expected error logging for the thrown error
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ renderInRouter(
+
+
+
+ );
+
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(screen.getByText(/could not load/i)).toBeInTheDocument();
+ expect(screen.queryByText('Creator profile content')).not.toBeInTheDocument();
+ });
+
+ it('fallback includes a link back to the creator list', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ renderInRouter(
+
+
+
+ );
+
+ const link = screen.getByRole('link', { name: /back to creators/i });
+ expect(link).toHaveAttribute('href', '/creators');
+ });
+});
diff --git a/src/components/common/__tests__/CreatorProfileErrorState.test.tsx b/src/components/common/__tests__/CreatorProfileErrorState.test.tsx
new file mode 100644
index 00000000..e9a91b39
--- /dev/null
+++ b/src/components/common/__tests__/CreatorProfileErrorState.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import CreatorProfileErrorState from '../CreatorProfileErrorState';
+
+describe('CreatorProfileErrorState (#573)', () => {
+ it('renders network error title and default plain language description', () => {
+ render();
+
+ expect(
+ screen.getByRole('alert')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('Unable to load this creator profile')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/We couldn't load the latest profile details due to a network error/i)
+ ).toBeInTheDocument();
+ });
+
+ it('renders custom error message when supplied as an Error object or string', () => {
+ const customError = new Error('Failed to connect to Stellar node');
+ render();
+
+ expect(
+ screen.getByText('Failed to connect to Stellar node')
+ ).toBeInTheDocument();
+ });
+
+ it('triggers onRetry callback when retry button is clicked', async () => {
+ const user = userEvent.setup();
+ const handleRetry = vi.fn();
+ render();
+
+ const retryButton = screen.getByRole('button', { name: /retry/i });
+ expect(retryButton).toBeEnabled();
+
+ await user.click(retryButton);
+ expect(handleRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders retrying loading state when isRetrying is true', () => {
+ const handleRetry = vi.fn();
+ render(
+
+ );
+
+ const retryButton = screen.getByRole('button', { name: /retrying\.\.\./i });
+ expect(retryButton).toBeDisabled();
+ });
+
+ it('renders without error when onRetry is not provided', () => {
+ render();
+
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(screen.getByText('Unable to load this creator profile')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/CreatorProfileHeader.isOwnWallet.integration.test.tsx b/src/components/common/__tests__/CreatorProfileHeader.isOwnWallet.integration.test.tsx
new file mode 100644
index 00000000..4d23827b
--- /dev/null
+++ b/src/components/common/__tests__/CreatorProfileHeader.isOwnWallet.integration.test.tsx
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';
+
+const CREATOR_ADDRESS = '0xCreator1111111111111111111111111111111111';
+const OTHER_ADDRESS = '0xOther22222222222222222222222222222222222';
+
+const BASE_PROPS = {
+ name: 'Alex Rivers',
+ handle: 'arivers',
+ creatorId: CREATOR_ADDRESS,
+ avatarUrl: 'https://example.com/avatar.png',
+};
+
+describe('CreatorProfileHeader – isOwnWallet edit-controls visibility', () => {
+ it('hides edit controls when the connected wallet does not match the creator', () => {
+ const { container } = render(
+
+ );
+
+ expect(screen.queryByRole('button', { name: /edit bio/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /change avatar/i })).not.toBeInTheDocument();
+
+ // Confirm the controls are truly absent from the DOM, not just hidden
+ expect(container.querySelector('[aria-label="Edit bio"]')).toBeNull();
+ expect(container.querySelector('[aria-label="Change avatar"]')).toBeNull();
+ });
+
+ it('shows edit controls when the connected wallet matches the creator', () => {
+ render(
+
+ );
+
+ expect(screen.getByRole('button', { name: /edit bio/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /change avatar/i })).toBeInTheDocument();
+ });
+
+ it('matches case-insensitively so mixed-case addresses are treated as the same wallet', () => {
+ render(
+
+ );
+
+ expect(screen.getByRole('button', { name: /edit bio/i })).toBeInTheDocument();
+ });
+
+ it('toggles edit controls when the connected wallet changes', () => {
+ const { rerender } = render(
+
+ );
+
+ expect(screen.queryByRole('button', { name: /edit bio/i })).not.toBeInTheDocument();
+
+ rerender(
+
+ );
+
+ expect(screen.getByRole('button', { name: /edit bio/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /change avatar/i })).toBeInTheDocument();
+ });
+
+ it('hides edit controls when no wallet is connected', () => {
+ render(
+
+ );
+
+ expect(screen.queryByRole('button', { name: /edit bio/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /change avatar/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/CreatorProfileHeader.test.tsx b/src/components/common/__tests__/CreatorProfileHeader.test.tsx
index 64e3d867..9a0cc99e 100644
--- a/src/components/common/__tests__/CreatorProfileHeader.test.tsx
+++ b/src/components/common/__tests__/CreatorProfileHeader.test.tsx
@@ -1,10 +1,32 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';
+const PROFILE_URL = 'https://accesslayer.example/creators/arivers';
+
describe('CreatorProfileHeader', () => {
- it('renders a share/copy button for profile actions', async () => {
+ beforeEach(() => {
+ vi.stubGlobal('location', {
+ ...window.location,
+ href: PROFILE_URL,
+ });
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ },
+ });
+ vi.stubGlobal('prompt', vi.fn());
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it('renders a share button that copies the profile URL to the clipboard', async () => {
render(
{
/>
);
- // The header exposes a share/copy button for profile link actions
- const actionButton = screen.getByRole('button', {
- name: /Copy Profile Link|Copy|Share Profile|Share/i,
+ const shareButton = screen.getByRole('button', { name: /share profile/i });
+ expect(shareButton).toBeInTheDocument();
+
+ await act(async () => {
+ fireEvent.click(shareButton);
+ });
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(PROFILE_URL);
+ expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();
+ expect(screen.getByText('Copied!')).toBeInTheDocument();
+ expect(window.prompt).not.toHaveBeenCalled();
+ });
+
+ it('reverts from Copied! back to the share icon after 2 seconds', async () => {
+ vi.useFakeTimers();
+
+ render(
+
+ );
+
+ const shareButton = screen.getByRole('button', { name: /share profile/i });
+
+ await act(async () => {
+ fireEvent.click(shareButton);
+ });
+
+ expect(screen.getByText('Copied!')).toBeInTheDocument();
+
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: /share profile/i })
+ ).toBeInTheDocument();
+ });
+
+ it('falls back to window.prompt when the Clipboard API is unavailable', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: undefined,
+ });
+
+ render(
+
+ );
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /share profile/i }));
+ });
+
+ expect(window.prompt).toHaveBeenCalledWith(PROFILE_URL);
+ expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
+ });
+
+ it('falls back to window.prompt when clipboard writeText rejects', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: {
+ writeText: vi.fn().mockRejectedValue(new Error('denied')),
+ },
+ });
+
+ render(
+
+ );
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /share profile/i }));
});
- expect(actionButton).toBeInTheDocument();
- fireEvent.click(actionButton);
- // clicking should not throw and button remains in the document
- expect(actionButton).toBeInTheDocument();
+ expect(window.prompt).toHaveBeenCalledWith(PROFILE_URL);
});
});
diff --git a/src/components/common/__tests__/EmptyState.test.tsx b/src/components/common/__tests__/EmptyState.test.tsx
new file mode 100644
index 00000000..5138e32d
--- /dev/null
+++ b/src/components/common/__tests__/EmptyState.test.tsx
@@ -0,0 +1,138 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
+import { describe, expect, it, vi } from 'vitest';
+import EmptyState from '../EmptyState';
+
+const LocationDisplay = () => {
+ const location = useLocation();
+ return
{location.pathname}
;
+};
+
+describe('EmptyState', () => {
+ it('renders message and CTA when both are provided and asserts both are visible', () => {
+ render(
+
+
+
+ );
+
+ expect(
+ screen.getByText('No records found in this context')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: 'Go to Marketplace' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: 'Go to Marketplace' })
+ ).toHaveAttribute('href', '/marketplace');
+ });
+
+ it('renders with only a message (no CTA) and asserts the CTA element is absent', () => {
+ render();
+
+ expect(screen.getByText('No activity recorded yet')).toBeInTheDocument();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('clicks the CTA and asserts navigation to the provided href is triggered', () => {
+ render(
+
+
+
+ }
+ />
+ Creators Page
}
+ />
+
+
+
+ );
+
+ expect(screen.getByTestId('location-display')).toHaveTextContent('/');
+
+ const ctaLink = screen.getByRole('link', { name: 'Explore Creators' });
+ fireEvent.click(ctaLink);
+
+ expect(screen.getByTestId('creators-page')).toBeInTheDocument();
+ expect(screen.getByTestId('location-display')).toHaveTextContent('/creators');
+ });
+
+ it('renders with an empty message string and asserts the component renders without error', () => {
+ const { container } = render();
+
+ expect(container).toBeInTheDocument();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('supports cta specified as string with ctaHref prop', () => {
+ render(
+
+
+
+ );
+
+ const link = screen.getByRole('link', { name: 'Browse Tokens' });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', '/tokens');
+ });
+
+ it('triggers onClick handler when cta is an action button without href', () => {
+ const handleAction = vi.fn();
+ render(
+
+ );
+
+ const button = screen.getByRole('button', { name: 'Clear Filters' });
+ expect(button).toBeInTheDocument();
+
+ fireEvent.click(button);
+ expect(handleAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('maintains backward compatibility with title, description, image, and onReset props', () => {
+ const handleReset = vi.fn();
+ render(
+
+ );
+
+ expect(
+ screen.getByRole('status', { name: 'No creators found' })
+ ).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
+ 'No creators found'
+ );
+ expect(
+ screen.getByText('We couldn\'t find any creators matching your search.')
+ ).toBeInTheDocument();
+
+ const resetButton = screen.getByRole('button', { name: 'Reset search results' });
+ expect(resetButton).toBeInTheDocument();
+
+ fireEvent.click(resetButton);
+ expect(handleReset).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/common/__tests__/KeyHolderList.test.tsx b/src/components/common/__tests__/KeyHolderList.test.tsx
new file mode 100644
index 00000000..a17f837d
--- /dev/null
+++ b/src/components/common/__tests__/KeyHolderList.test.tsx
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen, within } from '@testing-library/react';
+import KeyHolderList from '@/components/common/KeyHolderList';
+import type { KeyHolder } from '@/utils/keyHolderRanking.utils';
+
+function holder(id: string, displayName: string, keyCount: number): KeyHolder {
+ return { id, displayName, keyCount };
+}
+
+describe('KeyHolderList', () => {
+ it('renders holders sorted descending by key count', () => {
+ render(
+
+ );
+
+ const rows = screen.getAllByTestId('key-holder-row');
+ expect(rows.map(row => within(row).getByText(/^(Alice|Bob|Cara)$/).textContent)).toEqual([
+ 'Bob',
+ 'Cara',
+ 'Alice',
+ ]);
+ });
+
+ it('renders sequential rank numbers 1, 2, 3', () => {
+ render(
+
+ );
+
+ const ranks = screen.getAllByTestId('key-holder-rank').map(el => el.textContent);
+ expect(ranks).toEqual(['1', '2', '3']);
+ });
+
+ it('shows a single holder with all keys at 100% share', () => {
+ render();
+
+ expect(screen.getByTestId('key-holder-share')).toHaveTextContent('100%');
+ });
+
+ it('renders tied holders at the same rank', () => {
+ render(
+
+ );
+
+ const ranks = screen.getAllByTestId('key-holder-rank').map(el => el.textContent);
+ expect(ranks).toEqual(['1', '1', '3']);
+ });
+
+ it('renders share percentages for each holder that are internally consistent with key counts', () => {
+ render(
+
+ );
+
+ const shares = screen.getAllByTestId('key-holder-share').map(el => el.textContent);
+ expect(shares).toEqual(['70%', '30%']);
+ });
+
+ it('shows an empty state when there are no holders', () => {
+ render();
+
+ expect(screen.getByTestId('key-holder-list-empty')).toBeInTheDocument();
+ expect(screen.queryByTestId('key-holder-list')).not.toBeInTheDocument();
+ });
+
+ it('sets an accessible label on each rank badge', () => {
+ render();
+
+ expect(screen.getByLabelText('Rank 1')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/NotificationBell.test.tsx b/src/components/common/__tests__/NotificationBell.test.tsx
new file mode 100644
index 00000000..76301168
--- /dev/null
+++ b/src/components/common/__tests__/NotificationBell.test.tsx
@@ -0,0 +1,326 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router';
+import NotificationBell from '@/components/common/NotificationBell';
+import { useNotifications } from '@/hooks/useNotifications';
+import type { Notification } from '@/services/notification.service';
+
+vi.mock('@/hooks/useNotifications');
+vi.mock('react-router', async () => {
+ const actual = await vi.importActual('react-router');
+ return { ...actual, useNavigate: vi.fn(() => vi.fn()) };
+});
+
+const mockUseNotifications = vi.mocked(useNotifications);
+
+function makeNotification(
+ id: string,
+ overrides: Partial = {}
+): Notification {
+ return {
+ id,
+ type: 'new_follower',
+ message: `Notification message ${id}`,
+ createdAt: new Date(Date.now() - 60_000).toISOString(),
+ read: false,
+ href: `/creator/${id}`,
+ ...overrides,
+ };
+}
+
+function renderBell(userId = 'user_abc') {
+ return render(
+
+
+
+ );
+}
+
+describe('NotificationBell', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── Badge visibility ─────────────────────────────────────────────────────
+
+ it('does not show the unread badge when unreadCount is 0', () => {
+ mockUseNotifications.mockReturnValue({
+ recent: [],
+ unreadCount: 0,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+
+ expect(
+ screen.queryByTestId('notification-unread-badge')
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows the unread badge with the correct count when unreadCount > 0', () => {
+ mockUseNotifications.mockReturnValue({
+ recent: [makeNotification('n1')],
+ unreadCount: 3,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+
+ const badge = screen.getByTestId('notification-unread-badge');
+ expect(badge).toBeInTheDocument();
+ expect(badge).toHaveTextContent('3');
+ });
+
+ it('caps the badge display at 99+ when unreadCount > 99', () => {
+ mockUseNotifications.mockReturnValue({
+ recent: [],
+ unreadCount: 150,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+
+ expect(screen.getByTestId('notification-unread-badge')).toHaveTextContent(
+ '99+'
+ );
+ });
+
+ // ── Accessible label ─────────────────────────────────────────────────────
+
+ it('gives the bell button an accessible label mentioning unread count', () => {
+ mockUseNotifications.mockReturnValue({
+ recent: [],
+ unreadCount: 2,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+
+ expect(
+ screen.getByRole('button', { name: /notifications — 2 unread/i })
+ ).toBeInTheDocument();
+ });
+
+ it('uses a generic label when unreadCount is 0', () => {
+ mockUseNotifications.mockReturnValue({
+ recent: [],
+ unreadCount: 0,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+
+ expect(
+ screen.getByRole('button', { name: /^notifications$/i })
+ ).toBeInTheDocument();
+ });
+
+ // ── Dropdown content ─────────────────────────────────────────────────────
+
+ it('opens the dropdown when the bell is clicked', async () => {
+ const user = userEvent.setup();
+ mockUseNotifications.mockReturnValue({
+ recent: [],
+ unreadCount: 0,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+
+ expect(
+ screen.getByTestId('notification-dropdown')
+ ).toBeInTheDocument();
+ });
+
+ it('shows the empty state when there are no notifications', async () => {
+ const user = userEvent.setup();
+ mockUseNotifications.mockReturnValue({
+ recent: [],
+ unreadCount: 0,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+
+ expect(
+ screen.getByTestId('notification-empty-state')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('No notifications yet')
+ ).toBeInTheDocument();
+ });
+
+ it('shows skeleton rows while loading', async () => {
+ const user = userEvent.setup();
+ mockUseNotifications.mockReturnValue({
+ recent: [],
+ unreadCount: 0,
+ isLoading: true,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+
+ expect(screen.getByTestId('notification-loading')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('notification-empty-state')
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders up to five notification items in the dropdown', async () => {
+ const user = userEvent.setup();
+ const recent = [
+ makeNotification('n1'),
+ makeNotification('n2'),
+ makeNotification('n3'),
+ makeNotification('n4'),
+ makeNotification('n5'),
+ ];
+ mockUseNotifications.mockReturnValue({
+ recent,
+ unreadCount: 5,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+
+ for (const n of recent) {
+ expect(
+ screen.getByTestId(`notification-item-${n.id}`)
+ ).toBeInTheDocument();
+ }
+ });
+
+ it('shows the notification message text for each item', async () => {
+ const user = userEvent.setup();
+ const recent = [makeNotification('n1', { message: 'Alice followed you' })];
+ mockUseNotifications.mockReturnValue({
+ recent,
+ unreadCount: 1,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+
+ expect(screen.getByText('Alice followed you')).toBeInTheDocument();
+ });
+
+ // ── Marking read ─────────────────────────────────────────────────────────
+
+ it('calls markAsRead with the notification id when an item is clicked', async () => {
+ const user = userEvent.setup();
+ const markAsRead = vi.fn();
+ const recent = [makeNotification('n1')];
+ mockUseNotifications.mockReturnValue({
+ recent,
+ unreadCount: 1,
+ isLoading: false,
+ isError: false,
+ markAsRead,
+ });
+
+ renderBell();
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+ await user.click(screen.getByTestId('notification-item-n1'));
+
+ expect(markAsRead).toHaveBeenCalledWith('n1');
+ });
+
+ // ── View all link ────────────────────────────────────────────────────────
+
+ it('renders a "View all" link in the dropdown', async () => {
+ const user = userEvent.setup();
+ mockUseNotifications.mockReturnValue({
+ recent: [],
+ unreadCount: 0,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ });
+
+ renderBell();
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+
+ expect(
+ screen.getByTestId('notification-view-all')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('notification-view-all')
+ ).toHaveTextContent('View all');
+ });
+
+ // ── Badge decrement ──────────────────────────────────────────────────────
+
+ it('badge count decrements after a notification is marked read', async () => {
+ const user = userEvent.setup();
+ const markAsRead = vi.fn();
+
+ // Start with count = 2
+ mockUseNotifications.mockReturnValue({
+ recent: [
+ makeNotification('n1', { read: false }),
+ makeNotification('n2', { read: false }),
+ ],
+ unreadCount: 2,
+ isLoading: false,
+ isError: false,
+ markAsRead,
+ });
+
+ const { rerender } = renderBell();
+
+ expect(screen.getByTestId('notification-unread-badge')).toHaveTextContent(
+ '2'
+ );
+
+ // Simulate the hook returning updated state after markAsRead is called
+ mockUseNotifications.mockReturnValue({
+ recent: [
+ makeNotification('n1', { read: true }),
+ makeNotification('n2', { read: false }),
+ ],
+ unreadCount: 1,
+ isLoading: false,
+ isError: false,
+ markAsRead,
+ });
+
+ rerender(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('notification-unread-badge')
+ ).toHaveTextContent('1');
+ });
+ });
+});
diff --git a/src/components/common/__tests__/PriceSparkline.test.tsx b/src/components/common/__tests__/PriceSparkline.test.tsx
new file mode 100644
index 00000000..996d3aa9
--- /dev/null
+++ b/src/components/common/__tests__/PriceSparkline.test.tsx
@@ -0,0 +1,88 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { PriceSparkline } from '../PriceSparkline';
+
+describe('PriceSparkline', () => {
+ describe('rendering', () => {
+ it('renders an SVG path for 7 data points', () => {
+ const { container } = render(
+
+ );
+
+ const svg = container.querySelector('svg');
+ expect(svg).toBeInTheDocument();
+
+ const path = container.querySelector('path');
+ expect(path).toBeInTheDocument();
+ expect(path).toHaveAttribute('d');
+ });
+
+ it('renders without error for 1 data point and does not produce a line path', () => {
+ const { container } = render(
+
+ );
+
+ const svg = container.querySelector('svg');
+ expect(svg).toBeInTheDocument();
+
+ const path = container.querySelector('path');
+ expect(path).not.toBeInTheDocument();
+ });
+
+ it('renders nothing for 0 data points', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('line colour', () => {
+ it('returns green for a rising history (last > first)', () => {
+ const { container } = render(
+
+ );
+
+ const path = container.querySelector('path');
+ expect(path).toHaveAttribute('stroke', '#34d399');
+ });
+
+ it('returns red for a falling history (last < first)', () => {
+ const { container } = render(
+
+ );
+
+ const path = container.querySelector('path');
+ expect(path).toHaveAttribute('stroke', '#ef4444');
+ });
+
+ it('returns neutral for a flat history (last === first)', () => {
+ const { container } = render(
+
+ );
+
+ const path = container.querySelector('path');
+ expect(path).toHaveAttribute('stroke', 'currentColor');
+ });
+
+ it('returns neutral for a single-element history', () => {
+ const { container } = render(
+
+ );
+
+ const circle = container.querySelector('circle');
+ expect(circle).toHaveAttribute('fill', 'currentColor');
+ });
+
+ it('returns neutral for an empty history (renders nothing)', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.innerHTML).toBe('');
+ });
+ });
+});
diff --git a/src/components/common/__tests__/SectionErrorBoundary.test.tsx b/src/components/common/__tests__/SectionErrorBoundary.test.tsx
index 9e024e35..36191385 100644
--- a/src/components/common/__tests__/SectionErrorBoundary.test.tsx
+++ b/src/components/common/__tests__/SectionErrorBoundary.test.tsx
@@ -78,4 +78,86 @@ describe('SectionErrorBoundary', () => {
expect(alert).toHaveStyle('min-height: 500px');
expect(alert).toHaveClass('custom-class');
});
+
+ it('renders a custom title when provided', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ render(
+
+
+
+ );
+
+ expect(
+ screen.getByText('Chart unavailable — try refreshing')
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
+ });
+
+ it('omits the description paragraph when description is an empty string', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ render(
+
+
+
+ );
+
+ expect(
+ screen.getByText('Chart unavailable — try refreshing')
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(/we encountered an error/i)
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders a custom description when provided', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Custom explanation text')).toBeInTheDocument();
+ });
+
+ it('does not retry automatically after repeated failures (manual retry only)', () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { rerender } = render(
+
+
+
+ );
+
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+
+ // Re-rendering with the same throwing child (e.g. parent re-render)
+ // must NOT clear the error state on its own — only clicking Retry does.
+ rerender(
+
+
+
+ );
+
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ });
+
+ it('logs the caught error via componentDidCatch when an error occurs', () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ render(
+
+
+
+ );
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('bonding curve chart'),
+ expect.any(Error),
+ expect.anything()
+ );
+ });
});
diff --git a/src/components/common/__tests__/TradeDialog.a11y.test.tsx b/src/components/common/__tests__/TradeDialog.a11y.test.tsx
new file mode 100644
index 00000000..d8d30ff0
--- /dev/null
+++ b/src/components/common/__tests__/TradeDialog.a11y.test.tsx
@@ -0,0 +1,179 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import TradeDialog from '@/components/common/TradeDialog';
+
+/**
+ * Keyboard accessibility for the buy key modal (#700). TradeDialog is built
+ * on Radix's Dialog primitive, which natively provides focus trapping,
+ * Escape-to-close, and focus-return-to-trigger-on-close. These tests verify
+ * that contract holds for THIS dialog's actual controls (amount input,
+ * Cancel, Confirm) rather than assuming the primitive is wired correctly,
+ * since `onOpenAutoFocus`/`onEscapeKeyDown` overrides in TradeDialog.tsx
+ * could silently break any of these if changed carelessly.
+ */
+describe('TradeDialog keyboard accessibility', () => {
+ /**
+ * Renders a real trigger button + TradeDialog together (mirroring how
+ * LandingPage.tsx wires them: a button's onClick opens the dialog), so
+ * focus-return-to-trigger can be verified against the actual element
+ * that had focus when the dialog opened, not a synthetic stand-in.
+ */
+ function DialogWithTrigger(
+ overrides: Partial> = {}
+ ) {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>
+ Open buy dialog
+
+
+ >
+ );
+ }
+
+ it('sets initial focus on the quantity input when the modal opens', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Open buy dialog' }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+ });
+
+ it('traps focus inside the modal: Tab from Confirm (the last control) cycles back into the dialog', async () => {
+ const user = userEvent.setup();
+ render();
+
+ // Capture the trigger element while it's still queryable — Radix
+ // marks background content aria-hidden once the dialog is open, so
+ // it can no longer be found via getByRole afterwards (correct
+ // behavior: background content should be invisible to assistive
+ // tech while a modal is open).
+ const trigger = screen.getByRole('button', { name: 'Open buy dialog' });
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+
+ screen.getByTestId('trade-dialog-confirm').focus();
+ expect(screen.getByTestId('trade-dialog-confirm')).toHaveFocus();
+
+ await user.tab();
+
+ // Focus must land back inside the dialog (Radix's focus guard sends it
+ // to the first focusable element), never escape to the trigger button
+ // behind the modal — which would be reachable if the trap were broken.
+ expect(trigger).not.toHaveFocus();
+ expect(document.activeElement).not.toBe(document.body);
+ expect(document.activeElement).not.toBe(document.documentElement);
+ });
+
+ it('pressing Escape closes the modal', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+
+ await user.keyboard('{Escape}');
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('does not close on Escape while a submission is in flight', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ render();
+
+ await user.keyboard('{Escape}');
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
+ it('activates the confirm button via Enter when focused', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render();
+
+ screen.getByTestId('trade-dialog-confirm').focus();
+ await user.keyboard('{Enter}');
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('activates the confirm button via Space when focused', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render();
+
+ screen.getByTestId('trade-dialog-confirm').focus();
+ await user.keyboard(' ');
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('is not activatable via keyboard while disabled by an invalid amount', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render();
+
+ const amountInput = screen.getByTestId('trade-dialog-amount');
+ await user.clear(amountInput);
+ amountInput.blur();
+
+ screen.getByTestId('trade-dialog-confirm').focus();
+ await user.keyboard('{Enter}');
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it('returns focus to the trigger button when the modal closes', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const trigger = screen.getByRole('button', { name: 'Open buy dialog' });
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+
+ await user.keyboard('{Escape}');
+
+ await waitFor(() => {
+ expect(trigger).toHaveFocus();
+ });
+ });
+
+ it('returns focus to the trigger when closed via the Cancel button', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const trigger = screen.getByRole('button', { name: 'Open buy dialog' });
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+
+ await user.click(screen.getByTestId('trade-dialog-cancel'));
+
+ await waitFor(() => {
+ expect(trigger).toHaveFocus();
+ });
+ });
+});
diff --git a/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx b/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx
deleted file mode 100644
index 98f6e30a..00000000
--- a/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, expect, it, vi } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-import TradeDialog from '@/components/common/TradeDialog';
-import { BUY_QUANTITY_BOUNDS } from '@/constants/fees';
-
-describe('TradeDialog buy quantity clamping and notes', () => {
- function renderDialog(
- overrides: Partial> = {}
- ) {
- return render(
-
- );
- }
-
- it('clamps values below minimum to minimum on blur', () => {
- renderDialog();
- const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
-
- // Input below minimum
- fireEvent.change(input, { target: { value: '0' } });
- fireEvent.blur(input);
-
- expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString());
- expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent(
- `Quantity adjusted to the minimum of ${BUY_QUANTITY_BOUNDS.MIN_QTY}.`
- );
- });
-
- it('clamps values above maximum to maximum on blur', () => {
- renderDialog();
- const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
-
- // Input above maximum
- fireEvent.change(input, { target: { value: '150' } });
- fireEvent.blur(input);
-
- expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString());
- expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent(
- `Quantity adjusted to the maximum of ${BUY_QUANTITY_BOUNDS.MAX_QTY}.`
- );
- });
-
- it('rounds decimal inputs on blur', () => {
- renderDialog();
- const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
-
- // Decimal input
- fireEvent.change(input, { target: { value: '5.6' } });
- fireEvent.blur(input);
-
- expect(input.value).toBe('6');
- expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent(
- 'Quantity rounded to 6.'
- );
- });
-
- it('does not clamp or show a note for valid quantities on blur', () => {
- renderDialog();
- const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
-
- // Valid quantity
- fireEvent.change(input, { target: { value: '10' } });
- fireEvent.blur(input);
-
- expect(input.value).toBe('10');
- expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument();
- });
-
- it('clears the adjustment note on input change', () => {
- renderDialog();
- const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
-
- // Trigger adjustment note
- fireEvent.change(input, { target: { value: '0' } });
- fireEvent.blur(input);
- expect(screen.getByTestId('buy-qty-adjustment-note')).toBeInTheDocument();
-
- // Change input
- fireEvent.change(input, { target: { value: '5' } });
- expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument();
- });
-});
diff --git a/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx b/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx
new file mode 100644
index 00000000..54578501
--- /dev/null
+++ b/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx
@@ -0,0 +1,123 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import TradeDialog from '@/components/common/TradeDialog';
+import { BUY_QUANTITY_BOUNDS } from '@/constants/fees';
+
+describe('TradeDialog – clampBuyQuantity integration', () => {
+ function renderDialog(
+ overrides: Partial> = {}
+ ) {
+ return render(
+
+ );
+ }
+
+ it('applies clampBuyQuantity to amount input field – values below minimum are clamped to MIN_QTY', () => {
+ renderDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // Input value below minimum
+ fireEvent.change(input, { target: { value: '0' } });
+ fireEvent.blur(input);
+
+ // Verify the value is clamped to minimum
+ expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString());
+ });
+
+ it('applies clampBuyQuantity to amount input field – values above maximum are clamped to MAX_QTY', () => {
+ renderDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // Input value above maximum
+ fireEvent.change(input, { target: { value: '150' } });
+ fireEvent.blur(input);
+
+ // Verify the value is clamped to maximum
+ expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString());
+ });
+
+ it('applies clampBuyQuantity to amount input field – decimal values are rounded to nearest integer', () => {
+ renderDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // Input decimal value
+ fireEvent.change(input, { target: { value: '5.6' } });
+ fireEvent.blur(input);
+
+ // Verify the value is rounded
+ expect(input.value).toBe('6');
+ });
+
+ it('applies clampBuyQuantity to amount input field – valid values within bounds remain unchanged', () => {
+ renderDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // Input valid value within bounds
+ fireEvent.change(input, { target: { value: '10' } });
+ fireEvent.blur(input);
+
+ // Verify the value remains unchanged
+ expect(input.value).toBe('10');
+ });
+
+ it('applies clampBuyQuantity to amount input field – negative values are clamped to MIN_QTY', () => {
+ renderDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // Input negative value
+ fireEvent.change(input, { target: { value: '-5' } });
+ fireEvent.blur(input);
+
+ // Verify the value is clamped to minimum
+ expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString());
+ });
+
+ it('applies clampBuyQuantity to amount input field – empty input is handled gracefully', () => {
+ renderDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // Clear the input
+ fireEvent.change(input, { target: { value: '' } });
+ fireEvent.blur(input);
+
+ // Verify empty input shows validation error (not clamped, as it's invalid)
+ expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent(
+ 'Please enter an amount.'
+ );
+ });
+
+ it('applies clampBuyQuantity to amount input field – boundary values (MIN_QTY and MAX_QTY) are accepted', () => {
+ renderDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // Test minimum boundary
+ fireEvent.change(input, { target: { value: BUY_QUANTITY_BOUNDS.MIN_QTY.toString() } });
+ fireEvent.blur(input);
+ expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString());
+
+ // Test maximum boundary
+ fireEvent.change(input, { target: { value: BUY_QUANTITY_BOUNDS.MAX_QTY.toString() } });
+ fireEvent.blur(input);
+ expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString());
+ });
+
+ it('applies clampBuyQuantity to amount input field – sell side also respects clamping', () => {
+ renderDialog({ side: 'sell', availableHoldings: 50 });
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // Input value above maximum on sell side
+ fireEvent.change(input, { target: { value: '150' } });
+ fireEvent.blur(input);
+
+ // Verify the value is clamped to maximum
+ expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString());
+ });
+});
diff --git a/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx b/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx
new file mode 100644
index 00000000..b6fe1b37
--- /dev/null
+++ b/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx
@@ -0,0 +1,123 @@
+/**
+ * Unit tests for the sell confirmation dialog correctly displaying the
+ * estimated XLM payout and disabling the confirm button while the sell
+ * mutation is pending (#692).
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import TradeDialog from '@/components/common/TradeDialog';
+import * as keyPriceDisplay from '@/utils/keyPriceDisplay.utils';
+
+describe('TradeDialog – sell payout display (#692)', () => {
+ function renderSellDialog(
+ overrides: Partial> = {}
+ ) {
+ return render(
+
+ );
+ }
+
+ it('displays the correct estimated XLM payout for a given sell quantity', () => {
+ renderSellDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ // keyPriceStroops=500_000 * quantity=2 = 1_000_000 stroops = 0.1 XLM
+ fireEvent.change(input, { target: { value: '2' } });
+
+ expect(screen.getByText(/Estimated proceeds/i)).toBeInTheDocument();
+ expect(screen.getByText('0.1 XLM')).toBeInTheDocument();
+ });
+
+ it('updates the displayed payout as the sell quantity changes', () => {
+ renderSellDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '1' } });
+ expect(screen.getByText('0.05 XLM')).toBeInTheDocument();
+
+ fireEvent.change(input, { target: { value: '4' } });
+ expect(screen.getByText('0.2 XLM')).toBeInTheDocument();
+ expect(screen.queryByText('0.05 XLM')).not.toBeInTheDocument();
+ });
+
+ it('shows "Estimated proceeds unavailable" when the payout cannot be computed', () => {
+ renderSellDialog({ keyPriceStroops: null, currentSupply: null });
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '2' } });
+
+ expect(screen.getByText('Estimated proceeds unavailable')).toBeInTheDocument();
+ });
+
+ it('calls the bonding curve sell calculation with the correct arguments', () => {
+ const spy = vi.spyOn(keyPriceDisplay, 'estimateSellProceeds');
+ renderSellDialog({ keyPriceStroops: 500_000, currentSupply: 100 });
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '3' } });
+
+ expect(spy).toHaveBeenCalledWith(500_000, 100, 3);
+ spy.mockRestore();
+ });
+
+ it('does not compute a payout on the buy side', () => {
+ renderSellDialog({ side: 'buy' });
+
+ expect(screen.queryByText(/Estimated proceeds/i)).not.toBeInTheDocument();
+ });
+
+ it('disables the confirm button while the sell mutation is pending', () => {
+ renderSellDialog({ isSubmitting: true });
+
+ expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled();
+ });
+
+ it('re-enables the confirm button once the sell mutation is no longer pending', () => {
+ const { rerender } = renderSellDialog({ isSubmitting: true });
+ expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled();
+
+ rerender(
+
+ );
+
+ expect(screen.getByTestId('trade-dialog-confirm')).not.toBeDisabled();
+ });
+
+ it('disables cancel and prevents closing while the sell mutation is pending', () => {
+ const onOpenChange = vi.fn();
+ renderSellDialog({ isSubmitting: true, onOpenChange });
+
+ expect(screen.getByTestId('trade-dialog-cancel')).toBeDisabled();
+ });
+
+ it('calls onConfirm with the parsed sell quantity when confirm is clicked', () => {
+ const onConfirm = vi.fn();
+ renderSellDialog({ onConfirm });
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '5' } });
+ fireEvent.click(screen.getByTestId('trade-dialog-confirm'));
+
+ expect(onConfirm).toHaveBeenCalledWith(5);
+ });
+});
diff --git a/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx b/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx
new file mode 100644
index 00000000..62f8b886
--- /dev/null
+++ b/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * Unit tests for the sell quantity input rejecting values exceeding the
+ * wallet's current holding (#657).
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import TradeDialog from '@/components/common/TradeDialog';
+
+describe('TradeDialog – sell quantity exceeds-holding validation', () => {
+ function renderSellDialog(
+ overrides: Partial> = {}
+ ) {
+ return render(
+
+ );
+ }
+
+ it('shows an exceeds-balance validation error when the quantity exceeds the holding', () => {
+ renderSellDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '4' } });
+
+ const error = screen.getByTestId('trade-dialog-amount-error');
+ expect(error).toBeInTheDocument();
+ expect(error).toHaveTextContent("You can't sell more than your holdings (3 keys).");
+ });
+
+ it('clears the validation error when the quantity is brought back within range', () => {
+ renderSellDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '4' } });
+ expect(screen.getByTestId('trade-dialog-amount-error')).toBeInTheDocument();
+
+ fireEvent.change(input, { target: { value: '3' } });
+ expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument();
+ });
+
+ it('shows a distinct zero-quantity error, not the exceeds-balance error', () => {
+ renderSellDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '0' } });
+
+ const error = screen.getByTestId('trade-dialog-amount-error');
+ expect(error).toBeInTheDocument();
+ expect(error).toHaveTextContent('Amount must be greater than zero.');
+ expect(error).not.toHaveTextContent('holdings');
+ });
+
+ it('accepts a quantity equal to the holding without any error', () => {
+ renderSellDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '3' } });
+
+ expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument();
+ });
+
+ it('accepts quantity 1 (well within holding) without any error', () => {
+ renderSellDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '1' } });
+
+ expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument();
+ });
+
+ it('disables the confirm button while the exceeds-balance error is present', () => {
+ renderSellDialog();
+ const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '4' } });
+
+ expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled();
+ });
+});
diff --git a/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx b/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx
new file mode 100644
index 00000000..1a54d214
--- /dev/null
+++ b/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx
@@ -0,0 +1,76 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/react';
+import TransactionHistory, {
+ type Transaction,
+} from '@/components/common/TransactionHistory';
+
+const CREATOR_A_ID = '101';
+const CREATOR_B_ID = '202';
+
+const twoCreatorTrades: Transaction[] = [
+ {
+ id: 'trade-a',
+ type: 'buy',
+ creatorId: CREATOR_A_ID,
+ creatorHandle: 'arivers',
+ amount: 2,
+ price: 0.05,
+ timestamp: Date.now() - 1000 * 60 * 15,
+ txHash: '0xaaaa...1111',
+ status: 'completed',
+ },
+ {
+ id: 'trade-b',
+ type: 'sell',
+ creatorId: CREATOR_B_ID,
+ creatorHandle: 'schen_dev',
+ amount: 1,
+ price: 0.12,
+ timestamp: Date.now() - 1000 * 60 * 45,
+ txHash: '0xbbbb...2222',
+ status: 'completed',
+ },
+];
+
+beforeEach(() => {
+ vi.stubEnv('NODE_ENV', 'test');
+ localStorage.clear();
+});
+
+describe('TransactionHistory – activity feed creator handles (integration)', () => {
+ it('renders the correct creator handle for each trade entry', () => {
+ render();
+
+ expect(
+ screen.getByTestId('activity-creator-handle-trade-a')
+ ).toHaveTextContent('@arivers');
+ expect(
+ screen.getByTestId('activity-creator-handle-trade-b')
+ ).toHaveTextContent('@schen_dev');
+ });
+
+ it('shows different handles for entries from different creators', () => {
+ render();
+
+ const handleA = screen.getByTestId('activity-creator-handle-trade-a');
+ const handleB = screen.getByTestId('activity-creator-handle-trade-b');
+
+ expect(handleA.textContent).not.toBe(handleB.textContent);
+ expect(handleA).toHaveTextContent('@arivers');
+ expect(handleB).toHaveTextContent('@schen_dev');
+ });
+
+ it('does not expose raw creator IDs in the rendered output', () => {
+ const { container } = render(
+
+ );
+
+ const buyRow = screen.getByTestId('activity-item-buy');
+ const sellRow = screen.getByTestId('activity-item-sell');
+
+ expect(within(buyRow).queryByText(CREATOR_A_ID)).not.toBeInTheDocument();
+ expect(within(sellRow).queryByText(CREATOR_B_ID)).not.toBeInTheDocument();
+ expect(container.textContent).not.toContain(CREATOR_A_ID);
+ expect(container.textContent).not.toContain(CREATOR_B_ID);
+ });
+});
diff --git a/src/components/common/__tests__/TransactionHistory.order.integration.test.tsx b/src/components/common/__tests__/TransactionHistory.order.integration.test.tsx
new file mode 100644
index 00000000..4b63091c
--- /dev/null
+++ b/src/components/common/__tests__/TransactionHistory.order.integration.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import TransactionHistory, {
+ type Transaction,
+} from '@/components/common/TransactionHistory';
+
+const trades: Transaction[] = [
+ {
+ id: 'ledger-1000',
+ type: 'buy',
+ creatorId: 'creator-c',
+ creatorHandle: 'nova',
+ amount: 1,
+ price: 8,
+ timestamp: 1_000,
+ txHash: '0xccc',
+ status: 'completed',
+ },
+ {
+ id: 'ledger-3000',
+ type: 'buy',
+ creatorId: 'creator-a',
+ creatorHandle: 'atlas',
+ amount: 5,
+ price: 12,
+ timestamp: 3_000,
+ txHash: '0xaaa',
+ status: 'completed',
+ },
+ {
+ id: 'ledger-2000',
+ type: 'sell',
+ creatorId: 'creator-b',
+ creatorHandle: 'beacon',
+ amount: 2,
+ price: 10,
+ timestamp: 2_000,
+ txHash: '0xbbb',
+ status: 'completed',
+ },
+];
+
+beforeEach(() => {
+ vi.stubEnv('NODE_ENV', 'test');
+ localStorage.clear();
+});
+
+describe('TransactionHistory – wallet activity order and type labels (integration)', () => {
+ it('renders trades in descending chronological order with correct buy and sell labels', () => {
+ render();
+
+ const rows = screen
+ .getAllByTestId(/activity-item-/)
+ .map(row => row.textContent ?? '');
+
+ expect(rows).toHaveLength(3);
+ expect(rows[0]).toContain('@atlas');
+ expect(rows[1]).toContain('@beacon');
+ expect(rows[2]).toContain('@nova');
+
+ const buyRows = screen.getAllByTestId('activity-item-buy');
+ const sellRows = screen.getAllByTestId('activity-item-sell');
+
+ expect(within(buyRows[0]).getByText('Buy')).toBeInTheDocument();
+ expect(within(buyRows[1]).getByText('Buy')).toBeInTheDocument();
+ expect(within(sellRows[0]).getByText('Sell')).toBeInTheDocument();
+ expect(buyRows).toHaveLength(2);
+ expect(sellRows).toHaveLength(1);
+ expect(rows[0]).toContain('5 keys');
+ expect(rows[1]).toContain('2 keys');
+ expect(rows[2]).toContain('1 keys');
+ });
+});
diff --git a/src/components/common/__tests__/TransactionHistory.test.tsx b/src/components/common/__tests__/TransactionHistory.test.tsx
new file mode 100644
index 00000000..9eb61797
--- /dev/null
+++ b/src/components/common/__tests__/TransactionHistory.test.tsx
@@ -0,0 +1,69 @@
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import TransactionHistory from '@/components/common/TransactionHistory';
+
+// TransactionHistory reads localStorage during initialisation.
+beforeEach(() => {
+ vi.stubEnv('NODE_ENV', 'test');
+ localStorage.clear();
+});
+
+describe('TransactionHistory – activity feed sign prefix (integration)', () => {
+ it('buy event amount is prefixed with a minus sign', () => {
+ render();
+
+ // There is at least one buy activity item in the sample data.
+ const buyItems = screen.getAllByTestId('activity-item-buy');
+ expect(buyItems.length).toBeGreaterThan(0);
+
+ // For each buy row the visible amount must start with "-".
+ buyItems.forEach(item => {
+ const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
+ expect(amountEl).not.toBeNull();
+ expect(amountEl!.textContent).toMatch(/^-/);
+ });
+ });
+
+ it('sell event amount is prefixed with a plus sign', () => {
+ render();
+
+ const sellItems = screen.getAllByTestId('activity-item-sell');
+ expect(sellItems.length).toBeGreaterThan(0);
+
+ sellItems.forEach(item => {
+ const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
+ expect(amountEl).not.toBeNull();
+ expect(amountEl!.textContent).toMatch(/^\+/);
+ });
+ });
+
+ it('XLM suffix is present on both buy and sell amounts', () => {
+ render();
+
+ const allItems = [
+ ...screen.getAllByTestId('activity-item-buy'),
+ ...screen.getAllByTestId('activity-item-sell'),
+ ];
+
+ allItems.forEach(item => {
+ const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
+ expect(amountEl).not.toBeNull();
+ expect(amountEl!.textContent).toMatch(/XLM$/);
+ });
+ });
+
+ it('renders relative time label correctly for recent versus older events (#487)', () => {
+ render();
+
+ // SAMPLE_TRANSACTIONS has one from 30 minutes ago, one from 5 days ago (120 hours).
+ // We expect the first one to say "30 min ago" and the latter to say "5 days ago".
+ // Actually, let's just check for 'min ago' and 'days ago'
+ expect(screen.getAllByText(/min ago/)).not.toHaveLength(0);
+ expect(screen.getAllByText(/days ago/)).not.toHaveLength(0);
+
+ // Ensure raw ISO timestamp is not shown
+ const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
+ const rawTimestamps = screen.queryAllByText(isoRegex);
+ expect(rawTimestamps).toHaveLength(0);
+ });
+});
diff --git a/src/components/common/__tests__/WalletActivityFeed.infiniteScroll.integration.test.tsx b/src/components/common/__tests__/WalletActivityFeed.infiniteScroll.integration.test.tsx
new file mode 100644
index 00000000..bbfe21d5
--- /dev/null
+++ b/src/components/common/__tests__/WalletActivityFeed.infiniteScroll.integration.test.tsx
@@ -0,0 +1,350 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import React from 'react';
+
+import WalletActivityFeed from '@/components/common/WalletActivityFeed';
+import * as walletActivityService from '@/services/walletActivity.service';
+import type { WalletActivityTrade } from '@/services/walletActivity.service';
+
+const WALLET_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5';
+
+// jsdom does not implement IntersectionObserver — install a recording mock
+// that captures the callback the hook registers so the test can drive it
+// deterministically without relying on real viewport geometry.
+interface CapturedObserver {
+ callback: IntersectionObserverCallback;
+ observe: ReturnType;
+ disconnect: ReturnType;
+ unobserve: ReturnType;
+}
+
+const observers: CapturedObserver[] = [];
+
+class MockIntersectionObserver {
+ callback: IntersectionObserverCallback;
+ observe = vi.fn();
+ disconnect = vi.fn();
+ unobserve = vi.fn();
+ constructor(cb: IntersectionObserverCallback) {
+ this.callback = cb;
+ observers.push({
+ callback: cb,
+ observe: this.observe,
+ disconnect: this.disconnect,
+ unobserve: this.unobserve,
+ });
+ }
+}
+
+const page1Trades: WalletActivityTrade[] = [
+ {
+ id: 'A',
+ type: 'buy',
+ creatorId: '1',
+ creatorHandle: 'arivers',
+ amount: 2,
+ price: 0.05,
+ timestamp: Date.now() - 1000 * 60 * 5,
+ txHash: '0xaaaa...0011',
+ status: 'completed',
+ },
+ {
+ id: 'B',
+ type: 'sell',
+ creatorId: '2',
+ creatorHandle: 'schen_dev',
+ amount: 1,
+ price: 0.12,
+ timestamp: Date.now() - 1000 * 60 * 45,
+ txHash: '0xbbbb...0022',
+ status: 'completed',
+ },
+ {
+ id: 'C',
+ type: 'buy',
+ creatorId: '3',
+ creatorHandle: 'mthorne',
+ amount: 4,
+ price: 0.08,
+ timestamp: Date.now() - 1000 * 60 * 90,
+ txHash: '0xcccc...0033',
+ status: 'completed',
+ },
+];
+
+const page2Trades: WalletActivityTrade[] = [
+ {
+ id: 'D',
+ type: 'buy',
+ creatorId: '4',
+ creatorHandle: 'evance_design',
+ amount: 3,
+ price: 0.04,
+ timestamp: Date.now() - 1000 * 60 * 60 * 2,
+ txHash: '0xdddd...0044',
+ status: 'completed',
+ },
+ {
+ id: 'E',
+ type: 'sell',
+ creatorId: '5',
+ creatorHandle: 'dkojo_beats',
+ amount: 2,
+ price: 0.15,
+ timestamp: Date.now() - 1000 * 60 * 60 * 4,
+ txHash: '0xeeee...0055',
+ status: 'completed',
+ },
+ {
+ id: 'F',
+ type: 'buy',
+ creatorId: '6',
+ creatorHandle: 'yuki_s',
+ amount: 6,
+ price: 0.07,
+ timestamp: Date.now() - 1000 * 60 * 60 * 8,
+ txHash: '0xffff...0066',
+ status: 'completed',
+ },
+];
+
+const setupFetchMock = () =>
+ vi
+ .spyOn(walletActivityService, 'fetchWalletActivityPage')
+ .mockResolvedValueOnce({ trades: page1Trades, nextPage: 2 })
+ .mockResolvedValueOnce({ trades: page2Trades, nextPage: null });
+
+const renderFeed = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ });
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+ const utils = render(, {
+ wrapper,
+ });
+ return { ...utils, queryClient };
+};
+
+const simulateIntersection = (isIntersecting: boolean) => {
+ const obs = observers[observers.length - 1];
+ if (!obs) throw new Error('No IntersectionObserver registered yet');
+ act(() => {
+ obs.callback(
+ [{ isIntersecting } as IntersectionObserverEntry],
+ {} as IntersectionObserver
+ );
+ });
+};
+
+describe('WalletActivityFeed infinite scroll integration (#677)', () => {
+ beforeEach(() => {
+ observers.length = 0;
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ // Acceptance #1: page 1 trades visible on initial load
+ it('renders page 1 trades on initial load', async () => {
+ const fetchSpy = setupFetchMock();
+
+ renderFeed();
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('activity-creator-handle-A')
+ ).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByTestId('activity-creator-handle-A')
+ ).toHaveTextContent('@arivers');
+ expect(
+ screen.getByTestId('activity-creator-handle-B')
+ ).toHaveTextContent('@schen_dev');
+ expect(
+ screen.getByTestId('activity-creator-handle-C')
+ ).toHaveTextContent('@mthorne');
+
+ // Page 2 trades must not be visible yet
+ expect(
+ screen.queryByTestId('activity-creator-handle-D')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('activity-creator-handle-E')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('activity-creator-handle-F')
+ ).not.toBeInTheDocument();
+
+ // Only page 1 was fetched
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(fetchSpy).toHaveBeenCalledWith(WALLET_ADDRESS, 1);
+ });
+
+ // Acceptance #2: scroll triggers next page fetch
+ it('fetches the next page when the sentinel scrolls into view', async () => {
+ const fetchSpy = setupFetchMock();
+
+ renderFeed();
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('activity-creator-handle-A')
+ ).toBeInTheDocument();
+ });
+
+ // The sentinel should have observed itself and registered an observer
+ await waitFor(() => {
+ expect(observers.length).toBeGreaterThan(0);
+ });
+
+ simulateIntersection(true);
+
+ await waitFor(() => {
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
+ });
+
+ expect(fetchSpy).toHaveBeenNthCalledWith(2, WALLET_ADDRESS, 2);
+ });
+
+ // Acceptance #3: page 2 trades appended below page 1 trades
+ it('appends page 2 trades below page 1 trades after fetching', async () => {
+ setupFetchMock();
+
+ const { container } = renderFeed();
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('activity-creator-handle-A')
+ ).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(observers.length).toBeGreaterThan(0);
+ });
+ simulateIntersection(true);
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('activity-creator-handle-D')
+ ).toBeInTheDocument();
+ });
+
+ // All six trades now visible
+ expect(
+ screen.getByTestId('activity-creator-handle-A')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('activity-creator-handle-B')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('activity-creator-handle-C')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('activity-creator-handle-D')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('activity-creator-handle-E')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId('activity-creator-handle-F')
+ ).toBeInTheDocument();
+
+ // DOM order preserves page 1 above page 2 so older trades render
+ // below the more recent ones — this is the "appended below" promise.
+ const handleTestIds = Array.from(
+ container.querySelectorAll(
+ '[data-testid^="activity-creator-handle-"]'
+ )
+ ).map(el => el.dataset.testid ?? '');
+ expect(handleTestIds).toEqual([
+ 'activity-creator-handle-A',
+ 'activity-creator-handle-B',
+ 'activity-creator-handle-C',
+ 'activity-creator-handle-D',
+ 'activity-creator-handle-E',
+ 'activity-creator-handle-F',
+ ]);
+ });
+
+ // Acceptance #4: no duplicate trades in the combined list
+ it('does not render duplicate trade ids when paginated data overlaps', async () => {
+ // Simulate a backend that paginates by timestamp boundary and includes
+ // 'C' in both pages. The component should still surface each trade id
+ // exactly once.
+ const overlapPage2: WalletActivityTrade[] = [
+ { ...page1Trades[2] }, // duplicate of 'C'
+ page2Trades[0],
+ page2Trades[1],
+ page2Trades[2],
+ ];
+
+ vi.spyOn(walletActivityService, 'fetchWalletActivityPage')
+ .mockResolvedValueOnce({ trades: page1Trades, nextPage: 2 })
+ .mockResolvedValueOnce({ trades: overlapPage2, nextPage: null });
+
+ renderFeed();
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('activity-creator-handle-A')
+ ).toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(observers.length).toBeGreaterThan(0);
+ });
+ simulateIntersection(true);
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('activity-creator-handle-D')
+ ).toBeInTheDocument();
+ });
+
+ // Each unique id appears in exactly one rendered row.
+ expect(
+ screen.getAllByTestId('activity-creator-handle-A')
+ ).toHaveLength(1);
+ expect(
+ screen.getAllByTestId('activity-creator-handle-B')
+ ).toHaveLength(1);
+ expect(
+ screen.getAllByTestId('activity-creator-handle-C')
+ ).toHaveLength(1);
+ expect(
+ screen.getAllByTestId('activity-creator-handle-D')
+ ).toHaveLength(1);
+ expect(
+ screen.getAllByTestId('activity-creator-handle-E')
+ ).toHaveLength(1);
+ expect(
+ screen.getAllByTestId('activity-creator-handle-F')
+ ).toHaveLength(1);
+
+ // Validate the rendered handle for 'C' carries the right creator
+ // so we know the deduplicated row is the original page-1 entry,
+ // not the duplicate appended by page 2.
+ expect(
+ screen.getByTestId('activity-creator-handle-C')
+ ).toHaveTextContent('@mthorne');
+
+ // Across the entire document there are six handles — one per unique
+ // trade id, regardless of the duplicate appearing in both pages.
+ const allHandles = document.body.querySelectorAll(
+ '[data-testid^="activity-creator-handle-"]'
+ );
+ expect(allHandles.length).toBe(6);
+ });
+});
diff --git a/src/components/common/__tests__/WalletConnectionPopover.test.tsx b/src/components/common/__tests__/WalletConnectionPopover.test.tsx
new file mode 100644
index 00000000..e30c5f25
--- /dev/null
+++ b/src/components/common/__tests__/WalletConnectionPopover.test.tsx
@@ -0,0 +1,305 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import WalletConnectionPopover from '@/components/common/WalletConnectionPopover';
+
+// ── wagmi mocks ───────────────────────────────────────────────────────────────
+vi.mock('wagmi', () => ({
+ useAccount: vi.fn(),
+ useConnect: vi.fn(),
+ useDisconnect: vi.fn(),
+}));
+
+// ── stall detection — keep it quiet in unit tests ────────────────────────────
+vi.mock('@/hooks/useWalletConnectionStallDetection', () => ({
+ useWalletConnectionStallDetection: vi.fn(() => false),
+ WALLET_CONNECTION_AD_BLOCKER_MESSAGE: 'Ad blocker detected.',
+}));
+
+import { useAccount, useConnect, useDisconnect } from 'wagmi';
+
+const mockUseAccount = vi.mocked(useAccount);
+const mockUseConnect = vi.mocked(useConnect);
+const mockUseDisconnect = vi.mocked(useDisconnect);
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+function setupDisconnected() {
+ mockUseAccount.mockReturnValue({
+ address: undefined,
+ isConnected: false,
+ } as ReturnType);
+
+ mockUseConnect.mockReturnValue({
+ connect: vi.fn(),
+ connectAsync: vi.fn(),
+ connectors: [{ id: 'mock', name: 'Mock Wallet' }] as ReturnType['connectors'],
+ error: null,
+ isPending: false,
+ variables: undefined,
+ status: 'idle',
+ data: undefined,
+ failureCount: 0,
+ failureReason: null,
+ isError: false,
+ isIdle: true,
+ isPaused: false,
+ isSuccess: false,
+ reset: vi.fn(),
+ submittedAt: 0,
+ } as ReturnType);
+
+ mockUseDisconnect.mockReturnValue({
+ disconnect: vi.fn(),
+ disconnectAsync: vi.fn(),
+ variables: undefined,
+ status: 'idle',
+ data: undefined,
+ error: null,
+ failureCount: 0,
+ failureReason: null,
+ isError: false,
+ isIdle: true,
+ isPending: false,
+ isPaused: false,
+ isSuccess: false,
+ reset: vi.fn(),
+ } as ReturnType);
+}
+
+function setupConnected(address = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12') {
+ mockUseAccount.mockReturnValue({
+ address,
+ isConnected: true,
+ } as ReturnType);
+
+ mockUseConnect.mockReturnValue({
+ connect: vi.fn(),
+ connectAsync: vi.fn(),
+ connectors: [],
+ error: null,
+ isPending: false,
+ variables: undefined,
+ status: 'idle',
+ data: undefined,
+ failureCount: 0,
+ failureReason: null,
+ isError: false,
+ isIdle: true,
+ isPaused: false,
+ isSuccess: false,
+ reset: vi.fn(),
+ submittedAt: 0,
+ } as ReturnType);
+
+ mockUseDisconnect.mockReturnValue({
+ disconnect: vi.fn(),
+ disconnectAsync: vi.fn(),
+ variables: undefined,
+ status: 'idle',
+ data: undefined,
+ error: null,
+ failureCount: 0,
+ failureReason: null,
+ isError: false,
+ isIdle: true,
+ isPending: false,
+ isPaused: false,
+ isSuccess: false,
+ reset: vi.fn(),
+ } as ReturnType);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('WalletConnectionPopover — disconnected state', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupDisconnected();
+ });
+
+ it('renders the Connect Wallet button when no wallet is connected', () => {
+ render();
+
+ expect(
+ screen.getByRole('button', { name: /connect wallet/i })
+ ).toBeInTheDocument();
+ });
+
+ it('does not render the wallet address trigger when no wallet is connected', () => {
+ render();
+
+ expect(
+ screen.queryByTestId('wallet-address-trigger')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not render a truncated address anywhere when no wallet is connected', () => {
+ render();
+
+ // No element should contain a hex address pattern
+ expect(screen.queryByText(/0x[0-9a-fA-F]/)).not.toBeInTheDocument();
+ });
+
+ it('does not render the disconnect button when no wallet is connected', () => {
+ render();
+
+ expect(
+ screen.queryByRole('button', { name: /disconnect/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it('calls connect with the primary connector when the button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockConnect = vi.fn();
+ const fakeConnector = { id: 'mock', name: 'Mock Wallet' };
+
+ mockUseConnect.mockReturnValue({
+ connect: mockConnect,
+ connectAsync: vi.fn(),
+ connectors: [fakeConnector] as ReturnType['connectors'],
+ error: null,
+ isPending: false,
+ variables: undefined,
+ status: 'idle',
+ data: undefined,
+ failureCount: 0,
+ failureReason: null,
+ isError: false,
+ isIdle: true,
+ isPaused: false,
+ isSuccess: false,
+ reset: vi.fn(),
+ submittedAt: 0,
+ } as ReturnType);
+
+ render();
+ await user.click(screen.getByRole('button', { name: /connect wallet/i }));
+
+ expect(mockConnect).toHaveBeenCalledOnce();
+ expect(mockConnect).toHaveBeenCalledWith({ connector: fakeConnector });
+ });
+});
+
+describe('WalletConnectionPopover — connected state', () => {
+ const FULL_ADDRESS = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12';
+ // shortenAddress defaults: first 6 chars + '...' + last 4 chars
+ const TRUNCATED_ADDRESS = '0xAbCd...f12'.slice(0, 6) + '...' + FULL_ADDRESS.slice(-4);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupConnected(FULL_ADDRESS);
+ });
+
+ it('renders the truncated address trigger when a wallet is connected', () => {
+ render();
+
+ const trigger = screen.getByTestId('wallet-address-trigger');
+ expect(trigger).toBeInTheDocument();
+ expect(trigger).toHaveTextContent(TRUNCATED_ADDRESS);
+ });
+
+ it('does not render the Connect Wallet button when a wallet is connected', () => {
+ render();
+
+ expect(
+ screen.queryByRole('button', { name: /connect wallet/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not render the full wallet address as plain text in the DOM', () => {
+ render();
+
+ // The full address must not appear as a text node anywhere
+ // (it only lives inside the CopyField input value attribute,
+ // which is not a text node and is only visible after opening the popover)
+ const allText = document.body.textContent ?? '';
+ expect(allText).not.toContain(FULL_ADDRESS);
+ });
+
+ it('opens the popover and reveals the copy button and disconnect button', async () => {
+ const user = userEvent.setup();
+ render();
+
+ // Before opening the popover, the content is not in the DOM
+ expect(
+ screen.queryByTestId('disconnect-wallet-button')
+ ).not.toBeInTheDocument();
+
+ // Open the popover
+ await user.click(screen.getByTestId('wallet-address-trigger'));
+
+ // Disconnect button should now be visible
+ expect(
+ screen.getByRole('button', { name: /disconnect/i })
+ ).toBeInTheDocument();
+
+ // Copy button for the wallet address should be visible
+ expect(
+ screen.getByRole('button', { name: /copy wallet address/i })
+ ).toBeInTheDocument();
+ });
+
+ it('shows the wallet address in the copy field input after opening the popover', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId('wallet-address-trigger'));
+
+ // The CopyField renders an input with aria-label="Wallet address"
+ const addressInput = screen.getByRole('textbox', {
+ name: /wallet address/i,
+ });
+ expect(addressInput).toHaveValue(FULL_ADDRESS);
+ });
+
+ it('calls disconnect when the disconnect button is clicked', async () => {
+ const user = userEvent.setup();
+ const mockDisconnect = vi.fn();
+
+ mockUseDisconnect.mockReturnValue({
+ disconnect: mockDisconnect,
+ disconnectAsync: vi.fn(),
+ variables: undefined,
+ status: 'idle',
+ data: undefined,
+ error: null,
+ failureCount: 0,
+ failureReason: null,
+ isError: false,
+ isIdle: true,
+ isPending: false,
+ isPaused: false,
+ isSuccess: false,
+ reset: vi.fn(),
+ } as ReturnType);
+
+ render();
+
+ await user.click(screen.getByTestId('wallet-address-trigger'));
+ await user.click(screen.getByRole('button', { name: /disconnect/i }));
+
+ expect(mockDisconnect).toHaveBeenCalledOnce();
+ });
+
+ it('the truncated address trigger does not expose the full address as text', () => {
+ render();
+
+ const trigger = screen.getByTestId('wallet-address-trigger');
+ // The text content of the trigger is the truncated address only
+ expect(trigger.textContent).toBe(TRUNCATED_ADDRESS);
+ expect(trigger.textContent).not.toContain(FULL_ADDRESS);
+ });
+
+ it('the popover content is not in the DOM before the trigger is clicked', () => {
+ render();
+
+ // Radix Popover uses a portal — content is absent until opened
+ expect(
+ screen.queryByRole('button', { name: /disconnect/i })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: /copy wallet address/i })
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/home/CreatorSpotlight.tsx b/src/components/home/CreatorSpotlight.tsx
new file mode 100644
index 00000000..6837a10e
--- /dev/null
+++ b/src/components/home/CreatorSpotlight.tsx
@@ -0,0 +1,228 @@
+import { useQuery } from '@tanstack/react-query';
+import { Users, Star, ArrowRight } from 'lucide-react';
+import { useEffect, useRef } from 'react';
+import { Link } from 'react-router';
+import { courseService, type Course } from '@/services/course.service';
+import { queryKeys } from '@/lib/queryKeys';
+import { STROOPS_PER_XLM } from '@/constants/stellar';
+import { formatHolderCount } from '@/utils/numberFormat.utils';
+import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils';
+import { cn } from '@/lib/utils';
+import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
+
+/* ------------------------------------------------------------------ */
+/* Skeleton */
+/* ------------------------------------------------------------------ */
+
+const skeletonBlockClass =
+ 'rounded-lg bg-white/12 skeleton-shimmer motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15';
+
+function SpotlightSkeletonCard() {
+ return (
+
- )}
{/* #301: subtle inline stale-data warning that
appears once the cached creator data is past
the 60s freshness window. The hook drives a
@@ -908,17 +1146,50 @@ function LandingPage() {
className="self-start"
/>
)}
+
+ {`You've reached the end — ${formatNumber(filteredCreators.length)} creator${filteredCreators.length === 1 ? '' : 's'} shown.`}
+
+ )}
+ >
+ ) : (
+ <>
+ {/* Invisible sentinel that triggers the next
+ page load once it scrolls into view. The
+ visible button beneath it is the accessible
+ fallback for keyboard users and browsers
+ without IntersectionObserver support. */}
+ {hasMoreInfinite && (
+
- Load more creators
-
-
- )}
- {safePage >= totalPages - 1 && (
-
- {`You've reached the end — ${formatNumber(filteredCreators.length)} creator${filteredCreators.length === 1 ? '' : 's'} shown.`}
-