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/accesslayer-client.zip b/accesslayer-client.zip new file mode 100644 index 00000000..3262939a Binary files /dev/null and b/accesslayer-client.zip differ diff --git a/docs/adding-page-and-data-fetching.md b/docs/adding-page-and-data-fetching.md new file mode 100644 index 00000000..50b311c9 --- /dev/null +++ b/docs/adding-page-and-data-fetching.md @@ -0,0 +1,464 @@ +# Contributing a New Page: Routing and Data Fetching + +This guide walks you through adding a new page to the Access Layer client. It covers route registration, component structure, React Query data-fetching conventions, and layout component usage. + +--- + +## Quick Start + +1. **Create the page component** at `src/pages/YourNamePage.tsx` (PascalCase + `Page` suffix) +2. **Register the route** in `src/routes.tsx` +3. **Handle data fetching** using React Query hooks following the query key factory pattern +4. **Manage loading and error states** with skeletons and error boundaries +5. **Use shared layouts** where they fit; create new ones only if needed + +--- + +## Part 1: File Structure and Naming Conventions + +### Page Component Location and Naming + +Every page lives in `src/pages/` with a consistent naming pattern: + +| Item | Convention | Example | +| ----------------- | ------------------------------------ | --------------------------------------------- | +| **File location** | `src/pages/` | `src/pages/CreatorDetailPage.tsx` | +| **File name** | PascalCase + `Page` suffix | `CreatorDetailPage.tsx`, `DashboardPage.tsx` | +| **Export** | Default export, function declaration | `export default function CreatorDetailPage()` | + +A page component takes **no props**. It owns its layout, fetching, and state: + +```tsx +// src/pages/CreatorDetailPage.tsx + +export default function CreatorDetailPage() { + // No props here ↑ + + return ( +
+

Page Title

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

` and subsections in `

`, `

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

{data.title}

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

+ All Creators +

+
+ {creators.map(creator => ( + + ))} +
+
+ ); +} + +export default function CreatorListPage() { + return ; +} +``` + +### Step 2: Add the Query Hook + +```ts +// src/hooks/useCreatorList.ts +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { courseService } from '@/services/course.service'; + +export function useCreatorList() { + return useQuery({ + queryKey: queryKeys.creators.list(), + queryFn: () => courseService.getCourses(), + }); +} +``` + +### Step 3: Register the Route + +```tsx +// src/routes.tsx +import HomePage from './pages/HomePage'; +import CreatorListPage from './pages/CreatorListPage'; // ← add import + +export const routes = [ + { + path: '/', + element: , + }, + { + path: '/creators', + element: , // ← add route + }, + { + path: '*', + element: , + }, +]; +``` + +### Step 4: Verify + +```bash +pnpm dev # visit http://localhost:5173/creators +pnpm lint +pnpm build +``` + +--- + +## Key Files Reference + +| File | Purpose | +| --------------------------------------- | ---------------------------------------------------------- | +| `src/routes.tsx` | Route registration — the single source of truth | +| `src/pages/` | All page components live here (one file per page) | +| `src/hooks/` | Custom React Query hooks for data fetching | +| `src/services/` | Service layer classes wrapping API calls | +| `src/lib/queryKeys.ts` | Central query key factory | +| `src/components/common/` | Shared layout, skeleton, and error boundary components | +| `src/components/ui/` | Reusable UI primitives (Button, Input, etc.) | +| `docs/react-query-cache-conventions.md` | Full React Query patterns (cache invalidation, stale time) | +| `docs/api-layer.md` | Service layer conventions and API error handling | +| `docs/shared-components.md` | Shared component library and styling guide | +| `docs/environment-variables.md` | Environment variables reference | + +--- + +## Cross-references + +- [React Query Cache Conventions](./react-query-cache-conventions.md) — detailed query key design and cache invalidation patterns +- [API Layer Conventions](./api-layer.md) — service layer design, error handling, and request/response patterns +- [Shared Components Guide](./shared-components.md) — existing layout and UI components to reuse +- [Environment Variables](./environment-variables.md) — API endpoints and configuration +- [Contributing Guide](../CONTRIBUTING.md) — general project conventions and PR workflow diff --git a/docs/adding-page-routes.md b/docs/adding-page-routes.md new file mode 100644 index 00000000..519c70de --- /dev/null +++ b/docs/adding-page-routes.md @@ -0,0 +1,273 @@ +# Adding a New Page Route + +This guide explains how to add a new route to the Access Layer client. It covers where routes are registered, the file naming convention for page components, and how to mark a route as auth-protected. + +--- + +## Where routes are registered + +Every route in the client is declared in a **single source of truth** at the top of `src/App.tsx`. The router is built once with `createBrowserRouter([...])` and passed to ``. To add a new route, append a `{ path, element }` entry to that array. + +```tsx +// src/App.tsx +import { createBrowserRouter, RouterProvider } from 'react-router'; +import HomePage from './pages/HomePage'; +import NotFoundPage from './pages/NotFoundPage'; +import AboutPage from './pages/AboutPage'; // ← new import + +const router = createBrowserRouter([ + { + path: '/', + element: , + }, + { + path: '/about', // ← new public route + element: , + }, + { + path: '*', // catch-all stays last + element: , + }, +]); +``` + +Key things to know: + +- **Order matters** only for the `*` catch-all — keep it as the **last entry** so it does not shadow real routes. +- **Imports** for new page components live at the top of `src/App.tsx` alongside the existing ones. Use the relative `'./pages/Page'` path shown above, matching the existing imports. +- **Do not** create a second router or wrap the app in another ``. The router configured here is the only one. +- Nested routes for sub-pages (for example `/creators/:handle/keys`) are added the same way — just declare the full pattern on each entry. The current client uses flat routes only. + +--- + +## File naming convention for page components + +| What | Convention | Example | +| ------------------ | ----------------------------------------------- | ------------------------------------- | +| File location | `src/pages/` | `src/pages/HomePage.tsx` | +| File name | PascalCase + `Page` suffix | `AboutPage.tsx` | +| Exported component | Default export of a function named `Page` | `export default function AboutPage()` | + +The component itself uses `export default function Page()` — not a named export, and not an arrow const. This keeps imports straightforward and matches every existing page in `src/pages/`: + +``` +src/pages/ +├── HomePage.tsx // registered as '/' +├── MarketingPage.tsx // exists on disk, not yet registered +├── LandingPage.tsx // exists on disk, not yet registered +└── NotFoundPage.tsx // registered as '*' (catch-all) +``` + +A few pages exist as files but are not currently wired into the router in `src/App.tsx`. They are kept on disk because they are planned routes waiting on the marketplace flows to land. If you need one of them live, follow this guide to register it like any other page. + +### What a page component looks like + +Top-level page components take **no props**. They own their own layout, fetching, and state. A minimal page is just a function that returns JSX: + +```tsx +// src/pages/AboutPage.tsx +export default function AboutPage() { + return ( +
+

+ About Access Layer +

+

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

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

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

+ About Access Layer +

+

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

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

Loading…

; + if (error) return

Failed to load holdings.

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

Loading…

; + if (error) return

Could not load holdings.

; + if (!holdings?.length) return

No holdings yet.

