diff --git a/.env.example b/.env.example index b7957baa..ef3afc1e 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,33 @@ -VITE_BACKEND_URL=/api -# UTM settings for shared profile links (optional) -# Set any of these to enable UTM parameters on shared profile URLs. -VITE_UTM_SOURCE=twitter -VITE_UTM_MEDIUM=social -VITE_UTM_CAMPAIGN=share_profile -#VITE_UTM_TERM= -#VITE_UTM_CONTENT= +# Access Layer Client — environment variables +# Copy this file to `.env` and adjust values as needed: +# cp .env.example .env +# All client-exposed variables must be prefixed with VITE_ (see https://vitejs.dev/guide/env-and-mode). +# See CONTRIBUTING.md ("Environment variables") for which vars are required vs optional. + +# --- Required (sensible defaults provided) --- + +# Base URL for the backend API. Use the local backend during development. VITE_BACKEND_URL=http://localhost:3000/api/v1 + +# Chain ID selected by default on load. 84532 = Base Sepolia testnet. VITE_DEFAULT_CHAIN_ID=84532 + +# RPC URL for a local Anvil node (chain 31337). Used when developing against a local chain. VITE_ANVIL_RPC_URL=http://127.0.0.1:8545 + +# RPC URL for the Base Sepolia testnet (chain 84532). The public default works out of the box. VITE_BASE_SEPOLIA_RPC_URL=https://sepolia.base.org + +# --- Optional --- + +# RPC URL for the Ethereum Sepolia testnet (chain 11155111). Get one from Alchemy, Infura, or another provider. VITE_SEPOLIA_RPC_URL= + +# RPC URL for Ethereum mainnet (chain 1). Only needed when testing against mainnet. VITE_MAINNET_RPC_URL= -# UTM settings for shared profile links (optional) -# Set any of these to enable UTM parameters on shared profile URLs. -# Remove or comment out to disable UTM tracking. -# Example configuration: +# UTM parameters appended to shared profile links (optional). +# Set any of these to enable UTM tracking; remove or leave blank to disable. VITE_UTM_SOURCE=accesslayer VITE_UTM_MEDIUM=share VITE_UTM_CAMPAIGN=profile-sharing diff --git a/.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/docs/adding-page-routes.md b/docs/adding-page-routes.md new file mode 100644 index 00000000..519c70de --- /dev/null +++ b/docs/adding-page-routes.md @@ -0,0 +1,273 @@ +# Adding a New Page Route + +This guide explains how to add a new route to the Access Layer client. It covers where routes are registered, the file naming convention for page components, and how to mark a route as auth-protected. + +--- + +## Where routes are registered + +Every route in the client is declared in a **single source of truth** at the top of `src/App.tsx`. The router is built once with `createBrowserRouter([...])` and passed to ``. To add a new route, append a `{ path, element }` entry to that array. + +```tsx +// src/App.tsx +import { createBrowserRouter, RouterProvider } from 'react-router'; +import HomePage from './pages/HomePage'; +import NotFoundPage from './pages/NotFoundPage'; +import AboutPage from './pages/AboutPage'; // ← new import + +const router = createBrowserRouter([ + { + path: '/', + element: , + }, + { + path: '/about', // ← new public route + element: , + }, + { + path: '*', // catch-all stays last + element: , + }, +]); +``` + +Key things to know: + +- **Order matters** only for the `*` catch-all — keep it as the **last entry** so it does not shadow real routes. +- **Imports** for new page components live at the top of `src/App.tsx` alongside the existing ones. Use the relative `'./pages/Page'` path shown above, matching the existing imports. +- **Do not** create a second router or wrap the app in another ``. The router configured here is the only one. +- Nested routes for sub-pages (for example `/creators/:handle/keys`) are added the same way — just declare the full pattern on each entry. The current client uses flat routes only. + +--- + +## File naming convention for page components + +| What | Convention | Example | +| ------------------ | ----------------------------------------------- | ------------------------------------- | +| File location | `src/pages/` | `src/pages/HomePage.tsx` | +| File name | PascalCase + `Page` suffix | `AboutPage.tsx` | +| Exported component | Default export of a function named `Page` | `export default function AboutPage()` | + +The component itself uses `export default function Page()` — not a named export, and not an arrow const. This keeps imports straightforward and matches every existing page in `src/pages/`: + +``` +src/pages/ +├── HomePage.tsx // registered as '/' +├── MarketingPage.tsx // exists on disk, not yet registered +├── LandingPage.tsx // exists on disk, not yet registered +└── NotFoundPage.tsx // registered as '*' (catch-all) +``` + +A few pages exist as files but are not currently wired into the router in `src/App.tsx`. They are kept on disk because they are planned routes waiting on the marketplace flows to land. If you need one of them live, follow this guide to register it like any other page. + +### What a page component looks like + +Top-level page components take **no props**. They own their own layout, fetching, and state. A minimal page is just a function that returns JSX: + +```tsx +// src/pages/AboutPage.tsx +export default function AboutPage() { + return ( +
+

+ About Access Layer +

+

+ Access Layer is a Stellar-native creator keys marketplace. +

+
+ ); +} +``` + +Conventions to follow: + +- **Default export only.** Named exports break the import in `App.tsx`. +- **One component per file.** Don't lump multiple pages into a single file. +- **No props.** Reach for URL params via `useParams()` from `react-router` instead of prop drilling. +- **Accessibility:** wrap content in a single `
` landmark and use semantic headings (`

` for the page title). +- **Match the project styling.** Use the existing `font-grotesque`, `font-jakarta`, and dark-on-blue palette referenced throughout the codebase. Don't introduce new global styles for a single page. +- **Use the `@/` alias for component imports.** The project-wide path alias `@/` maps to `src/` (configured via Vite + TypeScript path mapping). Use it freely from any component file; reserve short relative paths like `'../pages/Page'` for tight sibling-file imports. + +--- + +## Public vs auth-protected routes + +There is **no existing `RequireAuth` / `ProtectedRoute` wrapper** in the codebase yet. Every route registered today is public. When a feature needs auth gating, follow the pattern below — and ship the wrapper component with that feature, since the client doesn't have a standalone auth-guard component yet. + +### Recommended pattern + +1. Create a small wrapper component, conventionally `src/components/auth/RequireAuth.tsx` (create the `auth/` subfolder if it doesn't exist yet). +2. Inside the wrapper, check the appropriate auth state — wagmi's `useAccount` for wallet-gated flows, or `authService.isAuthenticated()` for email/login-gated flows. +3. While the state is resolving, render a lightweight placeholder (the existing `PendingOnboardingPlaceholder` makes a good model). +4. If unauthenticated, render a redirect or a connect-wallet CTA — do **not** render the protected page. +5. Wrap the protected page's `element` with the wrapper inside `App.tsx`. + +```tsx +// src/components/auth/RequireAuth.tsx +import type { ReactNode } from 'react'; +import { useAccount } from 'wagmi'; +import { Navigate, useLocation } from 'react-router'; +import PendingOnboardingPlaceholder from '@/components/common/PendingOnboardingPlaceholder'; + +interface RequireAuthProps { + children: ReactNode; +} + +export default function RequireAuth({ children }: RequireAuthProps) { + const { isConnected, isConnecting } = useAccount(); + const location = useLocation(); + + // Show the placeholder while a fresh wallet connection is in flight + // so the user isn't redirected to "/" mid-connect. Once it resolves, + // isConnected flips to true and we render the protected page below. + // Note: isReconnecting is intentionally NOT in this branch — a + // reconnect of a prior session keeps the user authenticated, so + // rendering the protected page during a reconnect is fine. + if (isConnecting) { + return ; + } + + if (!isConnected) { + // Send unauthenticated users back to the homepage while preserving + // the path they tried to reach so a future flow can deep-link them + // back here after they connect. + return ; + } + + return <>{children}; +} +``` + +Then wire the protection in `src/App.tsx` by wrapping the element rather than registering the page directly: + +```tsx +// src/App.tsx +import RequireAuth from './components/auth/RequireAuth'; +import DashboardPage from './pages/DashboardPage'; + +const router = createBrowserRouter([ + { path: '/', element: }, + { + path: '/dashboard', + // Public route → element: + // Protected route → wrap in : + element: ( + + + + ), + }, + { path: '*', element: }, +]); +``` + +### Choosing which auth check to use + +| Use case | Check | +| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| The page reads or writes Stellar assets (keys, trades, portfolio) | `useAccount().isConnected` from wagmi | +| The page reads or writes user-profile data via the backend REST API | `authService.isAuthenticated()` | +| Both | Call both. Render the placeholder until both resolve; redirect if either is false. | + +Don't mix the two states in a single component without documenting which is the source of truth for that page — that's the kind of bug that's hard to spot in review. + +### Known repo state (read before adding a protected route) + +Two pre-existing repo facts you should know before shipping a wagmi-based guard: + +1. **`` is not currently mounted above `` in `src/main.tsx`.** As of writing this guide, `main.tsx` renders `` directly inside ``. Any wagmi hook — including `useAccount` inside `RequireAuth` — will throw at runtime because the `WagmiProvider` context is missing. Wiring `` here is an app-level change and should be tracked separately; reference the tracking issue in your route PR description rather than embedding the wiring fix in your route PR. +2. **Wagmi has a transient `isConnecting` state** while a fresh wallet handshake is in flight. During this window `isConnected` is `false`, but redirecting the user mid-connect would bounce them away. The example above handles this by rendering `PendingOnboardingPlaceholder` for the `isConnecting` branch — copy that pattern verbatim. (Note: `isReconnecting` is _not_ included in that branch — a reconnect of a prior, already-authenticated session keeps the user authenticated, so rendering the protected page during a reconnect is fine and avoids a UX flash.) + +If your guard only uses `authService.isAuthenticated()` (no wagmi hooks), neither caveat applies — the helper reads `localStorage` directly. + +--- + +## Worked example — adding a new public page + +This walks a contributor end-to-end through adding `AboutPage` at `/about`. The page is public, so no `RequireAuth` wrapper is involved. + +### 1. Create the page component + +Add a new file at `src/pages/AboutPage.tsx`: + +```tsx +// src/pages/AboutPage.tsx +import { Link } from 'react-router'; +import { Button } from '@/components/ui/button'; + +export default function AboutPage() { + return ( +
+

+ About Access Layer +

+

+ Access Layer is a Stellar-native creator keys marketplace built on + the open AccessLayer protocol. +

+ +
+ +
+
+ ); +} +``` + +### 2. Register the route + +Add the import and a new entry to the router array in `src/App.tsx`: + +```tsx +// src/App.tsx +import HomePage from './pages/HomePage'; +import NotFoundPage from './pages/NotFoundPage'; +import AboutPage from './pages/AboutPage'; // ← added + +const router = createBrowserRouter([ + { path: '/', element: }, + { path: '/about', element: }, // ← added + { path: '*', element: }, +]); +``` + +### 3. Link to it from another page + +Open `src/pages/HomePage.tsx`, import `Link`, and add a `` to the new route: + +```tsx +import { Link } from 'react-router'; + +// inside the JSX you return + + About this project +; +``` + +### 4. Verify locally + +```bash +pnpm dev # visit http://localhost:5173/about +pnpm lint +pnpm build +``` + +If `pnpm build` succeeds and `/about` renders the page with a working "Back to marketplace" link, you are done. + +--- + +## Key files at a glance + +| File | Purpose | +| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/App.tsx` | The single source of truth for routing — the only place routes are registered. | +| `src/pages/` | Folder where every page component lives. One file per page, PascalCase + `Page` suffix, default export. | +| `src/components/auth/RequireAuth.tsx` | The recommended wrapper for auth-protected pages. Create it the first time a protected route is added. | +| `src/main.tsx` | Mounts `` inside React's `createRoot`. **Currently does not wrap `` in `Web3Provider`** — must be updated the first time a wagmi-based route guard lands. | +| `src/providers/Web3Provider.tsx` | Provides `WagmiProvider` + `QueryClientProvider`. Any auth wrapper that uses `useAccount` only works after this provider is mounted above the router in `main.tsx`. | +| `CONTRIBUTING.md` | High-level project conventions — read this alongside this guide. | +| `docs/api-layer.md` | Sibling guide covering how to add backend/API endpoints. | +| [docs/shared-components.md](file:///Users/marvellous/Desktop/accesslayer-client/docs/shared-components.md) | Guide to the project's shared UI component library, key props, and styling. | diff --git a/docs/api-layer.md b/docs/api-layer.md new file mode 100644 index 00000000..28f50a70 --- /dev/null +++ b/docs/api-layer.md @@ -0,0 +1,254 @@ +# Client API Layer Conventions + +This document explains how the client's API layer is structured, how errors are handled, and how to add a new server call end-to-end. + +--- + +## Folder structure + +All API service files live in `src/services/`: + +``` +src/services/ +├── api.service.ts # Base class — all services extend this +├── auth.service.ts # Authentication endpoints +└── course.service.ts # Creator / course data endpoints +``` + +Each file exports a **singleton instance** of its service class. + +--- + +## File and class naming convention + +| What | Convention | Example | +| ------------------ | --------------------- | ------------------- | +| File name | `.service.ts` | `wallet.service.ts` | +| Class name | `Service` | `WalletService` | +| Exported singleton | `Service` | `walletService` | + +Every service class **extends `BaseApiService`** from `api.service.ts`, which provides: + +- A pre-configured Axios instance (`this.api`) pointing at `VITE_BACKEND_URL` +- Automatic token refresh on `401 TOKEN_EXPIRED` responses +- A shared `handleError(error)` method that normalises any thrown value to `ApiError` + +--- + +## Error handling + +Every service method wraps its Axios call in a `try/catch` and re-throws via `this.handleError`: + +```ts +async getWalletHoldings(address: string): Promise { + try { + const response = await this.api.get>( + `/wallets/${address}/holdings` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } +} +``` + +`handleError` always returns an `ApiError` instance with: + +| Field | Type | Description | +| ---------- | ------------------------------- | ------------------------------------------ | +| `message` | `string` | Human-readable error message | +| `status` | `number` | HTTP status code; `0` for network failures | +| `response` | `APIErrorResponse \| undefined` | Full server error payload when available | + +Callers can check `error instanceof ApiError` and inspect `error.status` for branching logic. + +--- + +## How to add a new endpoint + +### 1. Add the method to the relevant service file + +Open `src/services/.service.ts` (or create a new one if the domain is new). Add a method that: + +1. Calls `this.api.get/post/patch/delete` +2. Extracts `response.data.data` +3. Re-throws any error via `this.handleError` + +```ts +// src/services/wallet.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +export interface Holding { + creatorId: string; + quantity: number; + priceStroops: number; +} + +class WalletService extends BaseApiService { + async getHoldings(address: string): Promise { + try { + const response = await this.api.get>( + `/wallets/${address}/holdings` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const walletService = new WalletService(); +``` + +### 2. Define a query key in `src/lib/queryKeys.ts` + +Add an entry for the new endpoint so all hooks that reference the same data use an identical cache key: + +```ts +// src/lib/queryKeys.ts +wallet: { + holdings: (address: string) => ['wallet', address, 'holdings'] as const, + // ... +}, +``` + +### 3. Write a React Query hook + +`QueryClientProvider` is already wired up in `src/providers/Web3Provider.tsx` — no setup changes needed. + +```ts +// src/hooks/useWalletHoldings.ts +import { useQuery } from '@tanstack/react-query'; +import { walletService } from '@/services/wallet.service'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useWalletHoldings(address: string | undefined) { + return useQuery({ + queryKey: queryKeys.wallet.holdings(address ?? ''), + queryFn: () => walletService.getHoldings(address!), + enabled: Boolean(address), + }); +} +``` + +### 4. Consume the hook in a component + +```tsx +import { useWalletHoldings } from '@/hooks/useWalletHoldings'; + +function HoldingsList({ address }: { address: string }) { + const { data: holdings, isLoading, error } = useWalletHoldings(address); + + if (isLoading) return

Loading…

; + if (error) return

Failed to load holdings.

; + + return ( +
    + {holdings?.map(h => ( +
  • + {h.creatorId} — {h.quantity} keys +
  • + ))} +
+ ); +} +``` + +--- + +## Worked example — full GET call + +The following shows a complete end-to-end flow for a `GET /wallets/:address/holdings` endpoint. + +### Service method + +```ts +// src/services/wallet.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +export interface Holding { + creatorId: string; + quantity: number; + priceStroops: number; +} + +class WalletService extends BaseApiService { + async getHoldings(address: string): Promise { + try { + const response = await this.api.get>( + `/wallets/${address}/holdings` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const walletService = new WalletService(); +``` + +### Query key + +```ts +// src/lib/queryKeys.ts (existing file — add the entry) +wallet: { + holdings: (address: string) => ['wallet', address, 'holdings'] as const, +}, +``` + +### Hook + +```ts +// src/hooks/useWalletHoldings.ts +import { useQuery } from '@tanstack/react-query'; +import { walletService } from '@/services/wallet.service'; +import { queryKeys } from '@/lib/queryKeys'; + +export function useWalletHoldings(address: string | undefined) { + return useQuery({ + queryKey: queryKeys.wallet.holdings(address ?? ''), + queryFn: () => walletService.getHoldings(address!), + enabled: Boolean(address), + }); +} +``` + +### Component + +```tsx +// Usage in any component +import { useAccount } from 'wagmi'; +import { useWalletHoldings } from '@/hooks/useWalletHoldings'; + +function HoldingsSummary() { + const { address } = useAccount(); + const { data: holdings, isLoading, error } = useWalletHoldings(address); + + if (isLoading) return

Loading…

; + if (error) return

Could not load holdings.

; + if (!holdings?.length) return

No holdings yet.

; + + return ( +
    + {holdings.map(h => ( +
  • + {h.creatorId} — {h.quantity} keys at {h.priceStroops} stroops +
  • + ))} +
+ ); +} +``` + +--- + +## Key files at a glance + +| File | Purpose | +| -------------------------------- | ------------------------------------------------- | +| `src/services/api.service.ts` | `BaseApiService`, `ApiError`, `APIResponse` types | +| `src/services/auth.service.ts` | Auth endpoints (login, register, profile) | +| `src/services/course.service.ts` | Creator / course endpoints | +| `src/lib/queryKeys.ts` | Centralised React Query key constants | +| `src/providers/Web3Provider.tsx` | `QueryClientProvider` setup | diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 00000000..fd805cac --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,99 @@ +# Environment Variable Guide + +This guide explains how to add a new client environment variable safely and consistently in Access Layer Client. + +The client is built with Vite, so any value that must be available in browser code must use the `VITE_` prefix. Values without that prefix are not exposed to the client bundle. + +## Files involved + +| File | Purpose | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `.env.example` | Documents every supported variable and provides safe local defaults or blank optional placeholders. | +| `src/utils/env.utils.ts` | Validates environment variables at startup with Zod and exports the typed `env` object used by application code. | +| `.env` | Local developer overrides. This file should not be committed. | + +## Add a new variable + +1. Add the variable to `.env.example`. +2. Add validation for the variable in `src/utils/env.utils.ts`. +3. Pass the raw `import.meta.env` value into the `envSchema.parse(...)` call in `src/utils/env.utils.ts`. +4. Import the validated `env` object in application code. +5. Avoid reading `import.meta.env` directly from components, hooks, or service files. + +## Declaration pattern + +Add the new variable to `.env.example` near related settings. Use a short comment that explains what the value controls and whether it is required. + +```env +# Feature flag for the creator discovery experiment. Use `true` to enable locally. +VITE_ENABLE_CREATOR_DISCOVERY=false +``` + +Prefer safe development defaults when the app can run without secrets. Leave optional third-party keys blank if a contributor can work without them. + +## Runtime validation pattern + +All supported variables should be declared in `src/utils/env.utils.ts` so missing or malformed configuration is caught in one place. + +```ts +const envSchema = z.object({ + VITE_ENABLE_CREATOR_DISCOVERY: z.coerce.boolean().default(false), +}); + +export const env = envSchema.parse({ + VITE_ENABLE_CREATOR_DISCOVERY: import.meta.env.VITE_ENABLE_CREATOR_DISCOVERY, +}); +``` + +Use the Zod type that matches how the app consumes the value: + +| Value type | Validation example | +| --------------- | ----------------------------------------------- | +| Required string | `z.string().min(1, "VITE_API_KEY is required")` | +| Optional string | `z.string().optional()` | +| Number | `z.coerce.number().default(84532)` | +| Boolean flag | `z.coerce.boolean().default(false)` | + +If a value is required for the app to start, avoid a silent fallback. Use `.min(1, "... is required")` or another explicit validation rule so the startup error points to the missing variable. + +## Access pattern in application code + +Import `env` from the validation module and read the typed value from there: + +```ts +import { env } from '@/utils/env.utils'; + +if (env.VITE_ENABLE_CREATOR_DISCOVERY) { + // Render or enable the feature. +} +``` + +This keeps validation, defaults, and type coercion centralized. + +## Anti-pattern: direct component access + +Do not import or read `import.meta.env` directly in components, hooks, services, or utilities outside the validation module. + +```tsx +// Avoid this. +const backendUrl = import.meta.env.VITE_BACKEND_URL; +``` + +Direct access bypasses schema validation, makes defaults inconsistent, and spreads environment knowledge across the app. Use `env` instead: + +```tsx +import { env } from '@/utils/env.utils'; + +const backendUrl = env.VITE_BACKEND_URL; +``` + +## Required vs optional checklist + +Use this checklist before opening a PR that adds a new variable: + +- The variable is listed in `.env.example`. +- The variable has a clear comment describing its purpose. +- Required values fail fast in `src/utils/env.utils.ts` with a useful error. +- Optional values use `.optional()` or a safe `.default(...)`. +- Application code reads from `env`, not `import.meta.env`. +- The variable name starts with `VITE_` if browser code needs it. diff --git a/docs/error-handling-in-hooks.md b/docs/error-handling-in-hooks.md new file mode 100644 index 00000000..e5f813cf --- /dev/null +++ b/docs/error-handling-in-hooks.md @@ -0,0 +1,373 @@ +# Error Handling in React Query Hooks + +This guide documents the standard pattern for handling API errors in React Query hooks across this codebase. Follow it when writing new `useQuery` or `useMutation` hooks so error behavior is consistent and predictable for users. + +--- + +## How Errors Flow In + +All HTTP requests go through the service layer (`src/services/`), which extends `BaseApiService`. The `handleError` method on that base class normalises every failure into an `ApiError` before it reaches the hook: + +| Raw failure | What you receive | +| ------------------------------------- | ------------------------------------------------------ | +| HTTP response with an error status | `ApiError(message, httpStatus, responseBody)` | +| Request sent but no response received | `ApiError('Network error - check your connection', 0)` | +| Unexpected non-HTTP exception | `ApiError(error.message, 500)` | + +One important exception: **401 + `TOKEN_EXPIRED`** is handled transparently by the Axios interceptor in `BaseApiService`. The interceptor silently retries the original request after refreshing the access token. If the refresh also fails the user is redirected to `/login`; the hook never sees this error. + +--- + +## The `ApiError` Shape + +```ts +// src/services/api.service.ts +class ApiError extends Error { + status: number; // HTTP status code; 0 means no network response + response?: { + success: false; + message: string; + code?: string; // machine-readable code from the API, e.g. "INSUFFICIENT_BALANCE" + errors?: Array<{ + field?: string; // present on 422 validation failures + message: string; + }>; + }; +} +``` + +Always cast the error to `ApiError` before inspecting it: + +```ts +import { ApiError } from '@/services/api.service'; + +onError: error => { + const apiError = error as ApiError; + console.log(apiError.status); // 0, 400, 403, 422, 500 … + console.log(apiError.message); // human-readable message from the API + console.log(apiError.response?.errors); // field-level details on 422 +}; +``` + +--- + +## Distinguishing Error Types + +### Network errors (`status === 0`) + +No response was received — the user is offline, the server is unreachable, or a timeout occurred. The user cannot fix the request payload; they need to retry later. + +```ts +if (apiError.status === 0) { + showToast.error('Network error. Check your connection and try again.'); + return; +} +``` + +### 4xx — Client errors + +The request was received but rejected because of something the client sent. The message from the API is usually safe to show to the user. + +| Status | Cause | Typical UI response | +| ------ | ------------------------ | ---------------------------------------------------- | +| 400 | Malformed request | Toast with `apiError.message` | +| 401 | Session expired | Auto-handled by the interceptor | +| 403 | Insufficient permissions | Inline error or redirect | +| 404 | Resource not found | Inline error state | +| 422 | Validation failure | Inline field errors from `apiError.response?.errors` | +| 429 | Rate limited | Toast with retry suggestion | + +### 5xx — Server errors + +The API itself failed. The user cannot fix the payload; they can only retry after the server recovers. Avoid showing raw server messages — use a generic fallback instead. + +```ts +if (apiError.status >= 500) { + showToast.error('The server ran into a problem. Please try again shortly.'); + return; +} +``` + +--- + +## Deciding: Toast vs. Inline Error vs. Error Boundary + +### Use a toast when + +- The failure came from a **user-initiated action** (mutation): buying a key, submitting a form, enrolling in a course. +- The error **does not block the current view** — the page can still render usefully. +- The fix is to retry or change input: one line of feedback is enough. + +```ts +onError: error => { + const apiError = error as ApiError; + showToast.error( + apiError.status >= 500 + ? 'Something went wrong. Try again.' + : apiError.message + ); +}; +``` + +### Use an inline error state when + +- The error **blocks the primary purpose of the screen** — for example, the creator list failed to load so the page is empty. +- The error contains **field-level detail** (422) that needs to map to specific form inputs. +- The user needs to take **corrective action** (fix a field, switch networks) before retrying makes sense. + +```tsx +const { data, isError, error } = useCreatorKeys(creatorId); + +if (isError) { + const apiError = error as ApiError; + return ( +
+ {apiError.status >= 500 + ? 'Unable to load data. Please try again later.' + : apiError.message} +
+ ); +} +``` + +### Use `SectionErrorBoundary` when + +- A **component throws during render**, not from an API call. +- You want to **isolate a section** so one broken widget does not crash the whole page. +- React Query's `throwOnError` option is enabled on a query. + +```tsx +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; + + + +; +``` + +`SectionErrorBoundary` renders a retry button that resets its own error state. Use it as a safety net around sections that fetch and render data together. + +--- + +## `useQuery` Pattern + +React Query v5 removed the `onError` callback from `useQuery`. Errors surface through `isError` and `error` in the component. Keep the hook thin and handle the error at the call site: + +```ts +// src/hooks/useCreatorProfile.ts +import { useQuery } from '@tanstack/react-query'; +import { creatorService } from '@/services/creator.service'; + +export function useCreatorProfile(creatorId: string) { + return useQuery({ + queryKey: ['creator-profile', creatorId], + queryFn: () => creatorService.getProfile(creatorId), + staleTime: 30_000, + }); +} +``` + +```tsx +// In the component +import { ApiError } from '@/services/api.service'; +import { useCreatorProfile } from '@/hooks/useCreatorProfile'; + +function CreatorProfileSection({ creatorId }: { creatorId: string }) { + const { data, isLoading, isError, error } = useCreatorProfile(creatorId); + + if (isLoading) return ; + + if (isError) { + const apiError = error as ApiError; + return ( +
+ {apiError.status >= 500 + ? 'Unable to load this profile right now.' + : apiError.message} +
+ ); + } + + return ; +} +``` + +--- + +## `useMutation` Pattern + +`useMutation` still accepts `onError` and `onSuccess` callbacks. Use them for toasts and cache invalidation: + +```ts +// src/hooks/useEnrollInCourse.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { courseService } from '@/services/course.service'; +import { ApiError } from '@/services/api.service'; +import showToast from '@/utils/toast.util'; + +export function useEnrollInCourse() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (courseId: string) => courseService.enrollInCourse(courseId), + onError: error => { + const apiError = error as ApiError; + + if (apiError.status === 0) { + showToast.error( + 'Network error. Check your connection and try again.' + ); + return; + } + + if (apiError.status >= 500) { + showToast.error( + 'Something went wrong on our end. Please try again.' + ); + return; + } + + // 4xx: the API message is safe and actionable + showToast.error(apiError.message); + }, + onSuccess: (_, courseId) => { + queryClient.invalidateQueries({ queryKey: ['enrolled-courses'] }); + queryClient.invalidateQueries({ queryKey: ['course', courseId] }); + showToast.success('Enrolled successfully!'); + }, + }); +} +``` + +--- + +## Worked Example: Handling Both Error Types + +The following hook wraps a write operation (buying a creator key) and shows how to handle network errors, 4xx validation failures, and 5xx server errors in a single consistent flow. + +```ts +// src/hooks/useCreatorKeys.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ApiError } from '@/services/api.service'; +import showToast from '@/utils/toast.util'; +import { creatorKeysService } from '@/services/creatorKeys.service'; + +// --- Read --- +export function useCreatorKeys(creatorId: string) { + return useQuery({ + queryKey: ['creator-keys', creatorId], + queryFn: () => creatorKeysService.getKeys(creatorId), + staleTime: 30_000, + }); +} + +// --- Write --- +export function useBuyCreatorKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + creatorId, + amount, + }: { + creatorId: string; + amount: number; + }) => creatorKeysService.buyKey(creatorId, amount), + + onError: error => { + const apiError = error as ApiError; + + // No response received — user is likely offline + if (apiError.status === 0) { + showToast.error( + 'Network error. Check your connection and try again.' + ); + return; + } + + // Server-side failure — not actionable by the user + if (apiError.status >= 500) { + showToast.error( + 'The server ran into a problem. Please try again shortly.' + ); + return; + } + + // 422 Validation — show the first field error if available + if (apiError.status === 422 && apiError.response?.errors?.length) { + showToast.error(apiError.response.errors[0].message); + return; + } + + // All other 4xx — the API message is safe to surface + showToast.error(apiError.message); + }, + + onSuccess: (_, { creatorId }) => { + // Invalidate relevant queries so the UI reflects the purchase + queryClient.invalidateQueries({ + queryKey: ['creator-keys', creatorId], + }); + queryClient.invalidateQueries({ queryKey: ['user-holdings'] }); + showToast.success('Key purchased successfully!'); + }, + }); +} +``` + +Usage in a component: + +```tsx +import { ApiError } from '@/services/api.service'; +import { useCreatorKeys, useBuyCreatorKey } from '@/hooks/useCreatorKeys'; +import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; + +function CreatorKeysSection({ creatorId }: { creatorId: string }) { + const { data: keys, isLoading, isError, error } = useCreatorKeys(creatorId); + const { mutate: buyKey, isPending } = useBuyCreatorKey(); + + if (isLoading) return ; + + if (isError) { + const apiError = error as ApiError; + return ( +
+

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

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

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

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