; + + return ( +
    + {holdings.map(h => ( +
  • + {h.creatorId} — {h.quantity} keys at {h.priceStroops} stroops +
  • + ))} +
+ ); +} +``` + +--- + +## Key files at a glance + +| File | Purpose | +| -------------------------------- | ------------------------------------------------- | +| `src/services/api.service.ts` | `BaseApiService`, `ApiError`, `APIResponse` types | +| `src/services/auth.service.ts` | Auth endpoints (login, register, profile) | +| `src/services/course.service.ts` | Creator / course endpoints | +| `src/lib/queryKeys.ts` | Centralised React Query key constants | +| `src/providers/Web3Provider.tsx` | `QueryClientProvider` setup | diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 00000000..fd805cac --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,99 @@ +# Environment Variable Guide + +This guide explains how to add a new client environment variable safely and consistently in Access Layer Client. + +The client is built with Vite, so any value that must be available in browser code must use the `VITE_` prefix. Values without that prefix are not exposed to the client bundle. + +## Files involved + +| File | Purpose | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `.env.example` | Documents every supported variable and provides safe local defaults or blank optional placeholders. | +| `src/utils/env.utils.ts` | Validates environment variables at startup with Zod and exports the typed `env` object used by application code. | +| `.env` | Local developer overrides. This file should not be committed. | + +## Add a new variable + +1. Add the variable to `.env.example`. +2. Add validation for the variable in `src/utils/env.utils.ts`. +3. Pass the raw `import.meta.env` value into the `envSchema.parse(...)` call in `src/utils/env.utils.ts`. +4. Import the validated `env` object in application code. +5. Avoid reading `import.meta.env` directly from components, hooks, or service files. + +## Declaration pattern + +Add the new variable to `.env.example` near related settings. Use a short comment that explains what the value controls and whether it is required. + +```env +# Feature flag for the creator discovery experiment. Use `true` to enable locally. +VITE_ENABLE_CREATOR_DISCOVERY=false +``` + +Prefer safe development defaults when the app can run without secrets. Leave optional third-party keys blank if a contributor can work without them. + +## Runtime validation pattern + +All supported variables should be declared in `src/utils/env.utils.ts` so missing or malformed configuration is caught in one place. + +```ts +const envSchema = z.object({ + VITE_ENABLE_CREATOR_DISCOVERY: z.coerce.boolean().default(false), +}); + +export const env = envSchema.parse({ + VITE_ENABLE_CREATOR_DISCOVERY: import.meta.env.VITE_ENABLE_CREATOR_DISCOVERY, +}); +``` + +Use the Zod type that matches how the app consumes the value: + +| Value type | Validation example | +| --------------- | ----------------------------------------------- | +| Required string | `z.string().min(1, "VITE_API_KEY is required")` | +| Optional string | `z.string().optional()` | +| Number | `z.coerce.number().default(84532)` | +| Boolean flag | `z.coerce.boolean().default(false)` | + +If a value is required for the app to start, avoid a silent fallback. Use `.min(1, "... is required")` or another explicit validation rule so the startup error points to the missing variable. + +## Access pattern in application code + +Import `env` from the validation module and read the typed value from there: + +```ts +import { env } from '@/utils/env.utils'; + +if (env.VITE_ENABLE_CREATOR_DISCOVERY) { + // Render or enable the feature. +} +``` + +This keeps validation, defaults, and type coercion centralized. + +## Anti-pattern: direct component access + +Do not import or read `import.meta.env` directly in components, hooks, services, or utilities outside the validation module. + +```tsx +// Avoid this. +const backendUrl = import.meta.env.VITE_BACKEND_URL; +``` + +Direct access bypasses schema validation, makes defaults inconsistent, and spreads environment knowledge across the app. Use `env` instead: + +```tsx +import { env } from '@/utils/env.utils'; + +const backendUrl = env.VITE_BACKEND_URL; +``` + +## Required vs optional checklist + +Use this checklist before opening a PR that adds a new variable: + +- The variable is listed in `.env.example`. +- The variable has a clear comment describing its purpose. +- Required values fail fast in `src/utils/env.utils.ts` with a useful error. +- Optional values use `.optional()` or a safe `.default(...)`. +- Application code reads from `env`, not `import.meta.env`. +- The variable name starts with `VITE_` if browser code needs it. diff --git a/docs/error-handling-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/testing-conventions.md b/docs/testing-conventions.md new file mode 100644 index 00000000..a44bf63c --- /dev/null +++ b/docs/testing-conventions.md @@ -0,0 +1,163 @@ +# Testing Conventions + +How tests are structured in this repo, how to mock the seams (React Query, +wallet, browser APIs), and how to set up an integration test. For +util-specific guidance see the [Utils Testing Guide](./utils-testing-guide.md); +for what hooks should do on failure paths (and therefore what your tests +should assert), see [Error Handling in Hooks](./error-handling-in-hooks.md). + +The runner is **Vitest** (`vitest.config.ts`: jsdom environment, globals +enabled, setup in `src/test/setup.ts`). Run everything with `pnpm test`, or a +single file with `pnpm test `. + +## File naming and co-location + +Tests live in a `__tests__/` folder next to the code they exercise: + +``` +src/hooks/ + ├─ useFormatXlm.ts + └─ __tests__/ + └─ useFormatXlm.test.ts +src/pages/ + ├─ LandingPage.tsx + └─ __tests__/ + ├─ LandingPage.holdings.test.tsx ← unit-ish page test + └─ LandingPage.sellFlow.integration.test.tsx ← integration test +``` + +- **Unit tests**: `.test.ts` / `.test.tsx`. +- **Integration tests**: `..integration.test.tsx` — one flow + per file, named after the feature under test. Components may also co-locate + a test directly beside the file (e.g. + `src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx`). +- Reference the issue number in the top-level `describe` when the test + exists to lock in an issue's acceptance criteria, e.g. + `describe('LandingPage sell flow end-to-end (#644)', …)`. + +## Mocking React Query responses + +There are two established patterns — pick based on what the test is about. + +**1. Mock the service, keep React Query real** (preferred for integration +tests — caching, invalidation and optimistic updates stay honest): + +```tsx +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { courseService } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourses: vi.fn() }, +})); +const mockGetCourses = vi.mocked(courseService.getCourses); + +const renderPage = () => + render( + + + + + + ); + +// in the test: +mockGetCourses.mockResolvedValue([…fixtures…]); +``` + +Always create a **fresh `QueryClient` per render** (never share one between +tests — cached data leaks across cases) and disable retries so failure-path +tests don't wait on backoff. + +**2. Mock the hook module wholesale** (for unit tests where query machinery +is noise): + +```tsx +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); +``` + +Anything rendering a component that calls `useQuery`/`useMutation` **must** +be wrapped in a `QueryClientProvider` unless every such hook is mocked out — +a missing provider fails with `No QueryClient set`. + +## Mocking wallet connection state + +Wallet state flows through the hooks in `src/hooks/useWallet.ts` +(`useWalletHoldings`, `useWalletActivity`, `useTradeMutation`). Component +tests mock at that seam: + +```tsx +vi.mock('@/hooks/useWallet', () => ({ + // "connected wallet holding 2 keys of creator-a" + useWalletHoldings: () => ({ + data: [{ creatorId: 'creator-a', quantity: 2, priceStroops: 500_000, price: 0.05, pending: false }], + }), + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); +``` + +For full-flow tests, prefer **not** mocking `useWallet` at all: the demo +wallet seeds the featured creator with 3 held keys, and the real +`useTradeMutation` exercises the optimistic-update and invalidation paths +(see `LandingPage.sellFlow.integration.test.tsx`). Trade submissions resolve +on real timers (~1.2s), so assert with +`waitFor(…, { timeout: 5000 })` rather than fake timers. + +## Integration test setup + +The standard shell for a page-level integration test: + +1. **Providers**: wrap in `QueryClientProvider` (fresh client) and + `MemoryRouter` — pages use react-router hooks. +2. **Service mocks**: `vi.mock('@/services/course.service')` and resolve + fixture data per test. +3. **Toast sink**: mock `@/utils/toast.util` and assert on + `showToast.success` / `error` / `transactionSuccess` calls instead of + scraping toast DOM (no `` is mounted in tests). +4. **Presentation mocks** (copy from an existing integration test): + `framer-motion` (pass-through elements), `@/components/common/CreatorCard` + (lightweight article), `StellarConnectionQualityBadge`, + `FeaturedCreatorAudienceChip`, and network/staleness hooks + (`useNetworkMismatch`, `useStaleData`) pinned to healthy values. +5. **Browser API stubs**, in `beforeEach`: + - `matchMedia` — jsdom doesn't implement it; use the `mockMatchMedia` + helper pattern found in the page tests. + - `localStorage` / `sessionStorage` — newer Node versions (v22+ + WebStorage, default in v25) shadow jsdom's storage with a global that + has no working methods, so `window.localStorage.clear()` throws. New + suites should install an in-memory stub (see `installStorageStub` in + `LandingPage.sellFlow.integration.test.tsx`) instead of touching the + global directly. +6. **Cleanup**: `afterEach(cleanup)` — automatic unmount is not enabled. + +## Available test utilities + +There is deliberately no shared custom `render` yet; each suite composes its +own providers. The reusable pieces to copy today: + +| Utility | Where | What it does | +|---|---|---| +| `src/test/setup.ts` | global setup | registers `@testing-library/jest-dom` matchers | +| `mockMatchMedia()` | page test files | stubs `window.matchMedia` for jsdom | +| `installStorageStub()` | `LandingPage.sellFlow.integration.test.tsx` | Node-version-proof localStorage/sessionStorage stub | +| `makeQueryClient()` | `LandingPage.sort.integration.test.tsx` | fresh `QueryClient` with retries disabled | +| `confirmTrade(side, amount)` | `LandingPage.holdingsSellBalanceUpdate.integration.test.tsx` | drives the trade dialog: open → amount → confirm | +| `dispatchRejection(reason)` | `unhandledRejectionLogger.test.ts` | synthesizes an unhandled-rejection event | + +If you find yourself copying more than two of these into a new file, that is +the signal to promote them into `src/test/` as shared utilities — do it in +the same PR. + +## What good assertions look like here + +- Assert **user-visible outcomes** (rendered text, toast calls, holdings + rows), not internal state. +- For flows with optimistic updates, assert both the intermediate state + (pending) and the settled state where practical. +- Error paths deserve their own tests — see + [Error Handling in Hooks](./error-handling-in-hooks.md) for the expected + failure behaviour to pin down. diff --git a/docs/utils-testing-guide.md b/docs/utils-testing-guide.md new file mode 100644 index 00000000..f316176d --- /dev/null +++ b/docs/utils-testing-guide.md @@ -0,0 +1,69 @@ +# Utils Testing Guide + +## Naming Convention & Co-location + +- Test files should be named **`.test.ts`** (or `.test.tsx` for React‑related helpers). +- Place the test file **side‑by‑side** with the helper it exercises, inside the same directory. + + Example directory layout: + + ``` + src/utils/ + ├─ formatNumber.utils.ts + └─ formatNumber.utils.test.ts ← test file + ``` + +## Running Only Util Tests + +The project uses **Vitest** as the test runner (configured in `vitest.config.ts`). + +- To run **all** tests: `pnpm test` +- To run **only utils** tests: + ```bash + pnpm test "src/utils/**/*.test.ts" + ``` + This pattern matches every test file under `src/utils`. + +## Worked Example Test + +Below is a simple example for a pure helper `formatNumber` that formats a number with commas and two decimal places. + +```ts +// src/utils/formatNumber.utils.test.ts +import { describe, expect, it } from 'vitest'; +import { formatNumber } from './formatNumber.utils'; + +describe('formatNumber utils', () => { + it('formats an integer with commas', () => { + expect(formatNumber(1234567)).toBe('1,234,567.00'); + }); + + it('formats a floating‑point number with two decimals', () => { + expect(formatNumber(1234.5)).toBe('1,234.50'); + }); + + it('handles negative numbers', () => { + expect(formatNumber(-9876.543)).toBe('-9,876.54'); + }); +}); +``` + +### Explanation + +- **`describe`** groups related tests under a readable heading. +- **`it`** defines individual test cases. +- **`expect(...).toBe(...)`** performs the assertion. +- Because `formatNumber` is a **pure function** (no side‑effects), we can achieve **100 % branch coverage** with the three cases above (positive, decimal, negative). + +## Branch Coverage Expectation + +- **Pure helpers** (functions that depend only on their inputs) must have **100 % branch coverage**. +- Run the coverage report with: + ```bash + pnpm test --coverage + ``` +- Ensure the generated `coverage` report shows `100%` for each pure helper file. + +--- + +_This guide lives in the repository under `docs/utils-testing-guide.md` and should be referenced by contributors when adding new utility helpers._ diff --git a/src/App.tsx b/src/App.tsx index 599195eb..421867ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,23 +2,22 @@ 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'; +import { useRouteChangeLogging } from './hooks/useRouteChangeLogging'; -const router = createBrowserRouter([ - { - path: '/', - element: , - }, - { - path: '*', - element: , - }, -]); +const router = createBrowserRouter(routes); function App() { + useNavigationTiming(); + useRouteChangeLogging(); + 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 +27,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/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/BuyPriceEstimate.tsx b/src/components/common/BuyPriceEstimate.tsx new file mode 100644 index 00000000..717fdc32 --- /dev/null +++ b/src/components/common/BuyPriceEstimate.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { + computeBuyCost, + DEFAULT_BONDING_CURVE_PARAMS, + type BondingCurveParams, +} from '@/utils/bondingCurve.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; + +export interface BuyPriceEstimateProps { + /** Current key supply for the creator being priced. */ + currentSupply: number; + /** Number of keys the user intends to buy. */ + quantity: number; + /** Bonding curve parameters; defaults to the platform-wide curve. */ + params?: BondingCurveParams; + /** Called with the quantity when the buy button is pressed. */ + onBuy?: (quantity: number) => void; +} + +/** + * Debounce before recomputing the bonding-curve price preview after the + * quantity changes. computeBuyCost() itself is synchronous, but debouncing + * avoids recalculating (and re-rendering) on every keystroke while the user + * is still typing a multi-digit quantity. + */ +const PRICE_CALCULATION_DEBOUNCE_MS = 150; + +/** + * Shows the total XLM cost to buy `quantity` keys at the current bonding + * curve price, recalculating via computeBuyCost() whenever the quantity (or + * current supply) changes. Renders a buy button only for a positive + * quantity, and 0 XLM with no buy button for a zero quantity. + */ +export default function BuyPriceEstimate({ + currentSupply, + quantity, + params = DEFAULT_BONDING_CURVE_PARAMS, + onBuy, +}: BuyPriceEstimateProps) { + const [isCalculating, setIsCalculating] = useState(false); + const [totalCostStroops, setTotalCostStroops] = useState(() => + quantity > 0 ? computeBuyCost(currentSupply, quantity, params) : 0 + ); + + useEffect(() => { + if (quantity <= 0) { + setIsCalculating(false); + setTotalCostStroops(0); + return; + } + + setIsCalculating(true); + const timer = window.setTimeout(() => { + setTotalCostStroops(computeBuyCost(currentSupply, quantity, params)); + setIsCalculating(false); + }, PRICE_CALCULATION_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [currentSupply, quantity, params]); + + const canBuy = quantity > 0 && !isCalculating; + + return ( +
+ {isCalculating ? ( + + Calculating price… + + ) : ( + + {formatDisplayKeyPrice(totalCostStroops)} + + )} + {quantity > 0 && ( + + )} +
+ ); +} diff --git a/src/components/common/ConnectWalletButton.tsx b/src/components/common/ConnectWalletButton.tsx index c72d8520..09f436fc 100644 --- a/src/components/common/ConnectWalletButton.tsx +++ b/src/components/common/ConnectWalletButton.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useAccount, useConnect, useDisconnect } from 'wagmi'; +import { Copy, Check } from 'lucide-react'; import { Dialog, DialogClose, @@ -16,12 +17,20 @@ 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'; +import { logWalletDisconnectSession } from '@/lib/walletSessionLog'; function ConnectWalletButton() { const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); + const [copied, setCopied] = useState(false); + const connectedAtRef = useRef(null); 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 +38,101 @@ 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.' + ); + } + }; + + useEffect(() => { + if (isConnected && address && connectedAtRef.current == null) { + connectedAtRef.current = Date.now(); + return; + } + + if (!isConnected) { + connectedAtRef.current = null; + } + }, [address, isConnected]); + 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/CreatorMarketplaceInfiniteList.tsx b/src/components/common/CreatorMarketplaceInfiniteList.tsx new file mode 100644 index 00000000..e92a74f4 --- /dev/null +++ b/src/components/common/CreatorMarketplaceInfiniteList.tsx @@ -0,0 +1,64 @@ +import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import CreatorCard from '@/components/common/CreatorCard'; +import { CreatorGridSkeleton } from '@/components/common/CreatorSkeleton'; +import type { GetCoursesParams } from '@/services/course.service'; + +export interface CreatorMarketplaceInfiniteListProps { + params?: Omit; +} + +/** + * Creator key marketplace listing with IntersectionObserver-driven infinite + * scroll (#685): fetches the first page on mount, then automatically fetches + * subsequent pages via useInfiniteQuery as the user scrolls the sentinel + * element into view. Shows a skeleton row while the next page is loading and + * stops fetching once the backend reports no more pages. + */ +export default function CreatorMarketplaceInfiniteList({ + params, +}: CreatorMarketplaceInfiniteListProps) { + const { + creators, + hasMore, + isLoadingFirstPage, + isFetchingNextPage, + fetchNextPage, + } = useInfiniteCreatorMarketplace(params); + + const sentinelRef = useInfiniteScroll({ + enabled: !isLoadingFirstPage && !isFetchingNextPage, + hasMore: Boolean(hasMore), + onLoadMore: () => { + void fetchNextPage(); + }, + }); + + if (isLoadingFirstPage) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {creators.map(creator => ( + + ))} +
+ + {isFetchingNextPage && ( +
+ +
+ )} + + {hasMore && ( + + ); +} diff --git a/src/components/common/CreatorPageErrorBoundary.tsx b/src/components/common/CreatorPageErrorBoundary.tsx new file mode 100644 index 00000000..8b7edf45 --- /dev/null +++ b/src/components/common/CreatorPageErrorBoundary.tsx @@ -0,0 +1,79 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { Link } from 'react-router'; +import { AlertCircle, ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ApiError } from '@/services/api.service'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +/** + * Error boundary scoped to creator detail routes. When a creator page throws + * during render, this catches the error and shows a fallback with a link back + * to the creator list instead of crashing the whole app to a blank screen. + */ +class CreatorPageErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + if (import.meta.env.DEV) { + console.error('Error rendering creator page:', error, errorInfo); + } + } + + public render() { + if (this.state.hasError) { + const isNotFound = + this.state.error instanceof ApiError && + this.state.error.status === 404; + + return ( +
+
+
+ +
+ ); + } + + 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 = ({
-
- + + + )} + + ) : onReset ? ( - )} + ) : null}
); }; export default EmptyState; + diff --git a/src/components/common/EmptyTransactionTimelineState.tsx b/src/components/common/EmptyTransactionTimelineState.tsx index 768be70e..4b449d8f 100644 --- a/src/components/common/EmptyTransactionTimelineState.tsx +++ b/src/components/common/EmptyTransactionTimelineState.tsx @@ -5,6 +5,8 @@ import { formatRecentActivityCompactTimestamp } from '@/utils/recentActivityTime import { groupEntriesByDate, formatDateHeader } from '@/utils/activityTimeline.utils'; import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement'; import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement'; +import showToast from '@/utils/toast.util'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; type CopyState = 'idle' | 'success' | 'error'; @@ -83,11 +85,14 @@ const EmptyTransactionTimelineState: React.FC< const copyTxHash = async (entryId: string, txHash: string) => { try { - await navigator.clipboard.writeText(txHash); + await copyTextToClipboard(txHash); announceCopySuccess('Transaction hash copied.'); setCopyStateById(current => ({ ...current, [entryId]: 'success' })); } catch { setCopyStateById(current => ({ ...current, [entryId]: 'error' })); + showToast.error( + 'Could not copy the transaction hash. Please copy it manually.' + ); } window.setTimeout(() => { diff --git a/src/components/common/FeaturedCreatorAudienceChip.tsx b/src/components/common/FeaturedCreatorAudienceChip.tsx new file mode 100644 index 00000000..d8dc436b --- /dev/null +++ b/src/components/common/FeaturedCreatorAudienceChip.tsx @@ -0,0 +1,24 @@ +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 ( + + ); +} 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..b695f98e 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'; @@ -47,14 +48,27 @@ const TradeDialog: React.FC = ({ const [amountText, setAmountText] = useState('1'); const [touched, setTouched] = useState(false); const amountInputRef = useRef(null); + const pricePreviewFailureLogged = useRef(false); useEffect(() => { if (open) { setAmountText('1'); setTouched(false); + pricePreviewFailureLogged.current = false; } }, [open]); + const handleBlur = () => { + setTouched(true); + const normalized = amountText.trim(); + if (normalized) { + const clampedResult = clampBuyQuantity(amountText); + if (clampedResult.adjusted) { + setAmountText(clampedResult.value.toString()); + } + } + }; + const parsedAmount = useMemo(() => { const normalized = amountText.trim(); if (!normalized) return NaN; @@ -88,6 +102,38 @@ const TradeDialog: React.FC = ({ return estimateSellProceeds(keyPriceStroops, currentSupply, parsedAmount); }, [side, keyPriceStroops, currentSupply, parsedAmount]); + useEffect(() => { + if (process.env.NODE_ENV === 'test') return; + if (!open || pricePreviewFailureLogged.current) return; + + if (side === 'buy' && keyPriceStroops == null) { + console.debug('[price-preview-failure]', { + creator_name: creatorName, + quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, + side: 'buy', + reason: 'key_price_missing', + timestamp: new Date().toISOString(), + }); + pricePreviewFailureLogged.current = true; + } + }, [open, side, keyPriceStroops, creatorName, parsedAmount]); + + useEffect(() => { + if (process.env.NODE_ENV === 'test') return; + if (!open || pricePreviewFailureLogged.current) return; + + if (side === 'sell' && estimatedProceedsStroops == null) { + console.debug('[price-preview-failure]', { + creator_name: creatorName, + quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, + side: 'sell', + reason: 'estimate_unavailable', + timestamp: new Date().toISOString(), + }); + pricePreviewFailureLogged.current = true; + } + }, [open, side, estimatedProceedsStroops, creatorName, parsedAmount]); + return ( = ({ 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..d385fd2e 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); @@ -94,6 +97,14 @@ const TransactionHistory: React.FC = () => { localStorage.setItem(COMPACT_VIEW_KEY, String(isCompact)); }, [isCompact]); + const sortedTransactions = [...transactions].sort((left, right) => { + if (left.timestamp !== right.timestamp) { + return right.timestamp - left.timestamp; + } + + return right.id.localeCompare(left.id); + }); + const toggleCompact = () => { setIsCompact(!isCompact); }; @@ -154,11 +165,13 @@ const TransactionHistory: React.FC = () => {
- {SAMPLE_TRANSACTIONS.map(tx => { + {sortedTransactions.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 +211,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 +234,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
+ + + + + Connected wallet + {/* break-all so a full address wraps instead of overflowing the menu */} + {address} + + Network: {network.label} + + + + + + { + // Keep the menu open so the copied-state tick is visible. + event.preventDefault(); + void handleCopy(); + }}> + {copied ? : } + {copied ? 'Copied' : 'Copy address'} + + + disconnect()} + className="text-red-600 focus:text-red-600" + > + + Disconnect + + + + ); +} + +export default WalletStatusChip; diff --git a/src/components/common/__tests__/BuyPriceEstimate.test.tsx b/src/components/common/__tests__/BuyPriceEstimate.test.tsx new file mode 100644 index 00000000..3784bc96 --- /dev/null +++ b/src/components/common/__tests__/BuyPriceEstimate.test.tsx @@ -0,0 +1,132 @@ +/** + * Unit tests for BuyPriceEstimate — the bonding curve price preview + * component that renders the correct total XLM amount for buying N keys + * (#684). + */ +import { render, screen } from '@testing-library/react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import BuyPriceEstimate from '@/components/common/BuyPriceEstimate'; +import * as bondingCurveUtils from '@/utils/bondingCurve.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; + +describe('BuyPriceEstimate', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('displays the correct XLM price for quantity 1', () => { + render(); + + act(() => { + vi.runAllTimers(); + }); + + const expectedCostStroops = bondingCurveUtils.computeBuyCost( + 0, + 1, + bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS + ); + expect(screen.getByTestId('buy-price-total')).toHaveTextContent( + formatDisplayKeyPrice(expectedCostStroops) + ); + expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeInTheDocument(); + }); + + it('displays 0 XLM and no buy button for quantity 0', () => { + render(); + + act(() => { + vi.runAllTimers(); + }); + + expect(screen.getByTestId('buy-price-total')).toHaveTextContent('0 XLM'); + expect( + screen.queryByTestId('buy-price-estimate-buy-button') + ).not.toBeInTheDocument(); + }); + + it('displays a higher total price for a large quantity than for quantity 1', () => { + const { unmount } = render(); + act(() => { + vi.runAllTimers(); + }); + const priceForOneText = screen.getByTestId('buy-price-total').textContent; + unmount(); + + render(); + act(() => { + vi.runAllTimers(); + }); + const priceForHundredText = screen.getByTestId('buy-price-total').textContent; + + const expectedForHundred = bondingCurveUtils.computeBuyCost( + 0, + 100, + bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS + ); + const expectedForOne = bondingCurveUtils.computeBuyCost( + 0, + 1, + bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS + ); + + expect(expectedForHundred).toBeGreaterThan(expectedForOne); + expect(priceForHundredText).not.toBe(priceForOneText); + expect(priceForHundredText).toContain('XLM'); + }); + + it('updates the displayed price when the quantity prop changes, without unmounting', () => { + const { rerender } = render(); + act(() => { + vi.runAllTimers(); + }); + const firstPrice = screen.getByTestId('buy-price-total').textContent; + + rerender(); + act(() => { + vi.runAllTimers(); + }); + const secondPrice = screen.getByTestId('buy-price-total').textContent; + + expect(secondPrice).not.toBe(firstPrice); + }); + + it('shows a loading state while the price is being (re)calculated', () => { + const { rerender } = render(); + act(() => { + vi.runAllTimers(); + }); + expect(screen.queryByTestId('buy-price-loading')).not.toBeInTheDocument(); + + rerender(); + // Before the debounce timer fires, the loading state should be visible + // and the buy button disabled (it stays mounted for layout stability, + // but must not be clickable while the price is stale/recalculating). + expect(screen.getByTestId('buy-price-loading')).toBeInTheDocument(); + expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeDisabled(); + + act(() => { + vi.runAllTimers(); + }); + expect(screen.queryByTestId('buy-price-loading')).not.toBeInTheDocument(); + expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeEnabled(); + }); + + it('calls the bonding curve calculation function with the correct arguments', () => { + const spy = vi.spyOn(bondingCurveUtils, 'computeBuyCost'); + const params = { basePriceStroops: 5_000_000, growthFactor: 1.02 }; + + render(); + act(() => { + vi.runAllTimers(); + }); + + expect(spy).toHaveBeenCalledWith(42, 7, params); + }); +}); diff --git a/src/components/common/__tests__/ConnectWalletButton.test.tsx b/src/components/common/__tests__/ConnectWalletButton.test.tsx index 261204f8..5692b49c 100644 --- a/src/components/common/__tests__/ConnectWalletButton.test.tsx +++ b/src/components/common/__tests__/ConnectWalletButton.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import ConnectWalletButton from '@/components/common/ConnectWalletButton'; import { useAccount, useConnect, useDisconnect } from 'wagmi'; @@ -14,31 +14,40 @@ const mockUseAccount = vi.mocked(useAccount); const mockUseConnect = vi.mocked(useConnect); const mockUseDisconnect = vi.mocked(useDisconnect); +const FULL_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; +const TRUNCATED_ADDRESS_PATTERN = /0x12.*5678/i; + +function setupConnectedWalletMocks(disconnect = vi.fn()) { + mockUseAccount.mockReturnValue({ + address: FULL_ADDRESS, + isConnected: true, + } as ReturnType); + mockUseConnect.mockReturnValue({ + connect: vi.fn(), + connectors: [], + error: null, + isPending: false, + } as unknown as ReturnType); + mockUseDisconnect.mockReturnValue({ + disconnect, + } as unknown as ReturnType); + + return { disconnect }; +} + describe('ConnectWalletButton wallet disconnect confirmation', () => { function renderConnectedWallet(disconnect = vi.fn()) { - mockUseAccount.mockReturnValue({ - address: '0x1234567890abcdef1234567890abcdef12345678', - isConnected: true, - } as ReturnType); - mockUseConnect.mockReturnValue({ - connect: vi.fn(), - connectors: [], - error: null, - isPending: false, - } as unknown as ReturnType); - mockUseDisconnect.mockReturnValue({ - disconnect, - } as unknown as ReturnType); - + const result = setupConnectedWalletMocks(disconnect); render(); - - return { disconnect }; + return result; } it('opens a confirmation dialog before disconnecting', () => { const { disconnect } = renderConnectedWallet(); - fireEvent.click(screen.getByRole('button', { name: /0x1234/i })); + fireEvent.click( + screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN }) + ); expect( screen.getByRole('dialog', { name: /disconnect wallet/i }) @@ -49,16 +58,50 @@ describe('ConnectWalletButton wallet disconnect confirmation', () => { it('disconnects when the confirmation action is clicked', () => { const { disconnect } = renderConnectedWallet(); - fireEvent.click(screen.getByRole('button', { name: /0x1234/i })); + fireEvent.click( + screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN }) + ); fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i })); expect(disconnect).toHaveBeenCalledTimes(1); }); + it('emits a structured disconnect log with session duration outside test env', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-27T10:00:00.000Z')); + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const { disconnect } = renderConnectedWallet(); + + act(() => { + vi.advanceTimersByTime(4_500); + }); + + fireEvent.click( + screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN }) + ); + fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i })); + + expect(disconnect).toHaveBeenCalledTimes(1); + expect(debugSpy).toHaveBeenCalledWith('[wallet-disconnect]', { + truncated_address: '0x12...5678', + session_duration_ms: 4_500, + disconnected_at: '2026-07-27T10:00:04.500Z', + }); + expect(JSON.stringify(debugSpy.mock.calls[0][1])).not.toContain(FULL_ADDRESS); + + debugSpy.mockRestore(); + process.env.NODE_ENV = originalEnv; + vi.useRealTimers(); + }); + it('cancels without disconnecting', async () => { const { disconnect } = renderConnectedWallet(); - fireEvent.click(screen.getByRole('button', { name: /0x1234/i })); + fireEvent.click( + screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN }) + ); fireEvent.click(screen.getByRole('button', { name: /cancel/i })); await waitFor(() => { @@ -70,7 +113,9 @@ describe('ConnectWalletButton wallet disconnect confirmation', () => { it('dismisses with Escape without disconnecting', async () => { const { disconnect } = renderConnectedWallet(); - fireEvent.click(screen.getByRole('button', { name: /0x1234/i })); + fireEvent.click( + screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN }) + ); fireEvent.keyDown(document, { key: 'Escape' }); await waitFor(() => { @@ -79,3 +124,70 @@ describe('ConnectWalletButton wallet disconnect confirmation', () => { expect(disconnect).not.toHaveBeenCalled(); }); }); + +describe('ConnectWalletButton copy wallet address', () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + function renderConnectedWallet() { + setupConnectedWalletMocks(); + render(); + } + + it('shows a copy button when the wallet is connected', () => { + renderConnectedWallet(); + + expect( + screen.getByRole('button', { name: /copy wallet address/i }) + ).toBeInTheDocument(); + }); + + it('copies the full unmasked address to the clipboard on click', async () => { + renderConnectedWallet(); + + fireEvent.click( + screen.getByRole('button', { name: /copy wallet address/i }) + ); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(FULL_ADDRESS); + }); + }); + + it('shows a Copied! confirmation after clicking', async () => { + renderConnectedWallet(); + + fireEvent.click( + screen.getByRole('button', { name: /copy wallet address/i }) + ); + + expect(await screen.findByText('Copied!')).toBeInTheDocument(); + }); + + it('removes the Copied! confirmation after 2 seconds', async () => { + vi.useFakeTimers(); + renderConnectedWallet(); + + fireEvent.click( + screen.getByRole('button', { name: /copy wallet address/i }) + ); + + // Flush the clipboard promise microtask so state updates land + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText('Copied!')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.queryByText('Copied!')).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); +}); diff --git a/src/components/common/__tests__/CopyField.test.tsx b/src/components/common/__tests__/CopyField.test.tsx new file mode 100644 index 00000000..90145138 --- /dev/null +++ b/src/components/common/__tests__/CopyField.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import CopyField from '@/components/common/CopyField'; + +vi.mock('@/utils/toast.util', () => ({ + default: { success: vi.fn() }, +})); + +const FULL_ADDRESS = 'GBUKOFF6RS5OTIHMGMH4MOVKPAS4JJIZGYXS4DOVZDNBH5YXKJXFNEC'; + +function setupClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + writable: true, + }); + return { writeText }; +} + +describe('CopyField clipboard integration', () => { + beforeEach(() => { + setupClipboard(); + }); + + it('copies the full unmasked address to clipboard on button click', async () => { + const { writeText } = setupClipboard(); + const user = userEvent.setup(); + + render(); + + const copyBtn = screen.getByRole('button', { name: /copy wallet address/i }); + await user.click(copyBtn); + + expect(writeText).toHaveBeenCalledOnce(); + expect(writeText).toHaveBeenCalledWith(FULL_ADDRESS); + }); + + it('displays the full address in the input field', () => { + render(); + + const input = screen.getByRole('textbox', { name: /wallet address/i }); + expect(input).toHaveValue(FULL_ADDRESS); + }); + + it('shows copied state after clicking copy', async () => { + setupClipboard(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: /copy wallet address/i })); + + expect(screen.getByRole('button', { name: /wallet address copied/i })).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/CreatorCard.accessibility.test.tsx b/src/components/common/__tests__/CreatorCard.accessibility.test.tsx index 5ce1eccb..51f58d58 100644 --- a/src/components/common/__tests__/CreatorCard.accessibility.test.tsx +++ b/src/components/common/__tests__/CreatorCard.accessibility.test.tsx @@ -65,4 +65,27 @@ describe('CreatorCard accessibility', () => { }) ).toBeInTheDocument(); }); + + it('updates displayed price when price snapshot data changes', () => { + const { rerender } = render(); + + // Initial price should be 12 XLM + const initialPriceBadge = screen.getByTestId('creator-card-price-badge'); + expect(initialPriceBadge).toHaveTextContent(/12/i); + + // Update creator with new price + const updatedCreator: Course = { + ...creator, + price: 25, + }; + + rerender(); + + // New price should be 25 XLM + const updatedPriceBadge = screen.getByTestId('creator-card-price-badge'); + expect(updatedPriceBadge).toHaveTextContent(/25/i); + + // Old price (12) should no longer be visible + expect(updatedPriceBadge).not.toHaveTextContent(/12/i); + }); }); diff --git a/src/components/common/__tests__/CreatorCardSkeleton.noLayoutShift.test.tsx b/src/components/common/__tests__/CreatorCardSkeleton.noLayoutShift.test.tsx new file mode 100644 index 00000000..fc0079c6 --- /dev/null +++ b/src/components/common/__tests__/CreatorCardSkeleton.noLayoutShift.test.tsx @@ -0,0 +1,73 @@ +/** + * CreatorCardSkeleton no-layout-shift coverage (#643). + * + * #643 asks for the skeleton to mirror the real card so no layout shift + * occurs when data loads, and to render no real content while loading. + * The skeleton (added for #421) already exceeds a minimal 4-region design — + * it mirrors every content region of CreatorCard (avatar, title/badges, + * handle, bio, sparkline, stat chips, meta rows, social links, action row, + * helper text) rather than only avatar/name/price/holder-count. Redesigning + * it down to 4 regions would regress the #421 suite + * (CreatorCardSkeleton.test.tsx), so this suite verifies the properties + * #643 actually cares about against the current, richer design: every + * placeholder region carries the animated pulse class, no real text or + * images are rendered, and the root surface carries fixed sizing + * constraints (rounded-2xl card surface, aspect-square avatar) so swapping + * in the real card does not shift layout. + */ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import CreatorCardSkeleton from '../CreatorCardSkeleton'; + +describe('CreatorCardSkeleton no-layout-shift (#643)', () => { + it('applies the animated pulse class to every placeholder region', () => { + const { container } = render(); + + const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer'); + expect(shimmerBlocks.length).toBeGreaterThan(0); + + shimmerBlocks.forEach(block => { + expect(block.className).toMatch(/skeleton-shimmer/); + }); + + // No placeholder block should be missing the shared block styling + // (rounded corners are part of the shared block class in every + // region except the divider rules, so every shimmer block is + // expected to carry a rounded-* utility). + const nonRounded = Array.from(shimmerBlocks).filter( + block => !/rounded-/.test(block.className) + ); + expect(nonRounded).toHaveLength(0); + }); + + it('renders no real creator content — no text nodes, no images', () => { + const { container, queryByRole } = render(); + + expect(container.querySelectorAll('img')).toHaveLength(0); + expect(queryByRole('img')).toBeNull(); + + // Only the visually-hidden "Loading creator card" label should be + // present as text — no creator name, price, or bio strings. + const srOnly = container.querySelector('.sr-only'); + expect(srOnly?.textContent).toBe('Loading creator card'); + + const visibleText = Array.from(container.querySelectorAll('div')) + .filter(el => el.children.length === 0) + .map(el => el.textContent?.trim()) + .filter(Boolean); + expect(visibleText).toHaveLength(0); + }); + + it('constrains the root surface and avatar block the same way CreatorCard does', () => { + const { getByTestId } = render(); + + const card = getByTestId('creator-card-skeleton'); + // Same card-surface + rounding classes CreatorCard's root uses, so + // swapping skeleton -> real card does not change the card footprint. + expect(card).toHaveClass('marketplace-card-surface'); + expect(card).toHaveClass('rounded-2xl'); + + const avatarBlock = card.querySelector('.aspect-square'); + expect(avatarBlock).not.toBeNull(); + }); +}); diff --git a/src/components/common/__tests__/CreatorCardSkeleton.test.tsx b/src/components/common/__tests__/CreatorCardSkeleton.test.tsx new file mode 100644 index 00000000..8612dca1 --- /dev/null +++ b/src/components/common/__tests__/CreatorCardSkeleton.test.tsx @@ -0,0 +1,92 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import CreatorCardSkeleton, { + CreatorCardGridSkeleton, +} from '../CreatorCardSkeleton'; + +describe('CreatorCardSkeleton', () => { + it('renders shimmer blocks for the loading affordance', () => { + const { container, getByRole, getAllByTestId } = render( + + ); + + // role="status" announces loading to assistive tech. + expect( + getByRole('status', { name: 'Loading creator card' }) + ).toBeInTheDocument(); + + // Sanity-check that the card test id is exposed so other + // components / tests can target a single placeholder card. + expect(getAllByTestId('creator-card-skeleton')).toHaveLength(1); + + // Animated shimmer is present by default — many blocks are + // expected because CreatorCardSkeleton mirrors the full card + // (avatar, title, badges, handle, bio, sparkline, chips, meta + // rows, social links, action row, helper text). + const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer'); + expect(shimmerBlocks.length).toBeGreaterThanOrEqual(10); + }); + + it('disables the shimmer with `disableShimmer` and falls back to the static block', () => { + const { container } = render(); + + expect(container.querySelectorAll('.skeleton-shimmer')).toHaveLength(0); + expect( + container.querySelectorAll('.ring-white\\/15').length + ).toBeGreaterThanOrEqual(10); + }); + + it('merges additional className onto the card surface', () => { + const { container } = render( + + ); + + const card = container.querySelector('[data-testid="creator-card-skeleton"]'); + expect(card).not.toBeNull(); + expect(card).toHaveClass('custom-class'); + expect(card).toHaveClass('rounded-2xl'); + }); +}); + +describe('CreatorCardGridSkeleton', () => { + it('renders 6 skeletons by default (#421 acceptance criterion)', () => { + const { container, getAllByTestId, getByTestId } = render( + + ); + + expect( + getByTestId('creator-card-grid-skeleton') + ).toBeInTheDocument(); + expect(getAllByTestId('creator-card-skeleton')).toHaveLength(6); + // Each card contributes at least 10 shimmer blocks, so the grid + // should expose 60+ shimmer nodes. + const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer'); + expect(shimmerBlocks.length).toBeGreaterThanOrEqual(60); + }); + + it('renders the requested number of cards', () => { + const { getAllByTestId } = render(); + expect(getAllByTestId('creator-card-skeleton')).toHaveLength(3); + }); + + it('propagates `disableShimmer` to every card in the grid', () => { + const { container, getAllByTestId } = render( + + ); + + expect(getAllByTestId('creator-card-skeleton')).toHaveLength(2); + expect(container.querySelectorAll('.skeleton-shimmer')).toHaveLength(0); + expect( + container.querySelectorAll('.ring-white\\/15').length + ).toBeGreaterThanOrEqual(20); + }); + + it('applies extra className to the grid wrapper', () => { + const { getByTestId } = render( + + ); + const grid = getByTestId('creator-card-grid-skeleton'); + expect(grid).toHaveClass('extra-grid-class'); + expect(grid).toHaveClass('grid-cols-1'); + }); +}); diff --git a/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx new file mode 100644 index 00000000..62412869 --- /dev/null +++ b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx @@ -0,0 +1,142 @@ +/** + * Unit tests for CreatorMarketplaceInfiniteList — the IntersectionObserver- + * driven infinite scroll marketplace listing (#685). + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import CreatorMarketplaceInfiniteList from '@/components/common/CreatorMarketplaceInfiniteList'; +import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import type { Course } from '@/services/course.service'; + +vi.mock('@/hooks/useInfiniteCreatorMarketplace'); +vi.mock('@/hooks/useInfiniteScroll'); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { id: string; title: string } }) => + React.createElement('article', { 'aria-label': `Creator ${creator.title}` }, creator.title), + }; +}); + +const mockUseInfiniteCreatorMarketplace = vi.mocked(useInfiniteCreatorMarketplace); +const mockUseInfiniteScroll = vi.mocked(useInfiniteScroll); + +function makeCreator(id: string): Course { + return { + id, + title: `Creator ${id}`, + description: 'desc', + price: 0.1, + instructorId: id, + category: 'Art', + level: 'BEGINNER', + }; +} + +const baseHookReturn = { + creators: [] as Course[], + hasMore: false, + isLoadingFirstPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + error: null, +}; + +describe('CreatorMarketplaceInfiniteList', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseInfiniteScroll.mockReturnValue({ current: null }); + }); + + it('shows the initial skeleton while the first page is loading', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + isLoadingFirstPage: true, + }); + + render(); + + expect(screen.getByTestId('creator-marketplace-initial-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('creator-marketplace-infinite-list')).not.toBeInTheDocument(); + }); + + it('renders every creator returned by the hook, with no duplicates', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a'), makeCreator('b')], + }); + + render(); + + expect(screen.getByLabelText('Creator Creator a')).toBeInTheDocument(); + expect(screen.getByLabelText('Creator Creator b')).toBeInTheDocument(); + expect(screen.getAllByRole('article')).toHaveLength(2); + }); + + it('shows a skeleton row while the next page is fetching', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a')], + isFetchingNextPage: true, + hasMore: true, + }); + + render(); + + expect(screen.getByTestId('creator-marketplace-next-page-skeleton')).toBeInTheDocument(); + }); + + it('does not show the next-page skeleton once no more pages remain', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a')], + hasMore: false, + isFetchingNextPage: false, + }); + + render(); + + expect( + screen.queryByTestId('creator-marketplace-next-page-skeleton') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('creator-marketplace-sentinel')).not.toBeInTheDocument(); + }); + + it('renders the sentinel and calls fetchNextPage via useInfiniteScroll when more pages remain', () => { + const fetchNextPage = vi.fn(); + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a')], + hasMore: true, + fetchNextPage, + }); + + render(); + + expect(screen.getByTestId('creator-marketplace-sentinel')).toBeInTheDocument(); + + // Simulate the sentinel scrolling into view by invoking the + // onLoadMore callback useInfiniteScroll was configured with. + const call = mockUseInfiniteScroll.mock.calls[0]![0]; + call.onLoadMore(); + + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('disables the scroll observer while a page is already loading (enabled: false)', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a')], + hasMore: true, + isFetchingNextPage: true, + }); + + render(); + + const call = mockUseInfiniteScroll.mock.calls[0]![0]; + expect(call.enabled).toBe(false); + expect(call.hasMore).toBe(true); + }); +}); diff --git a/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx b/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx new file mode 100644 index 00000000..e1b5d11a --- /dev/null +++ b/src/components/common/__tests__/CreatorPageErrorBoundary.test.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; + +// Mock component that throws a render error on demand +const BuggyCreatorPage = ({ shouldThrow = false }: { shouldThrow?: boolean }) => { + if (shouldThrow) { + throw new Error('Creator page render error'); + } + return
Creator profile content
; +}; + +const renderInRouter = (ui: ReactNode) => + render({ui}); + +describe('CreatorPageErrorBoundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders children when no error occurs', () => { + renderInRouter( + + + + ); + + expect(screen.getByText('Creator profile content')).toBeInTheDocument(); + }); + + it('renders fallback UI instead of crashing when a creator page throws', () => { + // Suppress React's expected error logging for the thrown error + vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderInRouter( + + + + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/could not load/i)).toBeInTheDocument(); + expect(screen.queryByText('Creator profile content')).not.toBeInTheDocument(); + }); + + it('fallback includes a link back to the creator list', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderInRouter( + + + + ); + + const link = screen.getByRole('link', { name: /back to creators/i }); + expect(link).toHaveAttribute('href', '/creators'); + }); +}); diff --git a/src/components/common/__tests__/CreatorProfileErrorState.test.tsx b/src/components/common/__tests__/CreatorProfileErrorState.test.tsx new file mode 100644 index 00000000..e9a91b39 --- /dev/null +++ b/src/components/common/__tests__/CreatorProfileErrorState.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import CreatorProfileErrorState from '../CreatorProfileErrorState'; + +describe('CreatorProfileErrorState (#573)', () => { + it('renders network error title and default plain language description', () => { + render(); + + expect( + screen.getByRole('alert') + ).toBeInTheDocument(); + expect( + screen.getByText('Unable to load this creator profile') + ).toBeInTheDocument(); + expect( + screen.getByText(/We couldn't load the latest profile details due to a network error/i) + ).toBeInTheDocument(); + }); + + it('renders custom error message when supplied as an Error object or string', () => { + const customError = new Error('Failed to connect to Stellar node'); + render(); + + expect( + screen.getByText('Failed to connect to Stellar node') + ).toBeInTheDocument(); + }); + + it('triggers onRetry callback when retry button is clicked', async () => { + const user = userEvent.setup(); + const handleRetry = vi.fn(); + render(); + + const retryButton = screen.getByRole('button', { name: /retry/i }); + expect(retryButton).toBeEnabled(); + + await user.click(retryButton); + expect(handleRetry).toHaveBeenCalledTimes(1); + }); + + it('renders retrying loading state when isRetrying is true', () => { + const handleRetry = vi.fn(); + render( + + ); + + const retryButton = screen.getByRole('button', { name: /retrying\.\.\./i }); + expect(retryButton).toBeDisabled(); + }); + + it('renders without error when onRetry is not provided', () => { + render(); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Unable to load this creator profile')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/CreatorProfileHeader.isOwnWallet.integration.test.tsx b/src/components/common/__tests__/CreatorProfileHeader.isOwnWallet.integration.test.tsx new file mode 100644 index 00000000..4d23827b --- /dev/null +++ b/src/components/common/__tests__/CreatorProfileHeader.isOwnWallet.integration.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; + +const CREATOR_ADDRESS = '0xCreator1111111111111111111111111111111111'; +const OTHER_ADDRESS = '0xOther22222222222222222222222222222222222'; + +const BASE_PROPS = { + name: 'Alex Rivers', + handle: 'arivers', + creatorId: CREATOR_ADDRESS, + avatarUrl: 'https://example.com/avatar.png', +}; + +describe('CreatorProfileHeader – isOwnWallet edit-controls visibility', () => { + it('hides edit controls when the connected wallet does not match the creator', () => { + const { container } = render( + + ); + + expect(screen.queryByRole('button', { name: /edit bio/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /change avatar/i })).not.toBeInTheDocument(); + + // Confirm the controls are truly absent from the DOM, not just hidden + expect(container.querySelector('[aria-label="Edit bio"]')).toBeNull(); + expect(container.querySelector('[aria-label="Change avatar"]')).toBeNull(); + }); + + it('shows edit controls when the connected wallet matches the creator', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /edit bio/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /change avatar/i })).toBeInTheDocument(); + }); + + it('matches case-insensitively so mixed-case addresses are treated as the same wallet', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /edit bio/i })).toBeInTheDocument(); + }); + + it('toggles edit controls when the connected wallet changes', () => { + const { rerender } = render( + + ); + + expect(screen.queryByRole('button', { name: /edit bio/i })).not.toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByRole('button', { name: /edit bio/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /change avatar/i })).toBeInTheDocument(); + }); + + it('hides edit controls when no wallet is connected', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /edit bio/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /change avatar/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/EmptyState.test.tsx b/src/components/common/__tests__/EmptyState.test.tsx new file mode 100644 index 00000000..5138e32d --- /dev/null +++ b/src/components/common/__tests__/EmptyState.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; +import { describe, expect, it, vi } from 'vitest'; +import EmptyState from '../EmptyState'; + +const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; +}; + +describe('EmptyState', () => { + it('renders message and CTA when both are provided and asserts both are visible', () => { + render( + + + + ); + + expect( + screen.getByText('No records found in this context') + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Go to Marketplace' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Go to Marketplace' }) + ).toHaveAttribute('href', '/marketplace'); + }); + + it('renders with only a message (no CTA) and asserts the CTA element is absent', () => { + render(); + + expect(screen.getByText('No activity recorded yet')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('clicks the CTA and asserts navigation to the provided href is triggered', () => { + render( + + + + } + /> + Creators Page
} + /> + + + + ); + + expect(screen.getByTestId('location-display')).toHaveTextContent('/'); + + const ctaLink = screen.getByRole('link', { name: 'Explore Creators' }); + fireEvent.click(ctaLink); + + expect(screen.getByTestId('creators-page')).toBeInTheDocument(); + expect(screen.getByTestId('location-display')).toHaveTextContent('/creators'); + }); + + it('renders with an empty message string and asserts the component renders without error', () => { + const { container } = render(); + + expect(container).toBeInTheDocument(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('supports cta specified as string with ctaHref prop', () => { + render( + + + + ); + + const link = screen.getByRole('link', { name: 'Browse Tokens' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/tokens'); + }); + + it('triggers onClick handler when cta is an action button without href', () => { + const handleAction = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { name: 'Clear Filters' }); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + expect(handleAction).toHaveBeenCalledTimes(1); + }); + + it('maintains backward compatibility with title, description, image, and onReset props', () => { + const handleReset = vi.fn(); + render( + + ); + + expect( + screen.getByRole('status', { name: 'No creators found' }) + ).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + 'No creators found' + ); + expect( + screen.getByText('We couldn\'t find any creators matching your search.') + ).toBeInTheDocument(); + + const resetButton = screen.getByRole('button', { name: 'Reset search results' }); + expect(resetButton).toBeInTheDocument(); + + fireEvent.click(resetButton); + expect(handleReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/common/__tests__/PriceSparkline.test.tsx b/src/components/common/__tests__/PriceSparkline.test.tsx new file mode 100644 index 00000000..996d3aa9 --- /dev/null +++ b/src/components/common/__tests__/PriceSparkline.test.tsx @@ -0,0 +1,88 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { PriceSparkline } from '../PriceSparkline'; + +describe('PriceSparkline', () => { + describe('rendering', () => { + it('renders an SVG path for 7 data points', () => { + const { container } = render( + + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + const path = container.querySelector('path'); + expect(path).toBeInTheDocument(); + expect(path).toHaveAttribute('d'); + }); + + it('renders without error for 1 data point and does not produce a line path', () => { + const { container } = render( + + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + const path = container.querySelector('path'); + expect(path).not.toBeInTheDocument(); + }); + + it('renders nothing for 0 data points', () => { + const { container } = render( + + ); + + expect(container.innerHTML).toBe(''); + }); + }); + + describe('line colour', () => { + it('returns green for a rising history (last > first)', () => { + const { container } = render( + + ); + + const path = container.querySelector('path'); + expect(path).toHaveAttribute('stroke', '#34d399'); + }); + + it('returns red for a falling history (last < first)', () => { + const { container } = render( + + ); + + const path = container.querySelector('path'); + expect(path).toHaveAttribute('stroke', '#ef4444'); + }); + + it('returns neutral for a flat history (last === first)', () => { + const { container } = render( + + ); + + const path = container.querySelector('path'); + expect(path).toHaveAttribute('stroke', 'currentColor'); + }); + + it('returns neutral for a single-element history', () => { + const { container } = render( + + ); + + const circle = container.querySelector('circle'); + expect(circle).toHaveAttribute('fill', 'currentColor'); + }); + + it('returns neutral for an empty history (renders nothing)', () => { + const { container } = render( + + ); + + expect(container.innerHTML).toBe(''); + }); + }); +}); diff --git a/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx b/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx deleted file mode 100644 index 98f6e30a..00000000 --- a/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import TradeDialog from '@/components/common/TradeDialog'; -import { BUY_QUANTITY_BOUNDS } from '@/constants/fees'; - -describe('TradeDialog buy quantity clamping and notes', () => { - function renderDialog( - overrides: Partial> = {} - ) { - return render( - - ); - } - - it('clamps values below minimum to minimum on blur', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Input below minimum - fireEvent.change(input, { target: { value: '0' } }); - fireEvent.blur(input); - - expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); - expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( - `Quantity adjusted to the minimum of ${BUY_QUANTITY_BOUNDS.MIN_QTY}.` - ); - }); - - it('clamps values above maximum to maximum on blur', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Input above maximum - fireEvent.change(input, { target: { value: '150' } }); - fireEvent.blur(input); - - expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); - expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( - `Quantity adjusted to the maximum of ${BUY_QUANTITY_BOUNDS.MAX_QTY}.` - ); - }); - - it('rounds decimal inputs on blur', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Decimal input - fireEvent.change(input, { target: { value: '5.6' } }); - fireEvent.blur(input); - - expect(input.value).toBe('6'); - expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( - 'Quantity rounded to 6.' - ); - }); - - it('does not clamp or show a note for valid quantities on blur', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Valid quantity - fireEvent.change(input, { target: { value: '10' } }); - fireEvent.blur(input); - - expect(input.value).toBe('10'); - expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument(); - }); - - it('clears the adjustment note on input change', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Trigger adjustment note - fireEvent.change(input, { target: { value: '0' } }); - fireEvent.blur(input); - expect(screen.getByTestId('buy-qty-adjustment-note')).toBeInTheDocument(); - - // Change input - fireEvent.change(input, { target: { value: '5' } }); - expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx b/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx new file mode 100644 index 00000000..54578501 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeDialog from '@/components/common/TradeDialog'; +import { BUY_QUANTITY_BOUNDS } from '@/constants/fees'; + +describe('TradeDialog – clampBuyQuantity integration', () => { + function renderDialog( + overrides: Partial> = {} + ) { + return render( + + ); + } + + it('applies clampBuyQuantity to amount input field – values below minimum are clamped to MIN_QTY', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input value below minimum + fireEvent.change(input, { target: { value: '0' } }); + fireEvent.blur(input); + + // Verify the value is clamped to minimum + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); + }); + + it('applies clampBuyQuantity to amount input field – values above maximum are clamped to MAX_QTY', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input value above maximum + fireEvent.change(input, { target: { value: '150' } }); + fireEvent.blur(input); + + // Verify the value is clamped to maximum + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); + }); + + it('applies clampBuyQuantity to amount input field – decimal values are rounded to nearest integer', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input decimal value + fireEvent.change(input, { target: { value: '5.6' } }); + fireEvent.blur(input); + + // Verify the value is rounded + expect(input.value).toBe('6'); + }); + + it('applies clampBuyQuantity to amount input field – valid values within bounds remain unchanged', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input valid value within bounds + fireEvent.change(input, { target: { value: '10' } }); + fireEvent.blur(input); + + // Verify the value remains unchanged + expect(input.value).toBe('10'); + }); + + it('applies clampBuyQuantity to amount input field – negative values are clamped to MIN_QTY', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input negative value + fireEvent.change(input, { target: { value: '-5' } }); + fireEvent.blur(input); + + // Verify the value is clamped to minimum + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); + }); + + it('applies clampBuyQuantity to amount input field – empty input is handled gracefully', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Clear the input + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + // Verify empty input shows validation error (not clamped, as it's invalid) + expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent( + 'Please enter an amount.' + ); + }); + + it('applies clampBuyQuantity to amount input field – boundary values (MIN_QTY and MAX_QTY) are accepted', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Test minimum boundary + fireEvent.change(input, { target: { value: BUY_QUANTITY_BOUNDS.MIN_QTY.toString() } }); + fireEvent.blur(input); + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); + + // Test maximum boundary + fireEvent.change(input, { target: { value: BUY_QUANTITY_BOUNDS.MAX_QTY.toString() } }); + fireEvent.blur(input); + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); + }); + + it('applies clampBuyQuantity to amount input field – sell side also respects clamping', () => { + renderDialog({ side: 'sell', availableHoldings: 50 }); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input value above maximum on sell side + fireEvent.change(input, { target: { value: '150' } }); + fireEvent.blur(input); + + // Verify the value is clamped to maximum + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); + }); +}); diff --git a/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx b/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx new file mode 100644 index 00000000..62f8b886 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx @@ -0,0 +1,86 @@ +/** + * Unit tests for the sell quantity input rejecting values exceeding the + * wallet's current holding (#657). + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeDialog from '@/components/common/TradeDialog'; + +describe('TradeDialog – sell quantity exceeds-holding validation', () => { + function renderSellDialog( + overrides: Partial> = {} + ) { + return render( + + ); + } + + it('shows an exceeds-balance validation error when the quantity exceeds the holding', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '4' } }); + + const error = screen.getByTestId('trade-dialog-amount-error'); + expect(error).toBeInTheDocument(); + expect(error).toHaveTextContent("You can't sell more than your holdings (3 keys)."); + }); + + it('clears the validation error when the quantity is brought back within range', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '4' } }); + expect(screen.getByTestId('trade-dialog-amount-error')).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: '3' } }); + expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument(); + }); + + it('shows a distinct zero-quantity error, not the exceeds-balance error', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '0' } }); + + const error = screen.getByTestId('trade-dialog-amount-error'); + expect(error).toBeInTheDocument(); + expect(error).toHaveTextContent('Amount must be greater than zero.'); + expect(error).not.toHaveTextContent('holdings'); + }); + + it('accepts a quantity equal to the holding without any error', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '3' } }); + + expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument(); + }); + + it('accepts quantity 1 (well within holding) without any error', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '1' } }); + + expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument(); + }); + + it('disables the confirm button while the exceeds-balance error is present', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '4' } }); + + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + }); +}); diff --git a/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx b/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx new file mode 100644 index 00000000..1a54d214 --- /dev/null +++ b/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import TransactionHistory, { + type Transaction, +} from '@/components/common/TransactionHistory'; + +const CREATOR_A_ID = '101'; +const CREATOR_B_ID = '202'; + +const twoCreatorTrades: Transaction[] = [ + { + id: 'trade-a', + type: 'buy', + creatorId: CREATOR_A_ID, + creatorHandle: 'arivers', + amount: 2, + price: 0.05, + timestamp: Date.now() - 1000 * 60 * 15, + txHash: '0xaaaa...1111', + status: 'completed', + }, + { + id: 'trade-b', + type: 'sell', + creatorId: CREATOR_B_ID, + creatorHandle: 'schen_dev', + amount: 1, + price: 0.12, + timestamp: Date.now() - 1000 * 60 * 45, + txHash: '0xbbbb...2222', + status: 'completed', + }, +]; + +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'test'); + localStorage.clear(); +}); + +describe('TransactionHistory – activity feed creator handles (integration)', () => { + it('renders the correct creator handle for each trade entry', () => { + render(); + + expect( + screen.getByTestId('activity-creator-handle-trade-a') + ).toHaveTextContent('@arivers'); + expect( + screen.getByTestId('activity-creator-handle-trade-b') + ).toHaveTextContent('@schen_dev'); + }); + + it('shows different handles for entries from different creators', () => { + render(); + + const handleA = screen.getByTestId('activity-creator-handle-trade-a'); + const handleB = screen.getByTestId('activity-creator-handle-trade-b'); + + expect(handleA.textContent).not.toBe(handleB.textContent); + expect(handleA).toHaveTextContent('@arivers'); + expect(handleB).toHaveTextContent('@schen_dev'); + }); + + it('does not expose raw creator IDs in the rendered output', () => { + const { container } = render( + + ); + + const buyRow = screen.getByTestId('activity-item-buy'); + const sellRow = screen.getByTestId('activity-item-sell'); + + expect(within(buyRow).queryByText(CREATOR_A_ID)).not.toBeInTheDocument(); + expect(within(sellRow).queryByText(CREATOR_B_ID)).not.toBeInTheDocument(); + expect(container.textContent).not.toContain(CREATOR_A_ID); + expect(container.textContent).not.toContain(CREATOR_B_ID); + }); +}); diff --git a/src/components/common/__tests__/TransactionHistory.order.integration.test.tsx b/src/components/common/__tests__/TransactionHistory.order.integration.test.tsx new file mode 100644 index 00000000..4b63091c --- /dev/null +++ b/src/components/common/__tests__/TransactionHistory.order.integration.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import TransactionHistory, { + type Transaction, +} from '@/components/common/TransactionHistory'; + +const trades: Transaction[] = [ + { + id: 'ledger-1000', + type: 'buy', + creatorId: 'creator-c', + creatorHandle: 'nova', + amount: 1, + price: 8, + timestamp: 1_000, + txHash: '0xccc', + status: 'completed', + }, + { + id: 'ledger-3000', + type: 'buy', + creatorId: 'creator-a', + creatorHandle: 'atlas', + amount: 5, + price: 12, + timestamp: 3_000, + txHash: '0xaaa', + status: 'completed', + }, + { + id: 'ledger-2000', + type: 'sell', + creatorId: 'creator-b', + creatorHandle: 'beacon', + amount: 2, + price: 10, + timestamp: 2_000, + txHash: '0xbbb', + status: 'completed', + }, +]; + +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'test'); + localStorage.clear(); +}); + +describe('TransactionHistory – wallet activity order and type labels (integration)', () => { + it('renders trades in descending chronological order with correct buy and sell labels', () => { + render(); + + const rows = screen + .getAllByTestId(/activity-item-/) + .map(row => row.textContent ?? ''); + + expect(rows).toHaveLength(3); + expect(rows[0]).toContain('@atlas'); + expect(rows[1]).toContain('@beacon'); + expect(rows[2]).toContain('@nova'); + + const buyRows = screen.getAllByTestId('activity-item-buy'); + const sellRows = screen.getAllByTestId('activity-item-sell'); + + expect(within(buyRows[0]).getByText('Buy')).toBeInTheDocument(); + expect(within(buyRows[1]).getByText('Buy')).toBeInTheDocument(); + expect(within(sellRows[0]).getByText('Sell')).toBeInTheDocument(); + expect(buyRows).toHaveLength(2); + expect(sellRows).toHaveLength(1); + expect(rows[0]).toContain('5 keys'); + expect(rows[1]).toContain('2 keys'); + expect(rows[2]).toContain('1 keys'); + }); +}); diff --git a/src/components/common/__tests__/TransactionHistory.test.tsx b/src/components/common/__tests__/TransactionHistory.test.tsx new file mode 100644 index 00000000..9eb61797 --- /dev/null +++ b/src/components/common/__tests__/TransactionHistory.test.tsx @@ -0,0 +1,69 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import TransactionHistory from '@/components/common/TransactionHistory'; + +// TransactionHistory reads localStorage during initialisation. +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'test'); + localStorage.clear(); +}); + +describe('TransactionHistory – activity feed sign prefix (integration)', () => { + it('buy event amount is prefixed with a minus sign', () => { + render(); + + // There is at least one buy activity item in the sample data. + const buyItems = screen.getAllByTestId('activity-item-buy'); + expect(buyItems.length).toBeGreaterThan(0); + + // For each buy row the visible amount must start with "-". + buyItems.forEach(item => { + const amountEl = item.querySelector('[data-testid^="tx-amount-"]'); + expect(amountEl).not.toBeNull(); + expect(amountEl!.textContent).toMatch(/^-/); + }); + }); + + it('sell event amount is prefixed with a plus sign', () => { + render(); + + const sellItems = screen.getAllByTestId('activity-item-sell'); + expect(sellItems.length).toBeGreaterThan(0); + + sellItems.forEach(item => { + const amountEl = item.querySelector('[data-testid^="tx-amount-"]'); + expect(amountEl).not.toBeNull(); + expect(amountEl!.textContent).toMatch(/^\+/); + }); + }); + + it('XLM suffix is present on both buy and sell amounts', () => { + render(); + + const allItems = [ + ...screen.getAllByTestId('activity-item-buy'), + ...screen.getAllByTestId('activity-item-sell'), + ]; + + allItems.forEach(item => { + const amountEl = item.querySelector('[data-testid^="tx-amount-"]'); + expect(amountEl).not.toBeNull(); + expect(amountEl!.textContent).toMatch(/XLM$/); + }); + }); + + it('renders relative time label correctly for recent versus older events (#487)', () => { + render(); + + // SAMPLE_TRANSACTIONS has one from 30 minutes ago, one from 5 days ago (120 hours). + // We expect the first one to say "30 min ago" and the latter to say "5 days ago". + // Actually, let's just check for 'min ago' and 'days ago' + expect(screen.getAllByText(/min ago/)).not.toHaveLength(0); + expect(screen.getAllByText(/days ago/)).not.toHaveLength(0); + + // Ensure raw ISO timestamp is not shown + const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + const rawTimestamps = screen.queryAllByText(isoRegex); + expect(rawTimestamps).toHaveLength(0); + }); +}); diff --git a/src/components/common/__tests__/WalletActivityFeed.infiniteScroll.integration.test.tsx b/src/components/common/__tests__/WalletActivityFeed.infiniteScroll.integration.test.tsx new file mode 100644 index 00000000..bbfe21d5 --- /dev/null +++ b/src/components/common/__tests__/WalletActivityFeed.infiniteScroll.integration.test.tsx @@ -0,0 +1,350 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; + +import WalletActivityFeed from '@/components/common/WalletActivityFeed'; +import * as walletActivityService from '@/services/walletActivity.service'; +import type { WalletActivityTrade } from '@/services/walletActivity.service'; + +const WALLET_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +// jsdom does not implement IntersectionObserver — install a recording mock +// that captures the callback the hook registers so the test can drive it +// deterministically without relying on real viewport geometry. +interface CapturedObserver { + callback: IntersectionObserverCallback; + observe: ReturnType; + disconnect: ReturnType; + unobserve: ReturnType; +} + +const observers: CapturedObserver[] = []; + +class MockIntersectionObserver { + callback: IntersectionObserverCallback; + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); + constructor(cb: IntersectionObserverCallback) { + this.callback = cb; + observers.push({ + callback: cb, + observe: this.observe, + disconnect: this.disconnect, + unobserve: this.unobserve, + }); + } +} + +const page1Trades: WalletActivityTrade[] = [ + { + id: 'A', + type: 'buy', + creatorId: '1', + creatorHandle: 'arivers', + amount: 2, + price: 0.05, + timestamp: Date.now() - 1000 * 60 * 5, + txHash: '0xaaaa...0011', + status: 'completed', + }, + { + id: 'B', + type: 'sell', + creatorId: '2', + creatorHandle: 'schen_dev', + amount: 1, + price: 0.12, + timestamp: Date.now() - 1000 * 60 * 45, + txHash: '0xbbbb...0022', + status: 'completed', + }, + { + id: 'C', + type: 'buy', + creatorId: '3', + creatorHandle: 'mthorne', + amount: 4, + price: 0.08, + timestamp: Date.now() - 1000 * 60 * 90, + txHash: '0xcccc...0033', + status: 'completed', + }, +]; + +const page2Trades: WalletActivityTrade[] = [ + { + id: 'D', + type: 'buy', + creatorId: '4', + creatorHandle: 'evance_design', + amount: 3, + price: 0.04, + timestamp: Date.now() - 1000 * 60 * 60 * 2, + txHash: '0xdddd...0044', + status: 'completed', + }, + { + id: 'E', + type: 'sell', + creatorId: '5', + creatorHandle: 'dkojo_beats', + amount: 2, + price: 0.15, + timestamp: Date.now() - 1000 * 60 * 60 * 4, + txHash: '0xeeee...0055', + status: 'completed', + }, + { + id: 'F', + type: 'buy', + creatorId: '6', + creatorHandle: 'yuki_s', + amount: 6, + price: 0.07, + timestamp: Date.now() - 1000 * 60 * 60 * 8, + txHash: '0xffff...0066', + status: 'completed', + }, +]; + +const setupFetchMock = () => + vi + .spyOn(walletActivityService, 'fetchWalletActivityPage') + .mockResolvedValueOnce({ trades: page1Trades, nextPage: 2 }) + .mockResolvedValueOnce({ trades: page2Trades, nextPage: null }); + +const renderFeed = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const utils = render(, { + wrapper, + }); + return { ...utils, queryClient }; +}; + +const simulateIntersection = (isIntersecting: boolean) => { + const obs = observers[observers.length - 1]; + if (!obs) throw new Error('No IntersectionObserver registered yet'); + act(() => { + obs.callback( + [{ isIntersecting } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); +}; + +describe('WalletActivityFeed infinite scroll integration (#677)', () => { + beforeEach(() => { + observers.length = 0; + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + // Acceptance #1: page 1 trades visible on initial load + it('renders page 1 trades on initial load', async () => { + const fetchSpy = setupFetchMock(); + + renderFeed(); + + await waitFor(() => { + expect( + screen.getByTestId('activity-creator-handle-A') + ).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('activity-creator-handle-A') + ).toHaveTextContent('@arivers'); + expect( + screen.getByTestId('activity-creator-handle-B') + ).toHaveTextContent('@schen_dev'); + expect( + screen.getByTestId('activity-creator-handle-C') + ).toHaveTextContent('@mthorne'); + + // Page 2 trades must not be visible yet + expect( + screen.queryByTestId('activity-creator-handle-D') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('activity-creator-handle-E') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('activity-creator-handle-F') + ).not.toBeInTheDocument(); + + // Only page 1 was fetched + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith(WALLET_ADDRESS, 1); + }); + + // Acceptance #2: scroll triggers next page fetch + it('fetches the next page when the sentinel scrolls into view', async () => { + const fetchSpy = setupFetchMock(); + + renderFeed(); + + await waitFor(() => { + expect( + screen.getByTestId('activity-creator-handle-A') + ).toBeInTheDocument(); + }); + + // The sentinel should have observed itself and registered an observer + await waitFor(() => { + expect(observers.length).toBeGreaterThan(0); + }); + + simulateIntersection(true); + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + expect(fetchSpy).toHaveBeenNthCalledWith(2, WALLET_ADDRESS, 2); + }); + + // Acceptance #3: page 2 trades appended below page 1 trades + it('appends page 2 trades below page 1 trades after fetching', async () => { + setupFetchMock(); + + const { container } = renderFeed(); + + await waitFor(() => { + expect( + screen.getByTestId('activity-creator-handle-A') + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(observers.length).toBeGreaterThan(0); + }); + simulateIntersection(true); + + await waitFor(() => { + expect( + screen.getByTestId('activity-creator-handle-D') + ).toBeInTheDocument(); + }); + + // All six trades now visible + expect( + screen.getByTestId('activity-creator-handle-A') + ).toBeInTheDocument(); + expect( + screen.getByTestId('activity-creator-handle-B') + ).toBeInTheDocument(); + expect( + screen.getByTestId('activity-creator-handle-C') + ).toBeInTheDocument(); + expect( + screen.getByTestId('activity-creator-handle-D') + ).toBeInTheDocument(); + expect( + screen.getByTestId('activity-creator-handle-E') + ).toBeInTheDocument(); + expect( + screen.getByTestId('activity-creator-handle-F') + ).toBeInTheDocument(); + + // DOM order preserves page 1 above page 2 so older trades render + // below the more recent ones — this is the "appended below" promise. + const handleTestIds = Array.from( + container.querySelectorAll( + '[data-testid^="activity-creator-handle-"]' + ) + ).map(el => el.dataset.testid ?? ''); + expect(handleTestIds).toEqual([ + 'activity-creator-handle-A', + 'activity-creator-handle-B', + 'activity-creator-handle-C', + 'activity-creator-handle-D', + 'activity-creator-handle-E', + 'activity-creator-handle-F', + ]); + }); + + // Acceptance #4: no duplicate trades in the combined list + it('does not render duplicate trade ids when paginated data overlaps', async () => { + // Simulate a backend that paginates by timestamp boundary and includes + // 'C' in both pages. The component should still surface each trade id + // exactly once. + const overlapPage2: WalletActivityTrade[] = [ + { ...page1Trades[2] }, // duplicate of 'C' + page2Trades[0], + page2Trades[1], + page2Trades[2], + ]; + + vi.spyOn(walletActivityService, 'fetchWalletActivityPage') + .mockResolvedValueOnce({ trades: page1Trades, nextPage: 2 }) + .mockResolvedValueOnce({ trades: overlapPage2, nextPage: null }); + + renderFeed(); + + await waitFor(() => { + expect( + screen.getByTestId('activity-creator-handle-A') + ).toBeInTheDocument(); + }); + await waitFor(() => { + expect(observers.length).toBeGreaterThan(0); + }); + simulateIntersection(true); + + await waitFor(() => { + expect( + screen.getByTestId('activity-creator-handle-D') + ).toBeInTheDocument(); + }); + + // Each unique id appears in exactly one rendered row. + expect( + screen.getAllByTestId('activity-creator-handle-A') + ).toHaveLength(1); + expect( + screen.getAllByTestId('activity-creator-handle-B') + ).toHaveLength(1); + expect( + screen.getAllByTestId('activity-creator-handle-C') + ).toHaveLength(1); + expect( + screen.getAllByTestId('activity-creator-handle-D') + ).toHaveLength(1); + expect( + screen.getAllByTestId('activity-creator-handle-E') + ).toHaveLength(1); + expect( + screen.getAllByTestId('activity-creator-handle-F') + ).toHaveLength(1); + + // Validate the rendered handle for 'C' carries the right creator + // so we know the deduplicated row is the original page-1 entry, + // not the duplicate appended by page 2. + expect( + screen.getByTestId('activity-creator-handle-C') + ).toHaveTextContent('@mthorne'); + + // Across the entire document there are six handles — one per unique + // trade id, regardless of the duplicate appearing in both pages. + const allHandles = document.body.querySelectorAll( + '[data-testid^="activity-creator-handle-"]' + ); + expect(allHandles.length).toBe(6); + }); +}); diff --git a/src/components/home/Header.tsx b/src/components/home/Header.tsx index 57452a12..8441f7c9 100644 --- a/src/components/home/Header.tsx +++ b/src/components/home/Header.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import WalletStatusChip from '@/components/common/WalletStatusChip'; import { Link } from 'react-router'; const navLinks = [ @@ -65,17 +66,10 @@ export default function Header() { )} - {/* CTA */} - - Connect Wallet - + {/* CTA — #686: a persistent wallet status chip replaces the bare + Connect link. WalletStatusChip renders the same link itself when + no wallet is connected, so the slot is never empty. */} +
); diff --git a/src/components/home/TrendingCreatorCard.tsx b/src/components/home/TrendingCreatorCard.tsx index 5a91c2bf..bf226de0 100644 --- a/src/components/home/TrendingCreatorCard.tsx +++ b/src/components/home/TrendingCreatorCard.tsx @@ -4,6 +4,9 @@ import { Link } from 'react-router'; import type { Course } from '@/services/course.service'; type Props = { creator: Course & { walletAddress: string } }; +import { formatHolderCount } from '@/utils/numberFormat.utils'; +import { cn } from '@/lib/utils'; +import { creatorCardSubtitleClampClass } from '@/utils/lineClamp.utils'; export default function TrendingCreatorCard({ creator }: Props) { const name = creator.title || 'Unnamed creator'; @@ -32,7 +35,12 @@ export default function TrendingCreatorCard({ creator }: Props) {

{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__/optimisticBuy.test.ts b/src/hooks/__tests__/optimisticBuy.test.ts new file mode 100644 index 00000000..49aa3174 --- /dev/null +++ b/src/hooks/__tests__/optimisticBuy.test.ts @@ -0,0 +1,111 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useTradeMutation } from '../useWallet'; +import { queryKeys } from '@/lib/queryKeys'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return { + wrapper: function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + }, + queryClient, + }; +} + +const address = 'GWALLET'; + +function seedHoldings( + queryClient: QueryClient, + holdings: HeldKeyPosition[] +) { + queryClient.setQueryData(queryKeys.wallet.holdings(address), holdings); +} + +const HOLDINGS_SEED: HeldKeyPosition[] = [ + { creatorId: 'creator-a', quantity: 2, priceStroops: null, price: null, pending: false }, + { creatorId: 'creator-b', quantity: 3, priceStroops: null, price: null, pending: false }, + { creatorId: 'creator-c', quantity: 1, priceStroops: null, price: null, pending: false }, +]; + +const TRADE_VARIABLES = { + creatorId: 'creator-a', + amount: 1, + priceStroops: 500_000, + price: 0.05, +}; + +describe('optimistic buy cache isolation (#655)', () => { + it('updates only the targeted creator when an optimistic buy is applied', async () => { + const { wrapper, queryClient } = createWrapper(); + + seedHoldings(queryClient, [...HOLDINGS_SEED]); + + const { result } = renderHook(() => useTradeMutation(address), { wrapper }); + + result.current.mutate(TRADE_VARIABLES); + + await waitFor(() => { + const holdings = queryClient.getQueryData( + queryKeys.wallet.holdings(address) + ) ?? []; + expect(holdings.find(h => h.creatorId === 'creator-a')?.quantity).toBe(3); + }); + + const holdings = queryClient.getQueryData( + queryKeys.wallet.holdings(address) + ) ?? []; + + expect(holdings.find(h => h.creatorId === 'creator-a')?.pending).toBe(true); + expect(holdings.find(h => h.creatorId === 'creator-b')?.quantity).toBe(3); + expect(holdings.find(h => h.creatorId === 'creator-c')?.quantity).toBe(1); + }); + + it('reverts only the targeted creator on rollback without altering other creators', () => { + const { queryClient } = createWrapper(); + + seedHoldings(queryClient, [...HOLDINGS_SEED]); + + const holdingsKey = queryKeys.wallet.holdings(address); + const previousHoldings = queryClient.getQueryData(holdingsKey); + + // Apply optimistic update (same transformation as onMutate in useWallet.ts) + queryClient.setQueryData(holdingsKey, (old = []) => + old.map(h => + h.creatorId === 'creator-a' + ? { ...h, quantity: (h.quantity ?? 0) + 1, pending: true } + : h + ) + ); + + let holdings = queryClient.getQueryData(holdingsKey) ?? []; + expect(holdings.find(h => h.creatorId === 'creator-a')?.quantity).toBe(3); + expect(holdings.find(h => h.creatorId === 'creator-a')?.pending).toBe(true); + expect(holdings.find(h => h.creatorId === 'creator-b')?.quantity).toBe(3); + expect(holdings.find(h => h.creatorId === 'creator-c')?.quantity).toBe(1); + + // Simulate rollback (same transformation as onError in useWallet.ts) + queryClient.setQueryData(holdingsKey, previousHoldings); + + holdings = queryClient.getQueryData(holdingsKey) ?? []; + expect(holdings.find(h => h.creatorId === 'creator-a')?.quantity).toBe(2); + expect(holdings.find(h => h.creatorId === 'creator-a')?.pending).toBe(false); + expect(holdings.find(h => h.creatorId === 'creator-b')?.quantity).toBe(3); + expect(holdings.find(h => h.creatorId === 'creator-c')?.quantity).toBe(1); + }); +}); 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__/tradeCacheInvalidation.test.ts b/src/hooks/__tests__/tradeCacheInvalidation.test.ts new file mode 100644 index 00000000..635d19d0 --- /dev/null +++ b/src/hooks/__tests__/tradeCacheInvalidation.test.ts @@ -0,0 +1,80 @@ +/** + * Unit test for structured cache invalidation log after a confirmed trade (#636). + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useTradeMutation } from '../useWallet'; + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +describe('useTradeMutation cache invalidation log (#636)', () => { + it('emits a structured debug log after trade settlement in non-test environment', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper }); + + result.current.mutate({ + creatorId: 'creator-1', + amount: 3, + priceStroops: 1_000_000, + price: 0.1, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true), { timeout: 3000 }); + + expect(debugSpy).toHaveBeenCalledWith( + '[cache-invalidation]', + expect.objectContaining({ + invalidated_keys: expect.arrayContaining([expect.any(String)]), + trigger: 'buy', + creator_id: 'creator-1', + invalidated_at: expect.any(String), + }), + ); + + debugSpy.mockRestore(); + process.env.NODE_ENV = originalEnv; + }); + + it('does not emit the log in test environment', async () => { + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTradeMutation('GWALLET'), { wrapper }); + + result.current.mutate({ + creatorId: 'creator-2', + amount: -1, + priceStroops: 500_000, + price: 0.05, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true), { timeout: 3000 }); + + expect(debugSpy).not.toHaveBeenCalled(); + + debugSpy.mockRestore(); + }); +}); diff --git a/src/hooks/__tests__/transactionFailureLog.test.ts b/src/hooks/__tests__/transactionFailureLog.test.ts new file mode 100644 index 00000000..520c908e --- /dev/null +++ b/src/hooks/__tests__/transactionFailureLog.test.ts @@ -0,0 +1,273 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import React, { type ReactNode } from 'react'; +import { useTradeMutation, type TradeVariables } from '../useWallet'; + +vi.mock('@/utils/toast.util', () => ({ + default: { + error: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: ReactNode }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + children + ); +}; + +describe('useTradeMutation transaction failure logging', () => { + let debugSpy: ReturnType; + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + process.env.NODE_ENV = 'production'; + debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + afterEach(() => { + debugSpy.mockRestore(); + process.env.NODE_ENV = originalEnv; + }); + + it('emits structured log on transaction failure with all required fields', async () => { + const wrapper = createWrapper(); + const address = 'GWALLET1234567890ABCDEFGHIJ'; + const { result } = renderHook(() => useTradeMutation(address), { + wrapper, + }); + + const variables: TradeVariables = { + creatorId: 'creator-123', + amount: 5, + priceStroops: 1_000_000, + price: 0.1, + }; + + // Trigger mutation failure by throwing an error + const error = new Error('Insufficient funds'); + vi.mocked(result.current.mutateAsync).mockRejectedValueOnce(error); + + result.current.mutate(variables); + + await waitFor(() => { + expect(debugSpy).toHaveBeenCalledWith( + '[transaction-failed]', + expect.objectContaining({ + error_code: expect.any(String), + creator_id: 'creator-123', + action: 'buy', + quantity: 5, + wallet_address: expect.any(String), + failed_at: expect.any(String), + }) + ); + }); + }); + + it('truncates wallet address to first 4 + last 4 characters', async () => { + const wrapper = createWrapper(); + const address = 'GWALLET1234567890ABCDEFGHIJ'; + const { result } = renderHook(() => useTradeMutation(address), { + wrapper, + }); + + const variables: TradeVariables = { + creatorId: 'creator-456', + amount: 2, + priceStroops: 500_000, + price: 0.05, + }; + + const error = new Error('Network timeout'); + vi.mocked(result.current.mutateAsync).mockRejectedValueOnce(error); + + result.current.mutate(variables); + + await waitFor(() => { + const calls = debugSpy.mock.calls; + const failureLog = calls.find( + call => call[0] === '[transaction-failed]' + ); + expect(failureLog).toBeDefined(); + + const logData = failureLog?.[1] as Record; + expect(logData.wallet_address).toBe('GWAL...GHIJ'); + }); + }); + + it('correctly identifies action as sell for negative amount', async () => { + const wrapper = createWrapper(); + const { result } = renderHook( + () => useTradeMutation('GWALLET1234567890ABCDEFGHIJ'), + { wrapper } + ); + + const variables: TradeVariables = { + creatorId: 'creator-789', + amount: -3, + priceStroops: 1_500_000, + price: 0.15, + }; + + const error = new Error('Wallet locked'); + vi.mocked(result.current.mutateAsync).mockRejectedValueOnce(error); + + result.current.mutate(variables); + + await waitFor(() => { + const calls = debugSpy.mock.calls; + const failureLog = calls.find( + call => call[0] === '[transaction-failed]' + ); + const logData = failureLog?.[1] as Record; + + expect(logData.action).toBe('sell'); + expect(logData.quantity).toBe(3); + }); + }); + + it('captures error name or message as error_code', async () => { + const wrapper = createWrapper(); + const { result } = renderHook( + () => useTradeMutation('GWALLET1234567890ABCDEFGHIJ'), + { wrapper } + ); + + const variables: TradeVariables = { + creatorId: 'creator-999', + amount: 1, + priceStroops: 2_000_000, + price: 0.2, + }; + + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'SignatureRejected'; + } + } + + const error = new CustomError('User declined signature'); + vi.mocked(result.current.mutateAsync).mockRejectedValueOnce(error); + + result.current.mutate(variables); + + await waitFor(() => { + const calls = debugSpy.mock.calls; + const failureLog = calls.find( + call => call[0] === '[transaction-failed]' + ); + const logData = failureLog?.[1] as Record; + + expect(logData.error_code).toBe('SignatureRejected'); + }); + }); + + it('does not emit log in test environment', async () => { + process.env.NODE_ENV = 'test'; + debugSpy.mockClear(); + + const wrapper = createWrapper(); + const { result } = renderHook( + () => useTradeMutation('GWALLET1234567890ABCDEFGHIJ'), + { wrapper } + ); + + const variables: TradeVariables = { + creatorId: 'creator-test', + amount: 1, + priceStroops: 1_000_000, + price: 0.1, + }; + + const error = new Error('Test error'); + vi.mocked(result.current.mutateAsync).mockRejectedValueOnce(error); + + result.current.mutate(variables); + + await waitFor( + () => { + const failureLogs = debugSpy.mock.calls.filter( + call => call[0] === '[transaction-failed]' + ); + expect(failureLogs).toHaveLength(0); + }, + { timeout: 2000 } + ); + }); + + it('includes failed_at timestamp in ISO format', async () => { + const wrapper = createWrapper(); + const { result } = renderHook( + () => useTradeMutation('GWALLET1234567890ABCDEFGHIJ'), + { wrapper } + ); + + const variables: TradeVariables = { + creatorId: 'creator-timestamp', + amount: 1, + priceStroops: 1_000_000, + price: 0.1, + }; + + const error = new Error('Timestamp test'); + vi.mocked(result.current.mutateAsync).mockRejectedValueOnce(error); + + result.current.mutate(variables); + + await waitFor(() => { + const calls = debugSpy.mock.calls; + const failureLog = calls.find( + call => call[0] === '[transaction-failed]' + ); + const logData = failureLog?.[1] as Record; + + expect(logData.failed_at).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + }); + }); + + it('handles non-Error rejection reasons as string', async () => { + const wrapper = createWrapper(); + const { result } = renderHook( + () => useTradeMutation('GWALLET1234567890ABCDEFGHIJ'), + { wrapper } + ); + + const variables: TradeVariables = { + creatorId: 'creator-string-error', + amount: 1, + priceStroops: 1_000_000, + price: 0.1, + }; + + vi.mocked(result.current.mutateAsync).mockRejectedValueOnce( + 'String error' + ); + + result.current.mutate(variables); + + await waitFor(() => { + const calls = debugSpy.mock.calls; + const failureLog = calls.find( + call => call[0] === '[transaction-failed]' + ); + const logData = failureLog?.[1] as Record; + + expect(logData.error_code).toBe('String error'); + }); + }); +}); 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..74c2a777 --- /dev/null +++ b/src/hooks/__tests__/useFormatXlm.test.ts @@ -0,0 +1,165 @@ +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'); + }); + }); + describe('bigint inputs (#645)', () => { + it('formats a safe-range bigint identically to the equivalent number', () => { + expect(formatXlm(15_000_000n)).toBe(formatXlm(15_000_000)); + expect(formatXlm(500_000n)).toBe(formatXlm(500_000)); + expect(formatXlm(70_000_000_000n)).toBe(formatXlm(70_000_000_000)); + }); + + it('respects the decimals option for bigint inputs', () => { + expect(formatXlm(10_000_000n, { decimals: 0 })).toBe( + formatXlm(10_000_000, { decimals: 0 }) + ); + expect(formatXlm(15_000_000n, { decimals: 7 })).toBe( + formatXlm(15_000_000, { decimals: 7 }) + ); + }); + + it('formats a bigint above Number.MAX_SAFE_INTEGER without precision loss', () => { + // 9_007_199_254_740_993 is MAX_SAFE_INTEGER + 2; as a number it + // silently rounds to ...992, so the final displayed digit proves + // whether the bigint path avoided float conversion. + const stroops = 9_007_199_254_740_993n; + const result = formatXlm(stroops, { decimals: 7 }); + + const expectedWhole = new Intl.NumberFormat(undefined, { + useGrouping: true, + }).format(900_719_925n); + expect(result.startsWith(expectedWhole)).toBe(true); + expect(result.endsWith('4740993')).toBe(true); + }); + + it('never renders scientific notation for very large bigints', () => { + const result = formatXlm(123_456_789_012_345_678_901_234_567_890n); + expect(result).not.toMatch(/e/i); + }); + + it('keeps every digit of a very large bigint', () => { + // 12_345_678_901_234_567_890 stroops = 1_234_567_890_123.4567890 XLM + const result = formatXlm(12_345_678_901_234_567_890n, { decimals: 7 }); + const digitsOnly = result.replace(/[^0-9]/g, ''); + expect(digitsOnly).toBe('12345678901234567890'); + }); + + it('formats 0n as 0.00', () => { + expect(formatXlm(0n)).toBe('0.00'); + }); + + it('formats a negative bigint as a negative formatted string', () => { + expect(formatXlm(-15_000_000n)).toBe(`-${formatXlm(15_000_000n)}`); + expect(formatXlm(-15_000_000n)).toBe(formatXlm(-15_000_000)); + }); + + it('does not emit a negative sign when a negative amount rounds to zero', () => { + // -1 stroop rounds to 0.00 at 2 decimals — "-0.00" would be wrong + expect(formatXlm(-1n)).toBe('0.00'); + }); + + it('hook format function accepts bigint inputs', () => { + const { result } = renderHook(() => useFormatXlm()); + expect(result.current.format(15_000_000n)).toBe(formatXlm(15_000_000)); + }); + }); +}); diff --git a/src/hooks/__tests__/useInfiniteCreatorMarketplace.test.ts b/src/hooks/__tests__/useInfiniteCreatorMarketplace.test.ts new file mode 100644 index 00000000..2b5869bb --- /dev/null +++ b/src/hooks/__tests__/useInfiniteCreatorMarketplace.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for useInfiniteCreatorMarketplace — cursor-based infinite + * pagination over the creator key marketplace listing (#685). + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { useInfiniteCreatorMarketplace } from '../useInfiniteCreatorMarketplace'; +import { courseService, type Course, type CoursesPage } from '@/services/course.service'; + +vi.mock('@/services/course.service', async () => { + const actual = await vi.importActual( + '@/services/course.service' + ); + return { + ...actual, + courseService: { + getCoursesPage: vi.fn(), + }, + }; +}); + +const mockGetCoursesPage = vi.mocked(courseService.getCoursesPage); + +function makeCreator(id: string): Course { + return { + id, + title: `Creator ${id}`, + description: 'desc', + price: 0.1, + instructorId: id, + category: 'Art', + level: 'BEGINNER', + }; +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +describe('useInfiniteCreatorMarketplace', () => { + beforeEach(() => { + mockGetCoursesPage.mockReset(); + }); + + it('fetches only the first page on mount', async () => { + const page1: CoursesPage = { + items: [makeCreator('a'), makeCreator('b')], + page: 1, + hasMore: true, + }; + mockGetCoursesPage.mockResolvedValue(page1); + + const { result } = renderHook(() => useInfiniteCreatorMarketplace(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoadingFirstPage).toBe(false)); + + expect(mockGetCoursesPage).toHaveBeenCalledTimes(1); + expect(mockGetCoursesPage).toHaveBeenCalledWith(1, undefined); + expect(result.current.creators).toHaveLength(2); + expect(result.current.hasMore).toBe(true); + }); + + it('fetches the next page when fetchNextPage is called, appending without duplicates', async () => { + const page1: CoursesPage = { + items: [makeCreator('a'), makeCreator('b')], + page: 1, + hasMore: true, + }; + const page2: CoursesPage = { + items: [makeCreator('b'), makeCreator('c')], // 'b' repeated across pages + page: 2, + hasMore: false, + }; + mockGetCoursesPage.mockResolvedValueOnce(page1).mockResolvedValueOnce(page2); + + const { result } = renderHook(() => useInfiniteCreatorMarketplace(), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isLoadingFirstPage).toBe(false)); + + result.current.fetchNextPage(); + + await waitFor(() => expect(mockGetCoursesPage).toHaveBeenCalledTimes(2)); + expect(mockGetCoursesPage).toHaveBeenNthCalledWith(2, 2, undefined); + + await waitFor(() => + expect(result.current.creators.map(c => c.id)).toEqual(['a', 'b', 'c']) + ); + expect(result.current.hasMore).toBe(false); + }); + + it('stops fetching once the last page reports hasMore: false', async () => { + mockGetCoursesPage.mockResolvedValue({ + items: [makeCreator('a')], + page: 1, + hasMore: false, + }); + + const { result } = renderHook(() => useInfiniteCreatorMarketplace(), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isLoadingFirstPage).toBe(false)); + + expect(result.current.hasMore).toBe(false); + }); + + it('passes filter params through to every page request', async () => { + mockGetCoursesPage.mockResolvedValue({ + items: [makeCreator('a')], + page: 1, + hasMore: false, + }); + + const params = { category: 'Art', limit: 10 }; + renderHook(() => useInfiniteCreatorMarketplace(params), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(mockGetCoursesPage).toHaveBeenCalledWith(1, params)); + }); +}); 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/__tests__/useWalletConnectionLogger.test.ts b/src/hooks/__tests__/useWalletConnectionLogger.test.ts new file mode 100644 index 00000000..83562396 --- /dev/null +++ b/src/hooks/__tests__/useWalletConnectionLogger.test.ts @@ -0,0 +1,167 @@ +/** + * Unit tests for the structured wallet-connection debug log (#599). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useAccount } from 'wagmi'; +import { useWalletConnectionLogger } from '@/hooks/useWalletConnectionLogger'; + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), +})); + +const mockUseAccount = vi.mocked(useAccount); + +const FULL_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'; +const SECOND_ADDRESS = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + +function mockAccount(overrides: Partial> = {}) { + mockUseAccount.mockReturnValue({ + address: undefined, + isConnected: false, + connector: undefined, + ...overrides, + } as ReturnType); +} + +describe('useWalletConnectionLogger (#599)', () => { + let debugSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + it('emits a structured debug log with all three fields on a successful connection', () => { + mockAccount({ + address: FULL_ADDRESS, + isConnected: true, + connector: { id: 'injected', name: 'MetaMask' } as ReturnType< + typeof useAccount + >['connector'], + }); + + renderHook(() => useWalletConnectionLogger({ isTestEnv: false })); + + expect(debugSpy).toHaveBeenCalledTimes(1); + expect(debugSpy).toHaveBeenCalledWith( + '[wallet-connection]', + expect.objectContaining({ + truncated_address: '0x12...5678', + connection_method: 'MetaMask', + connected_at: expect.stringMatching( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ + ), + }) + ); + }); + + it('never includes the full wallet address under any field name', () => { + mockAccount({ + address: FULL_ADDRESS, + isConnected: true, + connector: { id: 'injected', name: 'Injected' } as ReturnType< + typeof useAccount + >['connector'], + }); + + renderHook(() => useWalletConnectionLogger({ isTestEnv: false })); + + const [, log] = debugSpy.mock.calls[0]; + expect(JSON.stringify(log)).not.toContain(FULL_ADDRESS); + }); + + it('falls back to the connector id when no name is available', () => { + mockAccount({ + address: FULL_ADDRESS, + isConnected: true, + connector: { id: 'albedo' } as ReturnType['connector'], + }); + + renderHook(() => useWalletConnectionLogger({ isTestEnv: false })); + + expect(debugSpy).toHaveBeenCalledWith( + '[wallet-connection]', + expect.objectContaining({ connection_method: 'albedo' }) + ); + }); + + it('falls back to "unknown" when there is no connector at all', () => { + mockAccount({ address: FULL_ADDRESS, isConnected: true, connector: undefined }); + + renderHook(() => useWalletConnectionLogger({ isTestEnv: false })); + + expect(debugSpy).toHaveBeenCalledWith( + '[wallet-connection]', + expect.objectContaining({ connection_method: 'unknown' }) + ); + }); + + it('does not emit when the wallet is not connected', () => { + mockAccount({ address: undefined, isConnected: false }); + + renderHook(() => useWalletConnectionLogger({ isTestEnv: false })); + + expect(debugSpy).not.toHaveBeenCalled(); + }); + + it('does not re-emit on re-renders for the same connected address', () => { + mockAccount({ + address: FULL_ADDRESS, + isConnected: true, + connector: { id: 'injected', name: 'Injected' } as ReturnType< + typeof useAccount + >['connector'], + }); + + const { rerender } = renderHook(() => + useWalletConnectionLogger({ isTestEnv: false }) + ); + rerender(); + rerender(); + + expect(debugSpy).toHaveBeenCalledTimes(1); + }); + + it('emits again after a disconnect/reconnect cycle', () => { + mockAccount({ + address: FULL_ADDRESS, + isConnected: true, + connector: { id: 'injected', name: 'Injected' } as ReturnType< + typeof useAccount + >['connector'], + }); + const { rerender } = renderHook(() => + useWalletConnectionLogger({ isTestEnv: false }) + ); + expect(debugSpy).toHaveBeenCalledTimes(1); + + mockAccount({ address: undefined, isConnected: false }); + rerender(); + expect(debugSpy).toHaveBeenCalledTimes(1); + + mockAccount({ + address: SECOND_ADDRESS, + isConnected: true, + connector: { id: 'injected', name: 'Injected' } as ReturnType< + typeof useAccount + >['connector'], + }); + rerender(); + expect(debugSpy).toHaveBeenCalledTimes(2); + }); + + it('does not emit in the test environment by default', () => { + mockAccount({ + address: FULL_ADDRESS, + isConnected: true, + connector: { id: 'injected', name: 'Injected' } as ReturnType< + typeof useAccount + >['connector'], + }); + + renderHook(() => useWalletConnectionLogger()); + + expect(debugSpy).not.toHaveBeenCalled(); + }); +}); 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..32fd514d --- /dev/null +++ b/src/hooks/useCreators.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + courseService, + type GetCoursesParams, +} from '@/services/course.service'; + +export function useCreatorList(params?: GetCoursesParams) { + return useQuery({ + queryKey: queryKeys.creators.list(params), + queryFn: async () => [], + }); +} + +export function useCreatorDetail(id: string) { + return useQuery({ + queryKey: queryKeys.creators.detail(id), + queryFn: () => courseService.getCourse(id), + 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..c2c869bf --- /dev/null +++ b/src/hooks/useFormatXlm.ts @@ -0,0 +1,98 @@ +import { STROOPS_PER_XLM } from '@/constants/stellar'; + +export interface FormatXlmOptions { + /** Number of decimal places to display. Defaults to 2. */ + decimals?: number; +} + +/** + * Formats a bigint stroop amount without ever passing through `number`, + * so values beyond Number.MAX_SAFE_INTEGER keep every digit. The whole-XLM + * part is formatted by Intl (which accepts bigint natively) for locale + * grouping; the fractional digits are computed with integer arithmetic and + * joined with the locale's decimal separator so output matches the number + * path in any locale. + */ +function formatBigintXlm(stroops: bigint, decimals: number): string { + const negative = stroops < 0n; + const abs = negative ? -stroops : stroops; + const stroopsPerXlm = BigInt(STROOPS_PER_XLM); + const scale = 10n ** BigInt(decimals); + + // Round half up on the last displayed digit, mirroring Intl's rounding + const scaled = (abs * scale + stroopsPerXlm / 2n) / stroopsPerXlm; + const whole = scaled / scale; + const fraction = scaled % scale; + + const wholeStr = new Intl.NumberFormat(undefined, { + useGrouping: true, + }).format(whole); + + const sign = negative && scaled !== 0n ? '-' : ''; + + if (decimals === 0) { + return `${sign}${wholeStr}`; + } + + const decimalSeparator = + new Intl.NumberFormat(undefined, { minimumFractionDigits: 1 }) + .formatToParts(1.1) + .find(part => part.type === 'decimal')?.value ?? '.'; + + const fractionStr = fraction.toString().padStart(decimals, '0'); + + return `${sign}${wholeStr}${decimalSeparator}${fractionStr}`; +} + +/** + * Converts a stroop amount to a formatted XLM string. + * + * Accepts both `number` and `bigint` stroops. Bigint inputs are formatted + * with integer arithmetic end to end, so amounts above + * `Number.MAX_SAFE_INTEGER` render with full precision and never fall back + * to scientific notation. Negative amounts (either type) format with a + * leading minus sign. + * + * @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_000n) // "1.00" + * formatXlm(10_000_000, { decimals: 0 }) // "1" + * formatXlm(15_000_000, { decimals: 7 }) // "1.5000000" + */ +export function formatXlm( + stroops: number | bigint, + options: FormatXlmOptions = {} +): string { + const { decimals = 2 } = options; + + if (typeof stroops === 'bigint') { + return formatBigintXlm(stroops, decimals); + } + + 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/useInfiniteCreatorMarketplace.ts b/src/hooks/useInfiniteCreatorMarketplace.ts new file mode 100644 index 00000000..56dcfb66 --- /dev/null +++ b/src/hooks/useInfiniteCreatorMarketplace.ts @@ -0,0 +1,49 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { courseService, type Course, type GetCoursesParams } from '@/services/course.service'; +import { queryKeys } from '@/lib/queryKeys'; + +const FIRST_PAGE = 1; + +/** + * Cursor-based (page-number) infinite pagination over the creator key + * marketplace listing, backed by React Query's useInfiniteQuery (#685). + * + * Replaces "load everything up front, reveal more client-side" with real + * paged fetches: only the first page loads initially, later pages fetch on + * demand via `fetchNextPage` (wire this to a useInfiniteScroll sentinel), + * and fetching stops once the last page reports `hasMore: false`. + */ +export function useInfiniteCreatorMarketplace(params?: Omit) { + const query = useInfiniteQuery({ + queryKey: queryKeys.creators.infiniteList(params), + queryFn: ({ pageParam }) => courseService.getCoursesPage(pageParam, params), + initialPageParam: FIRST_PAGE, + getNextPageParam: lastPage => (lastPage.hasMore ? lastPage.page + 1 : undefined), + }); + + // De-duplicate creators across pages by id -- a creator that shifts + // position between page fetches (e.g. sort order changing as data + // updates) should never be rendered twice. + const creators = useMemo(() => { + const seen = new Set(); + const result: Course[] = []; + for (const page of query.data?.pages ?? []) { + for (const creator of page.items) { + if (seen.has(creator.id)) continue; + seen.add(creator.id); + result.push(creator); + } + } + return result; + }, [query.data]); + + return { + creators, + hasMore: query.hasNextPage, + isLoadingFirstPage: query.isLoading, + isFetchingNextPage: query.isFetchingNextPage, + fetchNextPage: query.fetchNextPage, + error: query.error, + }; +} 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/useRouteChangeLogging.ts b/src/hooks/useRouteChangeLogging.ts new file mode 100644 index 00000000..52b97885 --- /dev/null +++ b/src/hooks/useRouteChangeLogging.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router'; + +export function useRouteChangeLogging() { + const location = useLocation(); + const previousRoute = useRef(null); + const lastNavCompletedAt = useRef(null); + const isFirstNav = useRef(true); + + useEffect(() => { + // Vitest sets MODE to 'test' by default; skip logging in that environment. + if (import.meta.env.MODE === 'test') return; + + const now = performance.now(); + const toRoute = location.pathname; + + console.debug('[route-change]', { + from_route: isFirstNav.current ? null : previousRoute.current, + to_route: toRoute, + time_since_last_nav_ms: + lastNavCompletedAt.current === null + ? null + : Math.round(now - lastNavCompletedAt.current), + }); + + isFirstNav.current = false; + previousRoute.current = toRoute; + lastNavCompletedAt.current = now; + }, [location]); +} \ No newline at end of file diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts new file mode 100644 index 00000000..cabdf093 --- /dev/null +++ b/src/hooks/useWallet.ts @@ -0,0 +1,157 @@ +import { useInfiniteQuery, 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'; +import { fetchWalletActivityPage } from '@/services/walletActivity.service'; + +export function useWalletHoldings(address: string) { + return useQuery({ + queryKey: queryKeys.wallet.holdings(address), + queryFn: async () => [], + enabled: !!address, + }); +} + +/** + * Paginated wallet activity feed. + * + * #677 — uses `useInfiniteQuery` so the feed can incrementally load + * older trades via `fetchNextPage()` triggered by an `IntersectionObserver` + * sentinel mounted at the bottom of the list. The query key is the same + * `queryKeys.wallet.activity(address)` constant used by the original + * `useQuery` implementation so existing cache-key tests continue to pass. + */ +export function useWalletActivity(address: string) { + return useInfiniteQuery({ + queryKey: queryKeys.wallet.activity(address), + queryFn: ({ pageParam }) => + fetchWalletActivityPage(address, pageParam ?? 1), + initialPageParam: 1, + getNextPageParam: lastPage => lastPage.nextPage, + 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)); + + // Emit structured log for failed transaction + if (process.env.NODE_ENV !== 'test') { + const truncatedAddress = address + ? `${address.slice(0, 4)}...${address.slice(-4)}` + : 'unknown'; + + const errorCode = + error instanceof Error + ? error.name || error.message + : String(error); + + console.debug('[transaction-failed]', { + error_code: errorCode, + creator_id: (variables as TradeVariables).creatorId, + action: + (variables as TradeVariables).amount > 0 ? 'buy' : 'sell', + quantity: Math.abs((variables as TradeVariables).amount), + wallet_address: truncatedAddress, + failed_at: new Date().toISOString(), + }); + } + }, + 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: (_data, _error, variables) => { + const invalidatedKeys = [queryKeys.wallet.holdings(address)]; + queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(address), + }); + + if (process.env.NODE_ENV !== 'test') { + console.debug('[cache-invalidation]', { + invalidated_keys: invalidatedKeys.map(k => JSON.stringify(k)), + trigger: + (variables as TradeVariables).amount > 0 ? 'buy' : 'sell', + creator_id: (variables as TradeVariables).creatorId, + invalidated_at: new Date().toISOString(), + }); + } + }, + }); + + return mutation; +} diff --git a/src/hooks/useWalletConnectionLogger.ts b/src/hooks/useWalletConnectionLogger.ts new file mode 100644 index 00000000..c0a16850 --- /dev/null +++ b/src/hooks/useWalletConnectionLogger.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react'; +import { useAccount } from 'wagmi'; +import { shortenAddress } from '@/lib/web3/format'; + +export interface WalletConnectionLog { + truncated_address: string; + connection_method: string; + connected_at: string; +} + +interface UseWalletConnectionLoggerOptions { + /** + * Suppresses log emission when true. Defaults to detecting the Vitest + * runner via `process.env.NODE_ENV`; injectable so the hook itself can be + * tested. + */ + isTestEnv?: boolean; +} + +/** + * Emits a debug-level structured log the moment a wallet connection + * succeeds, so developers can trace wallet session activity without relying + * on browser extension logs. + * + * Dedupes on the connected address: the log fires once per distinct + * connection, not on every re-render and not again for unrelated state or + * query cache updates that leave the address unchanged. Disconnecting resets + * the dedupe key, so a later reconnection (same or different address) logs + * again. + * + * The full wallet address is never logged — only the truncated form + * (first 4 + last 4 characters). + */ +export function useWalletConnectionLogger({ + isTestEnv = process.env.NODE_ENV === 'test', +}: UseWalletConnectionLoggerOptions = {}): void { + const { address, isConnected, connector } = useAccount(); + const loggedAddressRef = useRef(undefined); + + useEffect(() => { + if (!isConnected || !address) { + loggedAddressRef.current = undefined; + return; + } + + if (loggedAddressRef.current === address) { + return; + } + loggedAddressRef.current = address; + + if (isTestEnv) return; + + const log: WalletConnectionLog = { + truncated_address: shortenAddress(address), + connection_method: connector?.name ?? connector?.id ?? 'unknown', + connected_at: new Date().toISOString(), + }; + + console.debug('[wallet-connection]', log); + }, [address, isConnected, connector, isTestEnv]); +} 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/__tests__/walletSessionLog.test.ts b/src/lib/__tests__/walletSessionLog.test.ts new file mode 100644 index 00000000..4ad2c85f --- /dev/null +++ b/src/lib/__tests__/walletSessionLog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createWalletDisconnectLog, + logWalletDisconnectSession, +} from '@/lib/walletSessionLog'; + +describe('walletSessionLog', () => { + it('builds a structured disconnect log with a truncated address and session duration', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + const connectedAt = 1_000; + const disconnectedAt = 4_250; + + expect( + createWalletDisconnectLog(address, connectedAt, disconnectedAt) + ).toEqual({ + truncated_address: '0x12...5678', + session_duration_ms: 3_250, + disconnected_at: '1970-01-01T00:00:04.250Z', + }); + }); + + it('does not emit a disconnect log in the test environment', () => { + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logWalletDisconnectSession( + '0x1234567890abcdef1234567890abcdef12345678', + 1_000, + 2_000 + ); + + expect(debugSpy).not.toHaveBeenCalled(); + debugSpy.mockRestore(); + }); +}); diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts new file mode 100644 index 00000000..a27c8a6c --- /dev/null +++ b/src/lib/queryKeys.ts @@ -0,0 +1,18 @@ +import type { GetCoursesParams } from '@/services/course.service'; + +export const queryKeys = { + creators: { + all: ['creators'] as const, + list: (params?: GetCoursesParams) => + ['creators', 'list', params ?? null] as const, + infiniteList: (params?: Omit) => + ['creators', 'infiniteList', 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/walletSessionLog.ts b/src/lib/walletSessionLog.ts new file mode 100644 index 00000000..764e9a12 --- /dev/null +++ b/src/lib/walletSessionLog.ts @@ -0,0 +1,34 @@ +import { shortenAddress } from '@/lib/web3/format'; + +export interface WalletDisconnectLogEntry { + truncated_address: string; + session_duration_ms: number; + disconnected_at: string; +} + +export function createWalletDisconnectLog( + address: string, + connectedAt: number, + disconnectedAt: number = Date.now() +): WalletDisconnectLogEntry { + return { + truncated_address: shortenAddress(address), + session_duration_ms: Math.max(0, disconnectedAt - connectedAt), + disconnected_at: new Date(disconnectedAt).toISOString(), + }; +} + +export function logWalletDisconnectSession( + address: string, + connectedAt: number, + disconnectedAt: number = Date.now() +) { + if (process.env.NODE_ENV === 'test') { + return; + } + + console.debug( + '[wallet-disconnect]', + createWalletDisconnectLog(address, connectedAt, disconnectedAt) + ); +} 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/__tests__/network.test.ts b/src/lib/web3/__tests__/network.test.ts new file mode 100644 index 00000000..b1027848 --- /dev/null +++ b/src/lib/web3/__tests__/network.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { mainnet, sepolia, baseSepolia, anvil } from 'wagmi/chains'; +import { describeNetwork } from '@/lib/web3/network'; + +/** Issue #686 — network badge classification. */ +describe('describeNetwork', () => { + it('classifies Ethereum mainnet as mainnet with a green badge', () => { + const info = describeNetwork(mainnet.id); + expect(info.kind).toBe('mainnet'); + expect(info.badgeClass).toContain('green'); + }); + + it.each([ + ['sepolia', sepolia.id], + ['baseSepolia', baseSepolia.id], + ['anvil', anvil.id], + ])('classifies %s as testnet with a yellow badge', (_name, id) => { + const info = describeNetwork(id); + expect(info.kind).toBe('testnet'); + expect(info.badgeClass).toContain('yellow'); + }); + + it('reports an unknown chain as unsupported, not testnet', () => { + // Showing "testnet" for a chain the app cannot talk to would imply the + // app works there. + const info = describeNetwork(999_999); + expect(info.kind).toBe('unsupported'); + expect(info.label).toContain('999999'); + }); + + it('handles an undefined chain id', () => { + expect(describeNetwork(undefined).kind).toBe('unsupported'); + }); + + it('never labels a non-mainnet chain green', () => { + // The safe default: a chain added to supportedChains later shows as + // testnet until deliberately promoted, rather than silently rendering + // "you are on mainnet". + for (const id of [sepolia.id, baseSepolia.id, anvil.id, 42_161]) { + expect(describeNetwork(id).badgeClass).not.toContain('green'); + } + }); +}); 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/lib/web3/network.ts b/src/lib/web3/network.ts new file mode 100644 index 00000000..678e8863 --- /dev/null +++ b/src/lib/web3/network.ts @@ -0,0 +1,57 @@ +import { mainnet } from 'wagmi/chains'; +import { supportedChains } from '@/lib/web3/chains'; + +/** + * Network classification for the nav status chip (issue #686). + * + * Only Ethereum mainnet is a live-value network among the supported chains; + * anvil, sepolia and baseSepolia are all test environments. Treating anything + * that is not mainnet as a testnet is the safe default — a new chain added to + * `supportedChains` shows as "testnet" until someone deliberately promotes it, + * rather than silently rendering a green "you are on mainnet" badge. + */ +export type NetworkKind = 'mainnet' | 'testnet' | 'unsupported'; + +export interface NetworkInfo { + kind: NetworkKind; + label: string; + /** Tailwind classes for the badge. Green for mainnet, yellow for testnet. */ + badgeClass: string; +} + +const MAINNET_CHAIN_IDS: readonly number[] = [mainnet.id]; + +export function describeNetwork(chainId: number | undefined): NetworkInfo { + if (chainId === undefined) { + return { + kind: 'unsupported', + label: 'Unknown network', + badgeClass: 'bg-red-500/15 text-red-600 ring-red-500/30', + }; + } + + const chain = supportedChains.find(c => c.id === chainId); + if (!chain) { + // The wallet is on a chain the app cannot talk to. Surfacing this as its + // own state matters: showing "testnet" would imply the app works there. + return { + kind: 'unsupported', + label: `Unsupported (${chainId})`, + badgeClass: 'bg-red-500/15 text-red-600 ring-red-500/30', + }; + } + + if (MAINNET_CHAIN_IDS.includes(chain.id)) { + return { + kind: 'mainnet', + label: chain.name, + badgeClass: 'bg-green-500/15 text-green-600 ring-green-500/30', + }; + } + + return { + kind: 'testnet', + label: chain.name, + badgeClass: 'bg-yellow-500/15 text-yellow-700 ring-yellow-500/30', + }; +} diff --git a/src/main.tsx b/src/main.tsx index 98c2e6cb..69a34532 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,9 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; +import { registerUnhandledRejectionLogger } from './utils/unhandledRejectionLogger'; + +registerUnhandledRejectionLogger(); createRoot(document.getElementById('root')!).render( diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx new file mode 100644 index 00000000..d66981b6 --- /dev/null +++ b/src/pages/CreatorDetailPage.tsx @@ -0,0 +1,79 @@ +import { useParams } from 'react-router'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; +import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; +import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid'; +import { CreatorProfileHeaderSkeleton } from '@/components/common/CreatorSkeleton'; +import { bpsToPercent } from '@/utils/numberFormat.utils'; +import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary'; +import { ApiError } from '@/services/api.service'; + +function CreatorDetailPageContent() { + const { id } = useParams<{ id: string }>(); + const { data: creator, isLoading, error } = useCreatorDetail(id || ''); + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (error) { + throw error; + } + + if (!creator) { + throw new ApiError('Creator not found', 404); + } + + const feeItems = [ + { + label: 'Creator fee', + value: bpsToPercent(creator.creatorFeeBps), + helperText: 'Fee paid directly to the creator on each trade.', + }, + { + label: 'Protocol fee', + value: bpsToPercent(creator.protocolFeeBps), + helperText: 'Fee paid to the platform for protocol maintenance.', + }, + ]; + + return ( +
+
+ + +
+

+ Fee Structure +

+ +
+
+
+ ); +} + +export default function CreatorDetailPage() { + return ( + + + + ); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 9c26ce8b..17955463 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,9 @@ 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'; +const FEATURED_CREATOR_NAME = 'Alex Rivers'; // Fallback demo data in case API fails const DEMO_CREATORS: Course[] = [ @@ -192,11 +181,14 @@ 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 CREATOR_VISIBLE_COUNT_KEY = 'accesslayer.creator-visibleCount'; 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 +220,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 +255,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 +274,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 +314,27 @@ 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'; + }); + // Persisted like `page` so infinite-scroll progress survives navigating + // away and back (#639) instead of resetting to the first page. + const [visibleCount, setVisibleCount] = useState(() => { + if (typeof window === 'undefined') return PAGE_SIZE; + const saved = window.sessionStorage.getItem(CREATOR_VISIBLE_COUNT_KEY); + const parsed = saved ? Number(saved) : PAGE_SIZE; + return Number.isFinite(parsed) && parsed >= PAGE_SIZE ? parsed : 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 +357,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 +414,19 @@ function LandingPage() { window.sessionStorage.setItem(CREATOR_PAGE_KEY, String(page)); }, [page]); + useEffect(() => { + if (typeof window === 'undefined') return; + window.sessionStorage.setItem( + CREATOR_VISIBLE_COUNT_KEY, + String(visibleCount) + ); + }, [visibleCount]); + + 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 +476,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 +524,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 @@ -544,10 +600,33 @@ function LandingPage() { return () => clearTimeout(timer); }, [trimmedSearchQuery, sortOption, creators.length]); + // Resets pagination when the search/sort criteria actually change. Skips + // the initial mount so restoring a persisted page/visibleCount (#639) + // isn't immediately clobbered by this effect's first run. + const isFirstSearchSortRenderRef = useRef(true); useEffect(() => { + if (isFirstSearchSortRenderRef.current) { + isFirstSearchSortRenderRef.current = false; + return; + } 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. Skips + // the initial mount so restoring a persisted page/visibleCount (#639) + // isn't immediately clobbered by this effect's first run. + const isFirstListModeRenderRef = useRef(true); + useEffect(() => { + if (isFirstListModeRenderRef.current) { + isFirstListModeRenderRef.current = false; + return; + } + setPage(0); + setVisibleCount(PAGE_SIZE); + }, [listMode]); + const totalPages = Math.max( 1, Math.ceil(filteredCreators.length / PAGE_SIZE) @@ -557,10 +636,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 +679,10 @@ function LandingPage() { }; const handleResetSearch = () => setSearchQuery(''); + const handleClearPriceFilters = () => { + setMinPriceFilter(''); + setMaxPriceFilter(''); + }; const handleRetryCreatorFetch = useCallback(() => { setFinalFetchError(''); @@ -646,20 +748,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 +789,83 @@ 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', + `Sold ${formatNumber(amount)} key${amount === 1 ? '' : 's'} from ${FEATURED_CREATOR_NAME}` + ); + } setTradeDialogOpen(false); } catch (error) { - setFeaturedHoldings(previousHoldings); - showToast.error(getSignatureErrorMessage(error)); + if (process.env.NODE_ENV !== 'test') { + console.debug('[trade-confirmation-failure]', { + creator_name: FEATURED_CREATOR_NAME, + side: tradeSide, + quantity: amount, + error: error instanceof Error ? `${error.name}: ${error.message}` : String(error), + timestamp: new Date().toISOString(), + }); + } + if (tradeSide === 'sell') { + showToast.error(getSignatureErrorMessage(error)); + } } finally { setTradeSubmitting(false); } @@ -824,6 +975,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 -