+ The idea +

+``` + +Body copy sits in `

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

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

+ Failed to load profile details +

+

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

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

{creator?.name}

+

{creator?.bio}

+
+ ); +} +``` diff --git a/docs/utils-testing-guide.md b/docs/utils-testing-guide.md new file mode 100644 index 00000000..f316176d --- /dev/null +++ b/docs/utils-testing-guide.md @@ -0,0 +1,69 @@ +# Utils Testing Guide + +## Naming Convention & Co-location + +- Test files should be named **`.test.ts`** (or `.test.tsx` for React‑related helpers). +- Place the test file **side‑by‑side** with the helper it exercises, inside the same directory. + + Example directory layout: + + ``` + src/utils/ + ├─ formatNumber.utils.ts + └─ formatNumber.utils.test.ts ← test file + ``` + +## Running Only Util Tests + +The project uses **Vitest** as the test runner (configured in `vitest.config.ts`). + +- To run **all** tests: `pnpm test` +- To run **only utils** tests: + ```bash + pnpm test "src/utils/**/*.test.ts" + ``` + This pattern matches every test file under `src/utils`. + +## Worked Example Test + +Below is a simple example for a pure helper `formatNumber` that formats a number with commas and two decimal places. + +```ts +// src/utils/formatNumber.utils.test.ts +import { describe, expect, it } from 'vitest'; +import { formatNumber } from './formatNumber.utils'; + +describe('formatNumber utils', () => { + it('formats an integer with commas', () => { + expect(formatNumber(1234567)).toBe('1,234,567.00'); + }); + + it('formats a floating‑point number with two decimals', () => { + expect(formatNumber(1234.5)).toBe('1,234.50'); + }); + + it('handles negative numbers', () => { + expect(formatNumber(-9876.543)).toBe('-9,876.54'); + }); +}); +``` + +### Explanation + +- **`describe`** groups related tests under a readable heading. +- **`it`** defines individual test cases. +- **`expect(...).toBe(...)`** performs the assertion. +- Because `formatNumber` is a **pure function** (no side‑effects), we can achieve **100 % branch coverage** with the three cases above (positive, decimal, negative). + +## Branch Coverage Expectation + +- **Pure helpers** (functions that depend only on their inputs) must have **100 % branch coverage**. +- Run the coverage report with: + ```bash + pnpm test --coverage + ``` +- Ensure the generated `coverage` report shows `100%` for each pure helper file. + +--- + +_This guide lives in the repository under `docs/utils-testing-guide.md` and should be referenced by contributors when adding new utility helpers._ diff --git a/src/App.tsx b/src/App.tsx index 599195eb..54ceba82 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,23 +2,20 @@ import Lenis from 'lenis'; import { useEffect } from 'react'; import { Toaster } from 'react-hot-toast'; import { createBrowserRouter, RouterProvider } from 'react-router'; -import HomePage from './pages/HomePage'; -import NotFoundPage from './pages/NotFoundPage'; +import AppErrorBoundary from './components/common/AppErrorBoundary'; +import { useNavigationTiming } from './hooks/useNavigationTiming'; +import { routes } from './routes'; -const router = createBrowserRouter([ - { - path: '/', - element: , - }, - { - path: '*', - element: , - }, -]); +const router = createBrowserRouter(routes); function App() { + useNavigationTiming(); + useEffect(() => { - const lenis = new Lenis({ duration: 1.2, easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)) }); + const lenis = new Lenis({ + duration: 1.2, + easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)), + }); function raf(time: number) { lenis.raf(time); requestAnimationFrame(raf); @@ -28,7 +25,7 @@ function App() { }, []); return ( - <> + - + ); } diff --git a/src/components/__tests__/CreatorErrorBoundary.integration.test.tsx b/src/components/__tests__/CreatorErrorBoundary.integration.test.tsx new file mode 100644 index 00000000..df92c8d3 --- /dev/null +++ b/src/components/__tests__/CreatorErrorBoundary.integration.test.tsx @@ -0,0 +1,59 @@ +import { useState, type ReactElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; +import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; + +const BombComponent = () => { + throw new Error('Simulated component rendering crash'); +}; + +const HealthySibling = () => { + const [clicked, setClicked] = useState(false); + + return ( +
+ + {clicked ?

Sibling clicked

: null} +
+ ); +}; + +const renderWithRouter = (ui: ReactElement) => + render({ui}); + +describe('CreatorPageErrorBoundary integration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('catches render faults while keeping the rest of the app mounted', async () => { + const user = userEvent.setup(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderWithRouter( +
+ + + + +
+ ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/this creator page could not load/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /back to creators/i })).toHaveAttribute( + 'href', + '/creators' + ); + expect(screen.getByRole('button', { name: /healthy sibling/i })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /healthy sibling/i })); + + expect(screen.getByText('Sibling clicked')).toBeInTheDocument(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/FollowButton.test.tsx b/src/components/__tests__/FollowButton.test.tsx new file mode 100644 index 00000000..69f0e29b Binary files /dev/null and b/src/components/__tests__/FollowButton.test.tsx differ diff --git a/src/components/common/AppErrorBoundary.tsx b/src/components/common/AppErrorBoundary.tsx new file mode 100644 index 00000000..385400e7 --- /dev/null +++ b/src/components/common/AppErrorBoundary.tsx @@ -0,0 +1,77 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; +} + +/** + * Catches uncaught render errors anywhere in the app that aren't already + * handled by a more specific boundary (SectionErrorBoundary, + * CreatorPageErrorBoundary, etc). This is the last line of defense before + * React would otherwise unmount the whole tree to a blank screen. + * + * A full reload is used for recovery rather than resetting local state: + * an error this high up means the app-level state that produced it is + * suspect, so a fresh mount is safer than trying to resume it. + */ +class AppErrorBoundary extends Component { + public state: State = { + hasError: false, + }; + + public static getDerivedStateFromError(): State { + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error at app root:', error, errorInfo); + } + + private handleReload = () => { + window.location.reload(); + }; + + public render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+ ); + } + + return this.props.children; + } +} + +export default AppErrorBoundary; diff --git a/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx index c72d8520..075f96dc 100644 --- a/src/components/common/ConnectWalletButton.tsx +++ b/src/components/common/ConnectWalletButton.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useAccount, useConnect, useDisconnect } from 'wagmi'; +import { Copy, Check } from 'lucide-react'; import { Dialog, DialogClose, @@ -16,12 +17,18 @@ import { WALLET_CONNECTION_AD_BLOCKER_MESSAGE, useWalletConnectionStallDetection, } from '@/hooks/useWalletConnectionStallDetection'; +import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; +import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; +import showToast from '@/utils/toast.util'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; function ConnectWalletButton() { const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); + const [copied, setCopied] = useState(false); const { address, isConnected } = useAccount(); const { connect, connectors, error, isPending } = useConnect(); const { disconnect } = useDisconnect(); + const { announcement, announceCopySuccess } = useCopySuccessAnnouncement(); const primaryConnector = connectors[0]; const showAdBlockerSuggestion = useWalletConnectionStallDetection({ @@ -29,45 +36,83 @@ function ConnectWalletButton() { hasWalletResponse: isConnected || Boolean(error), }); + const handleCopyAddress = async () => { + if (!address) return; + try { + await copyTextToClipboard(address); + announceCopySuccess('Wallet address copied.'); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + showToast.error( + 'Could not copy the wallet address. Please copy it manually.' + ); + } + }; + if (isConnected && address) { return ( - - + <> +
+ + + + + + + Disconnect wallet? + + Disconnecting clears your current wallet session and any + pending wallet state. You will need to reconnect to + continue. + + + + + + + + + + - - - - Disconnect wallet? - - Disconnecting clears your current wallet session and any - pending wallet state. You will need to reconnect to continue. - - - - - - - - - -
+ {copied && ( + + )} +
+ + ); } diff --git a/src/components/common/CopyField.tsx b/src/components/common/CopyField.tsx index 6ecfffcb..cc487b4d 100644 --- a/src/components/common/CopyField.tsx +++ b/src/components/common/CopyField.tsx @@ -4,6 +4,8 @@ import { cn } from '@/lib/utils'; import { useAutoSelectOnFocus } from '@/hooks/useAutoSelectOnFocus'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; +import showToast from '@/utils/toast.util'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; interface CopyFieldProps { value: string; @@ -26,12 +28,14 @@ const CopyField: React.FC = ({ const handleCopy = async () => { try { - await navigator.clipboard.writeText(value); + await copyTextToClipboard(value); announceCopySuccess(`${label} copied.`); + showToast.success('Address copied to clipboard', { duration: 2000 }); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { setCopied(false); + showToast.error(`Could not copy ${label}. Please copy it manually.`); } }; diff --git a/src/components/common/CreatorBio.tsx b/src/components/common/CreatorBio.tsx index ae3f60c5..b87480dc 100644 --- a/src/components/common/CreatorBio.tsx +++ b/src/components/common/CreatorBio.tsx @@ -1,6 +1,9 @@ import { useId, useState } from 'react'; import { cn } from '@/lib/utils'; -import { lineClampClassFor } from '@/utils/lineClamp.utils'; +import { + lineClampClassFor, + creatorCardSubtitleClampClass, +} from '@/utils/lineClamp.utils'; interface CreatorBioProps { /** Raw bio string from the creator profile. Anything falsy or whitespace-only is treated as missing. */ @@ -141,7 +144,10 @@ const CreatorBio: React.FC = ({ const clampVariant: 'card' | 'profile' = shouldOfferCollapse ? 'card' : variant; - const clampClass = lineClampClassFor(clampVariant, effectiveMaxLines); + const clampClass = + clampVariant === 'card' + ? creatorCardSubtitleClampClass(effectiveMaxLines) + : lineClampClassFor(clampVariant, effectiveMaxLines); const bioParagraph = (

= ({ const displayInstructorHandle = formatCreatorHandle(creator.instructorId) || '@creator'; const displaySocialHandle = formatCreatorHandle(creator.socialHandle); + const truncatedInstructorHandle = truncateHandle(displayInstructorHandle); + const truncatedSocialHandle = truncateHandle(displaySocialHandle); const displayCreatorName = normalizeCreatorDisplayName(creator.title) || 'Unnamed creator'; const priceChartAccessibility = getCreatorPriceChartAccessibilityCopy({ @@ -100,8 +107,12 @@ const CreatorCard: React.FC = ({ }); const hasFailedOnceRef = useRef(false); const trackTransactionEvent = useTransactionTelemetry(); + const cardRef = useRef(null); + + // Keyboard shortcut for quick buy (press 'b' when card is focused) - const runPurchaseAttempt = () => { + + const runPurchaseAttempt = useCallback(() => { setTransactionState('submitting'); trackTransactionEvent('tx_submitted', { creatorId: creator.id, @@ -139,7 +150,7 @@ const CreatorCard: React.FC = ({ }); showToast.transactionSuccess( 'Purchase Successful!', - `You successfully bought a key for ${displayCreatorName}`, + `Bought 1 key from ${displayCreatorName}`, '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'https://stellar.expert/explorer/testnet/tx/0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' ); @@ -148,32 +159,38 @@ const CreatorCard: React.FC = ({ setTransactionState('idle'); }, 1800); }, 1500); - }; + }, [creator.id, displayCreatorName, trackTransactionEvent, setTransactionState]); const isRecentlyActive = (creator.volume24h ?? 0) > 0; const keyPriceDisplay = formatCreatorKeyPriceDisplay(creator); - const handleCopyLink = () => { + const handleCopyLink = async () => { const url = `${window.location.origin}/creator/${creator.id}`; - navigator.clipboard - .writeText(url) - .then(() => toast.success('Profile link copied')) - .catch(() => toast.error('Could not copy link')); + try { + await copyTextToClipboard(url); + toast.success('Profile link copied'); + } catch { + toast.error('Could not copy the profile link. Please copy it manually.'); + } }; - const handleShare = () => { + const handleShare = async () => { const url = `${window.location.origin}/creator/${creator.id}`; if (navigator.share) { navigator.share({ title: displayCreatorName, url }).catch(() => {}); } else { - navigator.clipboard - .writeText(url) - .then(() => toast.success('Link copied to clipboard')) - .catch(() => toast.error('Could not share')); + try { + await copyTextToClipboard(url); + toast.success('Link copied to clipboard'); + } catch { + toast.error( + 'Could not copy the share link. Please copy it manually.' + ); + } } }; - const handleBuy = () => { + const handleBuy = useCallback(() => { if (!isConnected) { toast.error('Please connect your wallet to purchase keys', { duration: 4000, @@ -193,12 +210,14 @@ const CreatorCard: React.FC = ({ }); // Implementation for contract interaction would go here runPurchaseAttempt(); - }; + }, [isConnected, isNetworkMismatch, expectedChainName, displayCreatorName, runPurchaseAttempt]); return (

@@ -300,7 +319,7 @@ const CreatorCard: React.FC = ({ creatorShareSupply={creator.creatorShareSupply} isVerified={creator.isVerified} > - {displayInstructorHandle} + {truncatedInstructorHandle}

@@ -324,7 +343,7 @@ const CreatorCard: React.FC = ({ creatorShareSupply={creator.creatorShareSupply} isVerified={creator.isVerified} > - {displaySocialHandle} + {truncatedSocialHandle}
) : ( @@ -339,32 +358,40 @@ const CreatorCard: React.FC = ({ )} - {/* Sparkline placeholder */} -
-
- - - - - - - - - - {priceChartAccessibility.points.map(point => ( - - - + {/* Price history sparkline */} + {creator.priceHistory && creator.priceHistory.length >= 2 && (() => { + const latest = creator.priceHistory[creator.priceHistory.length - 1]; + const earliest = creator.priceHistory[0]; + let lineColor = '#fbbf24'; + if (latest > earliest) lineColor = '#22c55e'; + else if (latest < earliest) lineColor = '#ef4444'; + + return ( +
+ +
{priceChartAccessibility.summary}
PointKey price
{point.label}{point.value}
+ + + + + - ))} - -
{priceChartAccessibility.summary}
PointKey price
-
+ + + {priceChartAccessibility.points.map(point => ( + + {point.label} + {point.value} + + ))} + + +
+ ); + })()}
@@ -410,7 +437,7 @@ const CreatorCard: React.FC = ({ } value={ creator.socialHandle - ? displaySocialHandle + ? truncatedSocialHandle : 'No public handle' } valueTitle={ @@ -476,7 +503,12 @@ const CreatorCard: React.FC = ({ > Purchase actions for {displayCreatorName} - +
+ + + Press B to quick buy + +
= ({ + className, + disableShimmer = false, +}) => { + const blockClass = disableShimmer ? skeletonStaticBlockClass : skeletonBlockClass; + + return ( +
+ Loading creator card + + {/* + * Top-right dropdown trigger placeholder. CreatorCard + * absolutely-positions a size-8 trigger at right-3 top-3, + * so the skeleton matches that footprint to avoid the + * sibling controls shifting when real cards render. + */} +
+
+
+ + {/* Avatar block */} +
+ +
+ {/* Title row: name + verified + change + supply badges */} +
+
+
+
+
+
+ + {/* Handle line (marketplace-label-muted) */} +
+ + {/* Bio — two short lines */} +
+
+
+
+ + {/* Sparkline placeholder (matches CreatorCard's price chart placeholder) */} +
+ + + {/* Mini stat chips (Price / Category / Level) */} +
+
+
+
+
+
+ + {/* Divider (CreatorListRowDivider equivalent) */} +
+ + {/* Meta rows: Join Date / Handle / Key Price */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Divider */} +
+ + {/* Social links row */} +
+
+
+
+
+
+ + {/* + * Action row: NetworkFeeHint + Buy Key button placeholders. + * Widths (w-24) mirror `CreatorSkeleton`'s convention so the + * skeleton matches CreatorCard's h-9 "Buy Key" button plus + * the compact `.font-mono text-[9px]` NetworkFeeHint chip. + */} +
+
+
+
+ + {/* Helper text bar (matches BuyActionHelperText height) */} +
+
+
+
+ ); +}; + +/** + * Grid of creator card skeletons shown while the creator list is + * loading (#421). Defaults to `count = 6` so the placeholder grid + * matches the live first-page footprint on `LandingPage`. + */ +export const CreatorCardGridSkeleton: React.FC<{ + count?: number; + disableShimmer?: boolean; + className?: string; +}> = ({ count = 6, disableShimmer = false, className }) => { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +}; + +export default CreatorCardSkeleton; diff --git a/src/components/common/CreatorPageErrorBoundary.tsx b/src/components/common/CreatorPageErrorBoundary.tsx new file mode 100644 index 00000000..8dffcdfe --- /dev/null +++ b/src/components/common/CreatorPageErrorBoundary.tsx @@ -0,0 +1,69 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { Link } from 'react-router'; +import { AlertCircle, ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; +} + +/** + * Error boundary scoped to creator detail routes. When a creator page throws + * during render, this catches the error and shows a fallback with a link back + * to the creator list instead of crashing the whole app to a blank screen. + */ +class CreatorPageErrorBoundary extends Component { + public state: State = { + hasError: false, + }; + + public static getDerivedStateFromError(): State { + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + if (import.meta.env.DEV) { + console.error('Error rendering creator page:', error, errorInfo); + } + } + + public render() { + if (this.state.hasError) { + return ( +
+
+
+ +
+ ); + } + + return this.props.children; + } +} + +export default CreatorPageErrorBoundary; diff --git a/src/components/common/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 ( +
+
+
+

+ {title} +

+

+ {errorMessage} +

+ {onRetry && ( + + )} +
+ ); +}; + +export default CreatorProfileErrorState; diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index 7f01d93b..5a174e54 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import { Copy, Check, Share2 } from 'lucide-react'; +import { Copy, Check, Share2, Pencil } from 'lucide-react'; import showToast from '@/utils/toast.util'; import appendUtmParams from '@/utils/utm.utils'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import VerifiedBadge from '@/components/common/VerifiedBadge'; @@ -11,6 +12,7 @@ import CreatorBio from '@/components/common/CreatorBio'; import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils'; import { CREATOR_CARD_MEDIA_RADIUS_CLASS } from '@/utils/creatorCardTokens'; +import { isOwnWallet } from '@/utils/isOwnWallet'; interface CreatorProfileHeaderProps { name: string; @@ -20,6 +22,7 @@ interface CreatorProfileHeaderProps { isVerified?: boolean; bio?: string | null; className?: string; + connectedWalletAddress?: string | null; } const CREATOR_PROFILE_SUBTITLE_WRAP_CLASS_NAME = @@ -33,6 +36,7 @@ const CreatorProfileHeader: React.FC = ({ isVerified, bio, className, + connectedWalletAddress, }) => { const [copied, setCopied] = useState(false); const [isScrolled, setIsScrolled] = useState(false); @@ -49,6 +53,10 @@ const CreatorProfileHeader: React.FC = ({ // URL construction the caller might do via the prop. const displayHandle = formatCreatorHandle(handle); const displayName = normalizeCreatorDisplayName(name) || 'Unnamed creator'; + const normalizedCreatorId = + creatorId == null ? creatorId : String(creatorId); + +const own = isOwnWallet(connectedWalletAddress, normalizedCreatorId); const handleShare = async () => { let url = window.location.href; @@ -73,12 +81,14 @@ const CreatorProfileHeader: React.FC = ({ // Fallback: copy to clipboard try { - await navigator.clipboard.writeText(url); + await copyTextToClipboard(url); setCopied(true); showToast.success('Profile link copied to clipboard!'); setTimeout(() => setCopied(false), 2000); } catch { - showToast.error('Failed to copy link'); + showToast.error( + 'Could not copy the profile link. Please copy it manually.' + ); } }; @@ -160,13 +170,41 @@ const CreatorProfileHeader: React.FC = ({
-
- + + + )} + + ); +} \ No newline at end of file diff --git a/src/components/common/HoldingsEmptyState.tsx b/src/components/common/HoldingsEmptyState.tsx new file mode 100644 index 00000000..e0254e31 --- /dev/null +++ b/src/components/common/HoldingsEmptyState.tsx @@ -0,0 +1,63 @@ +import { ArrowRight, KeyRound } from 'lucide-react'; +import { Link } from 'react-router'; +import { cn } from '@/lib/utils'; +import { EMPTY_STATE_ILLUSTRATION_SIZES } from './emptyStateIllustration.config'; + +interface HoldingsEmptyStateProps { + className?: string; + /** Creator discovery path — defaults to marketplace creators route. */ + browseHref?: string; +} + +/** + * Shown when the holdings query has settled with zero creator keys. + * Distinct from loading (skeleton) and from marketplace search empty states. + */ +const HoldingsEmptyState: React.FC = ({ + className, + browseHref = '/creators', +}) => ( +
+
+
+ + +
+ +

+ No creator keys yet +

+

+ This wallet doesn't hold any creator keys right now. Browse + creators and secure your first key. +

+ + + Browse creators +
+); + +export default HoldingsEmptyState; 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/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 14038b4f..ea82b4b4 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -16,6 +16,7 @@ import PercentageBadge from '@/components/common/PercentageBadge'; import NetworkFeeHint from '@/components/common/NetworkFeeHint'; import { TRADE_FEE_ESTIMATE } from '@/constants/fees'; import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils'; +import { clampBuyQuantity } from '@/utils/buyQuantity'; export type TradeSide = 'buy' | 'sell'; @@ -55,6 +56,17 @@ const TradeDialog: React.FC = ({ } }, [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; @@ -136,7 +148,7 @@ const TradeDialog: React.FC = ({ setAmountText(event.target.value); setTouched(true); }} - onBlur={() => setTouched(true)} + onBlur={handleBlur} disabled={isSubmitting} className={cn( 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors', diff --git a/src/components/common/TransactionFailureDrawer.tsx b/src/components/common/TransactionFailureDrawer.tsx index 6ccce355..c7241ce7 100644 --- a/src/components/common/TransactionFailureDrawer.tsx +++ b/src/components/common/TransactionFailureDrawer.tsx @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'; import { AlertCircle, Copy, Check } from 'lucide-react'; import showToast from '@/utils/toast.util'; import { formatTimestampTooltip } from '@/utils/time.utils'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; import { COPY_SUCCESS_TOAST_ARIA_PROPS, @@ -50,7 +51,7 @@ const TransactionFailureDrawer: React.FC = ({ field: 'errorCode' | 'txHash' ) => { try { - await navigator.clipboard.writeText(text); + await copyTextToClipboard(text); showToast.success('Copied to clipboard', { ariaProps: COPY_SUCCESS_TOAST_ARIA_PROPS, }); @@ -62,7 +63,11 @@ const TransactionFailureDrawer: React.FC = ({ setCopiedField(field); window.setTimeout(() => setCopiedField(null), 2000); } catch { - showToast.error('Failed to copy to clipboard'); + showToast.error( + field === 'errorCode' + ? 'Could not copy the error code. Please copy it manually.' + : 'Could not copy the transaction hash. Please copy it manually.' + ); } }; diff --git a/src/components/common/TransactionHistory.tsx b/src/components/common/TransactionHistory.tsx index d55e5371..b2b3d00f 100644 --- a/src/components/common/TransactionHistory.tsx +++ b/src/components/common/TransactionHistory.tsx @@ -2,11 +2,16 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { ChevronDown, ChevronUp, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { formatRelativeTime } from '@/utils/time.utils'; +import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; -interface Transaction { +export interface Transaction { id: string; type: 'buy' | 'sell'; - creator: string; + /** Raw creator identifier from the API — never shown in the UI. */ + creatorId: string; + /** Human-readable handle used for display (e.g. instructorId). */ + creatorHandle: string; amount: number; price: number; timestamp: number; @@ -14,13 +19,18 @@ interface Transaction { status: 'completed' | 'pending' | 'failed'; } +interface TransactionHistoryProps { + transactions?: Transaction[]; +} + const COMPACT_VIEW_KEY = 'accesslayer.transaction-compact-view'; const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '1', type: 'buy', - creator: 'Alex Rivers', + creatorId: '1', + creatorHandle: 'arivers', amount: 5, price: 0.05, timestamp: Date.now() - 1000 * 60 * 30, @@ -30,7 +40,8 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '2', type: 'sell', - creator: 'Sarah Chen', + creatorId: '2', + creatorHandle: 'schen_dev', amount: 3, price: 0.12, timestamp: Date.now() - 1000 * 60 * 60 * 2, @@ -40,7 +51,8 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '3', type: 'buy', - creator: 'Marcus Thorne', + creatorId: '3', + creatorHandle: 'mthorne', amount: 10, price: 0.08, timestamp: Date.now() - 1000 * 60 * 60 * 5, @@ -50,7 +62,8 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '4', type: 'buy', - creator: 'Elena Vance', + creatorId: '4', + creatorHandle: 'evance_design', amount: 2, price: 0.04, timestamp: Date.now() - 1000 * 60 * 60 * 24, @@ -60,7 +73,8 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '5', type: 'sell', - creator: 'David Kojo', + creatorId: '5', + creatorHandle: 'dkojo_beats', amount: 7, price: 0.15, timestamp: Date.now() - 1000 * 60 * 60 * 48, @@ -69,20 +83,9 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ }, ]; -const formatTimestamp = (timestamp: number) => { - const now = Date.now(); - const diff = now - timestamp; - const minutes = Math.floor(diff / (1000 * 60)); - const hours = Math.floor(diff / (1000 * 60 * 60)); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - - if (minutes < 1) return 'Just now'; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - return `${days}d ago`; -}; - -const TransactionHistory: React.FC = () => { +const TransactionHistory: React.FC = ({ + transactions = SAMPLE_TRANSACTIONS, +}) => { const [isCompact, setIsCompact] = useState(() => { if (typeof window === 'undefined') return false; const saved = localStorage.getItem(COMPACT_VIEW_KEY); @@ -154,11 +157,13 @@ const TransactionHistory: React.FC = () => {
- {SAMPLE_TRANSACTIONS.map(tx => { + {transactions.map(tx => { + const displayHandle = formatCreatorHandle(tx.creatorHandle); const isExpanded = expandedRows.has(tx.id) || !isCompact; return (
{ {getTransactionTypeLabel(tx.type)} - {tx.creator} + + {displayHandle} +
{(!isCompact || isExpanded) && (
{tx.amount} keys - {tx.price} ETH + {tx.price} XLM - {formatTimestamp(tx.timestamp)} + {formatRelativeTime(tx.timestamp)}
)}
@@ -193,9 +203,12 @@ const TransactionHistory: React.FC = () => { {(!isCompact || isExpanded) && (
-
- {tx.type === 'buy' ? '+' : '-'} - {(tx.amount * tx.price).toFixed(4)} ETH +
+ {tx.type === 'buy' ? '-' : '+'} + {(tx.amount * tx.price).toFixed(4)} XLM
{tx.txHash} @@ -213,9 +226,12 @@ const TransactionHistory: React.FC = () => { {isCompact && !isExpanded && (
-
- {tx.type === 'buy' ? '+' : '-'} - {(tx.amount * tx.price).toFixed(4)} ETH +
+ {tx.type === 'buy' ? '-' : '+'} + {(tx.amount * tx.price).toFixed(4)} XLM

{creator.description && ( -

+

{creator.description}

)} @@ -49,7 +57,7 @@ export default function TrendingCreatorCard({ creator }: Props) {
- {creator.creatorShareSupply.toLocaleString()} + {formatHolderCount(creator.creatorShareSupply)}
)} @@ -64,6 +72,6 @@ export default function TrendingCreatorCard({ creator }: Props) { - + ); } diff --git a/src/components/home/__tests__/TrendingCreatorCard.integration.test.tsx b/src/components/home/__tests__/TrendingCreatorCard.integration.test.tsx new file mode 100644 index 00000000..de51bc4e --- /dev/null +++ b/src/components/home/__tests__/TrendingCreatorCard.integration.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { MemoryRouter } from 'react-router'; +import TrendingCreatorCard from '../TrendingCreatorCard'; +import type { Course } from '@/services/course.service'; + +const baseCreator: Course & { walletAddress: string } = { + id: 'test-1', + title: 'Test Creator', + description: 'Test Description', + price: 10, + instructorId: 'user1', + walletAddress: '0x123', + socialHandle: 'test', + category: 'Art', + level: 'Advanced', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + isVerified: false, + status: 'active', +}; + +describe('TrendingCreatorCard integration (#484)', () => { + it('formats holder count above 1000 with a K suffix', () => { + render( + + + + ); + + expect(screen.getByText('1.5K')).toBeInTheDocument(); + expect(screen.queryByText('1,500')).not.toBeInTheDocument(); + expect(screen.queryByText('1500')).not.toBeInTheDocument(); + }); + + it('shows the raw number for counts below 1000', () => { + render( + + + + ); + + expect(screen.getByText('999')).toBeInTheDocument(); + }); + + it('applies creator card subtitle clamp helper class to description', () => { + render( + + + + ); + + const descriptionElement = screen.getByText( + 'A long subtitle description for testing clamp helper.' + ); + expect(descriptionElement.className).toMatch(/\bline-clamp-2\b/); + }); +}); diff --git a/src/components/home/__tests__/TrendingCreatorCard.navigation.integration.test.tsx b/src/components/home/__tests__/TrendingCreatorCard.navigation.integration.test.tsx new file mode 100644 index 00000000..f4872f68 --- /dev/null +++ b/src/components/home/__tests__/TrendingCreatorCard.navigation.integration.test.tsx @@ -0,0 +1,154 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { MemoryRouter, RouterProvider, createMemoryRouter, useParams } from 'react-router'; +import TrendingCreators from '../TrendingCreators'; +import userEvent from '@testing-library/user-event'; + +// Mock IntersectionObserver since it's not available in jsdom +class MockIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} + +global.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver; + +describe('TrendingCreatorCard navigation integration (#601)', () => { + it('renders Buy Keys buttons with correct creator profile links in discovery list', () => { + render( + + + + ); + + // Find all Buy Keys buttons/links + const buyKeysButtons = screen.getAllByRole('link', { name: /buy keys/i }); + expect(buyKeysButtons).toHaveLength(6); + + // Assert the first link points to the correct creator profile URL + expect(buyKeysButtons[0]).toHaveAttribute('href', '/creator/1'); + }); + + it('navigates to creator profile page when clicking Buy Keys button from discovery list', async () => { + const router = createMemoryRouter( + [ + { + path: '/', + element: , + }, + { + path: '/creator/:id', + element:
Creator Profile
, + }, + ], + { + initialEntries: ['/'], + } + ); + + render(); + + // Find the Buy Keys button for the first creator (Lena Markov, ID: '1') + const buyKeysButtons = screen.getAllByRole('link', { name: /buy keys/i }); + + // Click the first Buy Keys button to trigger navigation + await userEvent.click(buyKeysButtons[0]); + + // Assert navigation occurred to the correct route + expect(router.state.location.pathname).toBe('/creator/1'); + }); + + it('navigates with correct creator ID when clicking different creators from discovery list', async () => { + const router = createMemoryRouter( + [ + { + path: '/', + element: , + }, + { + path: '/creator/:id', + element:
Creator Profile
, + }, + ], + { + initialEntries: ['/'], + } + ); + + render(); + + const buyKeysButtons = screen.getAllByRole('link', { name: /buy keys/i }); + + // Click the second creator's Buy Keys button (Dario Fuentes, ID: '2') + await userEvent.click(buyKeysButtons[1]); + + expect(router.state.location.pathname).toBe('/creator/2'); + }); + + it('profile page begins loading data for correct creator ID from URL after navigation', async () => { + let loadedCreatorId: string | null = null; + + const MockCreatorProfilePage = () => { + const params = useParams(); + loadedCreatorId = params.id || null; + + return ( +
+
Loading: {params.id}
+
+ ); + }; + + const router = createMemoryRouter( + [ + { + path: '/', + element: , + }, + { + path: '/creator/:id', + element: , + }, + ], + { + initialEntries: ['/'], + } + ); + + render(); + + const buyKeysButtons = screen.getAllByRole('link', { name: /buy keys/i }); + await userEvent.click(buyKeysButtons[0]); + + // Assert that the profile page is loading data for the correct creator ID + expect(screen.getByTestId('loading-creator-id').textContent).toBe('Loading: 1'); + expect(loadedCreatorId).toBe('1'); + }); + + it('does not navigate when clicking outside the clickable area in discovery list', async () => { + const router = createMemoryRouter( + [ + { + path: '/', + element: , + }, + { + path: '/creator/:id', + element:
Creator Profile
, + }, + ], + { + initialEntries: ['/'], + } + ); + + render(); + + // Click on the section header (not a Buy Keys button) + const sectionHeader = screen.getByText(/creators worth holding/i); + await userEvent.click(sectionHeader); + + // Assert no navigation occurred - still on the home page + expect(router.state.location.pathname).toBe('/'); + }); +}); diff --git a/src/components/ui/sparkline.tsx b/src/components/ui/sparkline.tsx new file mode 100644 index 00000000..828ae8a9 --- /dev/null +++ b/src/components/ui/sparkline.tsx @@ -0,0 +1,40 @@ +interface SparklineProps { + data: number[]; + width?: number; + height?: number; + color?: string; +} + +export function Sparkline({ + data, + width = 320, + height = 80, + color = '#fbbf24', +}: SparklineProps) { + if (!data || data.length < 2) return null; + + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + + const points = data + .map((value, index) => { + const x = (index / (data.length - 1)) * width; + const y = height - ((value - min) / range) * height; + return `${x},${y}`; + }) + .join(' '); + + return ( + + ); +} \ No newline at end of file diff --git a/src/hooks/__tests__/queryKeyIntegration.test.tsx b/src/hooks/__tests__/queryKeyIntegration.test.tsx new file mode 100644 index 00000000..0ec72806 --- /dev/null +++ b/src/hooks/__tests__/queryKeyIntegration.test.tsx @@ -0,0 +1,55 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +import { queryKeys } from '@/lib/queryKeys'; +import { useCreatorList, useCreatorDetail } from '../useCreators'; +import { useWalletHoldings, useWalletActivity } from '../useWallet'; + +describe('queryKeyIntegration', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('useCreatorList uses the correct query key constant', () => { + const params = { page: 1 }; + renderHook(() => useCreatorList(params), { wrapper }); + + const cache = queryClient.getQueryCache().getAll(); + expect(cache).toHaveLength(1); + expect(cache[0].queryKey).toEqual(queryKeys.creators.list(params)); + }); + + it('useCreatorDetail uses the correct query key constant', () => { + renderHook(() => useCreatorDetail('creator-1'), { wrapper }); + + const cache = queryClient.getQueryCache().getAll(); + expect(cache).toHaveLength(1); + expect(cache[0].queryKey).toEqual(queryKeys.creators.detail('creator-1')); + }); + + it('useWalletHoldings uses the correct query key constant', () => { + renderHook(() => useWalletHoldings('0x123'), { wrapper }); + + const cache = queryClient.getQueryCache().getAll(); + expect(cache).toHaveLength(1); + expect(cache[0].queryKey).toEqual(queryKeys.wallet.holdings('0x123')); + }); + + it('useWalletActivity uses the correct query key constant', () => { + renderHook(() => useWalletActivity('0x123'), { wrapper }); + + const cache = queryClient.getQueryCache().getAll(); + expect(cache).toHaveLength(1); + expect(cache[0].queryKey).toEqual(queryKeys.wallet.activity('0x123')); + }); +}); diff --git a/src/hooks/__tests__/useDebounce.integration.test.tsx b/src/hooks/__tests__/useDebounce.integration.test.tsx new file mode 100644 index 00000000..5340eafd --- /dev/null +++ b/src/hooks/__tests__/useDebounce.integration.test.tsx @@ -0,0 +1,93 @@ +import { act, render, screen } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDebounce } from '@/hooks/useDebounce'; + +const DEBOUNCE_DELAY_MS = 500; + +function DebouncedProbe({ + initialValue = 'initial', + onDebouncedChange, +}: { + initialValue?: string; + onDebouncedChange?: (value: string) => void; +}) { + const [value, setValue] = useState(initialValue); + const debouncedValue = useDebounce(value, DEBOUNCE_DELAY_MS); + + useEffect(() => { + onDebouncedChange?.(debouncedValue); + }, [debouncedValue, onDebouncedChange]); + + return ( +
+ +
{debouncedValue}
+
+ ); +} + +describe('useDebounce integration (#498)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('clears the timeout on unmount so no state update fires after unmount', () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const onDebouncedChange = vi.fn(); + + const { unmount } = render( + + ); + + onDebouncedChange.mockClear(); + + act(() => { + screen.getByRole('button', { name: /update value/i }).click(); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(DEBOUNCE_DELAY_MS + 100); + }); + + expect(onDebouncedChange).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it('does not apply a pending debounced update after unmount when timers advance', () => { + const onDebouncedChange = vi.fn(); + + const { unmount, getByTestId } = render( + + ); + + expect(getByTestId('debounced-value')).toHaveTextContent('stable'); + onDebouncedChange.mockClear(); + + act(() => { + screen.getByRole('button', { name: /update value/i }).click(); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(DEBOUNCE_DELAY_MS + 100); + }); + + expect(onDebouncedChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/useDebounce.test.tsx b/src/hooks/__tests__/useDebounce.test.tsx new file mode 100644 index 00000000..ad869079 --- /dev/null +++ b/src/hooks/__tests__/useDebounce.test.tsx @@ -0,0 +1,77 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useState } from 'react'; +import { useDebounce } from '@/hooks/useDebounce'; + +describe('useDebounce – integration (fake timers)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not update the debounced value before the delay elapses', () => { + const { result } = renderHook(() => { + const [value, setValue] = useState('initial'); + const debounced = useDebounce(value, 300); + return { value, setValue, debounced }; + }); + + act(() => { + result.current.setValue('updated'); + }); + + // Immediately after the change the debounced value must still be the old one. + expect(result.current.debounced).toBe('initial'); + }); + + it('updates the debounced value after the delay elapses', () => { + const { result } = renderHook(() => { + const [value, setValue] = useState('initial'); + const debounced = useDebounce(value, 300); + return { value, setValue, debounced }; + }); + + act(() => { + result.current.setValue('updated'); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.debounced).toBe('updated'); + }); + + it('resets the timer on each new value during the debounce window', () => { + const { result } = renderHook(() => { + const [value, setValue] = useState('a'); + const debounced = useDebounce(value, 300); + return { value, setValue, debounced }; + }); + + act(() => { + result.current.setValue('b'); + }); + act(() => { + vi.advanceTimersByTime(150); + }); + // Still within the window — another update resets the timer. + act(() => { + result.current.setValue('c'); + }); + act(() => { + vi.advanceTimersByTime(150); + }); + // Only 150 ms have passed since the last update, not yet 300 ms. + expect(result.current.debounced).toBe('a'); + + act(() => { + vi.advanceTimersByTime(150); + }); + // Now the full 300 ms have elapsed since the last value change. + expect(result.current.debounced).toBe('c'); + }); +}); diff --git a/src/hooks/__tests__/useFormatXlm.test.ts b/src/hooks/__tests__/useFormatXlm.test.ts new file mode 100644 index 00000000..31e860be --- /dev/null +++ b/src/hooks/__tests__/useFormatXlm.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { formatXlm, useFormatXlm } from '@/hooks/useFormatXlm'; +import { renderHook } from '@testing-library/react'; + +describe('useFormatXlm', () => { + describe('default decimal precision (2 places)', () => { + it('formats a standard stroop amount with 2 decimal places', () => { + // 15_000_000 stroops = 1.5 XLM → "1.50" + expect(formatXlm(15_000_000)).toBe('1.50'); + }); + + it('formats zero input as 0.00', () => { + expect(formatXlm(0)).toBe('0.00'); + }); + + it('formats exactly 1 XLM (10_000_000 stroops) as 1.00', () => { + expect(formatXlm(10_000_000)).toBe('1.00'); + }); + + it('formats a sub-XLM value with 2 decimal places', () => { + // 500_000 stroops = 0.05 XLM → "0.05" + expect(formatXlm(500_000)).toBe('0.05'); + }); + }); + + describe('decimal override', () => { + it('formats with 0 decimal places when decimals=0', () => { + // 10_000_000 stroops = 1 XLM → "1" + expect(formatXlm(10_000_000, { decimals: 0 })).toBe('1'); + }); + + it('formats zero with 0 decimal places', () => { + expect(formatXlm(0, { decimals: 0 })).toBe('0'); + }); + + it('formats with 7 decimal places when decimals=7', () => { + // 10_000_000 stroops = 1 XLM → "1.0000000" + expect(formatXlm(10_000_000, { decimals: 7 })).toBe('1.0000000'); + }); + + it('formats a partial XLM value with 7 decimal places', () => { + // 1 stroop = 0.0000001 XLM + expect(formatXlm(1, { decimals: 7 })).toBe('0.0000001'); + }); + }); + + describe('thousands separator', () => { + it('includes a thousands separator for values above 1 000 XLM', () => { + // 10_001_000_000 stroops = 1_000.1 XLM + const result = formatXlm(10_001_000_000); + // The formatted string should contain a thousands separator character + // between the thousands and hundreds position (locale-dependent). + // We verify by checking that 1000 XLM renders as a 5+ char string. + expect(result.length).toBeGreaterThan(4); // at least "1,000" or "1 000" + // Must contain the numeric value 1000 with a separator + expect(result).toMatch(/1.000/); // separator can be , or . or space + }); + + it('does not include a thousands separator for values below 1 000 XLM', () => { + // 9_990_000_000 stroops = 999 XLM → "999.00" + const result = formatXlm(9_990_000_000); + expect(result).toBe('999.00'); + }); + }); + + describe('large values', () => { + it('formats 10_000_000 stroops (1 XLM) without scientific notation', () => { + const result = formatXlm(10_000_000); + expect(result).not.toMatch(/e/i); + expect(result).toBe('1.00'); + }); + + it('formats a large value (100_000_000_000_000 stroops = 10,000,000 XLM) without scientific notation', () => { + const result = formatXlm(100_000_000_000_000); + expect(result).not.toMatch(/e/i); + // Should contain the numeric value 10000000 with formatting + expect(result).toMatch(/10/); + }); + + it('formats 70_000_000_000 stroops (7000 XLM) without scientific notation', () => { + const result = formatXlm(70_000_000_000); + expect(result).not.toMatch(/e/i); + // 7000.00 formatted + expect(result).toMatch(/7/); + }); + }); + + describe('hook interface', () => { + it('exposes a format function', () => { + const { result } = renderHook(() => useFormatXlm()); + expect(typeof result.current.format).toBe('function'); + }); + + it('format function produces the same output as the standalone formatXlm', () => { + const { result } = renderHook(() => useFormatXlm()); + expect(result.current.format(15_000_000)).toBe(formatXlm(15_000_000)); + }); + + it('format function respects decimals option', () => { + const { result } = renderHook(() => useFormatXlm()); + expect(result.current.format(10_000_000, { decimals: 0 })).toBe('1'); + }); + }); +}); diff --git a/src/hooks/__tests__/useInfiniteScroll.test.tsx b/src/hooks/__tests__/useInfiniteScroll.test.tsx new file mode 100644 index 00000000..38b5cdeb --- /dev/null +++ b/src/hooks/__tests__/useInfiniteScroll.test.tsx @@ -0,0 +1,99 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; + +const observers: { + callback: IntersectionObserverCallback; + observe: ReturnType; + disconnect: ReturnType; +}[] = []; + +beforeEach(() => { + observers.length = 0; + + 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, + }); + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function simulateIntersection(isIntersecting: boolean) { + const obs = observers[observers.length - 1]; + act(() => { + obs.callback( + [{ isIntersecting } as IntersectionObserverEntry], + {} as IntersectionObserver, + ); + }); +} + +function TestComponent({ + enabled, + hasMore, + onLoadMore, +}: { + enabled: boolean; + hasMore: boolean; + onLoadMore: () => void; +}) { + const sentinelRef = useInfiniteScroll({ enabled, hasMore, onLoadMore }); + return
; +} + +describe('useInfiniteScroll', () => { + it('calls onLoadMore when the observed element intersects', () => { + const onLoadMore = vi.fn(); + render(); + + simulateIntersection(true); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); + + it('does not call onLoadMore when the element is not intersecting', () => { + const onLoadMore = vi.fn(); + render(); + + simulateIntersection(false); + + expect(onLoadMore).not.toHaveBeenCalled(); + }); + + it('disconnects the observer on unmount', () => { + const { unmount } = render( + , + ); + + unmount(); + + expect(observers[0].disconnect).toHaveBeenCalledTimes(1); + }); + + it('does not create an observer when enabled is false', () => { + render(); + + expect(observers).toHaveLength(0); + }); + + it('does not create an observer when hasMore is false', () => { + render(); + + expect(observers).toHaveLength(0); + }); +}); diff --git a/src/hooks/__tests__/useIsMobile.integration.test.tsx b/src/hooks/__tests__/useIsMobile.integration.test.tsx new file mode 100644 index 00000000..6c7b6312 --- /dev/null +++ b/src/hooks/__tests__/useIsMobile.integration.test.tsx @@ -0,0 +1,86 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useIsMobile } from '@/hooks/useIsMobile'; + +type MQCallback = (event: Pick) => void; + +interface MockMQL { + matches: boolean; + addEventListener: (event: string, cb: MQCallback) => void; + removeEventListener: (event: string, cb: MQCallback) => void; + _fire: (newMatches: boolean) => void; +} + +function mockViewportWidth(widthPx: number): MockMQL { + const matches = widthPx < 768; + const listeners: MQCallback[] = []; + const mql: MockMQL = { + matches, + addEventListener: (_event: string, cb: MQCallback) => listeners.push(cb), + removeEventListener: (_event: string, cb: MQCallback) => { + const idx = listeners.indexOf(cb); + if (idx !== -1) listeners.splice(idx, 1); + }, + _fire: (newMatches: boolean) => { + mql.matches = newMatches; + listeners.forEach(cb => cb({ matches: newMatches })); + }, + }; + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue(mql), + }); + + return mql; +} + +function MobileProbe() { + const isMobile = useIsMobile(); + return
{isMobile ? 'mobile' : 'desktop'}
; +} + +describe('useIsMobile integration (#485)', () => { + let mql: MockMQL; + + beforeEach(() => { + mql = mockViewportWidth(500); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns true below 768px', () => { + render(); + expect(screen.getByTestId('mobile-state')).toHaveTextContent('mobile'); + }); + + it('returns false at or above 768px', () => { + mql = mockViewportWidth(1024); + render(); + expect(screen.getByTestId('mobile-state')).toHaveTextContent('desktop'); + }); + + it('updates correctly when the viewport is resized in both directions', () => { + render(); + expect(screen.getByTestId('mobile-state')).toHaveTextContent('mobile'); + + act(() => { + mql._fire(false); + }); + expect(screen.getByTestId('mobile-state')).toHaveTextContent('desktop'); + + act(() => { + mql._fire(true); + }); + expect(screen.getByTestId('mobile-state')).toHaveTextContent('mobile'); + }); + + it('cleans up the media query listener on unmount', () => { + const removeSpy = vi.spyOn(mql, 'removeEventListener'); + const { unmount } = render(); + unmount(); + expect(removeSpy).toHaveBeenCalledWith('change', expect.any(Function)); + }); +}); diff --git a/src/hooks/__tests__/useRelativeTime.test.ts b/src/hooks/__tests__/useRelativeTime.test.ts new file mode 100644 index 00000000..86e4d2a1 --- /dev/null +++ b/src/hooks/__tests__/useRelativeTime.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useRelativeTime } from '@/hooks/useRelativeTime'; + +describe('useRelativeTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-26T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns a seconds-relative string for a timestamp 30 seconds ago', () => { + const { result } = renderHook(() => + useRelativeTime(new Date(Date.now() - 30_000)) + ); + expect(result.current).toMatch(/just now/i); + }); + + it('returns a minutes-relative string for a timestamp 90 seconds ago', () => { + const { result } = renderHook(() => + useRelativeTime(new Date(Date.now() - 90_000)) + ); + expect(result.current).toMatch(/minute/i); + }); + + it('returns an hours-relative string for a timestamp 2 hours ago', () => { + const { result } = renderHook(() => + useRelativeTime(new Date(Date.now() - 2 * 60 * 60 * 1000)) + ); + expect(result.current).toMatch(/hour/i); + }); + + it('returns a days-relative string for a timestamp 3 days ago', () => { + const { result } = renderHook(() => + useRelativeTime(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)) + ); + expect(result.current).toMatch(/day/i); + }); + + it('updates the output after the 60-second refresh interval', () => { + const base = Date.now(); + const { result } = renderHook(() => + useRelativeTime(new Date(base - 30_000)) + ); + + expect(result.current).toMatch(/just now/i); + + act(() => { + vi.setSystemTime(new Date(base + 60_000)); + vi.advanceTimersByTime(60_000); + }); + + expect(result.current).toMatch(/minute/i); + }); + + it('returns N/A for a null timestamp', () => { + const { result } = renderHook(() => useRelativeTime(null)); + expect(result.current).toBe('N/A'); + }); + + it('returns N/A for an undefined timestamp', () => { + const { result } = renderHook(() => useRelativeTime(undefined)); + expect(result.current).toBe('N/A'); + }); + + it('returns N/A for an invalid date string', () => { + const { result } = renderHook(() => useRelativeTime('not-a-date')); + expect(result.current).toBe('N/A'); + }); + + it('cleans up the interval on unmount', () => { + const clearIntervalSpy = vi.spyOn(window, 'clearInterval'); + const { unmount } = renderHook(() => + useRelativeTime(new Date(Date.now() - 60_000)) + ); + unmount(); + expect(clearIntervalSpy).toHaveBeenCalled(); + clearIntervalSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useCreatorHolderCount.ts b/src/hooks/useCreatorHolderCount.ts new file mode 100644 index 00000000..ff4d14f0 --- /dev/null +++ b/src/hooks/useCreatorHolderCount.ts @@ -0,0 +1,31 @@ +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, + }; +} diff --git a/src/hooks/useCreators.ts b/src/hooks/useCreators.ts new file mode 100644 index 00000000..1ea1cec2 --- /dev/null +++ b/src/hooks/useCreators.ts @@ -0,0 +1,40 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import type { GetCoursesParams } from '@/services/course.service'; + +export function useCreatorList(params?: GetCoursesParams) { + return useQuery({ + queryKey: queryKeys.creators.list(params), + queryFn: async () => [], + }); +} + +export function useCreatorDetail(id: string) { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: queryKeys.creators.detail(id), + queryFn: async () => { + const key = queryKeys.creators.detail(id); + const cached = queryClient.getQueryData(key); + const isCacheMiss = cached === undefined; + + if (isCacheMiss && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') { + const startMs = Date.now(); + const result = await Promise.resolve(null); + const duration_ms = Date.now() - startMs; + + console.debug('[creator-profile]', { + creator_id: id, + cache_status: 'miss', + duration_ms, + }); + + return result; + } + + return null; + }, + enabled: !!id, + }); +} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..b01db7c1 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react'; + +export function useDebounce(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/src/hooks/useFormatXlm.ts b/src/hooks/useFormatXlm.ts new file mode 100644 index 00000000..88431c1f --- /dev/null +++ b/src/hooks/useFormatXlm.ts @@ -0,0 +1,47 @@ +import { STROOPS_PER_XLM } from '@/constants/stellar'; + +export interface FormatXlmOptions { + /** Number of decimal places to display. Defaults to 2. */ + decimals?: number; +} + +/** + * Converts a stroop amount to a formatted XLM string. + * + * @param stroops - Amount in stroops (1 XLM = 10,000,000 stroops) + * @param options - Formatting options + * @returns Formatted XLM string, e.g. "1.50" for 15,000,000 stroops + * + * @example + * formatXlm(10_000_000) // "1.00" + * formatXlm(10_000_000, { decimals: 0 }) // "1" + * formatXlm(15_000_000, { decimals: 7 }) // "1.5000000" + */ +export function formatXlm( + stroops: number, + options: FormatXlmOptions = {} +): string { + const { decimals = 2 } = options; + const xlm = stroops / STROOPS_PER_XLM; + + return new Intl.NumberFormat(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: true, + }).format(xlm); +} + +/** + * Hook that exposes a formatXlm formatter bound to the app locale. + * + * Returns a stable `format` function that converts a stroop amount to a + * locale-aware XLM string. + * + * @example + * const { format } = useFormatXlm(); + * format(10_000_000) // "1.00" + * format(10_000_000, { decimals: 0 }) // "1" + */ +export function useFormatXlm() { + return { format: formatXlm }; +} diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts new file mode 100644 index 00000000..ee51f0bd --- /dev/null +++ b/src/hooks/useInfiniteScroll.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react'; + +interface UseInfiniteScrollOptions { + /** Whether the observer should be active (e.g. only in infinite-scroll mode). */ + enabled: boolean; + /** Whether there is more content left to load. */ + hasMore: boolean; + /** Called when the sentinel enters the viewport and more content should load. */ + onLoadMore: () => void; + rootMargin?: string; +} + +/** + * Attaches an IntersectionObserver to a sentinel element and calls + * `onLoadMore` once it scrolls into view. Falls back to doing nothing when + * IntersectionObserver isn't available (SSR, old browsers) so callers should + * always pair this with a manual "Load more" control. + */ +export function useInfiniteScroll({ + enabled, + hasMore, + onLoadMore, + rootMargin = '200px', +}: UseInfiniteScrollOptions) { + const sentinelRef = useRef(null); + const onLoadMoreRef = useRef(onLoadMore); + onLoadMoreRef.current = onLoadMore; + + useEffect(() => { + if (!enabled || !hasMore) return; + if (typeof IntersectionObserver === 'undefined') return; + + const node = sentinelRef.current; + if (!node) return; + + const observer = new IntersectionObserver( + entries => { + if (entries[0]?.isIntersecting) { + onLoadMoreRef.current(); + } + }, + { rootMargin } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [enabled, hasMore, rootMargin]); + + return sentinelRef; +} diff --git a/src/hooks/useIsMobile.ts b/src/hooks/useIsMobile.ts new file mode 100644 index 00000000..d30ac864 --- /dev/null +++ b/src/hooks/useIsMobile.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +/** Viewport widths below this value (in px) are treated as mobile. */ +export const MOBILE_BREAKPOINT_PX = 768; + +const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX - 1}px)`; + +/** + * Returns `true` when the viewport width is below {@link MOBILE_BREAKPOINT_PX}. + */ +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia(MOBILE_MEDIA_QUERY).matches; + }); + + useEffect(() => { + const media = window.matchMedia(MOBILE_MEDIA_QUERY); + const onChange = (event: MediaQueryListEvent) => { + setIsMobile(event.matches); + }; + + setIsMobile(media.matches); + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); + }, []); + + return isMobile; +} diff --git a/src/hooks/useNavigationTiming.ts b/src/hooks/useNavigationTiming.ts new file mode 100644 index 00000000..83520258 --- /dev/null +++ b/src/hooks/useNavigationTiming.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router'; + +interface NavigationTiming { + route: string; + dom_content_loaded_ms: number; + load_event_ms: number; + time_to_first_byte_ms: number; +} + +function readNavigationTiming(): { + timing: NavigationTiming; + startTime: number; +} | null { + const entries = performance.getEntriesByType('navigation'); + if (!entries.length) return null; + + const nav = entries[0] as PerformanceNavigationTiming; + + return { + timing: { + route: window.location.pathname, + dom_content_loaded_ms: Math.round( + nav.domContentLoadedEventEnd - nav.startTime + ), + load_event_ms: Math.round(nav.loadEventEnd - nav.startTime), + time_to_first_byte_ms: Math.round( + nav.responseStart - nav.requestStart + ), + }, + startTime: nav.startTime, + }; +} + +export function useNavigationTiming() { + const location = useLocation(); + const loggedStartTime = useRef(null); + + useEffect(() => { + function emit() { + const result = readNavigationTiming(); + if (!result) return; + if (loggedStartTime.current === result.startTime) return; + + loggedStartTime.current = result.startTime; + console.debug('[navigation-timing]', result.timing); + } + + if (document.readyState === 'complete') { + emit(); + } else { + window.addEventListener('load', emit, { once: true }); + return () => window.removeEventListener('load', emit); + } + }, [location]); +} diff --git a/src/hooks/useRelativeTime.ts b/src/hooks/useRelativeTime.ts new file mode 100644 index 00000000..0562b5a4 --- /dev/null +++ b/src/hooks/useRelativeTime.ts @@ -0,0 +1,30 @@ +import { useEffect, useMemo, useState } from 'react'; +import { formatRelativeTimeLabel } from '@/utils/time.utils'; + +const REFRESH_INTERVAL_MS = 60_000; + +/** + * Returns a human-readable relative-time label for the given timestamp, + * automatically refreshing every 60 seconds so the display stays current. + */ +export function useRelativeTime( + timestamp: string | number | Date | null | undefined +): string { + const date = useMemo(() => { + if (timestamp == null) return null; + const d = timestamp instanceof Date ? timestamp : new Date(timestamp); + return Number.isNaN(d.getTime()) ? null : d; + }, [timestamp]); + + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + if (date == null) return; + const id = window.setInterval(() => setNow(new Date()), REFRESH_INTERVAL_MS); + return () => window.clearInterval(id); + }, [date]); + + if (date == null) return 'N/A'; + + return formatRelativeTimeLabel(date, now); +} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts new file mode 100644 index 00000000..849e4c60 --- /dev/null +++ b/src/hooks/useWallet.ts @@ -0,0 +1,99 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; + +export function useWalletHoldings(address: string) { + return useQuery({ + queryKey: queryKeys.wallet.holdings(address), + queryFn: async () => [], + enabled: !!address, + }); +} + +export function useWalletActivity(address: string) { + return useQuery({ + queryKey: queryKeys.wallet.activity(address), + queryFn: async () => [], + enabled: !!address, + }); +} + +export interface TradeVariables { + creatorId: string; + amount: number; + priceStroops: number | null | undefined; + price: number | null | undefined; +} + +export function useTradeMutation(address: string) { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationKey: ['trade', address], + mutationFn: async () => { + await new Promise(resolve => window.setTimeout(resolve, 900)); + return { success: true as const }; + }, + onMutate: async ({ creatorId, amount, priceStroops, price }: TradeVariables) => { + const queryKey = queryKeys.wallet.holdings(address); + + await queryClient.cancelQueries({ queryKey }); + + const previousHoldings = queryClient.getQueryData(queryKey) ?? []; + + queryClient.setQueryData(queryKey, (old = []) => { + const existing = old.find(h => h.creatorId === creatorId); + if (existing) { + return old.map(h => + h.creatorId === creatorId + ? { ...h, quantity: (h.quantity ?? 0) + amount, pending: true } + : h + ); + } + return [ + ...old, + { + creatorId, + quantity: amount, + priceStroops: priceStroops ?? null, + price: price ?? null, + pending: true, + }, + ]; + }); + + return { previousHoldings }; + }, + onError: (error, _variables, context) => { + if (context?.previousHoldings) { + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + context.previousHoldings + ); + } + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: (_data, variables) => { + queryClient.setQueryData( + queryKeys.wallet.holdings(address), + (old = []) => + old.map(h => + h.creatorId === variables.creatorId + ? { ...h, pending: false } + : h + ) + ); + showToast.transactionSuccess( + 'Trade confirmed', + `Holdings refreshed: +${variables.amount} keys.` + ); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.wallet.holdings(address) }); + }, + }); + + return mutation; +} diff --git a/src/lib/__tests__/queryKeys.test.ts b/src/lib/__tests__/queryKeys.test.ts new file mode 100644 index 00000000..ff099914 --- /dev/null +++ b/src/lib/__tests__/queryKeys.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { queryKeys } from '../queryKeys'; + +describe('queryKeys – key shapes', () => { + it('every static key is an array', () => { + expect(Array.isArray(queryKeys.creators.all)).toBe(true); + }); + + it('every factory function returns an array', () => { + expect(Array.isArray(queryKeys.creators.list())).toBe(true); + expect(Array.isArray(queryKeys.creators.detail('abc'))).toBe(true); + expect(Array.isArray(queryKeys.creators.holders('abc'))).toBe(true); + expect(Array.isArray(queryKeys.wallet.holdings('0xabc'))).toBe(true); + expect(Array.isArray(queryKeys.wallet.activity('0xabc'))).toBe(true); + }); +}); + +describe('queryKeys – shared prefixes for cache invalidation', () => { + it('creators.list shares the creators prefix with creators.all', () => { + expect(queryKeys.creators.list()[0]).toBe(queryKeys.creators.all[0]); + }); + + it('creators.detail shares the creators prefix with creators.all', () => { + expect(queryKeys.creators.detail('x')[0]).toBe(queryKeys.creators.all[0]); + }); + + it('creators.holders shares the creators prefix with creators.all', () => { + expect(queryKeys.creators.holders('x')[0]).toBe( + queryKeys.creators.all[0] + ); + }); + + it('wallet.holdings and wallet.activity share the wallet + address prefix', () => { + const addr = '0x1234'; + expect(queryKeys.wallet.holdings(addr)[0]).toBe( + queryKeys.wallet.activity(addr)[0] + ); + expect(queryKeys.wallet.holdings(addr)[1]).toBe( + queryKeys.wallet.activity(addr)[1] + ); + }); +}); + +describe('queryKeys – parameter embedding', () => { + it('creators.list embeds params at index 2', () => { + const params = { page: 2, limit: 10 }; + const key = queryKeys.creators.list(params); + expect(key[2]).toStrictEqual(params); + }); + + it('creators.list uses null when no params given', () => { + expect(queryKeys.creators.list()[2]).toBeNull(); + }); + + it('creators.detail embeds the id at index 2', () => { + expect(queryKeys.creators.detail('creator-123')[2]).toBe('creator-123'); + }); + + it('creators.holders embeds creatorId at index 1', () => { + expect(queryKeys.creators.holders('creator-456')[1]).toBe('creator-456'); + }); + + it('wallet.holdings embeds address at index 1', () => { + expect(queryKeys.wallet.holdings('0xabc')[1]).toBe('0xabc'); + }); + + it('wallet.activity embeds address at index 1', () => { + expect(queryKeys.wallet.activity('0xabc')[1]).toBe('0xabc'); + }); +}); diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts new file mode 100644 index 00000000..22314ac7 --- /dev/null +++ b/src/lib/queryKeys.ts @@ -0,0 +1,16 @@ +import type { GetCoursesParams } from '@/services/course.service'; + +export const queryKeys = { + creators: { + all: ['creators'] as const, + list: (params?: GetCoursesParams) => + ['creators', 'list', params ?? null] as const, + detail: (id: string) => ['creators', 'detail', id] as const, + holders: (creatorId: string) => + ['creators', creatorId, 'holders'] as const, + }, + wallet: { + holdings: (address: string) => ['wallet', address, 'holdings'] as const, + activity: (address: string) => ['wallet', address, 'activity'] as const, + }, +}; diff --git a/src/lib/web3/__tests__/format.test.ts b/src/lib/web3/__tests__/format.test.ts new file mode 100644 index 00000000..1e9cf283 --- /dev/null +++ b/src/lib/web3/__tests__/format.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { shortenAddress } from '../format'; + +describe('shortenAddress', () => { + it('shortens a standard Stellar address to first 4 + ... + last 4', () => { + const address = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234'; + expect(shortenAddress(address)).toBe('GABC...1234'); + }); + + it('returns the full address unchanged when it is shorter than 10 characters', () => { + const address = 'GABCDEFG'; + expect(shortenAddress(address)).toBe('GABCDEFG'); + }); + + it('returns the full address unchanged when it is exactly 9 characters', () => { + const address = 'GABCDEFGH'; + expect(shortenAddress(address)).toBe('GABCDEFGH'); + }); + + it('shortens an address that is exactly 10 characters', () => { + const address = 'GABCDEFGHI'; + expect(shortenAddress(address)).toBe('GABC...FGHI'); + }); + + it('returns an empty string when given an empty string', () => { + expect(shortenAddress('')).toBe(''); + }); +}); diff --git a/src/lib/web3/format.test.ts b/src/lib/web3/format.test.ts new file mode 100644 index 00000000..d480c532 --- /dev/null +++ b/src/lib/web3/format.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { shortenAddress } from './format'; + +describe('shortenAddress', () => { + it('truncates a standard 56-character Stellar address to first 4 + last 4 with ellipsis', () => { + const address = 'GABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv'; + expect(shortenAddress(address)).toBe('GABC…stuv'); + }); + + it('returns an address shorter than 8 characters as-is without truncation', () => { + expect(shortenAddress('GABC')).toBe('GABC'); + expect(shortenAddress('1234567')).toBe('1234567'); + }); + + it('returns an empty string for empty string input', () => { + expect(shortenAddress('')).toBe(''); + }); + + it('uses a single ellipsis character (…) as separator, not three dots', () => { + const address = 'GABCDEFGHIJKLMNO'; + expect(shortenAddress(address)).toContain('…'); + expect(shortenAddress(address)).not.toContain('...'); + }); +}); \ No newline at end of file diff --git a/src/lib/web3/format.ts b/src/lib/web3/format.ts index e760dbf6..ce385648 100644 --- a/src/lib/web3/format.ts +++ b/src/lib/web3/format.ts @@ -1,8 +1,5 @@ -export function shortenAddress( - address: string, - startLength = 6, - endLength = 4 -) { +export function shortenAddress(address: string) { if (!address) return ''; - return `${address.slice(0, startLength)}...${address.slice(-endLength)}`; + if (address.length < 10) return address; + return `${address.slice(0, 4)}...${address.slice(-4)}`; } diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 9c26ce8b..36448de2 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useDebounce } from '@/hooks/useDebounce'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; import { LayoutGroup, motion } from 'framer-motion'; import { useSearchParams } from 'react-router'; import { courseService, type Course } from '@/services/course.service'; @@ -8,11 +10,12 @@ import SearchBar from '@/components/common/SearchBar'; import StickyFilterBar from '@/components/common/StickyFilterBar'; import CreatorCard from '@/components/common/CreatorCard'; import { - CreatorGridSkeleton, CreatorHoldingsListSkeleton, CreatorProfileHeaderSkeleton, } from '@/components/common/CreatorSkeleton'; +import { CreatorCardGridSkeleton } from '@/components/common/CreatorCardSkeleton'; import EmptyState from '@/components/common/EmptyState'; +import HoldingsEmptyState from '@/components/common/HoldingsEmptyState'; import EmptySearchSuggestions from '@/components/common/EmptySearchSuggestions'; import SectionDivider from '@/components/common/SectionDivider'; import { Button } from '@/components/ui/button'; @@ -23,16 +26,19 @@ import CompactSectionSubtitle from '@/components/common/CompactSectionSubtitle'; import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid'; import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow'; import MiniStatChip from '@/components/common/MiniStatChip'; +import { FeaturedCreatorAudienceChip } from '@/components/common/FeaturedCreatorAudienceChip'; import MarketplaceSection from '@/components/common/MarketplaceSection'; import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill'; import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; +import CreatorProfileErrorState from '@/components/common/CreatorProfileErrorState'; import TransactionRetryNotice from '@/components/common/TransactionRetryNotice'; import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionTimelineState'; import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog'; import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import StellarConnectionQualityBadge from '@/components/common/StellarConnectionQualityBadge'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; +import { useTradeMutation, useWalletHoldings } from '@/hooks/useWallet'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -62,11 +68,13 @@ import { } from '@/utils/portfolioValue.utils'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition'; -import { AlertCircle, ChevronDown, RefreshCw } from 'lucide-react'; +import { creatorListKey } from '@/utils/creatorListKey.utils'; +import { Check, ChevronDown, Copy, RefreshCw } from 'lucide-react'; import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState'; import CreatorListPagination from '@/components/common/CreatorListPagination'; import CreatorListGroupSeparator from '@/components/common/CreatorListGroupSeparator'; import MarketplaceSidebar from '@/components/common/MarketplaceSidebar'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; const FEATURED_CREATOR_FACTS = [ { label: 'Membership', value: 'Collectors Circle' }, @@ -77,28 +85,8 @@ const FEATURED_CREATOR_FACTS = [ const FEATURED_CREATOR_FOLLOWER_COUNT: number | null = null; const FEATURED_CREATOR_KEY_HOLDER_COUNT = 0; - -const getFeaturedCreatorKeyHolderCopy = (count: number | null) => { - 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.', - }; -}; +const FEATURED_CREATOR_STELLAR_ADDRESS = + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; // Fallback demo data in case API fails const DEMO_CREATORS: Course[] = [ @@ -192,11 +180,13 @@ const DEMO_CREATORS: Course[] = [ const CREATOR_SORT_KEY = 'accesslayer.creator-sort'; const CREATOR_PAGE_KEY = 'accesslayer.creator-page'; const CREATOR_SCROLL_KEY = 'accesslayer.creator-scrollY'; +const CREATOR_LIST_MODE_KEY = 'accesslayer.creator-list-mode'; const MAX_CREATOR_FETCH_RETRIES = 3; const BASE_RETRY_DELAY_MS = 800; const PAGE_SIZE = 6; const FETCH_RETRY_ACTION_LABEL = 'Try again'; const DEMO_HELD_KEY_QUANTITIES = [0, 2, 1] as const; +const DEMO_WALLET_ADDRESS = 'demo-wallet-address'; const FINAL_FETCH_ERROR_COPY = 'Unable to load live creators right now. Showing fallback creators.'; const CREATOR_REFRESH_SHORTCUT_LABEL = 'Ctrl/Cmd + Alt + R'; @@ -228,50 +218,31 @@ const isCreatorRefreshShortcut = (event: KeyboardEvent) => !event.shiftKey && event.key.toLowerCase() === 'r'; -type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; +const isTradeShortcut = (event: KeyboardEvent) => + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === 't'; -interface CreatorProfileLoadErrorProps { - onRetry: () => void; - isRetrying: boolean; -} +const toPriceFilterValue = (value: string) => { + if (!value.trim()) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +}; -const CreatorProfileLoadError: React.FC = ({ - onRetry, - isRetrying, -}) => ( -
-
-
-

- Unable to load this creator profile -

-

- We couldn't load the latest profile details. Check your connection and - try again. -

- -
-); +const getCreatorListKey = (creator: Course) => + creatorListKey(Number(creator.id)); + +type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; +type CreatorListMode = 'pagination' | 'infinite'; function LandingPage() { const [creators, setCreators] = useState([]); + // Creators used for wallet holdings; kept separate from the marketplace + // list so an empty API holdings response can show zero positions while + // the browse grid still falls back to demo creators. + const [holdingsCreators, setHoldingsCreators] = useState([]); // Last successful fetch timestamp (#301). `null` means we've never // resolved a load yet — the staleness helper treats that as "stale" // so the warning surfaces if the load hangs. @@ -282,7 +253,12 @@ function LandingPage() { const [isLoading, setIsLoading] = useState(true); const [isFilterLoading, setIsFilterLoading] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQuery] = useState(() => { + return searchParams.get('search') ?? searchParams.get('q') ?? ''; + }); + const debouncedSearchQuery = useDebounce(searchQuery, 300); + const [minPriceFilter, setMinPriceFilter] = useState(''); + const [maxPriceFilter, setMaxPriceFilter] = useState(''); const searchQueryRef = useRef(''); const sortOptionRef = useRef('featured'); const PROFILE_TABS = ['overview', 'creations', 'collectors', 'activity']; @@ -296,10 +272,14 @@ function LandingPage() { const [tradeSide, setTradeSide] = useState('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); const [tradeSubmitting, setTradeSubmitting] = useState(false); + const [stellarAddressCopied, setStellarAddressCopied] = useState(false); const prefersReducedMotion = usePrefersReducedMotion(); const [sortOption, setSortOption] = useState(() => { const sort = searchParams.get('sort') as SortOption | null; - if (sort && ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort)) { + if ( + sort && + ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort) + ) { sortOptionRef.current = sort; return sort; } @@ -332,17 +312,20 @@ function LandingPage() { const parsed = saved ? Number(saved) : 0; return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; }); + // Infinite scroll is an alternative to pagination for browsing the + // creator list; the chosen mode is remembered across visits. + const [listMode, setListMode] = useState(() => { + if (typeof window === 'undefined') return 'pagination'; + const saved = window.localStorage.getItem(CREATOR_LIST_MODE_KEY); + return saved === 'infinite' ? 'infinite' : 'pagination'; + }); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const pendingScrollRestoreRef = useRef(null); const shortcutConfirmationTimerRef = useRef(null); // Keep refs in sync with state - useEffect(() => { - searchQueryRef.current = searchQuery; - }, [searchQuery]); - - useEffect(() => { - sortOptionRef.current = sortOption; - }, [sortOption]); + searchQueryRef.current = searchQuery; + sortOptionRef.current = sortOption; // Use scroll preservation for profile tabs useScrollPreservation(activeProfileTab, { @@ -365,31 +348,55 @@ function LandingPage() { useEffect(() => { const newParams = new URLSearchParams(searchParams); - if (searchQuery.trim()) { - newParams.set('q', searchQuery.trim()); + let changed = false; + + const trimmedSearch = searchQuery.trim(); + const currentSearch = searchParams.get('search') ?? searchParams.get('q'); + + if (trimmedSearch) { + if (currentSearch !== trimmedSearch || searchParams.has('q')) { + newParams.set('search', trimmedSearch); + newParams.delete('q'); + changed = true; + } } else { - newParams.delete('q'); + if (searchParams.has('search') || searchParams.has('q')) { + newParams.delete('search'); + newParams.delete('q'); + changed = true; + } } + + const currentSort = searchParams.get('sort'); if (sortOption !== 'featured') { - newParams.set('sort', sortOption); + if (currentSort !== sortOption) { + newParams.set('sort', sortOption); + changed = true; + } } else { - newParams.delete('sort'); + if (searchParams.has('sort')) { + newParams.delete('sort'); + changed = true; + } + } + + if (changed) { + setSearchParams(newParams, { replace: true }); } - setSearchParams(newParams, { replace: true }); }, [searchQuery, sortOption, searchParams, setSearchParams]); useEffect(() => { - const q = searchParams.get('q'); - if (q !== null && q !== searchQueryRef.current) { - setSearchQuery(q); - } else if (q === null && searchQueryRef.current !== '') { - setSearchQuery(''); + const searchVal = searchParams.get('search') ?? searchParams.get('q') ?? ''; + if (searchVal !== searchQueryRef.current) { + setSearchQuery(searchVal); } const sort = searchParams.get('sort') as SortOption | null; - if (sort && ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort) && sort !== sortOptionRef.current) { - setSortOption(sort); - } else if (sort === null && sortOptionRef.current !== 'featured') { - setSortOption('featured'); + const validSort: SortOption = + sort && ['featured', 'price-asc', 'price-desc', 'supply-desc'].includes(sort) + ? (sort as SortOption) + : 'featured'; + if (validSort !== sortOptionRef.current) { + setSortOption(validSort); } }, [searchParams]); @@ -398,6 +405,11 @@ function LandingPage() { window.sessionStorage.setItem(CREATOR_PAGE_KEY, String(page)); }, [page]); + useEffect(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(CREATOR_LIST_MODE_KEY, listMode); + }, [listMode]); + useEffect(() => { if (typeof window === 'undefined') return; const handleScroll = () => { @@ -447,17 +459,36 @@ function LandingPage() { setShowRetryBanner(false); setFinalFetchError(''); try { - const data = await courseService.getCourses(); + const minPrice = toPriceFilterValue(minPriceFilter); + const maxPrice = toPriceFilterValue(maxPriceFilter); + const params = { + ...(minPrice !== undefined ? { min_price: minPrice } : {}), + ...(maxPrice !== undefined ? { max_price: maxPrice } : {}), + ...(debouncedSearchQuery.trim() + ? { search: debouncedSearchQuery.trim() } + : {}), + ...(sortOption !== 'featured' ? { sort: sortOption } : {}), + }; + const data = await courseService.getCourses( + Object.keys(params).length > 0 ? params : undefined + ); if (data && data.length > 0) { setCreators(data); + setHoldingsCreators(data); } else { setCreators(DEMO_CREATORS); + setHoldingsCreators([]); } // Track the last successful fetch so the stale-data warning // has a baseline to compare against (#301). setCreatorsFetchedAt(Date.now()); setFetchRetryAttempt(0); } catch { + if (fetchRetryAttempt === 0) { + showToast.error( + 'Unable to load creators. Check your connection and try again.' + ); + } if (fetchRetryAttempt < MAX_CREATOR_FETCH_RETRIES) { const nextAttempt = fetchRetryAttempt + 1; setShowRetryBanner(true); @@ -476,13 +507,21 @@ function LandingPage() { setShowRetryBanner(false); setFetchRetryAttempt(0); setCreators(DEMO_CREATORS); + setHoldingsCreators(DEMO_CREATORS); } finally { - setTimeout(() => setIsLoading(false), 800); + setIsLoading(false); } }; fetchCreators(); - }, [fetchRetryAttempt, fetchRequestId]); + }, [ + fetchRetryAttempt, + fetchRequestId, + maxPriceFilter, + minPriceFilter, + debouncedSearchQuery, + sortOption, + ]); const searchSuggestions = useMemo(() => { const fromCategories = creators @@ -546,8 +585,16 @@ function LandingPage() { useEffect(() => { setPage(0); + setVisibleCount(PAGE_SIZE); }, [trimmedSearchQuery, sortOption]); + // Switching modes starts the newly active view from the top of the + // filtered results rather than wherever the other mode left off. + useEffect(() => { + setPage(0); + setVisibleCount(PAGE_SIZE); + }, [listMode]); + const totalPages = Math.max( 1, Math.ceil(filteredCreators.length / PAGE_SIZE) @@ -557,10 +604,29 @@ function LandingPage() { const start = safePage * PAGE_SIZE; return filteredCreators.slice(start, start + PAGE_SIZE); }, [filteredCreators, safePage]); - const featuredCreatorKeyHolderCopy = getFeaturedCreatorKeyHolderCopy( - FEATURED_CREATOR_KEY_HOLDER_COUNT + const safeVisibleCount = Math.min( + Math.max(visibleCount, PAGE_SIZE), + Math.max(filteredCreators.length, PAGE_SIZE) + ); + const infiniteCreators = useMemo( + () => filteredCreators.slice(0, safeVisibleCount), + [filteredCreators, safeVisibleCount] ); + const hasMoreInfinite = safeVisibleCount < filteredCreators.length; + const visibleCreators = + listMode === 'infinite' ? infiniteCreators : pagedCreators; + const handleLoadMoreInfinite = useCallback(() => { + setVisibleCount(count => + Math.min(count + PAGE_SIZE, filteredCreators.length) + ); + }, [filteredCreators.length]); + + const infiniteScrollSentinelRef = useInfiniteScroll({ + enabled: listMode === 'infinite' && !isLoading && !isFilterLoading, + hasMore: hasMoreInfinite, + onLoadMore: handleLoadMoreInfinite, + }); // Choose the featured creator from live data when available, otherwise // fall back to the demo featured creator. This keeps the profile panel // reactive to backend updates (supply, price, etc.). @@ -581,6 +647,10 @@ function LandingPage() { }; const handleResetSearch = () => setSearchQuery(''); + const handleClearPriceFilters = () => { + setMinPriceFilter(''); + setMaxPriceFilter(''); + }; const handleRetryCreatorFetch = useCallback(() => { setFinalFetchError(''); @@ -646,20 +716,28 @@ function LandingPage() { handleRetryCreatorFetch(); }; + const tradeMutation = useTradeMutation(DEMO_WALLET_ADDRESS); + const { data: cachedHoldings = [] } = useWalletHoldings(DEMO_WALLET_ADDRESS); + const heldKeyPositions = useMemo( () => - creators.map((creator, index) => ({ - creatorId: creator.id, - quantity: + holdingsCreators.map((creator, index) => { + const cached = cachedHoldings.find(h => h.creatorId === creator.id); + const baseQuantity = index === 0 ? featuredHoldings - : (DEMO_HELD_KEY_QUANTITIES[index] ?? 0), - priceStroops: creator.priceStroops, - price: creator.price, - isPriceLoading: isPriceRefreshing, - isPriceStale: creatorsAreStale, - })), - [creators, creatorsAreStale, featuredHoldings, isPriceRefreshing] + : (DEMO_HELD_KEY_QUANTITIES[index] ?? 0); + return { + creatorId: creator.id, + quantity: cached?.quantity ?? baseQuantity, + priceStroops: creator.priceStroops, + price: creator.price, + isPriceLoading: isPriceRefreshing, + isPriceStale: creatorsAreStale, + pending: cached?.pending ?? false, + }; + }), + [holdingsCreators, creatorsAreStale, featuredHoldings, isPriceRefreshing, cachedHoldings] ); const portfolioValue = useMemo( () => calculatePortfolioValue(heldKeyPositions), @@ -679,42 +757,74 @@ function LandingPage() { displayedPortfolioValue ); - const openTradeDialog = (side: TradeSide) => { + const openTradeDialog = useCallback((side: TradeSide) => { setTradeSide(side); setTradeDialogOpen(true); - }; + }, []); - const handleConfirmTrade = async (amount: number) => { - const previousHoldings = featuredHoldings; - setTradeSubmitting(true); + // Issue 554: T key opens the trade panel from the creator profile page. + useEffect(() => { + const handleTradeShortcut = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.repeat || + !isTradeShortcut(event) || + isEditableShortcutTarget(event.target) + ) { + return; + } - try { - showToast.loading( - tradeSide === 'buy' - ? `Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...` - : `Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...` - ); + event.preventDefault(); + openTradeDialog('buy'); + }; - await new Promise(resolve => window.setTimeout(resolve, 900)); + window.addEventListener('keydown', handleTradeShortcut); + return () => window.removeEventListener('keydown', handleTradeShortcut); + }, [openTradeDialog]); - setFeaturedHoldings(current => - tradeSide === 'buy' - ? current + amount - : Math.max(0, current - amount) - ); + const handleCopyStellarAddress = async () => { + try { + await copyTextToClipboard(FEATURED_CREATOR_STELLAR_ADDRESS); + setStellarAddressCopied(true); + showToast.success('Address copied to clipboard', { duration: 2000 }); + setTimeout(() => setStellarAddressCopied(false), 2000); + } catch { + showToast.error('Could not copy the Stellar address. Please copy it manually.'); + } + }; - await new Promise(resolve => window.setTimeout(resolve, 250)); + const handleConfirmTrade = async (amount: number) => { + setTradeSubmitting(true); - showToast.transactionSuccess( - 'Trade confirmed', - tradeSide === 'buy' - ? `Holdings refreshed: +${formatNumber(amount)} keys.` - : `Holdings refreshed: -${formatNumber(amount)} keys.` - ); + try { + if (tradeSide === 'buy') { + showToast.loading( + `Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...` + ); + await tradeMutation.mutateAsync({ + creatorId: '1', + amount, + priceStroops: resolveCreatorKeyPriceStroops(featuredCreator), + price: featuredCreator?.price, + }); + setFeaturedHoldings(current => current + amount); + } else { + showToast.loading( + `Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...` + ); + await new Promise(resolve => window.setTimeout(resolve, 900)); + setFeaturedHoldings(current => Math.max(0, current - amount)); + await new Promise(resolve => window.setTimeout(resolve, 250)); + showToast.transactionSuccess( + 'Trade confirmed', + `Holdings refreshed: -${formatNumber(amount)} keys.` + ); + } setTradeDialogOpen(false); } catch (error) { - setFeaturedHoldings(previousHoldings); - showToast.error(getSignatureErrorMessage(error)); + if (tradeSide === 'sell') { + showToast.error(getSignatureErrorMessage(error)); + } } finally { setTradeSubmitting(false); } @@ -824,6 +934,57 @@ function LandingPage() {
+
+
+ + + setMinPriceFilter(event.target.value) + } + className="mt-1 h-10 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60" + /> +
+
+ + + setMaxPriceFilter(event.target.value) + } + className="mt-1 h-10 w-full rounded-lg border border-white/15 bg-slate-950/80 px-3 text-sm text-white outline-none focus:border-amber-400/60" + /> +
+ +
Shortcut -